Create a custom WordPress REST API endpoint

The WordPress REST API, introduced in WordPress 4.7, exposes your site’s data as JSON over standard HTTP endpoints. While the built-in endpoints cover posts, pages, users, and taxonomies, almost every project eventually needs a custom endpoint — to return aggregated data, power a mobile app, integrate a third-party service, or feed a decoupled React/Vue front-end. Registering a custom REST route is straightforward: you call register_rest_route() inside the rest_api_init action and provide a namespace, route pattern, HTTP method, callback function, and optionally an argument schema and permission callback. The permission callback is critical for security — it must return true (or check a capability) to allow the request; returning false sends a 403 response. Route parameters like (?P<id>\d+) capture named values from the URL that are passed to your callback in the WP_REST_Request object. Always return a WP_REST_Response or WP_Error from your callback — never echo raw output. For secure data retrieval in callbacks, use $wpdb->prepare() for any database queries, and apply the same sanitisation rules covered in the input sanitisation guide. Custom endpoints can also be authenticated with Application Passwords (WordPress 5.6+) for machine-to-machine use. The example below registers a public endpoint that returns post statistics and a protected endpoint that requires editor access.

Problem: You need a custom JSON endpoint in WordPress to return data to a JavaScript front-end or a third-party service, and the built-in REST routes do not expose the exact data you need.

Solution: Add the following code to your functions.php file:

add_action( 'rest_api_init', 'helloadmin_register_rest_routes' );
function helloadmin_register_rest_routes() {

    // Public endpoint: GET /wp-json/helloadmin/v1/stats
    register_rest_route( 'helloadmin/v1', '/stats', [
        'methods'             => WP_REST_Server::READABLE, // GET
        'callback'            => 'helloadmin_rest_stats',
        'permission_callback' => '__return_true',          // public
    ] );

    // Protected endpoint: GET /wp-json/helloadmin/v1/posts/{id}
    register_rest_route( 'helloadmin/v1', '/posts/(?P<id>\d+)', [
        'methods'             => WP_REST_Server::READABLE,
        'callback'            => 'helloadmin_rest_get_post',
        'permission_callback' => function() {
            return current_user_can( 'edit_posts' );
        },
        'args' => [
            'id' => [
                'required'          => true,
                'sanitize_callback' => 'absint',
                'validate_callback' => function( $val ) {
                    return $val > 0;
                },
            ],
        ],
    ] );
}

function helloadmin_rest_stats( WP_REST_Request $request ) {
    $data = get_transient( 'helloadmin_rest_stats' );
    if ( false === $data ) {
        global $wpdb;
        $data = [
            'published_posts' => (int) $wpdb->get_var(
                "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status='publish' AND post_type='post'"
            ),
            'registered_users' => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->users}" ),
        ];
        set_transient( 'helloadmin_rest_stats', $data, HOUR_IN_SECONDS );
    }
    return rest_ensure_response( $data );
}

function helloadmin_rest_get_post( WP_REST_Request $request ) {
    $post = get_post( $request['id'] );
    if ( ! $post || $post->post_status !== 'publish' ) {
        return new WP_Error( 'not_found', 'Post not found', [ 'status' => 404 ] );
    }
    return rest_ensure_response( [
        'id'      => $post->ID,
        'title'   => $post->post_title,
        'content' => apply_filters( 'the_content', $post->post_content ),
        'date'    => $post->post_date,
    ] );
}

NOTE: Never set permission_callback to __return_true for an endpoint that modifies data or returns private information. All custom REST routes are publicly discoverable via the /wp-json root response, so even “unlisted” routes must have proper permission callbacks. For endpoints that accept POST / PUT requests from browser JavaScript, verify the X-WP-Nonce header using check_ajax_referer() or wp_verify_nonce() inside the permission callback to prevent CSRF attacks.