14+ years building on WordPress / Replies in under 5 hours
Development 12 min read · Updated July 2026

Building Custom Endpoints with the WordPress REST API (A Developer’s Guide)

Photo of Ajay Khandal
Ajay Khandal
WordPress Developer
Building Custom Endpoints with the WordPress REST API (A Developer’s Guide)
TL;DR

Custom REST endpoints are built with register_rest_route() hooked to rest_api_init — every endpoint needs a versioned namespace, an explicit permission_callback (never omit it), and arguments declared with validate_callback and sanitize_callback. The callback receives a WP_REST_Request object and should return a WP_REST_Response with an explicit status code or a WP_Error. Same-domain requests authenticate via nonce in the X-WP-Nonce header; external headless clients use Application Passwords. Expensive GET responses should be cached with transients; slow write operations should schedule work with Action Scheduler and return 202 immediately.

WordPress’s built-in REST API exposes posts, pages, users, and taxonomies out of the box — but the default endpoints are generic. They return more fields than a client usually needs, carry no domain-specific business logic, and can’t serve data that lives outside the standard post schema. Custom endpoints fix all three problems: you define exactly what goes in and what comes out, enforce the right permissions for each operation, and keep the URL surface small and auditable.

This guide covers everything needed to build production-grade custom endpoints: register_rest_route() in full, working with the WP_REST_Request and WP_REST_Response objects, URL parameter capture, per-argument validation and sanitization, authentication options, exposing custom fields with register_rest_field(), and caching expensive responses with transients.

register_rest_route(): The Core Function

Every custom endpoint is registered by calling register_rest_route() inside a callback hooked to rest_api_init. The function signature is:

register_rest_route( $namespace, $route, $args );

Three arguments are required. Namespace — your plugin or app identifier, always versioned (myplugin/v1). Versioning lets you ship a breaking v2 without breaking existing clients. Route — the path segment after the namespace; may contain regex capture groups for URL parameters. Args — an array that at minimum specifies methods, callback, and permission_callback.

add_action( 'rest_api_init', function() {
    register_rest_route( 'myplugin/v1', '/products', array(
        'methods'             => WP_REST_Server::READABLE,    // GET only
        'callback'            => 'myplugin_get_products',
        'permission_callback' => '__return_true',             // public read — acceptable for GET
    ) );
} );

function myplugin_get_products( WP_REST_Request $request ) {
    $products = array(
        array( 'id' => 1, 'name' => 'Widget A', 'price' => 19.99 ),
        array( 'id' => 2, 'name' => 'Widget B', 'price' => 34.99 ),
    );
    return new WP_REST_Response( $products, 200 );
}

The methods key accepts 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', or the WP_REST_Server constants — READABLE, CREATABLE, EDITABLE, DELETABLE, ALLMETHODS — which are slightly more readable in code reviews.

Working with WP_REST_Request

The callback receives a WP_REST_Request object. It provides typed accessors for every part of the incoming request — URL parameters, query string values, body fields, headers, and raw body content:

function myplugin_handle_request( WP_REST_Request $request ) {
    // URL capture group (e.g. /products/(?P<id>\d+))
    $id = $request['id'];                        // validated/sanitized if declared in args

    // Query string param (?status=active)
    $status = $request->get_param( 'status' );

    // JSON body field (POST with Content-Type: application/json)
    $body_params = $request->get_json_params();
    $email = $body_params['email'] ?? '';

    // Form-encoded body (POST with Content-Type: application/x-www-form-urlencoded)
    $form_params = $request->get_body_params();

    // Request header
    $auth_header = $request->get_header( 'Authorization' );

    return new WP_REST_Response( array( 'received' => true ), 200 );
}

When arguments are declared in the args array (covered below), validated and sanitized values are available directly as array-access on the request object: $request['param_name']. Raw values before sanitization are available via $request->get_param().

Returning Data: WP_REST_Response and WP_Error

Callbacks should always return either a WP_REST_Response or a WP_Error. Returning a plain array works (WordPress wraps it automatically), but using WP_REST_Response directly lets you control the HTTP status code and add custom headers:

