How to Create Custom REST API Endpoints in WordPress: Production-Ready Guide

How to Create Custom REST API Endpoints in WordPress

The WordPress REST API has been a stable part of core since version 4.7 (December 2016), but most articles teaching custom endpoint creation skip the operational details that matter in production: permission callbacks (mandatory since 5.5), argument sanitization, namespace versioning, and the controller pattern that WordPress core itself uses for its own routes. This guide covers the full workflow β€” from a minimal first endpoint to a production-ready controller-based route with validation, sanitization, and authentication.

What This Guide Covers

  1. What a custom REST API endpoint is, and when you need one.
  2. The minimum viable endpoint β€” register_rest_route called from rest_api_init.
  3. The required permission_callback and why it exists.
  4. Sanitization and validation via args.
  5. Authentication options β€” cookies, Application Passwords, OAuth, JWT.
  6. The Controller pattern (extending WP_REST_Controller).
  7. Versioning strategy and namespace conventions.
  8. Where to put the code β€” plugin vs theme vs mu-plugin.
  9. Common mistakes and how to avoid them.
  10. Testing and debugging custom endpoints.
  11. FAQs.

What a Custom REST API Endpoint Is

The WordPress REST API exposes core data (posts, pages, users, comments, taxonomies, media) at predictable URLs under /wp-json/wp/v2/. A custom endpoint adds new URLs that return custom data, accept custom requests, or expose your plugin’s functionality to external clients.

Common use cases:

  • A headless frontend (React, Next.js, Vue) fetching data from WordPress as a content backend.
  • A mobile app that needs a tailored response shape rather than the full wp/v2/posts payload.
  • A plugin admin screen using REST instead of admin-ajax.php for cleaner separation.
  • Third-party integrations (CRM sync, payment webhooks, analytics ingestion).
  • Internal microservices that need to read or write WordPress data.

