TechEarl

Add a Custom REST API Endpoint in WordPress (register_rest_route)

How to add a custom WordPress REST API endpoint with register_rest_route: the namespace convention, methods, the required permission_callback, args validation and sanitization, and returning WP_REST_Response or WP_Error.

Ishan Karunaratne⏱️ 10 min readUpdated
Share thisCopied
How to register a custom WordPress REST API endpoint with register_rest_route on rest_api_init: namespace, methods, the required permission_callback, args validation and sanitization, and WP_REST_Response output.

To add a custom REST API endpoint in WordPress, call register_rest_route() on the rest_api_init hook. Here is the whole shape of it: a namespaced route, the methods it answers, the callback that does the work, and the permission_callback that decides who is allowed in:

php
add_action( 'rest_api_init', 'te_register_routes' );

function te_register_routes() {
    register_rest_route( 'te/v1', '/thing/(?P<id>\d+)', array(
        'methods'             => WP_REST_Server::READABLE,
        'callback'            => 'te_get_thing',
        'permission_callback' => function () {
            return current_user_can( 'read' );
        },
        'args'                => array(
            'id' => array(
                'required'          => true,
                'validate_callback' => function ( $value ) {
                    return is_numeric( $value );
                },
                'sanitize_callback' => 'absint',
            ),
        ),
    ) );
}

function te_get_thing( WP_REST_Request $request ) {
    $id = (int) $request->get_param( 'id' );

    $thing = get_post( $id );
    if ( ! $thing ) {
        return new WP_Error(
            'te_not_found',
            'No thing found for that id.',
            array( 'status' => 404 )
        );
    }

    return rest_ensure_response( array(
        'id'    => $id,
        'title' => get_the_title( $thing ),
    ) );
}

That registers GET /wp-json/te/v1/thing/1. The rest of this article walks each piece, with the most weight on permission_callback, because that is the one people leave on the floor and turn a private endpoint into a public one without noticing.

The anatomy: namespace, route, methods

register_rest_route() takes a namespace, a route pattern, and an options array. The signature is register_rest_route( $route_namespace, $route, $args, $override ).

Namespace. The first segment, te/v1, follows the vendor/version convention. The vendor part should be unique to your plugin so it never collides with another plugin's routes or with core's own wp/v2. The version part lets you ship a te/v2 later with breaking changes while te/v1 keeps working for existing clients. Pick a short vendor token and stick with it across all of a plugin's routes.

Route. The /thing/(?P<id>\d+) pattern uses a named capture group. (?P<id>\d+) matches one or more digits and exposes the captured value as a parameter called id, which is why $request->get_param( 'id' ) works in the callback. The \d+ constraint means a request to /thing/abc never reaches your callback at all: it does not match the route, so WordPress returns a 404 before any of your code runs. That is free input filtering, and it is worth designing the pattern tightly for that reason.

Methods. Use the WP_REST_Server constants rather than bare strings so the intent is obvious and typo-proof:

ConstantHTTP verbs
WP_REST_Server::READABLEGET
WP_REST_Server::CREATABLEPOST
WP_REST_Server::EDITABLEPOST, PUT, PATCH
WP_REST_Server::DELETABLEDELETE
WP_REST_Server::ALLMETHODSGET, POST, PUT, PATCH, DELETE

You can register several method blocks on one route by passing an array of arrays, so a single /thing/(?P<id>\d+) route can answer a readable GET with one callback and an editable PUT with another.

permission_callback: the part people get dangerously wrong

permission_callback is required, and it has been since WordPress 5.5 (August 2020). Omit it and WordPress throws a _doing_it_wrong() notice that reads roughly: the route definition is missing the required permission_callback argument, and for routes that are intended to be public you should use __return_true. The endpoint may still register, but you have shipped an explicit warning that something is wrong.

Here is the trap. The callback's return value is interpreted like this: false, null, or a WP_Error denies access; anything else grants it. So the fix for the notice that most people reach for, dropping in __return_true, does exactly what it says:

