JSON APIs
Headers, status codes, envelopes, errors and pagination — the conventions that make a JSON API predictable to consume.
Two Different Things Called "JSON API"
Worth separating before anything else. A JSON API, lowercase, is any web service that exchanges JSON — which is most of them. JSON:API, the specification at jsonapi.org, is a formal standard prescribing exact structures for resources, relationships, sparse fieldsets and pagination.
The specification is thorough and worth adopting if you want a well-trodden set of decisions made for you. Most teams do not use it, and build a conventional REST API that happens to speak JSON. This page is about the latter.
Get the Headers Right
Content-Type: application/jsonThat is the registered media type. text/json and application/x-json are not registered, and some clients will refuse to parse a response labelled with them. The charset parameter is redundant — RFC 8259 requires JSON to be UTF-8 — though including it causes no harm.
Set the header before writing any body content. In PHP especially, a warning or a stray newline emitted before your headers means the header is never sent and the warning text is prepended to your JSON — producing a parse error on the client that looks nothing like its actual cause.
Two more worth enabling. Content-Encoding: gzip matters far more than minification does — as measured on the minifier page, compression took a sample payload from 13.5 KB to under 900 bytes, a reduction stripping whitespace cannot approach. And X-Content-Type-Options: nosniff stops browsers from second-guessing the type you declared.
Use Real Status Codes
The most common JSON API design mistake is returning 200 OK with an error described in the body:
HTTP/1.1 200 OK
{ "success": false, "message": "User not found" }This breaks everything in the chain that reads status codes rather than bodies: HTTP caches, retry middleware, load balancer health checks, error-rate dashboards, and the response.ok check in every fetch client. All of them see success. Use the status code the situation actually warrants:
| Status | When |
|---|---|
| 200 / 201 | Success; 201 when a resource was created |
| 204 | Success with no body — do not send null |
| 400 | Malformed request or failed validation |
| 401 / 403 | Not authenticated / authenticated but not permitted |
| 404 | Resource does not exist |
| 422 | Well-formed but semantically invalid |
| 500 | Your fault, not the client's |
Envelope or Bare Resource
// Bare
{ "id": 101, "name": "Alice" }
// Enveloped
{
"data": { "id": 101, "name": "Alice" },
"meta": { "requestId": "abc-123" }
}Both are defensible. The envelope's advantage is room to grow — you can add pagination, warnings or a request ID later without changing the response type, which would otherwise be a breaking change for every client. That argument is strongest for collection endpoints, where pagination metadata is almost always needed eventually.
Whichever you choose, be consistent across every endpoint. An API where some routes return bare objects and others return {"data": …} forces consumers to remember which is which, and that inconsistency is worse than either choice.
For collections, prefer {"data": [...]} over a bare top-level array, for the growth reason above and the historical security note covered on the arrays page.
Error Responses
Errors must be JSON too. A client that parsed a JSON success response and then receives an HTML error page produces Unexpected token < in JSON at position 0 — the most common and least informative failure in web development.
RFC 9457 defines a standard shape for this, application/problem+json:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://example.com/errors/validation",
"title": "Validation failed",
"status": 422,
"detail": "The request contained 2 invalid fields.",
"errors": [
{ "field": "email", "message": "must be a valid email address" },
{ "field": "age", "message": "must be at least 0" }
]
}Even without adopting the standard, the important properties are the same: a stable machine-readable code the client can branch on, a human-readable message for logs, and field-level detail for validation failures. "Validation failed" alone forces the client to guess which field was wrong. A schema validator gives you that per-field detail for free.
Do not leak stack traces, SQL fragments or internal hostnames in error bodies. Log those server-side and return a correlation ID the client can quote to support.
Pagination
// Offset — simple, fine for small stable datasets
{
"data": [ … ],
"meta": { "page": 2, "perPage": 50, "total": 1284 }
}
// Cursor — stable under concurrent inserts
{
"data": [ … ],
"meta": { "nextCursor": "eyJpZCI6MTAyfQ", "hasMore": true }
}Offset pagination has a real correctness problem on active data. If a record is inserted while a client is paging, everything shifts by one — so a record that was going to appear at the top of page 2 moves to the bottom of page 1, and the client never sees it. Cursor pagination anchors to a position in the data rather than a count, which eliminates both skips and duplicates.
Return a total only when you can compute it cheaply. On a large table, COUNT(*) with filters is frequently more expensive than fetching the page itself.
Debugging a Failed Response
When a client reports invalid JSON, look at the raw response before looking at your serialiser. In order:
- Check the status code. A 500 or 404 usually means you are parsing an error page, not JSON.
- Check the Content-Type. If it says
text/html, the body is a page and your handler never ran. - Look at the first bytes.
<means HTML. An invisible byte-order mark also breaks parsing at position 0. - Check for output before the body. A warning, a debug print or whitespace outside a PHP closing tag will corrupt an otherwise valid response.
- Paste the body into a validator to get the exact line and column, if it really is a JSON problem.
# See headers and body together
curl -i https://api.example.com/users
# Headers only
curl -sI https://api.example.com/users
# Pretty print a successful response
curl -s https://api.example.com/users | jqFrequently Asked Questions
What Content-Type should a JSON API use?
application/json. The charset parameter is unnecessary because RFC 8259 requires JSON to be UTF-8, though adding it does no harm. Do not use text/json or application/x-json — neither is registered and some clients will refuse to parse them.
Should errors be returned as JSON too?
Yes. A client that has just parsed a JSON success response should not suddenly receive HTML on failure — that is the single most common cause of 'Unexpected token < in JSON'. Return a JSON body with a non-2xx status, and consider the RFC 9457 problem-details format.
What is JSON:API?
A specific specification at jsonapi.org that prescribes exact conventions for resource objects, relationships, includes and pagination. It is distinct from the general phrase 'a JSON API', which simply means any API exchanging JSON. Most APIs are the latter.
Should I return 200 with an error field, or a real error status?
Use the real HTTP status. Returning 200 with {"success": false} defeats caching, monitoring, retry logic and every generic HTTP tool in the chain, all of which read the status code. Reserve 4xx for client mistakes and 5xx for your own failures.
How should a JSON API handle pagination?
Offset pagination (page and per_page) is simple and fine for small, stable datasets. Cursor pagination is more robust for large or frequently changing collections, because inserts do not cause records to be skipped or repeated across pages. Return total counts only if you can compute them cheaply.