Adding a Custom Endpoint to the WooCommerce REST API
Sometimes the built-in wc/v3 routes are not enough: you need an action such as "mark this order as synced", a combined response for a mobile app, or an extra field on the product response. WordPress gives you two tools for this – registering your own route, or extending WooCommerce's existing responses. This guide shows both, including how to make your route accept WooCommerce API keys.
Table of Contents
- Custom route or extended response?
- Registering a custom route
- Accepting WooCommerce API keys on your route
- Adding fields to existing responses
- Restricting access to REST endpoints
- Testing
Custom Route or Extended Response?
- Extend the response when clients already call
/wc/v3/ordersor/wc/v3/productsand just need extra data. - Register a route when you need a new action or a response shape the core endpoints do not provide.
Use your own namespace (e.g. myshop/v1) instead of registering inside wc/v3, so WooCommerce updates cannot collide with your routes.
Registering a Custom Route
<?php
/**
* Plugin Name: MyShop REST extensions
*/
add_action( 'rest_api_init', function () {
register_rest_route( 'myshop/v1', '/orders/(?P<id>\d+)/sync', array(
'methods' => WP_REST_Server::CREATABLE, // POST
'callback' => 'myshop_mark_order_synced',
'permission_callback' => function () {
return current_user_can( 'edit_shop_orders' );
},
'args' => array(
'id' => array(
'validate_callback' => function ( $value ) {
return is_numeric( $value );
},
),
'reference' => array(
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
),
) );
} );
function myshop_mark_order_synced( WP_REST_Request $request ) {
$order = wc_get_order( (int) $request['id'] );
if ( ! $order ) {
return new WP_Error( 'myshop_order_not_found', 'Order not found.', array( 'status' => 404 ) );
}
$order->update_meta_data( '_myshop_erp_reference', $request['reference'] );
$order->add_order_note( 'Synced to ERP: ' . $request['reference'] );
$order->save();
return rest_ensure_response( array(
'id' => $order->get_id(),
'status' => $order->get_status(),
'reference' => $order->get_meta( '_myshop_erp_reference' ),
) );
}
Key points:
- Always set
permission_callback. Since WordPress 5.5 a missing callback triggers a notice; returning__return_truemakes the route public. - Use WooCommerce CRUD methods (
wc_get_order(),update_meta_data(),save()) rather thanupdate_post_meta(), so the code works with HPOS order storage. - Return
WP_Errorwith astatusfor errors; WordPress turns it into a JSON error response.
Accepting WooCommerce API Keys on Your Route
WooCommerce only checks consumer keys on requests whose route starts with wc/ or wc-. On a custom namespace, a request with -u ck_xxx:cs_xxx arrives unauthenticated and your permission_callback returns false (401). Tell WooCommerce to authenticate your namespace too:
add_filter( 'woocommerce_rest_is_request_to_rest_api', function ( $is_wc_request ) {
if ( $is_wc_request || empty( $_SERVER['REQUEST_URI'] ) ) {
return $is_wc_request;
}
$uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
$prefix = trailingslashit( rest_get_url_prefix() ); // usually "wp-json/"
return false !== strpos( $uri, $prefix . 'myshop/' );
} );
With that filter, the key's Read/Write permission is enforced for your route as well (GET needs read, POST needs write), and the request runs as the key's user so current_user_can() works. Application Passwords work without any filter, because WordPress core handles them for every route.
Adding Fields to Existing Responses
WooCommerce passes every object through a woocommerce_rest_prepare_{type}_object filter before returning it. For orders the type is shop_order; for products it is product:
add_filter( 'woocommerce_rest_prepare_shop_order_object', function ( $response, $order, $request ) {
$response->data['erp_reference'] = $order->get_meta( '_myshop_erp_reference' ) ?: null;
return $response;
}, 10, 3 );
add_filter( 'woocommerce_rest_prepare_product_object', function ( $response, $product, $request ) {
$response->data['warehouse_bin'] = $product->get_meta( '_warehouse_bin' ) ?: null;
return $response;
}, 10, 3 );
If you also need the field in the schema and writable through the API, use register_rest_field() on the post type (for products) with get_callback, update_callback and schema. Order meta is also exposed in the standard meta_data array, unless the key starts with an underscore (protected meta).
Restricting Access to REST Endpoints
To stop anonymous access to a route, rely on permission_callback. To block whole namespaces for unauthenticated users (for example on a store that is only accessed by back-office tools), filter rest_authentication_errors:
add_filter( 'rest_authentication_errors', function ( $result ) {
if ( ! empty( $result ) ) {
return $result; // another auth method already decided
}
$route = $GLOBALS['wp']->query_vars['rest_route'] ?? '';
if ( str_starts_with( $route, '/myshop/' ) && ! is_user_logged_in() ) {
return new WP_Error( 'rest_forbidden', 'Authentication required.', array( 'status' => 401 ) );
}
return $result;
} );
Do not blanket-disable the REST API: the block editor, WooCommerce admin screens and the Store API used by block-based checkout all depend on it.
Testing
curl -X POST "https://example.com/wp-json/myshop/v1/orders/727/sync" \
-u ck_xxx:cs_xxx -H "Content-Type: application/json" \
-d '{ "reference": "ERP-10045" }'
Check the route is registered with GET /wp-json/myshop/v1, and remember that WooCommerce must be active before you call wc_get_order() – ship the code as a small plugin rather than in a theme's functions.php.
Related: authentication and key permissions, webhooks, and the WordPress handbook on custom endpoints.