Custom REST API endpoints

Register your own routes with validated arguments, a permission check on every route, and proper JSON responses.

  • Intermediate
  • 13 min read
  • Updated 17 Oct 2026

What you will learn

  • Register a REST route
  • Write a permission callback
  • Validate and sanitise route arguments
  • Return responses and errors with the right status codes
  • Call a protected endpoint from JavaScript with a nonce

Before you start

The core endpoints cover posts, terms and users. When a front end needs something shaped differently, such as "upcoming events with just the fields the calendar uses" or "RSVP to this event", you add your own route. This lesson builds three endpoints for the event plugin and covers the three things every endpoint must get right: who may call it, what input it accepts, and what it returns.

Anatomy of a route

Routes are registered on the rest_api_init action with register_rest_route():

PHP
register_rest_route( $namespace, $route, $args );
  • Namespace: your plugin's prefix plus a version, such as myplugin/v1. The version lets you change the API later without breaking old clients.
  • Route: the path after the namespace, such as /events. Path parameters use named regular expression groups: /events/(?P<id>\d+).
  • Args: an array (or a list of arrays, one per HTTP method) with these keys:
KeyPurpose
methodsThe HTTP method: WP_REST_Server::READABLE (GET), CREATABLE (POST), EDITABLE (POST, PUT, PATCH) or DELETABLE (DELETE)
callbackThe function that does the work and returns the response
permission_callbackDecides whether the current user may call the route. Always required
argsThe parameters the route accepts, with types, defaults, validation and sanitising

Every route needs a permission_callback. If you leave it out, current versions of WordPress log a "doing it wrong" notice, and the route is open to everyone. Use '__return_true' only for genuinely public, read-only data that any visitor could already see on the site, and add a comment saying so.

A complete plugin

Save this as wp-content/plugins/myplugin-events-api/myplugin-events-api.php. It assumes the myplugin_event post type, the myplugin_event_type taxonomy and the myplugin_event_date meta key from earlier lessons.

PHP
<?php
/**
 * Plugin Name: My Plugin Events API
 * Description: REST endpoints for events.
 * Version:     1.0.0
 * Text Domain: myplugin
 */

defined( 'ABSPATH' ) || exit;

add_action( 'rest_api_init', 'myplugin_register_routes' );

function myplugin_register_routes(): void {
    // GET /myplugin/v1/events
    register_rest_route(
        'myplugin/v1',
        '/events',
        array(
            'methods'             => WP_REST_Server::READABLE,
            'callback'            => 'myplugin_rest_get_events',
            // Public: only published events, which are already visible on the site.
            'permission_callback' => '__return_true',
            'args'                => array(
                'per_page' => array(
                    'description'       => __( 'Events per page.', 'myplugin' ),
                    'type'              => 'integer',
                    'default'           => 10,
                    'minimum'           => 1,
                    'maximum'           => 50,
                    'validate_callback' => 'rest_validate_request_arg',
                    'sanitize_callback' => 'absint',
                ),
                'page'     => array(
                    'description'       => __( 'Page of results.', 'myplugin' ),
                    'type'              => 'integer',
                    'default'           => 1,
                    'minimum'           => 1,
                    'validate_callback' => 'rest_validate_request_arg',
                    'sanitize_callback' => 'absint',
                ),
                'type'     => array(
                    'description'       => __( 'Event type slug.', 'myplugin' ),
                    'type'              => 'string',
                    'validate_callback' => 'myplugin_rest_validate_event_type',
                    'sanitize_callback' => 'sanitize_key',
                ),
            ),
        )
    );

    // GET /myplugin/v1/events/123
    register_rest_route(
        'myplugin/v1',
        '/events/(?P<id>\d+)',
        array(
            'methods'             => WP_REST_Server::READABLE,
            'callback'            => 'myplugin_rest_get_event',
            // Public: the callback only returns published events.
            'permission_callback' => '__return_true',
            'args'                => array(
                'id' => array(
                    'type'              => 'integer',
                    'required'          => true,
                    'validate_callback' => 'rest_validate_request_arg',
                    'sanitize_callback' => 'absint',
                ),
            ),
        )
    );

    // POST /myplugin/v1/events/123/rsvp
    register_rest_route(
        'myplugin/v1',
        '/events/(?P<id>\d+)/rsvp',
        array(
            'methods'             => WP_REST_Server::CREATABLE,
            'callback'            => 'myplugin_rest_rsvp',
            'permission_callback' => 'myplugin_rest_can_rsvp',
            'args'                => array(
                'id'     => array(
                    'type'              => 'integer',
                    'required'          => true,
                    'validate_callback' => 'rest_validate_request_arg',
                    'sanitize_callback' => 'absint',
                ),
                'guests' => array(
                    'description'       => __( 'Extra guests, not counting the user.', 'myplugin' ),
                    'type'              => 'integer',
                    'default'           => 0,
                    'minimum'           => 0,
                    'maximum'           => 5,
                    'validate_callback' => 'rest_validate_request_arg',
                    'sanitize_callback' => 'absint',
                ),
            ),
        )
    );
}

