How do I create an API in WooCommerce?

7 minutes
How do I create an API in WooCommerce

Before writing any code, check whether you need to. WooCommerce ships a full REST API covering products, orders, customers, coupons, and reports, with key management built into the admin. For a more complex integration boundary, see what APIs are essential for iGaming software integration. For connecting a CRM, a shipping provider, or an inventory system, that is usually the whole answer. A custom endpoint is worth building when you need business logic the built-in API does not express, a response shaped for one specific consumer, or an aggregation that would otherwise take several round trips. This guide covers both: when to use what is already there, and how to build a custom endpoint that will survive a security review.

Start with the built-in API

WooCommerce exposes its REST API at /wp-json/wc/v3/. Keys are generated in the admin under WooCommerce → Settings → Advanced → REST API, tied to a specific user, with read or read/write permission.

Two prerequisites catch people out: permalinks must be set to anything other than Plain, and the connection must run over HTTPS.

What it already covers:

  • products, variations, categories, attributes
  • orders, refunds, order notes
  • customers, coupons, reports
  • webhooks for event-driven integrations

If your integration is expressible in those terms, stop here. A custom endpoint you write is a custom endpoint you maintain, secure, and document for as long as the store runs.

When a custom endpoint earns its cost

Write one when:

  • the response shape matters. A mobile app that needs eight fields should not download the full product payload on every request.
  • business logic sits between the data and the answer. Tiered pricing per customer group, availability calculated across warehouses, an eligibility check.
  • you are aggregating. One request that would otherwise be four.
  • an external system dictates the contract and you have to meet it rather than define it.

Registering an endpoint

Keep the code in its own plugin, not in the theme. A theme change should never take your integrations down.

php

