Skip to content

JSON Best Practices for REST APIs

Naming, dates, IDs, error envelopes, pagination, versioning — the JSON design decisions that age well, plus the ones that don't.

REST APIs that ship JSON have decades of accumulated convention. Following the conventions buys you compatibility with every client library, every code generator, and every developer's existing mental model. Deviating costs you support tickets forever. This guide is the opinionated playbook: the conventions that age well, with the rationale for each.

Consistent naming

Pick one casing and use it everywhere. The two reasonable choices:

  • camelCase — JavaScript-native. Idiomatic for APIs serving web and mobile clients. The dominant choice for new APIs.
  • snake_case — Python and Ruby-native. Common in older APIs (Stripe, GitHub) and in data-engineering contexts.

kebab-case and PascalCase show up occasionally but cause friction in every language with . member access. Avoid.

The rule is consistency, not which choice. Mixing — userId next to created_at — is the worst option; pick one and convert at the edges if your internals use a different convention.

Response shapes

Three reasonable top-level shapes for a "list resources" endpoint:

Bare arrayGET /users returns [{...}, {...}]. Simplest. Can't add metadata (pagination, total count) without a breaking change. Only use for endpoints that will never paginate.

Envelope objectGET /users returns { "data": [...], "meta": {...} }. Lets you add metadata without breaking clients. The default choice for new APIs.

{
  "data": [
    { "id": "user_1", "name": "Ada" },
    { "id": "user_2", "name": "Alan" }
  ],
  "meta": {
    "page": 1,
    "pageSize": 20,
    "total": 142
  }
}

JSON:API{"data": [...], "included": [...], "links": {...}}. Heavyweight but spec'd; gives you free relationship handling. Best when your domain is genuinely graph-shaped and clients benefit from the shared spec.

Don't invent novel wrappers — pick from the three.

Errors: RFC 7807

For errors, use RFC 7807 Problem Details. It's the standard error format with a Content-Type: application/problem+json.

{
  "type": "https://api.example.com/errors/insufficient-funds",
  "title": "Insufficient funds",
  "status": 402,
  "detail": "Your account balance is $5; the transaction requires $10.",
  "instance": "/accounts/acct_123/transactions/txn_456",
  "balance": 5,
  "required": 10
}

Five fields:

  • type — URI that identifies the error class (clients dispatch on this).
  • title — short human-readable summary.
  • status — HTTP status code (mirrors the response).
  • detail — long human-readable explanation.
  • instance — URI of the specific occurrence (useful for support).

Plus any extension fields specific to the error class. Validation errors typically include an errors array with field paths and messages.

Types: numbers, dates, money, IDs

The four type decisions that cause the most pain:

Numbers. JSON numbers are decimal text but JavaScript clients parse them to 64-bit floats. Anything beyond ±2^53 loses precision. Three classes of value that need string-encoding:

  • IDs"user_123" or "123456789012345" — strings. Even if your IDs are numeric, ship them as strings to avoid precision loss and reserve future flexibility.
  • Money — strings with explicit decimal places ("19.95") or an integer in the smallest unit (1995 for $19.95). Never floating point.
  • Large counters — strings.

Dates. ISO 8601 strings, with timezone. Always.

{
  "createdAt": "2026-05-13T10:30:00Z",
  "birthday": "1985-03-12"
}

Full timestamps in UTC (Z suffix). Date-only values without a time. Never Unix timestamps in seconds — they're ambiguous about precision, not human-readable, and clients have to know which field is a date.

Booleans. Just true and false. Don't use "yes"/"no" or 0/1.

Enums. Strings, lowercase, kebab- or snake-cased consistently with the rest of your API. "status": "in-progress", not "status": 2.

Nulls vs missing vs empty

Three distinct states that mean different things:

  • Missing ("middleName" not present) — "we don't have this data, or it doesn't apply."
  • Null ("middleName": null) — "we have asked, the user said nothing."
  • Empty string ("middleName": "") — usually a UI bug; the user typed nothing into a required field. Convert to null or reject at the API.

Pick one of "missing" or "null" for absence and stay consistent across the API. Mixing them ("sometimes the key is absent, sometimes it's null") is a constant source of client bugs. Most modern APIs always emit the key and use null for absence — easier for type generators, easier for TypeScript.

Pagination

Two patterns:

  • Page-based?page=2&pageSize=20. Easy to understand, exposes total count, but breaks when items are added/removed during paging.
  • Cursor-based?cursor=opaque-token. Stable under insertions and deletions; the right choice for any append-heavy resource (events, logs, messages).

Whichever you pick, return a next cursor or next page URL in the response so clients don't compute it themselves:

{
  "data": [...],
  "links": {
    "next": "https://api.example.com/events?cursor=eyJ0IjoxNzMxNTAwMDAwfQ"
  }
}

Versioning

The choice with the longest tail. Three real options:

  • URL/v1/users, /v2/users. Most visible; trivial to route; encourages big-bang migrations.
  • HeaderAccept: application/vnd.example.v2+json. Clean URLs; versioning is invisible to casual users.
  • Continuous — no version. Additive changes only (you can add fields and endpoints, never remove or rename). Best for first-party APIs you control on both ends.

Continuous works if you can enforce it. Otherwise URL versioning has the fewest surprises.

The rule that matters more than the format: adding a field is never a breaking change. Clients should ignore unknown fields. Removing or renaming a field is breaking and requires a version bump.

Transport: Content-Type, compression, minification

  • Content-Typeapplication/json for responses, problem+json for errors. Always include charset is unnecessary in 2026 — UTF-8 is the spec default.
  • Compression — gzip or brotli. Always, for anything over a few hundred bytes.
  • Minification — yes for responses, no for committed fixtures and config. See minify vs prettify JSON.
  • Content-Length or Transfer-Encoding: chunked — set one. For large responses, prefer streaming.

Documenting with JSON Schema and OpenAPI

A JSON contract you can hand to a tool beats prose documentation by an order of magnitude. Two viable paths:

  • JSON Schema per endpoint — write a schema for each request and response. Use it both for runtime validation (server side) and for type generation (client side). See JSON Schema for beginners.
  • OpenAPI — packages the schemas plus URL routes, auth schemes, and examples in one document. Drives every API client generator on the market (openapi-typescript, openapi-generator-cli).

For internal services where you control both ends, schema-driven types or OpenAPI both work. For public APIs, OpenAPI is the format the ecosystem expects.

Tooling

Next steps