JSON API Design Best Practices (2026)

Published

Designing a JSON REST API that is consistent, predictable, and easy for clients to consume requires deliberate decisions about naming, error handling, pagination, versioning, and more. This guide covers the patterns adopted by top API teams at Google, Stripe, GitHub, and Twilio.

Naming Conventions: camelCase vs snake_case

The most common API design debate is whether to use camelCase (firstName) or snake_case (first_name) for JSON field names. Both are widely used, and the right answer depends on your primary consumers and technology stack.

camelCase is the dominant convention in JavaScript ecosystems. When your API response is consumed directly by a JavaScript or TypeScript frontend, camelCase fields map to object properties without any transformation. Google's JSON Style Guide, the GitHub API v4 (GraphQL), and most Node.js frameworks use camelCase.

snake_case is the Python and Ruby community standard. The Twitter API v1, many Django REST Framework APIs, and PostgreSQL column names naturally use snake_case. If your primary consumers are Python data scientists or Ruby on Rails servers, snake_case reduces friction.

The single rule that overrides everything else: be consistent. Do not mix user_id and firstName in the same response. Pick one convention, document it in your API style guide, and enforce it with a linter like Spectral.

// camelCase (preferred for JavaScript/TypeScript consumers)
{
  "userId": 42,
  "firstName": "Alice",
  "lastName": "Chen",
  "createdAt": "2026-03-26T10:00:00Z",
  "isActive": true
}

// snake_case (preferred for Python/Ruby consumers)
{
  "user_id": 42,
  "first_name": "Alice",
  "last_name": "Chen",
  "created_at": "2026-03-26T10:00:00Z",
  "is_active": true
}

Additional naming rules from the Google JSON Style Guide: use meaningful, descriptive names; avoid abbreviations unless universally understood (e.g. id, url); use plural nouns for arrays (users, not user); prefix booleans with is, has, or can to make their type obvious at a glance.

Error Response Format

Inconsistent error formats are one of the most frustrating API design problems for client developers. If your success responses are JSON but your error responses are plain text or HTML, clients must write special-case code for every error path. Define a single, consistent error schema and use it everywhere.

RFC 7807 (Problem Details for HTTP APIs) defines a standard JSON error format that is increasingly adopted across the industry. It specifies five standard fields: type (a URI identifying the error class), title (short human-readable summary), status (HTTP status code), detail (longer human-readable explanation), and instance (a URI for this specific occurrence). You can extend it with custom fields.

// RFC 7807 Problem Details error response
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type": "https://jsonwebtools.com/errors/validation-error",
  "title": "Validation Error",
  "status": 422,
  "detail": "The request body contains invalid fields.",
  "instance": "/api/users/create#2026-03-26T10:00:00Z",
  "errors": [
    {
      "field": "email",
      "code": "invalid_format",
      "message": "Must be a valid email address."
    },
    {
      "field": "age",
      "code": "out_of_range",
      "message": "Must be between 0 and 150."
    }
  ]
}

Always include a machine-readable code string alongside the human-readable message. Clients can switch on code to show localised messages or trigger specific UI behaviour, while the message field is used for logging and debugging. Never embed the only error description inside an HTTP reason phrase — those are not reliably forwarded by all proxies.

Return the Content-Type: application/problem+json header (or application/json at minimum) on error responses, not text/html. The Stripe API and GitHub API both follow this pattern, making their error responses easy to handle programmatically.

Pagination Strategies

Any endpoint that returns a list of resources must support pagination. Returning all records in a single response is acceptable only for small, static datasets. For anything else, you need to paginate — both to protect server resources and to give clients a manageable amount of data to process.

Offset-based pagination uses page and per_page (or limit and offset) query parameters. It is easy to implement and allows clients to jump to any page. The downside is "page drift": if records are inserted or deleted between requests, the same record can appear on two pages or be skipped entirely. It also becomes slow on large datasets because the database must scan all preceding rows.

Cursor-based pagination uses an opaque token (the cursor) returned by the server. The client passes the cursor as a query parameter to get the next page. The cursor typically encodes the ID or timestamp of the last returned item. This approach is stable against insertions and deletions and remains fast even on very large tables (backed by an index seek rather than an offset scan). The GitHub REST API and Stripe API use cursor-based pagination for all list endpoints.

