WordPress REST API Deep Dive: Custom Routes, Multiple Methods on One Endpoint, and Full CRUD
πͺ Why the WP REST API Deserves Your Attention Every WordPress site already ships with a front door for data. Open yourdomain.com/wp-json/ and you'll see a big JSON map of everything the site can do. That's the WordPres
πͺ Why the WP REST API Deserves Your Attention
Every WordPress site already ships with a front door for data. Open yourdomain.com/wp-json/ and you'll see a big JSON map of everything the site can do. That's the WordPress REST API.
It lets anything that speaks HTTP talk to WordPress: a React app, a mobile app, a Zapier flow, or another website. No admin screens, no page reloads. Just JSON in, JSON out.
In this article we'll go from "what is this thing?" to a complete, secure CRUD API for a custom Bookshelf, step by step.
By the end you'll know how to:
- Register your own routes
- Use
GET,POST,PUT,PATCHandDELETE - Share one URL across several HTTP methods (the part that confuses almost everyone!)
- Validate, sanitize and protect every request
Let's go. π
π§ Part 1: The WP REST API in Plain English
Think of the REST API as a restaurant:
-
Namespace β the restaurant name (
bookshelf/v1) -
Route β the menu item (
/books) - Method β what you want to do with it (order, change, cancel)
- Callback β the kitchen that does the work
- Permission callback β the bouncer at the door π΄οΈ
A full URL looks like this:
https://example.com/wp-json/bookshelf/v1/books/42
ββ base βββnamespaceββrouteββidβ
WordPress already gives you built-in endpoints under the wp/v2 namespace:
# Latest 5 posts
curl "https://example.com/wp-json/wp/v2/posts?per_page=5"
Those are great for posts, pages and users. But the moment you have your own data (books, bookings, tickets, whatever), you'll want custom routes.
π€ HTTP methods at a glance
| Method | Typical meaning | WP constant |
|---|---|---|
GET |
Read data | WP_REST_Server::READABLE |
POST |
Create data | WP_REST_Server::CREATABLE |
PUT / PATCH
|
Update data |
WP_REST_Server::EDITABLE (it also includes POST) |
DELETE |
Remove data | WP_REST_Server::DELETABLE |
ALLMETHODS |
Everything above | WP_REST_Server::ALLMETHODS |
Two methods you didn't ask about but should know:
-
PATCHβ a partial update ("only change the rating"). -
OPTIONSβ WordPress answers automatically with your route's schema and allowed methods. You get it for free. π
π§© The four pieces of every endpoint
When you register a route, each handler has these keys:
| Key | What it does |
|---|---|
methods |
Which HTTP verbs this handler accepts |
callback |
The function that does the work |
permission_callback |
Who is allowed in (required, WordPress warns you if it's missing) |
args |
The parameters, with type, required, default, enum, minimum, maximum, sanitize_callback, validate_callback
|
Your callback receives a WP_REST_Request and should return a WP_REST_Response (or a WP_Error when something goes wrong).
π οΈ Part 2: Creating Your First Custom Route
Routes are registered on the rest_api_init hook. Here's the smallest useful example:
add_action( 'rest_api_init', function () {
register_rest_route(
'bookshelf/v1',
'/ping',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => function () {
return rest_ensure_response( array( 'message' => 'pong' ) );
},
'permission_callback' => '__return_true', // Public on purpose.
)
);
} );
Visit /wp-json/bookshelf/v1/ping and you'll get {"message":"pong"}. π
π Dynamic routes with URL parameters
Need an ID in the URL? Use a regex capture group:
register_rest_route(
'bookshelf/v1',
'/books/(?P<id>\d+)',
array( /* handlers go here */ )
);
Inside the callback, read it with $request->get_param( 'id' ). The \d+ also means /books/abc never reaches your code, so WordPress returns a 404 for you.
ποΈ A clean OOP structure
Since we're building a real API, let's keep it tidy. One class per job:
bookshelf/
βββ bookshelf.php β Plugin bootstrap
βββ includes/
βββ class-plugin.php β Wires everything up
βββ class-book-post-type.php β Registers the "book" post type
βββ class-books-controller.php β All REST logic lives here
For storage we'll use a private custom post type called book. This way we get trash, revisions and capabilities for free:
<?php
/**
* Registers the Book post type.
*
* @package Bookshelf
*/
namespace Bookshelf;
defined( 'ABSPATH' ) || exit;
/**
* Class Book_Post_Type
*/
class Book_Post_Type {
/**
* Hook into WordPress.
*
* @return void
*/
public function register_hooks(): void {
add_action( 'init', array( $this, 'register' ) );
}
/**
* Register the post type (hidden from the default REST output).
*
* @return void
*/
public function register(): void {
register_post_type(
'book',
array(
'label' => __( 'Books', 'bookshelf' ),
'public' => false,
'show_ui' => true,
'show_in_rest' => false, // We expose it with our own API.
'supports' => array( 'title' ),
)
);
}
}
π Part 3: Same Route, Different Methods
Here's the question I hear the most:
"If
/booksis used for both listing and creating, how does WordPress know which function to run?"
It looks at the HTTP method. The route is the address. The method is the intent. π¬
The trick is to pass an array of handlers to a single register_rest_route() call. Each handler declares its own methods, callback, permissions and args:
register_rest_route(
'bookshelf/v1',
'/books',
array(
// Handler 1: GET /books β list books.
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => '__return_true',
'args' => $this->get_collection_args(),
),
// Handler 2: POST /books β create a book.
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'create_item' ),
'permission_callback' => array( $this, 'can_create' ),
'args' => $this->get_write_args( true ),
),
)
);
Same URL, two different jobs, two different bouncers. Notice that listing is public while creating needs a capability.
π What WordPress does on each request
- Matches the URL against every registered route (the regex).
- Finds the handler whose
methodscontains the request's method. - Runs the
permission_callback(fail β401or403). - Validates and sanitizes
args(fail β400). - Finally runs your
callback. β
β οΈ Gotchas worth remembering
-
Method not registered? If you send
DELETE /booksand no handler accepts it, you get a404 rest_no_route. WordPress treats "route + method" as the unit. -
Don't mix up the args. Each handler has its own
args. Creating a book needs a requiredtitle, but listing a book needs optionalpageandper_page. -
EDITABLEincludesPOST. If you useWP_REST_Server::EDITABLEon/books/{id}, thenPOST /books/5will also update. It's handy, but it can surprise you. In this article I'll listPUTandPATCHexplicitly. -
One call is cleaner. Calling
register_rest_route()twice for the same route merges the handlers, but keeping them together in one call makes the code much easier to read.
π Part 4: The Complete Bookshelf API
Here's the endpoint map we're building. Keep this table handy, we'll go through it top to bottom:
| Method | Endpoint | Purpose |
|---|---|---|
POST |
/wp-json/bookshelf/v1/books |
Create a book |
GET |
/wp-json/bookshelf/v1/books |
List books with pagination |
GET |
/wp-json/bookshelf/v1/books/{id} |
Retrieve one book |
PUT / PATCH
|
/wp-json/bookshelf/v1/books/{id} |
Update a book |
DELETE |
/wp-json/bookshelf/v1/books/{id} |
Delete a book |
π§± The controller skeleton
All five handlers live in one class. First, the registration and the shared helpers:
<?php
/**
* Books REST controller.
*
* @package Bookshelf
*/
namespace Bookshelf;
use WP_Error;
use WP_Post;
use WP_Query;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
defined( 'ABSPATH' ) || exit;
/**
* Class Books_Controller
*/
class Books_Controller {
const REST_NAMESPACE = 'bookshelf/v1';
/**
* Hook into WordPress.
*
* @return void
*/
public function register_hooks(): void {
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register the collection and single-item routes.
*
* @return void
*/
public function register_routes(): void {
// Collection route: /books.
register_rest_route(
self::REST_NAMESPACE,
'/books',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => '__return_true',
'args' => $this->get_collection_args(),
),
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'create_item' ),
'permission_callback' => array( $this, 'can_create' ),
'args' => $this->get_write_args( true ),
),
)
);
// Single-item route: /books/{id}.
register_rest_route(
self::REST_NAMESPACE,
'/books/(?P<id>\d+)',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => '__return_true',
),
array(
'methods' => array( 'PUT', 'PATCH' ),
'callback' => array( $this, 'update_item' ),
'permission_callback' => array( $this, 'can_modify' ),
'args' => $this->get_write_args( false ),
),
array(
'methods' => WP_REST_Server::DELETABLE,
'callback' => array( $this, 'delete_item' ),
'permission_callback' => array( $this, 'can_modify' ),
'args' => array(
'force' => array(
'type' => 'boolean',
'default' => false,
),
),
),
)
);
}
/**
* Permission: can the current user create books?
*
* @return bool
*/
public function can_create(): bool {
return current_user_can( 'edit_posts' );
}
/**
* Permission: can the current user edit or delete this specific book?
*
* @param WP_REST_Request $request Request object.
* @return bool
*/
public function can_modify( WP_REST_Request $request ): bool {
return current_user_can( 'edit_post', absint( $request['id'] ) );
}
And the argument definitions. This is where validation and sanitization happen before your callback even runs:
/**
* Args for the list endpoint.
*
* @return array
*/
private function get_collection_args(): array {
return array(
'page' => array(
'type' => 'integer',
'default' => 1,
'minimum' => 1,
'sanitize_callback' => 'absint',
),
'per_page' => array(
'type' => 'integer',
'default' => 10,
'minimum' => 1,
'maximum' => 50,
'sanitize_callback' => 'absint',
),
'search' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
);
}
/**
* Args for create and update.
*
* @param bool $is_create Title is required only when creating.
* @return array
*/
private function get_write_args( bool $is_create ): array {
return array(
'title' => array(
'type' => 'string',
'required' => $is_create,
'minLength' => 1,
'sanitize_callback' => 'sanitize_text_field',
),
'author' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
'rating' => array(
'type' => 'integer',
'minimum' => 1,
'maximum' => 5,
'sanitize_callback' => 'absint',
),
'status' => array(
'type' => 'string',
'enum' => array( 'publish', 'draft' ),
),
);
}
Now the fun part. Each method gets its own mini-section: the code, a curl call, and the response.
π‘ Testing tip: for write requests, create an Application Password (Users β Profile) and send it with
curl -u "username:app-password". If you call the API from inside the browser, send theX-WP-Nonceheader instead.
1οΈβ£ POST: Create a Book
/**
* POST /books
*
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response|WP_Error
*/
public function create_item( WP_REST_Request $request ) {
$post_id = wp_insert_post(
wp_slash(
array(
'post_type' => 'book',
'post_title' => $request['title'],
'post_status' => $request['status'] ?? 'publish',
'meta_input' => array(
'_bookshelf_author' => $request['author'] ?? '',
'_bookshelf_rating' => $request['rating'] ?? 0,
),
)
),
true
);
if ( is_wp_error( $post_id ) ) {
return $post_id;
}
$response = new WP_REST_Response( $this->prepare_item( get_post( $post_id ) ), 201 );
$response->header( 'Location', rest_url( self::REST_NAMESPACE . '/books/' . $post_id ) );
return $response;
}
Notice the 201 Created status and the Location header. It's the polite REST way to say "here's where your new thing lives."
Request
curl -X POST "https://example.com/wp-json/bookshelf/v1/books" \
-u "kushang:xxxx xxxx xxxx xxxx" \
-H "Content-Type: application/json" \
-d '{"title":"Atomic Habits","author":"James Clear","rating":5}'
Response (201)
{
"id": 42,
"title": "Atomic Habits",
"author": "James Clear",
"rating": 5,
"status": "publish",
"link": "https://example.com/wp-json/bookshelf/v1/books/42"
}
Forget the title? You'll automatically get a 400 with rest_missing_callback_param. You didn't write a single if for that. π
2οΈβ£ GET: List Books (with Pagination)
/**
* GET /books
*
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function get_items( WP_REST_Request $request ): WP_REST_Response {
$query = new WP_Query(
array(
'post_type' => 'book',
'post_status' => 'publish',
'paged' => $request['page'],
'posts_per_page' => $request['per_page'],
's' => $request['search'] ?? '',
)
);
$items = array_map( array( $this, 'prepare_item' ), $query->posts );
$response = new WP_REST_Response( $items, 200 );
$response->header( 'X-WP-Total', (int) $query->found_posts );
$response->header( 'X-WP-TotalPages', (int) $query->max_num_pages );
return $response;
}
Pagination info goes in the headers, just like core endpoints do. Your JSON body stays a clean array.
Request
curl "https://example.com/wp-json/bookshelf/v1/books?page=1&per_page=2&search=habits"
Response (200, headers: X-WP-Total: 1, X-WP-TotalPages: 1)
[
{
"id": 42,
"title": "Atomic Habits",
"author": "James Clear",
"rating": 5,
"status": "publish",
"link": "https://example.com/wp-json/bookshelf/v1/books/42"
}
]
Try per_page=500 and you'll get a 400 back, thanks to maximum => 50. Always cap your list endpoints! π‘οΈ
3οΈβ£ GET: Retrieve One Book
We need a small helper that finds the book or returns a proper 404:
/**
* Fetch a book or return a 404 error.
*
* @param int $id Book ID.
* @return WP_Post|WP_Error
*/
private function get_book( int $id ) {
$post = get_post( $id );
if ( ! $post instanceof WP_Post || 'book' !== $post->post_type ) {
return new WP_Error(
'bookshelf_not_found',
__( 'Book not found.', 'bookshelf' ),
array( 'status' => 404 )
);
}
return $post;
}
/**
* GET /books/{id}
*
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response|WP_Error
*/
public function get_item( WP_REST_Request $request ) {
$book = $this->get_book( absint( $request['id'] ) );
if ( is_wp_error( $book ) ) {
return $book;
}
return new WP_REST_Response( $this->prepare_item( $book ), 200 );
}
Request
curl "https://example.com/wp-json/bookshelf/v1/books/42"
Response (200) β the same single book object you saw above.
Missing book β 404:
{
"code": "bookshelf_not_found",
"message": "Book not found.",
"data": { "status": 404 }
}
4οΈβ£ PUT / PATCH: Update a Book
/**
* PUT|PATCH /books/{id}
*
* Only the fields that were sent get updated.
*
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response|WP_Error
*/
public function update_item( WP_REST_Request $request ) {
$book = $this->get_book( absint( $request['id'] ) );
if ( is_wp_error( $book ) ) {
return $book;
}
$update = array( 'ID' => $book->ID );
if ( $request->has_param( 'title' ) ) {
$update['post_title'] = $request['title'];
}
if ( $request->has_param( 'status' ) ) {
$update['post_status'] = $request['status'];
}
$result = wp_update_post( wp_slash( $update ), true );
if ( is_wp_error( $result ) ) {
return $result;
}
if ( $request->has_param( 'author' ) ) {
update_post_meta( $book->ID, '_bookshelf_author', $request['author'] );
}
if ( $request->has_param( 'rating' ) ) {
update_post_meta( $book->ID, '_bookshelf_rating', $request['rating'] );
}
return new WP_REST_Response( $this->prepare_item( get_post( $book->ID ) ), 200 );
}
π§ PUT vs PATCH: by the book,
PUTreplaces the whole resource andPATCHchanges a few fields. WordPress core is relaxed here: both are treated as "update what I sent". I'm following core's habit, since it's friendlier for clients.
Request
curl -X PATCH "https://example.com/wp-json/bookshelf/v1/books/42" \
-u "kushang:xxxx xxxx xxxx xxxx" \
-H "Content-Type: application/json" \
-d '{"rating":4}'
Response (200)
{
"id": 42,
"title": "Atomic Habits",
"author": "James Clear",
"rating": 4,
"status": "publish",
"link": "https://example.com/wp-json/bookshelf/v1/books/42"
}
5οΈβ£ DELETE: Remove a Book
/**
* DELETE /books/{id}
*
* Moves to trash by default. Pass force=true to delete permanently.
*
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response|WP_Error
*/
public function delete_item( WP_REST_Request $request ) {
$book = $this->get_book( absint( $request['id'] ) );
if ( is_wp_error( $book ) ) {
return $book;
}
$previous = $this->prepare_item( $book );
$deleted = wp_delete_post( $book->ID, (bool) $request['force'] );
if ( ! $deleted ) {
return new WP_Error(
'bookshelf_delete_failed',
__( 'The book could not be deleted.', 'bookshelf' ),
array( 'status' => 500 )
);
}
return new WP_REST_Response(
array(
'deleted' => (bool) $request['force'],
'previous' => $previous,
),
200
);
}
Request
curl -X DELETE "https://example.com/wp-json/bookshelf/v1/books/42?force=true" \
-u "kushang:xxxx xxxx xxxx xxxx"
Response (200)
{
"deleted": true,
"previous": { "id": 42, "title": "Atomic Habits", "...": "..." }
}
π The Shared Formatter
One last helper so every endpoint returns the same shape. This is where we also escape the output we send out:
/**
* Turn a book post into a clean API array.
*
* @param WP_Post $post Book post.
* @return array
*/
private function prepare_item( WP_Post $post ): array {
return array(
'id' => $post->ID,
'title' => $post->post_title,
'author' => (string) get_post_meta( $post->ID, '_bookshelf_author', true ),
'rating' => (int) get_post_meta( $post->ID, '_bookshelf_rating', true ),
'status' => $post->post_status,
'link' => esc_url_raw( rest_url( self::REST_NAMESPACE . '/books/' . $post->ID ) ),
);
}
}
A quick note on escaping: JSON isn't HTML, so running esc_html() on API values would give clients double-encoded text like &. The rule of thumb is sanitize on the way in, escape at the place you render. Use esc_url_raw() for URLs, and let your front-end escape titles when it prints them.
π Security Checklist (Pin This One)
- β
Always set
permission_callback. Use__return_trueonly for data that's truly public, and say so in a comment. - β
Validate with
type,enum,minimum,maximum. - β
Sanitize with
sanitize_callbackbefore saving. - β
Check capabilities per item (
edit_post) and not only globally. - β
Cap
per_page. - β
Return a
WP_Errorwith a properstatuscode and neverdie()orecho. - β
Use
wp_slash()when passing REST data towp_insert_post()andwp_update_post().
π Final Verdict
The WP REST API looks big at first, but once you see the pattern it's actually tiny:
One namespace β routes β an array of handlers β each handler = methods + callback + permissions + args.
The thing to take with you is Part 3. A route is just an address, and the HTTP method decides which handler answers. Once that clicks, GET /books and POST /books living side by side stops feeling like magic.
A few honest opinions after building this:
- π§± Use a controller class. A pile of closures works for demos, but a class keeps your routes testable and readable.
- π‘οΈ Let
argsdo the heavy lifting. Declarative validation means fewerifblocks and fewer bugs. - π Treat permissions as a feature, not an afterthought. Most REST API security issues come from a lazy
permission_callback. - π¦ Version your namespace (
/v1). Your future self will thank you when/v2arrives.
Now it's your turn: take the Bookshelf, swap books for whatever your project needs (tasks, events, orders), and ship something. Got questions or a cooler example? Drop them in the comments, I read every one! π¬
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.