When a WordPress plugin grows past a couple of admin screens, someone inevitably says “just expose a REST endpoint.” That sentence has shipped more fragile APIs than any other line in my career. Routes are easy. Trustworthy routes — the ones that survive concurrent editors, stale React state, cookie vs application-password auth, and a merchant clicking Save twice — are a design problem.
I have spent years building WordPress and WooCommerce plugins where the REST layer is the real product surface: payment settings UIs, merchant dashboards, and headless-ish admin apps. Gateway work at Paysera taught me that every write endpoint is a financial boundary. Later work around SureCart / Brainstorm Force products reinforced the same lesson for billing and storefront state. This article is the checklist I use before I call a register_rest_route ready for production.
The contract is not the URL
A route like POST /wp-json/acme/v1/settings looks like a URL. Clients treat it like a form submit. In production it is a contract with four parts:
- Who may call it —
permission_callback, not “hope the UI only shows the button to admins.” - What shape is accepted — JSON Schema args, not “cast whatever arrived.”
- What side effects happen exactly once — create, update, enqueue, email, invalidate cache.
- What the client may rely on in the response — status codes, error codes, and field stability across minor versions.
If any of those four is implicit, the route will eventually break a merchant’s admin session or a partner integration. I write the contract down in a short comment above register_rest_route before I write the callback. It sounds ceremonial. It catches half the bugs.
Namespace and versioning I actually keep
I use a plugin-owned namespace and an explicit version from day one:
add_action( 'rest_api_init', function () {
register_rest_route( 'acme/v1', '/settings', [
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ Settings_Controller::class, 'show' ],
'permission_callback' => [ Settings_Controller::class, 'can_manage' ],
],
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ Settings_Controller::class, 'update' ],
'permission_callback' => [ Settings_Controller::class, 'can_manage' ],
'args' => Settings_Controller::update_args(),
],
] );
} );
Rules I do not bend:
- Own the namespace. Never hang plugin features off
wp/v2custom fields unless you are extending a core resource on purpose. - Bump the path version when the response shape breaks.
acme/v2is cheaper than teaching every client to sniff fields. - Prefer resource nouns over verbs.
/settings+EDITABLEbeats/save-settings. - One controller class per resource family. Controllers stay thin; domain services own business rules.
When WooCommerce plugins need shop-scoped data, I still keep my namespace and accept a shop_id or use the current blog in multisite — I do not invent a second REST stack “because Woo has Store API.” Store API is for cart/checkout. Admin and merchant tooling belong in the plugin namespace.
permission_callback is the security boundary
WordPress will happily run your callback if permission_callback returns true. The classic foot-gun is:
'permission_callback' => '__return_true', // "it's just reading config"
Reading config is often enough to leak webhook secrets, API keys, or merchant emails. Writing is worse. My defaults:
public static function can_manage(): bool {
return current_user_can( 'manage_woocommerce' )
|| current_user_can( 'manage_options' );
}
public static function can_view_reports( WP_REST_Request $request ): bool {
if ( ! is_user_logged_in() ) {
return false;
}
return current_user_can( 'view_woocommerce_reports' )
|| self::can_manage();
}
Patterns that keep me out of incident reviews:
- Never reuse cookie auth assumptions for public storefront routes. If a route is meant for logged-out shoppers, design it as public with rate limits and no sensitive fields — or do not expose it over REST at all.
- Application Passwords and JWT are not a permission model. They authenticate who; capabilities still decide what.
- Return
WP_Errorwith status401/403from the permission callback when you want a clear client signal. Returningfalseworks, but a coded error helps React UIs show the right toast. - Cap checks belong in one place. If
updateanddeleteshare a policy, share the method. Copy-pastedcurrent_user_candrifts.
For payment-adjacent settings I go further: capability check plus a nonce or intent token for destructive actions, even inside cookie-auth admin contexts. REST cookie auth already verifies nonces via X-WP-Nonce, but I still treat “disconnect gateway” like a privileged mutation.
Schema args beat tribal knowledge
If the only validation is inside the callback, every client reinvents the rules. I put the contract in args:
public static function update_args(): array {
return [
'mode' => [
'required' => true,
'type' => 'string',
'enum' => [ 'test', 'live' ],
'sanitize_callback' => 'sanitize_text_field',
],
'webhook_secret' => [
'required' => false,
'type' => 'string',
'minLength' => 16,
'sanitize_callback' => 'sanitize_text_field',
],
'enabled' => [
'required' => true,
'type' => 'boolean',
],
];
}
Why this matters in production:
- Core rejects bad types before your code runs.
- OpenAPI-ish docs fall out of the schema for free via the index.
- React admin forms can mirror
enumandrequiredinstead of duplicating PHP conditionals. - You stop accepting
"enabled": "yes"as a boolean by accident.
I still validate domain rules in the service layer — “live mode requires a non-empty secret” is business logic, not JSON Schema. Schema catches shape. Domain catches meaning.
Side effects: make writes boring
The callback should not be a novel. My shape looks like this:
public static function update( WP_REST_Request $request ) {
$payload = [
'mode' => $request['mode'],
'webhook_secret' => $request['webhook_secret'] ?? null,
'enabled' => (bool) $request['enabled'],
];
try {
$settings = Settings_Service::update( $payload );
} catch ( Invalid_Settings $e ) {
return new WP_Error(
'acme_invalid_settings',
$e->getMessage(),
[ 'status' => 400 ]
);
}
/**
* Fires after settings persist. Listeners may enqueue jobs;
* they must not throw into the request.
*/
do_action( 'acme_settings_updated', $settings );
return rest_ensure_response( [
'settings' => Settings_Presenter::to_rest( $settings ),
] );
}
Production habits:
- Persist first, notify second. If the DB write fails, do not enqueue “settings changed” emails.
- Keep hooks fail-soft. A listener that throws turns a successful save into a
500and a confused admin UI. - Do not do slow work inline. Report exports, remote reconciliation, and bulk backfills go to Action Scheduler or an external queue. The REST request returns quickly with a job id when needed.
- Presenters strip secrets.
to_rest()returnswebhook_secret_set: true, never the raw secret, unless the route is explicitly a one-time reveal with audit logging.
Idempotency belongs here too. If a React query retries PUT after a network blip, saving the same settings twice must be a no-op, not a double “connected” email. I key muteable side effects on a hash of the persisted payload or an explicit request_id when the client can send one.
Errors clients can handle
I treat REST errors as part of the product UI. A string blob in message is not enough for a settings screen with five fields.
return new WP_Error(
'acme_invalid_settings',
__( 'Live mode requires a webhook secret.', 'acme' ),
[
'status' => 400,
'params' => [
'webhook_secret' => __( 'Required when mode is live.', 'acme' ),
],
]
);
Conventions that save support time:
- Stable error codes (
acme_invalid_settings) that never change meaning. - HTTP status that matches reality:
400validation,401auth,403capability,404missing resource,409conflict,429rate limit. - Field-level
paramsfor forms. - No stack traces in production responses.
On the React side (Gutenberg data or a small admin app), I map code + params to field errors and keep a single toast for unknown 500s. That pattern is how admin UIs feel “native” instead of “someone wrapped fetch.”
Reads: cache headers and expensive joins
Not every GET needs object cache, but every GET needs a story for load.
For rarely changing settings, I set conservative cache headers only when the response is identical for the user and contains no secrets:
$response = rest_ensure_response( [ 'settings' => $dto ] );
$response->header( 'Cache-Control', 'no-store' ); // default for authenticated admin
return $response;
Authenticated admin GETs almost always get no-store. Public catalog-style endpoints are different — and rare in my plugins on purpose. When I do expose a public read, I:
- Return only public fields
- Add short TTL + surrogate keys if a CDN sits in front
- Rate-limit by IP and route
- Avoid unbounded list queries (
per_pagecapped, cursor or offset with a hard max)
List endpoints that join orders, subscriptions, and meta without indexes will pass code review and fail on Black Friday. I write the SQL (or WP_Query args) with the same paranoia I use for WooCommerce admin list tables: selective columns, indexed filters, and no posts_per_page => -1.
Collection patterns that age well
When I need collections — webhook delivery logs, reconciliation runs, connected shops — I stick to boring pagination:
page+per_pagewith a max of100- Total counts only when cheap; otherwise omit
X-WP-Totalrather than run a second heavyCOUNT(*) - Stable sort:
orderby=date&order=descwith a documented default - Filters as query args validated in schema (
status,after,before)
I avoid returning nested graphs three levels deep. If the UI needs related entities, I either embed a thin summary or provide a follow-up route. Over-fetching in REST is how PHP workers melt while React waits on a single mega-payload.
Testing the route, not just the service
Unit-testing the domain service is necessary and not sufficient. I also smoke the route:
- Unauthenticated request →
401/403 - Authenticated without capability →
403 - Invalid body →
400with expected code - Valid write →
200/201and DB assertion - Second identical write → same resource state, no duplicate side effects
- Response does not include secrets
In practice that is a small integration test bootstrapping WP_REST_Server or hitting the route through WordPress’s REST test helpers. The point is not coverage theater — it is locking the contract so a “quick fix” to permission_callback cannot silently open the door.
How this shows up in real plugins
Putting it together for a typical WooCommerce payment settings screen:
| Concern | Production choice |
|---|---|
| Namespace | paysera/v1 style plugin prefix |
| Auth | Cookie + X-WP-Nonce in wp-admin; Application Passwords for automation |
| Capability | manage_woocommerce (or a custom cap granted to shop managers) |
| Writes | Schema args + domain service + Action Scheduler for remote verify |
| Reads | no-store, presenter without secrets |
| Errors | Stable codes + field params |
| Versioning | New path version when breaking response fields |
That table is deliberately dull. Dull APIs are the ones merchants still use two years later without a migration guide every quarter.
What I refuse to ship
A short deny-list that has paid for itself:
__return_trueon any route that can read secrets or mutate store state- Callbacks that call remote payment APIs synchronously on every Save click
- Responses that echo back full API keys “for convenience”
- Undocumented breaking field renames inside the same
/v1 - List endpoints with no pagination ceiling
- Business rules that exist only in the React form and not in PHP
If a feature needs a long-running remote call, the REST route starts the job and returns a status resource. The UI polls or subscribes. That split is the same instinct as keeping WooCommerce plugins thin while heavier work sits on a queue — REST is the front door, not the factory floor.
Closing
A WordPress REST route you can trust is mostly discipline: explicit permissions, schema that tells the truth, boring side effects, and errors a UI can parse. The register_rest_route call is the smallest part of the work.
Next time you add an endpoint for an admin React panel, write the four-part contract first — who, shape, side effects, response — then implement the callback as a thin adapter over a service you would trust without HTTP. That is the bar I use on payment and merchant tooling, and it is the bar that keeps production quiet.