The WordPress REST API is one of the most underused features in the ecosystem. Most developers know it exists, a smaller number have actually consumed endpoints from JavaScript or a headless front-end, and fewer still have sat down and built a custom endpoint from scratch for production use. This post covers the last group — the stuff that matters when you’re shipping a real plugin and need a reliable, secure API surface behind your settings panel or SPA.
I’ve been working on SeedProd for a while now, and a big part of that work involves connecting the Vue.js page builder front-end to data in WordPress. Almost everything goes through custom REST endpoints. These are the patterns I rely on, the mistakes I’ve made, and the lessons I’d pass on to any developer who’s about to register their first route.
Why Custom Endpoints at All?
The built-in WordPress REST API is great for standard CRUD on posts, pages, users, and taxonomies. But the moment you step outside that territory — say, you need to save plugin settings, trigger a background job, return aggregated data from a custom table, or expose computed values that don’t map to a post type — you’re better off registering your own namespace and routes.
The alternative people reach for is admin-ajax.php — and it shows. It has no schema, no built-in authentication, no consistent response structure, and it runs every request through wp_ajax_{action} hooks with no versioning story. Custom REST endpoints give you all of that in exchange for maybe 30 extra lines of code.
Three reasons I use custom endpoints:
- Versioning. You can bump from
/v1/to/v2/without breaking existing integrations. - Schema-driven validation. WordPress will reject requests that don’t match your declared parameter types before your callback even runs.
- Authentication is free. Cookie auth with a nonce, application passwords, OAuth — it all works because the REST API handles it at the infrastructure layer.
Registering a Route with register_rest_route()
Everything starts with a call to register_rest_route() inside the rest_api_init action. The function signature looks like this:
register_rest_route( string $namespace, string $route, array $args, bool $override = false );
The $namespace should include your plugin slug and a version number. The $route is the path relative to /wp-json/{namespace}/. Here’s a minimal example:
add_action( 'rest_api_init', 'myplugin_register_routes' );
function myplugin_register_routes() {
register_rest_route(
'myplugin/v1',
'/settings',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'myplugin_get_settings',
'permission_callback' => 'myplugin_settings_permissions',
),
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => 'myplugin_save_settings',
'permission_callback' => 'myplugin_settings_permissions',
'args' => myplugin_get_settings_schema(),
),
)
);
}
Notice that I’m passing an array of arrays. That’s how you register multiple HTTP methods on the same route in one call — GET and POST handled at the same path but with different callbacks and argument schemas. WP_REST_Server::READABLE maps to GET, WP_REST_Server::CREATABLE maps to POST. There’s also EDITABLE for PUT/PATCH and DELETABLE for DELETE.
The Permission Callback: Never, Ever Skip This
This is the single most common mistake I see in plugin code reviewed by others, and I’ve been guilty of it myself early on. If you omit permission_callback, WordPress 5.5+ will emit a _doing_it_wrong() notice and effectively open your endpoint to the world. Before 5.5, it was silently unauthenticated.
For a settings endpoint that only admins should touch:
function myplugin_settings_permissions( WP_REST_Request $request ) {
return current_user_can( 'manage_options' );
}
That’s it. Two lines. When this returns false, WordPress automatically sends back a 401 or 403 with a proper JSON error body — you don’t have to handle that yourself.
If you genuinely need a public endpoint, be explicit about it:
'permission_callback' => '__return_true',
Using __return_true makes the intent obvious when someone reads the code six months later. Using function() { return true; } works too, but a named function tells a clearer story in a code review.
Schema Validation and Sanitization
The args array is where you declare your parameter schema, and it’s one of the best things about the REST API. WordPress runs validation and sanitization automatically before your callback fires, which means your callback receives clean data or the request is rejected with a descriptive error.
Here’s what a settings schema might look like for a plugin with a handful of options:
function myplugin_get_settings_schema() {
return array(
'site_title' => array(
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => function( $value ) {
return ! empty( $value ) && strlen( $value ) array(
'type' => 'boolean',
'default' => false,
),
'redirect_url' => array(
'type' => 'string',
'format' => 'uri',
'sanitize_callback' => 'esc_url_raw',
),
'max_items' => array(
'type' => 'integer',
'minimum' => 1,
'maximum' => 100,
'default' => 10,
),
);
}
A few things here. First, sanitize_callback and validate_callback are separate concerns. Validation is a true/false check — does this value meet your rules? Sanitization transforms the value into something safe to store. The REST API runs validation first, then sanitization. If validation fails, the sanitize callback never runs.
Second, for URLs, use esc_url_raw() as the sanitize callback, not esc_url(). The esc_url() function is for output in HTML attributes — it encodes things like ampersands for HTML context. esc_url_raw() is what you want when storing to the database.
Third, notice I’m using 'format' => 'uri' on the URL field. WordPress has built-in format validators for uri, email, date-time, and a few others. These run as part of the type validation step and give you a first pass for free.
Building GET and POST Callbacks
Your callback receives a WP_REST_Request object and should return either a WP_REST_Response, a WP_Error, or a raw scalar/array (WordPress will wrap the latter). I always return a WP_REST_Response explicitly — it lets me set the status code and headers cleanly.
Here’s the GET callback for our settings endpoint:
function myplugin_get_settings( WP_REST_Request $request ) {
$settings = array(
'site_title' => get_option( 'myplugin_site_title', '' ),
'enable_feature' => (bool) get_option( 'myplugin_enable_feature', false ),
'redirect_url' => get_option( 'myplugin_redirect_url', '' ),
'max_items' => (int) get_option( 'myplugin_max_items', 10 ),
);
return new WP_REST_Response( $settings, 200 );
}
And the POST callback:
function myplugin_save_settings( WP_REST_Request $request ) {
$params = $request->get_params();
update_option( 'myplugin_site_title', $params['site_title'] );
update_option( 'myplugin_enable_feature', $params['enable_feature'] );
update_option( 'myplugin_max_items', $params['max_items'] );
if ( ! empty( $params['redirect_url'] ) ) {
update_option( 'myplugin_redirect_url', $params['redirect_url'] );
}
return new WP_REST_Response(
array(
'success' => true,
'message' => __( 'Settings saved.', 'myplugin' ),
),
200
);
}
By the time this callback runs, the data in $request->get_params() has already been validated and sanitized according to your schema. You do not need to call sanitize_text_field() again inside the callback — that’s double-sanitization and it can corrupt data if someone legitimately stores characters like & or <.
When something goes wrong, return a WP_Error:
return new WP_Error(
'myplugin_save_failed',
__( 'Could not save settings.', 'myplugin' ),
array( 'status' => 500 )
);
The status key in the third argument is what tells the REST API infrastructure which HTTP status code to send. Without it you’ll get a 200 even for errors, which is confusing for API consumers.
Using prepare_item_for_response()
For simple settings endpoints, returning a flat array works fine. But if you’re building endpoints that return post-like data — custom post types, custom tables, complex objects — you should implement a controller class that extends WP_REST_Controller and override prepare_item_for_response().
This is the method that transforms your raw data into the shape you want to expose in the API. It’s where you strip out fields the requesting user doesn’t have permission to see, cast types consistently, and add hypermedia links if needed. Skipping it is fine for internal plugin settings, but if your endpoint is part of a public-facing API, not having it means your response structure becomes a mess of whatever happened to be in the database.
class MyPlugin_REST_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,
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
),
)
);
}
public function get_items_permissions_check( $request ) {
return current_user_can( 'read' );
}
public function get_items( $request ) {
global $wpdb;
$rows = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}myplugin_items LIMIT 50" );
$data = array();
foreach ( $rows as $row ) {
$data[] = $this->prepare_item_for_response( $row, $request );
}
return new WP_REST_Response( $data, 200 );
}
public function prepare_item_for_response( $item, $request ) {
return array(
'id' => (int) $item->id,
'title' => esc_html( $item->title ),
'created_at' => mysql2date( 'c', $item->created_at ),
);
}
}
Then register the controller:
add_action( 'rest_api_init', function() {
$controller = new MyPlugin_REST_Controller();
$controller->register_routes();
} );
Versioning Your API
The namespace is your versioning story. When I registered myplugin/v1 above, I was making an implicit contract: any client that hits /wp-json/myplugin/v1/settings can expect that response shape to stay stable. When I need to change the shape in a breaking way, I register myplugin/v2 alongside it and migrate clients one at a time.
In practice for a plugin like SeedProd where the front-end and the back-end ship together, you can move fast and bump versions alongside major releases. But if your endpoints are consumed by third-party developers or external services, treat versioning like a public API and follow semver principles: don’t remove or rename response fields in a minor version, don’t change field types, add fields rather than replacing them.
One practical thing I do: keep the version in a constant so it’s easy to grep for and change in one place:
define( 'MYPLUGIN_REST_VERSION', 'v1' );
register_rest_route( 'myplugin/' . MYPLUGIN_REST_VERSION, '/settings', $args );
A Real-World Example: Plugin Settings Panel Endpoint
Let me put this together into something you could actually drop into a plugin. The scenario: a settings panel built in Vue.js that needs to read and write a handful of plugin options. The front-end makes a GET on load and a POST on save.
<?php
/**
* REST API endpoints for MyPlugin settings panel.
*/
defined( 'ABSPATH' ) || exit;
add_action( 'rest_api_init', 'myplugin_register_settings_routes' );
function myplugin_register_settings_routes() {
$namespace = 'myplugin/' . MYPLUGIN_REST_VERSION;
register_rest_route(
$namespace,
'/settings',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'myplugin_api_get_settings',
'permission_callback' => 'myplugin_api_can_manage',
),
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => 'myplugin_api_save_settings',
'permission_callback' => 'myplugin_api_can_manage',
'args' => array(
'license_key' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
'global_template' => array(
'type' => 'integer',
'minimum' => 0,
),
'coming_soon_enabled' => array(
'type' => 'boolean',
'default' => false,
),
'excluded_urls' => array(
'type' => 'array',
'items' => array(
'type' => 'string',
'format' => 'uri',
),
'default' => array(),
),
),
),
)
);
}
function myplugin_api_can_manage( WP_REST_Request $request ) {
return current_user_can( 'manage_options' );
}
function myplugin_api_get_settings( WP_REST_Request $request ) {
$options = get_option( 'myplugin_settings', array() );
$defaults = array(
'license_key' => '',
'global_template' => 0,
'coming_soon_enabled' => false,
'excluded_urls' => array(),
);
$data = wp_parse_args( $options, $defaults );
// Cast types explicitly — don't trust what's in the DB.
$data['global_template'] = (int) $data['global_template'];
$data['coming_soon_enabled'] = (bool) $data['coming_soon_enabled'];
$data['excluded_urls'] = (array) $data['excluded_urls'];
return new WP_REST_Response( $data, 200 );
}
function myplugin_api_save_settings( WP_REST_Request $request ) {
$existing = get_option( 'myplugin_settings', array() );
$incoming = array(
'license_key' => $request->get_param( 'license_key' ),
'global_template' => $request->get_param( 'global_template' ),
'coming_soon_enabled' => $request->get_param( 'coming_soon_enabled' ),
'excluded_urls' => $request->get_param( 'excluded_urls' ),
);
// Merge, don't replace — so future fields survive old clients.
$updated = array_merge( $existing, array_filter( $incoming, function( $v ) {
return ! is_null( $v );
} ) );
$saved = update_option( 'myplugin_settings', $updated );
if ( false === $saved ) {
// update_option returns false both on DB error AND if value didn't change.
// Check if the values actually match before treating it as an error.
if ( get_option( 'myplugin_settings' ) !== $updated ) {
return new WP_Error(
'myplugin_save_error',
__( 'Failed to save settings.', 'myplugin' ),
array( 'status' => 500 )
);
}
}
return new WP_REST_Response(
array( 'success' => true ),
200
);
}
The array_merge + null-filter pattern on save is something I found useful in practice. When a new settings field is added in a plugin update, old clients that don’t know about it will still send requests — you don’t want those old requests to silently wipe the new field because it wasn’t included in the payload.
Common Mistakes to Watch Out For
Beyond the missing permission_callback issue already covered:
- Using
wp_send_json()inside a REST callback. This bypasses the REST API’s response layer entirely — no proper content-type header, no filter hooks, and it callsdie()which breaks things downstream. Always return aWP_REST_ResponseorWP_Error. - Registering routes outside of
rest_api_init. If you callregister_rest_route()oninitorplugins_loaded, the routes won’t be picked up by the REST server. It has to berest_api_init. - Not handling the
update_option()false return correctly. WordPress returnsfalsefromupdate_option()when the new value is identical to the existing value, not just on failure. If you return a 500 error in that case, your save endpoint will appear to fail on no-op saves, which is very confusing for users. - Forgetting nonce verification on the front-end. The REST API uses cookie authentication in browser contexts, and that requires the
X-WP-Nonceheader. WordPress generates this nonce viawp_create_nonce( 'wp_rest' )and it’s typically passed to your JS viawp_localize_script(). Without it, logged-in users will get 403 errors. - Escaping on output in the REST context. A common confusion: you don’t need to call
esc_html()oresc_attr()in a REST callback. Those functions are for HTML output contexts. REST responses are JSON — the JSON encoder handles character escaping. Runningesc_html()on a value before putting it in a REST response will double-encode things and corrupt data on the client side.
Testing with Postman and curl
I test endpoints two ways depending on what I’m checking. For quick smoke tests during development, curl is faster:
# GET settings (using application password for auth)
curl -s
-H "Authorization: Basic $(echo -n 'admin:xxxx xxxx xxxx xxxx xxxx xxxx' | base64)"
https://example.local/wp-json/myplugin/v1/settings | jq .
# POST settings
curl -s -X POST
-H "Authorization: Basic $(echo -n 'admin:xxxx xxxx xxxx xxxx xxxx xxxx' | base64)"
-H "Content-Type: application/json"
-d '{"coming_soon_enabled": true, "max_items": 25}'
https://example.local/wp-json/myplugin/v1/settings | jq .
For anything more involved — testing different auth scenarios, checking error responses on bad input, documenting the API for team members — I use Postman. I keep a collection per plugin with environment variables for the base URL, credentials, and any tokens. This has saved me more than once when I had to hand off a plugin to another developer; they could run the whole collection and see exactly what the API does.
One thing that trips people up: application passwords (introduced in WordPress 5.6) require HTTPS in production, but they’ll work over HTTP on a local dev environment when WP_ENVIRONMENT_TYPE is set to local. If your requests are coming back as 401 and you’re on http:// locally, check that constant in wp-config.php.
Wrapping Up
The pattern isn’t complicated: register on rest_api_init, declare a permission_callback, lean on schema validation, return WP_REST_Response objects, and version your namespace.
The parts that bite people are almost always the subtle ones: update_option() returning false on no-op saves, double-sanitizing data that’s already been through the schema pipeline, or forgetting the nonce header on the JavaScript side. Once you’ve hit those gotchas once you don’t forget them.
Skip admin-ajax.php for new work. A REST endpoint takes maybe an hour to set up and gives you auth, schema validation, and versioning for free. The first time you debug a production issue by replaying a curl command instead of clicking through wp-admin, you’ll understand why that hour was worth it.