REST API: Extending It

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

Any plugin can add REST routes next to core’s wp/v2, with the same validation, permission checks, discovery and linking. This page, for plugin developers, explains how a route is registered and dispatched, then builds the lumen/v1 API of lumen-catalog, the catalogue plugin of the fictitious publisher Lumen Press: a read-only books catalogue for apps and a loans resource with full create, read, update and delete, both as WP_REST_Controller subclasses, plus a stock field and a response filter on core’s own lp_book endpoint. Consuming the API (discovery, _fields, _embed, authentication, apiFetch) is covered in REST API: Using It.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, WPCS 3.4.1 and WP-CLI 2.12.0, and checked on 2026-10-28; WordPress 7.2 (in beta, planned for 8 December 2026) changes nothing here. register_rest_route() arrived in 4.4, WP_REST_Controller and register_rest_field() in 4.7, the mandatory permission_callback in 5.5. Every response was recorded on the standard local stack (see Local Development Environments) with the users maria.editor (Editor, ID 2) and tom.author (Author, ID 3) and their application passwords, over plain HTTP (allowed because WP_ENVIRONMENT_TYPE is local), and is shown with the canonical https://lumenpress.test address.

From URL to callback

A route is a URL pattern under /wp-json/, such as /lumen/v1/loans/(?P<id>[\d]+); an endpoint is one HTTP method on a route with its callback. WP_REST_Server collects the routes when the first REST request of a page load fires rest_api_init, then dispatches each request in a fixed order:

  1. Match the path against every route (anchored regular expressions, case-insensitive) and the method against its endpoints; no match gives 404 rest_no_route.

  2. Check the arguments: missing required ones give 400 rest_missing_callback_param, invalid ones 400 rest_invalid_param; then sanitize them.

  3. Run the permission_callback; false gives rest_forbidden with 401 (anonymous) or 403 (logged in), and a WP_Error is returned as is.

  4. Run the callback, wrap its result with rest_ensure_response(), then apply _fields, _embed and the envelope.

Arguments are checked before permissions, so an anonymous request with a bad body gets 400, not 401; the schema is public anyway, through OPTIONS and the index. The filters rest_pre_dispatch, rest_request_before_callbacks, rest_dispatch_request, rest_request_after_callbacks and rest_post_dispatch open each stage to plugins.

Registering a route

register_rest_route( $route_namespace, $route, $args = array(), $override = false ) must run on rest_api_init, which fires only for REST requests, so routes cost nothing on ordinary page views. The $args of one endpoint:

Key Meaning

methods

A string ('GET', 'POST, PUT') or array; WP_REST_Server::READABLE is GET, CREATABLE POST, EDITABLE POST, PUT, PATCH, DELETABLE DELETE, ALLMETHODS all five. GET also answers HEAD.

callback

Receives the WP_REST_Request; returns data, a WP_REST_Response or a WP_Error.

permission_callback

Receives the request; returns true, false or a WP_Error. Mandatory since 5.5.

args

Per-argument definitions (next section). Set at route level, outside the endpoint arrays, they apply to every endpoint.

schema

Callback returning the resource’s JSON Schema, shown by OPTIONS and used by _fields.

show_in_index

false hides the route from /wp-json/ and the namespace index.

$override

true replaces a route registered earlier with the same path instead of merging the endpoints.

A route can hold several endpoints (an array of endpoint arrays plus a shared args and schema, as below). Path parameters are named regex groups: (?P<id>[\d]+) puts the value in $request['id'].

Namespaces and versioning

The first segment is the vendor (plugin or theme slug, short and unique), the second the version: lumen/v1. Core uses wp/v2, wp-site-health/v1 and wp-block-editor/v1, so never register under wp/. A breaking change (a renamed field, a different type, a removed route) goes to lumen/v2, registered next to v1 until clients migrate; additive changes (new fields, new optional arguments) stay in v1. GET /wp-json/lumen/v1 lists the namespace’s routes.

Mistakes core reports

register_rest_route() calls _doing_it_wrong() for an empty namespace or route, a namespace with a leading or trailing slash, a non-array argument definition (6.1), a call outside rest_api_init (5.1) and a missing permission_callback (5.5). The route is still registered in the last two cases. A test script, run with wp eval-file, prints each notice from the doing_it_wrong_run action: it registers /lumen/v1/ping on rest_api_init without a permission callback and /lumen/v1/early at file load, then calls /lumen/v1/ping as user 0 through rest_do_request():

$ wp eval-file check-routes.php
doing_it_wrong: register_rest_route (5.1.0): REST API routes must be registered on the rest_api_init action. Instead route '/early' with namespace 'lumen/v1' was not registered on this action.
doing_it_wrong: register_rest_route (5.5.0): The REST API route definition for lumen/v1/ping is missing the required permission_callback argument. For REST API routes that are intended to be public, use __return_true as the permission callback.
anonymous /lumen/v1/ping: 200 "pong"