php
// This makes the endpoint fully public. Every visitor, logged in or not.
'permission_callback' => '__return_true',

That is the correct thing to write only when the data is genuinely public and you have decided so on purpose. It is the wrong thing to paste in just to silence the notice on an endpoint that exposes anything sensitive. I have read more than one plugin that returned user records or order data through an endpoint guarded by __return_true, because the author wanted the warning gone and __return_true was the first suggestion they found.

For a private endpoint, do a real check. Capability checks are the idiomatic way:

php
'permission_callback' => function ( WP_REST_Request $request ) {
    if ( ! current_user_can( 'edit_posts' ) ) {
        return new WP_Error(
            'te_forbidden',
            'You are not allowed to do that.',
            array( 'status' => 403 )
        );
    }
    return true;
},

Returning a WP_Error with a status is better than returning a bare false, because it lets you set 401 versus 403 and give the client a usable message instead of a generic refusal.

For a machine-to-machine endpoint with no logged-in user (a webhook, a server calling in), the permission check is where you verify a shared secret or signed header. Keep the comparison constant-time and never log the secret:

php
'permission_callback' => function ( WP_REST_Request $request ) {
    $sent     = (string) $request->get_header( 'x-te-api-key' );
    $expected = (string) get_option( 'te_api_key' );

    if ( '' === $expected || ! hash_equals( $expected, $sent ) ) {
        return new WP_Error(
            'te_bad_key',
            'Invalid API key.',
            array( 'status' => 401 )
        );
    }
    return true;
},

The one rule that covers all of this: permission_callback is the front door. The callback runs only after it returns truthy, so any check you skip here is a check that does not happen. Treat it as the security boundary it is, not as boilerplate to satisfy the linter.

Validating and sanitizing args

The args map declares the parameters your endpoint accepts and attaches per-parameter handling. Three keys do the heavy lifting: required, validate_callback, and sanitize_callback.

php
'args' => array(
    'id' => array(
        'required'          => true,
        'type'              => 'integer',
        'validate_callback' => function ( $value, $request, $key ) {
            return is_numeric( $value ) && (int) $value > 0;
        },
        'sanitize_callback' => 'absint',
    ),
    'format' => array(
        'required'          => false,
        'type'              => 'string',
        'enum'              => array( 'short', 'full' ),
        'default'           => 'short',
        'sanitize_callback' => 'sanitize_key',
    ),
),

The order matters. validate_callback runs first: return true and the request proceeds, return false or a WP_Error and WordPress rejects the request with a 400 before your main callback ever runs. sanitize_callback runs after validation passes and transforms the raw value into the clean value your callback receives. So validation answers "is this acceptable?" and sanitization answers "give me the safe, normalized form."

A couple of practical notes. For an enumerated parameter like format, declaring enum lets the schema reject anything outside the list without a custom validator. required => true means a missing parameter is a 400, not a silent default. And do not lean on sanitize_callback to do validation: absint will happily turn "-5" into 5 and "abc" into 0, so a value that should have been rejected gets quietly coerced into something plausible. Validate for correctness, sanitize for safety, and keep the two jobs separate.

Reading params, responses, and errors

Inside the callback, read every parameter through $request->get_param( 'name' ). It pulls from the URL pattern, the query string, and the body in a consistent order, and it returns the sanitized value (the one your sanitize_callback produced), not the raw input. WP_REST_Request also implements array access, so $request['id'] works, but get_param() is clearer about intent.

For the response, wrap your data in rest_ensure_response(). It takes a plain array (or an existing WP_REST_Response) and guarantees you hand back a proper response object:

php
return rest_ensure_response( array(
    'id'    => $id,
    'title' => get_the_title( $id ),
) );

When you need to set a specific status code or add headers, build the WP_REST_Response yourself:

php
$response = new WP_REST_Response( array( 'created' => $id ), 201 );
$response->header( 'Location', rest_url( "te/v1/thing/{$id}" ) );
return $response;

For failures, return a WP_Error rather than a response with an error-shaped body. WordPress maps the status you pass in the data array to the real HTTP status code and serializes the error consistently, so clients get a 404 or a 422 with a machine-readable code instead of a 200 carrying {"error": ...} that every caller has to special-case:

