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

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.


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. Use __return_true only for data that's truly public, and say so in a comment.
  • βœ… 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.
  • βœ… 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().

🏁 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 (2)

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