← APIS

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
  1. The data envelope
  2. Including related data
  3. Filtering
  4. Sorting
  5. Pagination
  6. Status codes
  7. The error envelope
  8. Validation errors

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.

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,comments

The 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 include stays 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=admin

For 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,-price

Pagination

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=20

Default 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=20

With 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=25

Status 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.

CodeMeaningWhen to use it
200OKThe request succeeded and the body has the data.
201CreatedA resource was created. Include a Location header.
204No ContentSuccess with no body, common for DELETE and some PUT responses.
400Bad RequestThe request is malformed or has invalid syntax.
401UnauthorisedThe request has no valid credentials.
403ForbiddenThe credentials are valid but do not allow this action.
404Not FoundThe resource does not exist.
409ConflictThe request conflicts with the resource's current state, such as a duplicate unique key.
410GoneA removed endpoint, during the transition after deprecation.
422Unprocessable EntityThe syntax is valid but the data fails validation or a business rule.
429Too Many RequestsThe client is over its rate limit.
500Internal Server ErrorSomething failed on the server. Log the details and return a generic message.
503Service UnavailableThe 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_a1b2c3d4

When 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."]
        }
    }
}
  • code repeats the HTTP status, so a client can read it from the body.
  • message is a short summary for logs or a general notice.
  • Each key in details is 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 is email_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.