<?php
/**
 * Plugin Name: Shop API
 * Description: Custom REST endpoints for the store.
 * Version:     1.0.0
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

add_action( 'rest_api_init', function () {
    register_rest_route( 'my-shop/v1', '/products/featured', array(
        'methods'             => WP_REST_Server::READABLE,
        'callback'            => 'shop_api_get_featured_products',
        'permission_callback' => 'shop_api_permission_check',
        'args'                => array(
            'per_page' => array(
                'type'              => 'integer',
                'default'           => 10,
                'minimum'           => 1,
                'maximum'           => 100,
                'sanitize_callback' => 'absint',
            ),
            'page'     => array(
                'type'              => 'integer',
                'default'           => 1,
                'minimum'           => 1,
                'sanitize_callback' => 'absint',
            ),
        ),
    ) );
} );

Two things matter here.

The args schema does your validation. Declaring types, bounds, and sanitisation callbacks means WordPress rejects bad input before your handler runs, and returns a correctly formed error. Hand-rolled if checks inside the callback are where validation gaps come from.

permission_callback is never optional and never true. WordPress logs a notice when it is missing, and an endpoint that returns true is an open endpoint. If you are tempted to set it to true “for now”, set it to __return_false instead and make the auth work before anything else.

Authentication

For server-to-server calls, prefer mechanisms WordPress and WooCommerce already provide: WooCommerce API keys, or WordPress application passwords for custom namespaces. Both are managed in the admin, revocable, and tied to a user whose capabilities you control.

Where a shared secret is genuinely the right fit, three rules apply.

php

function shop_api_permission_check( WP_REST_Request $request ) {

    // An authenticated WordPress user with the right capability.
    if ( current_user_can( 'manage_woocommerce' ) ) {
        return true;
    }

    // Server-to-server shared secret. Defined in wp-config.php or the environment,
    // never in this file and never in version control.
    $expected = defined( 'SHOP_API_SECRET' ) ? SHOP_API_SECRET : '';
    $provided = $request->get_header( 'x-shop-api-secret' );

    if ( '' !== $expected && is_string( $provided ) && hash_equals( $expected, $provided ) ) {
        return true;
    }

    return new WP_Error(
        'shop_api_forbidden',
        __( 'Authentication required.', 'shop-api' ),
        array( 'status' => rest_authorization_required_code() )
    );
}

The secret lives outside the code. In wp-config.php or an environment variable. A credential written into a plugin file ends up in your repository, in every backup, and in every copy of the site anyone has ever taken.

Compare with hash_equals(), not !==. A normal string comparison returns as soon as it finds a mismatched byte, and that timing difference is measurable across enough requests. hash_equals() compares in constant time. This is the single most commonly skipped line in WordPress API code.

Read headers through $request->get_header(). It normalises header names and keeps you out of $_SERVER.

Note the order of the checks. A capability check alone cannot authenticate a server-to-server call, because there is no logged-in user in that request. Auth and authorisation are separate steps: establish who is calling, then check what they are allowed to do.

Beyond this: run over HTTPS only, grant the narrowest capability that works, rate-limit at the edge or in the application, and log failed attempts.

The request handler

php

function shop_api_get_featured_products( WP_REST_Request $request ) {

    $products = wc_get_products( array(
        'status'   => 'publish',
        'featured' => true,
        'limit'    => $request['per_page'],
        'page'     => $request['page'],
    ) );

    $data = array();

    foreach ( $products as $product ) {
        $data[] = array(
            'id'            => $product->get_id(),
            'name'          => $product->get_name(),
            'price'         => wc_format_decimal( $product->get_price() ),
            'regular_price' => wc_format_decimal( $product->get_regular_price() ),
            'sale_price'    => wc_format_decimal( $product->get_sale_price() ),
            'image'         => wp_get_attachment_url( $product->get_image_id() ),
            'permalink'     => get_permalink( $product->get_id() ),
        );
    }

    return rest_ensure_response( array( 'products' => $data ) );
}

Because the schema already validated and sanitised the parameters, the handler stays readable. wc_format_decimal() keeps price formatting consistent for whatever consumes the response.

If your endpoint touches orders, use the CRUD layer. wc_get_order() and wc_get_orders(), never get_post() or get_post_meta() against order IDs. With high-performance order storage enabled, orders no longer live in the posts tables, and code that reads them directly returns nothing or stale data. For the full picture of what lives where, see where WooCommerce actually stores its data.

Personal data

An endpoint returning customer or order data moves personal data out of the store. That brings GDPR into scope: a legal basis for the transfer, a data processing agreement with whoever receives it, and a retention position on what the recipient keeps.

In practice this shapes the design. Return the minimum the consumer actually needs, keep customer endpoints on a separate namespace with a narrower permission check, and log access.

Caching

Cache the payload, not the response object, and cache only endpoints that are the same for every caller.

php

function shop_api_get_featured_products( WP_REST_Request $request ) {

    $cache_key = 'shop_api_featured_' . md5( wp_json_encode( $request->get_params() ) );
    $data      = get_transient( $cache_key );

    if ( false === $data ) {
        $data = shop_api_build_featured_payload( $request );
        set_transient( $cache_key, $data, HOUR_IN_SECONDS );
    }

    return rest_ensure_response( $data );
}

Never cache a response that varies by caller under a key that does not include the caller. Cache keyed only on request parameters will serve one customer’s data to another the moment you apply it to an authenticated endpoint.

CORS

If a browser application on another domain calls your endpoint, you need CORS headers. Scope them to your namespace and to known origins.

php

add_filter( 'rest_pre_serve_request', function ( $served, $result, $request ) {

    if ( 0 !== strpos( $request->get_route(), '/my-shop/v1' ) ) {
        return $served;
    }

    $allowed = array( 'https://app.example.com' );
    $origin  = get_http_origin();

    if ( $origin && in_array( $origin, $allowed, true ) ) {
        header( 'Access-Control-Allow-Origin: ' . esc_url_raw( $origin ) );
        header( 'Vary: Origin' );
    }

    return $served;
}, 10, 3 );

Two things to avoid. Do not send Access-Control-Allow-Origin: * together with Access-Control-Allow-Credentials: true. Browsers reject that combination, and the intent behind it is exactly what CORS exists to prevent. Do not remove WordPress’s own CORS filter globally to solve a problem on one route; that changes behaviour across the entire REST API.

Testing

Postman or Insomnia against a staging environment, never production.

Test the failure paths, not just the working one: a missing secret, a wrong secret, out-of-range parameters, an unknown route. Confirm the status codes are what a consumer can act on.

  • 200 success
  • 400 bad request, invalid parameters
  • 401 not authenticated
  • 403 authenticated but not permitted
  • 404 not found
  • 429 rate limit exceeded
  • 500 server error

Query Monitor shows the queries each request generates. An endpoint that looks fast on ten products often is not on ten thousand.

Before it goes live

  • secret stored outside the codebase, rotatable without a deployment
  • permission_callback on every route, verified with a request that should fail
  • HTTPS enforced
  • rate limiting in place
  • failed authentication attempts logged and monitored
  • versioned namespace, so you can change the contract without breaking consumers
  • written documentation, even a single page, covering routes, parameters, and error codes

An API is not a one-off task. It is a contract you maintain for as long as something depends on it.

If you are building integrations that carry order or customer data, a security audit before launch costs far less than an incident afterwards.

Pwel Zmyslowski

Paweł Zmysłowski

CEO WLC.team

At White Label Coders responsible for the sales process and sales team, still involved in the analytical and advisory roles in case of more complex projects.

Author page

Is your WordPress “working, but slow”?

MORE ARTICLES

Read also

  • Full Site Editing and design systems in WordPress
    7 minutes

    Full Site Editing and design systems in WordPress

    A campaign landing page is due Thursday. The design is signed off, the copy is written, and the change still goes into the engineering queue. We see this pattern in most WordPress platforms built before 2022, regardless of how strong the teams are on either side. WordPress solved this at the platform level. It was…

    Read

  • AI Search and WordPress How to prepare a large-scale platform for generative search
    15 minutes

    AI Search and WordPress: How to prepare a large-scale platform for generative search

    Large WordPress platforms do not disappear from AI-generated answers simply because their content is poor. They often lose visibility because, after years of development, no one has taken ownership of the information architecture, while crawler access may be restricted at a level that is not visible from the WordPress admin panel.

    Read

  • WordPress for Education in 2026
    11 minutes

    WordPress for Education in 2026: Architecture, tools, and decisions that will define your platform’s success

    WordPress powers over 40% of websites worldwide. In the education sector, that dominance is even more pronounced – the platform has become the de facto standard for institutions looking to combine a school website with a fully functional course management system, without per-user licensing costs that grow alongside their student base.

    Read