Your /wp-json/swiftly/v1/products endpoint takes 420ms to return 20 products. You already have Redis running. You already have a CDN in front of the site. And yet every single request is hitting the database, running posts_clauses filters, and rebuilding the same JSON payload on every call.
That endpoint shouldn’t take more than 8ms on a warm cache. The reason it does is not the REST API — it’s the way most developers register custom routes. They copy a snippet from Stack Overflow, return __return_true in the permission callback, call WP_Query inside the handler, and ship it. Then the client reports “the mobile app is slow” and nobody knows why.
This guide walks through building ONE endpoint the right way, end to end, for PHP 8.3 and WordPress 6.7+. We’re going to build GET /swiftly/v1/products/featured — a featured-products endpoint for a WooCommerce store — and cover every layer a real developer hits in production: schema validation, authentication strategy, HPOS-aware querying, object cache with transient fallback, cache busting, ETag conditional responses, per-user keys, and rate limiting without a plugin.
If you only know register_rest_route('my/v1', '/thing', [...]), you know about 10% of what you need. Let’s fix that.
Why the default REST API falls over at scale
WordPress ships a capable REST API. It doesn’t ship opinions. That means every default is tuned for “works on a $5/month shared host,” not “my headless frontend is about to hit 2 million requests a day.”
Three problems hit almost every team building headless sites or mobile backends on WordPress:
The first is that a naive handler rebuilds its response on every request. The second is that permission_callback is either too loose (__return_true for everything) or copy-pasted from a tutorial that never considered the route’s actual capability requirements. The third is that caching is either skipped entirely or bolted on with set_transient() and no invalidation, which means stale data ships to production and the client opens a bug ticket twelve hours later.
A well-built endpoint solves all three in under 120 lines of code.
The endpoint we’re building
GET /swiftly/v1/products/featured?limit=10&category=beverages
Requirements:
- Returns featured WooCommerce products.
- Supports
limit(1–50, default 10) and optionalcategoryslug. - Public reads, but authenticated requests get unpublished/draft featured products too.
- Response must be cacheable at the edge where safe, with per-user bypass when authenticated.
- Cache must invalidate automatically when a product is saved, deleted, or has its featured status toggled.
- Must be HPOS-safe — no direct wp_posts queries for order data.
- Must return 304 Not Modified on conditional If-None-Match requests.
- Must rate-limit anonymous callers to 60 requests per minute per IP.
This is a realistic spec for a headless storefront or mobile app backend. Everything below is built for this endpoint, but the patterns transfer to anything you register.
Step 1: Register the route with a real schema
Most tutorials show you this:
register_rest_route('swiftly/v1', '/products/featured', [
'methods' => 'GET',
'callback' => 'swiftly_featured_products',
]);
That’s fine for a demo. It’s a mess for production because there’s no schema, no sanitization, no validation, no permission callback, and no version awareness. Here’s the version we actually want:
<?php
declare(strict_types=1);
namespace SwiftlyWP\Api;
add_action('rest_api_init', static function (): void {
register_rest_route(
'swiftly/v1',
'/products/featured',
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [FeaturedProductsController::class, 'handle'],
'permission_callback' => [FeaturedProductsController::class, 'authorize'],
'args' => [
'limit' => [
'description' => 'Max products to return.',
'type' => 'integer',
'default' => 10,
'minimum' => 1,
'maximum' => 50,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
],
'category' => [
'description' => 'Product category slug.',
'type' => 'string',
'default' => '',
'sanitize_callback' => 'sanitize_title',
'validate_callback' => 'rest_validate_request_arg',
],
],
]
);
});
Three things worth noticing. WP_REST_Server::READABLE is more explicit than the string ‘GET’ and will never typo. The args block is doing all your input validation before the callback even runs — if someone passes limit=99999, the REST API rejects it with a 400 and you never touch the database. And rest_validate_request_arg is the built-in validator that respects minimum, maximum, enum, and pattern. Use it. Don’t write your own validators for types the schema already covers.
Step 2: Choose the right authentication method
This is where most posts wave their hands and tell you to “just use a plugin.” The plugin answer is usually wrong. Pick based on the actual use case:
| Use case | Auth method | Why |
|---|---|---|
| Public read-only data | No auth (__return_true) + rate limiting | Don’t add ceremony that brings nothing |
| Browser logged into wp-admin | Cookie + nonce (X-WP-Nonce) | Built in, zero setup, CSRF-safe |
| Server-to-server calls | Application Passwords (HTTP Basic over HTTPS) | Built into core since 5.6, revocable per app |
| Headless frontend or mobile app | JWT via a vetted plugin | Stateless, works cleanly with CORS and edge caches |
| Webhook receivers | HMAC-signed request body | You control the shared secret, no WP users involved |
Application Passwords are the best default for server-to-server in 2026. They’re in core, they’re revocable from the user profile screen, and they work transparently with register_rest_route’s permission callback because WordPress validates them before your callback ever runs.
JWT is still the right call for a decoupled frontend (think a Next.js storefront talking to WordPress). Use a maintained plugin like JWT Auth or build on wp-rest-api-authentication — don’t roll your own token signing. You will get it wrong, and the bug will be silent.
For our endpoint, we’re going to accept anonymous reads, but let authenticated callers see additional data. That requires a permission callback that always allows the request but records whether the caller is authenticated.
Step 3: Write a strict permission callback
<?php
declare(strict_types=1);
namespace SwiftlyWP\Api;
use WP_Error;
use WP_REST_Request;
final class FeaturedProductsController
{
public static function authorize(WP_REST_Request $request): true|WP_Error
{
// Public read is allowed. We still rate-limit anonymous callers.
if (! is_user_logged_in()) {
return RateLimiter::check($request) ?: true;
}
// Logged-in users: require read capability.
if (! current_user_can('read')) {
return new WP_Error(
'swiftly_rest_forbidden',
__('You do not have permission to access this endpoint.', 'swiftlywp'),
['status' => 403]
);
}
return true;
}
public static function handle(WP_REST_Request $request): \WP_REST_Response|WP_Error
{
// Implementation in Step 4.
}
}
A few things to internalise here. permission_callback must return true, false, or a WP_Error. Returning WP_Error gives you full control over the HTTP status and the error code the client sees. Never return 1 or "yes" or whatever — the REST server does loose comparisons in places and you will hit a bug that takes 45 minutes to track down.
If you’re building a write endpoint, the capability check stops being optional. POST /swiftly/v1/products must check current_user_can('edit_products'), not 'read'. And for authenticated write routes, add nonce verification when the client is a browser:
$nonce = $request->get_header('x-wp-nonce');
if (! wp_verify_nonce($nonce, 'wp_rest')) {
return new WP_Error('rest_cookie_invalid_nonce', 'Nonce invalid.', ['status' => 403]);
}
Step 4: The query — HPOS-aware and lean
For products, HPOS doesn’t apply directly (HPOS is about orders), but the principle is the same: query the canonical API, not the database. Use wc_get_products() for WooCommerce data, never WP_Query with post_type => 'product'. The helper respects custom stores, supports HPOS-aligned filters, and is future-proof against WooCommerce refactors.
public static function handle(WP_REST_Request $request): \WP_REST_Response|WP_Error
{
$limit = (int) $request->get_param('limit');
$category = (string) $request->get_param('category');
$is_auth = is_user_logged_in();
$cache_key = self::buildCacheKey($limit, $category, $is_auth);
// Try cache first.
$payload = Cache::get($cache_key);
if ($payload === false) {
$payload = self::buildPayload($limit, $category, $is_auth);
Cache::set($cache_key, $payload, MINUTE_IN_SECONDS * 15);
}
return self::respond($payload, $request);
}
private static function buildPayload(int $limit, string $category, bool $is_auth): array
{
$args = [
'limit' => $limit,
'featured' => true,
'status' => $is_auth ? ['publish', 'draft', 'private'] : 'publish',
'return' => 'objects',
];
if ($category !== '') {
$args['category'] = [$category];
}
$products = wc_get_products($args);
return array_map(
static fn(\WC_Product $p): array => [
'id' => $p->get_id(),
'slug' => $p->get_slug(),
'name' => $p->get_name(),
'price' => $p->get_price(),
'currency' => get_woocommerce_currency(),
'image' => wp_get_attachment_image_url($p->get_image_id(), 'medium'),
'permalink' => $p->get_permalink(),
'on_sale' => $p->is_on_sale(),
'stock' => $p->get_stock_status(),
],
$products
);
}
This is what “lean” looks like. We’re not returning the entire WC_Product object. We’re projecting into a shape the client actually needs. Every byte you don’t return is a byte you don’t encode, compress, transfer, parse, and garbage-collect on the client.
Step 5: Layered caching — object cache first, transient second
Here’s the cache helper. It’s short on purpose:
<?php
declare(strict_types=1);
namespace SwiftlyWP\Api;
final class Cache
{
private const GROUP = 'swiftly_rest_v1';
public static function get(string $key): mixed
{
if (wp_using_ext_object_cache()) {
return wp_cache_get($key, self::GROUP);
}
return get_transient(self::GROUP . '_' . $key);
}
public static function set(string $key, mixed $value, int $ttl): void
{
if (wp_using_ext_object_cache()) {
wp_cache_set($key, $value, self::GROUP, $ttl);
return;
}
set_transient(self::GROUP . '_' . $key, $value, $ttl);
}
public static function delete(string $key): void
{
if (wp_using_ext_object_cache()) {
wp_cache_delete($key, self::GROUP);
return;
}
delete_transient(self::GROUP . '_' . $key);
}
public static function flushGroup(): void
{
if (wp_using_ext_object_cache() && function_exists('wp_cache_flush_group')) {
wp_cache_flush_group(self::GROUP);
return;
}
// Transient fallback — invalidate version key used by buildCacheKey().
update_option('swiftly_rest_cache_version', (int) get_option('swiftly_rest_cache_version', 1) + 1);
}
}
Why two layers? Because set_transient() on a site without persistent object cache stores to the wp_options table. That table is already a performance liability on big sites — every autoloaded option is loaded on every request. A thousand transient keys in there is a bad week waiting to happen. On sites with Redis or Memcached (wp_using_ext_object_cache() returns true), we use the object cache directly, which is both faster and doesn’t touch the database.
The flushGroup() pattern deserves attention. wp_cache_flush_group() is only reliable on modern drop-ins (Redis Object Cache 2.4+). On older setups, or when falling back to transients, we bump a version number that’s baked into every cache key:
private static function buildCacheKey(int $limit, string $category, bool $is_auth): string
{
$version = (int) get_option('swiftly_rest_cache_version', 1);
$user = $is_auth ? get_current_user_id() : 0;
return sprintf(
'featured_v%d_u%d_l%d_c%s',
$version,
$user,
$limit,
$category !== '' ? $category : 'all'
);
}
When the version bumps, every old key becomes orphaned and every new request rebuilds fresh. It’s the cheapest, most reliable cache invalidation pattern in WordPress.
Step 6: Bust the cache on the right hooks
Stale cache is the number one cause of “it’s broken in production but works on staging” tickets. Invalidate on every hook that can affect featured products:
add_action('woocommerce_update_product', [Cache::class, 'flushGroup']);
add_action('woocommerce_delete_product', [Cache::class, 'flushGroup']);
add_action('woocommerce_trash_product', [Cache::class, 'flushGroup']);
// Featured-status toggle doesn't always fire the above on every setup.
add_action('updated_post_meta', static function (int $meta_id, int $post_id, string $key): void {
if ($key === '_featured' && get_post_type($post_id) === 'product') {
Cache::flushGroup();
}
}, 10, 3);
// Category changes.
add_action('set_object_terms', static function (int $object_id, array $terms, array $tt_ids, string $taxonomy): void {
if ($taxonomy === 'product_cat' && get_post_type($object_id) === 'product') {
Cache::flushGroup();
}
}, 10, 4);
Don’t use save_post alone. WooCommerce fires woocommerce_update_product after it finishes its internal bookkeeping, and that’s the hook you actually want. save_post fires while the product is half-written and your rebuilt cache will contain a stale price.
Step 7: ETags and 304 conditional responses
Cache invalidation solves the server side. ETags solve the bandwidth side. A client that has the current version of your response shouldn’t have to download it again:
private static function respond(array $payload, WP_REST_Request $request): \WP_REST_Response
{
$body = wp_json_encode($payload);
$etag = '"' . md5($body) . '"';
$client_etag = $request->get_header('if_none_match');
if ($client_etag !== null && trim($client_etag) === $etag) {
$response = new \WP_REST_Response(null, 304);
$response->header('ETag', $etag);
$response->header('Cache-Control', 'public, max-age=60, s-maxage=300');
return $response;
}
$response = new \WP_REST_Response($payload, 200);
$response->header('ETag', $etag);
$response->header('Cache-Control', 'public, max-age=60, s-maxage=300');
$response->header('Vary', 'Authorization');
return $response;
}
Vary: Authorization is critical the moment your endpoint varies by auth state. Without it, your CDN can serve an authenticated caller’s response to an anonymous one. That’s a data leak, not a performance bug.
Step 8: Per-user cache keys when auth matters
We already built this into buildCacheKey() — the u%d segment is the current user ID, which becomes u0 for anonymous callers. This keeps one cache pool shared across all anonymous visitors (which is what you want on a public endpoint with CDN amplification) while giving every authenticated user their own isolated payload.
Never, ever store an authenticated response under a shared cache key. A single logged-in admin hitting the endpoint once will poison the anonymous cache, and the next 5,000 anonymous visitors will see draft and private products. I have watched this happen on live sites. It is always the same bug.
Step 9: Rate limiting without a plugin
Rate limiting in PHP is fine for anonymous reads on most traffic levels. Use the object cache so counters don’t hit the database:
<?php
declare(strict_types=1);
namespace SwiftlyWP\Api;
use WP_Error;
use WP_REST_Request;
final class RateLimiter
{
private const LIMIT = 60; // requests
private const WINDOW = 60; // seconds
private const GROUP = 'swiftly_rest_rl';
public static function check(WP_REST_Request $request): ?WP_Error
{
$ip = self::clientIp();
$key = 'rl_' . md5($ip);
$hits = (int) wp_cache_get($key, self::GROUP);
if ($hits >= self::LIMIT) {
return new WP_Error(
'swiftly_rest_rate_limited',
__('Rate limit exceeded. Try again in a moment.', 'swiftlywp'),
['status' => 429]
);
}
wp_cache_set($key, $hits + 1, self::GROUP, self::WINDOW);
return null;
}
private static function clientIp(): string
{
// Honour the first trusted proxy header if set. In production, configure this
// list to match your actual proxies (Cloudflare, Kinsta, WP Engine).
foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'] as $key) {
if (! empty($_SERVER[$key])) {
$ip = explode(',', (string) $_SERVER[$key])[0];
return filter_var(trim($ip), FILTER_VALIDATE_IP) ?: '0.0.0.0';
}
}
return '0.0.0.0';
}
}
This is not a replacement for Cloudflare or fail2ban — it’s a safety net that keeps casual scrapers from hammering an endpoint. If you’re running behind a CDN, the CDN’s rate-limit rules will always be faster and smarter than anything PHP can do.
Never trust $_SERVER['HTTP_X_FORWARDED_FOR'] blindly. Anyone can set that header on a direct connection. The code above is safe as long as your proxy layer strips client-set forwarded headers before they reach WordPress — verify this on staging before relying on it.
Step 10: Return errors with WP_Error, not exceptions
WP_Error is the REST API’s native error type. Use it:
if ($limit > 50) {
return new WP_Error(
'swiftly_rest_limit_too_high',
__('Limit cannot exceed 50.', 'swiftlywp'),
['status' => 400]
);
}
The ['status' => 400] part is what maps the error to an HTTP status code. Forget it and the REST API defaults to 500, which is wrong for a client validation error and will trigger every monitoring alert you have.
Never throw exceptions from a REST callback without catching them first. Uncaught exceptions turn into PHP fatals, which in production mean a blank 500 and a very grumpy client.
Testing your endpoint
curl is fine for smoke tests:
# Anonymous request
curl -i "https://example.com/wp-json/swiftly/v1/products/featured?limit=5"
# Authenticated via Application Password
curl -i -u "admin:xxxx xxxx xxxx xxxx xxxx xxxx" \
"https://example.com/wp-json/swiftly/v1/products/featured?limit=5"
# Conditional request (should return 304 on the second hit)
curl -i -H 'If-None-Match: "abc123..."' \
"https://example.com/wp-json/swiftly/v1/products/featured?limit=5"
For real tests, write them. WordPress core has a full REST API test harness in WP_Test_REST_Controller_Testcase. Your CI should spin up WordPress, fire a request, assert the status code, and assert the shape of the response. A handful of these tests catches 80% of regressions before they reach staging.
Common mistakes we see in audits
Over the last six months, these are the bugs we’ve found on almost every headless WordPress build we’ve audited:
__return_true on write endpoints. It happens more than you’d think. Someone was prototyping, it worked, and nobody came back to fix the permission callback. An attacker discovered the route from OPTIONS /wp-json/ and happily created products.
Caching the entire response including the nonce. The nonce changes every 12–24 hours. If you cached the response with a nonce in it, every client gets the same dead nonce and writes start silently failing.
Querying wp_posts for orders. HPOS has been the default in WooCommerce for over a year. If you’re filtering post_type = shop_order, your code returns wrong data on any store with HPOS enabled. Use wc_get_orders() exclusively.
Returning raw database columns. Calling ->to_array() on a WC_Product returns things the client has no business knowing, including internal meta keys. Always project into a shape you designed.
Forgetting Vary headers. Without them, your CDN will cross-contaminate cached responses between different request contexts.
Rolling their own JWT implementation. Don’t. Use a maintained plugin.
What to do next
Pull the code blocks in this post into a single controller class. Wire up the cache helpers. Hook the invalidation actions. Add the rate limiter. Run it on staging with a load-testing tool — wrk or k6 will show you whether the cache is actually hitting. You want to see the second request return in under 15ms with the object cache warm.
Then write one REST integration test per code path: happy path, auth failure, invalid input, cache hit, cache miss, rate limit. Six tests, twenty minutes of work, a decade of saved debugging.
Need custom WordPress work done without the agency overhead? Our WordPress Plugin Customization and Custom WordPress Development services are built for exactly this — production-grade REST endpoints, headless integrations, and the boring caching work that keeps them fast.