Before you write a route
Start with the native REST API before adding another route. /wp/v2/posts already supports _fields filtering, _embed, context, per_page, ordering and a wide set of query arguments. For a lot of integrations, the honest answer is that you do not need a custom endpoint at all:
curl -i 'https://example.com/wp-json/wp/v2/posts?_fields=id,title,slug&per_page=10'That requests selected fields from the native posts collection without custom PHP. It returns a JSON array; title remains an object containing rendered, not a plain string.
A custom route becomes worthwhile when at least one of these is true:
- You need a different shape — a flat summary object, a computed field, or a joined resource that
wp/v2cannot express. - You need a different access rule — a route restricted to users with a particular capability, or a carefully selected public view of otherwise nonpublic data. Fewer fields alone are usually a reason to try
_fieldsfirst. - You need semantic stability — a path that means something to your integration (
/stock/levels) whose versioning and response contract you control. - You need to aggregate — combining several post types or a meta value into one payload.
If your need is "the same data, fewer fields", start with _fields. If your need is "a resource that does not exist yet", keep reading.
Put the code in a plugin, not in functions.php
The REST API is extended by adding code that runs during rest_api_init. That code has to live somewhere durable. A theme's functions.php can register a route, but the endpoint disappears if you switch away from that theme. A block theme can also have functions.php; changing theme technology is not itself the cause.
A small plugin is the right home. It is version-controlled, reviewable, survives theme changes, and can be disabled in one click when something goes wrong.
Create a folder, say rkx-tutorial-post-endpoint, with one file:
rkx-tutorial-post-endpoint/
└── rkx-tutorial-post-endpoint.phpAnd start the plugin file like this:
<?php
/**
* Plugin Name: RKX Tutorial — Public Post Endpoint
* Description: Tutorial endpoint for a public post summary.
* Version: 1.0.0
*/
defined( 'ABSPATH' ) || exit;Two details in that header are worth keeping in every plugin you write:
defined( 'ABSPATH' ) || exit;stops the file being executed directly over HTTP. Without it, a request towp-content/plugins/your-plugin/your-plugin.phpruns your file in a context where WordPress functions do not exist. It is a one-line guard that costs nothing.- A namespaced or prefixed function name. A distinctive prefix such as
rkx_tutorial_public_postreduces the risk of a collision. PHP namespaces are another option; WordPress does not automatically isolate plugin functions.
Why rest_api_init?
register_rest_route() must run after the REST API is initialised, but before routes are served. rest_api_init is that hook. It fires once per request that touches the REST API, and it is the correct place to register routes.
add_action( 'rest_api_init', 'rkx_tutorial_register_post_route' );Register routes on rest_api_init. Calling register_rest_route() before that hook triggers a WordPress developer notice; it does not reliably produce a missing-route response, so a 404 alone does not diagnose hook timing.
Register a versioned route
register_rest_route() takes a namespace, a route pattern and an array of endpoint arguments:
function rkx_tutorial_register_post_route() {
register_rest_route(
'rkx-tutorial/v1',
'/posts/(?P<id>\d+)',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'rkx_tutorial_public_post',
'permission_callback' => '__return_true',
'args' => array(
'id' => array(
'type' => 'integer',
'minimum' => 1,
'required' => true,
),
),
)
);
}Reading that piece by piece:
The namespace `rkx-tutorial/v1` has two halves. rkx-tutorial identifies your plugin. /v1 is your version. The version matters more than people expect: when your response shape changes, you can add rkx-tutorial/v2 and keep v1 running for clients that have not migrated. This is exactly how WordPress versions its own core routes (wp/v2), and it gives clients an explicit migration path.
Note that this is a tutorial namespace. If you later use a production plugin, use its own namespace instead.
The route pattern `/posts/(?P<id>\d+)` uses a named capture group. (?P<id>\d+) matches one or more digits and exposes them to your callback as $request['id']. The \d+ restriction does real work: a request for /posts/abc never reaches your callback at all, because the route does not match.
WP_REST_Server::READABLE equals the string 'GET'. WordPress falls back to a GET handler for HEAD requests when there is no explicit HEAD handler; using the constant does not add different method behavior.
'args' declares the parameter contract. type, minimum and required are not documentation — WordPress validates the incoming request against them and returns a 400 with a descriptive body before your callback runs.
'permission_callback' => '__return_true' means "this route is public". It is a deliberate choice, and it is only correct because the callback returns nothing sensitive. We will come back to this in the section on protecting non-public operations.
Validate the request and restrict what comes back
Validation and output selection are two different jobs, and it is worth keeping them separate in your head.
Validation answers "is this request well-formed?". The args block above handles the shape of the ID through WordPress's schema validation and sanitization. Accessing $request['id'] is parameter lookup, not an independent validation step. After validation, a cast is enough here:
$id = (int) $request['id'];Output selection answers "what am I allowed to return?". That is a policy decision about the data, and it belongs in the callback.
Here is the callback, which returns exactly two fields from posts that are already public:
function rkx_tutorial_public_post( WP_REST_Request $request ) {
$post = get_post( (int) $request['id'] );
if ( ! $post || 'post' !== $post->post_type ||
'publish' !== $post->post_status || '' !== $post->post_password ) {
return new WP_Error(
'rkx_tutorial_not_found',
'Post not found.',
array( 'status' => 404 )
);
}
return rest_ensure_response(
array(
'id' => (int) $post->ID,
'title' => wp_strip_all_tags( get_the_title( $post ) ),
)
);
}Four things in that callback matter more than the syntax:
One condition, one response. Drafts, posts of another post type, password-protected posts and non-existent IDs all produce the same 404. That is deliberate. If a private post returned 403 while a missing post returned 404, your endpoint would become an oracle: an attacker could enumerate IDs and discover which ones exist. A uniform error avoids disclosing existence through the status or error body; it does not eliminate every possible side channel.
wp_strip_all_tags() removes HTML tags from the filtered title. get_the_title() applies title filters, and their output may contain markup. Tag removal does not decode HTML entities: a title containing & can still contain that text. The example promises a tag-free string, not entity decoding or protection for every later HTML-rendering context.
`get_post()` rather than `get_posts()` or a query. For a single ID, get_post() is the appropriate object lookup and can use WordPress's object cache; you do not need to build a collection query.
rest_ensure_response() normalises whatever you return into a WP_REST_Response. Returning a bare array works, but rest_ensure_response() lets you set headers and status cleanly later without changing the shape of the code.
Why not just return the whole post?
Because get_post() gives you a WP_Post object with fields you did not think about: post_password, guid, post_modified, post_mime_type, comment_status, to_ping, pinged, filter and more. Some are harmless, some are sensitive, and the list changes between WordPress versions. Explicitly building the two-field array is what makes the contract stable and the disclosure intentional.
This is the core principle: an allowlist, not a denylist. You decide what leaves the site, rather than discovering what should have stayed.
Return data and meaningful errors
The callback above returns a WP_Error for the failure case. That is the native WordPress way to signal an error from a REST callback, and it matters that you use it correctly:
return new WP_Error(
'rkx_tutorial_not_found', // machine-readable code
'Post not found.', // human-readable message
array( 'status' => 404 ) // HTTP status
);The three parts each have a purpose. The code is what your client should branch on — it is stable, and you should not parse the message. The message is for humans. The status becomes the HTTP status code.
What not to do:
- No `echo`. Output before the response headers are sent produces malformed JSON that some HTTP clients will parse and others will not.
- No `die()` or `exit` in the route callback. They bypass normal REST response handling; the resulting body and status depend on output and headers already sent. The plugin's direct-access guard serves a different purpose.
- No `wp_send_json()` in a REST callback. It is a legacy AJAX helper. The REST API serialises your return value for you.
- No manual `json_encode()`. Let the REST API handle encoding. It supplies REST response encoding and headers. JSON encoding does not replace escaping when a client later inserts values into HTML.
A callback can also return JSON-serializable data such as an array; WordPress wraps it in a response. Use WP_REST_Response when you need explicit headers/status and WP_Error to report a failure.
Protect non-public operations
The route above is intentionally public and returns nothing sensitive. If your endpoint needs to write, or read private data, the permission_callback changes.
'permission_callback' => function ( WP_REST_Request $request ) {
return current_user_can( 'edit_post', (int) $request['id'] );
},This is an object-level check: it asks whether the current user may edit this specific post. A general edit_posts check is insufficient: for example, an author may edit their own posts but not another author's posts. Check the specific object and let WordPress map its capabilities.
Two distinctions worth being precise about:
Authentication identifies the caller. Authorization decides permission. Authentication answers "who is this?" — a user session, an Application Password, an API key. Authorization answers "may they do this?" That is permission_callback, plus whatever field-level validation the callback performs before writing.
A nonce is not a permission policy. The X-WP-Nonce header is a CSRF protection mechanism for cookie-authenticated same-origin requests. It helps WordPress validate the session/action token, but does not prove where the request originated and does not prevent replay during its validity window. It does not express what the user is allowed to do, and it is not a replacement for a capability check. A route that is __return_true on the basis of "the client sends a nonce" is a route with no authorization at all.
The other half of the rule: the callback must still validate what it writes. A capability check proves the caller may edit the post. It does not prove the submitted payload is safe. If your route accepts a title, sanitise it before storing it. The permission callback and the write are two separate responsibilities.
Test the endpoint
Test both successful requests and failure paths before relying on the endpoint.
Start the server, then:
# Valid, published post
curl -i 'https://example.com/wp-json/rkx-tutorial/v1/posts/42'The illustrative response:
{"id":42,"title":"Example post"}The actual ID and title depend on your fixture. Replace `42` with a real post ID from your own site — it is a placeholder throughout this section.
Now the cases that matter:
# Non-existent ID -> 404 with the same body as a private post
curl -i 'https://example.com/wp-json/rkx-tutorial/v1/posts/999999'
# Draft post -> 404, not 403
curl -i 'https://example.com/wp-json/rkx-tutorial/v1/posts/43'
# Password-protected post -> 404
curl -i 'https://example.com/wp-json/rkx-tutorial/v1/posts/44'
# Non-numeric ID -> the route does not match; 404 from the router
curl -i 'https://example.com/wp-json/rkx-tutorial/v1/posts/abc'
# Wrong method -> 404 (no POST handler is registered)
curl -i -X POST 'https://example.com/wp-json/rkx-tutorial/v1/posts/42'Use -i so you see the status line and headers. The distinction between 404 from your callback and 404 from the router is visible in the response body, and it tells you whether your route matched at all.
If the route 404s entirely
Check these possibilities:
- The plugin is not active. Check the Plugins screen.
- Registration code did not run. Confirm the plugin loads and attaches its route registration to
rest_api_init. - REST URL and rewrite rules. With plain permalinks, try
https://example.com/?rest_route=/rkx-tutorial/v1/posts/42. For pretty REST URLs, check the host's rewrite configuration. Registering a REST route does not normally require saving permalinks; flush rules only when diagnosing a rewrite problem. - Namespace or route typo. A mismatch between
rkx-tutorial/v1in the registration and the URL you request produces a clean 404 with no hint. - A proxy in front of WordPress. If you are testing a site behind a reverse proxy, the proxy may not forward
/wp-json/. Test directly against WordPress first, then test through the proxy.
You can confirm the route is registered at all by opening the REST index in a browser:
https://example.com/wp-json/rkx-tutorial/v1If your namespace appears there, registration is working and the problem is in the request.
Build the same kind of endpoint visually
Everything above was custom PHP. For a read-only GET endpoint returning published, unpassworded content, that work is avoidable.
RKX API Studio builds REST GET endpoints from a WordPress admin screen instead of a code file:
- Choose a content source — posts, pages, a custom post type, or a taxonomy.
- Name the route and pick the output fields.
- Preview the JSON.
- Save, then test the actual response URL.
Free 1.0.0 covers this workflow: the endpoint builder, public REST-visible content sources, field selection, supported filtering, sorting and pagination, text transforms, caching, JSON import/export and one shared API key. No RKXBeta account or licence is required for Free.
Two things to be precise about. Free endpoints live under the rkx-api-studio/v1 namespace, and the plugin returns an array of selected fields — not the same envelope as a native wp/v2 post response. Custom namespaces and developer write hooks are licensed Pro features; Pro is a separate add-on that requires active Free Core. Write hooks require developer-supplied validation and operations; they are not automatic visual CRUD. This tutorial does not need either.
For the full walkthrough, see Creating Endpoints. To see a working response, follow Your First API Request.
If you do need a protected endpoint, API Keys & Security covers the key model — and the difference between an endpoint key and a WordPress user session, which is a distinction worth getting right before you ship.
Common failures
| Symptom | Likely cause |
|---|---|
| REST base returns an HTML 404 | Check the REST URL, permalink mode and server rewrites |
| Namespace missing from a working REST index | Plugin inactive or registration code did not run |
| `404` on your route, index shows the namespace | Wrong namespace or route string in the URL |
| Route not matching at all | Regex in the route pattern, e.g. `\d+` vs `.*` |
| `rest_no_route` in the body | No matching path/method combination; check both |
| Works locally, fails behind a proxy | Proxy not forwarding `/wp-json/` |
| Response is HTML, not JSON | `echo` before returning; a PHP notice leaking into the body |
| `rest_invalid_param` | Your `args` validation rejected the request; read the message |
Where to go next
- WordPress: Adding custom endpoints — the official guide to the registration contract.
- registerrestroute() — the full argument reference, including
argsandpermission_callback. - WPRESTServer — method handling and permission dispatch.
- WordPress nonces — CSRF protection and authorization limits.
- RKX API Studio — visual GET endpoint builder.
- API Keys & Security — endpoint keys, and what they are not.
Prefer to configure a GET endpoint visually? Explore RKX API Studio Free and follow the creating-endpoints guide.
