JSON API Best Practices for Developers

Design better JSON APIs with consistent naming, structured responses, proper pagination, error handling, and performance optimizations used by top engineering teams.

JSONTech TeamFebruary 1, 202510 min read

Why API Design Matters

A well-designed JSON API is the difference between an integration that takes an afternoon and one that takes a week. Good API design reduces support tickets, makes your documentation shorter, and lets consumers predict how things work before reading the docs.

The patterns in this guide are not theoretical — they are drawn from APIs at Stripe, GitHub, Slack, and other companies known for excellent developer experience. Let us walk through each one.

Naming Conventions

The two dominant styles for JSON property names are camelCase and snake_case. Either is fine as long as you pick one and use it everywhere.

ConventionExampleUsed ByEcosystem
camelCasefirstName, createdAtGoogle APIs, AzureJavaScript / TypeScript frontends
snake_casefirst_name, created_atStripe, GitHub, SlackPython / Ruby backends

Guideline: Choose the convention that matches your primary consumer ecosystem. If your API is consumed mostly by JavaScript frontends, camelCase avoids friction. If your backend is Python or Ruby and the API is B2B, snake_case is idiomatic.

Whatever you choose, be absolutely consistent. Mixing user_name and firstName in the same response erodes trust faster than almost anything else.

Consistent Response Structure

Every response from your API should follow a predictable envelope. Here is a pattern that works for most APIs:

Success Response

{
  "data": {
    "id": "usr_abc123",
    "email": "alice@example.com",
    "name": "Alice Johnson",
    "created_at": "2025-01-15T09:30:00Z"
  },
  "meta": {
    "request_id": "req_7f2a9c"
  }
}

List Response

{
  "data": [
    { "id": "usr_abc123", "name": "Alice Johnson" },
    { "id": "usr_def456", "name": "Bob Smith" }
  ],
  "meta": {
    "total": 142,
    "page": 1,
    "per_page": 20,
    "request_id": "req_8b3d1e"
  }
}

Error Response

{
  "error": {
    "code": "validation_error",
    "message": "The request body contains invalid fields.",
    "details": [
      {
        "field": "email",
        "message": "Must be a valid email address."
      },
      {
        "field": "age",
        "message": "Must be at least 13."
      }
    ]
  },
  "meta": {
    "request_id": "req_4c8e2a"
  }
}

The key principle: data is present on success, error is present on failure, and meta appears in both for request tracing and pagination. Consumers can write a single response handler that checks for error first.

Try it yourself: Paste an API response into our JSON Formatter to inspect the structure and spot inconsistencies.

Pagination Patterns

Most list endpoints need pagination. There are three common approaches, each with different trade-offs:

1. Offset Pagination

The simplest approach. The client specifies a page number or offset:

GET /api/users?page=3&per_page=20

{
  "data": [...],
  "meta": {
    "total": 142,
    "page": 3,
    "per_page": 20,
    "total_pages": 8
  }
}

2. Cursor Pagination

Instead of page numbers, the server returns an opaque cursor that points to the next set of results:

GET /api/users?limit=20&after=cursor_abc123

{
  "data": [...],
  "meta": {
    "has_more": true,
    "next_cursor": "cursor_def456"
  }
}

3. Keyset Pagination

Uses a real column value (like a timestamp or ID) as the cursor. This is cursor pagination without the opacity:

GET /api/users?limit=20&created_after=2025-01-15T09:30:00Z

{
  "data": [...],
  "meta": {
    "has_more": true,
    "next_created_after": "2025-01-16T14:22:00Z"
  }
}

Pagination Comparison

AspectOffsetCursorKeyset
Jump to page NYesNoNo
Total countEasy to includeExpensiveExpensive
Performance on large tablesDegrades (OFFSET scans rows)ConsistentConsistent
Handles inserts/deletesCan skip or duplicate itemsStableStable
Implementation complexityLowMediumMedium
Used byTraditional web appsStripe, SlackTwitter / X

Guideline: Use offset pagination for admin dashboards and internal tools where users need to jump to specific pages. Use cursor or keyset pagination for public APIs, infinite scroll UIs, and any dataset that changes frequently.

Error Handling

Good error responses tell the consumer what went wrong, where, and ideally how to fix it. Here is the structure we recommend:

{
  "error": {
    "code": "rate_limit_exceeded",
    "message": "You have exceeded the rate limit of 100 requests per minute.",
    "details": [],
    "doc_url": "https://docs.example.com/errors/rate-limit"
  }
}