function myplugin_get_product( WP_REST_Request $request ) {
    $id      = absint( $request['id'] );
    $product = get_post( $id );

    if ( ! $product || 'product' !== $product->post_type ) {
        return new WP_Error(
            'rest_product_not_found',
            __( 'Product not found.', 'myplugin' ),
            array( 'status' => 404 )
        );
    }

    $data = array(
        'id'    => $product->ID,
        'name'  => $product->post_title,
        'price' => get_post_meta( $product->ID, '_price', true ),
    );

    $response = new WP_REST_Response( $data, 200 );
    $response->header( 'Cache-Control', 'max-age=300' );
    return $response;
}

When you return a WP_Error, WordPress serializes it as a application/json response with the appropriate HTTP status code. The REST API maps common WP_Error codes to HTTP status codes: rest_forbidden → 403, rest_invalid_param → 400, and so on. For unknown codes it defaults to 500, so always supply an explicit status in the $data array.

URL Parameters with Regex Capture Groups

Routes support named regex capture groups using the (?P<name>pattern) syntax. The matched value is available as $request['name'] in the callback:

// Route: /myplugin/v1/products/42
// Capture group name = 'id', pattern = \d+ (digits only)
register_rest_route( 'myplugin/v1', '/products/(?P<id>\d+)', array(
    'methods'             => WP_REST_Server::READABLE,
    'callback'            => 'myplugin_get_product',
    'permission_callback' => '__return_true',
) );

// In the callback:
function myplugin_get_product( WP_REST_Request $request ) {
    $id = absint( $request['id'] );  // '42' captured from URL
    // ...
}

The \d+ pattern restricts the capture to one or more digits, so /products/abc returns a 404 before the callback is ever called. Use the args array to layer additional validation on top of the regex:

register_rest_route( 'myplugin/v1', '/products/(?P<id>\d+)', array(
    'methods'             => WP_REST_Server::READABLE,
    'callback'            => 'myplugin_get_product',
    'permission_callback' => '__return_true',
    'args'                => array(
        'id' => array(
            'validate_callback' => function( $value ) {
                return is_numeric( $value ) && $value > 0;
            },
            'sanitize_callback' => 'absint',
        ),
    ),
) );

Permission Callbacks: Three Tiers

The permission_callback is called before the main callback. If it returns a falsy value, WordPress returns a 401 or 403 response without invoking the callback. Never omit it — as of WordPress 5.5, a missing permission_callback triggers a _doing_it_wrong() notice, and in a future version it may be treated as a fatal error.

Endpoint type Permission callback Notes
Public read (unauthenticated) '__return_true' Fine for GET endpoints returning public data
Any logged-in user is_user_logged_in Requires cookie auth or Application Password
Capability-gated write Custom function calling current_user_can() Always required for POST/PUT/DELETE
// Public GET — __return_true is acceptable
register_rest_route( 'myplugin/v1', '/products', array(
    'methods'             => WP_REST_Server::READABLE,
    'callback'            => 'myplugin_get_products',
    'permission_callback' => '__return_true',
) );

// Write endpoint — requires manage_options capability (administrators only)
register_rest_route( 'myplugin/v1', '/settings', array(
    'methods'             => WP_REST_Server::CREATABLE,
    'callback'            => 'myplugin_save_settings',
    'permission_callback' => function() {
        return current_user_can( 'manage_options' );
    },
) );

// Object-specific capability check (edit this particular post)
register_rest_route( 'myplugin/v1', '/posts/(?P<id>\d+)/approve', array(
    'methods'             => WP_REST_Server::CREATABLE,
    'callback'            => 'myplugin_approve_post',
    'permission_callback' => function( WP_REST_Request $request ) {
        return current_user_can( 'edit_post', absint( $request['id'] ) );
    },
) );

For endpoints that operate on a specific object (editing a post by ID), pass the object ID to current_user_can() so WordPress can apply meta capabilities correctly — this is covered in depth in the guide on WordPress custom user roles and capabilities.

Authentication Options

WordPress authenticates REST requests through three mechanisms — no plugin required for the first two:

Cookie authentication (same-domain)

When the request originates from the same WordPress installation (admin bar, Gutenberg, a frontend script loaded by wp_enqueue_script), the browser sends the logged-in cookie automatically. The REST API accepts it, but requires a nonce to prevent CSRF attacks. Use wp_create_nonce( 'wp_rest' ) to generate it and pass it in the X-WP-Nonce header:

// In functions.php or a plugin: pass the nonce to your script
wp_localize_script( 'myplugin-frontend', 'wpApiSettings', array(
    'root'  => esc_url_raw( rest_url() ),
    'nonce' => wp_create_nonce( 'wp_rest' ),
) );

