Building an extension
Custom REST Endpoints for Your Extension
Where an extension's data belongs in the Store API, how to attach it to routes that already exist, and the authorisation the Store API does not give you.
A shopper adds a gift message to the cart. The extension that collects it stores the
message in a session key and renders it on the order-received page. Then the store
enables the Checkout block, and the message stops appearing: the block never posts to
the cart page, it calls /wc/store/v1/checkout, and the extension's own
endpoint was never part of that conversation. The block worked, the extension did what
it was told, and the data still went missing.
That failure is the whole argument for this lesson. An extension that bolts a private REST API next to the Store API ends up with two clients to keep in sync: the classic checkout talking to your endpoint, and the block checkout talking to WooCommerce's. When the cart changes underneath you, the block is right and your copy is stale, and there is no hook that reconciles them because they were never the same request.
The Store API is not a read-only shopfront. It is the API the Blocks checkout is built on, and WooCommerce gives extensions two sanctioned ways into it. Everything below was run on WooCommerce 11.2.1 and WordPress 7.0.7, PHP 8.5.6, with the results quoted from that run.
Sixty-two routes live in the Store API, and none of them knows about your data
The namespace is wc/store, registered twice by
RoutesController::register_all_routes(): once as
wc/store/v1 and once as wc/store for clients that predate the
version segment. A request to either path reaches the same route class. I counted the
registered routes on the test store and found 62 entries under wc/store,
covering products, cart, checkout, product reviews, categories, brands and shopper
lists.
Extensions do not get to add a route to that list by writing a class. The route table
is built from RoutesController's own array of route identifiers, and the
only hook that changes the answer is
woocommerce_blocks_pre_get_routes_from_namespace, which merges the Store
API's routes into the list the Checkout block fetches. It is a filter WooCommerce uses
internally, not an extension point. Registering register_rest_route(
'wc/store/v1', ... ) from your own plugin works, and the next two sections
cover when that is the right answer. Reaching into RoutesController to
append a route class is not, and it breaks whenever that array is reorganised.
Two namespaces, one route table
Because RoutesController registers the v1 set under both
wc/store and wc/store/v1, a route you register yourself
under wc/store/v1 will not be reachable at wc/store. Pick
the versioned namespace and use it, or register both paths deliberately.
Extension data belongs on the endpoint that already owns it
When the data is a property of the cart, the cart item, the checkout or a product,
the endpoint already exists. What you need is a field in its response.
woocommerce_store_api_register_endpoint_data() adds one, and it takes four
keys: the endpoint identifier, your namespace, a schema callback and a data
callback.
<?php
add_action(
'woocommerce_blocks_loaded',
function () {
if ( ! function_exists( 'woocommerce_store_api_register_endpoint_data' ) ) {
return;
}
woocommerce_store_api_register_endpoint_data(
array(
'endpoint' => 'cart',
'namespace' => 'sifero',
'schema_callback' => function () {
return array(
'sifero_gift_message' => array(
'description' => 'Gift message for this order.',
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
);
},
'data_callback' => function () {
return array( 'sifero_gift_message' => 'Happy birthday' );
},
)
);
}
);
The valid endpoints are an allow-list inside ExtendSchema: the
identifiers are cart, cart-item, checkout and
product. Anything else returns a WP_Error rather than
registering. The schema callback describes the field, and the data callback returns the
value at the moment the response is built, which is why readonly is
honest: WooCommerce is not going to accept this key back from a client. To change the
value you register an update callback, which is the next section.
Where does the data land? Under an extensions key, nested by namespace.
On the test store, a cart containing one product came back with
"extensions":{"sifero":{"sifero_gift_message":"Happy birthday"}}. That
shape is the same one the Checkout block already understands, so an extension field
survives the blocks migration without the checkout template knowing it exists.
Writing to the cart goes through the extensions endpoint
Shopping data has to get into the session somehow.
woocommerce_store_api_register_update_callback() registers a callback for
POST /wc/store/v1/cart/extensions. The request carries a
namespace and a data object, WooCommerce looks the namespace
up, and calls your callback with the data. I registered a callback under
sifero, posted the namespace without a nonce and got
401 with code woocommerce_rest_missing_nonce. With the nonce,
the endpoint answered 200 and my callback received exactly what was
posted: {"wrap":true}. Posting an unregistered namespace produced
400 with code woocommerce_rest_cart_extensions_error and the
message There is no such namespace registered: not-registered.
<?php
woocommerce_store_api_register_update_callback(
array(
'namespace' => 'sifero',
'callback' => function ( $data ) {
$wrap = ! empty( $data['wrap'] );
if ( $wrap ) {
WC()->cart->add_fee( 'Gift wrap', 5.00 );
} else {
WC()->cart->remove_fees();
}
WC()->cart->calculate_totals();
},
)
);
Two details matter more than the code. The callback runs inside the Store API
request, so it can touch the cart directly and the totals it calculates are the totals
the client receives in the response. And registration is per request: the callback has
to be registered on every request that might use it, not once at activation, which is
why the example above hooks woocommerce_blocks_loaded. WooCommerce fires
that action after its own Store API files are loaded.
A private route is the right answer when the data is not shopping data
Some data genuinely has nothing to do with the cart. A courier reference, a delivery
slot list, a stock enquiry: the Store API has no opinion about these, and forcing them
into extensions on the cart would be worse than a small route of your own.
Use register_rest_route() with a namespace of your own and a version
segment, the same way WooCommerce namespaces its own admin API.
<?php
add_action(
'rest_api_init',
function () {
register_rest_route(
'sifero/v1',
'/delivery-slots/(?P<zone>[a-z0-9-]+)',
array(
'methods' => WP_REST_Server::READABLE,
'permission_callback' => '__return_true',
'callback' => function ( WP_REST_Request $request ) {
return rest_ensure_response(
array( 'zone' => $request->get_param( 'zone' ) )
);
},
'args' => array(
'zone' => array(
'required' => true,
'type' => 'string',
),
),
)
);
}
);
The argument worth stating plainly: permission_callback is not
optional. WordPress calls it, and when it returns false or
null the response is rest_forbidden with status
401 for a logged-out request and 403 for a logged-in one. I
confirmed both codes on the test store by returning false from a callback
and dispatching the same request twice, once after
wp_set_current_user( 1 ). A route that reads public data and wants to be
reachable without authentication says so with __return_true; every other
route needs a real check.
The Store API's headers prove intent, not ownership
Two headers guard the edge of the Store API. A nonce is required on every non-GET
cart route, and the check lives in
AbstractCartRoute::check_nonce(): no Nonce header gives
401 with woocommerce_rest_missing_nonce, a bad one gives
403 with woocommerce_rest_invalid_nonce. Both bodies come
straight from the class. The action the nonce is created against is
wc_store_api. There is also a filter,
woocommerce_store_api_disable_nonce_check, which exists for REST clients
in tests and should not be true anywhere near a store.
The cart token is the other half. It is a signed token that identifies a session, and
a valid one selects StoreApi\SessionHandler instead of the cookie handler,
which is how the Checkout block keeps a cart on a page with no cookies yet. On the test
store, POST /wc/store/v1/cart/add-item with a valid nonce returned
201 with headers including Cart-Token, Nonce,
Nonce-Timestamp, Cart-Hash and User-ID. Replaying
that token on a later GET /wc/store/v1/cart returned the cart with its one
item and no cookies involved.
That token identifies a cart. It does not authorise anything else. WooCommerce is
explicit about the header names it exposes to browsers:
rest_exposed_cors_headers adds Cart-Token and not the nonce.
Neither header answers the question "is this shopper allowed to see this record". If
your route takes an order id, the nonce in the request is the shopper's own nonce, and
it will verify happily while you hand them somebody else's order.
A route keyed by a guessable id leaks another shopper's data
Here is the failure, running. A route under wc/store/v1 that reads
order_id, fetches the order and returns its billing email and gift message,
with permission_callback => '__return_true'. No authentication, no
cookies, no nonce:
GET /wc/store/v1/sifero/gift-message?order_id=17
status=200 email=shopper@example.org message=Happy birthday
The order id was an incrementing integer. Any visitor can walk that parameter. The
same route with a permission callback that compares
$order->get_order_key() against a posted key using
hash_equals() behaved differently: a wrong key returned 403
with my own error code, and the real order key returned 200 with the
message. The order key is exactly the credential WooCommerce puts in the
order-received URL, and it is the right one to reuse.
<?php
function sifero_can_read_order( WP_REST_Request $request ) {
$order = wc_get_order( (int) $request->get_param( 'order_id' ) );
if ( ! $order ) {
return new WP_Error( 'sifero_no_order', 'No such order.', array( 'status' => 404 ) );
}
if ( ! hash_equals( $order->get_order_key(), (string) $request->get_param( 'key' ) ) ) {
return new WP_Error( 'sifero_forbidden', 'Not your order.', array( 'status' => 403 ) );
}
return true;
}
// Used as the route argument.
register_rest_route(
'sifero/v1',
'/order-message',
array(
'methods' => WP_REST_Server::READABLE,
'permission_callback' => 'sifero_can_read_order',
'callback' => 'sifero_render_order_message',
)
);
Three habits keep this class of bug out. An id alone never satisfies a permission
check; the id has to arrive with a secret the requester already had. Do not return the
object when the check fails: a 404 and a 403 tell an attacker different things. And keep
the checks in permission_callback rather than the first lines of the
callback, because WordPress runs permission before the callback, so it is the only place
a failure cannot be forgotten later.
The limit of everything above: none of it stops a plugin with a bug from returning data it fetched itself. The Store API gives you the session, the cart and the authorisation plumbing, and the rest is your check. If your extension cannot say in one sentence which secret a request must carry to read a record, the endpoint is not ready.
Build a small extension that puts a gift message on the cart and reads one order back safely.
- Register
sifero_gift_messageon thecartendpoint withwoocommerce_store_api_register_endpoint_data(). Add one item to the cart over REST and print theextensionskey from the response. - Register an update callback for the
siferonamespace that stores the posted message in the session, then post to/wc/store/v1/cart/extensionswithout a nonce and with one, printing the status and error code of each. - Add a route
sifero/v1/order-messagethat takesorder_idandkey, with apermission_callbackthat useshash_equals()against the order key. - Call it twice: once with a wrong key, once with the real one. Print status and body for both.
- Change the route's
permission_callbackto__return_true, place a second order as a different customer, and read it with the first order's id. Record what came back.
Then answer in one sentence: which check in your route would still be standing if the cart token leaked?
Show the solution and the reasoning
The order of the work matters more than the code. Registering the endpoint data
first tells you whether your field shows up under extensions before you
write any session code, and the nonce test tells you whether you are even reaching
your callback before you debug the callback itself. I lost an hour to this once by
printing inside the callback while the request was dying at the nonce check.
<?php
// The session-backed value, read by the schema data callback.
function sifero_gift_message() {
if ( ! WC()->session ) {
return '';
}
return (string) WC()->session->get( 'sifero_gift_message', '' );
}
// Points 1 and 2: attach the field, and accept a write for it.
add_action(
'woocommerce_blocks_loaded',
function () {
woocommerce_store_api_register_endpoint_data(
array(
'endpoint' => 'cart',
'namespace' => 'sifero',
'schema_callback' => function () {
return array(
'sifero_gift_message' => array(
'description' => 'Gift message for this order.',
'type' => 'string',
'context' => array( 'view', 'edit' ),
),
);
},
'data_callback' => function () {
return array( 'sifero_gift_message' => sifero_gift_message() );
},
)
);
woocommerce_store_api_register_update_callback(
array(
'namespace' => 'sifero',
'callback' => function ( $data ) {
$message = isset( $data['gift_message'] ) ? sanitize_text_field( $data['gift_message'] ) : '';
WC()->session->set( 'sifero_gift_message', $message );
},
)
);
}
);
On my run the cart response carried
"extensions":{"sifero":{"sifero_gift_message":"Happy birthday"}}; the
unauthenticated POST to cart/extensions returned 401 with
woocommerce_rest_missing_nonce; the authenticated one returned
200 and my callback printed the data it was handed.
The order route is where the lab earns its keep. With
__return_true, my second order was readable by id alone: status
200, the other customer's billing email in the body. With the order key
check, a wrong key returned 403 and the correct key returned
200. The answer to the closing question is the order key check. A leaked
cart token gets someone a session, which is bad, but it does not get them your
permission_callback, and a route that trusts an id has nothing left to
fall back on.
Two limits worth writing down. The nonce and the cart token are per-request
protections; they do nothing for a route that reads a record chosen by a parameter,
which is why the order-key comparison exists as a second layer rather than a
replacement. And a permission check that returns a WP_Error with a
status of 404 for orders that exist but are not yours is a deliberate
choice about information disclosure; a 403 is more honest and tells an
attacker less about whether the id exists.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.