A REST route is a public contract, even when it is used only by your own block or admin screen. The callback is only one part of that contract. Permissions, input rules, response shape, errors, pagination and caching determine whether the route remains trustworthy in production.
Make the route boring for its consumer: explicit arguments, stable output, useful errors and no hidden authorization assumptions.
Choose a versioned namespace
Use a namespace such as acme/v1. The version is not decoration; it gives you room to introduce a changed contract later without breaking existing clients.
register_rest_route(
'acme/v1',
'/projects',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'projects' ),
'permission_callback' => '__return_true',
'args' => $this->project_args(),
)
);
Authentication and authorization are different
Authentication identifies the requester. Authorization decides whether that requester may perform this action. A logged-in user is not automatically allowed to read private records or change settings.
Public read-only site data can intentionally use __return_true. Administrative routes should check the narrowest relevant capability, not simply whether a user exists.
Describe arguments in the route
Argument definitions create one reviewable place for defaults, sanitization and validation. They also help generated API documentation and reduce callback branching.
'page' => array(
'default' => 1,
'sanitize_callback' => 'absint',
'validate_callback' => static function ( $value ) {
return $value >= 1;
},
),
'per_page' => array(
'default' => 10,
'sanitize_callback' => 'absint',
'validate_callback' => static function ( $value ) {
return $value >= 1 && $value <= 50;
},
),
Return resources, not database internals
A consumer should not need to understand your post-meta naming convention. Build a deliberate response object with the fields the client needs. This also prevents accidental leakage of private metadata.
| Good contract | Fragile contract |
|---|---|
title, summary, url |
Entire raw WP_Post plus all meta |
| ISO date | Server-specific formatted date |
| Documented nullable field | Field disappears unpredictably |
Use meaningful HTTP errors
Return WP_Error with a stable machine-readable code, a safe message and an appropriate status. A validation error, permission error and unavailable dependency should not all look like a generic 500 response.
Paginate and constrain expensive queries
Every collection route needs a hard upper bound. Add total and total-page headers when useful. Avoid unbounded meta queries, and request only the fields or IDs the callback needs.
Cache the query result, not the permission decision
Read-heavy public endpoints can benefit from transients or object caching. Include relevant inputs in the cache key. More importantly, list the events that invalidate the cache: content save, delete, term changes or settings updates.
$key = 'acme_projects_' . md5( wp_json_encode( $args ) );
$data = get_transient( $key );
if ( false === $data ) {
$data = $this->query_projects( $args );
set_transient( $key, $data, 10 * MINUTE_IN_SECONDS );
}
Add operational headers carefully
Pagination headers, cache-state headers in development and a request identifier can make support easier. Never expose secrets, raw SQL or sensitive environment details.
Test the contract from the outside
- Anonymous, subscriber and administrator permissions.
- Missing, malformed, boundary and excessive arguments.
- Empty collections and deleted resources.
- Cache hit, miss and invalidation.
- Consumer behavior when an optional field is absent.
- Response time under realistic data volume.
Final review question
Could another developer build a client against this route using only the contract you expose? If the answer depends on reading the database or guessing permissions, the route is not finished.
Related implementation
See how the ideas map to working code.
The public repository connects the article’s architecture, security, REST, blocks, CLI, privacy, testing and CI concepts to working code.