REST API response shape, pagination and errors
How to shape REST API responses: a data envelope, opt-in related data, filtering, sorting and pagination, status codes, error handling and validation errors.
On this page
This page covers what a REST API sends back: the JSON envelope, related data, filtering and pagination for collections, status codes, and errors. The request side, including URL naming and field casing, is in REST API design rules for URLs, methods and fields.
The data envelope
Wrap every response in an object with a data key. For a collection, put pagination metadata and navigation links beside it:
{
"data": [
{ "id": 1, "name": "Widget A" },
{ "id": 2, "name": "Widget B" }
],
"meta": {
"total": 120,
"per_page": 20,
"current_page": 2,
"last_page": 6
},
"links": {
"self": "/products?page=2",
"next": "/products?page=3",
"prev": "/products?page=1"
}
}For a single resource, data holds the object itself:
{
"data": {
"id": 42,
"name": "Widget A",
"price": 19.99
}
}A bare JSON array causes two problems. You cannot add metadata later, because an array cannot become an object without breaking clients. And older browsers allowed a malicious page to read a top-level array response, an attack known as JSON hijacking. An object envelope avoids both from the start.
Including related data
By default, return only the fields that belong to the requested resource. Let clients ask for related resources with an include parameter, so they do not need a second request for each one:
GET /posts/100?include=author
GET /posts/100?include=author,category
GET /posts/100?include=author,commentsThe server embeds the requested relations:
{
"data": {
"id": 100,
"title": "Designing Better APIs",
"author": { "id": 7, "name": "Jane Smith" },
"category": { "id": 3, "name": "Engineering" }
}
}Dot notation expands one level deeper. This returns each order item with its product:
GET /orders/42?include=items,items.product- Allow only relations you have designed and tested. Do not let clients expand arbitrary paths.
- Cap how many relations one request can ask for, and document any depth limit. Deep expansion means large payloads and expensive queries.
- Document every supported relation.
- The base URL without
includestays cacheable, and each expansion can be its own cache key.
Filtering
Filter a collection with query parameters named after the fields:
GET /products?category=electronics&brand=acme
GET /users?status=active&role=adminFor ranges or partial matches, use a documented syntax such as filter[price][gte]=50.
Sorting
Use a sort parameter. A minus sign in front of the field means descending, and commas separate several keys:
GET /products?sort=price # ascending by price
GET /products?sort=-price # descending by price
GET /products?sort=-created_at # newest first
GET /products?sort=category,-pricePagination
Paginate every list endpoint. An unbounded collection wastes bandwidth, slows responses and can overload the database.
Offset pagination is the simplest, with page and per_page:
GET /products?page=2&per_page=20Default per_page to somewhere between 20 and 50 and enforce a hard maximum, such as 100.
Cursor pagination suits very large datasets and live feeds. The server returns an opaque cursor that the client sends back for the next page:
GET /products?cursor=eyJpZCI6IDQ2MH0=&per_page=20With offsets, a record inserted or deleted between requests shifts every later page, so a client can skip or repeat a row. A cursor does not have that problem.
All of these combine in one request. This returns the first 25 electronics products, most expensive first:
GET /products?category=electronics&sort=-price&page=1&per_page=25Status codes
Use the standard HTTP codes and keep to a small set. Clients branch on them, and an unusual or invented code breaks that logic.
| Code | Meaning | When to use it |
|---|---|---|
| 200 | OK | The request succeeded and the body has the data. |
| 201 | Created | A resource was created. Include a Location header. |
| 204 | No Content | Success with no body, common for DELETE and some PUT responses. |
| 400 | Bad Request | The request is malformed or has invalid syntax. |
| 401 | Unauthorised | The request has no valid credentials. |
| 403 | Forbidden | The credentials are valid but do not allow this action. |
| 404 | Not Found | The resource does not exist. |
| 409 | Conflict | The request conflicts with the resource's current state, such as a duplicate unique key. |
| 410 | Gone | A removed endpoint, during the transition after deprecation. |
| 422 | Unprocessable Entity | The syntax is valid but the data fails validation or a business rule. |
| 429 | Too Many Requests | The client is over its rate limit. |
| 500 | Internal Server Error | Something failed on the server. Log the details and return a generic message. |
| 503 | Service Unavailable | The server cannot handle requests for now, such as during maintenance or heavy load. |
A 404 means the resource does not exist, and a 403 means the client may not access it. Return 404 to hide that a resource exists only when that is a deliberate security decision.
The error envelope
Every error, wherever it starts in the code, reaches the client in the same shape. Write a global exception handler that turns unhandled errors into this envelope:
{
"error": {
"code": 500,
"message": "An unexpected error occurred.",
"request_id": "req_a1b2c3d4"
}
}The client then parses one shape and branches on code.
Give every response, success or error, a unique request ID in a header as well as in the error body:
X-Request-ID: req_a1b2c3d4When a client reports a problem, that ID finds the exact request in your logs.
Be specific about client errors and vague about server errors. A 404 can say "Product not found." A 500 should say "An unexpected error occurred." and nothing more. Stack traces, SQL queries and file paths help in development. In production they show an attacker how the application is built, so turn verbose errors off and strip internal details in the handler.
Log every 5xx with the full stack trace, the request details and the request ID. Log 4xx errors at a lower level, but still record them so you can spot patterns. A run of 401 responses can point to a credential problem, and repeated 404 responses can point to a broken client. Logging in Laravel covers structured logging for this.
Validation errors
When submitted data fails validation, return 422 and list every failing field under details:
{
"error": {
"code": 422,
"message": "Validation failed.",
"details": {
"email": ["A valid email address is required."],
"password": [
"Password must be at least 8 characters.",
"Password must contain a number."
],
"name": ["This field is required."]
}
}
}coderepeats the HTTP status, so a client can read it from the body.messageis a short summary for logs or a general notice.- Each key in
detailsis a field name, and each value is an array, because one field can break several rules at once.
The rules for the messages:
- Use the same field names as the request and response. If the API accepts
email_address, the error key isemail_address. - Write for people. "Email must be unique" helps. "UNIQUE constraint failed: users.email" does not.
- Return every failing field in one response, so the user can fix them all in one pass.
- The same field breaking the same rule gives the same message on every endpoint.