Contents
Overview — What this tutorial covers
This article is a comprehensive, step-by-step guide to creating custom WordPress REST API endpoints using register_rest_route. It covers fundamental concepts, detailed code examples (both procedural and class-based), request parsing, validation and sanitization, authorization and nonce usage, returning responses and errors, adding fields and schemas, caching best practices, versioning and namespacing, debugging tools, and production security practices. Each example is a complete, copyable snippet you can paste into a plugin or a themes functions.php for testing.
Prerequisites
- WordPress 4.7 or newer (the REST API is fully integrated in core from 4.7).
- Basic knowledge of PHP and WordPress plugin/theme development.
- Familiarity with JSON and HTTP methods (GET, POST, PUT/PATCH, DELETE).
Core concepts
- Namespace — A namespace groups your endpoints and versions, e.g. my-plugin/v1.
- Route — A path after the namespace, optionally with placeholders, e.g. /items/(?P
d ) . - Methods — HTTP verbs to support: GET, POST, PUT/PATCH, DELETE. Use WP_REST_Server constants for clarity.
- Callback — The function or method that receives a WP_REST_Request object and returns data (WP_Error, WP_REST_Response, array, object).
- Permission callback — A callback that returns true when the request is authorized. Always implement it for non-public endpoints.
- Args and schema — Define accepted parameters, validation, sanitization, and response schema for better documentation and automatic validation.
Registering a basic GET endpoint
Minimum steps: hook rest_api_init and call register_rest_route with namespace, route, methods, callback and permission_callback.
Simple example: a public GET endpoint returning a message
GET, callback => my_plugin_hello_endpoint, permission_callback => __return_true, // public endpoint ) ) } ) function my_plugin_hello_endpoint( WP_REST_Request request ) { return rest_ensure_response( array( message => Hello from my custom endpoint!, time => current_time( mysql ), ) ) } ?>
Access: GET /wp-json/my-plugin/v1/hello
Explanation — important parts
- rest_api_init is the hook used to register routes when the REST server initializes.
- register_rest_route accepts: namespace (string), route (string), and args (array). The args array can be one route or multiple route definitions (for different methods).
- callback receives a WP_REST_Request object with methods such as get_param, get_params, get_json_params, get_body_params, get_header.
- permission_callback must return boolean (or WP_Error) — use a capability check or nonce validation for protected endpoints.
- rest_ensure_response wraps a value into WP_REST_Response if needed.
Route parameters and regex placeholders
Use named capture groups with the syntax (?P
add_action( rest_api_init, function () { register_rest_route( my-plugin/v1, /items/(?Pd ), array( methods => GET, callback => my_plugin_get_item, permission_callback => __return_true, ) ) } ) function my_plugin_get_item( WP_REST_Request request ) { id = (int) request->get_param( id ) // Fetch post, custom table, or other resource by ID... post = get_post( id ) if ( ! post ) { return new WP_Error( not_found, Item not found, array( status => 404 ) ) } return rest_ensure_response( post ) }
HTTP method constants table
HTTP method | WP_REST_Server constant |
---|---|
GET | WP_REST_Server::READABLE |
POST | WP_REST_Server::CREATABLE |
PUT/PATCH | WP_REST_Server::EDITABLE |
DELETE | WP_REST_Server::DELETABLE |
Any | WP_REST_Server::ALLMETHODS |
Arguments, validation, and sanitization
In register_rest_route you can declare an args array to validate and sanitize input parameters. This helps the API reject malformed requests early and provide clear errors.
add_action( rest_api_init, function () { register_rest_route( my-plugin/v1, /search, array( methods => WP_REST_Server::READABLE, callback => my_plugin_search_items, permission_callback => __return_true, args => array( q => array( required => true, validate_callback => rest_validate_request_arg, sanitize_callback => sanitize_text_field, ), page => array( default => 1, sanitize_callback => absint, ), per_page => array( default => 10, validate_callback => function( param, request, key ) { return is_numeric( param ) param > 0 param <= 100 }, sanitize_callback => absint, ), ), ) ) } ) function my_plugin_search_items( WP_REST_Request request ) { q = request->get_param( q ) page = request->get_param( page ) per_page = request->get_param( per_page ) // Use WP_Query or custom search to return items... return rest_ensure_response( array( query => q, page => page, per_page => per_page, ) ) }
Permission callbacks and authentication
Permission callbacks must strongly enforce who can do what. For operations that alter data (POST, PUT, DELETE) always require capability checks or nonce validation. For public reads, you may use __return_true.
- current_user_can — Check a capability, e.g. current_user_can(edit_posts) or custom capability.
- nonce — Use WP nonces (X-WP-Nonce header) for AJAX-like requests from the frontend. Verify with wp_verify_nonce.
- cookie authentication — For logged-in users, default REST uses cookie-based auth if cookies are present.
- Application passwords / Basic Auth — Useful for server-to-server built-in since WP 5.6 (application passwords), or Basic Auth plugin for testing.
- JWT/OAuth — Use plugins if you need token-based external auth.
Example: permission callback using capability and nonce
function my_plugin_permission_check( WP_REST_Request request ) { // Check user capability if ( ! current_user_can( edit_posts ) ) { return new WP_Error( forbidden, Insufficient permissions, array( status => 403 ) ) } // Optional: verify a custom nonce passed in header or body nonce = request->get_header( x-my-plugin-nonce ) ?: request->get_param( nonce ) if ( ! nonce ! wp_verify_nonce( nonce, my_plugin_action ) ) { return new WP_Error( invalid_nonce, Invalid nonce, array( status => 403 ) ) } return true }
Creating (POST), updating (PUT/PATCH), and deleting (DELETE)
When accepting writes, always sanitize and validate inputs and ensure proper permission checks. Return appropriate HTTP status codes: 201 for created, 200 for OK, 204 for no content on successful delete, 400/403/404/422 on client errors.
add_action( rest_api_init, function () { register_rest_route( my-plugin/v1, /items, array( array( methods => WP_REST_Server::CREATABLE, // POST callback => my_plugin_create_item, permission_callback => my_plugin_permission_check, args => array( title => array( required => true, sanitize_callback => sanitize_text_field, ), content => array( required => true, sanitize_callback => wp_kses_post, ), ), ), ) ) } ) function my_plugin_create_item( WP_REST_Request request ) { title = request->get_param( title ) content = request->get_param( content ) post_id = wp_insert_post( array( post_title => title, post_content => content, post_status => publish, post_type => post, ), true ) if ( is_wp_error( post_id ) ) { return post_id } response = rest_ensure_response( array( id => post_id ) ) response->set_status( 201 ) response->header( Location, rest_url( sprintf( /my-plugin/v1/items/%d, post_id ) ) ) return response }
Returning errors and WP_Error
Return WP_Error with an appropriate status in the third parameter to let WordPress generate a proper JSON error response. Example:
if ( ! resource ) { return new WP_Error( no_resource, Resource not found, array( status => 404 ) ) }
Response customization and headers
Use WP_REST_Response for advanced responses: set status codes, headers, and add links. The rest_ensure_response() helper will convert arrays and objects automatically.
response = rest_ensure_response( data ) response->set_status( 200 ) response->header( Cache-Control, max-age=3600, public ) response->header( X-Custom-Header, value ) return response
Schema and documentation
Define a response schema via the schema argument in register_rest_route or by implementing a schema method when using controllers. This helps tools like the REST API index and documentation consumers understand your endpoint data types.
// Example of schema callback function my_plugin_item_schema() { return array( schema => http://json-schema.org/draft-04/schema#, title => my_item, type => object, properties => array( id => array( type => integer, context => array( view, edit ) ), title => array( type => string, context => array( view, edit ) ), content => array( type => string, context => array( view, edit ) ), ), ) } register_rest_route( my-plugin/v1, /items/(?Pd ), array( methods => WP_REST_Server::READABLE, callback => my_plugin_get_item, permission_callback => __return_true, args => array(), schema => my_plugin_item_schema, ) )
Class-based controllers (recommended for complex APIs)
Creating a controller class that extends WP_REST_Controller allows you to group routes, reuse code, and follow WordPress core patterns. Implement register_routes and helper methods like get_items, get_item, create_item, prepare_item_for_response, and get_item_schema.
namespace = my-plugin/v1 this->rest_base = items } public function register_routes() { register_rest_route( this->namespace, / . this->rest_base, array( array( methods => WP_REST_Server::READABLE, callback => array( this, get_items ), permission_callback => __return_true, ), array( methods => WP_REST_Server::CREATABLE, callback => array( this, create_item ), permission_callback => array( this, create_item_permissions_check ), args => this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ), ), ) ) register_rest_route( this->namespace, / . this->rest_base . /(?Pd ), array( array( methods => WP_REST_Server::READABLE, callback => array( this, get_item ), permission_callback => __return_true, args => array( id => array( validate_callback => is_numeric ), ), ), array( methods => WP_REST_Server::EDITABLE, callback => array( this, update_item ), permission_callback => array( this, update_item_permissions_check ), args => this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ), ), ) ) } public function get_items( request ) { // Return a list return rest_ensure_response( array() ) } public function get_item( request ) { id = (int) request[id] // Return a single item return rest_ensure_response( array( id => id ) ) } // ... other methods, prepare_item_for_response, get_item_schema, etc. } add_action( rest_api_init, function() { controller = new My_Plugin_Controller() controller->register_routes() } ) ?>
Adding custom fields to existing REST responses
Use register_rest_field to add computed or meta fields to the response for a post type, user, or term.
add_action( rest_api_init, function() { register_rest_field( post, reading_time, array( get_callback => function( object ) { content = object[content][rendered] ?? words = str_word_count( wp_strip_all_tags( content ) ) minutes = ceil( words / 200 ) return minutes }, schema => array( description => Estimated reading time in minutes., type => integer, context => array( view ), ), ) ) } )
Testing endpoints: curl and JavaScript examples
curl example for a GET request:
curl -X GET https://example.com/wp-json/my-plugin/v1/items/123
POST with JSON (and using a WP nonce for authentication in the header):
curl -X POST https://example.com/wp-json/my-plugin/v1/items -H Content-Type: application/json -H X-WP-Nonce: YOUR_NONCE_HERE -d {title:Hello,content:Content here}
Fetch from browser JavaScript (using the localized WP nonce stored in window.myPlugin.nonce):
fetch( /wp-json/my-plugin/v1/items, { method: POST, headers: { Content-Type: application/json, X-WP-Nonce: window.myPlugin.nonce }, body: JSON.stringify({ title: New, content: Content }) } ) .then( res =gt res.json() ) .then( data =gt console.log( data ) )
Caching and performance considerations
- Use Cache-Control headers for GET responses that can be cached. Set appropriate max-age and vary headers as needed.
- Implement pagination and limits (per_page) to avoid returning huge data sets in one response.
- Use transient caching or object caching for expensive queries, keyed by request parameters.
- Prefer WP_Query with appropriate fields and IDs instead of loading heavy objects when possible.
Security best practices
- Validate and sanitize all inputs. Never trust request data.
- Use capability checks in permission_callback for write operations and sensitive reads.
- Use nonces for requests originating from the authenticated browser. Use stronger token-based auth for external services.
- Return minimal data required. Avoid leaking private fields (emails, tokens, etc.).
- Rate-limit or throttle expensive endpoints if they are accessible publicly.
Common pitfalls and how to debug
- Wrong hook: Always use rest_api_init — registering routes earlier will not work reliably.
- Permissions always failing: Ensure permission_callback returns true (not WP_Error) for allowed access.
- 404 for valid route: Check namespace, route pattern, and that rest_api_init registration actually runs (no early exit).
- JSON parse errors: Ensure content-type header is application/json for requests with a JSON body.
- Debugging: Use error_log, WP_DEBUG with WP_DEBUG_LOG, and inspect the response body for WP_Error messages and statuses.
Advanced topics and hooks
- Use rest_pre_dispatch, rest_post_dispatch, or rest_request_before_callbacks to intercept or modify requests/responses globally.
- Use rest_api_init priority to order route registration if multiple plugins register the same routes.
- Integrate with WP REST APIs automatic schema generation by implementing get_item_schema in controllers.
- Use register_rest_route to define multiple route definitions for the same route path with different methods and permission callbacks.
Complete example plugin
The example below is a minimal but complete plugin file that registers a simple CRUD REST API for a custom post type item. It includes registration, permission checks, validation, and a controller-like grouping.
WP_REST_Server::READABLE, callback => my_plugin_get_items, permission_callback => __return_true, args => array( page => array( default => 1, sanitize_callback => absint ), per_page => array( default => 10, sanitize_callback => absint ), ), ), array( methods => WP_REST_Server::CREATABLE, callback => my_plugin_create_item, permission_callback => my_plugin_permission_check, args => array( title => array( required => true, sanitize_callback => sanitize_text_field ), content => array( required => true, sanitize_callback => wp_kses_post ), ), ), ) ) // GET single, PUT update, DELETE register_rest_route( my-plugin/v1, /items/(?Pd ), array( array( methods => WP_REST_Server::READABLE, callback => my_plugin_get_item, permission_callback => __return_true, ), array( methods => WP_REST_Server::EDITABLE, callback => my_plugin_update_item, permission_callback => my_plugin_permission_check, ), array( methods => WP_REST_Server::DELETABLE, callback => my_plugin_delete_item, permission_callback => my_plugin_permission_check, ), ) ) } ) // Implementations function my_plugin_get_items( WP_REST_Request request ) { page = request->get_param( page ) per_page = request->get_param( per_page ) args = array( post_type => post, // replace with custom type if needed posts_per_page => per_page, paged => page, ) posts = get_posts( args ) data = array() foreach ( posts as p ) { data[] = array( id => p->ID, title => get_the_title( p ), link => get_permalink( p ), ) } response = rest_ensure_response( data ) response->header( X-Total-Items, wp_count_posts( post )->publish ) return response } function my_plugin_get_item( WP_REST_Request request ) { id = (int) request->get_param( id ) post = get_post( id ) if ( ! post ) { return new WP_Error( not_found, Item not found, array( status => 404 ) ) } return rest_ensure_response( array( id => post->ID, title => get_the_title( post ), content => apply_filters( the_content, post->post_content ), ) ) } function my_plugin_create_item( WP_REST_Request request ) { title = request->get_param( title ) content = request->get_param( content ) post_id = wp_insert_post( array( post_title => title, post_content => content, post_status => publish, post_type => post, ), true ) if ( is_wp_error( post_id ) ) { return post_id } response = rest_ensure_response( array( id => post_id ) ) response->set_status( 201 ) response->header( Location, rest_url( sprintf( /my-plugin/v1/items/%d, post_id ) ) ) return response } function my_plugin_update_item( WP_REST_Request request ) { id = (int) request->get_param( id ) post = get_post( id ) if ( ! post ) { return new WP_Error( not_found, Item not found, array( status => 404 ) ) } update_args = array( ID => id ) if ( request->get_param( title ) ) { update_args[post_title] = sanitize_text_field( request->get_param( title ) ) } if ( request->get_param( content ) ) { update_args[post_content] = wp_kses_post( request->get_param( content ) ) } result = wp_update_post( update_args, true ) if ( is_wp_error( result ) ) { return result } return rest_ensure_response( array( id => id ) ) } function my_plugin_delete_item( WP_REST_Request request ) { id = (int) request->get_param( id ) deleted = wp_delete_post( id, true ) if ( ! deleted ) { return new WP_Error( delete_failed, Could not delete item, array( status => 500 ) ) } return rest_ensure_response( null ) // 200 with empty body or consider 204 } function my_plugin_permission_check( WP_REST_Request request ) { if ( ! is_user_logged_in() ) { return new WP_Error( not_logged_in, You must be logged in., array( status => 401 ) ) } if ( ! current_user_can( edit_posts ) ) { return new WP_Error( forbidden, Insufficient permissions., array( status => 403 ) ) } return true } ?>
Further reading
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |