WordPress register_rest_route Example: Build a Custom REST API Endpoint as a Plugin
A practical WordPress register_rest_route example with full plugin code, route arguments, permission_callback, and when the endpoint belongs in a plugin instead of functions.php.
Published
April 15, 2026
Reading Time
6 min read
Updated
April 15, 2026

Implementation Notes
Extension points, code paths, and implementation choices that should survive contact with production.
Best For
WordPress developers, agencies, and technical teams building custom plugin or theme functionality with cleaner operational defaults.
Primary Topics
Editorial Focus
Build Pattern: Extension points, code paths, and implementation choices that should survive contact with production. Updated on April 15, 2026.
Full Report
Last reviewed: April 15, 2026
One of the most common WordPress development questions is still some variation of: “Do I put this in functions.php, or should it be a plugin?” That question shows up constantly around custom REST API routes because developers want a working register_rest_route example they can paste, test quickly, and keep alive in production.
The problem is that many examples stop too early. They show the route registration, but skip the part that matters operationally: namespacing, permission_callback, request arguments, validation, sanitization, and the decision about whether the code belongs to the active theme or should survive a theme switch. If the goal is to add real functionality instead of one more temporary snippet, those details are the difference between a tutorial and something you can safely keep running.
Why this topic keeps getting searched
There are three recurring intents behind most searches in this area:
- Developers need a working
register_rest_routeexample, not only a reference signature. - Teams want to know whether custom endpoint code belongs in a plugin or in
functions.php. - Operators want a route that works in production without creating a public write surface or a maintenance trap.
That makes this a good topic for a practical article instead of another abstract overview. The official WordPress docs are clear on the key rules, but people still need a complete, realistic example they can adapt.
Plugin or functions.php?
WordPress theme documentation says the theme functions.php file behaves like a plugin, but it also makes an important distinction: if you are creating new features that should still exist no matter what the site looks like, best practice is to put them in a plugin. That is the right default for custom REST API routes too.
A route should usually live in a plugin if:
- another system depends on it;
- the route is part of business logic, integrations, dashboards, or automation;
- the feature should keep working even if the theme changes; or
- multiple themes may eventually use the same functionality.
A route can live in functions.php if it is tightly coupled to a single theme and should disappear with that theme. That is the exception, not the default.
A complete plugin example: public route for recent posts
The example below creates a tiny plugin that registers a read-only custom endpoint. The endpoint returns a small, structured list of recent published posts and accepts a validated limit parameter. It is intentionally simple, but it contains the pieces that many code snippets omit.
<?php
/**
* Plugin Name: VulnWP Recent Posts API
* Description: Example custom REST API route for returning recent published posts.
* Version: 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
add_action( 'rest_api_init', 'vulnwp_register_recent_posts_route' );
function vulnwp_register_recent_posts_route() {
register_rest_route(
'vulnwp/v1',
'/recent-posts',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'vulnwp_get_recent_posts',
'permission_callback' => '__return_true',
'args' => array(
'limit' => array(
'description' => 'How many posts to return.',
'type' => 'integer',
'default' => 5,
'sanitize_callback' => 'absint',
'validate_callback' => function( $value ) {
$value = absint( $value );
return $value >= 1 && $value <= 10;
},
),
),
)
);
}
function vulnwp_get_recent_posts( WP_REST_Request $request ) {
$limit = absint( $request->get_param( 'limit' ) );
$query = new WP_Query(
array(
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => $limit ?: 5,
'ignore_sticky_posts' => true,
'no_found_rows' => true,
)
);
$items = array();
foreach ( $query->posts as $post ) {
$items[] = array(
'id' => (int) $post->ID,
'title' => get_the_title( $post ),
'slug' => $post->post_name,
'url' => get_permalink( $post ),
'date' => get_post_time( DATE_ATOM, true, $post ),
);
}
return rest_ensure_response(
array(
'count' => count( $items ),
'items' => $items,
)
);
}
What this plugin gets right
It registers the route on rest_api_init. The WordPress code reference for register_rest_route() explicitly says not to use it before that hook. This matters because route registration is part of the REST bootstrap, not general application boot code.
It uses a real namespace. WordPress requires routes to be namespaced with the plugin or theme name and version. That prevents collisions and makes it obvious which component owns the route. Namespaces like vulnwp/v1 are much safer than loose or generic prefixes.
It includes permission_callback. This is not optional good behavior anymore. WordPress documents it as a required argument for route definitions, and for truly public endpoints the docs explicitly recommend __return_true. If the route is not supposed to be public, then this callback is where access control belongs.
It validates and sanitizes arguments. The REST API handbook’s route examples show argument schemas for a reason. Production routes should not simply trust query parameters because “it is only an internal tool.” If the input exists, it should be typed, sanitized, and validated.
It returns a predictable response shape. A route is an application contract. Returning a stable object with a count and an items array is easier for frontends, automations, and tests than dumping arbitrary post objects.
How to install the example as a plugin
- Create a directory under
wp-content/plugins, for examplevulnwp-recent-posts-api. - Create a PHP file inside it, such as
vulnwp-recent-posts-api.php. - Paste the example code into that file.
- Activate the plugin from the WordPress admin Plugins screen.
- Request
/wp-json/vulnwp/v1/recent-postsin the browser or withcurl.
The plugin handbook notes that even a simple plugin starts as a PHP file with a valid plugin header comment. That header is what makes WordPress detect and load it as an installed plugin.
Testing the route
# Default response
curl -s https://example.com/wp-json/vulnwp/v1/recent-posts
# Ask for 3 posts
curl -s 'https://example.com/wp-json/vulnwp/v1/recent-posts?limit=3'
# Pretty-print the JSON with jq if available
curl -s 'https://example.com/wp-json/vulnwp/v1/recent-posts?limit=3' | jq
If the route is active, you should get a JSON payload with a small list of published posts. If you pass an invalid limit, WordPress will reject it through the route’s argument validation instead of silently accepting bad input.
How to make the route private
The example above is intentionally public because it only returns already-public post data. If your route returns non-public information or performs write actions, the access model must change.
For an admin-only read route, the first change is the permission callback:
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
For external systems, do not confuse “private route” with “hidden route.” A private endpoint still needs a real authentication model. If an external service needs access, the safer production pattern is usually HTTPS plus an Application Password or another supported WordPress auth flow, not a custom shortcut.
If you truly need the route in functions.php
Sometimes the route is genuinely theme-owned. For example, a theme may expose a tiny read-only endpoint that only exists to support a theme-specific UI or rendering choice. In that case, the route can live in the active theme’s functions.php.
add_action( 'rest_api_init', 'mytheme_register_route' );
function mytheme_register_route() {
register_rest_route(
'mytheme/v1',
'/banner-message',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'mytheme_get_banner_message',
'permission_callback' => '__return_true',
)
);
}
function mytheme_get_banner_message() {
return rest_ensure_response(
array(
'message' => get_theme_mod( 'banner_message', '' ),
)
);
}
That is acceptable only if you are comfortable with the route disappearing when the theme changes. If losing the theme should not break the route, it belongs in a plugin.
Mistakes that keep showing up in production
- Registering the route outside
rest_api_init. WordPress will flag this as incorrect usage. - Skipping
permission_callback. This is one of the most common signs of copied code that was never finished. - Dropping business functionality into
functions.phpbecause it is faster. That often turns into a migration problem later. - Returning too much data. Small, predictable response objects are easier to secure and maintain.
- Not validating request arguments. If the route accepts input, define the argument schema up front.
- Using a public callback for a route that should require auth. Public routes should be public by deliberate design, not by tutorial accident.
When this tutorial approach is a better choice than a generic snippet
If your team is building headless features, internal tools, plugin integrations, or frontend widgets that rely on custom WordPress routes, a complete plugin example is a better starting point than a raw one-line code snippet. It creates a place where the route can be versioned, tested, reviewed, and removed cleanly later.
That is also why plugin-first examples tend to age better in production than “just paste this in functions.php” answers. They are easier to own.
Related reading
If you need to audit which routes are already public or plugin-added on your site, read WordPress REST API Exposure Checklist: Audit Public Routes and Authentication. If the route will be consumed by an external integration, pair it with WordPress Application Password Security Checklist: Create, Scope, Rotate.


