Skip to content
ExponaBLOG
WORDPRESS DEVELOPMENT & PLUGINS · DAY 018 / 100

Custom REST API endpoints: practical tips and trade-offs

Build your own API routes.

Expona Admin7 min read

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.

php
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/v1 is your route's prefix. This avoids conflicts with other plugins. The REST endpoint becomes /wp-json/expona/v1/stats.
  • Route: /stats is 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_Request object.
  • Permission callback: We'll cover this in detail below. It's not optional.

Here's a real callback function:

php
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:

php
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_callback to remove malicious input.
  • Runs them through validate_callback to check business logic.
  • Returns a 400 error if validation fails.

The callback then receives clean, validated data:

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

php
// 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_Error to deny access.
php
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:

php
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:

php
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 $_GET and $_POST data the same way you always have. Use sanitize_callback and validate_callback.
  • Returning raw arrays: Use WP_REST_Response so 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() during rest_api_init, using a unique namespace and RESTful routes.
  • Validate and sanitize all input via the args parameter; let WordPress handle the heavy lifting.
  • Never skip permission_callback. It's your first line of defense against unauthorized access.
  • Return WP_REST_Response with 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles