To add a custom REST API endpoint in WordPress, call register_rest_route() on the rest_api_init hook. Here is the whole shape of it: a namespaced route, the methods it answers, the callback that does the work, and the permission_callback that decides who is allowed in:
add_action( 'rest_api_init', 'te_register_routes' );
function te_register_routes() {
register_rest_route( 'te/v1', '/thing/(?P<id>\d+)', array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'te_get_thing',
'permission_callback' => function () {
return current_user_can( 'read' );
},
'args' => array(
'id' => array(
'required' => true,
'validate_callback' => function ( $value ) {
return is_numeric( $value );
},
'sanitize_callback' => 'absint',
),
),
) );
}
function te_get_thing( WP_REST_Request $request ) {
$id = (int) $request->get_param( 'id' );
$thing = get_post( $id );
if ( ! $thing ) {
return new WP_Error(
'te_not_found',
'No thing found for that id.',
array( 'status' => 404 )
);
}
return rest_ensure_response( array(
'id' => $id,
'title' => get_the_title( $thing ),
) );
}That registers GET /wp-json/te/v1/thing/1. The rest of this article walks each piece, with the most weight on permission_callback, because that is the one people leave on the floor and turn a private endpoint into a public one without noticing.
The anatomy: namespace, route, methods
register_rest_route() takes a namespace, a route pattern, and an options array. The signature is register_rest_route( $route_namespace, $route, $args, $override ).
Namespace. The first segment, te/v1, follows the vendor/version convention. The vendor part should be unique to your plugin so it never collides with another plugin's routes or with core's own wp/v2. The version part lets you ship a te/v2 later with breaking changes while te/v1 keeps working for existing clients. Pick a short vendor token and stick with it across all of a plugin's routes.
Route. The /thing/(?P<id>\d+) pattern uses a named capture group. (?P<id>\d+) matches one or more digits and exposes the captured value as a parameter called id, which is why $request->get_param( 'id' ) works in the callback. The \d+ constraint means a request to /thing/abc never reaches your callback at all: it does not match the route, so WordPress returns a 404 before any of your code runs. That is free input filtering, and it is worth designing the pattern tightly for that reason.
Methods. Use the WP_REST_Server constants rather than bare strings so the intent is obvious and typo-proof:
| Constant | HTTP verbs |
|---|---|
WP_REST_Server::READABLE | GET |
WP_REST_Server::CREATABLE | POST |
WP_REST_Server::EDITABLE | POST, PUT, PATCH |
WP_REST_Server::DELETABLE | DELETE |
WP_REST_Server::ALLMETHODS | GET, POST, PUT, PATCH, DELETE |
You can register several method blocks on one route by passing an array of arrays, so a single /thing/(?P<id>\d+) route can answer a readable GET with one callback and an editable PUT with another.
permission_callback: the part people get dangerously wrong
permission_callback is required, and it has been since WordPress 5.5 (August 2020). Omit it and WordPress throws a _doing_it_wrong() notice that reads roughly: the route definition is missing the required permission_callback argument, and for routes that are intended to be public you should use __return_true. The endpoint may still register, but you have shipped an explicit warning that something is wrong.
Here is the trap. The callback's return value is interpreted like this: false, null, or a WP_Error denies access; anything else grants it. So the fix for the notice that most people reach for, dropping in __return_true, does exactly what it says:
// This makes the endpoint fully public. Every visitor, logged in or not.
'permission_callback' => '__return_true',That is the correct thing to write only when the data is genuinely public and you have decided so on purpose. It is the wrong thing to paste in just to silence the notice on an endpoint that exposes anything sensitive. I have read more than one plugin that returned user records or order data through an endpoint guarded by __return_true, because the author wanted the warning gone and __return_true was the first suggestion they found.
For a private endpoint, do a real check. Capability checks are the idiomatic way:
'permission_callback' => function ( WP_REST_Request $request ) {
if ( ! current_user_can( 'edit_posts' ) ) {
return new WP_Error(
'te_forbidden',
'You are not allowed to do that.',
array( 'status' => 403 )
);
}
return true;
},Returning a WP_Error with a status is better than returning a bare false, because it lets you set 401 versus 403 and give the client a usable message instead of a generic refusal.
For a machine-to-machine endpoint with no logged-in user (a webhook, a server calling in), the permission check is where you verify a shared secret or signed header. Keep the comparison constant-time and never log the secret:
'permission_callback' => function ( WP_REST_Request $request ) {
$sent = (string) $request->get_header( 'x-te-api-key' );
$expected = (string) get_option( 'te_api_key' );
if ( '' === $expected || ! hash_equals( $expected, $sent ) ) {
return new WP_Error(
'te_bad_key',
'Invalid API key.',
array( 'status' => 401 )
);
}
return true;
},The one rule that covers all of this: permission_callback is the front door. The callback runs only after it returns truthy, so any check you skip here is a check that does not happen. Treat it as the security boundary it is, not as boilerplate to satisfy the linter.
Validating and sanitizing args
The args map declares the parameters your endpoint accepts and attaches per-parameter handling. Three keys do the heavy lifting: required, validate_callback, and sanitize_callback.
'args' => array(
'id' => array(
'required' => true,
'type' => 'integer',
'validate_callback' => function ( $value, $request, $key ) {
return is_numeric( $value ) && (int) $value > 0;
},
'sanitize_callback' => 'absint',
),
'format' => array(
'required' => false,
'type' => 'string',
'enum' => array( 'short', 'full' ),
'default' => 'short',
'sanitize_callback' => 'sanitize_key',
),
),The order matters. validate_callback runs first: return true and the request proceeds, return false or a WP_Error and WordPress rejects the request with a 400 before your main callback ever runs. sanitize_callback runs after validation passes and transforms the raw value into the clean value your callback receives. So validation answers "is this acceptable?" and sanitization answers "give me the safe, normalized form."
A couple of practical notes. For an enumerated parameter like format, declaring enum lets the schema reject anything outside the list without a custom validator. required => true means a missing parameter is a 400, not a silent default. And do not lean on sanitize_callback to do validation: absint will happily turn "-5" into 5 and "abc" into 0, so a value that should have been rejected gets quietly coerced into something plausible. Validate for correctness, sanitize for safety, and keep the two jobs separate.
Reading params, responses, and errors
Inside the callback, read every parameter through $request->get_param( 'name' ). It pulls from the URL pattern, the query string, and the body in a consistent order, and it returns the sanitized value (the one your sanitize_callback produced), not the raw input. WP_REST_Request also implements array access, so $request['id'] works, but get_param() is clearer about intent.
For the response, wrap your data in rest_ensure_response(). It takes a plain array (or an existing WP_REST_Response) and guarantees you hand back a proper response object:
return rest_ensure_response( array(
'id' => $id,
'title' => get_the_title( $id ),
) );When you need to set a specific status code or add headers, build the WP_REST_Response yourself:
$response = new WP_REST_Response( array( 'created' => $id ), 201 );
$response->header( 'Location', rest_url( "te/v1/thing/{$id}" ) );
return $response;For failures, return a WP_Error rather than a response with an error-shaped body. WordPress maps the status you pass in the data array to the real HTTP status code and serializes the error consistently, so clients get a 404 or a 422 with a machine-readable code instead of a 200 carrying {"error": ...} that every caller has to special-case:
return new WP_Error(
'te_not_found',
'No thing found for that id.',
array( 'status' => 404 )
);Verify it with curl