A route without permission_callback is public: the notice appears only with debugging on, and the anonymous call succeeds. The early call also starts the REST server, which fires rest_api_init at that moment; an add_action() placed after it would never run in that request.

Arguments: validation and sanitization

Each entry of args describes one parameter, wherever it comes from (path, query string, form body or JSON body):

Key Meaning

type

string, integer, number, boolean, array, object, null, or an array of types (5.3). Strings such as "5" are coerced.

required, default

A missing required argument stops the request; default fills an absent one.

enum, minimum, maximum, exclusiveMinimum, multipleOf

Value constraints (multipleOf 5.6).

minLength, maxLength, pattern

String constraints (5.5). pattern is an ECMA-style regex without delimiters.

format

Validated and sanitized: date-time, email, ip, uuid and hex-color. Sanitized only: uri, text-field and textarea-field (5.9). There is no date or hostname format.

items, properties, minItems, uniqueItems, anyOf, oneOf

Array and object schemas (anyOf/oneOf 5.6).

validate_callback

( $value, $request, $param ), returns true or a WP_Error.

sanitize_callback

( $value, $request, $param ), returns the clean value.

Without callbacks, an argument that has a type gets rest_parse_request_arg(), which validates against the schema keywords and then sanitizes. A custom validate_callback replaces that schema check, so call rest_validate_request_arg() inside it first. Every $request[...] read in the callback is the sanitized value; the raw body stays in get_body(). Escaping is still the job of whoever prints the data.

Permission callbacks

The permission callback answers "may this user do this to this resource?", with capabilities, never with roles:

  • __return_true only for data that is public by nature, such as published books.

  • current_user_can() on a specific capability, or on a meta capability with an object ID (edit_lp_book, 5), so map_meta_cap and ownership apply (see Roles and Capabilities API).

  • A WP_Error with rest_authorization_required_code() as status: 401 for anonymous callers, 403 for logged-in ones. For an unknown object, 404 tells the client more than 403.

Authentication happens before routing, and core checks the wp_rest nonce of cookie requests (without it the request runs as user 0). Keep capability checks out of the main callback: core also calls the permission callbacks to build the Allow header and the targetHints of links (6.7).

Requests, responses and errors

Class or function What it offers

WP_REST_Request

$request['name'] or get_param() (path, then JSON body, form body, query string, defaults), get_params(), get_url_params(), get_query_params(), get_body_params(), get_json_params(), get_file_params(), get_header(), get_method(), get_route(), has_param() (5.3), set_param().

WP_REST_Response

set_status(), header(), set_data(), get_data(), add_link(), add_links(), link_header(). Extends WP_HTTP_Response.

WP_Error

new WP_Error( 'lp_code', 'Message', array( 'status' => 400 ) ); the code becomes code, data.status the HTTP status (500 if absent).

rest_ensure_response()

Wraps plain data in a 200 WP_REST_Response; passes responses and errors through.

rest_do_request()

Runs a request inside PHP, with the same validation and permissions, without HTTP.

rest_url()

Absolute URL of a route, correct with plain permalinks too.

Error codes are part of the contract: prefix them (lp_loan_not_found) and keep them stable within a version.

The lumen/v1 API

Route Methods Owner

/lumen/v1/books, /lumen/v1/books/<id>

GET

BooksController, this page

/lumen/v1/loans, /lumen/v1/loans/<id>

GET, POST, PUT/PATCH, DELETE

LoansController, this page

/lumen/v1/books/<id>/read

POST

JavaScript, AJAX and Heartbeat

/lumen/v1/books/<id>/stock

PUT

Roles and Capabilities API

/lumen/v1/wishlist, /lumen/v1/wishlist/<book>

GET, PUT, DELETE

Interactivity API

The routes do not clash: core anchors every pattern, so /books/(?P<id>[\d]+) never matches /books/4/read.

Loans record review and event copies lent to reviewers, bookshops and festivals. They get their own table, {prefix}lp_loans, rather than rows in lp_stock: the stock table is the movement history of stock levels (one row per change, with the current level kept in the lp_stock post meta), while a loan is a record with its own dates and lifecycle, and a post type would put non-content records in wp_posts. Installer::DB_VERSION goes from 2 to 3, and upgrade() gains a step if ( $installed < 3 ) { self::create_loans_table(); }, which dbDelta() makes safe to repeat:

private static function create_loans_table(): void {
	global $wpdb;
	require_once ABSPATH . 'wp-admin/includes/upgrade.php';

	$table   = $wpdb->prefix . 'lp_loans';
	$charset = $wpdb->get_charset_collate();

	dbDelta(
		"CREATE TABLE {$table} (
  id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  book_id bigint(20) unsigned NOT NULL,
  borrower varchar(191) NOT NULL DEFAULT '',
  copies smallint(5) unsigned NOT NULL DEFAULT 1,
  loaned_on date NOT NULL,
  due_on date DEFAULT NULL,
  returned_on date DEFAULT NULL,
  created_by bigint(20) unsigned NOT NULL DEFAULT 0,
  PRIMARY KEY  (id),
  KEY book_id (book_id)
) {$charset};"
	);
}