// Cursor-based pagination response envelope
{
  "data": [
    { "id": "usr_101", "name": "Alice" },
    { "id": "usr_102", "name": "Bob" },
    { "id": "usr_103", "name": "Carol" }
  ],
  "pagination": {
    "total": 8420,
    "count": 3,
    "per_page": 3,
    "has_more": true,
    "next_cursor": "eyJpZCI6InVzcl8xMDMifQ==",
    "prev_cursor": null
  },
  "links": {
    "self": "https://api.example.com/users?limit=3",
    "next": "https://api.example.com/users?limit=3&cursor=eyJpZCI6InVzcl8xMDMifQ=="
  }
}

Always include a has_more boolean or equivalent so clients do not need to compare count to per_page to determine if another page exists. Include a total count where it is inexpensive to compute; omit it when running COUNT(*) on a large table would make every list request significantly slower.

API Versioning

APIs change over time. Fields get renamed, endpoints restructured, and new requirements emerge. Without a versioning strategy, every breaking change breaks existing client integrations. The two dominant approaches are URL-path versioning and header-based versioning.

URL-path versioning embeds the version in the URL: https://api.example.com/v1/users. This is the most visible and easy-to-test approach — you can open a versioned URL in a browser, share it in documentation, and easily route different versions to different server code. The GitHub REST API, Stripe API, and Twilio all use URL versioning. It is the recommended approach for public APIs.

Header versioning uses a custom header such as API-Version: 2026-03-26 or Accept: application/vnd.api+json;version=2. Stripe also supports date-based header versioning as an overlay on top of their URL version. This keeps URLs clean but makes the API harder to test in a browser and harder to cache.

Whichever approach you choose, adopt a compatibility promise: define what counts as a breaking change (removing a field, changing a type, making a previously optional field required) versus a non-breaking change (adding a new optional field, adding a new endpoint). Document your deprecation policy — how long old versions will be maintained and how clients will be notified of upcoming sunsets.

Comparison of Versioning Approaches

Approach Example Pros Cons
URL path /v1/users Visible, cacheable, easy to test Pollutes URL space
Custom header API-Version: 2 Clean URLs Hard to test in browser, harder to cache
Accept header Accept: application/vnd.api+json;v=2 RESTful, uses HTTP semantics Complex to implement and document
Query parameter ?version=2 Simple to add temporarily Easily forgotten, pollutes query strings

Null Handling and Optional Fields

One of the most underappreciated decisions in API design is how to represent the absence of a value. There are three options: return null, omit the field entirely, or use an empty string. Each has different implications for clients.

Return null explicitly when a field is part of the resource schema but currently has no value. This is the safest option for clients: they know the field exists, they know it has no value, and they can write a single null-check rather than both an existence check and a null-check. This is the approach used by the JSON:API specification.

Omit the field only when the field is genuinely not applicable to this resource instance — not when it simply has no value. For example, a businessName field might be omitted from personal user accounts entirely, because the concept does not apply. But if businessName applies to all accounts and this user simply has not set it yet, return "businessName": null.

Never use an empty string ("") to represent "no value" for a field that is conceptually nullable. This conflates two distinct states — "no value" and "explicitly set to empty" — and forces clients to treat "" and null as equivalent, which is error-prone.

// Good: null for "no value", field present in schema
{
  "id": "usr_42",
  "email": "alice@example.com",
  "phoneNumber": null,
  "avatarUrl": null,
  "deletedAt": null
}

// Bad: omitting fields that are null — forces client to handle both missing and null
{
  "id": "usr_42",
  "email": "alice@example.com"
  // phoneNumber, avatarUrl, deletedAt not present at all
}

// Also bad: empty string instead of null
{
  "id": "usr_42",
  "email": "alice@example.com",
  "phoneNumber": "",
  "avatarUrl": ""
}

Date and Time Formats (ISO 8601)

JSON has no native date type — dates are always represented as strings. This creates room for dangerous inconsistency. Establishing a single date/time convention across your entire API is essential.

Use ISO 8601 exclusively. The format YYYY-MM-DDTHH:mm:ssZ is the universally recognised standard for timestamps. The Z suffix means UTC. When events happen in a specific timezone, include the offset: 2026-03-26T14:30:00+05:30. Store and return all timestamps in UTC internally, and let clients convert to local time.