Application Passwords (headless / external clients)

For headless frontends, mobile apps, or server-to-server calls, Application Passwords (built into WordPress since 5.6) are the standard approach. The client sends Authorization: Basic base64(username:app_password) with every request. No plugin needed. This is the authentication method to use when building a JWT-authenticated headless WordPress front end or comparing REST vs GraphQL for a decoupled architecture.

JWT (plugin-based, stateless)

For high-traffic APIs where you want stateless token-based auth and short-lived token expiry, a JWT plugin (JWT Auth or Simple JWT Login) adds a /wp-json/jwt-auth/v1/token endpoint that exchanges credentials for a signed token. The token travels in the Authorization: Bearer <token> header on subsequent requests.

Per-Argument Validation and Sanitization

Declaring arguments in the args array is the cleanest way to enforce validation before the callback runs. WordPress handles the error response automatically if validation fails — your callback only receives clean, validated data. For the full breakdown of when to use which sanitization function, see the guide on WordPress sanitization vs validation.

register_rest_route( 'myplugin/v1', '/orders', array(
    'methods'             => WP_REST_Server::CREATABLE,
    'callback'            => 'myplugin_create_order',
    'permission_callback' => function() {
        return is_user_logged_in();
    },
    'args' => array(
        'email' => array(
            'required'          => true,
            'type'              => 'string',
            'validate_callback' => function( $value ) {
                return is_email( $value );
            },
            'sanitize_callback' => 'sanitize_email',
        ),
        'quantity' => array(
            'required'          => true,
            'type'              => 'integer',
            'minimum'           => 1,
            'maximum'           => 100,
            'sanitize_callback' => 'absint',
        ),
        'note' => array(
            'required'          => false,
            'type'              => 'string',
            'sanitize_callback' => 'sanitize_textarea_field',
        ),
    ),
) );

function myplugin_create_order( WP_REST_Request $request ) {
    // $request['email'] and $request['quantity'] are already validated and sanitized
    $email    = $request['email'];
    $quantity = $request['quantity'];
    $note     = $request['note'] ?? '';

    // ... create the order ...
    return new WP_REST_Response( array( 'created' => true ), 201 );
}

The type key supports JSON Schema types: 'string', 'integer', 'number', 'boolean', 'array', 'object'. When set alongside minimum/maximum, WordPress enforces them automatically without a custom validate_callback.

Exposing Custom Fields with register_rest_field()

register_rest_field() adds fields to an existing REST endpoint without building a custom route. This is the right tool when you want to attach custom meta or computed data to the standard /wp/v2/posts or a CPT endpoint, rather than replacing the endpoint entirely:

add_action( 'rest_api_init', function() {
    register_rest_field(
        'product',                    // Post type slug (or array of types)
        'sale_price',                 // Field name in the REST response
        array(
            'get_callback'    => function( $post_data ) {
                return (float) get_post_meta( $post_data['id'], '_sale_price', true );
            },
            'update_callback' => function( $value, WP_Post $post ) {
                $clean = floatval( $value );
                return update_post_meta( $post->ID, '_sale_price', $clean );
            },
            'schema'          => array(
                'description' => 'Sale price in USD',
                'type'        => 'number',
                'context'     => array( 'view', 'edit' ),
            ),
        )
    );
} );

The get_callback receives the current post data array and the request object; return the value directly. The update_callback receives the new value (already sanitized by schema) and the post object — return true on success or a WP_Error on failure.

Caching Expensive Responses with Transients

GET endpoints that aggregate data from multiple queries can become a performance bottleneck under traffic. Wrap the expensive logic in a transient so it only runs once per cache lifetime. For deeper caching strategies, see the guide on the WordPress Transient API.

function myplugin_get_stats( WP_REST_Request $request ) {
    $cache_key = 'myplugin_dashboard_stats';
    $cached    = get_transient( $cache_key );

    if ( false !== $cached ) {
        return new WP_REST_Response( $cached, 200 );
    }

    // Expensive aggregation query
    global $wpdb;
    $stats = array(
        'total_orders'    => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}orders" ),
        'pending_orders'  => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}orders WHERE status = 'pending'" ),
        'generated_at'    => current_time( 'c' ),
    );

    set_transient( $cache_key, $stats, 5 * MINUTE_IN_SECONDS );
    return new WP_REST_Response( $stats, 200 );
}