The rest of Installer (uninstall must also drop lp_loans) is in Plugin Fundamentals. After the upgrade, lumen_catalog_db_version is 3 and wp_lp_loans sits next to wp_lp_stock.

Wiring

Plugin::boot() calls Rest::boot(), which registers both controllers, the lp_stock field and the response filter (methods of this class shown in Adding a field to a core endpoint and Modifying core responses), and a CURIE so that link relations under https://lumenpress.example/rels/ print as lumen:book:

<?php
namespace Lumen\Catalog\Rest;

defined( 'ABSPATH' ) || exit;

final class Rest {

	const REL = 'https://lumenpress.example/rels/';

	public static function boot(): void {
		add_action( 'rest_api_init', array( self::class, 'register' ) );
		add_filter( 'rest_response_link_curies', array( self::class, 'curies' ) );
		add_filter( 'rest_prepare_lp_book', array( self::class, 'prepare_book' ), 10, 3 );
	}

	public static function register(): void {
		( new BooksController() )->register_routes();
		( new LoansController() )->register_routes();
		self::register_stock_field();
	}

	public static function curies( array $curies ): array {
		$curies[] = array( 'name' => 'lumen', 'href' => self::REL . '{rel}', 'templated' => true );
		return $curies;
	}
}

A controller: lumen/v1/loans

WP_REST_Controller (4.7) is the abstract base of every core endpoint. Subclassing it gives a standard shape (register_routes(), get_items()/get_item()/create_item()/update_item()/delete_item(), a *_permissions_check() per method, get_item_schema(), prepare_item_for_response()) and helpers that turn the schema into arguments, filter by context and apply fields added with register_rest_field(). The file is src/Rest/LoansController.php, condensed here: docblocks are omitted and short arrays stay on one line, which phpcbf expands before WPCS passes.

Routes and permissions

<?php
namespace Lumen\Catalog\Rest;

// phpcs:disable WordPress.DB.DirectDatabaseQuery -- custom table, no WP_Query equivalent.

use WP_Error;
use WP_REST_Controller;
use WP_REST_Response;
use WP_REST_Server;

defined( 'ABSPATH' ) || exit;

final class LoansController extends WP_REST_Controller {

	public function __construct() {
		$this->namespace = 'lumen/v1';
		$this->rest_base = 'loans';
	}

	public function register_routes(): void {
		$item_args = array( 'context' => $this->get_context_param( array( 'default' => 'view' ) ) );

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				$this->endpoint( WP_REST_Server::READABLE, 'get_items', 'edit_others_lp_books', $this->get_collection_params() ),
				$this->endpoint( WP_REST_Server::CREATABLE, 'create_item', 'edit_others_lp_books', $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ) ),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[\d]+)',
			array(
				'args'   => array( 'id' => array( 'type' => 'integer' ) ), // Shared by the three endpoints.
				$this->endpoint( WP_REST_Server::READABLE, 'get_item', 'edit_others_lp_books', $item_args ),
				$this->endpoint( WP_REST_Server::EDITABLE, 'update_item', 'edit_others_lp_books', $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ) ),
				$this->endpoint( WP_REST_Server::DELETABLE, 'delete_item', 'delete_others_lp_books' ), // Erasing history.
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
	}

	private function endpoint( string $methods, string $action, string $cap, array $args = array() ): array {
		return array(
			'methods'             => $methods,
			'callback'            => array( $this, $action ),
			'permission_callback' => fn() => $this->check( $cap ), // Every endpoint has one.
			'args'                => $args,
		);
	}

	private function check( string $cap ) {
		if ( current_user_can( $cap ) ) {
			return true;
		}
		return new WP_Error(
			'lp_cannot_manage_loans',
			__( 'Sorry, you are not allowed to manage loans.', 'lumen-catalog' ),
			array( 'status' => rest_authorization_required_code() )
		);
	}

Loans are staff data: Editors, Catalogue Managers and Administrators hold edit_others_lp_books; Authors, who only propose their own books, do not. get_endpoint_args_for_item_schema() turns the schema into args: for CREATABLE it keeps required and default, for EDITABLE it drops both so a PATCH may send one field, and it skips readonly properties in both.

