REST API: Using 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.

The REST API is the HTTP interface to a WordPress site’s content and configuration. The block editor, the Site Editor and the Font Library are clients of it, and so are mobile apps, headless front ends, import scripts and other sites. This page is for developers who consume the API, using the book catalogue of the fictitious publisher Lumen Press (the lp_book post type, the lp_genre and lp_series taxonomies and the lp_isbn, lp_pages and lp_published_on meta keys of the lumen-catalog plugin). Adding routes and fields of your own is covered in REST API: Extending 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, WP-CLI 2.12.0 and @wordpress/api-fetch 8.0.0 on Node.js 24, and checked on 2026-10-28. WordPress 7.2 is in beta and planned for 8 December 2026; nothing on this page depends on it. Every request and response shown was captured on a local site (wordpress:7.1.3-php8.4-apache, MariaDB 11.8, the default Twenty Twenty-Five 1.5 theme, WP_ENVIRONMENT_TYPE set to local) at lumenpress.test, with the users admin, maria.editor (Editor, ID 2), tom.author (Author, ID 3) and lena.catalogue (Catalogue Manager, ID 4); the stack is the one in Local Development Environments. JSON responses are reformatted for reading, and _links is removed unless it matters.

What the REST API is

The REST API arrived in two steps: the infrastructure (routing, WP_REST_Server, /wp-json/) in 4.4 and the content endpoints of the wp/v2 namespace in 4.7. A route is a path below the API root, such as /wp/v2/lp_book/5, and an endpoint is a route plus an HTTP method: GET reads, POST creates on a collection or updates an item, PUT and PATCH update, DELETE deletes. Routes are grouped in namespaces (wp/v2, wp-site-health/v1, a plugin’s lumen/v1) so that plugins cannot collide with core and can version their API. Every endpoint declares its arguments and a JSON Schema, so an OPTIONS request to any route describes it; responses carry HAL-style links (_links) to related resources; and every request is authorized by the endpoint’s permission check against the capabilities of the user it is authenticated as. Anonymous requests see what an anonymous visitor could see on the site.

The API does not replace the PHP APIs: theme and plugin code in the same request should call WP_Query and get_post_meta() directly. It is for code on the other side of an HTTP connection.

The figure follows one authenticated request through WordPress, from the URL to the JSON response.

sequenceDiagram participant C as Client (curl, app, apiFetch) participant W as Web server + index.php participant S as WP_REST_Server participant A as Authentication participant R as Route (WP_REST_Posts_Controller) participant D as WP_Query / database C->>W: POST /wp-json/wp/v2/lp_book (Authorization: Basic ... or cookie + X-WP-Nonce) W->>S: parse_request: rest_api_loaded() defines REST_REQUEST S->>A: rest_authentication_errors, determine_current_user (cookie or application password) A->>A: cookie without a valid wp_rest nonce: user 0, or 403 rest_cookie_invalid_nonce A-->>S: null (continue) or WP_Error S->>R: match route and method, else 404 rest_no_route R->>R: validate and sanitize args against the schema, else 400 rest_invalid_param R->>R: permission callback, else 401 or 403 R->>D: insert or query posts, terms, meta R-->>S: WP_REST_Response (data, status, headers, links) S->>S: rest_post_dispatch: _fields filtering, Allow header S-->>C: 201 Created, JSON body, Location, X-WP-Total ...

Discovery

A client should not hard-code /wp-json/: the root depends on the permalink settings and may live in a subdirectory. Every front-end page announces it, both as an HTTP header and as a <link> element in the <head>, so a HEAD request on the home page is enough. With "Plain" permalinks there is no /wp-json/ rewrite rule and the header points to /?rest_route=/ instead; that query-string form works on every site, whatever the permalink settings, and is a useful fallback when a server or firewall blocks the /wp-json/ path:

curl -sI https://lumenpress.test/ | grep -i '^link'
curl -s "https://lumenpress.test/?rest_route=/wp/v2/lp_book/5&_fields=id,slug"
Link: <https://lumenpress.test/wp-json/>; rel="https://api.w.org/"
{"id":5,"slug":"the-lighthouse-ledger"}

The root itself (GET /wp-json/) is the index: the site name, URL, gmt_offset and time zone, the namespaces list, an authentication object (core announces application-passwords with the URL of its authorization screen) and every registered route with its methods and arguments. On the test site with lumen-catalog active it listed 143 routes in the namespaces oembed/1.0, wp/v2, wp-site-health/v1, wp-block-editor/v1 and wp-abilities/v1, plus /batch/v1.

A namespace index (/wp-json/wp/v2) lists only that namespace’s routes. The index is public; it reveals which plugins register routes, but no content. In PHP, build API URLs with rest_url( 'wp/v2/lp_book' ) (or get_rest_url() on Multisite), which honours the permalink setting and the rest_url_prefix filter.

