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.
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"}
Source: REST API Handbook: Discovery
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 |
|---|---|---|
|
Posts and pages, with |
Published items only; |
|
Any post type registered with |
Published books |
|
Terms of any taxonomy with |
Read |
|
Attachments; |
Read attachments of published content |
|
Users and the current user |
Only users with published content in a REST-enabled post type, with public fields |
|
Comments (and, since 6.9, Notes when |
Approved comments; posting depends on the Discussion settings |
|
Search across post types ( |
Yes |
|
Options registered with |
No; requires |
|
Installed themes and plugins |
No |
|
User and theme global styles (Site Editor Styles), with revisions and variations |
No (401 |
|
Block templates and template parts, ids such as |
No (401 |
|
Synced patterns, registered block types, registered patterns |
No; |
|
Navigation block menus and classic menus |
Published Navigation posts; classic menus 401 |
|
Font Library |
No; need |
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]}]
Source: REST API Handbook: Posts
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 |
|---|---|---|
|
Returns only the listed top-level or dotted nested properties ( |
4.8 (nested 5.3) |
|
Adds |
4.4 (list 5.4) |
|
|
4.7 |
|
Lets a |
4.4 |
|
Returns status and headers inside the body with a 200 status, for clients that cannot read either |
4.4 |
|
Wraps the response in a JavaScript callback for legacy cross-domain clients; avoid it in new code |
4.4 |
|
Carries the |
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}}}}
Source: REST API Handbook: Pagination
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 |
|---|---|
|
Books in the genre with term ID 2 (IDs, not slugs: resolve slugs first with |
|
Both conditions; the default between taxonomies is |
|
Keyword search in title, excerpt and content; |
|
Lookup by slug (returns an array) |
|
Exactly those items, in that order |
|
Post date after a moment (ISO 8601, site time zone); |
|
Several statuses; anything but |
|
|
|
The site-wide search route, returning |
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 |
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 |
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_TYPEislocal. 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
Authorizationheader can be dropped before PHP sees it. The REST API FAQ gives the fix:SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1for 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.phpwithapp_name,app_idandsuccess_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 |
|
207 |
Batch request: see each response |
|
400 |
|
An argument failed the schema, a required one is missing, or the page is past the end |
401 |
|
Not authenticated (or authenticated without a nonce) for something that needs a user |
403 |
|
Authenticated, but the user lacks the capability, or the nonce is wrong |
404 |
|
No route for that path and method, or no such item |
500 |
|
A broken route or plugin; with a fatal error the body may be HTML or the recovery-mode message |
501 |
|
Deleting without |
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}}
Source: REST API Handbook: Posts
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
--useris passed: a write to/wp/v2/lp_book/6returned 401rest_cannot_editwithout--user, and the expected 400rest_invalid_type(an invalidlp_pages) with--user=maria.editor. -
No nonce is checked, and errors come back as a response with an error status;
is_error()andas_error()convert them. -
_embedset as a parameter is ignored; passtrueas the second argument ofresponse_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 );
}
Source: REST API Handbook: Pagination
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]}
Source: REST API Handbook: Posts
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-validatevalidates every sub-request first and runs none if any fails: with a second genre missing itsname, 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_embedto 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/usersreturnedadminandmaria.editor, but nottom.author).
Where older books are outdated
| Older advice | Current practice |
|---|---|
Registering a custom post type without |
Without it there is no REST route and no block editor; Lumen Press registers |
Making browser requests through |
For new code, use the REST API: |
Treating the REST API as optional, or switching it off for security |
The editors are REST clients; restrict with |
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 |
|
First-party parameters ( |
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.