All WordPress HTML Templates Forms & Webhooks AI & Tools
WordPress

Custom REST API Endpoints in WordPress: Auth, Caching and Versioning

Build a WordPress REST API custom endpoint that survives production: register_rest_route patterns, auth choices, CDN caching headers, ETags and safe

A client’s headless front end was pulling 14 requests per page view from a WordPress install on a $40/month VPS. Average response time on the worst endpoint: 840ms. The fix wasn’t a bigger box. It was one wordpress rest api custom endpoint that returned exactly the fields the front end rendered, cached in the object cache, with an ETag so repeat visits got a 304 and 0 bytes of body.

Most tutorials on this topic stop at registerrestroute and a callback that returns ['hello' => 'world']. That’s the first five percent. The parts that bite you in production are the permission callback you got wrong, the no-cache header WordPress sends on your behalf, and the day a mobile app that nobody maintains is still hitting /acme/v1/ with a field you deleted.

This is the version written after shipping a few dozen of these, including two we had to roll back.

Key Takeaways

  • A permission_callback is mandatory, and __return_true is a legitimate answer for public reads, but only if you write it deliberately rather than copying it from Stack Overflow.
  • WordPress sends no-cache headers on REST responses by default. Until you filter restsendnocache_headers, your CDN will never cache a single byte of your endpoint.
  • Application Passwords (core since 5.6) handle most server-to-server auth. A JWT plugin is usually the wrong choice unless you genuinely need stateless tokens shared across non-WordPress services.
  • Version the namespace, not the route: acme/v1 and acme/v2 should be thin controllers over one shared service layer, otherwise you’ll be fixing every bug twice.
  • Declare type, enum, minimum and maximum in args so core rejects bad input with a 400 before your callback runs, and set a hard maximum on per_page.

When a custom endpoint is the wrong answer

Before you write any PHP, check whether core already does it. /wp/v2/posts?fields=id,title,link&perpage=20 handles a surprising amount of what people build custom routes for, and fields trims the payload server-side. If you need extra data on an existing resource, registerrest_field is less code and inherits core’s permission model, pagination and schema.

Three cases where a custom route genuinely earns its keep: you’re aggregating several queries into one response, you’re exposing something that isn’t a post type (a pricing calculator, an availability lookup, a webhook receiver), or core’s response shape forces the client to do joins it shouldn’t.

One case where you should write nothing at all: if the endpoint exists purely to accept a contact form POST, you’re about to build spam filtering, rate limiting, email delivery and nonce handling for a feature that WebForms gives you as a free endpoint with no backend. We use it on every static build. Writing a WordPress route to catch form posts means booting 30MB of PHP to send one email.

A real working moment that fits an article section about

registerrestroute, written the way it should be

Here’s a route with everything that matters: multiple methods, declared argument schema, a real permission callback and a resource schema for discovery.

addaction( 'restapi_init', function () {
    registerrestroute( 'acme/v1', '/projects', [
        [
            'methods'             => WPRESTServer::READABLE,
            'callback'            => 'acmegetprojects',
            // Public read, chosen on purpose. Documented here so the next dev doesn't "fix" it.
            'permission_callback' => '__return_true',
            'args'                => [
                'page' => [
                    'type'              => 'integer',
                    'default'           => 1,
                    'minimum'           => 1,
                    'sanitize_callback' => 'absint',
                ],
                'per_page' => [
                    'type'    => 'integer',
                    'default' => 12,
                    'minimum' => 1,
                    'maximum' => 50, // a ceiling, not a suggestion
                ],
                'status' => [
                    'type'    => 'string',
                    'enum'    => [ 'active', 'archived' ],
                    'default' => 'active',
                ],
            ],
        ],
        [
            'methods'             => WPRESTServer::CREATABLE,
            'callback'            => 'acmecreateproject',
            'permission_callback' => function () {
                return currentusercan( 'edit_posts' );
            },
        ],
        'schema' => 'acmeprojectschema',
    ] );
} );

Current WordPress wires restvalidaterequestarg and restsanitizerequestarg as defaults when an arg declares a type, so perpage=5000 comes back as a 400 with a readable message and your callback never executes. If you support older installs, or the arg protects something expensive, set validatecallback explicitly and sleep better.

Two things developers skip that cost real time later. The schema callback makes the route self-documenting through an OPTIONS request and lets clients use fields. Return WPError instead of throwing, and you get the right status code for free:

if ( ! $project ) {
    return new WP_Error(
        'acmeprojectnot_found',
        __( 'Project not found.', 'acme' ),
        [ 'status' => 404 ] // without this you get a 500, and your monitoring will page you at 3am
    );
}

WordPress API authentication: pick a mechanism, not a plugin

There are only four options worth considering, and the decision is usually obvious once you name the caller.

Cookie plus nonce (same-origin browser calls)