Collection, pagination and single items

	public function get_items( $request ) {
		global $wpdb;
		$where  = array( '1=1' );
		$values = array( $wpdb->prefix . 'lp_loans' );
		if ( $request['book'] ) {
			$where[]  = 'book_id = %d';
			$values[] = $request['book'];
		}
		if ( 'out' === $request['status'] ) {
			$where[] = 'returned_on IS NULL';
		} elseif ( 'returned' === $request['status'] ) {
			$where[] = 'returned_on IS NOT NULL';
		}
		$where    = implode( ' AND ', $where ); // Literal SQL and placeholders only.
		$per_page = (int) $request['per_page'];
		$page     = (int) $request['page'];
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $where is literal SQL.
		$total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE {$where}", $values ) );
		$pages = (int) ceil( $total / $per_page );
		if ( $page > 1 && $page > $pages ) {
			return new WP_Error( 'rest_invalid_page_number', __( 'The page number requested is larger than the number of pages available.', 'lumen-catalog' ), array( 'status' => 400 ) );
		}
		$values[] = $per_page;
		$values[] = ( $page - 1 ) * $per_page;
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $where is literal SQL.
		$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE {$where} ORDER BY loaned_on DESC, id DESC LIMIT %d OFFSET %d", $values ) );
		$items = array();
		foreach ( $rows as $row ) {
			$items[] = $this->prepare_response_for_collection( $this->prepare_item_for_response( $row, $request ) );
		}
		$response = rest_ensure_response( $items );
		$response->header( 'X-WP-Total', (string) $total );
		$response->header( 'X-WP-TotalPages', (string) $pages );
		$base = add_query_arg( urlencode_deep( $request->get_query_params() ), rest_url( $this->namespace . '/' . $this->rest_base ) );
		if ( $page > 1 ) {
			$response->link_header( 'prev', add_query_arg( 'page', $page - 1, $base ) );
		}
		if ( $page < $pages ) {
			$response->link_header( 'next', add_query_arg( 'page', $page + 1, $base ) );
		}
		return $response;
	}

	public function get_item( $request ) {
		$loan = $this->get_loan( (int) $request['id'] );
		return is_wp_error( $loan ) ? $loan : $this->prepare_item_for_response( $loan, $request );
	}

	private function get_loan( int $id ) {
		global $wpdb;
		$loan = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $wpdb->prefix . 'lp_loans', $id ) );
		if ( ! $loan ) {
			return new WP_Error( 'lp_loan_not_found', __( 'No such loan.', 'lumen-catalog' ), array( 'status' => 404 ) );
		}
		return $loan;
	}

	public function get_collection_params(): array {
		$params = parent::get_collection_params(); // context, page, per_page (1 to 100, default 10), search.
		unset( $params['search'] );
		$params['book']   = array( 'type' => 'integer', 'minimum' => 1 );
		$params['status'] = array( 'type' => 'string', 'enum' => array( 'out', 'returned' ) );
		return $params;
	}

%i (6.2) quotes the table name; the WHERE clause holds only literal strings and every value is a placeholder (see WP_Query and the Database API). Core exposes the headers to cross-origin scripts.

Create, update and delete

	public function create_item( $request ) {
		global $wpdb;
		$loan = $this->prepare_item_for_database( $request );
		if ( is_wp_error( $loan ) ) {
			return $loan;
		}
		$loan['created_by'] = get_current_user_id();
		if ( ! $wpdb->insert( $wpdb->prefix . 'lp_loans', $loan ) ) {
			return new WP_Error( 'lp_loan_db_error', __( 'The loan could not be saved.', 'lumen-catalog' ), array( 'status' => 500 ) );
		}
		$request->set_param( 'context', 'edit' ); // Return what an editor sees.
		$response = $this->prepare_item_for_response( $this->get_loan( (int) $wpdb->insert_id ), $request );
		$response->set_status( 201 );
		$response->header( 'Location', rest_url( "lumen/v1/loans/{$wpdb->insert_id}" ) );
		return $response;
	}

	public function update_item( $request ) {
		global $wpdb;
		$existing = $this->get_loan( (int) $request['id'] );
		$changes  = is_wp_error( $existing ) ? $existing : $this->prepare_item_for_database( $request, $existing );
		if ( is_wp_error( $changes ) ) {
			return $changes;
		}
		if ( $changes && false === $wpdb->update( $wpdb->prefix . 'lp_loans', $changes, array( 'id' => $existing->id ) ) ) {
			return new WP_Error( 'lp_loan_db_error', __( 'The loan could not be saved.', 'lumen-catalog' ), array( 'status' => 500 ) );
		}
		$request->set_param( 'context', 'edit' );
		return $this->prepare_item_for_response( $this->get_loan( (int) $existing->id ), $request );
	}

	public function delete_item( $request ) {
		global $wpdb;
		$loan = $this->get_loan( (int) $request['id'] );
		if ( is_wp_error( $loan ) ) {
			return $loan;
		}
		$request->set_param( 'context', 'edit' );
		$previous = $this->prepare_item_for_response( $loan, $request )->get_data();
		$wpdb->delete( $wpdb->prefix . 'lp_loans', array( 'id' => $loan->id ), array( '%d' ) );
		return new WP_REST_Response( array( 'deleted' => true, 'previous' => $previous ) );
	}

	protected function prepare_item_for_database( $request, $existing = null ) {
		$loan = array();
		if ( isset( $request['book'] ) ) {
			$book = get_post( (int) $request['book'] );
			if ( ! $book || 'lp_book' !== $book->post_type ) {
				return new WP_Error( 'lp_invalid_book', __( 'The book does not exist.', 'lumen-catalog' ), array( 'status' => 400 ) );
			}
			$loan['book_id'] = $book->ID;
		}
		foreach ( array( 'borrower', 'copies', 'loaned_on', 'due_on', 'returned_on' ) as $field ) {
			if ( $request->has_param( $field ) ) {
				$loan[ $field ] = $request[ $field ];
			}
		}
		// Rules a schema cannot express: dates must not precede the loan.
		$loaned_on = $loan['loaned_on'] ?? ( $existing->loaned_on ?? null );
		foreach ( array( 'due_on', 'returned_on' ) as $field ) {
			if ( ! empty( $loan[ $field ] ) && $loan[ $field ] < $loaned_on ) {
				/* translators: %s: field name. */
				return new WP_Error( 'lp_invalid_loan_dates', sprintf( __( '%s cannot be earlier than loaned_on.', 'lumen-catalog' ), $field ), array( 'status' => 400 ) );
			}
		}
		return $loan;
	}