The wp/v2 routes

The wp/v2 namespace covers almost everything an editor or administrator can change on screen. The table lists the main routes and what an anonymous request may do; everything else needs a user with the matching capability.

Route Resource Anonymous access

/wp/v2/posts, /wp/v2/pages

Posts and pages, with revisions and autosaves sub-routes

Published items only; context=edit refused

/wp/v2/lp_book

Any post type registered with show_in_rest (the REST base defaults to the post type name; rest_base changes it)

Published books

/wp/v2/categories, /wp/v2/tags, /wp/v2/lp_genre, /wp/v2/lp_series

Terms of any taxonomy with show_in_rest

Read

/wp/v2/media

Attachments; POST uploads a file

Read attachments of published content

/wp/v2/users, /wp/v2/users/me

Users and the current user

Only users with published content in a REST-enabled post type, with public fields

/wp/v2/comments

Comments (and, since 6.9, Notes when type=note is asked for)

Approved comments; posting depends on the Discussion settings

/wp/v2/search

Search across post types (subtype), terms or formats

Yes

/wp/v2/settings

Options registered with show_in_rest

No; requires manage_options

/wp/v2/themes, /wp/v2/plugins

Installed themes and plugins

No

/wp/v2/global-styles/<id>, /wp/v2/global-styles/themes/<stylesheet>

User and theme global styles (Site Editor Styles), with revisions and variations

No (401 rest_cannot_read_global_styles)

/wp/v2/templates, /wp/v2/template-parts

Block templates and template parts, ids such as twentytwentyfive//single

No (401 rest_cannot_manage_templates)

/wp/v2/blocks, /wp/v2/block-types, /wp/v2/block-patterns/patterns

Synced patterns, registered block types, registered patterns

No; /wp/v2/blocks answers an empty list, the others 401

/wp/v2/navigation, /wp/v2/menus, /wp/v2/menu-items

Navigation block menus and classic menus

Published Navigation posts; classic menus 401

/wp/v2/font-families, /wp/v2/font-collections

Font Library

No; need edit_theme_options

A custom post type, taxonomy or meta key only appears in the API when it is registered with show_in_rest; without it the route simply does not exist (404 rest_no_route), and a post type also cannot use the block editor. Older plugin books register custom post types without show_in_rest; see Custom Post Types and Taxonomies and Metadata and Custom Fields. Registered meta appears under meta, and the terms of each REST-enabled taxonomy as an array of term IDs under the taxonomy’s REST base:

curl -s "https://lumenpress.test/wp-json/wp/v2/lp_book?orderby=title&order=asc&_fields=id,title.rendered,lp_genre,lp_series,meta"
[{"id":6,"title":{"rendered":"The Glass Orchard"},"meta":{"lp_isbn":"978-1-23456-790-3","lp_pages":248,"lp_published_on":"2026-01-20","footnotes":""},"lp_genre":[3],"lp_series":[]},
 {"id":5,"title":{"rendered":"The Lighthouse Ledger"},"meta":{"lp_isbn":"978-1-23456-789-7","lp_pages":312,"lp_published_on":"2025-03-14","footnotes":""},"lp_genre":[2],"lp_series":[4]},
 {"id":7,"title":{"rendered":"The Quiet Lighthouse"},"meta":{"lp_isbn":"978-1-23456-791-0","lp_pages":296,"lp_published_on":"2026-09-02","footnotes":""},"lp_genre":[2],"lp_series":[4]}]

Core registers footnotes for post types that support it, hence its presence. Fields such as title and content are objects: rendered is the HTML after filters and blocks have run, and raw (only in the edit context) is what is stored in the database.

The Site Editor routes serve tools that synchronise design between environments, and all of them need an authenticated administrator (scripts that change global styles must run as one). Templates are addressed as <theme>//<slug>: /wp/v2/templates?_fields=id,source listed twentytwentyfive//404, twentytwentyfive//page and the other theme templates with "source":"theme". The id of the user’s global styles post differs per site and is found in the wp:user-global-styles link of /wp/v2/themes?status=active (/wp/v2/global-styles/11 on the test site). The user side of those screens is covered in Site Editor and Global Styles.

Uploading media is a POST to /wp/v2/media of the raw file body (curl --data-binary @file) with Content-Type and Content-Disposition (attachment; filename="lighthouse-ledger.jpg") headers; the 201 response holds the attachment with every generated size under media_details, which a headless front end uses to build its own srcset (see Media Library). A second request with featured_media=10 on /wp/v2/lp_book/5 made it the book cover. Nested _fields such as media_details.width returned no media_details at all on the test site; ask for the whole media_details object.

Global parameters

A few query parameters, sometimes called meta-parameters, are handled by the REST server itself and work on every route, core or custom.

