Requests & Responses
Reading WP_REST_Request, returning WP_REST_Response or WP_Error, and controlling status codes and headers from a callback.
function my_callback( WP_REST_Request $request ): WP_REST_Response|WP_ErrorEvery REST callback receives one argument — a WP_REST_Request — and returns either data, a WP_REST_Response, or a WP_Error. The server serialises whatever it gets into JSON and an HTTP status.
Reading the request
WP_REST_Request merges URL parameters, the query string, the form body and the JSON body into one bag. $request->get_param( 'id' ) is the longhand; because the class implements ArrayAccess, $request['id'] is the identical shorthand. When a name appears in more than one place the body wins, so use get_query_params(), get_json_params(), get_body_params() or get_url_params() when the source matters. Headers come from get_header( 'X-WP-Nonce' ).
Returning a response
Returning a plain array is shorthand: the server wraps it in new WP_REST_Response( $data, 200 ). Write the longhand when you need a different status or a header. WP_REST_Response extends WP_HTTP_Response, so it gives you set_status( $code ), header( $key, $value, $replace = true ) and set_data( $data ), plus add_link() for HAL _links. rest_ensure_response() normalises anything into a response object and passes a WP_Error straight through — use it when wrapping an existing function.
Errors
Return a WP_Error; do not throw. Its third argument sets the HTTP status via the status key, and the code and message are serialised into the body as code and message.
Example
<?php
/**
* Callback for POST /wp-json/acme/v1/orders/<id>/refund
*/
function acme_refund_order( WP_REST_Request $request ) {
// Shorthand and longhand for the same value. ArrayAccess is sugar for
// get_param(); both search URL params, query string, body and JSON.
$order_id = (int) $request['id'];
$order_id = (int) $request->get_param( 'id' );
// When the source matters -- e.g. you will only accept a JSON body and not
// a value smuggled in on the query string -- read that bag directly.
$body = $request->get_json_params();
$amount = isset( $body['amount'] ) ? (float) $body['amount'] : 0.0;
$order = acme_get_order( $order_id );
if ( ! $order ) {
// Without the status key this would surface as a 500.
return new WP_Error(
'acme_order_not_found',
__( 'No order with that ID.', 'acme' ),
array( 'status' => 404 )
);
}
if ( $amount > $order->balance ) {
return new WP_Error(
'acme_refund_too_large',
__( 'Refund exceeds the remaining balance.', 'acme' ),
array( 'status' => 422, 'balance' => $order->balance )
);
}
$refund = acme_create_refund( $order, $amount );
// Longhand, because 201 and a Location header are worth being explicit about.
$response = new WP_REST_Response( $refund, 201 );
$response->header( 'Location', rest_url( "acme/v1/refunds/{$refund['id']}" ) );
$response->add_link( 'order', rest_url( "acme/v1/orders/{$order_id}" ) );
return $response;
}
// The shorthand equivalent of a plain 200: returning the array alone is the
// same as returning new WP_REST_Response( $refund, 200 ).
// return $refund;When to use it
- Returning 201 and a Location header after a custom endpoint creates a record.
- Distinguishing a JSON body from query-string parameters of the same name.
- Setting Cache-Control on an expensive read endpoint fronted by a CDN.
- Turning a validation failure into a 422 with machine-readable detail instead of a 500.
More examples
Paginating a custom collection the way core does
Matching core's convention means any client already written against wp/v2 can page your collection with no special-casing. X-WP-Total and X-WP-TotalPages are also in the default CORS expose list, so browser JavaScript on another origin can actually read them.
<?php
function acme_list_books( WP_REST_Request $request ) {
$page = (int) $request->get_param( 'page' );
$per_page = (int) $request->get_param( 'per_page' );
$query = new WP_Query(
array(
'post_type' => 'book',
'post_status' => 'publish',
'posts_per_page' => $per_page,
'paged' => $page,
)
);
$data = array_map(
function ( WP_Post $post ) {
return array(
'id' => $post->ID,
'title' => get_the_title( $post ),
);
},
$query->posts
);
$response = rest_ensure_response( $data );
// Core's controllers publish paging totals as headers, not in the body.
// Clients (including apiFetch consumers) expect these exact names.
$response->header( 'X-WP-Total', (string) $query->found_posts );
$response->header( 'X-WP-TotalPages', (string) $query->max_num_pages );
return $response;
}Wrapping an existing function safely
This is the pattern for putting a REST face on code you did not write for REST. The ordering matters: rest_ensure_response() deliberately lets WP_Error through untouched, so any method call on its result has to come after an is_wp_error() check.
<?php
/**
* acme_build_report() is old code: it returns an array on success and a
* WP_Error on failure, and knows nothing about REST.
*/
function acme_report_endpoint( WP_REST_Request $request ) {
$result = acme_build_report( $request->get_param( 'range' ) );
// rest_ensure_response() returns a WP_Error unchanged and wraps anything
// else in a WP_REST_Response. So check for the error FIRST -- calling
// ->header() on the return value would fatal on the error path.
if ( is_wp_error( $result ) ) {
return $result;
}
$response = rest_ensure_response( $result );
// Let a CDN cache this for five minutes. REST responses are sent with
// no-cache headers by default, so you must opt in explicitly.
$response->header( 'Cache-Control', 'public, max-age=300' );
return $response;
}
Discussion