/**
 * Accept only slugs of existing event type terms.
 */
function myplugin_rest_validate_event_type( $value ) {
    if ( ! is_string( $value ) || ! term_exists( sanitize_key( $value ), 'myplugin_event_type' ) ) {
        return new WP_Error(
            'myplugin_invalid_type',
            __( 'Unknown event type.', 'myplugin' ),
            array( 'status' => 400 )
        );
    }

    return true;
}

function myplugin_rest_can_rsvp( WP_REST_Request $request ) {
    if ( ! is_user_logged_in() ) {
        return new WP_Error(
            'myplugin_login_required',
            __( 'You must be logged in to RSVP.', 'myplugin' ),
            array( 'status' => rest_authorization_required_code() )
        );
    }

    return current_user_can( 'read' );
}

/**
 * Return a published event, or null.
 */
function myplugin_get_published_event( int $id ): ?WP_Post {
    $post = get_post( $id );

    if ( ! $post || 'myplugin_event' !== $post->post_type || 'publish' !== $post->post_status ) {
        return null;
    }

    return $post;
}

function myplugin_rest_prepare_event( WP_Post $post ): array {
    return array(
        'id'    => $post->ID,
        'title' => get_the_title( $post ),
        'date'  => (string) get_post_meta( $post->ID, 'myplugin_event_date', true ),
        'link'  => get_permalink( $post ),
    );
}

function myplugin_rest_get_events( WP_REST_Request $request ): WP_REST_Response {
    $args = array(
        'post_type'      => 'myplugin_event',
        'post_status'    => 'publish',
        'posts_per_page' => $request['per_page'],
        'paged'          => $request['page'],
        'meta_key'       => 'myplugin_event_date',
        'orderby'        => 'meta_value',
        'order'          => 'ASC',
    );

    if ( ! empty( $request['type'] ) ) {
        $args['tax_query'] = array(
            array(
                'taxonomy' => 'myplugin_event_type',
                'field'    => 'slug',
                'terms'    => $request['type'],
            ),
        );
    }

    $query    = new WP_Query( $args );
    $events   = array_map( 'myplugin_rest_prepare_event', $query->posts );
    $response = new WP_REST_Response( $events, 200 );

    $response->header( 'X-WP-Total', (string) $query->found_posts );
    $response->header( 'X-WP-TotalPages', (string) $query->max_num_pages );

    return $response;
}

function myplugin_rest_get_event( WP_REST_Request $request ) {
    $post = myplugin_get_published_event( $request['id'] );

    if ( ! $post ) {
        return new WP_Error(
            'myplugin_not_found',
            __( 'Event not found.', 'myplugin' ),
            array( 'status' => 404 )
        );
    }

    return new WP_REST_Response( myplugin_rest_prepare_event( $post ), 200 );
}

function myplugin_rest_rsvp( WP_REST_Request $request ) {
    $post = myplugin_get_published_event( $request['id'] );

    if ( ! $post ) {
        return new WP_Error(
            'myplugin_not_found',
            __( 'Event not found.', 'myplugin' ),
            array( 'status' => 404 )
        );
    }

    $user_id = get_current_user_id();
    $rsvps   = get_user_meta( $user_id, 'myplugin_rsvps', true );
    $rsvps   = is_array( $rsvps ) ? $rsvps : array();

    $rsvps[ $post->ID ] = $request['guests'];
    update_user_meta( $user_id, 'myplugin_rsvps', $rsvps );

    return new WP_REST_Response(
        array(
            'event'  => $post->ID,
            'guests' => $request['guests'],
        ),
        200
    );
}

Activate the plugin and visit /wp-json/myplugin/v1/events to see the result.

Arguments: validate, then sanitise

WordPress processes each declared argument before your code sees it:

  1. Validation runs first, on the raw value. rest_validate_request_arg checks the value against the argument's own schema: type, minimum, maximum, enum, format and so on. A custom validator, such as myplugin_rest_validate_event_type(), returns true, false or a WP_Error.
  2. Sanitising runs next and converts the value to its clean form, here with absint and sanitize_key.
  3. Defaults fill in arguments the client did not send, and required arguments that are missing cause a 400 error.

If validation fails, WordPress responds with a 400 rest_invalid_param error and your callback never runs. Inside the callback, $request['per_page'] is therefore always an integer between 1 and 50.