A create answers 201 with a Location header; loans have no trash, so DELETE removes the row and returns it as previous, the shape core uses with force=true.

	public function prepare_item_for_response( $item, $request ) {
		$status = 'returned';
		if ( null === $item->returned_on ) {
			$status = ( $item->due_on && $item->due_on < current_time( 'Y-m-d' ) ) ? 'overdue' : 'out';
		}
		$data = array(
			'id'          => (int) $item->id,
			'book'        => (int) $item->book_id,
			'borrower'    => $item->borrower,
			'copies'      => (int) $item->copies,
			'loaned_on'   => $item->loaned_on,
			'due_on'      => $item->due_on,
			'returned_on' => $item->returned_on,
			'status'      => $status,
			'created_by'  => (int) $item->created_by,
		);
		$data = $this->add_additional_fields_to_object( $data, $request );
		$data = $this->filter_response_by_context( $data, $request['context'] ?? 'view' );
		$response = rest_ensure_response( $data );
		$response->add_links(
			array(
				'self'             => array( 'href' => rest_url( "lumen/v1/loans/{$item->id}" ) ),
				'collection'       => array( 'href' => rest_url( 'lumen/v1/loans' ) ),
				Rest::REL . 'book' => array( 'href' => rest_url( "lumen/v1/books/{$item->book_id}" ), 'embeddable' => true ),
				'author'           => array( 'href' => rest_url( "wp/v2/users/{$item->created_by}" ), 'embeddable' => true ),
			)
		);
		return apply_filters( 'lumen_catalog_rest_prepare_loan', $response, $item, $request );
	}

	public function get_item_schema(): array {
		if ( $this->schema ) {
			return $this->add_additional_fields_schema( $this->schema );
		}
		$date = '^\d{4}-\d{2}-\d{2}$';
		$prop = static function ( array $schema, array $context = array( 'view', 'edit' ) ): array {
			return $schema + array( 'context' => $context );
		};

		$this->schema = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'lp_loan',
			'type'       => 'object',
			'properties' => array(
				'id'          => $prop( array( 'type' => 'integer', 'readonly' => true ), array( 'view', 'edit', 'embed' ) ),
				'book'        => $prop( array( 'type' => 'integer', 'minimum' => 1, 'required' => true ), array( 'view', 'edit', 'embed' ) ),
				'borrower'    => $prop( array( 'type' => 'string', 'minLength' => 1, 'maxLength' => 191, 'required' => true, 'arg_options' => array( 'sanitize_callback' => 'sanitize_text_field' ) ), array( 'view', 'edit', 'embed' ) ),
				'copies'      => $prop( array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 50, 'default' => 1 ) ),
				'loaned_on'   => $prop( array( 'type' => 'string', 'pattern' => $date, 'required' => true ) ),
				'due_on'      => $prop( array( 'type' => array( 'string', 'null' ), 'pattern' => $date ) ),
				'returned_on' => $prop( array( 'type' => array( 'string', 'null' ), 'pattern' => $date ) ),
				'status'      => $prop( array( 'type' => 'string', 'enum' => array( 'out', 'overdue', 'returned' ), 'readonly' => true ), array( 'view', 'edit', 'embed' ) ),
				'created_by'  => $prop( array( 'type' => 'integer', 'readonly' => true ), array( 'edit' ) ),
			),
		);
		return $this->add_additional_fields_schema( $this->schema );
	}
}

The schema does four jobs at once: it generates the arguments (required, minLength, pattern; arg_options adds keys only to the generated argument, here a sanitize_callback that strips tags), documents the resource for OPTIONS, drives _fields, and decides by context which properties appear: view (default), edit (here created_by, which the collection omits) and embed (the slim copy inside another resource’s _embedded). Links whose relation is not registered with IANA use a URI (Rest::REL . 'book'), which the CURIE prints as lumen:book; embeddable lets _embed include the target, and core adds targetHints.allow, the methods the current user may use on self (6.7).

