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:

Bash
curl "https://example.com/wp-json/wp/v2/posts?per_page=3&_fields=id,title,link"
  • per_page limits the count.
  • _fields returns only the fields you need and keeps the response light.
  • search, categories and page handle 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.

Bash
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 draft so 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
<?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
<?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:

PartJob
permission_callbackWho is allowed? Without it, the route is unsafe
validate_callbackIs the input valid?
sanitize_callbackClean 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
<?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:

JavaScript
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#

SymptomLikely cause
404 on /wp-json/Broken permalinks or a server rule; re-save permalinks
401 when writingWrong application password, or no HTTPS
403 with the right passwordThe user lacks the capability for that action
Response contains HTMLA plugin printed a PHP error or warning; check the log
400 with an invalid parameter messagevalidate_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_callback correctly, and always validate and sanitize input.
  • Keep heavy responses small with _fields and a small per_page.
  • If you call from a browser with a cookie, you need a nonce named wp_rest (the X-WP-Nonce header).

Summary#

  • The WordPress REST API is built into core and lives at /wp-json/.
  • Reading public content needs no login; _fields and per_page keep responses light.
  • For writing, use an application password over HTTPS and create a low-privilege user.
  • Build custom routes with register_rest_route and never forget permission_callback.
  • Control input with validate_callback and sanitize_callback.
  • Restrict sensitive routes such as the user list.