If the request comes from your own logged-in pages, pass wpcreatenonce('wprest') as the X-WP-Nonce header. The catch nobody mentions: that nonce dies with the session and has a limited lifetime, so a tab left open overnight starts getting 403 restcookieinvalidnonce errors. Refresh it with wprefreshnonces via heartbeat, or handle the 403 client-side and re-fetch. Progressive forms should still work without JS, which is a progressive enhancement problem rather than an API one.

Application Passwords (server-to-server)

Core since 5.6, Basic auth over HTTPS, per-user and individually revocable. It shows the last-used date in the user profile. For a Zapier connection, a deployment script or a partner integration, this is the right answer about 80 percent of the time. It costs you zero lines of code, and revoking one credential doesn’t log anyone out.

Custom bearer tokens (when you own both ends)

If you need machine identities that don’t map to a human, hook determinecurrentuser:

addfilter( 'determinecurrentuser', function ( $userid ) {
    if ( $user_id ) {
        return $user_id; // cookie auth already won
    }

    $header = $SERVER['HTTPAUTHORIZATION'] ?? '';
    if ( 0 !== strpos( $header, 'Bearer ' ) ) {
        return $user_id;
    }

    // Store only hashes. Look up by a short prefix, then verify in constant time.
    $token  = substr( $header, 7 );
    $record = acmefindtokenbyprefix( substr( $token, 0, 8 ) );

    if ( $record && hash_equals( $record->hash, hash( 'sha256', $token ) ) ) {
        return (int) $record->user_id;
    }

    return $user_id;
}, 20 );

Let cookie auth win first. Never call currentusercan() inside this filter: you’re in the middle of resolving the current user, and that’s an infinite loop we’ve debugged more than once.

JWT plugins

Usually the wrong choice. You inherit a secret in wp-config.php, a token you can’t revoke before expiry, and a plugin that may stop being maintained. JWT is justified when a non-WordPress service in your stack already validates the same tokens. Otherwise Application Passwords do the job with code that ships with core.

Whatever you pick, lock the namespace down in one place rather than per route:

addfilter( 'restauthentication_errors', function ( $result ) {
    if ( ! empty( $result ) ) {
        return $result;
    }
    $route = $GLOBALS['wp']->queryvars['restroute'] ?? '';
    if ( 0 === strpos( $route, '/acme/v1/internal' ) && ! isuserlogged_in() ) {
        return new WPError( 'acmeauth_required', 'Authentication required.', [ 'status' => 401 ] );
    }
    return $result;
} );

Do not blanket-block the REST API for anonymous users. Embeds, some block editor features and plenty of plugins talk to it. If you’re building paid tiers on top of this, read our piece on membership and gated content architecture first, because capability checks in a route are the last 10 percent of that problem.

Caching: WordPress works against you by default

Run curl -I against any REST route on a stock install. You’ll see Cache-Control: no-cache, must-revalidate, max-age=0. Core controls this through the restsendnocacheheaders filter, whose default in current versions is isuserloggedin(). For anonymous GETs on a public route you want it off, then set your own policy:

addfilter( 'restsendnocacheheaders', function ( $send ) {
    if ( isuserloggedin() || 'GET' !== $SERVER['REQUEST_METHOD'] ) {
        return $send;
    }
    $route = $GLOBALS['wp']->queryvars['restroute'] ?? '';
    return 0 === strpos( $route, '/acme/v1/projects' ) ? false : $send;
} );

Then layer three caches: object cache for the query result, HTTP headers for the CDN, ETag for the client:

function acmegetprojects( WPRESTRequest $request ) {
    // Version the key off the last post modification so saves invalidate without a hook.
    $version = get_lastpostmodified( 'GMT', 'project' );
    $key     = 'projects' . md5( $version . wpjsonencode( $request->getparams() ) );

    $payload = wpcacheget( $key, 'acme_api' );

    if ( false === $payload ) {
        $query = new WP_Query( [
            'post_type'              => 'project',
            'post_status'            => 'publish',
            'postsperpage'         => (int) $request['per_page'],
            'paged'                  => (int) $request['page'],
            'updatepostterm_cache' => false, // we never touch terms in the response
            'ignorestickyposts'    => true,
        ] );

        $payload = [
            'items' => arraymap( 'acmeprepare_project', $query->posts ),
            'total' => (int) $query->found_posts,
        ];

        wpcacheset( $key, $payload, 'acme_api', 300 );
    }

    $etag     = '"' . md5( wpjsonencode( $payload ) ) . '"';
    $response = new WPRESTResponse( $payload['items'] );

    $response->header( 'X-WP-Total', $payload['total'] );
    $response->header( 'ETag', $etag );
    $response->header( 'Cache-Control', 'public, max-age=60, s-maxage=600, stale-while-revalidate=86400' );

    if ( trim( (string) $request->getheader( 'ifnone_match' ) ) === $etag ) {
        $response->set_status( 304 );
        $response->set_data( null );
    }

    return $response;
}

One caveat that matters: wpcacheset without Redis or Memcached is per-request only, so on shared hosting you get no reuse at all. Check wpusingextobjectcache() and fall back to a transient if you must, accepting the wp_options write. On one magazine site, adding s-maxage=600 in front of Cloudflare took the origin from roughly 90 requests per second to under 4, and the p95 for cached hits dropped from about 310ms to under 20ms at the edge.