php
return new WP_Error(
    'te_not_found',
    'No thing found for that id.',
    array( 'status' => 404 )
);

Verify it with curl

curl fetching the custom /wp-json/te/v1/thing/7 REST endpoint and its JSON response
Real output: the custom REST endpoint returning JSON.

Once the route is registered, hit it directly. For a public endpoint:

bash
curl -s https://example.com/wp-json/te/v1/thing/1

For a private one, authenticate. The simplest path for testing is an application password (Users to Profile to Application Passwords in wp-admin), passed as HTTP Basic auth:

bash
curl -s -u "admin:xxxx xxxx xxxx xxxx xxxx xxxx" \
  https://example.com/wp-json/te/v1/thing/1

Two quick sanity checks. First, hit https://example.com/wp-json/ with no path: the index lists every registered namespace, and te/v1 should appear in it. If it does not, your rest_api_init hook is not firing (wrong file, plugin not active, typo in the action name). Second, request a bad id like /thing/abc and confirm you get a 404 from the route not matching, which proves the \d+ constraint is doing its job. On a site without pretty permalinks, the same routes are reachable as ?rest_route=/te/v1/thing/1 instead of the /wp-json/ path.

A drop-in plugin

Here is the whole thing as a small plugin you can drop into wp-content/plugins/ (or mu-plugins/) and adapt. It keeps the route definition, the permission check, the arg schema, and the callback in one file:

php
<?php
/**
 * Plugin Name: TE REST Endpoint
 * Plugin URI:  https://techearl.com/wordpress-custom-rest-api-endpoint
 * Description: A minimal, correctly-secured custom REST API endpoint registered with register_rest_route.
 * Version:     1.0.0
 * Author:      Ishan Karunaratne
 * Author URI:  https://techearl.com
 * License:     GPL-2.0-or-later
 * Text Domain: te-rest-endpoint
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

add_action( 'rest_api_init', 'te_register_routes' );

function te_register_routes() {
    register_rest_route( 'te/v1', '/thing/(?P<id>\d+)', array(
        'methods'             => WP_REST_Server::READABLE,
        'callback'            => 'te_get_thing',
        'permission_callback' => 'te_thing_permissions_check',
        'args'                => array(
            'id' => array(
                'required'          => true,
                'type'              => 'integer',
                'validate_callback' => function ( $value ) {
                    return is_numeric( $value ) && (int) $value > 0;
                },
                'sanitize_callback' => 'absint',
            ),
        ),
    ) );
}

function te_thing_permissions_check( WP_REST_Request $request ) {
    if ( ! current_user_can( 'read' ) ) {
        return new WP_Error(
            'te_forbidden',
            'You are not allowed to read this.',
            array( 'status' => 403 )
        );
    }
    return true;
}

function te_get_thing( WP_REST_Request $request ) {
    $id    = (int) $request->get_param( 'id' );
    $thing = get_post( $id );

    if ( ! $thing || 'publish' !== get_post_status( $thing ) ) {
        return new WP_Error(
            'te_not_found',
            'No thing found for that id.',
            array( 'status' => 404 )
        );
    }

    return rest_ensure_response( array(
        'id'    => $id,
        'title' => get_the_title( $thing ),
        'link'  => get_permalink( $thing ),
    ) );
}

Swap current_user_can( 'read' ) for the real capability your data needs (or the API-key check above for a machine caller), replace the get_post() body with your actual data source, and you have a production-shaped endpoint: registered on the right hook, locked behind a permission check, with validated and sanitized input and well-formed responses and errors.

See also

Sources

Authoritative references this article was fact-checked against.

TagsWordPressREST APIPHPregister_rest_routepermission_callback

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

Add a Custom WP-CLI Command in WordPress

How to register a custom WP-CLI command: guard it with defined('WP_CLI'), wire it up with WP_CLI::add_command(), turn class methods into subcommands, document args with @synopsis, and show progress with make_progress_bar().