Building Custom REST API Endpoints in WordPress
How to register a REST route properly: namespaces, permission callbacks, argument validation, response shaping, errors and caching.

The core REST endpoints are general-purpose, which is exactly what makes them awkward for a specific screen. A dashboard needing five fields from three post types shouldn't make three requests and throw away 90% of each response.
Registering your own route fixes that, and it takes very little code. The care goes into the parts that aren't the data.
Namespace and versioning
Register on the rest_api_init hook, in a namespace of your own — something like my-site/v1. Never add routes to wp/v2; that's core's namespace and a future WordPress release can collide with you.
The v1 is not decoration. The moment something external consumes your endpoint, the response shape is a contract. When you need to change it incompatibly, you add v2 and leave v1 working until the consumer has moved. Without a version in the path, your only options are breaking the consumer or never changing the response.
The permission callback
Every route needs one, and it has to genuinely decide something. WordPress warns if you omit it, and the tempting fix is to set it to __return_true to silence the warning — which is how private data ends up publicly readable.
Check capabilities with current_user_can, not roles. A site with custom roles, or a plugin that grants a capability to a role you didn't anticipate, will behave correctly with a capability check and incorrectly with a role check.
- Public read endpoint: __return_true is fine, deliberately, and the response must contain nothing private.
- Reading private or draft content: check a capability like edit_posts.
- Writing: check the specific capability for that object, and for a single object check it against that object's ID.
- Anything destructive: check the capability and require a nonce or an application password, not just a logged-in session.
Validate and sanitise through args
The args array is where you declare every parameter the endpoint accepts: its type, whether it's required, a default, a sanitize_callback and a validate_callback. Do this properly and your main callback can trust its input completely.
It's tempting to skip it and read $request['id'] directly, casting as you go. That works until someone passes an array where you expected an integer, or a string that happens to parse as SQL in a place you forgot to escape.
Declare types precisely. An integer parameter with minimum and maximum bounds rejects nonsense before your code runs. An enum parameter with a fixed list of allowed values makes an invalid state unrepresentable.
Shape the response deliberately
The single biggest long-term mistake is returning raw objects — a WP_Post, a database row, an array from get_post_meta. Do that and your API is coupled to your schema forever: adding a meta field changes your public contract, and renaming one breaks a consumer.
Build the response explicitly. Name each key, pick each value, and convert types as you go. Booleans should be booleans, not the string "1" that WordPress meta returns. Dates should be in a consistent format, ideally ISO 8601 with a timezone.
Return prepared, escaped values appropriate to a JSON consumer. Remember that a non-WordPress frontend receiving rendered HTML has to sanitise it itself — decide whether you're returning HTML or plain text and be consistent.
Errors that tell the caller something
Return a WP_Error with a machine-readable code, a human-readable message, and a status in the data array. WordPress converts that into a proper HTTP status and a JSON body a client can act on.
Use the right status: 400 for a malformed request, 401 when authentication is missing, 403 when the caller is authenticated but not allowed, 404 when the thing genuinely isn't there, 409 for a conflict, 500 only for an actual server failure.
The distinction between 401 and 403 matters more than it seems. A client that gets 401 knows to authenticate and retry. A client that gets 403 knows retrying is pointless. Collapsing both into one status means every consumer has to guess.
Pagination and large collections
- Accept page and per_page parameters with a sane default and an enforced maximum. An endpoint that returns everything will eventually be asked to return everything.
- Send X-WP-Total and X-WP-TotalPages headers so clients can paginate without a second count request.
- For very large or frequently changing collections, cursor-based pagination avoids the duplicate-and-skip problems that offset pagination has when items are inserted mid-iteration.
- Support a fields parameter if consumers only need a subset. It's a few lines and it can cut response size dramatically.
Performance inside the callback
REST responses are generated by PHP on every request and aren't covered by page caching. An endpoint polled by a frontend is a database query running as often as the poll interval.
- Watch for N+1 queries. A loop calling get_post_meta per item is the classic; use update_meta_cache or a single query instead.
- Cache expensive responses in a transient keyed by the parameters that affect them, and invalidate on save_post rather than waiting for expiry.
- Set no_found_rows on queries where you don't need the total count, and limit fields where you only need IDs.
- For public, cacheable GET endpoints, send cache headers and let a CDN absorb the traffic.
Writes need more care
A write endpoint is the one that can damage something. Beyond the capability check, think about idempotency: if a client retries after a timeout, will you create a second record? Accepting a client-supplied idempotency key, or deduplicating on a natural key, prevents the duplicate.
Rate limit public write endpoints. An unauthenticated form submission endpoint without rate limiting is a spam endpoint the day someone finds it.
Log writes with enough context to reconstruct what happened. When an integration misbehaves at 3am, the log is the only evidence you'll have.
Documenting and discovering
WordPress generates a schema for routes that declare one, exposed via an OPTIONS request and in the namespace index. Providing a schema callback means your endpoint is self-describing, and it also enables automatic validation of responses in some tooling.
Even with that, write a short human-readable note somewhere: what the endpoint is for, who consumes it, what authentication it expects, and who to ask when it breaks. Endpoints outlive the people who built them.
A checklist before shipping one
- Own namespace with a version in it.
- A permission callback that checks a capability, not a role.
- Every parameter declared in args with a type, sanitiser and validator.
- A response built explicitly, with consistent types and date formats.
- WP_Error with correct HTTP statuses for every failure path.
- Pagination with an enforced maximum.
- Caching or rate limiting where the traffic warrants it.
- Tested as an unauthenticated user, to confirm it refuses what it should.
Frequently asked questions
Where should I register custom REST routes?
In a plugin, on the rest_api_init hook — not in the theme's functions.php. An endpoint is business logic, and if switching themes would break an integration, the code is in the wrong place. A small site-specific plugin is the right home.
What should permission_callback be for a public endpoint?
__return_true is correct for a genuinely public read endpoint, and you should write it deliberately rather than as a way to silence WordPress's warning. If you find yourself using it on an endpoint that returns anything private or performs a write, that's the signal to stop and check a capability instead.
Why is my custom endpoint returning a 404?
Usually the route isn't registered on the rest_api_init hook, or the registration runs too late. Flushing permalinks can help if the site uses plain permalinks. Check /wp-json/ for your namespace in the index — if it isn't listed, registration isn't running at all.
Should I use the REST API or admin-ajax for my custom functionality?
REST, for anything new. It gives you routing, validation, permission handling, consistent error responses and a discoverable schema, all for less code than the equivalent admin-ajax handler. admin-ajax remains only for legacy code and a few admin-specific cases.
How do I test a custom REST endpoint?
Hit it with curl or an HTTP client as an unauthenticated user first, and confirm it refuses what it should — that's the test people skip. Then test with credentials, with invalid parameters, with missing required parameters, and at the pagination boundaries. WP-CLI and PHPUnit with the WordPress test suite cover the rest.
Topics
- custom REST endpoint WordPress
- register_rest_route
- WordPress API development
- WordPress JSON API