Never set Cache-Control: public on a route whose response varies by user. If it does vary, keep no-cache and make the payload small instead.

Versioning without stranding clients

The version in registerrestroute( 'acme/v1', ... ) is a major version. Bump it only for breaking changes: removing a field, changing a type, renaming a key, tightening validation that previously passed. Adding fields is not breaking, and clients that break on new fields are badly written clients.

When you do bump, keep one implementation. The v1 controller becomes an adapter:

function acmegetprojectsv1( WPREST_Request $request ) {
    $response = Acme\Projects\Service::list( $request->get_params() );

    // v1 shipped name; v2 renamed it to title. Map, don't fork the query.
    $data = array_map( function ( $item ) {
        $item['name'] = $item['title'];
        unset( $item['title'] );
        return $item;
    }, $response->get_data() );

    $response->set_data( $data );
    $response->header( 'Deprecation', 'true' );
    $response->header( 'Sunset', 'Wed, 01 Jul 2026 00:00:00 GMT' ); // RFC 8594
    $response->header( 'Link', '<https://example.com/docs/api/v2>; rel="deprecation"' );

    return $response;
}

Log every v1 hit with the user agent and the authenticating user. That log is how you find out whether the sunset date is safe. We’ve retired endpoints on the strength of a 30-day log showing nothing but our own uptime monitor, and that’s the only honest basis for telling a client the route is clear to remove.

Rate limiting, debugging and the failures you’ll actually hit

Rate limit at the edge, not in PHP. A Cloudflare rule or an nginx limit_req zone rejects the request before WordPress boots, which on a typical install is 80ms to 200ms of work you’re paying for on every abusive request. In-PHP rate limiting is only worth it when the limit is per-API-key rather than per-IP, and even then store counters in the object cache, not the database.

# nginx: 10 requests/second per IP on the REST API, bursts of 20 queued
limitreqzone $binaryremoteaddr zone=restapi:10m rate=10r/s;

location /wp-json/ {
    limit_req zone=restapi burst=20 nodelay;
    try_files $uri $uri/ /index.php?$args;
}

For internal calls, use restdorequest() rather than wpremoteget() against your own URL. A loopback HTTP request boots a second WordPress process, adds latency and fails silently behind basic auth or on hosts that block loopback. restdorequest() runs in process and respects the same permission callbacks.

When something misbehaves, Query Monitor surfaces REST request data through X-QM-* response headers for admin users. That’s the fastest way to catch an endpoint firing 300 queries because of a missing updatepostmeta_cache. Our walkthrough on isolating the culprit with logs and Query Monitor covers the workflow. For POST routes that receive third-party traffic, build fixtures and replay them locally rather than poking production, which is the whole argument in testing webhooks locally.

The failure that keeps recurring on handover projects: a permission_callback of '__return_true' on a POST route, because someone copied the GET registration and changed the method. Grep for it before every launch.

Frequently Asked Questions

Can permission_callback ever be __returntrue?

Yes, for public read-only data that you’d happily put on a public web page. It’s required precisely so you make the decision consciously, and WordPress logs a doingitwrong notice if you omit it. Never use it on a route with POST, PUT, PATCH or DELETE.

Should I use Application Passwords or a JWT plugin?

Application Passwords, unless a non-WordPress service in your architecture already issues and validates the same JWTs. They’re in core, revocable per credential, and show last-used timestamps. JWT plugins add an unrevocable token, a shared secret to protect and a maintenance dependency you don’t control.

Why won’t my CDN cache my REST endpoint?

Because WordPress is sending Cache-Control: no-cache, must-revalidate, max-age=0. Filter restsendnocache_headers to return false for anonymous GET requests on that specific route, then set your own Cache-Control with an s-maxage. Verify with curl -I, not by guessing.

How do I version a custom endpoint without duplicating code?

Put the logic in a plain PHP class or function that knows nothing about REST, then register thin controllers per namespace version that call it and reshape the output. Add Deprecation and Sunset headers to old versions and log their usage so you can retire them with evidence rather than hope.

Is a custom endpoint faster than core’s /wp/v2/posts?

Only if you make it so. The win comes from a tighter query (updateposttermcache off, no unnecessary meta), a smaller payload and caching, not from the route being custom. A naive custom route with postsper_page => -1 will be slower than core’s paginated one.

If you take one thing from this: write the permission callback and the cache policy at the same time you write the route, not in a follow-up ticket. Those two lines decide whether the endpoint survives traffic and whether it leaks something. Go look at the custom routes already in your current project, check each permission_callback, then run curl -I against the GETs and see what headers you’re really sending.

determine_current_user bearer token wordpress rate limiting wp-json nginx limit_req register_rest_route permission_callback example rest_send_nocache_headers caching versioning custom rest api namespace wordpress wordpress api authentication application passwords wordpress rest api custom endpoint wordpress rest api etag 304 response