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.

What belongs in the envelope and what belongs in headers The envelope holds the items array, pagination cursors, applied filters and result counts, which are part of the representation. Headers hold caching validators, rate limit state and request identifiers, which vary per caller or per request. envelope (body) part of the representation — cacheable data[] — the items themselves meta.next_cursor — position in the collection meta.applied_filters — what the serverunderstood meta.total_count — opt-in, expensive warnings[] — non-fatal notices headers per-request or per-caller — not therepresentation ETag / Last-Modified — cache validators RateLimit — varies per API key Deprecation / Sunset — lifecycle signals X-Request-Id — correlation, not content putting these in the body breaks ETag stability

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.

Evolving an envelope additively Version one has data and has_more. Version two adds next_cursor and applied_filters. Version three adds an optional total_count and a warnings array. Every step is additive, so clients built against version one keep working. v1 data: [...] meta.has_more object, not bare array v2 — additive + meta.next_cursor + meta.applied_filters v1 clients unaffected v3 — additive + meta.total_count (opt-in) + warnings[] still no version bump a bare array at v1 would have made every one of these a breaking change

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.

Is this envelope change safe? If the change only adds optional members or opt-in parameters, it is additive and ships immediately. If it alters an existing default, existing callers silently receive different data and it must be staged. does the change alter an existing default? yes no stage it — this is breaking warn in-band, measure adoption, enforce on an announced date additive — ship it new optional members and opt-in parameters leave callersuntouched a spec diff sees the new parameter and calls it additive; only a traffic replay catches a changed default

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.