Response Envelopes & Metadata
Part of the Query Patterns & Data Shaping Strategies reference. The envelope is the outermost decision in a collection response, and the hardest to change afterwards: it is the shape every client deserialises into, so a bare array today is a breaking change tomorrow when pagination arrives. This section covers what belongs in the envelope, what belongs in headers, and how to evolve one safely.
Problem framing
The first version of a list endpoint returns [{...}, {...}] because that is what the ORM produced. It works until the collection grows, at which point pagination becomes necessary — and there is nowhere to put a cursor. Every option is bad: adding a wrapper changes the top-level type and breaks every consumer’s deserialiser; putting the cursor in a header hides it from tools and documentation; returning a partial array with no indication that more exists silently corrupts every integration that assumed completeness.
An envelope is the cheap insurance against that. { "data": [...] } costs one level of nesting and one accessor, and it means cursors, counts, warnings, deprecation notices and applied-filter echoes can all be added later as additive changes. The rule generalises: the top level of a response should be an object, because objects are extensible and arrays are not. Cursor semantics themselves are covered in Offset vs Cursor Pagination; this section is about the container they travel in.
What goes where
Not everything belongs in the body. The split between envelope and headers determines cacheability and how much machinery a client needs.
The test is simple: would two callers requesting the same URL get the same value? If yes it is part of the representation and belongs in the envelope; if no it varies per caller and belongs in a header. A remaining-quota field in the body means the same logical page has a different body for every caller and on every request, which makes strong validators useless.
Spec definition
# openapi.yaml (OpenAPI 3.1.0) — one reusable envelope for every collection
components:
schemas:
PageMeta:
type: object
required: [has_more]
properties:
has_more:
type: boolean
description: Whether a further page exists after this one.
next_cursor:
type: [string, "null"]
description: Opaque cursor for the next page; null when has_more is false.
applied_filters:
type: object
additionalProperties: true
description: Filters the server actually applied, echoed for verification.
total_count:
type: [integer, "null"]
description: Present only when requested with include_total=true.
total_is_estimate:
type: boolean
default: false
description: True when total_count is approximate.
InvoiceListEnvelope:
type: object
required: [data, meta]
properties:
data:
type: array
items: { $ref: "#/components/schemas/Invoice" }
meta: { $ref: "#/components/schemas/PageMeta" }
warnings:
type: array
items: { $ref: "#/components/schemas/Warning" }
paths:
/invoices:
get:
operationId: listInvoices
parameters:
- name: cursor
in: query
schema: { type: string }
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
- name: include_total
in: query
description: Compute total_count. Slower; omit on hot paths.
schema: { type: boolean, default: false }
responses:
"200":
description: A page of invoices.
content:
application/json:
schema: { $ref: "#/components/schemas/InvoiceListEnvelope" }
Two decisions in that schema are worth defending. next_cursor is nullable rather than absent when exhausted, because an always-present key is easier to type in strict languages than an optional one. And total_count is opt-in, because an exact count on a filtered collection usually costs a second full scan — the page itself touches 50 rows, the count touches all of them.
Standard alignment
| Element | Standard | Clause | Note |
|---|---|---|---|
Link relations (next, prev) |
RFC 8288 | §3 | Header-based alternative to body links |
application/json structure |
RFC 8259 | §2 | Any JSON value may be top-level — but see below |
| Cache validators | RFC 9110 | §8.8 | ETag over the representation |
206 Partial Content |
RFC 9110 | §15.3.7 | Byte ranges — not for pagination |
Prefer / Preference-Applied |
RFC 7240 | §2, §3 | Negotiating optional metadata |
The 206 row is a common misreading: partial content is about byte ranges of a single representation, not about pages of a collection. A page of results is a complete representation of that page and returns 200.
Implementation: building the envelope
// list-invoices.ts — one envelope builder shared by every collection endpoint
interface Page<T> { rows: T[]; nextCursor: string | null; }
export interface Envelope<T> {
data: T[];
meta: {
has_more: boolean;
next_cursor: string | null;
applied_filters: Record<string, unknown>;
total_count?: number | null;
total_is_estimate?: boolean;
};
warnings?: Array<{ code: string; message: string }>;
}
export function envelope<T>(
page: Page<T>,
appliedFilters: Record<string, unknown>,
total?: { count: number; estimate: boolean },
warnings: Array<{ code: string; message: string }> = [],
): Envelope<T> {
const body: Envelope<T> = {
data: page.rows,
meta: {
has_more: page.nextCursor !== null,
next_cursor: page.nextCursor,
applied_filters: appliedFilters,
},
};
if (total) {
body.meta.total_count = total.count;
body.meta.total_is_estimate = total.estimate;
}
if (warnings.length) body.warnings = warnings;
return body;
}
applied_filters deserves its place. When a client sends ?status=paid&currecy=GBP — note the typo — echoing what the server actually applied makes the silent ignoring visible, and a client can assert against it in tests rather than wondering why the result set is too large. It complements the strict-mode alternative discussed in Debugging Projection Field Validation Errors.
Implementation: consuming it without leaking the envelope
Client code should not thread .data through every layer. Unwrap once, at the boundary:
// paginate.ts — an async iterator that hides the envelope from callers
export async function* paginate<T>(
fetchPage: (cursor: string | null) => Promise<Envelope<T>>,
): AsyncGenerator<T> {
let cursor: string | null = null;
do {
const page = await fetchPage(cursor);
for (const warning of page.warnings ?? []) {
console.warn(`[api] ${warning.code}: ${warning.message}`); // never swallow
}
yield* page.data;
cursor = page.meta.has_more ? page.meta.next_cursor : null;
} while (cursor !== null);
}
// Callers never see the envelope:
for await (const invoice of paginate(c => api.listInvoices({ cursor: c }))) {
process(invoice);
}
Looping on has_more rather than on next_cursor !== null is deliberate: it keeps the loop correct even if a server bug emits a stale cursor after exhaustion.
Edge-case handling
Empty collections. Return {"data": [], "meta": {"has_more": false, "next_cursor": null}} with 200, never 204 or 404. An empty collection exists; it just has no members, and 204 forces clients to special-case a body-less response.
A page that is the last page. has_more: false must be authoritative. Returning a next_cursor alongside has_more: false invites an extra round trip that returns nothing.
Warnings that are not errors. A deprecated filter, a coerced value, a truncated response: all deserve a warnings array, because the alternative is silence. Warnings must never change the meaning of data.
Envelope for errors. Do not reuse the success envelope for failures. Errors have their own media type and shape — see RFC 7807 Problem+JSON Implementation — and a {"data": null, "error": {...}} hybrid forces every client to check two places for failure.
Validation and testing patterns
// envelope.contract.test.ts — the invariants every collection must satisfy
import { describe, it, expect } from "vitest";
const COLLECTIONS = ["/invoices", "/customers", "/payments"];
describe.each(COLLECTIONS)("envelope contract: %s", (path) => {
it("returns an object with data and meta, never a bare array", async () => {
const body = await get(path);
expect(Array.isArray(body)).toBe(false);
expect(Array.isArray(body.data)).toBe(true);
expect(typeof body.meta.has_more).toBe("boolean");
});
it("returns an empty page rather than 204 or 404", async () => {
const res = await rawGet(`${path}?filter=definitely-no-matches`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toEqual([]);
expect(body.meta.has_more).toBe(false);
expect(body.meta.next_cursor).toBeNull();
});
it("omits total_count unless it was requested", async () => {
expect((await get(path)).meta.total_count).toBeUndefined();
expect((await get(`${path}?include_total=true`)).meta.total_count).toBeTypeOf("number");
});
});
# .spectral.yaml — stop a bare array from ever shipping
rules:
collections-are-enveloped:
description: A 200 response body must be an object, not a top-level array.
severity: error
given: "$.paths[*].get.responses['200'].content['application/json'].schema"
then:
field: type
function: pattern
functionOptions:
notMatch: "^array$"
SDK generation impact
// Generated TypeScript
- export type ListInvoicesResponse = Invoice[];
- // adding pagination later changes this type — every caller breaks
+ export interface ListInvoicesResponse {
+ data: Invoice[];
+ meta: PageMeta;
+ warnings?: Warning[];
+ }
+ // new meta members are additive; existing callers keep compiling
Because the envelope is a named component, all collection endpoints share one PageMeta type, so a client can write a single generic pagination helper rather than one per endpoint. That reuse is what makes the extra nesting worth its cost, and it depends on the component naming discipline in Reusable Schema Components and $ref Cycles.
Anti-patterns quick-reference
| Anti-pattern | Recommended approach |
|---|---|
| Bare top-level array | Wrap in an object with data and meta |
| Pagination fields mixed into item objects | Keep items clean; metadata lives in meta |
| Always computing an exact total | Make it opt-in; consider an estimate flag |
204 or 404 for an empty collection |
200 with an empty data array |
| Quota or request-id fields inside the body | Put per-caller values in headers |
| Reusing the success envelope for errors | Use application/problem+json |
| A different envelope shape per endpoint | One shared component across all collections |
| Silently ignoring unknown filters | Echo applied_filters or reject explicitly |
Evolving an envelope that already shipped
The envelope pays for itself the first time you need to add something to it, and the mechanics of that addition are worth being precise about because one of them is deceptively dangerous.
Adding a new optional member is additive: clients that ignore unknown fields — which is nearly all of them — carry on unchanged, and clients generated from the specification gain an optional property that still compiles against existing code. Adding an opt-in parameter such as include_total is likewise additive, because the default behaviour does not move.
The dangerous change is one no spec diff can see: altering a default. Introducing a default page size on an endpoint that previously returned everything gives every existing caller a well-formed 200 containing a fraction of the data and no indication that anything is missing. A nightly reconciliation job silently processes fifty rows instead of four thousand, and the discovery happens weeks later in a financial control rather than in an error log. Keep the historical behaviour, add pagination as opt-in, warn in-band on unbounded requests, and enforce a cap only after an announced date — the sequence in Exposing Pagination Metadata Without Breaking Clients.
FAQ
Should a collection endpoint return a bare JSON array?
No — not because a top-level array is invalid JSON (RFC 8259 permits any value at the top level), but because it is a dead end for evolution. There is no place to add a cursor, a count, a warning or a deprecation notice, so the first time you need one you must change the top-level type and break every deserialiser at once. An object wrapper costs one accessor and buys you every future addition as a non-breaking change. The only case for a bare array is an endpoint you are certain will never paginate, and that certainty is rarely justified.
Should single-resource responses use the same envelope as collections?
Usually not. GET /invoices/{id} has no pagination, no filters and no counts, so wrapping it in { "data": {...} } adds nesting that buys nothing and makes every property access go through an extra hop. Consistency arguments favour wrapping everything, but consistency of shape is less valuable than consistency of meaning: collections have metadata, single resources do not. If a single-resource response genuinely needs per-response metadata that cannot travel in a header, wrap that endpoint and document why.
Where should a total count live?
In meta, and only when asked for. On any non-trivial collection an exact count requires a second query that scans every matching row — the page itself reads 50 rows while the count reads all 4 million — so making it unconditional taxes every request for a number most callers never display. Gate it behind include_total=true, and for very large collections consider returning a fast estimate with total_is_estimate: true so clients can render “about 4.2 million” without the scan. Cursor-based pagination rarely needs the total at all, which is one of its quieter advantages.
Do envelopes conflict with HTTP caching?
Only when you put the wrong things in them. A body containing a remaining-quota field or a request identifier changes on every request even when the underlying data has not, so ETag values never repeat and conditional requests always miss. Keep the envelope limited to the representation — items, cursors, applied filters, counts — and put per-caller or per-request values in headers. Done that way, an envelope is strictly cache-neutral, and the conditional-request machinery in Statelessness & Caching Strategies works exactly as it would for a bare resource.
Related
- Query Patterns & Data Shaping Strategies — up-link: the parent reference for pagination, filtering, sorting and projection
- Envelope vs Top-Level Arrays in REST Responses — the decision and its migration path in detail
- JSON:API vs HAL vs Plain JSON Contracts — choosing a specified envelope over a bespoke one
- Exposing Pagination Metadata Without Breaking Clients — adding cursors and counts to a shipped API
- Offset vs Cursor Pagination — the sibling section defining what the cursors mean
- Sparse Fieldsets & Projection — shaping the items the envelope carries