Designing a JSON payload
Naming, null versus omitted, envelopes, pagination and error contracts - the decisions that are cheap now and expensive after release.
Names, nulls and shape
{
"id": "ord_9f2c",
"customer_id": "cus_1042",
"line_items": [
{ "sku": "A-1", "quantity": 2, "unit_price_minor": 1999 }
],
"total_minor": 3998,
"currency": "GBP",
"placed_at": "2026-09-18T11:04:22Z",
"cancelled_at": null,
"coupon": null
}- Pick one naming convention and hold it. Mixed
customer_idandcustomerIdin the same payload forces every consumer to special-case something. - Spell out units:
total_minorwith a separatecurrencyis unambiguous; a field calledamountis not. - Timestamps carry the timezone in the value. A bare local time is a bug waiting for the first server in another region.
- Use a prefix in opaque identifiers (
ord_,cus_) so a log line or a support ticket makes it obvious what the value refers to.
| Decision | Meaning of null | Or |
|---|---|---|
null is a value | Known to be absent | An explicit empty array or empty string |
| Field omitted | Not applicable or not loaded | Requires clients to handle a missing key |
Empty array [] | Known to have none | Better than null for collections |
0 | A real measurement of zero | Never use null to mean zero |
⚠️
Publishing the distinction between
null and an omitted field is a contract, not an implementation detail. If null means 'known to be empty' and omission means 'unknown', say so in the documentation - otherwise clients will treat them as the same and the difference is lost.Envelopes and pagination
{
"data": [
{ "id": "ord_9f2c" },
{ "id": "ord_9f30" }
],
"meta": {
"request_id": "req_7c1a",
"next_cursor": "eyJpZCI6Im9yZF85ZjMwIn0",
"has_more": true
}
}{
"error": {
"code": "VALIDATION_FAILED",
"message": "Quantity must be at least 1.",
"details": [
{ "field": "line_items[0].quantity", "issue": "minimum", "limit": 1 }
],
"request_id": "req_7c1a"
}
}- A stable machine-readable
codeis what clients branch on. The human-readablemessagemay change or be translated at any time. - Include a request id in every response so a support conversation can start from the actual call.
- Pagination: cursor-based for feeds that change (stable under inserts), offset-based for static reports where clients want a page number.
- Return
has_moreexplicitly rather than making clients infer it from the page length - the two agree only until the page is exactly full. - Use an envelope consistently, including for errors. A response that is sometimes an array and sometimes an object is the most expensive kind of inconsistency.
// an inconsistent API, and the client code it produces
[{ "id": 1 }] // list
{ "error": "not found" } // error
{ "items": [], "total": 0 } // list, again, different shape
// a consistent one
{ "data": [ ... ], "meta": { ... } }
{ "error": { "code": "...", "message": "..." }, "meta": { "request_id": "..." } }Versioning and backward compatibility
| Change | Breaking? | Do it how |
|---|---|---|
| Add an optional field | No | Just add it |
| Add a required field to a request | Yes | Accept it as optional first |
| Rename a field | Yes | Add the new one, deprecate the old, remove later |
| Remove a field | Yes | Deprecate, announce, then remove |
| Change a type | Yes | Add a new field with a new name |
| Change a value's meaning | Yes | Always - treat as a new field |
| Change an enum by adding a value | Sometimes | Clients must tolerate unknown values |
- Additive changes are the only free ones. Everything else needs a period where both shapes work.
- Never repurpose a field. A field that means one thing in v1 and another in v2 will be misread by an old client that is still deployed.
- Instruct clients to ignore unknown fields. A strict client that rejects new fields makes every addition a breaking change.
- Version the API where it is visible: a path segment, a header, or a media type. A version key inside the payload is the hardest to route on.
Deprecation sequence for renaming total_minor to amount_minor:
1. Return both fields. Document total_minor as deprecated with a removal date.
2. Add a response header pointing at the migration note.
3. Emit a metric per client that still reads the old field.
4. After the date, and once the metric is zero, remove it.FAQ
Should I wrap responses in a data envelope?
Yes for anything that will grow: it gives you somewhere to put pagination, metadata and warnings without a breaking change. The cost is one extra level of navigation in every client, which is worth paying once rather than renegotiating the top-level shape later.
How long should a deprecated field stay?
Long enough for the slowest consumer to migrate, which you should measure rather than assume. Publish a date, instrument who still uses the field, and remove it only when that number is zero - an announced deadline that slips is less damaging than an unannounced removal.
Related
JSON in HTTP APIs Validating with JSON Schema
Last refreshed 2026-09-18.