When to add your own endpoint
The core routes cover posts, terms and users. Your plugin may need something else: a "like" button, a search suggestion list, a form submission, data for a JavaScript app. A custom endpoint gives that a clean JSON address under your own namespace, such as /wp-json/bakery/v1/recipes. It replaces the older admin-ajax.php approach.
A first endpoint
<?php
/**
* Plugin Name: Bakery API
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
add_action( 'rest_api_init', function () {
register_rest_route( 'bakery/v1', '/hello', [
'methods' => WP_REST_Server::READABLE, // GET
'callback' => 'bakery_api_hello',
'permission_callback' => '__return_true', // public on purpose
] );
} );
function bakery_api_hello( WP_REST_Request $request ) {
return rest_ensure_response( [ 'message' => 'Hello from the bakery', 'site' => get_bloginfo( 'name' ) ] );
}wp plugin activate bakery-apiPlugin 'bakery-api' activated.
Success: Activated 1 of 1 plugins.GET /wp-json/bakery/v1/hello{"message":"Hello from the bakery","site":"My WordPress Site"}The pieces:
- Namespace
bakery/v1: your plugin's name and an API version, so you can later addv2without breaking clients. - Route
/hello: the path below the namespace. methods:GET,POST,PUT/PATCHorDELETE; the constantsREADABLE,CREATABLE,EDITABLEandDELETABLEare shortcuts.callback: your function; it receives aWP_REST_Request.permission_callback: required. It must returntrueto allow the request.'__return_true'makes a route public; use it only for data that really is public. Leaving it out makes WordPress log a "doing it wrong" notice.
Parameters: validation and sanitising
Routes usually take input, from the query string, the URL itself, or a JSON body. Describe each parameter in args, and WordPress checks them before your callback runs:
<?php
/**
* Plugin Name: Bakery API
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
add_action( 'init', function () {
register_post_type( 'recipe', [ 'public' => true, 'label' => 'Recipes', 'supports' => [ 'title', 'editor' ], 'show_in_rest' => true ] );
} );
add_action( 'rest_api_init', function () {
// GET /bakery/v1/recipes?limit=2&search=rye
register_rest_route( 'bakery/v1', '/recipes', [
[
'methods' => WP_REST_Server::READABLE,
'callback' => 'bakery_api_list',
'permission_callback' => '__return_true',
'args' => [
'limit' => [
'type' => 'integer',
'default' => 10,
'minimum' => 1,
'maximum' => 50,
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'absint',
],
'search' => [
'type' => 'string',
'default' => '',
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'sanitize_text_field',
],
],
],
[
'methods' => WP_REST_Server::CREATABLE, // POST
'callback' => 'bakery_api_create',
'permission_callback' => fn() => current_user_can( 'publish_posts' ),
'args' => [
'title' => [ 'type' => 'string', 'required' => true, 'minLength' => 3, 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'sanitize_text_field' ],
'level' => [ 'type' => 'string', 'enum' => [ 'easy', 'medium', 'hard' ], 'default' => 'easy' ],
],
],
] );
// GET /bakery/v1/recipes/12 — a number in the address
register_rest_route( 'bakery/v1', '/recipes/(?P<id>\d+)', [
'methods' => WP_REST_Server::READABLE,
'callback' => 'bakery_api_get',
'permission_callback' => '__return_true',
'args' => [ 'id' => [ 'type' => 'integer', 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'absint' ] ],
] );
} );
function bakery_api_list( WP_REST_Request $request ) {
$query = new WP_Query( [
'post_type' => 'recipe',
's' => $request['search'],
'posts_per_page' => $request['limit'],
'orderby' => 'title',
'order' => 'ASC',
'no_found_rows' => true,
] );
$items = array_map( fn( $post ) => [ 'id' => $post->ID, 'title' => get_the_title( $post ) ], $query->posts );
return rest_ensure_response( $items );
}
function bakery_api_get( WP_REST_Request $request ) {
$post = get_post( $request['id'] );
if ( ! $post || 'recipe' !== $post->post_type || 'publish' !== $post->post_status ) {
return new WP_Error( 'bakery_not_found', 'No recipe with that ID.', [ 'status' => 404 ] );
}
return [ 'id' => $post->ID, 'title' => get_the_title( $post ), 'content' => apply_filters( 'the_content', $post->post_content ) ];
}
function bakery_api_create( WP_REST_Request $request ) {
$id = wp_insert_post( [
'post_type' => 'recipe',
'post_title' => $request['title'],
'post_status' => 'draft',
], true );
if ( is_wp_error( $id ) ) {
return $id;
}
update_post_meta( $id, '_level', $request['level'] );
return new WP_REST_Response( [ 'id' => $id, 'level' => $request['level'] ], 201 );
}wp post create --post_type=recipe --post_title="Rye bread" --post_content="Rye flour, water, salt." --post_status=publish --porcelain
wp post create --post_type=recipe --post_title="Sourdough loaf" --post_status=publish --porcelain
wp post create --post_type=recipe --post_title="Focaccia" --post_status=publish --porcelain4
5
6Parameters declared in args can be read like an array from the request ($request['limit']). Their sources are checked in order: URL parts ((?P<id>\d+)), the JSON body, then the query string. The schema keys type, minimum, maximum, minLength, enum and required describe what is valid. WordPress checks them automatically only when an argument has no sanitize_callback of its own; as soon as you add one, you must also name 'validate_callback' => 'rest_validate_request_arg' (as above) to keep the schema checks. You can also write your own validate function, which returns true or a WP_Error.
GET /wp-json/bakery/v1/recipes[{"id":6,"title":"Focaccia"},{"id":4,"title":"Rye bread"},{"id":5,"title":"Sourdough loaf"}]GET /wp-json/bakery/v1/recipes?limit=2&search=bread[{"id":4,"title":"Rye bread"}]GET /wp-json/bakery/v1/recipes?limit=500HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=UTF-8
Allow: GET
{"code":"rest_invalid_param","message":"Invalid parameter(s): limit","data":{"status":400,"params":{"limit":"limit must be between 1 (inclusive) and 50 (inclusive)"},"details":{"limit":{"code":"rest_out_of_bounds","message":"limit must be between 1 (inclusive) and 50 (inclusive)","data":null}}}}The last request asked for more than maximum, and WordPress answered with a 400 and the reason, without running your callback. A route for one item shows the error path:
GET /wp-json/bakery/v1/recipes/99999HTTP/1.1 404 Not Found
Content-Type: application/json; charset=UTF-8
Allow: GET
{"code":"bakery_not_found","message":"No recipe with that ID.","data":{"status":404}}GET /wp-json/bakery/v1/recipes/4{"id":4,"title":"Rye bread","content":"<p>Rye flour, water, salt.<\/p>\n"}Permissions
The permission_callback decides who may call the route. The POST route above requires the publish_posts capability, so an anonymous request is refused with 401, and a logged-in subscriber with 403:
POST /wp-json/bakery/v1/recipes
Content-Type: application/json
{"title": "Anonymous recipe"}HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=UTF-8
Allow: GET
{"code":"rest_forbidden","message":"Sorry, you are not allowed to do that.","data":{"status":401}}Test the same route as the administrator through PHP, including validation of a bad level:
<?php
wp_set_current_user( 1 );
$ok = new WP_REST_Request( 'POST', '/bakery/v1/recipes' );
$ok->set_header( 'Content-Type', 'application/json' );
$ok->set_body( wp_json_encode( [ 'title' => 'Ciabatta', 'level' => 'medium' ] ) );
$response = rest_do_request( $ok );
echo $response->get_status(), ' ', wp_json_encode( $response->get_data() ), "\n";
$bad = new WP_REST_Request( 'POST', '/bakery/v1/recipes' );
$bad->set_header( 'Content-Type', 'application/json' );
$bad->set_body( wp_json_encode( [ 'title' => 'Baguette', 'level' => 'impossible' ] ) );
$response = rest_do_request( $bad );
echo $response->get_status(), ' ', $response->get_data()['code'], "\n";
echo $response->get_data()['message'], "\n";201 {"id":7,"level":"medium"}
400 rest_invalid_param
Invalid parameter(s): levelA permission check says who the user is allowed to be; for actions on a particular object, check that object in the callback (current_user_can( 'edit_post', $id )). Requests from a logged-in browser must also send the nonce: wp.apiFetch does it, and without it WordPress treats the request as logged out.
Responses and errors
- Return an array, object or
WP_REST_Responsefor success: arrays become JSON with status200.new WP_REST_Response( $data, 201 )sets another status, and$response->header( 'X-Total', 5 )adds headers. - Return a
WP_Errorfor problems, with'status'in the data:404not found,400bad input,401not logged in,403not allowed. - Never
echoordie()in a callback; always return.
Common mistakes
- Missing or
__return_truepermission callbacks on routes that write data. - Trusting parameters without
args, or building SQL from them withoutprepare(). - Returning HTML where a client expects JSON.
- No version in the namespace, which makes later changes breaking.
- Registering routes on
initinstead ofrest_api_init.
Exercise
Add GET /bakery/v1/count that returns { "recipes": <number of published recipes> }.
Show one possible answer
add_action( 'rest_api_init', function () {
register_rest_route( 'bakery/v1', '/count', [
'methods' => 'GET',
'permission_callback' => '__return_true',
'callback' => fn() => [ 'recipes' => (int) wp_count_posts( 'recipe' )->publish ],
] );
} );wp_count_posts( 'recipe' ) returns counts per status.
Key points
- register_rest_route( 'namespace/v1', '/route', $args ) on the rest_api_init hook adds an endpoint at /wp-json/namespace/v1/route.
- Every route needs a callback and a permission_callback; use '__return_true' only for truly public data.
- The args array validates and sanitises each parameter (type, required, enum, validate_callback, sanitize_callback) before your callback runs.
- Return data with rest_ensure_response() or WP_REST_Response, and problems with WP_Error and an HTTP status.