Parameter Effect Since

_fields

Returns only the listed top-level or dotted nested properties (_fields=id,title.rendered,meta.lp_isbn); the controller skips computing the others, so it also makes the request faster

4.8 (nested 5.3)

_embed

Adds _embedded with the resources behind embeddable links (author, terms, featured media, parent); a value restricts it to some relations, _embed=author,wp:term

4.4 (list 5.4)

context

view (default, public fields), embed (the minimal form used inside _embedded) or edit (adds raw values and private fields; needs edit rights on each item)

4.7

_method, or the X-HTTP-Method-Override header

Lets a POST act as PUT, PATCH or DELETE for clients and proxies that cannot send them; send it with POST only, since a GET with an override could be cached

4.4

_envelope

Returns status and headers inside the body with a 200 status, for clients that cannot read either

4.4

_jsonp

Wraps the response in a JavaScript callback for legacy cross-domain clients; avoid it in new code

4.4

_wpnonce

Carries the wp_rest nonce for cookie authentication when a header cannot be set

4.4

_embed and _fields combine only when the field list includes both _links and _embedded; otherwise the links are filtered out first and nothing is embedded. On the test site, /wp/v2/lp_book/5?_embed&_fields=id,title returned just the id and title, while this request embedded the genre and series terms:

curl -s "https://lumenpress.test/wp-json/wp/v2/lp_book/5?_embed=wp:term&_fields=id,title,_links,_embedded"
{
  "id": 5, "title": {"rendered": "The Lighthouse Ledger"}, "_links": {"...": "..."},
  "_embedded": {"wp:term": [
    [{"id": 2, "name": "Mystery", "slug": "mystery", "taxonomy": "lp_genre",
      "link": "https://lumenpress.test/books/genre/mystery/"}],
    [{"id": 4, "name": "Lighthouse Mysteries", "slug": "lighthouse-mysteries", "taxonomy": "lp_series",
      "link": "https://lumenpress.test/books/series/lighthouse-mysteries/"}]
  ]}
}

lp_book does not support author, so the response has no author link and _embed=author embeds nothing for it; for posts and pages it embeds the author’s public profile. Each embedded request is a separate internal dispatch, so a collection of 100 items with _embed can be expensive: restrict it to the relations the client really displays.

context=edit is what the block editor requests. Anonymous clients get 401 rest_forbidden_context; a user who can edit the item (here lena.catalogue) gets title.raw, content.raw with the block comments, the status and other private fields.

On the test site, a POST to /wp/v2/lp_book/5?_method=PATCH updated the excerpt like a PATCH, and /wp/v2/lp_book/999?_envelope answered 200 with {"body":{"code":"rest_post_invalid_id",...},"status":404,"headers":[]}.

Pagination

Collections return 10 items by default. per_page accepts 1 to 100, page selects the page and offset skips items. The totals travel in headers, and a Link header carries prev and next URLs:

curl -s -D - -o /dev/null "https://lumenpress.test/wp-json/wp/v2/lp_book?per_page=2" | grep -iE '^(HTTP|X-WP|Link|Allow)'
curl -s "https://lumenpress.test/wp-json/wp/v2/lp_book?per_page=101"
HTTP/1.1 200 OK
Link: <https://lumenpress.test/wp-json/wp/v2/lp_book?per_page=2&page=2>; rel="next"
X-WP-Total: 3
X-WP-TotalPages: 2
Allow: GET
{"code":"rest_invalid_param","message":"Invalid parameter(s): per_page","data":{"status":400,"params":{"per_page":"per_page must be between 1 (inclusive) and 100 (inclusive)"},"details":{"per_page":{"code":"rest_out_of_bounds","message":"per_page must be between 1 (inclusive) and 100 (inclusive)","data":null}}}}

A page past the end returns 400 rest_post_invalid_page_number. The 100 limit is deliberate: larger pages hurt the server. A client that needs every item loops over page until X-WP-TotalPages, as the JavaScript client below does. The Allow header lists the methods the current user may use (GET when anonymous; all five for an editor on a book), useful to show or hide edit buttons. Paging through a collection that changes between requests can skip or repeat items; for synchronisation jobs, sort by id or modified and filter with modified_after.

Filtering, searching and ordering

Collection routes accept the filters their controller declares, which an OPTIONS request lists. On /wp/v2/lp_book they include search, after, before, modified_after, modified_before, include, exclude, slug, status, order, orderby, offset, tax_relation and, per taxonomy, lp_genre, lp_genre_exclude, lp_series and lp_series_exclude.

Request Meaning

?lp_genre=2

Books in the genre with term ID 2 (IDs, not slugs: resolve slugs first with /wp/v2/lp_genre?slug=mystery)

?lp_genre=2&lp_series=4&tax_relation=AND

Both conditions; the default between taxonomies is AND

