Himanshu SharmaSoftware engineer
Let’s talk

WordPress · Lesson 38 of 45

Custom REST API endpoints

Add your own REST API routes with register_rest_route(), with argument validation and sanitising, permission callbacks, proper responses and errors.

15 minData and APIsUpdated 23 October 2026

By the end of this lessonBuild a small JSON API in a plugin that validates its input, checks permissions, and returns clear success and error responses.

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
<?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' ) ] );
}
bash
wp plugin activate bakery-api
result
Plugin 'bakery-api' activated.
Success: Activated 1 of 1 plugins.
http
GET /wp-json/bakery/v1/hello
result
{"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 add v2 without breaking clients.
  • Route /hello: the path below the namespace.
  • methods: GET, POST, PUT/PATCH or DELETE; the constants READABLE, CREATABLE, EDITABLE and DELETABLE are shortcuts.
  • callback: your function; it receives a WP_REST_Request.
  • permission_callback: required. It must return true to 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
<?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 );
}
bash
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 --porcelain
result
4
5
6

Parameters 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.

http
GET /wp-json/bakery/v1/recipes
result
[{"id":6,"title":"Focaccia"},{"id":4,"title":"Rye bread"},{"id":5,"title":"Sourdough loaf"}]
http
GET /wp-json/bakery/v1/recipes?limit=2&search=bread
result
[{"id":4,"title":"Rye bread"}]
http
GET /wp-json/bakery/v1/recipes?limit=500
result
HTTP/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:

http
GET /wp-json/bakery/v1/recipes/99999
result
HTTP/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}}
http
GET /wp-json/bakery/v1/recipes/4
result
{"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:

http
POST /wp-json/bakery/v1/recipes
Content-Type: application/json

{"title": "Anonymous recipe"}
result
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
<?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";
result
201 {"id":7,"level":"medium"}
400 rest_invalid_param
Invalid parameter(s): level

A 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_Response for success: arrays become JSON with status 200. new WP_REST_Response( $data, 201 ) sets another status, and $response->header( 'X-Total', 5 ) adds headers.
  • Return a WP_Error for problems, with 'status' in the data: 404 not found, 400 bad input, 401 not logged in, 403 not allowed.
  • Never echo or die() in a callback; always return.

Common mistakes

  • Missing or __return_true permission callbacks on routes that write data.
  • Trusting parameters without args, or building SQL from them without prepare().
  • Returning HTML where a client expects JSON.
  • No version in the namespace, which makes later changes breaking.
  • Registering routes on init instead of rest_api_init.

Exercise

Add GET /bakery/v1/count that returns { "recipes": <number of published recipes> }.

Show one possible answer
php
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.

Check yourself

1. On which hook do you register routes?
2. What is permission_callback for?
3. How do you return an error with status 404?
4. Why declare 'args' for a route?