Response Envelopes & Metadata
Structuring API Responses
Every list endpoint returns more than just data. Clients need to know how many results exist, how to get the next page, what filters were applied, and how to trace the request. The response envelope wraps the actual data with this essential context.
The Response Envelope
An envelope is a consistent wrapper around the response data. Instead of returning a bare array, you return an object with the data nested inside.
Bare Array (No Envelope)
[
{"id": "user_123", "name": "Jane Smith"},
{"id": "user_456", "name": "John Doe"}
]
Enveloped Response
{
"data": [
{"id": "user_123", "name": "Jane Smith"},
{"id": "user_456", "name": "John Doe"}
],
"meta": {
"total": 1547,
"page": 1,
"per_page": 20,
"total_pages": 78
},
"links": {
"self": "/api/v1/users?page=1&limit=20",
"next": "/api/v1/users?page=2&limit=20",
"last": "/api/v1/users?page=78&limit=20"
}
}
The envelope provides a consistent structure. Clients always know where to find the data (data), pagination info (meta), and navigation (links).
Metadata
Metadata communicates context about the response that is not part of the data itself.
Pagination Metadata
For offset-based pagination:
{
"meta": {
"total": 1547,
"page": 3,
"per_page": 20,
"total_pages": 78
}
}
For cursor-based pagination:
{
"meta": {
"has_more": true,
"next_cursor": "eyJpZCI6InVzZXJfNjAifQ==",
"result_count": 20
}
}
Notice that cursor-based pagination omits total and total_pages. Computing totals on large datasets is expensive, and cursor pagination does not require them. Use has_more instead.
Request Identification
Include a request ID for debugging and support:
{
"meta": {
"request_id": "req_abc123xyz"
}
}
When a client reports an issue, the request ID lets your team trace the exact request through logs, load balancers, and downstream services. Stripe includes a request ID in every response:
HTTP/1.1 200 OK
Request-Id: req_abc123xyz
Timing & Rate Limit Context
Some APIs include processing time and rate limit status in metadata:
{
"meta": {
"request_id": "req_abc123xyz",
"processing_time_ms": 47,
"rate_limit": {
"limit": 1000,
"remaining": 958,
"reset_at": "2024-03-22T12:00:00Z"
}
}
}
Filter Echo
Echo back the applied filters so the client can confirm what was actually applied:
{
"meta": {
"filters": {
"status": "active",
"created_after": "2024-01-01T00:00:00Z"
},
"sort": "created_at",
"order": "desc"
}
}
This is especially valuable when the server normalizes or adjusts client input (e.g., capping per_page at 100 when the client requested 500).
HATEOAS & Links
HATEOAS (Hypermedia as the Engine of Application State) means the response includes links that tell the client what it can do next. The client does not need to construct URLs — it follows links from the response.
Pagination Links
{
"links": {
"self": "/api/v1/users?page=3&limit=20",
"first": "/api/v1/users?page=1&limit=20",
"prev": "/api/v1/users?page=2&limit=20",
"next": "/api/v1/users?page=4&limit=20",
"last": "/api/v1/users?page=78&limit=20"
}
}
When there is no next page, omit the next link rather than setting it to null. The absence of the link communicates "there is no next page."
Resource Links
Beyond pagination, links can point to related resources:
{
"data": {
"id": "order_789",
"status": "shipped",
"total": 59.99
},
"links": {
"self": "/api/v1/orders/order_789",
"customer": "/api/v1/customers/cust_456",
"items": "/api/v1/orders/order_789/items",
"invoice": "/api/v1/invoices/inv_321"
}
}
Link Relations (GitHub's Approach)
GitHub uses the Link HTTP header (RFC 8288) for pagination:
HTTP/1.1 200 OK
Link: <https://api.github.com/user/repos?page=2>; rel="next",
<https://api.github.com/user/repos?page=5>; rel="last"
This keeps the response body clean — just the data array — while providing pagination links in the header. The tradeoff is that headers are less visible and harder to parse than a JSON links object.
How Much HATEOAS Is Practical?
Full HATEOAS — where the client discovers the entire API through links — is rarely implemented in practice. Most APIs take a pragmatic middle ground:
- Always include pagination links for list endpoints
- Include
selflinks on individual resources - Include links to closely related resources
- Do not attempt to describe every possible action through hypermedia
The Envelope Debate
Not everyone agrees that envelopes are the right approach. There are legitimate arguments for and against.
Arguments for Envelopes
Consistent structure. Every response has the same top-level shape. Clients can write generic parsing logic.
Metadata alongside data. Pagination info, request IDs, and links live in a predictable location.
Forward compatibility. You can add new metadata fields without changing the data structure.
Error consistency. Error responses can use the same envelope with error instead of data:
{
"error": {
"type": "not_found",
"status": 404,
"message": "User not found"
},
"meta": {
"request_id": "req_abc123xyz"
}
}
Arguments Against Envelopes
Unnecessary nesting. For a simple single-resource GET, wrapping the object in { data: { ... } } adds boilerplate.
HTTP already has metadata. Status codes, headers (Link, X-RateLimit-*, X-Request-Id), and content type communicate metadata without polluting the body.
Standard compliance. Some standards (JSON:API, OData) prescribe specific envelope formats. Using a custom envelope may conflict.
Stripe's Approach
Stripe uses a minimal envelope for list endpoints:
{
"object": "list",
"url": "/v1/charges",
"has_more": true,
"data": [
{
"id": "ch_abc123",
"object": "charge",
"amount": 2000,
"currency": "usd"
}
]
}
For single resources, Stripe returns the object directly (no envelope):
{
"id": "ch_abc123",
"object": "charge",
"amount": 2000,
"currency": "usd"
}
This hybrid approach keeps single-resource responses simple while providing structure for lists.
GitHub's Approach
GitHub returns bare arrays for list endpoints and uses HTTP headers for metadata:
HTTP/1.1 200 OK
Link: <https://api.github.com/user/repos?page=2>; rel="next"
X-Total-Count: 1547
[
{"id": 1, "name": "hello-world", "full_name": "octocat/hello-world"},
{"id": 2, "name": "spoon-knife", "full_name": "octocat/spoon-knife"}
]
Advantages: responses are simpler, and HTTP headers are the semantically correct place for metadata. Disadvantages: parsing Link headers is less convenient than reading a JSON object, and some HTTP clients make headers harder to access.
Common Pitfalls
Returning a bare array at the top level. A bare JSON array cannot be extended with metadata without a breaking change. Wrapping in an object from the start preserves forward compatibility. A bare array also had historical security implications with JSON hijacking (though modern browsers have mitigated this).
Inconsistent envelope structure across endpoints. If /users returns { data, meta, links } but /orders returns { results, pagination }, clients need special handling for each endpoint. Pick one structure and use it everywhere.
Including total count on every cursor-paginated response. Computing COUNT(*) on large tables is expensive and unnecessary for cursor pagination. Use has_more and provide totals only when the client explicitly requests them.
Omitting the request ID. When a client reports "the API returned an error," without a request ID you are searching through millions of log entries. Always include a request ID, in headers or metadata or both.
Returning null links instead of omitting them. "next": null is ambiguous. Does it mean "no next page" or "the server did not compute the next link"? Omit the key entirely when there is no next page.
Mixing pagination metadata into the data array. Some APIs return metadata as the first or last element of the array. This forces clients to check every element's type. Keep data and metadata in separate top-level keys.
Key Takeaways
- Use a response envelope with
data,meta, andlinkskeys for consistent, extensible API responses across all list endpoints. - Include pagination metadata appropriate to your strategy:
totalandpagefor offset,has_moreand cursors for cursor-based pagination. - Provide navigation links so clients can traverse pages without constructing URLs; omit links (rather than nulling them) when navigation is not available.
- Include a request ID in every response for debugging and support; this single addition saves countless hours of troubleshooting.
- Choose between Stripe's approach (minimal envelope for lists, bare objects for singles) and a full envelope on every response; either works, but be consistent.