JSON in HTTP APIs

Response envelopes, a stable error shape, timestamps as strings, and the error messages you will meet most often.

Choosing a response shape

{
  "data": [
    { "id": 1, "title": "First", "dueAt": "2026-09-20T09:00:00Z" },
    { "id": 2, "title": "Second", "dueAt": null }
  ],
  "meta": { "page": 1, "perPage": 20, "total": 137 }
}
  • Wrap a collection in an object from day one: adding meta later is then a compatible change rather than a breaking one.
  • Use one name for one concept and keep it consistent: id everywhere, never Id in one endpoint and ID in another.
  • Prefer real booleans and nulls to the strings "true" and "null", which every client has to special-case.
  • Send timestamps as ISO 8601 UTC strings so every client parses them the same way.
  • Numbers beyond 2^53 lose precision in a JSON parser, so account numbers and snowflake ids belong in strings.

A stable error shape

{
  "error": {
    "code": "validation_failed",
    "message": "The request body is not valid.",
    "details": [
      { "field": "email", "issue": "must contain @" }
    ]
  }
}
💡
An error body needs both parts: a machine-readable code the client branches on and a human-readable message it can display. Clients must never branch on the message text, because wording changes without warning.

Practical pitfalls

const res = await fetch('/api/tasks', { headers: { Accept: 'application/json' } });

// a 200 does not mean the body is JSON
const type = res.headers.get('content-type') || '';
if (!type.includes('json')) {
  throw new Error('expected JSON, got ' + type);
}
const payload = await res.json();

// dates always arrive as strings and must be converted by the client
const dueAt = payload.data[0].dueAt ? new Date(payload.data[0].dueAt) : null;
SymptomUsual cause
Unexpected token < in JSON at position 0the server returned HTML, often a login page or an error page
Unexpected end of JSON inputempty or truncated body, or a 204 read as if it had content
Keys arrive as stringsunavoidable: JSON object keys are always strings
Large numbers come back roundedvalues beyond 2^53; send them as strings
body already usedboth res.text() and res.json() were called on one response
Converting circular structure to JSONa live object graph was stringified instead of a plain DTO
  • Log a truncated copy of the raw body when parsing fails; the first character is usually enough to identify an HTML error page.
  • Treat every field from a third-party API as optional and validate before you store it.
  • Keep a JSON Schema or a generated type next to the contract so both sides fail in the same place.

FAQ

Snake case or camel case keys?
Either works as long as the whole API agrees. What matters is that the server contract, the generated types and the client code use one convention with no per-field translation layer.
How do I paginate a large JSON collection?
Return an envelope with meta and either page numbers or an opaque cursor, and keep the page size limited by the server so a client cannot request a million rows in one response.

parse, stringify, revivers and replacers HTTP status codes

Last refreshed 2026-09-18.