The code is a machine-readable string (not an HTTP status code — that goes in the actual HTTP response). The message is human-readable. The details array provides field-level errors for validation failures. And doc_url links to documentation about the error.

Common error codes and their HTTP status mappings:

HTTP StatusError CodeWhen to Use
400validation_errorRequest body fails schema validation
401unauthorizedMissing or invalid authentication
403forbiddenAuthenticated but lacks permission
404not_foundResource does not exist
409conflictDuplicate resource or state conflict
422unprocessable_entityValid JSON but semantically wrong
429rate_limit_exceededToo many requests
500internal_errorUnexpected server failure

Data Types and Formatting

Getting data types right prevents an entire category of integration bugs:

Dates and Times

Always use ISO 8601 format with timezone information:

{
  "created_at": "2025-01-15T09:30:00Z",
  "expires_at": "2025-02-15T23:59:59+05:30"
}

Never use Unix timestamps in response bodies — they are not human-readable and create timezone ambiguity. If you need Unix timestamps (e.g., for JWT claims), document it clearly.

Large Numbers

JavaScript's Number type loses precision beyond 2^53 - 1 (9,007,199,254,740,991). If your IDs or monetary values exceed this, use strings:

{
  "id": "9223372036854775807",
  "amount": "99.99",
  "currency": "USD"
}

Stripe, Twitter, and Discord all use string IDs for this exact reason.

Null vs. Missing

Define a clear convention and document it:

  • null — The field exists but has no value (e.g., the user has not set a bio yet).
  • Missing key — The field is not relevant to this resource or was not requested (e.g., in a partial response).

Many APIs include null fields to maintain a consistent shape, which makes it easier for clients to write typed interfaces without optional chaining everywhere.

Booleans

Use real JSON booleans, not strings or integers:

// Good
{ "is_active": true }

// Bad
{ "is_active": "true" }
{ "is_active": 1 }

Versioning Strategies

APIs evolve. You need a strategy for making changes without breaking existing consumers. The three main approaches:

  • URL versioning: /v1/users, /v2/users. The most common and most visible approach. Used by Stripe, GitHub, and Twilio. Easy to understand, easy to route, but can lead to code duplication.
  • Header versioning: Accept: application/vnd.api+json;version=2. Cleaner URLs but less discoverable. Used by GitHub (as an alternative) and Azure.
  • Query parameter: /users?version=2. Simple but pollutes the query string. Less common in practice.

Guideline: URL versioning is the pragmatic default. It is explicit, cacheable, and works with every HTTP client. Only deviate if you have a specific technical reason.

Compression

JSON is text and compresses exceptionally well. Always enable compression for API responses:

AlgorithmCompression RatioSpeedBrowser Support
gzipGood (70–80% reduction)FastUniversal
Brotli (br)Better (75–85% reduction)Slightly slower to compressAll modern browsers

For a typical JSON response of 50KB, gzip brings it down to about 10–15KB, and Brotli to about 8–12KB. The server should respect the Accept-Encoding header and choose accordingly. Most reverse proxies (Nginx, Cloudflare) handle this automatically.

Performance Tips

Partial Responses (Field Selection)

Let consumers request only the fields they need. This reduces payload size and database load:

GET /api/users?fields=id,name,email

{
  "data": [
    { "id": "usr_abc123", "name": "Alice", "email": "alice@example.com" }
  ]
}

Google APIs call this "partial responses" and support it across all endpoints. It is especially valuable for mobile clients on slow connections.

Sparse Fieldsets

For APIs that return related resources, let consumers control which fields are included for each resource type:

GET /api/articles?include=author&fields[article]=title,body&fields[author]=name

This is a pattern from the JSON:API specification. It eliminates the N+1 query problem on the client while keeping payloads minimal.

ETags and Conditional Requests

Return an ETag header with responses. Clients can send If-None-Match on subsequent requests, and you return 304 Not Modified (empty body) if nothing changed. This saves bandwidth and processing time for resources that do not change often.

Summary Checklist

Before shipping your API, run through this checklist:

  • Consistent naming convention (camelCase or snake_case) across all endpoints.
  • Predictable response envelope with data, error, and meta.
  • Pagination on all list endpoints with documented limits.
  • Structured error responses with machine-readable codes, messages, and field-level details.
  • ISO 8601 dates, string IDs for large numbers, real booleans.
  • API versioning strategy documented and enforced.
  • gzip or Brotli compression enabled.
  • Field selection or sparse fieldsets for performance-sensitive consumers.

Try it yourself: Format and validate your API responses with our JSON Formatter to ensure they follow a clean, consistent structure.

Related Tools