The WordPress REST API, introduced in WordPress 4.7, exposes your site’s data as JSON over standard HTTP endpoints. While the built-in endpoints cover posts, pages, users, and taxonomies, almost every project eventually needs a custom endpoint — to return aggregated data, power a mobile app, integrate a third-party service, or feed a decoupled React/Vue front-end. Registering a custom REST route is straightforward: you call register_rest_route() inside the rest_api_init action and provide a namespace, route pattern, HTTP method, callback function, and optionally an argument schema and permission callback. The permission callback is critical for security — it must return true (or check a capability) to allow the request; returning false sends a 403 response. Route parameters like (?P<id>\d+) capture named values from the URL that are passed to your callback in the WP_REST_Request object. Always return a WP_REST_Response or WP_Error from your callback — never echo raw output. For secure data retrieval in callbacks, use $wpdb->prepare() for any database queries, and apply the same sanitisation rules covered in the input sanitisation guide. Custom endpoints can also be authenticated with Application Passwords (WordPress 5.6+) for machine-to-machine use. The example below registers a public endpoint that returns post statistics and a protected endpoint that requires editor access.
Problem: You need a custom JSON endpoint in WordPress to return data to a JavaScript front-end or a third-party service, and the built-in REST routes do not expose the exact data you need.
Solution: Add the following code to your functions.php file:
add_action( 'rest_api_init', 'helloadmin_register_rest_routes' );
function helloadmin_register_rest_routes() {
// Public endpoint: GET /wp-json/helloadmin/v1/stats
register_rest_route( 'helloadmin/v1', '/stats', [
'methods' => WP_REST_Server::READABLE, // GET
'callback' => 'helloadmin_rest_stats',
'permission_callback' => '__return_true', // public
] );
// Protected endpoint: GET /wp-json/helloadmin/v1/posts/{id}
register_rest_route( 'helloadmin/v1', '/posts/(?P<id>\d+)', [
'methods' => WP_REST_Server::READABLE,
'callback' => 'helloadmin_rest_get_post',
'permission_callback' => function() {
return current_user_can( 'edit_posts' );
},
'args' => [
'id' => [
'required' => true,
'sanitize_callback' => 'absint',
'validate_callback' => function( $val ) {
return $val > 0;
},
],
],
] );
}
function helloadmin_rest_stats( WP_REST_Request $request ) {
$data = get_transient( 'helloadmin_rest_stats' );
if ( false === $data ) {
global $wpdb;
$data = [
'published_posts' => (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status='publish' AND post_type='post'"
),
'registered_users' => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->users}" ),
];
set_transient( 'helloadmin_rest_stats', $data, HOUR_IN_SECONDS );
}
return rest_ensure_response( $data );
}
function helloadmin_rest_get_post( WP_REST_Request $request ) {
$post = get_post( $request['id'] );
if ( ! $post || $post->post_status !== 'publish' ) {
return new WP_Error( 'not_found', 'Post not found', [ 'status' => 404 ] );
}
return rest_ensure_response( [
'id' => $post->ID,
'title' => $post->post_title,
'content' => apply_filters( 'the_content', $post->post_content ),
'date' => $post->post_date,
] );
}
NOTE: Never set permission_callback to __return_true for an endpoint that modifies data or returns private information. All custom REST routes are publicly discoverable via the /wp-json root response, so even “unlisted” routes must have proper permission callbacks. For endpoints that accept POST / PUT requests from browser JavaScript, verify the X-WP-Nonce header using check_ajax_referer() or wp_verify_nonce() inside the permission callback to prevent CSRF attacks.