WordPress ships with a REST API covering posts, pages, users, and other core content out of the box — but real-world integrations (a custom app, a third-party service, a decoupled frontend needing data shaped differently than core's default response) usually need custom endpoints. Registering them correctly matters more than it looks, especially around permissions.
Register routes with register_rest_route, on the right hook
Custom endpoints are registered on the rest_api_init hook, not init — registering too early means the REST infrastructure isn't ready yet and the route silently won't exist.
add_action('rest_api_init', function () {
register_rest_route('myplugin/v1', '/items/(?P<id>\d+)', [
'methods' => 'GET',
'callback' => 'myplugin_get_item',
'permission_callback' => 'myplugin_get_item_permissions',
'args' => [
'id' => [
'validate_callback' => fn ($param) => is_numeric($param),
],
],
]);
});
Namespace your routes (myplugin/v1) rather than registering bare routes at the API root — this avoids collisions with core or other plugins, and gives you a clean place to version the endpoint later.
Never skip permission_callback
This is the most consequential mistake in custom REST endpoints. Omitting permission_callback used to default to public access in older WordPress versions, and even where a default exists, relying on it rather than being explicit is a real security risk — you want to state exactly who can call this endpoint, not inherit a default you didn't choose.
For read endpoints exposing public content, an explicit return true; is fine — the point is that it's a deliberate decision, not an oversight. For anything touching non-public data or performing a write, check capabilities explicitly:
function myplugin_get_item_permissions(WP_REST_Request $request): bool {
return current_user_can('edit_posts');
}
Validate and sanitize every parameter
Use the args schema to validate incoming parameters before your callback runs, rather than validating manually inside the handler — this rejects malformed requests earlier and keeps validation logic declarative and testable. Pair validate_callback (is this input acceptable) with sanitize_callback (clean it before use) rather than trusting raw request input directly in database queries or output.
Return proper WP_REST_Response objects, and use real HTTP status codes
Return WP_REST_Response (or WP_Error for failures) rather than raw arrays where you need control over status codes and headers. A "not found" should return a 404, a permission failure a 403, a validation failure a 400 — clients integrating against your API will branch on status codes, not just parse the body and hope.
if (! $item) {
return new WP_Error('not_found', 'Item not found.', ['status' => 404]);
}
Version your endpoints from the start
Bake a version into the namespace (myplugin/v1) even for a first release. Breaking changes to a custom endpoint are much easier to ship as a new v2 route existing alongside v1 than to coordinate a simultaneous update across every consumer of the API — especially once external integrations depend on it.
Consider authentication beyond core's default
Core's REST API uses cookie authentication (fine for same-origin admin-area JS) or application passwords for external clients. For a headless frontend or third-party integration, application passwords are usually the right default — they're built into core, revocable per-application, and don't require a separate auth plugin. For more advanced needs (fine-grained scopes, OAuth-style flows), that's a deliberate additional layer on top, not something to reach for by default.
Document the shape of your response
If this API is consumed by a separate frontend team or an external integrator, document the response shape explicitly (a simple OpenAPI/JSON schema doc, even informally) rather than leaving it implicit in the code — response shape is a contract, and undocumented contracts break silently when refactored.
The takeaway
Custom REST endpoints in WordPress are straightforward to register — the part worth real care is permission callbacks (never implicit), input validation via the args schema, and proper HTTP semantics in responses. Get those right and the endpoint behaves like a real API, not just a PHP function with a URL attached.
