Custom REST API endpoints: practical tips and trade-offs
When you're scaling a WordPress project beyond the standard REST endpoints, you'll eventually need to build your own API routes. Custom REST API endpoints let you expose site data and functionality exactly the way your frontend—or external services—expect it. In this guide, we'll walk through the mechanics of registering endpoints, validating arguments, enforcing permissions, and returning proper responses.
Why build custom endpoints
WordPress ships with solid REST coverage for posts, users, and taxonomy. But if you're building a real-time analytics dashboard, a headless storefront, or an internal admin tool, the standard endpoints often don't cut it. You might need to aggregate data across multiple custom post types, apply complex filtering, or expose internal metrics. Custom endpoints give you that control without forcing the frontend to make dozens of API calls.
The trade-off is straightforward: you're adding code to maintain, and you're responsible for security. But when done well, a focused set of custom routes is cleaner than wrestling WordPress's generic endpoints.
register_rest_route — Namespace, route and callback
At the heart of every custom endpoint is register_rest_route(). You call this during the rest_api_init hook, typically in a plugin or theme's functions file.
add_action('rest_api_init', function() {
register_rest_route('expona/v1', '/stats', [
'methods' => 'GET',
'callback' => 'expona_stats',
'permission_callback' => fn() => current_user_can('manage_options'),
]);
});Let's break this down:
- Namespace:
expona/v1is your route's prefix. This avoids conflicts with other plugins. The REST endpoint becomes/wp-json/expona/v1/stats. - Route:
/statsis the path segment. Keep it RESTful: nouns, not verbs. - Methods: Array of HTTP verbs:
GET,POST,PUT,DELETE, etc. - Callback: A function or callable that handles the request. It receives a
WP_REST_Requestobject. - Permission callback: We'll cover this in detail below. It's not optional.
Here's a real callback function:
function expona_stats(WP_REST_Request $request) {
$stats = [
'total_posts' => wp_count_posts('post')->publish,
'total_users' => count_users(),
'cache_hits' => get_option('expona_cache_hits', 0),
];
return new WP_REST_Response($stats, 200);
}The callback receives the request object, processes it, and returns a response. Simple and testable.
Validate args — args with validate_callback and sanitize_callback
Most endpoints accept query or body parameters. You need to validate and sanitize them. Use the args key in your route registration:
register_rest_route('expona/v1', '/posts', [
'methods' => 'GET',
'callback' => 'expona_get_posts',
'permission_callback' => fn() => current_user_can('read'),
'args' => [
'category_id' => [
'type' => 'integer',
'required' => false,
'sanitize_callback' => 'absint',
'validate_callback' => function($param) {
return $param > 0 || new WP_Error(
'invalid_category',
'Category ID must be positive',
['status' => 400]
);
},
],
'search' => [
'type' => 'string',
'required' => false,
'sanitize_callback' => 'sanitize_text_field',
],
'per_page' => [
'type' => 'integer',
'required' => false,
'default' => 10,
'sanitize_callback' => 'absint',
'validate_callback' => fn($param) => $param > 0 && $param <= 100,
],
],
]);When a request hits this endpoint, WordPress automatically:
- Coerces parameters to the declared type.
- Runs them through
sanitize_callbackto remove malicious input. - Runs them through
validate_callbackto check business logic. - Returns a 400 error if validation fails.
The callback then receives clean, validated data:
function expona_get_posts(WP_REST_Request $request) {
$category_id = $request->get_param('category_id');
$search = $request->get_param('search');
$per_page = $request->get_param('per_page');
$args = [
'posts_per_page' => $per_page,
'post_type' => 'post',
's' => $search,
];
if ($category_id) {
$args['cat'] = $category_id;
}
$posts = get_posts($args);
return new WP_REST_Response($posts, 200);
}Never skip validation. A malformed or missing parameter can crash your site or expose data. WordPress's validation framework is lightweight and does the job.
Permissions — Always set permission_callback
This is where many custom endpoints fail in production. Every route must have a permission_callback. No exceptions.
// Bad: No permission check
register_rest_route('expona/v1', '/sensitive-data', [
'methods' => 'GET',
'callback' => 'expona_sensitive_data',
// Missing permission_callback — defaults to allow all
]);
// Good: Explicit capability check
register_rest_route('expona/v1', '/sensitive-data', [
'methods' => 'GET',
'callback' => 'expona_sensitive_data',
'permission_callback' => fn() => current_user_can('manage_options'),
]);Common patterns:
- Public endpoints:
'permission_callback' => '__return_true'(or omit and let it default, but be explicit). - Logged-in users:
'permission_callback' => fn() => is_user_logged_in(). - Capability-based:
'permission_callback' => fn() => current_user_can('edit_posts'). - Custom logic: Return a
WP_Errorto deny access.
register_rest_route('expona/v1', '/author-stats', [
'methods' => 'GET',
'callback' => 'expona_author_stats',
'permission_callback' => function() {
if (!is_user_logged_in()) {
return new WP_Error(
'not_authenticated',
'You must be logged in',
['status' => 401]
);
}
if (!current_user_can('edit_others_posts')) {
return new WP_Error(
'insufficient_permissions',
'You cannot view author stats',
['status' => 403]
);
}
return true;
},
]);Always return true on success, or a WP_Error with an appropriate HTTP status code. WordPress will handle the rest.
Return WP_REST_Response — Control status codes and headers
Your callback should return a WP_REST_Response object, not raw data. This gives you control over status codes, headers, and the response structure:
function expona_create_event(WP_REST_Request $request) {
$title = $request->get_param('title');
$description = $request->get_param('description');
// Validation
if (empty($title)) {
return new WP_Error(
'missing_title',
'Event title is required',
['status' => 400]
);
}
// Create the event (custom post type)
$post_id = wp_insert_post([
'post_type' => 'event',
'post_title' => $title,
'post_content' => $description,
'post_status' => 'publish',
]);
if (is_wp_error($post_id)) {
return $post_id;
}
$event = get_post($post_id);
// Return 201 Created with Location header
$response = new WP_REST_Response($event, 201);
$response->header('Location', rest_url('expona/v1/events/' . $post_id));
return $response;
}Notice the 201 status code for creation, and the Location header pointing to the new resource. This follows REST conventions and helps clients know what happened.
For a DELETE endpoint:
function expona_delete_event($request) {
$id = $request['id'];
$event = get_post($id);
if (!$event || $event->post_type !== 'event') {
return new WP_Error(
'not_found',
'Event not found',
['status' => 404]
);
}
wp_delete_post($id, true);
return new WP_REST_Response(null, 204);
}A 204 No Content response signals success with no body to return. Clean and predictable.
Common mistakes
- Forgetting permission callbacks: Every endpoint is a potential security hole. Always set one, even if it's
'permission_callback' => '__return_true'. - Not sanitizing input: Treat
$_GETand$_POSTdata the same way you always have. Usesanitize_callbackandvalidate_callback. - Returning raw arrays: Use
WP_REST_Responseso you can set proper status codes and headers. Clients rely on them. - Loose namespacing: Don't use generic namespaces like
v1. Make yours unique:yourplugin/v1. - Over-fetching data: If your endpoint returns thousands of posts without pagination, you'll hit memory limits. Always paginate large result sets.
- Ignoring nonce validation for POST/PUT/DELETE: While the REST API doesn't use traditional nonces, you should still validate that the request is legitimate. Use
rest_ensure_request_has_nonce()for additional safety if needed.
Key takeaways
- Register endpoints with
register_rest_route()duringrest_api_init, using a unique namespace and RESTful routes. - Validate and sanitize all input via the
argsparameter; let WordPress handle the heavy lifting. - Never skip
permission_callback. It's your first line of defense against unauthorized access. - Return
WP_REST_Responsewith appropriate status codes (201 for creation, 204 for deletion, 400 for validation errors) and headers. - Test your endpoints thoroughly with curl or Postman. Security and correctness matter as much as they do in any API.






Comments
Loading comments…