5 min read
On this page

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

{
  "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."

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"
  }
}

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 self links 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, and links keys for consistent, extensible API responses across all list endpoints.
  • Include pagination metadata appropriate to your strategy: total and page for offset, has_more and 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.