Declare every parameter you read, including path parameters such as id. Undeclared parameters are still available through $request, but they skip validation and sanitising, which defeats the point.

The type argument shows an allowlist check: rather than trying to clean any string, it accepts only slugs that exist. Allowlists are the strongest form of validation; the sanitising lesson returns to them.

Permission callbacks

The permission callback runs after the arguments are validated and before your callback. It returns:

  • true to allow the request;
  • false to refuse it with a generic rest_forbidden error;
  • a WP_Error to refuse it with your own code, message and status.

rest_authorization_required_code() returns 401 when the user is not logged in and 403 when they are logged in but not allowed. Using it gives clients the right signal: log in, or stop trying.

Check capabilities, not roles or mere login state, whenever the action touches content. Because arguments are already sanitised when the permission callback runs, you can check permissions on a specific object. For example, a route that lets editors mark an event as featured:

PHP
<?php

register_rest_route(
    'myplugin/v1',
    '/events/(?P<id>\d+)/featured',
    array(
        'methods'             => WP_REST_Server::EDITABLE,
        'callback'            => 'myplugin_rest_set_featured',
        'permission_callback' => function ( WP_REST_Request $request ) {
            return current_user_can( 'edit_post', $request['id'] );
        },
        'args'                => array(
            'id'       => array(
                'type'              => 'integer',
                'required'          => true,
                'validate_callback' => 'rest_validate_request_arg',
                'sanitize_callback' => 'absint',
            ),
            'featured' => array(
                'type'              => 'boolean',
                'required'          => true,
                'validate_callback' => 'rest_validate_request_arg',
                'sanitize_callback' => 'rest_sanitize_boolean',
            ),
        ),
    )
);

This register_rest_route() call goes inside myplugin_register_routes() with the others. edit_post with an ID asks "can this user edit this particular post?", which accounts for ownership, status and custom roles. The roles and capabilities lesson explains how that works. myplugin_rest_set_featured() would load the event, update the meta and return a response, just like the RSVP callback.

The permission callback answers "may this user do this?". It does not replace the checks inside your callback that the object exists and is in the right state. The RSVP callback still checks that the event is a published myplugin_event.

Responses and errors

  • Return a WP_REST_Response with data and a status code: 200 for success, 201 when you create something new.
  • Return a WP_Error with a status in its data for failures: 400 for bad input, 404 for missing objects, 409 for conflicts.
  • Error codes are prefixed, like everything else, so clients can tell your errors from core's.
  • Add headers with $response->header(). The events route copies core's X-WP-Total and X-WP-TotalPages so clients can paginate the same way.

The REST API sends JSON, not HTML, so do not HTML-escape values in the response. Escaping happens in the client, at the point where the data is put into a page. Note that get_the_title() returns the title as WordPress displays it, which may contain HTML entities such as ’.

Notice also what the public routes do not return: drafts, private events or any user data. A public endpoint must never reveal more than the public site does.

Calling the protected endpoint from JavaScript

The RSVP route needs a logged-in user. JavaScript on the same site authenticates with the login cookie plus a wp_rest nonce in the X-WP-Nonce header. Pass the nonce and URL from PHP as shown in the enqueuing lesson, then:

JavaScript
async function rsvp( eventId, guests ) {
    const response = await fetch(
        `${ mypluginData.restUrl }events/${ eventId }/rsvp`,
        {
            method: 'POST',
            credentials: 'same-origin',
            headers: {
                'Content-Type': 'application/json',
                'X-WP-Nonce': mypluginData.nonce,
            },
            body: JSON.stringify( { guests } ),
        }
    );

    const data = await response.json();

    if ( ! response.ok ) {
        throw new Error( data.message );
    }

    return data;
}

WordPress verifies the wp_rest nonce itself and sets the current user. Your code does not call wp_verify_nonce(); it relies on the permission callback. Without a valid nonce the request is treated as logged out, and the permission callback returns a 401.

Growing the API

For a handful of routes, functions are fine. For larger APIs, extend the WP_REST_Controller class, which organises routes, permission checks and a JSON schema for each resource in the same way core's controllers do. The REST API Handbook's schema pages show how a schema documents your responses and lets clients discover them with an OPTIONS request.

Summary

  • Register routes on rest_api_init with register_rest_route(), a versioned namespace and prefixed error codes.
  • Every route has a permission_callback; __return_true is only for public read-only data, and you should say so in a comment.
  • Declare every argument with a type and constraints, and set validate_callback and sanitize_callback; your callback then receives clean values.
  • Check capabilities such as edit_post on the specific object, and still check that the object exists and is in the right state.
  • Return WP_REST_Response or WP_Error with correct status codes; leave HTML escaping to the client that displays the data.

Further reading