DEV Community

Cover image for WordPress REST API Deep Dive: Custom Routes, Multiple Methods on One Endpoint, and Full CRUD
Kushang Tailor
Kushang Tailor

Posted on Edited on

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 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, PATCH and DELETE
  • 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β”˜
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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.
        )
    );
} );
Enter fullscreen mode Exit fullscreen mode

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 */ )
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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' ),
            )
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

πŸ”€ Part 3: Same Route, Different Methods

Here's the question I hear the most:

"If /books is 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 ),
        ),
    )
);
Enter fullscreen mode Exit fullscreen mode

Same URL, two different jobs, two different bouncers. Notice that listing is public while creating needs a capability.

πŸ” What WordPress does on each request

  1. Matches the URL against every registered route (the regex).
  2. Finds the handler whose methods contains the request's method.
  3. Runs the permission_callback (fail β†’ 401 or 403).
  4. Validates and sanitizes args (fail β†’ 400).
  5. Finally runs your callback. βœ…

⚠️ Gotchas worth remembering

  • Method not registered? If you send DELETE /books and no handler accepts it, you get a 404 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 required title, but listing a book needs optional page and per_page.
  • EDITABLE includes POST. If you use WP_REST_Server::EDITABLE on /books/{id}, then POST /books/5 will also update. It's handy, but it can surprise you. In this article I'll list PUT and PATCH explicitly.
  • 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'] ) );
    }
Enter fullscreen mode Exit fullscreen mode

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' ),
            ),
        );
    }
Enter fullscreen mode Exit fullscreen mode

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 the X-WP-Nonce header 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"
Enter fullscreen mode Exit fullscreen mode

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)"
Enter fullscreen mode Exit fullscreen mode

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;
    }
Enter fullscreen mode Exit fullscreen mode

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}'
Enter fullscreen mode Exit fullscreen mode

Response (201)

{
  "id": 42,
  "title": "Atomic Habits",
  "author": "James Clear",
  "rating": 5,
  "status": "publish",
  "link": "https://example.com/wp-json/bookshelf/v1/books/42"
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
  }
]
Enter fullscreen mode Exit fullscreen mode

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 );
    }
Enter fullscreen mode Exit fullscreen mode

Request

curl "https://example.com/wp-json/bookshelf/v1/books/42"
Enter fullscreen mode Exit fullscreen mode

Response (200) β†’ the same single book object you saw above.

Missing book β†’ 404:

{
  "code": "bookshelf_not_found",
  "message": "Book not found.",
  "data": { "status": 404 }
}
Enter fullscreen mode Exit fullscreen mode

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 );
    }
Enter fullscreen mode Exit fullscreen mode

🧠 PUT vs PATCH: by the book, PUT replaces the whole resource and PATCH changes 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}'
Enter fullscreen mode Exit fullscreen mode

Response (200)

{
  "id": 42,
  "title": "Atomic Habits",
  "author": "James Clear",
  "rating": 4,
  "status": "publish",
  "link": "https://example.com/wp-json/bookshelf/v1/books/42"
}
Enter fullscreen mode Exit fullscreen mode

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
        );
    }
Enter fullscreen mode Exit fullscreen mode

Request

curl -X DELETE "https://example.com/wp-json/bookshelf/v1/books/42?force=true" \
  -u "kushang:xxxx xxxx xxxx xxxx"
Enter fullscreen mode Exit fullscreen mode

Response (200)

{
  "deleted": true,
  "previous": { "id": 42, "title": "Atomic Habits", "...": "..." }
}
Enter fullscreen mode Exit fullscreen mode

🎁 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 ) ),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

A quick note on escaping: JSON isn't HTML, so running esc_html() on API values would give clients double-encoded text like &amp;. 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_true only for data that's truly public, and add a comment saying so.
  • βœ… Validate with type, enum, minimum, maximum.
  • βœ… Sanitize with sanitize_callback before 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_Error with a proper status code and never die() or echo.
  • βœ… Use wp_slash() when passing REST data to wp_insert_post() and wp_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"}'
Enter fullscreen mode Exit fullscreen mode

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' ),
    )
);
Enter fullscreen mode Exit fullscreen mode

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 );
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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_ADDR may 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 args do the heavy lifting. Declarative validation means fewer if blocks 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 /v2 arrives.

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)

Collapse
 
ryan_accuweb_cloud profile image
Ryan_AccuWebCloud •

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 => 50 and 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 with manage_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 /books endpoint with a search parameter 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

Collapse
 
kushang_tailor profile image
Kushang Tailor •

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.

Collapse
 
devenquan profile image
Zhang Enquan •

Good walkthrough. Two things cost me real time and aren't obvious from the docs:

  1. 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.

  2. 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.

Collapse
 
kushang_tailor profile image
Kushang Tailor •

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. πŸ”

Collapse
 
devenquan profile image
Zhang Enquan •

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.

Collapse
 
kushang_tailor profile image
Kushang Tailor •

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. πŸ˜„