A well-designed JSON API is a joy to consume. A poorly designed one creates bugs, confusion, and endless Slack threads asking "what does this field actually mean?" The decisions you make about naming, error formats, pagination, and date handling ripple through every client that ever calls your API. This covers the patterns that separate maintainable APIs from painful ones, with concrete JSON examples for each.
1. Field Naming Conventions
Pick snake_case or camelCase and be consistent across every endpoint. The worst choice is mixing them.
// snake_case (most common in Python/Ruby APIs)
{
"user_id": 42,
"first_name": "Alice",
"last_login_at": "2025-05-01T10:30:00Z",
"is_active": true
}
// camelCase (most common in JavaScript/Node APIs)
{
"userId": 42,
"firstName": "Alice",
"lastLoginAt": "2025-05-01T10:30:00Z",
"isActive": true
}
Both are fine. The critical rule: never mix. A response body with user_id alongside firstName is a maintenance nightmare.
Naming rules that hold regardless of case style:
- Plural nouns for collections:
users,orders,line_items - Boolean fields with
is/has/canprefix:is_active,has_children,can_edit - No abbreviations:
descriptionnotdesc,quantitynotqty - IDs as strings if they exceed 53 bits — JavaScript's
Numbercan't safely represent integers above 2⁵³ − 1:
// IDs from distributed systems (Snowflake IDs, etc.) should be strings
{
"id": "7823648237462834762", // string — safe in all languages
"order_number": 10045 // small integer — fine as number
}
2. Response Structure
Wrap your response in a consistent envelope. Clients should be able to predict the shape of every response without reading the documentation.
// Single resource response
{
"data": {
"id": "usr_42",
"name": "Alice",
"email": "alice@example.com",
"created_at": "2024-01-15T08:00:00Z"
}
}
// Collection response
{
"data": [
{ "id": "usr_42", "name": "Alice" },
{ "id": "usr_43", "name": "Bob" }
],
"meta": {
"total": 248,
"page": 1,
"per_page": 20
}
}
The data wrapper lets you add meta, links, or other top-level fields later without breaking existing clients:
// BAD: bare array — can never add metadata without breaking clients
[
{ "id": 1, "name": "Alice" },
{ "id": 2, "name": "Bob" }
]
// GOOD: wrapped — extensible
{
"data": [
{ "id": 1, "name": "Alice" },
{ "id": 2, "name": "Bob" }
]
}
3. Error Response Format
Error responses are where the most inconsistency lives. Pick a format and enforce it via middleware so every error — including 500s — returns the same shape.
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Request body validation failed",
"details": [
{ "field": "email", "message": "Must be a valid email address", "value": "not-an-email" },
{ "field": "age", "message": "Must be a positive integer", "value": -5 }
],
"request_id": "req_8f3k2j9s"
}
}
-
code— machine-readable string constant clients can switch on. Never change these once published. -
message— human-readable, can change. For debugging, not UI display. -
details— array of field-level errors for validation failures. -
request_id— correlates the error to a server log entry. Invaluable for support.
| HTTP Status | Error Code | When to Use |
|---|---|---|
| 400 | VALIDATION_FAILED |
Request body or query params failed validation |
| 401 | UNAUTHORIZED |
No valid authentication credentials |
| 403 | FORBIDDEN |
Authenticated but not permitted |
| 404 | NOT_FOUND |
Resource does not exist |
| 409 | CONFLICT |
Optimistic lock failure, duplicate key |
| 422 | UNPROCESSABLE |
Syntactically valid but semantically wrong |
| 429 | RATE_LIMITED |
Too many requests |
| 500 | INTERNAL_ERROR |
Unexpected server error |
Always pair the HTTP status code with a machine-readable code field. The status tells the client the category; the code tells it the specific cause.
4. Pagination
Three patterns exist, each with tradeoffs.
// Offset pagination — Request: GET /users?page=3&per_page=20
{
"data": [ ],
"meta": { "total": 248, "page": 3, "per_page": 20, "total_pages": 13 },
"links": {
"first": "/users?page=1&per_page=20",
"prev": "/users?page=2&per_page=20",
"next": "/users?page=4&per_page=20",
"last": "/users?page=13&per_page=20"
}
}
Pros: can jump to any page, easy to implement. Cons: skips or duplicates records if the dataset changes mid-pagination; slow on large tables (SQL OFFSET 10000 scans 10,000 rows).
// Cursor pagination — Request: GET /events?limit=20&after=cursor_abc123
{
"data": [ ],
"meta": { "has_next_page": true, "has_prev_page": true },
"links": {
"next": "/events?limit=20&after=cursor_xyz789",
"prev": "/events?limit=20&before=cursor_abc123"
}
}
Pros: stable — new inserts don't cause duplicates or skips; efficient — keyset pagination uses an index. Cons: can't jump to page 7; total count is expensive to compute.
Use cursor pagination for feeds, timelines, and any high-write collection. Use offset pagination for admin tables where users need to jump to specific pages. Always include next/prev links in the response so clients don't have to construct URLs themselves.
5. Dates and Times
Use ISO 8601 with UTC timezone for all timestamps. Full stop.
// GOOD
{
"created_at": "2025-05-01T10:30:00Z", // UTC
"scheduled_at": "2025-06-15T14:00:00+05:30", // with offset if timezone matters
"birth_date": "1990-07-22" // date-only when time is irrelevant
}
// BAD
{
"created_at": "May 1, 2025", // ambiguous, not machine-parseable
"created_at": 1746094200, // Unix timestamp — readable but not self-documenting
"created_at": "01/05/2025 10:30" // is that May 1 or January 5?
}
- Always include time zone —
2025-05-01T10:30:00without one is ambiguous. - Use
Zsuffix for UTC; use+HH:MMoffset only when local timezone is meaningful. - Use date-only format (
YYYY-MM-DD) when time of day genuinely doesn't matter. - Never use Unix timestamps as your primary format — they require documentation to interpret.
For durations, always encode the unit in the field name:
{
"timeout_seconds": 30,
"retry_interval_ms": 500,
"session_duration": "PT1H30M"
}
timeout: 30 is seconds? milliseconds? minutes? You'll get it wrong and break something.
6. Null vs Missing Fields
null and a missing field mean different things.
// Field present and null — the value exists, explicitly set to null
{ "name": "Alice", "middle_name": null }
// Field absent — you don't know or it doesn't apply
{ "name": "Alice" }
-
Include with
nullwhen the field is part of the resource schema but has no value — clients can rely on the key always being present. - Omit entirely for optional metadata that may not apply.
- Never use empty string as null — an empty string is a valid value (zero characters), not a sentinel for "no value."
7. Versioning
Plan for breaking changes from day one.
GET /v1/users
GET /v2/users
URL versioning is simple, explicit, and cacheable — the version is visible in every log entry. Header versioning (Accept: application/vnd.myapi.v2+json) keeps URLs clean but requires clients to set headers correctly.
| Breaking | Non-breaking (safe to ship) |
|---|---|
| Removing a field | Adding a new optional field |
| Renaming a field | Adding a new endpoint |
| Changing a field's type | Adding a new HTTP method to an endpoint |
| Changing error codes | Making a required field optional |
| Changing auth requirements | Adding new valid enum values (controversial) |
8. Anti-Patterns to Avoid
Using HTTP 200 for errors:
// BAD — client has to parse body to detect failure
HTTP 200 OK
{ "success": false, "error": "User not found" }
// GOOD — HTTP status communicates outcome
HTTP 404 Not Found
{ "error": { "code": "NOT_FOUND", "message": "User with id usr_999 does not exist" } }
Inconsistent ID types across endpoints:
// BAD
GET /users/42 → { "id": 42 }
GET /orders/42 → { "id": "ord_42" }
// GOOD — consistent format everywhere
GET /users/usr_42 → { "id": "usr_42" }
GET /orders/ord_42 → { "id": "ord_42" }
Encoding data in field names:
// BAD — field names change with data
{ "price_usd": 10.99, "price_eur": 9.50, "price_gbp": 8.75 }
// GOOD — structure encodes the variation
{ "prices": [
{ "currency": "USD", "amount": 10.99 },
{ "currency": "EUR", "amount": 9.50 },
{ "currency": "GBP", "amount": 8.75 }
] }
Deeply nested responses:
// BAD — three levels deep to reach the data
{ "response": { "body": { "result": { "users": [] } } } }
// GOOD — data at predictable depth
{ "data": [] }
Exposing internal implementation details:
// BAD — reveals DB column names, ORM internals
{ "tbl_usr_id": 42, "__v": 0, "_id": "507f1f77bcf86cd799439011" }
// GOOD — API shape is independent of storage
{ "id": "usr_42", "created_at": "2025-05-01T10:30:00Z" }
Summary
| Concern | Recommendation |
|---|---|
| Field naming |
snake_case or camelCase — pick one, enforce it everywhere |
| Response shape | Wrap in data key; never bare arrays at top level |
| Error format | Consistent envelope: code, message, details, request_id
|
| HTTP status codes | Use them correctly — 200 means success |
| Pagination | Cursor for feeds; offset for admin; always include links |
| Dates | ISO 8601 with UTC (Z suffix), always |
| Null handling |
null = known absence; omit = not applicable |
| IDs | Strings for large IDs; consistent prefix per resource type |
| Versioning | URL versioning for simplicity; plan for breaking changes |
Well-designed JSON APIs aren't just easier to consume — they're easier to maintain. When the response shape is consistent, clients are more resilient, debugging is faster, and adding new features doesn't require coordinating SDK changes across every consumer.
If you're debugging an API response right now, JSON Formatter Hub formats, validates, and lets you explore it in a tree/table view — entirely in your browser.
Top comments (0)