
The WordPress REST API lets other apps, JavaScript front ends and Gutenberg blocks read and write your site's data as JSON. WordPress ships with endpoints for posts, pages and users, but real projects almost always need a custom endpoint: a contact form handler, a stats feed for a mobile app, or data for a React-based block.
In this guide you'll build a custom endpoint from scratch, the way you would in a production plugin: with proper routing, argument validation, permission checks and clean responses.
What you'll build
Two routes under a custom namespace postverses/v1:
GET /wp-json/postverses/v1/booksreturns a list of books (public).POST /wp-json/postverses/v1/bookscreates a book (only for users who can edit posts).
You can drop the code into a small plugin file such as wp-content/plugins/pv-books-api/pv-books-api.php. Avoid putting it in a theme's functions.php, because an API usually needs to survive theme changes.
Step 1: Register the route on rest_api_init
Routes must be registered inside the rest_api_init action with register_rest_route(). The first argument is your namespace (vendor name plus version), the second is the route.
<?php
/**
* Plugin Name: PV Books API
*/
add_action( 'rest_api_init', function () {
register_rest_route( 'postverses/v1', '/books', array(
array(
'methods' => WP_REST_Server::READABLE, // GET
'callback' => 'pv_get_books',
'permission_callback' => '__return_true', // public
'args' => array(
'per_page' => array(
'default' => 10,
'sanitize_callback' => 'absint',
'validate_callback' => function ( $value ) {
return $value > 0 && $value <= 50;
},
),
),
),
array(
'methods' => WP_REST_Server::CREATABLE, // POST
'callback' => 'pv_create_book',
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
'args' => array(
'title' => array(
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
'author' => array(
'required' => false,
'sanitize_callback' => 'sanitize_text_field',
),
),
),
) );
} );
Three details matter here:
permission_callbackis required. Since WordPress 5.5, leaving it out triggers a_doing_it_wrongnotice. For a public route, use__return_trueexplicitly so the intent is clear.argsvalidates input before your callback runs. Ifvalidate_callbackreturns false, WordPress answers with a 400 error automatically.WP_REST_Server::READABLEandCREATABLEare constants forGETandPOST; they read better than raw strings.
Step 2: Write the callbacks
A callback receives a WP_REST_Request object. Read parameters with $request->get_param() and return data with rest_ensure_response(), or return a WP_Error for failures.
function pv_get_books( WP_REST_Request $request ) {
$query = new WP_Query( array(
'post_type' => 'book',
'posts_per_page' => $request->get_param( 'per_page' ),
'post_status' => 'publish',
) );
$books = array_map( function ( $post ) {
return array(
'id' => $post->ID,
'title' => get_the_title( $post ),
'author' => get_post_meta( $post->ID, 'book_author', true ),
'link' => get_permalink( $post ),
);
}, $query->posts );
return rest_ensure_response( $books );
}
function pv_create_book( WP_REST_Request $request ) {
$post_id = wp_insert_post( array(
'post_type' => 'book',
'post_title' => $request->get_param( 'title' ),
'post_status' => 'publish',
), true );
if ( is_wp_error( $post_id ) ) {
return new WP_Error( 'pv_book_not_created', 'Could not create the book.', array( 'status' => 500 ) );
}
if ( $request->get_param( 'author' ) ) {
update_post_meta( $post_id, 'book_author', $request->get_param( 'author' ) );
}
$response = rest_ensure_response( array( 'id' => $post_id ) );
$response->set_status( 201 ); // Created
return $response;
}
This example assumes a book custom post type exists. If you don't have one yet, register it with register_post_type() on the init hook, or simply change 'book' to 'post' while testing.
Step 3: Test the endpoint
After activating the plugin, visit https://your-site.com/wp-json/postverses/v1/books in a browser. You should see a JSON array. You can also test from the terminal:
curl https://your-site.com/wp-json/postverses/v1/books?per_page=5
Try an invalid value such as ?per_page=500 and you'll get a 400 rest_invalid_param response. That is your validation working.
Step 4: Authenticate write requests
The POST route checks current_user_can( 'edit_posts' ), so the request must come from a logged-in user. There are two common ways to authenticate:
- Cookie + nonce (JavaScript running inside WordPress, for example a block or admin page). Create a nonce with
wp_create_nonce( 'wp_rest' ), pass it to your script withwp_localize_script()orwp_add_inline_script(), and send it in theX-WP-Nonceheader. - Application Passwords (external apps and scripts). Since WordPress 5.6 every user can create one under Users → Profile, then send it with HTTP Basic Auth over HTTPS.
// Inside WordPress (cookie + nonce)
fetch( '/wp-json/postverses/v1/books', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': pvApi.nonce
},
body: JSON.stringify( { title: 'Clean Code', author: 'Robert C. Martin' } )
} ).then( r => r.json() ).then( console.log );
# From an external script (Application Password)
curl -X POST https://your-site.com/wp-json/postverses/v1/books \
-u "username:xxxx xxxx xxxx xxxx xxxx xxxx" \
-H "Content-Type: application/json" \
-d '{"title":"Clean Code","author":"Robert C. Martin"}'
Common mistakes to avoid
- Returning raw
WP_Postobjects. They expose fields you don't intend to publish. Map them to a small array, as in the example. - Skipping sanitisation. Always set a
sanitize_callbackfor every argument you store. - Using
__return_trueon write routes. Anyone could create content. Check a capability instead. - Getting 404 on
/wp-json/. Re-save Settings → Permalinks; plain permalinks need?rest_route=/postverses/v1/booksinstead.
Where to go next
For larger APIs, move the routes into a class that extends WP_REST_Controller; it gives you a standard structure for schemas, pagination headers and item permissions. If you are building blocks, the same endpoint can feed a Gutenberg block through @wordpress/api-fetch, which adds the nonce for you.
Related reading on Post Verses: