| name | add-rest-endpoint |
| description | Add a REST API endpoint to an existing plugin following Dekode conventions. |
Skill: Add REST Endpoint
Create a properly structured, secure REST API endpoint in an existing plugin using WP_REST_Controller.
Inputs
The following inputs are required. If any are missing from the request, ask for them before writing any code.
| Input | Required | Notes |
|---|
| Plugin | Yes | Which plugin in packages/plugins/, e.g. packages/plugins/acme-blocks/ |
| Resource name | Yes | What entity does this endpoint expose? e.g. products, reviews |
| HTTP methods | Yes | GET / POST / PUT/PATCH / DELETE |
| Is public? | Yes | Does this require authentication, or is it open to all? |
| Arguments | Yes | What parameters does the endpoint accept, and are they required or optional? |
| Route namespace | No | Defaults to {plugin-slug}/v1 (kebab-case plugin slug + /v1) |
Step 1 — Create the controller
Create src/Api/<ResourceName>Controller.php in the target plugin.
<?php
declare( strict_types=1 );
namespace Dekodeinteraktiv\Plugin\<PascalSlug>\Api;
use WP_REST_Controller;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
class <ResourceName>Controller extends WP_REST_Controller {
public function __construct() {
$this->namespace = '<plugin-slug>/v1';
$this->rest_base = '<resource-name>';
}
public function register_routes(): void {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_items' ],
'permission_callback' => [ $this, 'get_items_permissions_check' ],
'args' => $this->get_collection_params(),
],
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'create_item' ],
'permission_callback' => [ $this, 'create_item_permissions_check' ],
'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
],
'schema' => [ $this, 'get_public_item_schema' ],
]
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)',
[
'args' => [
'id' => [
'description' => __( 'Unique identifier for the item.', '<plugin-slug>' ),
'type' => 'integer',
'minimum' => 1,
],
],
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_item' ],
'permission_callback' => [ $this, 'get_item_permissions_check' ],
],
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ $this, 'update_item' ],
'permission_callback' => [ $this, 'update_item_permissions_check' ],
'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
],
[
'methods' => WP_REST_Server::DELETABLE,
'callback' => [ $this, 'delete_item' ],
'permission_callback' => [ $this, 'delete_item_permissions_check' ],
],
'schema' => [ $this, 'get_public_item_schema' ],
]
);
}
public function get_items_permissions_check( WP_REST_Request $request ): bool|WP_Error {
return true;
}
public function create_item_permissions_check( WP_REST_Request $request ): bool|WP_Error {
if ( ! current_user_can( 'edit_posts' ) ) {
return new WP_Error(
'rest_forbidden',
__( 'You do not have permission to create items.', '<plugin-slug>' ),
[ 'status' => rest_authorization_required_code() ]
);
}
return true;
}
public function get_item_permissions_check( WP_REST_Request $request ): bool|WP_Error {
return $this->get_items_permissions_check( $request );
}
public function update_item_permissions_check( WP_REST_Request $request ): bool|WP_Error {
return $this->create_item_permissions_check( $request );
}
public function delete_item_permissions_check( WP_REST_Request $request ): bool|WP_Error {
return $this->create_item_permissions_check( $request );
}
public function get_items( WP_REST_Request $request ): WP_REST_Response|WP_Error {
return rest_ensure_response( [] );
}
public function get_item( WP_REST_Request $request ): WP_REST_Response|WP_Error {
$id = (int) $request['id'];
return rest_ensure_response( [] );
}
public function create_item( WP_REST_Request $request ): WP_REST_Response|WP_Error {
$item = $this->prepare_item_for_database( $request );
$response = $this->prepare_item_for_response( $item, $request );
return rest_ensure_response( $response );
}
public function update_item( WP_REST_Request $request ): WP_REST_Response|WP_Error {
return rest_ensure_response( [] );
}
public function delete_item( WP_REST_Request $request ): WP_REST_Response|WP_Error {
return rest_ensure_response( [ 'deleted' => true ] );
}
public function get_item_schema(): array {
if ( $this->schema ) {
return $this->add_additional_fields_schema( $this->schema );
}
$this->schema = [
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => '<resource-name>',
'type' => 'object',
'properties' => [
'id' => [
'description' => __( 'Unique identifier.', '<plugin-slug>' ),
'type' => 'integer',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
],
];
return $this->add_additional_fields_schema( $this->schema );
}
}
Step 2 — Register routes via hooks()
In the plugin's main class, register the controller inside the hooks() method — never in a constructor or anonymous closure:
public function hooks(): void {
add_action( 'rest_api_init', [ $this, 'register_rest_routes' ] );
}
public function register_rest_routes(): void {
( new \Dekodeinteraktiv\Plugin\<PascalSlug>\Api\<ResourceName>Controller() )->register_routes();
}
Step 3 — External HTTP (if the endpoint calls an upstream API)
Always use wp_remote_*() — never curl, file_get_contents(), or Guzzle:
$response = wp_remote_post( 'https://api.example.com/endpoint', [
'headers' => [ 'Content-Type' => 'application/json' ],
'body' => wp_json_encode( [ 'param' => $param ] ),
] );
if ( is_wp_error( $response ) ) {
return new WP_REST_Response(
[ 'success' => false, 'error' => $response->get_error_message() ],
500
);
}
$code = wp_remote_retrieve_response_code( $response );
if ( ! in_array( $code, [ 200, 201 ], true ) ) {
return new WP_REST_Response(
[ 'success' => false, 'error' => __( 'Unexpected response from upstream.', '<plugin-slug>' ) ],
500
);
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
return new WP_REST_Response( [ 'success' => true, 'data' => $body ], 200 );
Step 4 — Lint
composer lint
composer lint-fix
composer lint
Conventions
- Route namespace:
{plugin-slug}/v1 — kebab-case slug, never camelCase, never shared dekodeinteraktiv/v1 across plugins
- Response shape: return
['success' => bool] with either 'data' on success or 'error' (i18n string) on failure
- HTTP status codes:
200/201 success, 400 bad input, 403 forbidden, 500 server/upstream errors
- Permission callback: never omit it. Use
true only for genuinely public endpoints. For anything touching user data, check current_user_can() and return rest_authorization_required_code() on failure
- External HTTP: always
wp_remote_*() — never curl, file_get_contents(), or Guzzle
is_wp_error() first: always check before reading the response body
- Validate response code with
in_array( $code, [200, 201], true ) — never assume success
- No raw output: never
echo or die() inside a REST callback — always return WP_REST_Response
- i18n: all user-facing error strings wrapped in
__() with the plugin's text domain