If your needs are read-only and the standard wp/v2/* endpoints already cover the data you want, you do not need a custom endpoint β€” you need to extend the existing one with register_rest_field() or filter the existing response. Build a custom endpoint when the standard routes do not fit your shape, authentication, or business logic.

The Minimum Viable Endpoint

The smallest possible custom endpoint registers a route at rest_api_init and returns data:

add_action('rest_api_init', function () {
    register_rest_route('myplugin/v1', '/hello', [
        'methods'             => 'GET',
        'callback'            => 'myplugin_hello_callback',
        'permission_callback' => '__return_true',
    ]);
});

function myplugin_hello_callback(WP_REST_Request $request) {
    return new WP_REST_Response([
        'message' => 'Hello from a custom endpoint',
    ], 200);
}

This registers GET /wp-json/myplugin/v1/hello. Three things matter in this minimal example:

  1. Registration happens on the rest_api_init action. Calling register_rest_route() outside this hook is undefined behavior β€” it might work in some scenarios but the official guidance (WordPress REST API Handbook) is to register only on this hook so WordPress can lazily load REST infrastructure when actually needed.
  2. The namespace is myplugin/v1 β€” your plugin’s identifier plus a version segment. Never use wp/v2 for custom endpoints (that namespace is reserved for core). Never use /v1 without a vendor prefix (that risks collision with other plugins).
  3. permission_callback is required. Since WordPress 5.5, registering a route without a permission_callback triggers a _doing_it_wrong() notice. For public endpoints use __return_true; for any endpoint that should be restricted, use a capability check (covered below).

The Required permission_callback

This is the single most misunderstood piece of REST API development. The permission_callback runs before the actual callback, decides whether the current user is allowed to access the route, and short-circuits the request with an error if not.

For public endpoints (open to everyone, including unauthenticated visitors):

'permission_callback' => '__return_true',

This is explicit and intentional. Do not omit permission_callback entirely and hope WordPress defaults to public β€” since 5.5 the default is restrictive and you get warnings.

For authenticated users only:

'permission_callback' => function () {
    return is_user_logged_in();
},

For users with a specific capability:

'permission_callback' => function () {
    return current_user_can('edit_posts');
},

For object-specific permissions (e.g., user can only edit their own posts):

'permission_callback' => function (WP_REST_Request $request) {
    $post_id = (int) $request['id'];
    return current_user_can('edit_post', $post_id);
},

Common mistake: writing 'permission_callback' => true (literal boolean) instead of '__return_true' (function returning true). The first is incorrect β€” permission_callback must be a callable, not a value.

Sanitization and Validation via args

Routes that accept parameters should define them in the args array. Each argument can specify:

  • default β€” value used if the request omits the argument.
  • required β€” whether the argument must be present.
  • validate_callback β€” function that returns true or false, validating the value.
  • sanitize_callback β€” function that returns the cleaned value.
  • type, format, enum, description β€” JSON Schema-style metadata used in API documentation and auto-validation.

Example with full parameter handling:

add_action('rest_api_init', function () {
    register_rest_route('myplugin/v1', '/items/(?P<id>\d+)', [
        'methods'             => 'GET',
        'callback'            => 'myplugin_get_item',
        'permission_callback' => function () {
            return current_user_can('read');
        },
        'args' => [
            'id' => [
                'required'          => true,
                'validate_callback' => function ($value) {
                    return is_numeric($value) && $value > 0;
                },
                'sanitize_callback' => 'absint',
                'type'              => 'integer',
                'description'       => 'The item ID.',
            ],
            'fields' => [
                'default'           => 'all',
                'enum'              => ['all', 'minimal', 'full'],
                'sanitize_callback' => 'sanitize_text_field',
                'type'              => 'string',
                'description'       => 'Which fields to return.',
            ],
        ],
    ]);
});

function myplugin_get_item(WP_REST_Request $request) {
    $id     = $request['id'];     // already validated + sanitized
    $fields = $request['fields']; // already sanitized to a valid enum value
    // ... business logic
    return new WP_REST_Response(['id' => $id, 'fields' => $fields], 200);
}

The URL pattern /items/(?P<id>\d+) uses a named capture group β€” id becomes available as $request['id'].

Standard sanitization callbacks worth knowing:

  • absint β€” non-negative integer
  • sanitize_text_field β€” strips tags, removes line breaks, sanitizes for single-line input
  • sanitize_textarea_field β€” preserves line breaks but strips other unsafe content
  • sanitize_email β€” validates and cleans an email
  • sanitize_key β€” lowercase alphanumeric and underscores only (for option/meta keys)
  • wp_kses_post β€” allows the same HTML tags as post content
  • esc_url_raw β€” for URLs to be stored (use esc_url for output)

Authentication Options

The REST API supports several authentication mechanisms; pick based on the client:

Cookie authentication β€” for requests from the same WordPress site (e.g., admin dashboard JavaScript). Requires a nonce: include _wpnonce parameter or X-WP-Nonce header with the value of wp_create_nonce('wp_rest'). Used automatically by wp_remote_request from inside WP and by apiFetch() in block editor JavaScript.

Application Passwords β€” built into WordPress 5.6+. A user generates a 32-character password under Users β†’ Profile β†’ Application Passwords, then external clients authenticate via HTTP Basic Auth (username:application_password). The current site’s Application Password is the recommended approach for external integrations talking to a self-hosted WordPress.

curl -u "admin:xxxx xxxx xxxx xxxx xxxx xxxx" \
  https://example.com/wp-json/myplugin/v1/items/42

JWT (JSON Web Tokens) β€” not in WordPress core; requires a plugin like JWT Authentication for WP REST API or miniOrange JWT. Useful for stateless authentication where Application Passwords don’t fit (e.g., mobile apps issuing tokens after login).

OAuth 1.0a β€” supported via the OAuth 1.0a plugin from the WordPress REST API team. Useful for third-party services that need delegated access without sharing user passwords.

OAuth 2.0 β€” not built into core; available via plugins (WP OAuth Server, miniOrange OAuth Server). Suitable for complex multi-app scenarios.

For most server-to-server integrations against a self-hosted WordPress, Application Passwords are the simplest correct choice.

The Controller Pattern (WP_REST_Controller)

For endpoints beyond a single GET, the controller pattern keeps the code maintainable and consistent with how WordPress core implements its own REST routes. WP_REST_Controller is an abstract class in core that provides a structure for register_routes(), request handling, response shaping, and schema definition.

A minimal controller-based plugin:

class MyPlugin_Items_Controller extends WP_REST_Controller {

    public function __construct() {
        $this->namespace = 'myplugin/v1';
        $this->rest_base = 'items';
    }

    public function register_routes() {
        register_rest_route($this->namespace, '/' . $this->rest_base, [
            [
                'methods'             => WP_REST_Server::READABLE, // GET
                'callback'            => [$this, 'get_items'],
                'permission_callback' => [$this, 'get_items_permissions_check'],
                'args'                => $this->get_collection_params(),
            ],
            [
                'methods'             => WP_REST_Server::CREATABLE, // POST
                'callback'            => [$this, 'create_item'],
                'permission_callback' => [$this, 'create_item_permissions_check'],
                'args'                => $this->get_endpoint_args_for_item_schema(WP_REST_Server::CREATABLE),
            ],
            'schema' => [$this, 'get_public_item_schema'],
        ]);

        register_rest_route($this->namespace, '/' . $this->rest_base . '/(?P<id>\d+)', [
            'args' => [
                'id' => [
                    'description' => 'Unique identifier for the item.',
                    'type'        => 'integer',
                ],
            ],
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [$this, 'get_item'],
                'permission_callback' => [$this, 'get_item_permissions_check'],
            ],
            [
                'methods'             => WP_REST_Server::EDITABLE, // POST, PUT, PATCH
                'callback'            => [$this, 'update_item'],
                'permission_callback' => [$this, 'update_item_permissions_check'],
                'args'                => $this->get_endpoint_args_for_item_schema(WP_REST_Server::EDITABLE),
            ],
            [
                'methods'             => WP_REST_Server::DELETABLE,
                'callback'            => [$this, 'delete_item'],
                'permission_callback' => [$this, 'delete_item_permissions_check'],
            ],
        ]);
    }

    public function get_items_permissions_check($request) {
        return current_user_can('read');
    }

    public function create_item_permissions_check($request) {
        return current_user_can('edit_posts');
    }

    // ... similar permission methods for get_item, update_item, delete_item

    public function get_items($request) {
        // Fetch and return collection
    }

    public function get_item($request) {
        // Fetch and return single item
    }

    // ... create_item, update_item, delete_item implementations

    public function get_item_schema() {
        return [
            '$schema'    => 'http://json-schema.org/draft-04/schema#',
            'title'      => 'item',
            'type'       => 'object',
            'properties' => [
                'id' => [
                    'description' => 'Unique identifier.',
                    'type'        => 'integer',
                    'context'     => ['view', 'edit'],
                    'readonly'    => true,
                ],
                'name' => [
                    'description' => 'Item name.',
                    'type'        => 'string',
                    'context'     => ['view', 'edit'],
                ],
            ],
        ];
    }
}

add_action('rest_api_init', function () {
    $controller = new MyPlugin_Items_Controller();
    $controller->register_routes();
});

The benefits of the controller pattern:

  • Standard method names β€” get_items, get_item, create_item, update_item, delete_item mirror what core uses, making the codebase legible to other WordPress developers.
  • Built-in schema support β€” get_public_item_schema() auto-generates argument validation from your schema definition.
  • Helper methods β€” get_collection_params() returns standard pagination/filter args (page, per_page, search, orderby).
  • Consistent error handling β€” return WP_Error from any method to produce a properly-formatted REST error.

For endpoints beyond a handful of routes, the controller pattern is the recommended approach by core developers.

Versioning Strategy

URL versioning lives in the namespace: myplugin/v1, myplugin/v2. When you need to change the response shape in a breaking way:

  1. Keep v1 routes registered as-is so existing clients continue working.
  2. Register a new namespace myplugin/v2 for the new shape.
  3. Deprecate v1 over a defined timeline (announce in plugin changelog, set deprecation headers via WP_REST_Response::header()).
  4. Eventually remove v1 after sufficient migration window.

WordPress core uses this pattern β€” wp/v1 existed during the REST API development period, wp/v2 is the stable production namespace.

Where to Put the Code

The most important architectural decision: where the endpoint code lives.

Plugin (recommended for most cases). A custom plugin is the right home for REST endpoints that represent functionality (data your plugin manages, integrations your plugin provides). Plugins are portable across themes, can be enabled/disabled cleanly, and survive theme switches.

Must-Use Plugin (/wp-content/mu-plugins/). Use for site-specific endpoints that must always load regardless of which plugins are active. Common for endpoints used by site-specific automations or integrations the site owner controls.

Theme’s functions.php β€” generally avoid. Endpoints in functions.php break when the theme changes. The exception: endpoints that are inherently tied to the theme (e.g., a theme exposing its block patterns or template parts). For functionality, prefer a plugin.

Block plugin / block theme functions. Block-related endpoints (custom block data, block patterns) can be registered alongside the block PHP, but the route registration still belongs in a plugin or mu-plugin, not the theme.

Common Mistakes

1. Omitting permission_callback. Triggers _doing_it_wrong() notice and exposes the endpoint as restricted by default. Always specify, even for public endpoints (__return_true).

2. Registering routes outside rest_api_init. Routes registered too early may not be available when the REST request is processed, or may register multiple times.

3. Using wp/v2 namespace. Reserved for core. Always use your plugin’s namespace.

4. Trusting $_REQUEST instead of $request->get_param(). Direct superglobal access skips the sanitization and validation that REST request handling provides.

5. Returning raw arrays instead of WP_REST_Response. Returning an array works but loses control over HTTP status code and headers. Use WP_REST_Response for proper response shaping or WP_Error for errors.

6. Forgetting to flush rewrite rules. Custom routes registered via permalink-style patterns work without explicit flushing because REST routes use their own routing, not WordPress rewrite rules. But if you also register custom rewrites alongside, those need flushing on plugin activation.

7. Inconsistent error responses. Use WP_Error with consistent error codes:

function myplugin_get_item($request) {
    $item = my_data_lookup($request['id']);
    if (!$item) {
        return new WP_Error(
            'myplugin_item_not_found',
            'Item not found.',
            ['status' => 404]
        );
    }
    return new WP_REST_Response($item, 200);
}

8. Not handling the OPTIONS preflight for CORS. If your endpoint is consumed by a frontend on a different domain, CORS preflight requests need handling. WordPress handles standard CORS for wp/v2 automatically but custom endpoints may need explicit Access-Control-Allow-* headers via the rest_pre_serve_request filter.

Testing and Debugging

The fastest way to test a custom endpoint during development:

With curl:

# Public GET
curl -i https://example.com/wp-json/myplugin/v1/hello

# Authenticated GET with Application Password
curl -i -u "admin:xxxx xxxx xxxx xxxx xxxx xxxx" \
  https://example.com/wp-json/myplugin/v1/items/42

# POST with JSON body
curl -i -X POST \
  -u "admin:xxxx xxxx xxxx xxxx xxxx xxxx" \
  -H "Content-Type: application/json" \
  -d '{"name":"New Item"}' \
  https://example.com/wp-json/myplugin/v1/items

With wp-cli:

wp eval 'echo rest_do_request(new WP_REST_Request("GET", "/myplugin/v1/hello"))->get_status();'

In browser DevTools network tab β€” for endpoints called from same-origin JavaScript, the request and response are inspectable directly.

Enable REST API logging β€” set WP_DEBUG_LOG in wp-config.php and error_log() from your callbacks to trace request flow. For production, use a proper logging plugin rather than error_log().

Verify your route is registered:

curl -s https://example.com/wp-json/ | jq '.routes | keys[] | select(. | contains("myplugin"))'

This lists all registered routes under your namespace.

FAQs

Should I use the REST API or admin-ajax.php for plugin admin screens?

REST API. admin-ajax.php predates the REST API and remains supported, but new code should use REST for cleaner separation of concerns, better routing, automatic nonce handling, and the JSON Schema introspection that REST provides. The Block Editor (Gutenberg) uses REST exclusively.

How do I rate-limit a custom endpoint?

WordPress core does not provide rate limiting. Options: a WAF at the edge (Cloudflare, Sucuri), a plugin like Wordfence with rate limiting features, or custom logic in your permission_callback that checks request frequency per user/IP against a transient. For production-grade rate limiting, edge-layer protection is more effective than application-layer.

Can I document my custom endpoints automatically?

Yes. The OpenAPI specification can be generated from your schema definitions. Plugins like WP REST API Documentation or wp-rest-api-doc generate readable documentation from registered routes and their schemas. For internal use, the standard introspection at /wp-json/myplugin/v1 returns all registered routes with their methods, args, and schemas.

How do I version a breaking change without breaking existing clients?

Register a new namespace (myplugin/v2) alongside the old one. Keep the old endpoint functional, add a deprecation header in its response, and communicate the migration window to your client developers. Remove the old version only after sufficient time has passed for all clients to migrate.

Should custom endpoints return JSON or can they return XML/CSV?

REST endpoints in WordPress are JSON-first. If you need other formats, you can set Content-Type headers in your response and return the alternative format string, but you lose REST conventions (error handling, schema validation, automatic JSON serialization). For non-JSON use cases, consider a separate URL handler (registered via add_rewrite_rule) rather than forcing a REST endpoint to serve non-JSON content.

How do I handle file uploads through a custom endpoint?

Use WP_REST_Request::get_file_params() to access uploaded files. Validate the file with wp_check_filetype_and_ext(), sanitize the filename with sanitize_file_name(), and use wp_handle_upload() to move the file to the uploads directory. Apply capability checks (current_user_can('upload_files')) and consider blocking PHP execution in the upload directory at the server level β€” see WordPress file upload security guidance for the full picture.

Authoritative Resources

Related WP Winners guide: WordPress security checklist for developers
Related WP Winners guide: How to protect WordPress REST API endpoints

Building a custom endpoint for a production workflow?

A working response is only the first step. Permissions, validation, error handling, tests, and integration constraints determine whether an endpoint remains safe and maintainable. Osom Studio can help design or review that implementation as part of custom WordPress development.


Discover more from WP Winners πŸ†

Subscribe to get the latest posts sent to your email.

More WorDPRESS Tips, tutorials and Guides

Discover more from WP Winners πŸ†

Subscribe now to keep reading and get access to the full archive.

Continue reading