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
metalater is then a compatible change rather than a breaking one. - Use one name for one concept and keep it consistent:
ideverywhere, neverIdin one endpoint andIDin 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;| Symptom | Usual cause |
|---|---|
| Unexpected token < in JSON at position 0 | the server returned HTML, often a login page or an error page |
| Unexpected end of JSON input | empty or truncated body, or a 204 read as if it had content |
| Keys arrive as strings | unavoidable: JSON object keys are always strings |
| Large numbers come back rounded | values beyond 2^53; send them as strings |
| body already used | both res.text() and res.json() were called on one response |
| Converting circular structure to JSON | a 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.Related
parse, stringify, revivers and replacers HTTP status codes
Last refreshed 2026-09-18.