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_callbackon 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.