?search=lighthouse

Keyword search in title, excerpt and content; search_columns[]=post_title narrows it

?slug=the-glass-orchard

Lookup by slug (returns an array)

?include=7,5&orderby=include

Exactly those items, in that order

?after=2026-01-01T00:00:00

Post date after a moment (ISO 8601, site time zone); modified_after for the last change

?status=draft,publish

Several statuses; anything but publish needs authentication

?orderby=title&order=asc

orderby is one of author, date, id, include, modified, parent, relevance, slug, include_slugs, title

/wp/v2/search?search=lighthouse&subtype=lp_book

The site-wide search route, returning id, title, url, type and subtype

There is no filter on meta values and no orderby on meta keys: ?orderby=lp_isbn returns 400 rest_invalid_param ("orderby is not one of author, date, …​"), and after filters the post date, not lp_published_on. Lumen Press publishes books with the post date of their release so that core filters work; ordering or filtering on a meta key requires a custom route or the rest_lp_book_query filter (rest_{$this->post_type}_query), see REST API: Extending It. The ?filter[...]= parameter of the pre-4.7 feature plugin, which passed raw WP_Query arguments, was not merged into core; tutorials that use it are outdated.

Authentication

Reading public content needs no credentials. Everything else needs the request to be authenticated as a user, and then the user’s capabilities decide. Core supports two mechanisms; others come from plugins.

Mechanism How the client proves who it is Use it for Notes

Cookie + nonce

The login cookies plus a wp_rest nonce in X-WP-Nonce (or _wpnonce)

JavaScript running in the site’s own pages: the editor, admin screens, front-end features for logged-in users

Built in; CSRF-protected by the nonce; only works from the same site

Application passwords

HTTP Basic authentication with the username and a per-application password

Scripts, CI jobs, server-to-server integrations, mobile and desktop apps

Built in since 5.6; HTTPS (or WP_ENVIRONMENT_TYPE local) required; revocable one by one

OAuth 1.0a, OAuth 2.0, JWT

A token issued by a plugin

Third-party apps that must not see a password, single sign-on

Plugins only; not part of core

Cookies and the wp_rest nonce

When a logged-in user’s browser calls the API, the cookies prove the identity, but a hostile site could make the same browser send them. The REST API therefore ignores the login cookie unless the request also carries a valid nonce for the wp_rest action. Without one, the request is treated as anonymous (user 0) silently; with an invalid or expired one, it fails with 403. The session below logs in as maria.editor with curl, gets a nonce from the rest-nonce AJAX action (the same endpoint apiFetch calls to refresh an expired nonce) and tries the combinations:

curl -s -c maria.jar -b 'wordpress_test_cookie=WP%20Cookie%20check' -o /dev/null \
  --data-urlencode log=maria.editor --data-urlencode "pwd=$MARIA_PASSWORD" -d testcookie=1 \
  https://lumenpress.test/wp-login.php
NONCE=$(curl -s -b maria.jar "https://lumenpress.test/wp-admin/admin-ajax.php?action=rest-nonce")

curl -s -b maria.jar "https://lumenpress.test/wp-json/wp/v2/users/me?_fields=id"                          # no nonce
curl -s -b maria.jar -H "X-WP-Nonce: $NONCE" "https://lumenpress.test/wp-json/wp/v2/users/me?_fields=id,name"
curl -s -b maria.jar -H "X-WP-Nonce: 0123456789" "https://lumenpress.test/wp-json/wp/v2/users/me"           # bad nonce
curl -s -b maria.jar -d title=Draft "https://lumenpress.test/wp-json/wp/v2/lp_book"                        # write, no nonce
{"code":"rest_not_logged_in","message":"You are not currently logged in.","data":{"status":401}}
{"id":2,"name":"Maria Ortega"}
{"code":"rest_cookie_invalid_nonce","message":"Cookie check failed","data":{"status":403}}
{"code":"rest_cannot_create","message":"Sorry, you are not allowed to create posts as this user.","data":{"status":401}}

The last line is the classic symptom of a missing nonce: a logged-in user gets "not allowed" with status 401. Each authenticated response also returns a fresh nonce in an X-WP-Nonce response header, which long-lived pages use to stay valid. Core verifies the nonce in rest_cookie_check_errors(), so route code never checks it; and the nonce proves only that the request came from the site’s own page, not that the user may act, which the permission check decides. How nonces are created and why they are not authorization is explained in Security for Developers.

In WordPress pages, scripts enqueue wp-api-fetch (or import @wordpress/api-fetch) and core configures it with the REST root and a nonce middleware, as the "Mark as read" button in JavaScript, AJAX and Heartbeat shows. Other scripts read the nonce from data passed by PHP (wp_create_nonce( 'wp_rest' ) with rest_url()) and sends it in X-WP-Nonce.

