Envelope vs Top-Level Arrays in REST Responses

This decision guide belongs to Response Envelopes & Metadata in the Query Patterns & Data Shaping Strategies reference. The choice looks cosmetic — one level of nesting — and is in fact the single hardest response decision to reverse, because the top-level type is what every client’s deserialiser is bound to.

The decision trigger

Symptom What it means
Pagination is needed and there is nowhere to put a cursor bare array has no extension point
A total count has to travel in a custom header metadata pushed out of the body
Clients cannot tell a truncated page from a complete list no has_more signal available
Adding a warnings field would break every consumer top-level type change
Generated types differ from every other endpoint array response vs object responses
A filter was ignored and nobody noticed no place to echo applied filters

Spec snippet: both shapes, side by side

// Bare array — nothing can be added without changing the top-level type
[
  { "id": "inv_1", "total_minor": 4200, "currency": "GBP" },
  { "id": "inv_2", "total_minor":  990, "currency": "GBP" }
]
// Envelope — every future addition is a new key, not a new type
{
  "data": [
    { "id": "inv_1", "total_minor": 4200, "currency": "GBP" },
    { "id": "inv_2", "total_minor":  990, "currency": "GBP" }
  ],
  "meta": { "has_more": true, "next_cursor": "eyJpZCI6Imludl8yIn0" }
}
Extension cost of an array versus an envelope On the left, adding pagination to a bare array forces the top-level type to change from array to object, breaking every deserialiser. On the right, the envelope gains a meta key and existing clients keep parsing data unchanged. Bare array Envelope [ {...}, {...} ] clients: Invoice[] add pagination { "data": [...], "meta": {...} } top-level type changed every deserialiser fails requires a version bump and a coordinated migration { "data": [...] } clients: read .data add pagination { "data": [...], "meta": {...} } same top-level type old clients ignore meta additive change no coordination needed

The security argument, honestly stated

The historical case against top-level arrays was JSON hijacking: in older browsers, a script tag could load a cookie-authenticated JSON array cross-origin and capture its contents by overriding Array’s constructor or setter behaviour. That specific attack no longer works in current browsers, and modern APIs authenticated with bearer tokens rather than ambient cookies were never exposed to it anyway.

So do not argue for envelopes on security grounds; argue for them on extensibility, and note that the risk profile depends on your auth model:

Condition Hijacking-style exposure
Bearer token auth, no cookies none — the cross-origin request carries no credentials
Cookie auth, modern browsers closed by browser changes
Cookie auth, legacy browser targets residual — do not return bare arrays
SameSite=Lax/Strict cookies mitigated at the cookie layer

Step-by-step: migrating an existing array endpoint

Changing the shape in place is a breaking change under any reasonable classification — see Detecting Breaking Changes with oasdiff — so migrate it like one.

Step 1 — Serve both, negotiated

// invoices-route.ts — content negotiation between the two representations
import type { Request, Response } from "express";

const ENVELOPE_TYPE = "application/vnd.example.invoices+json;v=2";

export async function listInvoices(req: Request, res: Response) {
  const page = await queryInvoices(req.query);
  const wantsEnvelope =
    req.accepts(ENVELOPE_TYPE) === ENVELOPE_TYPE || req.query.envelope === "true";

  if (wantsEnvelope) {
    res.type(ENVELOPE_TYPE).json({
      data: page.rows,
      meta: { has_more: page.nextCursor !== null, next_cursor: page.nextCursor },
    });
    return;
  }

  // Legacy representation: bare array, with retirement signalled in-band.
  res.setHeader("Deprecation", "true");
  res.setHeader("Sunset", "Tue, 31 Mar 2026 23:59:59 GMT");
  res.setHeader("Link", `<${req.path}>; rel="successor-version"; type="${ENVELOPE_TYPE}"`);
  res.type("application/json").json(page.rows);
}

Media-type negotiation keeps one URL, which matters when the URL is embedded in customer configuration. The mechanics and trade-offs are covered in Media-Type Versioning with Content Negotiation.

Step 2 — Measure who is still on the array

-- Which API keys still receive the legacy representation?
SELECT api_key_id,
       count(*)                             AS legacy_responses,
       max(occurred_at)                     AS last_seen
FROM   api_access_log
WHERE  route = '/invoices'
  AND  response_content_type = 'application/json'   -- not the envelope media type
  AND  occurred_at > now() - interval '30 days'
GROUP  BY api_key_id
ORDER  BY legacy_responses DESC;

Retirement decisions made without this query are guesses. With it, you can contact the six remaining integrations by name instead of announcing to everyone and hoping.

Step 3 — Retire on the announced date