The loans API in action

SITE=https://lumenpress.test/wp-json/lumen/v1
maria() { curl -s -u "maria.editor:$MARIA_APP_PASSWORD" -H 'Content-Type: application/json' "$@"; }
curl -s "$SITE/loans"                                                    # anonymous
curl -s -u "tom.author:$TOM_APP_PASSWORD" "$SITE/loans"                  # Author
maria -X POST "$SITE/loans" -d '{"book":4,"borrower":"Harbour Books","copies":3,"loaned_on":"2026-10-01","due_on":"2026-11-15"}'
maria -X POST "$SITE/loans" -d '{"book":5,"borrower":"<b>Northern Review</b>","loaned_on":"2026-09-02","due_on":"2026-10-02"}'
maria -X POST "$SITE/loans" -d '{"borrower":"X"}'
maria -X POST "$SITE/loans" -d '{"book":4,"borrower":"","loaned_on":"01/10/2026"}'
maria -X POST "$SITE/loans" -d '{"book":1,"borrower":"X","loaned_on":"2026-10-01"}'
maria -X POST "$SITE/loans" -d '{"book":4,"borrower":"X","loaned_on":"2026-10-01","due_on":"2026-09-01"}'
maria -X PATCH "$SITE/loans/2" -d '{"returned_on":"2026-10-20"}'
curl -s -u "tom.author:$TOM_APP_PASSWORD" -X DELETE "$SITE/loans/2"
maria -X DELETE "$SITE/loans/2"
maria "$SITE/loans/2"
maria "$SITE/loans?page=9"

The responses, with status codes and _links shortened to their relations:

401 {"code":"lp_cannot_manage_loans","message":"Sorry, you are not allowed to manage loans.","data":{"status":401}}
403 {"code":"lp_cannot_manage_loans","message":"Sorry, you are not allowed to manage loans.","data":{"status":403}}
201 {"id":1,"book":4,"borrower":"Harbour Books","copies":3,"loaned_on":"2026-10-01","due_on":"2026-11-15","returned_on":null,"status":"out","created_by":2,"_links":{self,collection,author,lumen:book,curies}}
201 {"id":2,"book":5,"borrower":"Northern Review","copies":1,"loaned_on":"2026-09-02","due_on":"2026-10-02","returned_on":null,"status":"overdue","created_by":2,...}
400 {"code":"rest_missing_callback_param","message":"Missing parameter(s): book, loaned_on","data":{"status":400,"params":["book","loaned_on"]}}
400 {"code":"rest_invalid_param","message":"Invalid parameter(s): borrower, loaned_on","data":{"status":400,"params":{"borrower":"borrower must be at least 1 character long.","loaned_on":"loaned_on does not match pattern ^\\d{4}-\\d{2}-\\d{2}$."},"details":{...}}}
400 {"code":"lp_invalid_book","message":"The book does not exist.","data":{"status":400}}
400 {"code":"lp_invalid_loan_dates","message":"due_on cannot be earlier than loaned_on.","data":{"status":400}}
200 {"id":2,...,"returned_on":"2026-10-20","status":"returned",...}
403 {"code":"lp_cannot_manage_loans","message":"Sorry, you are not allowed to manage loans.","data":{"status":403}}
200 {"deleted":true,"previous":{"id":2,"book":5,"borrower":"Northern Review","copies":1,"loaned_on":"2026-09-02","due_on":"2026-10-02","returned_on":"2026-10-20","status":"returned","created_by":2}}
404 {"code":"lp_loan_not_found","message":"No such loan.","data":{"status":404}}
400 {"code":"rest_invalid_page_number","message":"The page number requested is larger than the number of pages available.","data":{"status":400}}

The sanitize_callback turned <b>Northern Review</b> into plain text, and copies took its default. With two loans out, curl -sI ... "$SITE/loans?per_page=1&status=out" returns X-WP-Total: 2, X-WP-TotalPages: 2, Link: <https://lumenpress.test/wp-json/lumen/v1/loans?per_page=1&status=out&page=2>; rel="next" and Allow: GET, POST. GET $SITE/loans?_embed=lumen:book (a list of relations for _embed works since 5.4) adds "_embedded":{"lumen:book":[{"id":4,"title":"The Lighthouse Ledger","isbn":"978-1-23456-789-7","link":...}]} to each loan, the book in its embed context. OPTIONS $SITE/loans lists the POST arguments book, borrower, copies, loaned_on, due_on and returned_on, with book, borrower and loaned_on required.

A read-only controller: lumen/v1/books