For write operations that trigger slow work — sending emails, processing files, calling third-party APIs — avoid blocking the REST response entirely by handing the work off to Action Scheduler for async background processing. Return a 202 Accepted immediately and let the queue handle the heavy lifting.

Checklist: Custom REST Endpoint Quality

  1. Register all endpoints inside rest_api_init, never in init or plugins_loaded.
  2. Namespace is versioned: myplugin/v1, not myplugin.
  3. Every endpoint has an explicit permission_callback — never omit it.
  4. __return_true is only used for genuinely public GET endpoints returning non-sensitive data.
  5. Write endpoints (POST/PUT/DELETE) check current_user_can() for the minimum required capability.
  6. All arguments are declared in args with validate_callback and sanitize_callback.
  7. Callbacks return WP_REST_Response with an explicit HTTP status code, or WP_Error with a status in $data.
  8. URL parameters are cast (absint(), sanitize_key()) even when already validated by the regex pattern.
  9. Expensive GET callbacks cache results with set_transient().
  10. Slow POST operations schedule background work and return 202 Accepted immediately.

Building a headless application or internal tool on top of custom REST endpoints? Get in touch — I design and build secure, versioned WordPress REST APIs that scale alongside the products they power.

Frequently asked questions

No. __return_true means the endpoint is public and unauthenticated — any request from anywhere in the world can call it and trigger your callback. For read-only GET endpoints returning non-sensitive public data, this is acceptable. For any write operation (POST, PUT, DELETE), you must check the current user's capabilities with current_user_can(). A common mistake is copy-pasting a GET endpoint registration and forgetting to update the permission callback when adding a write route alongside it.

The callback receives a WP_REST_Request object. URL capture groups (e.g. (?P<id>d+)) are available as $request['id']. Query string parameters are available via $request->get_param( 'name' ). JSON body parameters (sent with Content-Type: application/json) are available via $request->get_json_params(); form-encoded body parameters via $request->get_body_params(). When arguments are declared in the args array and have a sanitize_callback, the sanitized value is what you get from $request['param'].

register_rest_route() creates an entirely new URL endpoint at /wp-json/namespace/route. You control the full request-response cycle: what methods are accepted, what data is returned, what permissions are required. register_rest_field() adds extra fields to an existing endpoint — for example, adding a sale_price field to the standard /wp/v2/products endpoint without building a custom route. Use register_rest_field() when you want to augment the default WordPress endpoint for a post type; use register_rest_route() when you need a completely custom URL with its own logic and schema.

For same-domain requests (a JavaScript file loaded by wp_enqueue_script calling the API), use cookie authentication plus a nonce: pass wp_create_nonce('wp_rest') to your script via wp_localize_script() and send it in the X-WP-Nonce header. For external headless frontends (Next.js, Nuxt, a mobile app), Application Passwords are the built-in option: the client sends Authorization: Basic base64(user:app_password). For stateless short-lived tokens, a JWT plugin adds token issuance and verification without rolling your own implementation.

Return a WP_Error object instead of a response. WordPress serializes it as a JSON error body and maps the error code to an HTTP status. Always pass the status explicitly in the data array — new WP_Error( 'rest_not_found', 'Item not found.', array( 'status' => 404 ) ) — because WordPress defaults unknown error codes to 500. Use descriptive, namespaced error codes (myplugin_not_found rather than just error) so clients can branch on specific error types without parsing the message string, which may be translated.

Wrap the expensive logic in a transient: call get_transient( $key ) at the top of the callback and return immediately if the result is cached. If not, run the query, call set_transient( $key, $data, $expiry ), then return the response. Choose the cache key carefully — if the result varies by query parameter (e.g. a page number or filter value), include those values in the key. Invalidate the transient on relevant write operations so clients don't receive stale data after an update. For site-wide high-frequency data, consider a full-page cache layer (WP Rocket, LiteSpeed) instead of per-endpoint transients.

Photo of Ajay Khandal

Written by Ajay Khandal

I'm a freelance WordPress developer with 14+ years of experience building, fixing, and speeding up sites for businesses, agencies, and store owners across the US, UK, Europe, and Australia. I specialize in custom themes, WooCommerce, and performance — the kind of work that shows up as faster load times and fewer support tickets. No account managers, no outsourced tickets — you work directly with me, with replies typically inside 5 hours.

Work with me →