Dual-serving migration timeline The envelope is introduced alongside the array. During the dual-serving window adoption is measured and deprecation headers are emitted. On the sunset date the array representation is removed and only the envelope remains. envelope added new media type array still default dual-serving window Deprecation + Sunset on the array adoption measured per API key sunset date envelope becomes default array returns 406

After the date, a request that explicitly asks for the retired representation should get 406 Not Acceptable with a problem body naming the replacement — not a silent switch to the envelope, which would break the client just as badly while hiding the cause.

Standard compliance

Concern Standard Clause Note
Top-level array legality RFC 8259 §2 Any JSON value may be top-level
Content negotiation RFC 9110 §12 Accept drives representation choice
406 Not Acceptable RFC 9110 §15.5.7 No acceptable representation available
Deprecation signalling RFC 8594 §2, §3 Deprecation and Sunset headers
Successor links RFC 8288 §3 rel="successor-version"

For an endpoint that is already live, the retirement path is the same one used for removing any representation:

Retiring a bare-array representation The enveloped representation is added under a new media type, both are served while adoption is measured, deprecation headers are attached to the array, and the array is removed after the announced date. content negotiation keeps one URL throughout add envelope media type array remains the default negotiate clients opt in adoption measured per key deprecate Sunset on the array successor-version Link sent remove 406 for the old type never a silent switch switching silently would break clients just as badly while hiding the cause

Performance and streaming

The overhead argument against envelopes does not survive measurement: the wrapper adds tens of bytes to responses measured in kilobytes, and compresses to almost nothing. Streaming is the one real consideration — a client parsing incrementally wants items first — and it is solved by serialisation order:

// stream-envelope.ts — emit data first so consumers can start parsing immediately
export async function streamEnvelope(res: Response, rows: AsyncIterable<Invoice>) {
  res.type("application/json");
  res.write('{"data":[');
  let first = true;
  let count = 0;
  for await (const row of rows) {
    if (!first) res.write(",");
    res.write(JSON.stringify(row));
    first = false;
    count++;
  }
  // Metadata that depends on the whole stream goes last, by necessity.
  res.write(`],"meta":{"has_more":false,"returned":${count}}}`);
  res.end();
}

SDK / codegen downstream effect

- export type ListInvoicesResponse = Invoice[];
- const invoices = await api.listInvoices();          // Invoice[]
+ export interface ListInvoicesResponse {
+   data: Invoice[];
+   meta: PageMeta;
+ }
+ const { data: invoices, meta } = await api.listInvoices();

The migration cost for consumers is exactly one destructuring change per call site — small, but multiplied across every integration, which is why doing it before the first release is worth so much more than doing it afterwards.

Common mistakes

Mistake Correct approach
Changing an array response to an envelope in place Negotiate representations and retire the old one on a date
Justifying envelopes purely on security grounds Argue extensibility; the hijacking risk is largely historical
Serving the envelope silently to legacy Accept headers Honour the request; 406 after retirement
Retiring without measuring adoption Instrument per-client usage before setting a date
Emitting metadata before data when streaming Write data first so clients can parse incrementally
Enveloping single-resource responses too Reserve the envelope for collections

FAQ

Is returning a top-level JSON array a security risk?

It was, in a specific and now largely closed way. Before browsers changed the behaviour, an attacker’s page could include a cookie-authenticated JSON array endpoint via a script tag and capture the values by tampering with Array’s constructor or property setters. That vector is closed in current browsers, and it never applied to APIs authenticated with bearer tokens, since a cross-origin script tag sends no Authorization header. If you still support cookie-authenticated endpoints for legacy browsers, avoid bare arrays; otherwise make the extensibility argument, which is the one that actually holds today.

Can I add an envelope to an existing array endpoint without breaking clients?

Not by changing the response in place — the top-level type is what deserialisers bind to, so Invoice[] becoming {data: Invoice[]} fails at parse time in every statically typed client and at property access in every dynamic one. Treat it as the breaking change it is: introduce the envelope under a new media type (or a new path version), let clients opt in by negotiation, measure who is still on the legacy representation, announce a retirement date through Deprecation and Sunset, and only then remove it. The one shortcut worth considering is a query flag like ?envelope=true for internal consumers, which is easier to adopt than a custom Accept header.

Does an envelope hurt payload size or performance?

Not measurably. The wrapper is roughly thirty bytes of structural overhead against responses that are typically kilobytes, and gzip or brotli removes nearly all of it. The one dimension where it can matter is streaming: a client parsing a large response incrementally wants items as early as possible, so serialise data first and put whole-stream metadata at the end. Done that way, an incremental parser reaches the first item at the same point it would with a bare array, and the metadata arrives when it is computable anyway.