JSON API response shapes that survive five years
Almost every painful API migration traces back to a shape decision made in an afternoon and frozen by the first integration.
Every claim here is either measured or sourced. Where it is neither, it says so.
Here is a response that shipped in someone’s v1 and is still shipping today:
[
{ "id": 8102, "name": "Ada" },
{ "id": 8103, "name": "Grace" }
]
Three years later the collection is big enough to need pagination, and there is nowhere to put a cursor. The top level is an array. Wrapping it changes the type every client already parses, so the team ships /v2/users and maintains two code paths forever. Nothing about that array was wrong when it was written. It just had no room to grow.
That is the whole subject. Response design is not about elegance, it is about which changes stay cheap.
Envelope or bare value
An envelope is a top level object with the payload under a key:
{
"data": [ { "id": "8102", "name": "Ada" } ],
"nextCursor": "eyJpZCI6ODEwM30",
"hasMore": true
}
The case against is real: it is noise, and every client writes .data. The case for is that an object is extensible and a naked array is not. You can add a cursor, a total, a deprecation notice or a trace id later without changing the type of anything already there.
What I do: envelope collections, return the bare object for a single resource. A single resource is already an object, so it has the room to grow that a wrapper would have given it. Collections get the envelope because they are the ones that eventually need metadata.
Whatever you pick, pick once. Half your endpoints wrapped and half bare is worse than either. And do not put a field called data inside a field called data.
The field types you cannot walk back
IDs are strings. Always, including while they are still small integers. A JSON number in JavaScript is an IEEE 754 double, so any identifier above 9007199254740991 is silently rounded on arrival and you are now looking at a different record. Twitter hit this when it moved to 64-bit Snowflake IDs in 2010 and shipped id_str alongside id, and the pattern stuck. The mechanics are in why your JSON IDs keep changing value. The design point is narrower: an identifier is not a quantity. You never add to it, sort it arithmetically or average it, so a numeric type buys nothing and costs you the day you move to UUIDs.
Money is an integer in minor units, or a decimal string. Never a float.
{ "amountMinor": 1005, "currency": "GBP" }
{ "amount": "10.05", "currency": "GBP" }
1.005 is not exactly representable as a double, so in JavaScript 1.005 * 100 is 100.49999999999999, which rounds to 100 instead of 101. Pick one representation, carry the currency beside it, and never let a bare price: 10.05 into the schema, because taking it out later means auditing every consumer that does arithmetic on it.
Dates are RFC 3339 strings with an explicit offset. "2026-09-05T14:30:00Z". Not a Unix timestamp, not "05/09/2026", and above all not a local time with no offset, because that parses fine and is wrong by hours. JSON has no date type, so this convention only exists if code review enforces it. JSON date and time formats covers the rest.
null, absent, and empty
Four shapes, four meanings:
| Shape | Meaning |
|---|---|
"middleName": "Jane" |
Known value |
"middleName": null |
Known to have no value |
| key absent | Not known, not loaded, or not permitted |
"tags": [] |
Known to have zero tags |
The mistake is not picking the wrong convention, it is using all four inconsistently, so a client cannot tell “this user has no middle name” from “you asked for a sparse projection”. Decide per field and hold the line.
Two traps. JSON.stringify drops keys whose value is undefined but keeps null, so a JavaScript producer flips between absent and null depending on whether a variable was assigned. And JSON Schema’s required asserts that a key is present, not that it is non null: {"name": null} satisfies required: ["name"]. If you mean non null, put it in the type.
{
"type": "object",
"required": ["name", "middleName"],
"properties": {
"name": { "type": "string" },
"middleName": { "type": ["string", "null"] }
}
}
Generate the first draft from a real payload with the schema generator, then fix the nullability by hand, because a generator only sees the values that happened to be in your sample.
Naming
Pick camelCase or snake_case, apply it to every key on every endpoint, and stop having the conversation. Mixed casing in one document is the clearest signal that two teams wrote two halves and neither read the other, and it breaks the cheap client trick of mapping keys mechanically onto struct fields. created_at beats ts. A key that needs a comment needs a better name.
Errors
An error body needs three separate things, and most ship one:
{
"type": "https://api.example.com/errors/insufficient-funds",
"title": "Insufficient funds",
"status": 402,
"detail": "Balance is 320 minor units, transfer requires 1005.",
"code": "INSUFFICIENT_FUNDS",
"pointer": "/transfer/amountMinor"
}
A stable machine code the client branches on, which you commit to never changing. A human message you stay free to reword or translate, and which no client should ever match against. A pointer to the offending field, ideally an RFC 6901 JSON Pointer so it resolves mechanically against the request body.
RFC 9457, Problem Details for HTTP APIs, standardises type, title, status, detail and instance, and explicitly allows extension members, so you can adopt it and still carry your own code. It obsoleted RFC 7807, the name most existing implementations know it by. Using it gives you a shape other people’s tooling already understands, which {"error": "something went wrong"} never will. For validation failures, return all of them rather than the first.
Cursors beat offsets
Offset pagination is quietly lossy the moment there are concurrent writes. Page one returns rows 1 to 50. A row is inserted near the top. Page two, offset=50, now begins at what was row 50, so the consumer sees that record twice. Deletes do it in reverse and skip records entirely. Nothing errors; it surfaces weeks later as a reconciliation mismatch.
A cursor encodes a position in a stable sort, normally the sort key plus a tiebreaking id, so inserts above it are irrelevant. Document the cursor as opaque so you can change its encoding later, and return an explicit hasMore rather than making clients infer the end from a short page. Skip totalCount unless someone actually needs it and you are willing to pay for the second query.
Additive change is the only free change
The contract that makes evolution possible lives on the client side: unknown fields must be ignored. If that holds, adding a field is not breaking and you can ship continuously. If a consumer validates strictly, or generates types with additionalProperties: false, every addition breaks someone and you are on v1 forever. Say it in the first paragraph of your docs.
Everything else is a version: removing a field, renaming one, changing its type, changing what a value means, tightening what you accept, or making a nullable field non nullable. Running last release’s sample payload and this one through a JSON diff catches the type change nobody meant to make.
Heterogeneous arrays cost the consumer more than they save you
{ "items": [
{ "kind": "comment", "body": "..." },
{ "kind": "reaction", "emoji": "..." },
{ "id": 7, "legacy": true }
] }
Every consumer now writes a dispatch, and every statically typed one writes a tagged union by hand. If you must mix shapes, discriminate them: a required kind with a documented closed set of values, present on every member. Then the union is mechanical. The unforgivable version is the third element, where the shape varies with no tag and clients sniff for keys. Same for a field that is sometimes a string and sometimes an object: it saves you one version bump and costs every client a type guard forever.
When the response gets large
Every engine has a hard ceiling on string length, and it is lower than people expect: on 64-bit V8 (Chrome and Node) it is 536,870,888 characters, so a response past roughly half a gigabyte cannot even be held as a string, let alone parsed. Other engines sit higher, but they all have a ceiling, and the parsed object tree costs several times what the text did. Long before any of that, a multi second parse blocks the main thread.
Three exits, in order of how much they disturb the API. Paginate harder so no single response is large. Stream line delimited records so the consumer works as it receives instead of waiting for a closing brace (NDJSON and JSON Lines). Or move bulk export off the synchronous API entirely: return a job id and a signed URL for the finished file. Handling large JSON files covers the consumer side.
The checklist
- Envelope collections, return bare objects for single resources, and be consistent.
- IDs are strings. Money is minor units or a decimal string. Dates are RFC 3339 with an offset.
- Define what null, absent and empty each mean, per field.
- One casing convention across every endpoint.
- Errors carry a stable code, a mutable message and a field pointer. Consider RFC 9457.
- Cursor pagination, opaque cursors, an explicit
hasMore. - Tell clients to ignore unknown fields, then keep every other change behind a version.
- Discriminate every heterogeneous array with a required
kind.
None of this is expensive on day one. All of it is expensive on day one thousand, which is the only reason it is worth arguing about now.