For date-only fields (birthdays, scheduled dates with no time component), use YYYY-MM-DD. For durations, use ISO 8601 duration format: P1Y2M3DT4H5M6S. Never use Unix timestamps as the primary format — they are not human-readable and make debugging significantly harder. You can include them as supplementary fields alongside the ISO string if a consumer specifically needs them.

The full specification for date/time interchange is defined in RFC 3339, which is a profile of ISO 8601 specifically designed for internet protocols. The MDN documentation on the JavaScript Date object also describes ISO 8601 parsing behaviour in browsers.

Response Envelope Patterns and HATEOAS

A response envelope wraps your actual data in a consistent outer structure that carries metadata such as pagination info, request ID, and status. Envelopes improve discoverability and make it easier to extend responses in the future without breaking existing clients.

A minimal envelope pattern places the main payload in a data key at the top level. Errors go in an errors array. Metadata (pagination, rate limit info, request tracing) goes in a meta key. This is the approach formalised by the JSON:API specification, which is also supported by tools like the Ember.js data layer.

HATEOAS (Hypermedia as the Engine of Application State) extends this by embedding links to related resources and available actions directly in the response. A HATEOAS-compliant response includes a links object that tells clients what they can do next — follow pagination, access a related resource, or take an available action — without the client needing to construct URLs itself. This is the highest level of the Richardson Maturity Model for REST APIs and the approach recommended by the MDN HTTP documentation for truly RESTful APIs.

// Response envelope with HATEOAS links
{
  "data": {
    "id": "order_789",
    "status": "pending",
    "total": 49.99,
    "currency": "USD",
    "createdAt": "2026-03-26T09:15:00Z"
  },
  "links": {
    "self": "https://api.example.com/v1/orders/order_789",
    "cancel": "https://api.example.com/v1/orders/order_789/cancel",
    "pay": "https://api.example.com/v1/orders/order_789/payment",
    "customer": "https://api.example.com/v1/customers/cust_42"
  },
  "meta": {
    "requestId": "req_abc123",
    "responseTime": 34
  }
}

Not every API needs full HATEOAS compliance — it adds complexity. But including at minimum a self link in every response and next/prev links in paginated list responses gives clients enough navigability to avoid hard-coding URLs. The JSON format specification itself (RFC 8259) does not prescribe envelope patterns, so this is a design choice you make above the format level.

Frequently Asked Questions

Should JSON API keys use camelCase or snake_case?

Both are valid, but camelCase (firstName) is preferred in JavaScript and TypeScript ecosystems because it maps directly to object properties without transformation. snake_case (first_name) is the convention in Python and Ruby communities. The critical rule is consistency: pick one and enforce it across your entire API with a linter like Spectral. Mixing styles in the same API is the worst outcome.

What is the best format for dates in a JSON API?

Use ISO 8601 format for all dates and timestamps. For timestamps, use YYYY-MM-DDTHH:mm:ssZ (UTC) or include a timezone offset like +05:30. For date-only fields, use YYYY-MM-DD. Never use locale-specific formats like MM/DD/YYYY or ambiguous formats like DD-MM-YYYY. Avoid Unix timestamps as the primary format — they are not human-readable and make debugging harder.

How should a JSON API return errors?

Return a consistent JSON error object for every error response. At minimum include a machine-readable code string, a human-readable message, and the HTTP status code. Consider adopting RFC 7807 (Problem Details) for a standardised schema. Always set Content-Type: application/json (or application/problem+json) on error responses — never return HTML error pages from an API endpoint.

How do you paginate a JSON REST API?

For most APIs, cursor-based pagination is the better choice. It returns an opaque token (next_cursor) that the client passes to get the next page. Unlike offset pagination, it remains accurate when records are added or deleted between requests, and it stays performant on large tables because it uses an index seek rather than an offset scan. Include has_more and total in the pagination metadata to help clients display progress indicators.

Should you use null or omit fields that have no value?

Prefer explicit null over omitting the field. When a field is omitted, clients cannot distinguish "no value" from "this field does not exist in the schema". Returning null makes your schema predictable — clients write a single null-check rather than both an existence check and a null-check. Only omit a field when it is genuinely not applicable to the resource type, not merely when its value is absent.

Validate Your API JSON

Paste any JSON API response and validate it instantly. Check structure, spot errors, and format output. Free, private, no account needed.

JSON Validator JSON Formatter Schema Validator

Also useful: JSON Performance Guide | JSON vs YAML | JSON Schema Tutorial | What is JSON? | JSONPath Tutorial