WordPress REST API routes I trust in production

Most plugin REST bugs are permission, schema, or side-effect bugs — not routing bugs. Here is how I design WordPress REST routes so admin UIs, storefront clients, and webhooks stay honest under real traffic.

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:

  1. Who may call it — permission_callback, not “hope the UI only shows the button to admins.”
  2. What shape is accepted — JSON Schema args, not “cast whatever arrived.”
  3. What side effects happen exactly once — create, update, enqueue, email, invalidate cache.
  4. 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/v2 custom fields unless you are extending a core resource on purpose.
  • Bump the path version when the response shape breaks. acme/v2 is cheaper than teaching every client to sniff fields.
  • Prefer resource nouns over verbs. /settings + EDITABLE beats /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_Error with status 401 / 403 from the permission callback when you want a clear client signal. Returning false works, but a coded error helps React UIs show the right toast.
  • Cap checks belong in one place. If update and delete share a policy, share the method. Copy-pasted current_user_can drifts.

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 enum and required instead 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 500 and 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() returns webhook_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: 400 validation, 401 auth, 403 capability, 404 missing resource, 409 conflict, 429 rate limit.
  • Field-level params for 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_page capped, 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_page with a max of 100
  • Total counts only when cheap; otherwise omit X-WP-Total rather than run a second heavy COUNT(*)
  • Stable sort: orderby=date&order=desc with 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:

  1. Unauthenticated request → 401 / 403
  2. Authenticated without capability → 403
  3. Invalid body → 400 with expected code
  4. Valid write → 200/201 and DB assertion
  5. Second identical write → same resource state, no duplicate side effects
  6. 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_true on 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.