πͺ 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.
π Reading Data Back: Two Traps That Look Like Bugs
You update something, read it back, and the result looks wrong. Before you start debugging, check these two things. (Big thanks to a reader in the comments, @devenquan , for sharing both!)
Trap 1: rendered vs raw
When you read content from core endpoints like /wp/v2/posts, you get content.rendered. That's the finished version, after WordPress filters (paragraph tags, shortcodes, and so on) have run.
The text you actually saved is content.raw, and you only get it with ?context=edit (you must be logged in with edit rights):
curl "https://example.com/wp-json/wp/v2/posts/42?context=edit" \
-u "kushang:xxxx xxxx xxxx xxxx"
If you save Hello and read back <p>Hello</p>, your write didn't fail. You are comparing two different versions of the same text. β
Trap 2: Stale reads after an update
You send a PUT, then a GET, and still see the old value. Often a page cache or CDN served the old response. A simple trick for testing is to add a throwaway query parameter, so the URL looks new every time:
curl "https://example.com/wp-json/wp/v2/posts/42?_cb=$(date +%s)"
It costs nothing and can save you an hour of chasing a ghost. π»
π‘ Tip: use this in your test scripts only. In production, fix your caching rules so API responses aren't cached when they shouldn't be.
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. If you forget it, WordPress doesn't block the route. On WordPress 5.5+ it only logs a warning in the debug log, and the route stays open to everyone. On older versions you don't even get the warning. Use__return_trueonly for data that's truly public, and add a comment saying so. - β
Validate with
type,enum,minimum,maximum. - β
Sanitize with
sanitize_callbackbefore saving. - β
Check capabilities per item (
edit_post) and not only globally. - β
Cap
per_page, but don't lock out legit users. Keep a strict limit on the public endpoint. For trusted users (like admins exporting 2,000 records), allow a higher limit or build a separate export route. - β
Add rate limiting on public endpoints. WordPress doesn't do it for you, so a public
GET /books?search=...can be spammed to overload your database. - β
Return a
WP_Errorwith a properstatuscode and neverdie()orecho. - β
Use
wp_slash()when passing REST data towp_insert_post()andwp_update_post().
π΅οΈ Test It Like a Stranger
A route without a proper permission_callback doesn't throw errors. It works, your tests pass, and a code review has nothing visible to flag. That's why you should test it on purpose.
Send every write request without any credentials:
curl -i -X POST "https://example.com/wp-json/bookshelf/v1/books" \
-H "Content-Type: application/json" \
-d '{"title":"Should Not Work"}'
What you want to see:
| Result | Meaning |
|---|---|
401 rest_forbidden
|
β Locked: you aren't logged in |
403 rest_forbidden
|
β Locked: logged in, but without permission |
200 / 201
|
π¨ Anyone can write to this route. Fix it now |
Do it for POST, PUT, PATCH and DELETE. It takes a minute, and it catches the problem a review often can't see.
π Don't Let a Missing permission_callback Slip Through
This is the sneakiest one. Your route works fine in testing, so nobody notices. Here's the wrong way and the right way:
// β Missing permission_callback: the route works, but it's public.
register_rest_route(
'bookshelf/v1',
'/books',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'create_item' ),
)
);
// β
Always be explicit.
register_rest_route(
'bookshelf/v1',
'/books',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'create_item' ),
'permission_callback' => array( $this, 'can_create' ),
)
);
Tip: turn on WP_DEBUG and WP_DEBUG_LOG while developing and check the log for _doing_it_wrong notices about missing permission callbacks.
π A Smarter per_page Limit
A hard maximum in args applies to everyone. If you want a higher limit for admins, remove maximum from the schema and clamp the value inside the callback:
$max_per_page = current_user_can( 'manage_options' ) ? 500 : 50;
$per_page = min( (int) $request['per_page'], $max_per_page );
Visitors asking for more than 50 are quietly limited to 50, while admins can pull larger batches. Another clean option is a separate permission-protected route such as /books/export.
π¦ Basic Rate Limiting with Transients
Here's a simple per-IP limit for the public GET /books route. Use it as its permission_callback:
/**
* Permission: allow public reads, but limit requests per IP.
*
* @return true|WP_Error
*/
public function can_read() {
$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
$key = 'bookshelf_rl_' . md5( $ip );
$hits = (int) get_transient( $key );
if ( $hits >= 60 ) {
return new WP_Error(
'bookshelf_rate_limited',
__( 'Too many requests. Please slow down.', 'bookshelf' ),
array( 'status' => 429 )
);
}
set_transient( $key, $hits + 1, MINUTE_IN_SECONDS );
return true;
}
This allows 60 requests per minute per IP and then returns a 429 Too Many Requests.
β οΈ Keep in mind:
- It's a basic safety net, not a full solution. Transients aren't atomic, so heavy bursts can slip through.
- Behind a proxy or CDN,
REMOTE_ADDRmay show the proxy's IP and not the visitor's. Read the real client IP carefully and only trust headers set by your own infrastructure. - For serious traffic, rate limit at the server, CDN or WAF level.
π 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! π¬
Top comments (6)
Hello,
Nice write-up! I mostly agree with the security checklist, but I want to add one point about the advice to "always cap
per_page."Setting a limit is important, but I have seen teams set
maximum => 50and then face problems when an internal tool needs to export 2,000 records. A better approach is to limit the public endpoint and create a separate route for users with the right permissions. Another option is to allow a higher limit for users withmanage_options. This avoids forcing users to paginate 40 times when they need to read a large number of records for a valid reason.I would also add rate limiting to the checklist. WordPress does not provide this by default. A public
GET /booksendpoint with asearchparameter can be used to send too many requests to the database. Even a simple rate limit using transients for each IP address is better than having no protection.The point about PUT vs PATCH is spot on, though. Following WordPress core's "update what I sent" behavior is a practical choice. I wish more tutorials explained this instead of assuming that everyone follows strict PUT rules.
I'm curious: have you ever worked with clients who expected PUT to replace the entire resource? If so, how did you handle it?
Regards,
Ryan
Thanks Ryan, great points! π Youβre right that a hard maximum can hurt legit use cases. A higher limit for manage_options or a separate export route is a cleaner way to handle it, and Iβll add it to the article. Rate limiting is a good catch too. A transient per IP works as a basic guard, but behind a proxy or CDN you have to read the real client IP carefully, and doing it at the server or WAF level is even better. π
On PUT vs PATCH: honestly, I havenβt had a client expect strict PUT behavior yet, since most of my work follows WordPress coreβs βupdate what I sentβ style. If it did come up, Iβd document the behavior clearly, make PUT require all fields, and keep PATCH for partial updates. But, As suggestion the safest approach I know is to document clearly what your endpoint does, so clients arenβt guessing. If someone needs strict βreplace everythingβ behavior, you can make PUT require all fields and keep PATCH for partial updates.
Good walkthrough. Two things cost me real time and aren't obvious from the docs:
Reading content back: GET /wp/v2/posts/ returns content.rendered β already through the_content filters. You need ?context=edit to get content.raw. PUT raw, then GET without context=edit, and you're comparing two different strings and concluding your write failed.
Object cache: after a PUT, the next GET can still hand you the old body. I append &_cb= on every read in my scripts. Costs nothing, saves an hour of chasing a ghost.
One more for the security section: a missing permission_callback doesn't fail loudly on older installs, it only warns into the debug log. That's how a lot of custom routes end up world-writable without anyone noticing.
Thanks for sharing these, super useful! π The content.raw vs content.rendered mix-up is a classic. ?context=edit is the fix, and it needs an authenticated user with edit rights. A cache-buster like &_cb= is a smart habit too, since page caches or CDNs can serve stale reads even when WordPress itself is fine. And yes, a missing permission_callback only triggers a notice on newer versions (5.5+), and the route still works as public. Iβll add that point to the security section. π
Glad the cache-buster habit made the cut too - that one saved me a full debugging session once. Looking forward to the security section update; the silent-public-route case is exactly the kind of thing that only bites after code review signs off.
Thanks! π The security section is updated now, with the silent-public-route case front and center, including a before/after code example. Youβre right that it slips past code review because nothing visibly breaks, so I also added a tip to watch the debug log for those notices during development. Iβll add the cache-buster tip as a small note in the article too, giving you credit for it. π