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:
-
Match the path against every route (anchored regular expressions, case-insensitive) and the method against its endpoints; no match gives 404
rest_no_route. -
Check the arguments: missing
requiredones give 400rest_missing_callback_param, invalid ones 400rest_invalid_param; then sanitize them. -
Run the
permission_callback;falsegivesrest_forbiddenwith 401 (anonymous) or 403 (logged in), and aWP_Erroris returned as is. -
Run the
callback, wrap its result withrest_ensure_response(), then apply_fields,_embedand 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 |
|---|---|
|
A string ( |
|
Receives the |
|
Receives the request; returns |
|
Per-argument definitions (next section). Set at route level, outside the endpoint arrays, they apply to every endpoint. |
|
Callback returning the resource’s JSON Schema, shown by |
|
|
|
|
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 |
|---|---|
|
|
|
A missing required argument stops the request; |
|
Value constraints ( |
|
String constraints (5.5). |
|
Validated and sanitized: |
|
Array and object schemas ( |
|
|
|
|
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_trueonly 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), somap_meta_capand ownership apply (see Roles and Capabilities API). -
A
WP_Errorwithrest_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 |
|---|---|
|
|
|
|
|
|
|
Wraps plain data in a 200 |
|
Runs a request inside PHP, with the same validation and permissions, without HTTP. |
|
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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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};"
);
}
Source: Code Reference: dbDelta()
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;
}
Source: REST API Handbook: Pagination
%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.
Schema, context, links and embedding
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 );
}
}
Source: REST API Handbook: 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 |
|---|---|---|
|
4.7 |
Adds |
|
4.7 |
Route name, default the key ( |
|
5.9 |
Namespace, default |
|
4.7 |
Controller, default |
|
6.4 |
Controllers of the autosave and revisions sub-routes; |
|
6.4 |
Registers the type’s routes after other routes, for types that need other routes first. |
|
4.7 |
|
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 |
Mandatory since 5.5; a missing one is a public route plus a notice. Use |
Capability checks or nonce checks inside the main |
Capabilities in |
Custom post types registered without |
No REST route and no block editor; one classic-theme book’s CPT example has this gap. |
|
On |
Adding fields in |
|
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.