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():
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:
| Key | Purpose |
|---|---|
methods | The HTTP method: WP_REST_Server::READABLE (GET), CREATABLE (POST), EDITABLE (POST, PUT, PATCH) or DELETABLE (DELETE) |
callback | The function that does the work and returns the response |
permission_callback | Decides whether the current user may call the route. Always required |
args | The 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
/**
* 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:
- Validation runs first, on the raw value.
rest_validate_request_argchecks the value against the argument's own schema:type,minimum,maximum,enum,formatand so on. A custom validator, such asmyplugin_rest_validate_event_type(), returnstrue,falseor aWP_Error. - Sanitising runs next and converts the value to its clean form, here with
absintandsanitize_key. - Defaults fill in arguments the client did not send, and
requiredarguments that are missing cause a400error.
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:
trueto allow the request;falseto refuse it with a genericrest_forbiddenerror;- a
WP_Errorto 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
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_Responsewith data and a status code:200for success,201when you create something new. - Return a
WP_Errorwith astatusin its data for failures:400for bad input,404for missing objects,409for 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'sX-WP-TotalandX-WP-TotalPagesso 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:
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_initwithregister_rest_route(), a versioned namespace and prefixed error codes. - Every route has a
permission_callback;__return_trueis 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_callbackandsanitize_callback; your callback then receives clean values. - Check capabilities such as
edit_poston the specific object, and still check that the object exists and is in the right state. - Return
WP_REST_ResponseorWP_Errorwith correct status codes; leave HTML escaping to the client that displays the data.