lumen/v1/books is a small, stable catalogue view for apps and headless front ends: fewer fields than /wp/v2/lp_book, published books only, and a genre filter that rejects unknown slugs. Its collection route uses 'permission_callback' => '__return_true', since published books are public; the single-book route has a get_item_permissions_check() that returns 404 lp_book_not_found for anything that is not an lp_book, true for a published book or when current_user_can( 'read_post', $id ), and otherwise lp_cannot_read_book with rest_authorization_required_code(). The genre filter shows a custom validate_callback:

	public function get_collection_params(): array {
		$params          = parent::get_collection_params();
		$params['genre'] = array( 'type' => 'string', 'validate_callback' => array( $this, 'validate_genre' ), 'sanitize_callback' => 'sanitize_title' );
		return $params;
	}

	public function validate_genre( $value, $request, $param ) {
		$valid = rest_validate_request_arg( $value, $request, $param ); // Keep the schema check.
		if ( is_wp_error( $valid ) ) {
			return $valid;
		}
		if ( ! term_exists( sanitize_title( $value ), 'lp_genre' ) ) {
			/* translators: %s: genre slug. */
			return new WP_Error( 'rest_invalid_param', sprintf( __( 'Unknown genre: %s.', 'lumen-catalog' ), $value ), array( 'status' => 400 ) );
		}
		return true;
	}

get_items() runs a WP_Query (post_type lp_book, post_status publish, posts_per_page and paged from the request, s from search, a tax_query on lp_genre for genre) and sets X-WP-Total from found_posts and X-WP-TotalPages from max_num_pages. prepare_item_for_response() builds only the properties _fields asks for, with get_fields_for_response() and rest_is_field_included(), so ?_fields=id,title never reads meta or terms, and links alternate to /wp/v2/lp_book/<id> and https://api.w.org/term (printed wp:term) to the book’s genres. The properties are id, title, isbn, pages, published_on, genres and link, all readonly:

GET /lumen/v1/books?_fields=id,title,isbn,genres
200 [{"id":5,"title":"The Glass Orchard","isbn":"978-1-23456-790-3","genres":[]},{"id":4,"title":"The Lighthouse Ledger","isbn":"978-1-23456-789-7","genres":["mystery"]}]
GET /lumen/v1/books?genre=sci-fi
400 {"code":"rest_invalid_param","message":"Invalid parameter(s): genre","data":{"status":400,"params":{"genre":"Unknown genre: sci-fi."},...}}
GET /lumen/v1/books/6                          (draft, anonymous)
401 {"code":"lp_cannot_read_book","message":"Sorry, you are not allowed to read this book.","data":{"status":401}}
GET /lumen/v1/books/6?_fields=id,title         (draft, maria.editor)
200 {"id":6,"title":"The Quiet Lighthouse"}
GET /lumen/v1/books/1                          (a post, not a book)
404 {"code":"lp_book_not_found","message":"No such book.","data":{"status":404}}

Adding a field to a core endpoint

register_rest_field( $object_type, $attribute, $args ) (4.7) adds a top-level property to every response of a core object type (a post type, term names, user, comment), with optional write support and a schema. The current stock count is the lp_stock post meta written by the stock sync; it is not registered with show_in_rest, so a field exposes it, and its update callback also appends the change to the lp_stock movement-history table, as the stock route does:

	private static function register_stock_field(): void {
		register_rest_field(
			'lp_book',
			'lp_stock',
			array(
				'get_callback'    => array( self::class, 'get_stock' ),
				'update_callback' => array( self::class, 'update_stock' ),
				'schema'          => array( 'type' => array( 'integer', 'null' ), 'minimum' => 0, 'context' => array( 'view', 'edit' ) ),
			)
		);
	}

	public static function get_stock( array $book ) {
		$quantity = get_post_meta( $book['id'], 'lp_stock', true );
		return '' === $quantity ? null : (int) $quantity;
	}

	public static function update_stock( $value, \WP_Post $book ) {
		global $wpdb;
		if ( null === $value ) {
			return true;
		}
		$previous = (int) get_post_meta( $book->ID, 'lp_stock', true );
		$row      = array( 'book_id' => $book->ID, 'delta' => (int) $value - $previous, 'quantity' => (int) $value, 'changed_at' => current_time( 'mysql', true ) );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		if ( false === $wpdb->insert( $wpdb->prefix . 'lp_stock', $row, array( '%d', '%d', '%d', '%s' ) ) ) {
			return new \WP_Error( 'lp_stock_not_saved', __( 'The stock could not be saved.', 'lumen-catalog' ), array( 'status' => 500 ) );
		}
		update_post_meta( $book->ID, 'lp_stock', (int) $value );
		return true;
	}

The schema validates writes (a negative value gives 400 rest_out_of_bounds inside rest_invalid_param), and the update callback runs only after core’s own permission check for the post, so an Author cannot change the stock of a book that is not theirs (403 rest_cannot_edit). A get_callback runs once per item: reading post meta costs no query because core primes the meta cache for the whole page, whereas a custom-table lookup there would be 100 queries on a 100-item page unless _fields omits the field or the values are cached. Fields with private data need their own capability check in get_callback: context only says in which contexts the field appears.

Modifying core responses

