Sometimes you need to read a WordPress site's data from outside it: showing the latest posts on another site, feeding a mobile app, or filling products automatically from an inventory system. WordPress has a REST API for this, built into core for years, with no plugin needed. It sends and receives data as JSON.
If you want to build your own custom route, start with building a WordPress plugin from scratch, because this article's code belongs in a plugin.
Reading: no login#
Public content needs no authentication:
curl "https://example.com/wp-json/wp/v2/posts?per_page=3&_fields=id,title,link"per_pagelimits the count._fieldsreturns only the fields you need and keeps the response light.search,categoriesandpagehandle filtering and pagination.
Post types registered with show_in_rest, such as a custom post type, are available at their own routes. The list of routes is visible at /wp-json/.
Writing: application passwords#
To create or edit content, you must prove the identity of a user with enough permissions. A user's profile has an Application Passwords section; create a separate password for each app so that if one leaks, you revoke only that one. This feature is usually enabled only on HTTPS connections.
curl -X POST "https://example.com/wp-json/wp/v2/posts" \
-u "USERNAME:APPLICATION-PASSWORD" \
-H "Content-Type: application/json" \
-d '{"title":"Test post","content":"Body text","status":"draft"}'Notes:
- Set the status to
draftso you can review before publishing. - Don't put the password in source code or a public repository; keep it in an environment variable or a config file outside the web root.
- Give the API user only the role it needs (for example "Author"), not Administrator.
The same request in PHP, using WordPress's own function (from another WordPress site):
<?php
$response = wp_remote_post( 'https://example.com/wp-json/wp/v2/posts', array(
'timeout' => 15,
'headers' => array(
'Authorization' => 'Basic ' . base64_encode( $user . ':' . $app_password ),
'Content-Type' => 'application/json',
),
'body' => wp_json_encode( array(
'title' => 'Test post',
'status' => 'draft',
) ),
) );
if ( is_wp_error( $response ) ) {
error_log( $response->get_error_message() );
} elseif ( 201 !== wp_remote_retrieve_response_code( $response ) ) {
error_log( 'REST error: ' . wp_remote_retrieve_body( $response ) );
}Building a custom route#
If your data isn't in a standard format, register your own route:
<?php
add_action( 'rest_api_init', function () {
register_rest_route( 'demo/v1', '/stats', array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'demo_stats',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
'args' => array(
'days' => array(
'default' => 7,
'sanitize_callback' => 'absint',
'validate_callback' => function ( $value ) {
return is_numeric( $value ) && $value > 0 && $value <= 90;
},
),
),
) );
} );
function demo_stats( WP_REST_Request $request ): WP_REST_Response {
$days = (int) $request->get_param( 'days' );
return new WP_REST_Response( array(
'days' => $days,
'posts' => (int) wp_count_posts()->publish,
), 200 );
}Keep these three parts separate in your mind:
| Part | Job |
|---|---|
permission_callback | Who is allowed? Without it, the route is unsafe |
validate_callback | Is the input valid? |
sanitize_callback | Clean the input |
If a route really must be public, use __return_true, but only for reading public data; never for writing or private information.
The resulting URL is https://example.com/wp-json/demo/v1/stats?days=30, and it must be called as an administrator (for example with an application password).
Calling it from JavaScript inside your own site#
When JavaScript runs on pages of the same site, the user is logged in with a cookie, and you must send a nonce instead of an application password. Pass the base URL and the nonce from PHP to the script:
<?php
add_action( 'wp_enqueue_scripts', function () {
wp_enqueue_script( 'demo-app', plugins_url( 'app.js', __FILE__ ), array(), '1.0', true );
wp_add_inline_script(
'demo-app',
'window.demoApi = ' . wp_json_encode( array(
'root' => esc_url_raw( rest_url( 'demo/v1/' ) ),
'nonce' => wp_create_nonce( 'wp_rest' ),
) ) . ';',
'before'
);
} );And in app.js:
fetch(window.demoApi.root + 'stats?days=30', {
headers: { 'X-WP-Nonce': window.demoApi.nonce }
}).then(r => r.json()).then(console.log);Without the nonce, WordPress treats the request as logged out, and a route with a permission_callback answers 401 or 403.
Common errors#
| Symptom | Likely cause |
|---|---|
404 on /wp-json/ | Broken permalinks or a server rule; re-save permalinks |
| 401 when writing | Wrong application password, or no HTTPS |
| 403 with the right password | The user lacks the capability for that action |
| Response contains HTML | A plugin printed a PHP error or warning; check the log |
| 400 with an invalid parameter message | validate_callback rejected the value |
If you hit a 500 error, the troubleshooting steps are in common WordPress errors.
Security tips#
- The user list (
/wp/v2/users) reveals usernames. If you don't need it, restrict it; this is also part of the WordPress security checklist. - Always use HTTPS when writing; the application password is sent with every request.
- Use
permission_callbackcorrectly, and always validate and sanitize input. - Keep heavy responses small with
_fieldsand a smallper_page. - If you call from a browser with a cookie, you need a nonce named
wp_rest(theX-WP-Nonceheader).
Summary#
- The WordPress REST API is built into core and lives at
/wp-json/. - Reading public content needs no login;
_fieldsandper_pagekeep responses light. - For writing, use an application password over HTTPS and create a low-privilege user.
- Build custom routes with
register_rest_routeand never forgetpermission_callback. - Control input with
validate_callbackandsanitize_callback. - Restrict sensitive routes such as the user list.