REST API Introduction
WordPress serves a JSON API from /wp-json/: namespaces, the core wp/v2 routes, and what an unauthenticated client can see.
GET /wp-json/wp/v2/posts (or GET /?rest_route=/wp/v2/posts)WordPress serves a JSON HTTP API from /wp-json/ alongside the theme-rendered site. Core registers the routes on the rest_api_init hook, and a GET on the root returns an index describing every namespace, route and endpoint the install knows about. It exists so that code which never runs a template — the block editor, a mobile app, a JavaScript front end — can read and write content over HTTP.
Namespaces, routes and endpoints
A route is a URI such as /wp/v2/posts. An endpoint is one HTTP method mapped onto that route, so GET /wp/v2/posts lists posts and POST /wp/v2/posts creates one. Every route sits inside a namespace; core's is wp/v2, and plugins register their own so route names cannot collide.
Core exposes posts, pages, media, comments, users, categories, tags, taxonomies, types, statuses, settings, search, themes, plugins, blocks and revisions by default.
Two URL forms for the same route
With pretty permalinks the route is /wp-json/wp/v2/posts. Without them, the identical request is /?rest_route=/wp/v2/posts. These are the same thing; rest_url() returns whichever form the site is configured for, which is why you never hard-code /wp-json/.
What is not exposed
Only content that opted in appears. A custom post type is absent until registered with 'show_in_rest' => true (WordPress 4.7), and post meta is absent until registered the same way. Unauthenticated callers see published content only — drafts, private posts and the edit context require authentication.
Example
<?php
/**
* Discovering and calling the API from PHP.
*
* WordPress already advertises the API root to clients: it prints
* <link rel="https://api.w.org/" href="..."> into wp_head and sends the same
* value as a Link header. These are the server-side equivalents.
*/
// The API root for this site. Returns https://example.com/wp-json/ with pretty
// permalinks, and https://example.com/?rest_route=/ without them.
$root = rest_url();
// A full URL for one core route, built the same permalink-aware way.
$posts_url = rest_url( 'wp/v2/posts' );
// The hand-written equivalent. Same string on most sites -- and broken on any
// site that has plain permalinks, or a non-default rest_url_prefix.
$posts_url_longhand = home_url( '/wp-json/wp/v2/posts' );
// Fetch the five most recent posts over real HTTP. Useful when the site you are
// querying is a *different* WordPress install; for the current one, prefer
// WP_Query or rest_do_request() and skip the network round trip entirely.
$response = wp_remote_get(
add_query_arg(
array(
'per_page' => 5,
'_fields' => 'id,title,link', // trim the payload to three fields
),
$posts_url
)
);
if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) ) {
$posts = json_decode( wp_remote_retrieve_body( $response ), true );
// Collection responses carry the paging totals in headers, not the body.
$total = (int) wp_remote_retrieve_header( $response, 'x-wp-total' );
}When to use it
- Feeding a separate React or mobile front end from an existing WordPress install.
- Checking which routes a newly installed plugin registered by reading /wp-json/.
- Syncing posts from one WordPress site into another over HTTP.
- Auditing what an anonymous visitor can read from a site before it goes live.
More examples
Dispatching a route without an HTTP request
Reach for this when PHP code needs exactly what an endpoint returns — a shortcode that renders the same payload a block does, or a test that asserts on a controller's output — without paying for an HTTP round trip back into your own site. Because it goes through the normal dispatcher, permission_callback still runs against the current user.
<?php
// rest_do_request() (WordPress 4.4) runs a route through the REST server
// in-process: no socket, no second WordPress boot, same permission checks.
$request = new WP_REST_Request( 'GET', '/wp/v2/posts' );
$request->set_query_params(
array(
'per_page' => 3,
'search' => 'release notes',
)
);
$response = rest_do_request( $request ); // WP_REST_Response
if ( $response->is_error() ) {
$error = $response->as_error(); // WP_Error
error_log( $error->get_error_message() );
} else {
// The raw data, before JSON encoding. Run it through the server if you want
// the same shape a real client would receive, including _links.
$data = rest_get_server()->response_to_data( $response, false );
}Requiring authentication for the whole API
Use this on sites that are not public — staging copies, client intranets — where the default read access to users and content is unwanted. Be aware that it locks out the block editor for logged-out contexts and anything else relying on anonymous reads, so it belongs on environments, not on production sites with a public front end.
<?php
/**
* Close the API to anonymous readers on a staging or intranet site.
*
* The rest_authentication_errors filter runs before any route's
* permission_callback. Returning a WP_Error here stops every request.
*/
add_filter(
'rest_authentication_errors',
function ( $result ) {
// Another authentication handler already decided. Do not overrule it.
if ( ! empty( $result ) ) {
return $result;
}
if ( ! is_user_logged_in() ) {
return new WP_Error(
'rest_not_logged_in',
__( 'REST API access is restricted on this site.', 'my-plugin' ),
array( 'status' => 401 )
);
}
return $result;
}
);
Discussion