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_id and customerId in the same payload forces every consumer to special-case something.
  • Spell out units: total_minor with a separate currency is unambiguous; a field called amount is 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.
DecisionMeaning of nullOr
null is a valueKnown to be absentAn explicit empty array or empty string
Field omittedNot applicable or not loadedRequires clients to handle a missing key
Empty array []Known to have noneBetter than null for collections
0A real measurement of zeroNever 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"
  }
}
  1. A stable machine-readable code is what clients branch on. The human-readable message may change or be translated at any time.
  2. Include a request id in every response so a support conversation can start from the actual call.
  3. Pagination: cursor-based for feeds that change (stable under inserts), offset-based for static reports where clients want a page number.
  4. Return has_more explicitly rather than making clients infer it from the page length - the two agree only until the page is exactly full.
  5. 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

ChangeBreaking?Do it how
Add an optional fieldNoJust add it
Add a required field to a requestYesAccept it as optional first
Rename a fieldYesAdd the new one, deprecate the old, remove later
Remove a fieldYesDeprecate, announce, then remove
Change a typeYesAdd a new field with a new name
Change a value's meaningYesAlways - treat as a new field
Change an enum by adding a valueSometimesClients 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.

JSON in HTTP APIs Validating with JSON Schema

Last refreshed 2026-09-18.