Application passwords

Application passwords are created per user on Users → Profile or with wp user application-password create, and sent with HTTP Basic authentication. Where to create them, how they are stored and revoked, and how to restrict them by role are covered in Users, Roles and Profiles. Points that matter to API clients:

  • They are only available when the site is served over HTTPS or WP_ENVIRONMENT_TYPE is local. Never send them over plain HTTP to a real site: Basic authentication is a reversible encoding, not encryption.

  • The password has every capability of its user. Integrations should use a dedicated user with the smallest role that works (Lumen Press’s catalogue import runs as a Catalogue Manager, not as admin).

  • A wrong, revoked or unavailable application password did not produce an authentication error on the test site: the request continued as anonymous, so public reads still returned 200 and protected routes answered 401 rest_not_logged_in. A client that receives 401 on a route it expects to reach should report a credentials problem.

  • Under Apache with PHP as CGI or FastCGI, the Authorization header can be dropped before PHP sees it. The REST API FAQ gives the fix: SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1 for Apache, fastcgi_pass_header Authorization; for nginx (see Web Servers: Apache and nginx).

  • An application can request a password through the authorization screen announced in the index (/wp-admin/authorize-application.php with app_name, app_id and success_url).

OAuth and JWT

Core has no OAuth server and no JSON Web Token support. The REST API handbook points to plugins (an OAuth 1.0a server, JWT); others accept OAuth 2.0 or OpenID Connect tokens from an external identity provider. They hook determine_current_user and rest_authentication_errors, so routes and permission checks work unchanged. Use them when a third party must act for users without seeing a password, or for single sign-on; for first-party scripts, application passwords are simpler. See OAuth 2.0, JWT and JOSE and Authentication and Login API. The "Basic Authentication" plugin, which sends the real login password on every request, is for development only.

Requiring authentication everywhere

The REST API should not be disabled: the block editor, the Site Editor and many plugins depend on it. To keep a staging site’s content away from anonymous clients, require a logged-in user for every request with the rest_authentication_errors filter. This must-use plugin (wp-content/mu-plugins/lumen-private-rest.php) does it only outside production:

<?php
/**
 * Plugin Name: Lumen Private REST (staging)
 * Description: Requires a logged-in user for every REST request outside production.
 */
if ( ! in_array( wp_get_environment_type(), array( 'local', 'staging' ), true ) ) {
	return;
}

add_filter(
	'rest_authentication_errors',
	function ( $result ) {
		// Keep an earlier error, or an earlier success, from another handler.
		if ( true === $result || is_wp_error( $result ) ) {
			return $result;
		}
		if ( ! is_user_logged_in() ) {
			return new WP_Error(
				'rest_not_logged_in',
				__( 'You are not currently logged in.', 'lumen-catalog' ),
				array( 'status' => 401 )
			);
		}
		return $result;
	}
);

With it active, an anonymous request to /wp/v2/lp_book and even to the /wp-json/ index returned 401, while the same request with Maria’s application password returned the three books. The editor keeps working because it authenticates with the cookie and nonce. Hiding the user list from anonymous clients on production is a separate, narrower decision discussed in Security Hardening.

The REST API does not check the Origin header: by design it echoes the request’s origin in Access-Control-Allow-Origin, with credentials allowed, and exposes X-WP-Total, X-WP-TotalPages and Link. CSRF is prevented by the nonce, not by CORS. Restricting origins means replacing rest_send_cors_headers on rest_pre_serve_request; see CORS for the mechanism itself.

Errors and status codes

Errors are JSON objects with a machine-readable code, a translated message and data.status; validation errors add data.params (one message per argument) and data.details. Clients should branch on code and status, never on the message.

Status Typical codes Cause

200, 201

Success; 201 with a Location header after a creation

207

Batch request: see each response

400

rest_invalid_param, rest_missing_callback_param, rest_invalid_type, rest_post_invalid_page_number

An argument failed the schema, a required one is missing, or the page is past the end

401

rest_not_logged_in, rest_forbidden_context, rest_cannot_create, rest_cannot_edit

Not authenticated (or authenticated without a nonce) for something that needs a user

403

rest_cookie_invalid_nonce, rest_cannot_create, rest_cannot_edit, rest_forbidden

Authenticated, but the user lacks the capability, or the nonce is wrong

404

rest_no_route, rest_post_invalid_id, rest_term_invalid

No route for that path and method, or no such item

500

rest_invalid_handler, any PHP fatal

A broken route or plugin; with a fatal error the body may be HTML or the recovery-mode message

501

rest_trash_not_supported

Deleting without force=true a resource that has no trash (terms, users, menus, media when MEDIA_TRASH is off)

The same rule produces 401 or 403 depending on whether anybody is logged in. On the test site:

