Back to Articles
32 min read

Modern WordPress APIs: REST, AJAX, and Asynchronous Architecture

The bridge to modern, headless architecture. This guide provides a comparative analysis of WordPress's asynchronous capabilities. We cover the legacy `admin-ajax.php` workflow for standard implementations and provide a definitive deep-dive into the REST API—including custom Controller architecture, schema validation, and authentication strategies for decoupled frontends.

Admin AJAX

admin-ajax.php

The central WordPress file located at /wp-admin/admin-ajax.php that handles all AJAX requests; it loads the WordPress environment, processes the action parameter, and triggers corresponding hooks—all AJAX calls should target this endpoint.

[Browser] --POST/GET--> /wp-admin/admin-ajax.php?action=my_action --> [WordPress] --> [Your Hook]

wp_ajax_ hooks

Action hooks for handling AJAX requests from logged-in users only; the hook name is dynamically constructed as wp_ajax_{action} where {action} matches the action parameter sent from JavaScript.

// Handles AJAX for logged-in users add_action('wp_ajax_get_user_data', 'handle_get_user_data'); function handle_get_user_data() { $user_id = get_current_user_id(); wp_send_json_success(['user_id' => $user_id]); }

wp_ajax_nopriv_ hooks

Action hooks for handling AJAX requests from non-logged-in (anonymous) users; typically you register both hooks if the functionality should work for all visitors regardless of login status.

// For logged-in users add_action('wp_ajax_load_posts', 'ajax_load_posts'); // For guests (non-privileged) add_action('wp_ajax_nopriv_load_posts', 'ajax_load_posts'); function ajax_load_posts() { // Same handler for both }

Action parameter

The required action parameter in every AJAX request that WordPress uses to determine which wp_ajax_ hook to trigger; without it, WordPress cannot route your request to the correct handler.

┌─────────────────────────────────────────────────────┐
│  POST /wp-admin/admin-ajax.php                      │
│  ─────────────────────────────────────              │
│  action=my_custom_action  ←── Maps to:              │
│  data=some_value              wp_ajax_my_custom_action
│  nonce=abc123                                       │
└─────────────────────────────────────────────────────┘

jQuery AJAX calls

The traditional method for making AJAX requests in WordPress using jQuery's $.ajax() or shorthand methods like $.post(); WordPress includes jQuery by default, making this approach historically common.