Once the route is registered, hit it directly. For a public endpoint:
curl -s https://example.com/wp-json/te/v1/thing/1For a private one, authenticate. The simplest path for testing is an application password (Users to Profile to Application Passwords in wp-admin), passed as HTTP Basic auth:
curl -s -u "admin:xxxx xxxx xxxx xxxx xxxx xxxx" \
https://example.com/wp-json/te/v1/thing/1Two quick sanity checks. First, hit https://example.com/wp-json/ with no path: the index lists every registered namespace, and te/v1 should appear in it. If it does not, your rest_api_init hook is not firing (wrong file, plugin not active, typo in the action name). Second, request a bad id like /thing/abc and confirm you get a 404 from the route not matching, which proves the \d+ constraint is doing its job. On a site without pretty permalinks, the same routes are reachable as ?rest_route=/te/v1/thing/1 instead of the /wp-json/ path.
A drop-in plugin
Here is the whole thing as a small plugin you can drop into wp-content/plugins/ (or mu-plugins/) and adapt. It keeps the route definition, the permission check, the arg schema, and the callback in one file:
<?php
/**
* Plugin Name: TE REST Endpoint
* Plugin URI: https://techearl.com/wordpress-custom-rest-api-endpoint
* Description: A minimal, correctly-secured custom REST API endpoint registered with register_rest_route.
* Version: 1.0.0
* Author: Ishan Karunaratne
* Author URI: https://techearl.com
* License: GPL-2.0-or-later
* Text Domain: te-rest-endpoint
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
add_action( 'rest_api_init', 'te_register_routes' );
function te_register_routes() {
register_rest_route( 'te/v1', '/thing/(?P<id>\d+)', array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'te_get_thing',
'permission_callback' => 'te_thing_permissions_check',
'args' => array(
'id' => array(
'required' => true,
'type' => 'integer',
'validate_callback' => function ( $value ) {
return is_numeric( $value ) && (int) $value > 0;
},
'sanitize_callback' => 'absint',
),
),
) );
}
function te_thing_permissions_check( WP_REST_Request $request ) {
if ( ! current_user_can( 'read' ) ) {
return new WP_Error(
'te_forbidden',
'You are not allowed to read this.',
array( 'status' => 403 )
);
}
return true;
}
function te_get_thing( WP_REST_Request $request ) {
$id = (int) $request->get_param( 'id' );
$thing = get_post( $id );
if ( ! $thing || 'publish' !== get_post_status( $thing ) ) {
return new WP_Error(
'te_not_found',
'No thing found for that id.',
array( 'status' => 404 )
);
}
return rest_ensure_response( array(
'id' => $id,
'title' => get_the_title( $thing ),
'link' => get_permalink( $thing ),
) );
}Swap current_user_can( 'read' ) for the real capability your data needs (or the API-key check above for a machine caller), replace the get_post() body with your actual data source, and you have a production-shaped endpoint: registered on the right hook, locked behind a permission check, with validated and sanitized input and well-formed responses and errors.
See also
- Render a dynamic Gutenberg block with a render_callback: the same register-on-a-hook pattern, applied to server-rendered blocks instead of REST routes
- Trim the default output from wp_head: WordPress prints REST API discovery links in the head by default, and this covers what is safe to remove
- An AI-assisted WP-CLI workflow: scripting WordPress from the command line, a natural companion to driving the REST API with curl
- Build a table of contents without a plugin: another do-it-in-a-few-lines WordPress task that skips the plugin and goes straight to the hook
Sources
Authoritative references this article was fact-checked against.
- register_rest_route(): WordPress Developer Referencedeveloper.wordpress.org
- Routes and Endpoints: WordPress REST API Handbookdeveloper.wordpress.org
- rest_api_init hook: WordPress Developer Referencedeveloper.wordpress.org