curl -s -u "tom.author:$WP_APP_PASSWORD" -d 'title=About&status=publish' https://lumenpress.test/wp-json/wp/v2/pages
curl -s -u "maria.editor:$WP_APP_PASSWORD" -H 'Content-Type: application/json' \
  -d '{"meta":{"lp_pages":"many"}}' https://lumenpress.test/wp-json/wp/v2/lp_book/5
curl -s -u "maria.editor:$WP_APP_PASSWORD" https://lumenpress.test/wp-json/wp/v2/settings
curl -s https://lumenpress.test/wp-json/wp/v2/lp_books
{"code":"rest_cannot_create","message":"Sorry, you are not allowed to create posts as this user.","data":{"status":403}}
{"code":"rest_invalid_type","message":"meta.lp_pages is not of type integer.","data":{"status":400},"additional_data":[{"param":"meta.lp_pages"}]}
{"code":"rest_forbidden","message":"Sorry, you are not allowed to do that.","data":{"status":403}}
{"code":"rest_no_route","message":"No route was found matching the URL and request method.","data":{"status":404}}

Authors cannot create pages, and they cannot create books either, because lp_book uses its own edit_lp_books capability, granted only to Administrators, Editors and Catalogue Managers (see Roles and Capabilities API). The settings route requires manage_options for every key. A typo in the route name is a 404, not a 400.

DELETE /wp/v2/lp_book/8 moved the book to the trash ("status":"trash"); ?force=true deletes for good. Terms, users and media (with MEDIA_TRASH off) have no trash and answer 501 rest_trash_not_supported without force=true.

When the editor shows "The response is not a valid JSON response", some code printed output (a PHP notice, a byte-order mark, a security plugin’s HTML page) before the JSON, or a firewall blocked /wp-json/. Request the route with curl to see the raw body, and see Debugging, Site Health and Troubleshooting.

Consuming the API from PHP

Inside WordPress: rest_do_request()

Code running inside WordPress can dispatch a REST request without HTTP. rest_do_request() takes a WP_REST_Request, runs the same validation, permission checks and controllers as an HTTP request, as the current user, and returns a WP_REST_Response. It is useful when PHP code needs exactly the REST representation, for example to preload data for a script or to reuse a controller’s validation; for ordinary queries, WP_Query is simpler and faster. This script lists the mystery books (genre term 2):

<?php
// lumen-mysteries.php: lists the mystery books through the REST API, inside WordPress.
$request = new WP_REST_Request( 'GET', '/wp/v2/lp_book' );
$request->set_query_params(
	array(
		'lp_genre' => 2,
		'orderby'  => 'title',
		'order'    => 'asc',
		'_fields'  => 'id,title,meta.lp_isbn',
	)
);
$response = rest_do_request( $request );

if ( $response->is_error() ) {
	$error = $response->as_error();
	WP_CLI::error( $error->get_error_code() . ': ' . $error->get_error_message() );
}

$books = rest_get_server()->response_to_data( $response, false );
$total = $response->get_headers()['X-WP-Total'];

printf( "%d books, status %d\n", $total, $response->get_status() );
foreach ( $books as $book ) {
	printf( "%d  %s  %s\n", $book['id'], $book['meta']['lp_isbn'], $book['title']['rendered'] );
}
$ wp eval-file lumen-mysteries.php
2 books, status 200
5  978-1-23456-789-7  The Lighthouse Ledger
7  978-1-23456-791-0  The Quiet Lighthouse

Points to know about internal requests:

  • The request runs as the current user. Under WP-CLI that is nobody unless --user is passed: a write to /wp/v2/lp_book/6 returned 401 rest_cannot_edit without --user, and the expected 400 rest_invalid_type (an invalid lp_pages) with --user=maria.editor.

  • No nonce is checked, and errors come back as a response with an error status; is_error() and as_error() convert them.

  • _embed set as a parameter is ignored; pass true as the second argument of response_to_data() to embed.

Core uses the same mechanism (rest_preload_api_request()) to preload the editor’s first requests.

From another site or service: wp_remote_get()

Another WordPress site, such as the Lumen Press newsletter site, reads the catalogue over HTTP with the HTTP API (wp_remote_get(), wp_remote_post(), wp_remote_request()), described in HTTP and Filesystem APIs. The function checks both kinds of failure: transport errors (WP_Error) and HTTP error statuses, whose JSON body carries the REST error code:

<?php
/**
 * Reads the Lumen Press catalogue from another WordPress site.
 * LUMEN_API_USER and LUMEN_API_PASSWORD are defined in wp-config.php
 * from environment variables, never in code.
 */