jQuery(document).ready(function($) { $.ajax({ url: ajaxurl, // Available in admin, or use localized variable type: 'POST', data: { action: 'my_action', nonce: myPlugin.nonce, post_id: 123 }, success: function(response) { if (response.success) { console.log(response.data); } } }); });

Vanilla JS AJAX

Modern approach using native JavaScript fetch() or XMLHttpRequest without jQuery dependency; results in smaller bundle sizes and is preferred for modern WordPress development, especially with block editor integration.

fetch(wpApiSettings.ajaxUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ action: 'my_action', nonce: wpApiSettings.nonce, post_id: 123 }) }) .then(response => response.json()) .then(data => console.log(data));

AJAX Implementation

wp_localize_script() for AJAX

Function that passes PHP variables to JavaScript by attaching data to an enqueued script; essential for providing the AJAX URL and nonce to frontend scripts since JavaScript cannot directly access PHP values.

wp_enqueue_script('my-ajax-script', plugin_url('js/ajax.js'), ['jquery'], '1.0', true); wp_localize_script('my-ajax-script', 'myAjax', [ 'ajaxurl' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('my_ajax_nonce'), 'post_id' => get_the_ID() ]); // In JavaScript: myAjax.ajaxurl, myAjax.nonce, myAjax.post_id

Nonce in AJAX

Security tokens (Number used ONCE) that verify AJAX requests originate from your site and prevent CSRF attacks; always create nonces server-side, pass them to JavaScript, and verify them in your AJAX handler before processing.

// PHP Handler function my_ajax_handler() { // ALWAYS verify nonce first! if (!wp_verify_nonce($_POST['nonce'], 'my_ajax_nonce')) { wp_send_json_error('Invalid nonce', 403); } // Safe to proceed wp_send_json_success('Verified!'); }

Response handling

The client-side process of receiving and processing the server's AJAX response; WordPress standardizes responses with success boolean and data payload, enabling consistent handling across your application.

// Standard WordPress AJAX response structure fetch(ajaxurl, { method: 'POST', body: formData }) .then(res => res.json()) .then(response => { if (response.success) { // response.data contains your payload updateUI(response.data); } else { // response.data contains error info showError(response.data); } }) .catch(err => console.error('Network error:', err));

JSON responses

The standard data format for WordPress AJAX responses; JSON (JavaScript Object Notation) is lightweight, easily parsed by JavaScript, and WordPress provides helper functions to properly format and send JSON with correct headers.

┌──────────────────────────────────────┐
│  Standard WP JSON Response Format    │
├──────────────────────────────────────┤
│  Success:                            │
│  { "success": true,                  │
│    "data": { "id": 1, "msg": "OK" }} │
│                                      │
│  Error:                              │
│  { "success": false,                 │
│    "data": "Error message" }         │
└──────────────────────────────────────┘

wp_send_json()

Sends arbitrary data as JSON response, sets proper headers (Content-Type: application/json), and terminates execution with wp_die(); use when you need complete control over response structure without the success/error wrapper.

function custom_ajax_handler() { $data = [ 'status' => 'completed', 'items' => get_posts(['numberposts' => 5]), 'timestamp' => current_time('mysql') ]; wp_send_json($data); // Sends raw JSON and dies // No code executes after this }

wp_send_json_success()

Sends a standardized success response with {"success": true, "data": ...} structure; the preferred method for successful AJAX responses as it provides consistent formatting that client-side code can reliably parse.

function get_user_posts() { check_ajax_referer('my_nonce', 'nonce'); $posts = get_posts(['author' => get_current_user_id()]); wp_send_json_success([ 'posts' => $posts, 'count' => count($posts), 'message' => 'Posts retrieved successfully' ]); } // Output: {"success":true,"data":{"posts":[...],"count":5,"message":"..."}}

wp_send_json_error()

Sends a standardized error response with {"success": false, "data": ...} structure; accepts optional error message/data and HTTP status code, making error handling consistent and predictable on the client side.

function delete_item() { if (!current_user_can('delete_posts')) { wp_send_json_error('Permission denied', 403); } $id = intval($_POST['item_id']); if (!$id) { wp_send_json_error(['code' => 'invalid_id', 'message' => 'Invalid item ID']); } wp_delete_post($id); wp_send_json_success('Deleted'); }

wp_die() in AJAX

Properly terminates AJAX request execution; while wp_send_json_* functions call this automatically, use wp_die() directly when sending non-JSON responses or when you need to stop execution after custom output.

function custom_ajax_handler() { // For JSON responses - wp_die() is called automatically wp_send_json_success($data); // For non-JSON responses (like HTML) echo '<div class="result">HTML content</div>'; wp_die(); // MUST call explicitly! // NEVER use: die(), exit(), or let function end naturally // These bypass WordPress shutdown hooks }

Heartbeat API

Heartbeat hooks

WordPress's polling system that sends periodic AJAX requests (every 15-60 seconds) to keep sessions alive and sync data; hooks include heartbeat_received (server-side processing) and heartbeat_send/heartbeat_tick (client-side JavaScript).

// Server-side: receive and respond to heartbeat add_filter('heartbeat_received', 'my_heartbeat_handler', 10, 2); function my_heartbeat_handler($response, $data) { if (!empty($data['my_plugin_check'])) { $response['my_plugin_data'] = [ 'notifications' => get_user_notifications(), 'timestamp' => time() ]; } return $response; }

Custom heartbeat data

Adding your own data payload to heartbeat requests for real-time updates like notifications, post locking status, or live content changes; data is sent via JavaScript and processed by PHP filters.

// Client: Send data with heartbeat wp.heartbeat.enqueue('my_plugin_check', { post_id: 123 }); // Client: Receive response jQuery(document).on('heartbeat-tick', function(e, data) { if (data.my_plugin_data) { updateNotifications(data.my_plugin_data.notifications); } });
┌─────────┐   heartbeat_send    ┌─────────┐   heartbeat_received   ┌────────┐
│ Browser │ ──────────────────► │ Server  │ ◄──────────────────── │ Plugin │
│         │ ◄────────────────── │         │ ─────────────────────►│        │
└─────────┘   heartbeat_tick    └─────────┘   return $response     └────────┘

Heartbeat frequency

Controls how often heartbeat AJAX requests are sent; default is 15-60 seconds depending on context, adjustable via heartbeat_settings filter—balance between real-time updates and server load.

// Modify heartbeat interval (in seconds) add_filter('heartbeat_settings', 'modify_heartbeat_frequency'); function modify_heartbeat_frequency($settings) { // Options: 15, 30, 60, 120 (seconds) $settings['interval'] = 30; // Every 30 seconds // Minimum interval allowed $settings['minimalInterval'] = 15; return $settings; }

Heartbeat suspend

Pausing or disabling heartbeat to reduce server load; heartbeat automatically slows when browser tab is inactive, but you can manually control this for specific pages or conditions.

// Disable heartbeat entirely on frontend add_action('init', 'control_heartbeat'); function control_heartbeat() { if (!is_admin()) { wp_deregister_script('heartbeat'); } } // Or disable on specific admin pages add_action('admin_enqueue_scripts', function($hook) { if ($hook !== 'post.php') { wp_deregister_script('heartbeat'); } });

REST API Fundamentals

REST API endpoints

URL patterns that respond to HTTP requests and return data (typically JSON); WordPress REST API lives at /wp-json/ and provides programmatic access to WordPress content, enabling headless CMS architectures and external integrations.

┌─────────────────────────────────────────────────────────────┐
│  WordPress REST API Endpoint Examples                       │
├─────────────────────────────────────────────────────────────┤
│  GET  /wp-json/wp/v2/posts          → List all posts       │
│  GET  /wp-json/wp/v2/posts/123      → Get single post      │
│  POST /wp-json/wp/v2/posts          → Create new post      │
│  PUT  /wp-json/wp/v2/posts/123      → Update post          │
│  DELETE /wp-json/wp/v2/posts/123    → Delete post          │
└─────────────────────────────────────────────────────────────┘

Endpoint structure

The hierarchical URL pattern consisting of base URL, namespace, version, resource, and optional resource ID; this RESTful structure makes APIs predictable and self-documenting.

https://example.com/wp-json/wp/v2/posts/123
│                   │       │  │  │     │
│                   │       │  │  │     └── Resource ID (optional)
│                   │       │  │  └──────── Resource/Route
│                   │       │  └─────────── Version
│                   │       └────────────── Namespace
│                   └────────────────────── REST API Base
└────────────────────────────────────────── Site URL

HTTP methods (GET, POST, PUT, PATCH, DELETE)

Standard HTTP verbs mapping to CRUD operations; GET retrieves data (Read), POST creates (Create), PUT/PATCH updates (Update), and DELETE removes (Delete)—WordPress REST API follows these RESTful conventions.

┌──────────┬─────────────┬────────────────────────────────────┐
│  Method  │  Operation  │  Example                           │
├──────────┼─────────────┼────────────────────────────────────┤
│  GET     │  Read       │  Fetch posts, get user data        │
│  POST    │  Create     │  Create new post, upload media     │
│  PUT     │  Replace    │  Complete update of resource       │
│  PATCH   │  Partial    │  Update specific fields only       │
│  DELETE  │  Remove     │  Delete post, remove comment       │
└──────────┴─────────────┴────────────────────────────────────┘

Default endpoints

Built-in REST API routes WordPress provides out-of-the-box for posts, pages, users, comments, taxonomies, media, and settings; available at /wp-json/wp/v2/ covering most common content operations.

// Default WordPress REST API endpoints /wp-json/wp/v2/posts // Posts /wp-json/wp/v2/pages // Pages /wp-json/wp/v2/users // Users /wp-json/wp/v2/comments // Comments /wp-json/wp/v2/media // Media/Attachments /wp-json/wp/v2/categories // Categories /wp-json/wp/v2/tags // Tags /wp-json/wp/v2/settings // Site settings /wp-json/wp/v2/blocks // Reusable blocks /wp-json/wp/v2/search // Search results

Namespace concept

Organizational prefix that groups related endpoints and prevents naming collisions between plugins; format is typically vendor/version (e.g., wp/v2, myplugin/v1), enabling versioning and clear ownership.

// WordPress core namespace /wp-json/wp/v2/posts // WooCommerce namespace /wp-json/wc/v3/products // Custom plugin namespace register_rest_route('myplugin/v1', '/items', [...]); // Results in: /wp-json/myplugin/v1/items // Another version register_rest_route('myplugin/v2', '/items', [...]); // Results in: /wp-json/myplugin/v2/items

Route parameters

Dynamic URL segments that capture values from the endpoint path; defined using regex patterns in route registration, they allow accessing specific resources like individual posts or filtered collections.

register_rest_route('myplugin/v1', '/items/(?P<id>\d+)', [ 'methods' => 'GET', 'callback' => 'get_item', ]); // (?P<id>\d+) captures numeric ID // URL: /wp-json/myplugin/v1/items/42 function get_item($request) { $id = $request['id']; // 42 $id = $request->get_param('id'); // Alternative method return new WP_REST_Response(['item_id' => $id]); }

Consuming REST API

Fetch API

Modern JavaScript method for making HTTP requests; preferred over XMLHttpRequest for its cleaner Promise-based syntax, native browser support, and seamless integration with async/await patterns.

// GET request const response = await fetch('/wp-json/wp/v2/posts'); const posts = await response.json(); // POST request with authentication fetch('/wp-json/wp/v2/posts', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': wpApiSettings.nonce }, body: JSON.stringify({ title: 'New Post', content: 'Post content here', status: 'publish' }) });

Authentication methods

Various ways to verify user identity for REST API requests; required for endpoints that modify data or access private content—WordPress supports multiple methods with different security profiles and use cases.

┌────────────────────┬────────────────────────────────────────┐
│  Method            │  Best For                              │
├────────────────────┼────────────────────────────────────────┤
│  Cookie + Nonce    │  Same-site JS (logged-in users)        │
│  Application Pass  │  External apps, scripts                │
│  Basic Auth        │  Development/testing only              │
│  OAuth 2.0         │  Third-party integrations              │
│  JWT               │  Stateless mobile/SPA apps             │
└────────────────────┴────────────────────────────────────────┘

Application passwords

WordPress 5.6+ feature providing per-application authentication tokens; safer than sharing main passwords, revocable individually, and ideal for external applications, scripts, or integrations accessing the REST API.

// Generated in: Users → Profile → Application Passwords // Format: xxxx xxxx xxxx xxxx xxxx xxxx // Usage with cURL curl -X POST https://example.com/wp-json/wp/v2/posts \ -u "username:xxxx xxxx xxxx xxxx xxxx xxxx" \ -H "Content-Type: application/json" \ -d '{"title":"New Post","status":"publish"}' // Usage with fetch (base64 encoded) headers: { 'Authorization': 'Basic ' + btoa('username:app_password') }

Default WordPress authentication for same-origin JavaScript requests; leverages existing logged-in session cookie combined with nonce verification—automatic for admin pages, requires wp_localize_script() for frontend.

// PHP: Provide nonce to JavaScript wp_localize_script('my-script', 'wpApiSettings', [ 'root' => esc_url_raw(rest_url()), 'nonce' => wp_create_nonce('wp_rest') ]);
// JavaScript: Include nonce in requests fetch(wpApiSettings.root + 'wp/v2/posts', { headers: { 'X-WP-Nonce': wpApiSettings.nonce }, credentials: 'same-origin' // Include cookies });

OAuth integration

Industry-standard authorization framework allowing third-party applications to access WordPress without exposing user credentials; requires additional plugin (like OAuth 2.0 Server) as WordPress doesn't include OAuth by default.

┌──────────┐     1. Request Auth     ┌──────────┐
│  Client  │ ──────────────────────► │   WP     │
│   App    │                         │  Server  │
│          │ ◄────────────────────── │          │
│          │     2. Auth Code        │          │
│          │                         │          │
│          │     3. Exchange Code    │          │
│          │ ──────────────────────► │          │
│          │                         │          │
│          │ ◄────────────────────── │          │
│          │     4. Access Token     │          │
│          │                         │          │
│          │     5. API Requests     │          │
│          │ ──────────────────────► │          │
└──────────┘   (with Bearer token)   └──────────┘

Basic authentication

Simplest authentication method sending base64-encoded username:password in request headers; only use for development/testing as credentials are easily decoded—never use over non-HTTPS connections.

// NOT for production use! const credentials = btoa('admin:password123'); fetch('/wp-json/wp/v2/posts', { headers: { 'Authorization': 'Basic ' + credentials } }); // With Application Passwords (safer) const credentials = btoa('admin:XXXX XXXX XXXX XXXX XXXX XXXX');

JWT authentication

JSON Web Token-based stateless authentication ideal for mobile apps and SPAs; tokens contain encoded user data, are self-verifying, and don't require server-side session storage—requires plugin like JWT Authentication for WP REST API.

// 1. Get token const auth = await fetch('/wp-json/jwt-auth/v1/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'user', password: 'pass' }) }); const { token } = await auth.json(); // 2. Use token in subsequent requests fetch('/wp-json/wp/v2/posts', { headers: { 'Authorization': 'Bearer ' + token } }); // Token structure: header.payload.signature // eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.sig

Creating Custom Endpoints

register_rest_route()

Primary function for creating custom REST API endpoints; accepts namespace, route pattern, and array of options including HTTP methods, callback, permission callback, and argument definitions.

add_action('rest_api_init', function() { register_rest_route('myplugin/v1', '/books', [ 'methods' => 'GET', 'callback' => 'get_books', 'permission_callback' => '__return_true', // Public access ]); register_rest_route('myplugin/v1', '/books/(?P<id>\d+)', [ 'methods' => ['GET', 'PUT', 'DELETE'], 'callback' => 'handle_book', 'permission_callback' => 'check_book_permission', 'args' => [ 'id' => ['required' => true, 'type' => 'integer'] ], ]); });

Callback functions

The main handler function that processes the REST API request and returns data; receives WP_REST_Request object containing all request parameters, headers, and body data.

function get_books(WP_REST_Request $request) { // Access query parameters $per_page = $request->get_param('per_page') ?? 10; $category = $request['category']; // Array access works too // Access headers $custom_header = $request->get_header('X-Custom-Header'); // Access body (for POST/PUT) $body = $request->get_json_params(); // Fetch and return data $books = get_posts(['post_type' => 'book', 'numberposts' => $per_page]); return $books; // Auto-converted to JSON }

Permission callbacks

Security function that runs before the main callback to verify user authorization; must return true for access, false for generic 403, or WP_Error for custom error message.

register_rest_route('myplugin/v1', '/settings', [ 'methods' => 'POST', 'callback' => 'update_settings', 'permission_callback' => function($request) { // Check capability if (!current_user_can('manage_options')) { return new WP_Error( 'rest_forbidden', 'Admin access required', ['status' => 403] ); } return true; } ]); // NEVER use '__return_true' for sensitive endpoints!

Argument validation

Built-in mechanism to validate request parameters before callback execution; uses validate_callback for custom validation logic, returning true for valid or WP_Error for invalid parameters.

register_rest_route('myplugin/v1', '/users', [ 'methods' => 'POST', 'callback' => 'create_user', 'permission_callback' => '__return_true', 'args' => [ 'email' => [ 'required' => true, 'type' => 'string', 'format' => 'email', 'validate_callback' => function($value) { if (!is_email($value)) { return new WP_Error('invalid_email', 'Invalid email format'); } if (email_exists($value)) { return new WP_Error('email_exists', 'Email already registered'); } return true; } ], 'age' => [ 'type' => 'integer', 'minimum' => 18, 'maximum' => 120 ] ] ]);

Sanitization callbacks

Functions that clean and normalize parameter values after validation; uses sanitize_callback to ensure data is safe for storage and processing—runs automatically before callback receives data.

'args' => [ 'title' => [ 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'content' => [ 'type' => 'string', 'sanitize_callback' => 'wp_kses_post', // Allow safe HTML ], 'email' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_email', ], 'tags' => [ 'type' => 'array', 'sanitize_callback' => function($value) { return array_map('sanitize_text_field', $value); } ] ]

Schema definition

JSON Schema specification describing endpoint data structure; enables automatic validation, documentation generation, and helps clients understand expected request/response formats—defined via schema parameter or get_item_schema().

'schema' => [ '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'book', 'type' => 'object', 'properties' => [ 'id' => [ 'description' => 'Unique identifier', 'type' => 'integer', 'readonly' => true, ], 'title' => [ 'description' => 'Book title', 'type' => 'string', 'required' => true, ], 'price' => [ 'description' => 'Price in cents', 'type' => 'integer', 'minimum' => 0, ], ], ]

Response formatting

Structuring callback return values for consistent API responses; WordPress automatically converts arrays/objects to JSON, but using WP_REST_Response provides control over status codes, headers, and pagination.

function get_books($request) { $books = fetch_books_from_db(); $response = new WP_REST_Response($books, 200); // Add custom headers $response->header('X-Total-Count', count($books)); // Add pagination links $response->header('X-WP-Total', $total); $response->header('X-WP-TotalPages', ceil($total / $per_page)); // Set caching $response->header('Cache-Control', 'max-age=3600'); return $response; }

WP_REST_Response

WordPress class for constructing REST API responses with full control over status code, headers, and data; preferred over returning raw arrays when you need HTTP status codes or custom headers.

function create_book($request) { $book_id = wp_insert_post([ 'post_type' => 'book', 'post_title' => $request['title'], 'post_status' => 'publish', ]); if (is_wp_error($book_id)) { return $book_id; // WP_Error auto-converted to error response } $book = get_post($book_id); $response = new WP_REST_Response($book, 201); // 201 Created $response->header('Location', rest_url("myplugin/v1/books/{$book_id}")); return $response; }

WP_Error in REST

WordPress error class that REST API automatically converts to JSON error responses; include error code, message, and optional data array with HTTP status code for proper error handling.

function delete_book($request) { $book_id = $request['id']; if (!get_post($book_id)) { return new WP_Error( 'book_not_found', // Error code 'Book not found', // Human-readable message ['status' => 404] // HTTP status code ); } if (!current_user_can('delete_post', $book_id)) { return new WP_Error( 'rest_cannot_delete', 'Sorry, you cannot delete this book.', ['status' => 403, 'book_id' => $book_id] ); } wp_delete_post($book_id, true); return new WP_REST_Response(null, 204); // No content }

REST API Controllers

WP_REST_Controller

Abstract base class providing standardized structure for REST API endpoints; extending it ensures consistency with WordPress core endpoints and simplifies implementation of CRUD operations through predefined method stubs.

class Book_REST_Controller extends WP_REST_Controller { protected $namespace = 'myplugin/v1'; protected $rest_base = 'books'; public function __construct() { // Controller setup } public function register_routes() { /* ... */ } public function get_items($request) { /* ... */ } public function get_item($request) { /* ... */ } public function create_item($request) { /* ... */ } public function update_item($request) { /* ... */ } public function delete_item($request) { /* ... */ } public function get_item_schema() { /* ... */ } }

Controller methods

Standardized method naming conventions inherited from WP_REST_Controller; each method handles specific HTTP operations and includes corresponding permission check methods—follow this pattern for consistency with WordPress core.

┌──────────────────────────────────────────────────────────────┐
│  Controller Method Pattern                                   │
├──────────────────────────────────────────────────────────────┤
│  get_items()              + get_items_permissions_check()    │
│  get_item()               + get_item_permissions_check()     │
│  create_item()            + create_item_permissions_check()  │
│  update_item()            + update_item_permissions_check()  │
│  delete_item()            + delete_item_permissions_check()  │
│  prepare_item_for_response()                                 │
│  prepare_item_for_database()                                 │
│  get_item_schema()                                           │
└──────────────────────────────────────────────────────────────┘

register_routes()

Method that defines all routes for the controller; called from rest_api_init action, it uses register_rest_route() to map HTTP methods to controller methods with their permission callbacks.

public function register_routes() { // Collection route: /wp-json/myplugin/v1/books 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'], ], [ 'methods' => WP_REST_Server::CREATABLE, // POST 'callback' => [$this, 'create_item'], 'permission_callback' => [$this, 'create_item_permissions_check'], ], 'schema' => [$this, 'get_item_schema'], ]); // Single item route: /wp-json/myplugin/v1/books/123 register_rest_route($this->namespace, '/' . $this->rest_base . '/(?P<id>[\d]+)', [ /* GET, PUT, DELETE handlers */ ]); }

get_items()

Handler for retrieving collections (GET /resource); typically handles pagination, filtering, and sorting parameters, returning an array of resources formatted consistently with prepare_item_for_response().

public function get_items($request) { $args = [ 'post_type' => 'book', 'posts_per_page' => $request['per_page'] ?? 10, 'paged' => $request['page'] ?? 1, ]; if (!empty($request['search'])) { $args['s'] = $request['search']; } $query = new WP_Query($args); $items = []; foreach ($query->posts as $post) { $items[] = $this->prepare_item_for_response($post, $request); } $response = rest_ensure_response($items); $response->header('X-WP-Total', $query->found_posts); $response->header('X-WP-TotalPages', $query->max_num_pages); return $response; }

get_item()

Handler for retrieving a single resource (GET /resource/{id}); validates the resource exists, checks permissions, and returns the formatted resource using prepare_item_for_response().

public function get_item($request) { $id = (int) $request['id']; $post = get_post($id); if (!$post || 'book' !== $post->post_type) { return new WP_Error( 'rest_book_not_found', __('Book not found.'), ['status' => 404] ); } return $this->prepare_item_for_response($post, $request); } public function get_item_permissions_check($request) { $post = get_post($request['id']); if ($post && 'publish' !== $post->post_status) { return current_user_can('read_post', $request['id']); } return true; }

create_item()

Handler for creating new resources (POST /resource); validates and sanitizes input, creates the resource in database, and returns the new resource with 201 status code and Location header.

public function create_item($request) { $prepared = $this->prepare_item_for_database($request); $post_id = wp_insert_post([ 'post_type' => 'book', 'post_title' => $prepared->post_title, 'post_content' => $prepared->post_content, 'post_status' => 'publish', ], true); if (is_wp_error($post_id)) { return $post_id; } // Save meta fields if (!empty($request['isbn'])) { update_post_meta($post_id, 'isbn', $request['isbn']); } $post = get_post($post_id); $response = $this->prepare_item_for_response($post, $request); $response = rest_ensure_response($response); $response->set_status(201); $response->header('Location', rest_url("{$this->namespace}/{$this->rest_base}/{$post_id}")); return $response; }

update_item()

Handler for modifying existing resources (PUT/PATCH /resource/{id}); retrieves current resource, applies changes from request, saves to database, and returns updated resource.

public function update_item($request) { $id = (int) $request['id']; $post = get_post($id); if (!$post) { return new WP_Error('rest_book_not_found', 'Book not found', ['status' => 404]); } $prepared = $this->prepare_item_for_database($request); $updated = wp_update_post([ 'ID' => $id, 'post_title' => $prepared->post_title ?? $post->post_title, 'post_content' => $prepared->post_content ?? $post->post_content, ], true); if (is_wp_error($updated)) { return $updated; } return $this->prepare_item_for_response(get_post($id), $request); }

delete_item()

Handler for removing resources (DELETE /resource/{id}); verifies resource exists, performs deletion, and returns either the deleted item data (with force parameter) or success confirmation with 200/204 status.

public function delete_item($request) { $id = (int) $request['id']; $post = get_post($id); if (!$post) { return new WP_Error('rest_book_not_found', 'Book not found', ['status' => 404]); } $force = (bool) $request['force']; $previous = $this->prepare_item_for_response($post, $request); if ($force) { $result = wp_delete_post($id, true); } else { $result = wp_trash_post($id); } if (!$result) { return new WP_Error('rest_cannot_delete', 'Cannot delete book', ['status' => 500]); } return new WP_REST_Response([ 'deleted' => true, 'previous' => $previous ]); }

get_item_schema()

Returns JSON Schema definition for the resource; used for validation, documentation, and auto-generating response structure—cached using $this->schema property for performance.

public function get_item_schema() { if ($this->schema) { return $this->add_additional_fields_schema($this->schema); } $this->schema = [ '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'book', 'type' => 'object', 'properties' => [ 'id' => ['type' => 'integer', 'readonly' => true], 'title' => ['type' => 'string', 'required' => true], 'content' => ['type' => 'string'], 'author' => ['type' => 'integer'], 'isbn' => ['type' => 'string', 'pattern' => '^\d{13}$'], 'price' => ['type' => 'number', 'minimum' => 0], ], ]; return $this->add_additional_fields_schema($this->schema); }

REST API Advanced

Field registration

Adding custom fields to existing REST API responses without modifying core endpoints; allows plugins to extend default WordPress resources (posts, users, etc.) with additional data seamlessly.

// Add custom field to posts endpoint add_action('rest_api_init', function() { register_rest_field('post', 'view_count', [ 'get_callback' => function($post) { return (int) get_post_meta($post['id'], 'views', true); }, 'update_callback' => function($value, $post) { update_post_meta($post->ID, 'views', (int) $value); }, 'schema' => [ 'type' => 'integer', 'description' => 'Number of post views', ], ]); }); // Now GET /wp-json/wp/v2/posts returns: {..., "view_count": 150}

register_rest_field()

Function to add custom fields to existing REST API object types; accepts object type(s), field name, and array of callbacks for reading, writing, and schema definition.

register_rest_field( ['post', 'page'], // Object type(s) 'reading_time', // Field name [ 'get_callback' => function($object, $field_name, $request) { $content = $object['content']['rendered']; $word_count = str_word_count(strip_tags($content)); return ceil($word_count / 200); // Minutes }, 'update_callback' => null, // Read-only field 'schema' => [ 'type' => 'integer', 'description' => 'Estimated reading time in minutes', 'readonly' => true, ], ] );

Custom authentication

Implementing your own authentication method for REST API; hook into rest_authentication_errors or determine_current_user to verify custom tokens, API keys, or other credentials.

add_filter('rest_authentication_errors', function($result) { if (!empty($result)) return $result; // Already authenticated $api_key = $_SERVER['HTTP_X_API_KEY'] ?? null; if (!$api_key) return $result; // No key, continue to other methods $user_id = validate_api_key($api_key); // Your validation logic if (!$user_id) { return new WP_Error( 'invalid_api_key', 'Invalid API key provided', ['status' => 401] ); } wp_set_current_user($user_id); return true; });

Rate limiting

Restricting API request frequency to prevent abuse and ensure fair usage; WordPress doesn't include built-in rate limiting, requiring custom implementation using transients, object cache, or external services.

add_filter('rest_pre_dispatch', function($result, $server, $request) { $ip = $_SERVER['REMOTE_ADDR']; $key = 'rate_limit_' . md5($ip); $requests = get_transient($key) ?: 0; $limit = 100; // Requests per window $window = 60; // Seconds if ($requests >= $limit) { return new WP_Error( 'rate_limit_exceeded', 'Too many requests. Try again later.', ['status' => 429] ); } set_transient($key, $requests + 1, $window); return $result; }, 10, 3);

Caching responses

Storing REST API responses to reduce database queries and improve performance; implement via HTTP cache headers, transients, object cache, or reverse proxy (Varnish/CDN) depending on use case.

add_filter('rest_post_dispatch', function($response, $server, $request) { if ($request->get_method() !== 'GET') { return $response; // Only cache GET requests } // Browser caching $response->header('Cache-Control', 'public, max-age=300'); $response->header('ETag', md5(serialize($response->get_data()))); return $response; }, 10, 3); // Or using transients function get_items($request) { $cache_key = 'rest_books_' . md5(serialize($request->get_params())); $cached = get_transient($cache_key); if ($cached !== false) return $cached; $data = fetch_books(); // Expensive operation set_transient($cache_key, $data, HOUR_IN_SECONDS); return $data; }

Batch operations

Processing multiple API operations in a single request to reduce HTTP overhead; WordPress 5.6+ includes a built-in batch endpoint at /wp-json/batch/v1 that accepts multiple requests.

// WordPress 5.6+ built-in batch endpoint fetch('/wp-json/batch/v1', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce }, body: JSON.stringify({ requests: [ { path: '/wp/v2/posts', method: 'POST', body: { title: 'Post 1' } }, { path: '/wp/v2/posts', method: 'POST', body: { title: 'Post 2' } }, { path: '/wp/v2/posts/123', method: 'DELETE' } ] }) }); // Response contains results for each request // { responses: [{ body: {...}, status: 201 }, ...] }

Versioning strategies

Approaches for managing API changes without breaking existing clients; WordPress uses namespace versioning (wp/v2), and you should version your custom APIs from the start to allow backward-compatible evolution.

// Namespace versioning (recommended) register_rest_route('myplugin/v1', '/books', [...]); // Original register_rest_route('myplugin/v2', '/books', [...]); // Breaking changes // URL versioning /wp-json/myplugin/v1/books /wp-json/myplugin/v2/books // Header versioning (custom implementation) add_filter('rest_pre_dispatch', function($result, $server, $request) { $version = $request->get_header('X-API-Version') ?? 'v1'; // Route to appropriate handler based on version });
┌─────────────────────────────────────────────────────────────┐ │ Versioning Best Practices │ ├─────────────────────────────────────────────────────────────┤ │ • Start with v1 from day one │ │ • Increment major version for breaking changes │ │ • Support old versions for deprecation period │ │ • Document changes in changelog │ │ • Use semantic versioning (v1.0, v1.1, v2.0) │ └─────────────────────────────────────────────────────────────┘