Every core controller ends prepare_item_for_response() with a filter: rest_prepare_{$this->post_type} for posts (rest_prepare_lp_book here), rest_prepare_{$this->taxonomy}, rest_prepare_user, rest_prepare_comment and so on. It is the place to add links or remove data, not to add fields (which would then be missing from the schema):

	public static function prepare_book( \WP_REST_Response $response, \WP_Post $book, \WP_REST_Request $request ): \WP_REST_Response {
		// Point to the catalogue resource and, for staff, to this book's loans.
		$response->add_link( self::REL . 'catalogue', rest_url( 'lumen/v1/books/' . $book->ID ), array( 'embeddable' => true ) );
		if ( current_user_can( 'edit_others_lp_books' ) ) {
			$response->add_link( self::REL . 'loans', add_query_arg( 'book', $book->ID, rest_url( 'lumen/v1/loans' ) ) );
		}

		// Anonymous readers get a slimmer book: no guid, no CSS class list.
		if ( 'view' === $request['context'] && ! is_user_logged_in() ) {
			$data = $response->get_data();
			unset( $data['guid'], $data['class_list'] );
			$response->set_data( $data );
		}
		return $response;
	}
GET /wp/v2/lp_book/4?_fields=id,lp_stock,guid,class_list,_links                (anonymous)
200 {"id":4,"lp_stock":null,"_links":{self,collection,about,version-history,wp:attachment,wp:term,lumen:catalogue,curies}}
GET /wp/v2/lp_book/4?_fields=id,lp_stock,guid,_links                            (maria.editor)
200 {"id":4,"guid":{"rendered":"https:\/\/lumenpress.test\/books\/the-lighthouse-ledger\/"},"lp_stock":null,"_links":{...,"lumen:catalogue":[...],"lumen:loans":[{"href":"https:\/\/lumenpress.test\/wp-json\/lumen\/v1\/loans?book=4"}],...}}
POST /wp/v2/lp_book/4?_fields=id,lp_stock  {"lp_stock":40}                      (maria.editor)
200 {"id":4,"lp_stock":40,...}
POST /wp/v2/lp_book/4?_fields=id,lp_stock  {"lp_stock":-3}                      (maria.editor)
400 {"code":"rest_invalid_param","message":"Invalid parameter(s): lp_stock","data":{"status":400,"params":{"lp_stock":"lp_stock must be greater than or equal to 0"},...}}
POST /wp/v2/lp_book/4?_fields=id,lp_stock  {"lp_stock":0}                       (tom.author)
403 {"code":"rest_cannot_edit","message":"Sorry, you are not allowed to edit this post.","data":{"status":403}}

Removing core fields breaks clients that expect them, including the block editor, which requests context=edit; the filter above therefore touches only anonymous view responses. To change the data before it is saved, use rest_pre_insert_{$this->post_type}; to change the query of a collection, rest_{$this->post_type}_query.

show_in_rest for post types, taxonomies and meta

Content registered by a plugin reaches wp/v2 through registration arguments, not through custom routes:

Argument Since Effect

show_in_rest (post type, taxonomy)

4.7

Adds /wp/v2/<rest_base> routes; for post types also the block editor. lp_book and its taxonomies set it to true.

rest_base

4.7

Route name, default the key (lp_book).

rest_namespace

5.9

Namespace, default wp/v2.

rest_controller_class

4.7

Controller, default WP_REST_Posts_Controller or WP_REST_Terms_Controller; a subclass changes behaviour without new routes.

autosave_rest_controller_class, revisions_rest_controller_class

6.4

Controllers of the autosave and revisions sub-routes; false disables them.

late_route_registration

6.4

Registers the type’s routes after other routes, for types that need other routes first.

show_in_rest (meta)

4.7

true, or an array with schema and prepare_callback; puts the key in the meta object (see Metadata and Custom Fields).

Choose the mechanism by the data: post meta with show_in_rest for values stored as meta, register_rest_field() for computed or external values on a core object, a filter for links and removals, and a custom controller for data that is not a post, term, user or comment, such as loans. A post type without show_in_rest has no /wp/v2/ route and always opens in the classic editor (see Custom Post Types and Taxonomies).

Pitfalls and older material

Seen in older books and tutorials Current practice

Routes registered without permission_callback

Mandatory since 5.5; a missing one is a public route plus a notice. Use __return_true only for public reads.

Capability checks or nonce checks inside the main callback

Capabilities in permission_callback; the wp_rest nonce is checked by core for cookie authentication.

Custom post types registered without show_in_rest

No REST route and no block editor; one classic-theme book’s CPT example has this gap.

register_rest_route() called on init or at file load

On rest_api_init only (notice since 5.1).

Adding fields in rest_prepare_*

register_rest_field(), so the schema and _fields know them.

The older books either skip REST development or stop at consuming core routes; none shows a controller, a schema or a permission callback. Testing these endpoints with PHPUnit is covered in Testing, and exposing them to other sites in Headless WordPress (with CORS) and to AI agents through the Abilities API in Abilities API and MCP Adapter.