function lumen_remote_books( $page = 1 ) {
	$url = add_query_arg(
		array(
			'per_page' => 100,
			'page'     => $page,
			'status'   => 'publish,draft',
			'_fields'  => 'id,status,title.rendered,meta.lp_isbn',
		),
		'https://lumenpress.test/wp-json/wp/v2/lp_book'
	);

	$response = wp_remote_get(
		$url,
		array(
			'timeout' => 10,
			'headers' => array(
				'Authorization' => 'Basic ' . base64_encode( LUMEN_API_USER . ':' . LUMEN_API_PASSWORD ),
			),
		)
	);

	if ( is_wp_error( $response ) ) {
		return $response; // DNS, TLS or timeout failure: no HTTP status at all.
	}

	$code = wp_remote_retrieve_response_code( $response );
	$body = json_decode( wp_remote_retrieve_body( $response ), true );

	if ( 200 !== $code ) {
		return new WP_Error( $body['code'] ?? 'lumen_http_' . $code, $body['message'] ?? '', array( 'status' => $code ) );
	}

	return array(
		'books'       => $body,
		'total'       => (int) wp_remote_retrieve_header( $response, 'x-wp-total' ),
		'total_pages' => (int) wp_remote_retrieve_header( $response, 'x-wp-totalpages' ),
	);
}

Called from wp eval with Maria’s application password, the function returned the four books (one draft) and the totals from the headers. With a wrong password it returned Error: rest_invalid_param: Invalid parameter(s): status: the request fell back to anonymous, and anonymous clients may not ask for drafts. Header names are case-insensitive in wp_remote_retrieve_header(). Cache what the remote site returns (a transient keyed by page, see Options, Transients and Object Cache) rather than calling it on every page view.

Consuming the API from JavaScript and curl

fetch()

Any HTTP client works. This Node.js script (Node 22 or later, no dependencies) lists every book of the catalogue, following the pagination headers, as Lumen Press’s reader app does when it rebuilds its offline index:

// books.mjs: list every book, following the pagination headers.
const API = process.env.LUMEN_API ?? 'https://lumenpress.test/wp-json';
const auth = 'Basic ' + Buffer.from( `${ process.env.WP_USER }:${ process.env.WP_APP_PASSWORD }` ).toString( 'base64' );

async function getAll( route, params = {} ) {
	const items = [];
	for ( let page = 1, pages = 1; page <= pages; page++ ) {
		const url = new URL( API + route );
		url.search = new URLSearchParams( { ...params, per_page: 100, page } );
		const res = await fetch( url, { headers: { Authorization: auth } } );
		const body = await res.json();
		if ( ! res.ok ) {
			throw new Error( `${ res.status } ${ body.code }: ${ body.message }` );
		}
		pages = Number( res.headers.get( 'X-WP-TotalPages' ) );
		items.push( ...body );
	}
	return items;
}

const books = await getAll( '/wp/v2/lp_book', { status: 'publish,draft', _fields: 'id,status,title,meta.lp_isbn' } );
for ( const book of books ) {
	console.log( book.id, book.status, book.meta.lp_isbn, book.title.rendered );
}

Run with WP_USER=maria.editor and WP_APP_PASSWORD set, it printed the four books, the draft included.

Browser code on another origin may read public data, but must never embed credentials; the Next.js reader app in Headless WordPress keeps them on its server.

@wordpress/api-fetch

@wordpress/api-fetch is the client the block editor uses: it resolves path against the API root, sends JSON, parses the response and rejects with the REST error object. Inside WordPress, core configures it (root, nonce, nonce refresh); see JavaScript, AJAX and Heartbeat. Outside WordPress, it is an ordinary npm package whose behaviour is set with middlewares. This Node.js script, with "@wordpress/api-fetch": "8.0.0" pinned in its package.json ("type": "module"), authenticates with an application password:

// apifetch.mjs: @wordpress/api-fetch outside WordPress.
import apiFetch from '@wordpress/api-fetch';

apiFetch.use( apiFetch.createRootURLMiddleware( process.env.LUMEN_API ?? 'https://lumenpress.test/wp-json/' ) );
apiFetch.use( ( options, next ) =>
	next( {
		...options,
		headers: {
			...options.headers,
			Authorization: 'Basic ' + btoa( `${ process.env.WP_USER }:${ process.env.WP_APP_PASSWORD }` ),
		},
	} )
);

const book = await apiFetch( {
	path: '/wp/v2/lp_book/5?_fields=id,meta.lp_pages',
	method: 'POST',
	data: { meta: { lp_pages: 320 } },
} );
console.log( book );

try {
	await apiFetch( { path: '/wp/v2/lp_book/999' } );
} catch ( error ) {
	console.log( error.code, error.message, error.data );
}
{ id: 5, meta: { lp_pages: 320 } }
rest_post_invalid_id Invalid post ID. { status: 404 }

Pass parse: false to get the raw Response and read headers such as X-WP-Total (on the test site it returned status 200 and a total of 3). The editor’s data layer (@wordpress/core-data, useEntityRecords( 'postType', 'lp_book' )) sits on top of apiFetch and caches records; block code should prefer it. The older Backbone client (wp-api, wp.api.models) is still in core but no longer the recommended way.

curl

All requests on this page use curl: -u sends an application password (keep it in an environment variable or a .netrc file, not in shell history), a JSON body (-H 'Content-Type: application/json') is required for nested values such as meta and term arrays while flat fields can be form-encoded, -D - shows the response headers and -w ' %{http_code}' prints the status.

Creating a draft book with its genre and meta in one request, as the Catalogue Manager:

curl -s -u "lena.catalogue:$WP_APP_PASSWORD" -H 'Content-Type: application/json' \
  -d '{"title":"The Harbour Almanac","status":"draft","lp_genre":[2],
       "meta":{"lp_isbn":"978-1-23456-792-7","lp_pages":204}}' \
  "https://lumenpress.test/wp-json/wp/v2/lp_book?_fields=id,status,title.raw,meta.lp_isbn,lp_genre"
{"id":8,"status":"draft","title":{"raw":"The Harbour Almanac"},"meta":{"lp_isbn":"978-1-23456-792-7"},"lp_genre":[2]}

On the server itself, WP-CLI is usually the better tool; it needs no credentials.

Batch requests

Since 5.6, POST /batch/v1 runs up to 25 write requests (POST, PUT, PATCH, DELETE; GET is not allowed) in one HTTP round trip. Each sub-request has a path (with its own query string), an optional method (default POST), body and headers. The response has status 207 and one entry per sub-request. Only routes that declare allow_batch take part: the core post, attachment, term, user, widget, global styles and font controllers do, and custom routes must opt in (see REST API: Extending It).

curl -s -u "maria.editor:$WP_APP_PASSWORD" -H 'Content-Type: application/json' -d '{
  "validation": "require-all-validate",
  "requests": [
    { "path": "/wp/v2/lp_genre?_fields=id,slug", "body": { "name": "Thriller" } },
    { "path": "/wp/v2/lp_genre?_fields=id,slug", "body": { "name": "Memoir" } }
  ]
}' https://lumenpress.test/wp-json/batch/v1
{"responses":[{"body":{"id":5,"slug":"thriller"},"status":201,"headers":{"Location":"https://lumenpress.test/wp-json/wp/v2/lp_genre/5","Allow":"GET, POST"}},
              {"body":{"id":6,"slug":"memoir"},"status":201,"headers":{"Location":"https://lumenpress.test/wp-json/wp/v2/lp_genre/6","Allow":"GET, POST"}}]}

validation decides what happens when one sub-request is invalid:

  • require-all-validate validates every sub-request first and runs none if any fails: with a second genre missing its name, the response was {"failed":"validation","responses":[null,{...,"status":400}]} and no term was created.

  • normal (the default) runs each request on its own: an update of book 5 succeeded while an update of a missing book 999 returned its own 404 inside the same 207 response.

Batching is not a transaction. Validation happens up front, but a permission or database failure in the middle leaves the earlier requests applied, so clients must read every status. The HTTP status is 207 even when every sub-request failed, and a GET sub-request makes the whole batch fail with 400 rest_invalid_param.

Performance and security pitfalls

  • Over-fetching. Always send _fields: it saves work on the server, not only bytes. Restrict _embed to the relations the client shows. REST responses are dynamic PHP requests; cache public data on the client or a CDN (see Performance and Caching).

  • Credentials in the wrong place. Never put an application password in front-end JavaScript, a mobile app bundle or a repository; use per-integration users and revoke unused passwords.

  • Assuming the API is private. Anything public on the site is public over the API too, including the names and slugs of users with published content (/wp/v2/users returned admin and maria.editor, but not tom.author).

Where older books are outdated

Older advice Current practice

Registering a custom post type without show_in_rest (a classic-theme plugin book)

Without it there is no REST route and no block editor; Lumen Press registers lp_book, its taxonomies and its meta with show_in_rest

Making browser requests through admin-ajax.php without a nonce (the same book)

For new code, use the REST API: apiFetch sends the wp_rest nonce and core checks it; admin-ajax.php handlers must check their own nonce

Treating the REST API as optional, or switching it off for security

The editors are REST clients; restrict with rest_authentication_errors and capabilities instead

Basic Authentication or JWT plugins as the way to authenticate scripts

Application passwords have been in core since 5.6; OAuth or JWT plugins only when a token flow is really needed

?filter[...]= with WP_Query arguments (tutorials from the feature-plugin era)

First-party parameters (lp_genre, search, slug, include, after); custom routes for anything else

None of the three general WordPress books covers the API from the client’s side (global parameters, pagination headers, batch requests); see Where Older Books and Tutorials Are Outdated.