JSON Web API Design: Naming, Pagination, Errors, and Versioning

Last updated:

A well-designed JSON API is a joy to consume: consistent field names, predictable response shapes, informative errors, and clear pagination. This guide covers the decisions every API designer faces, with concrete examples and the tradeoffs behind each choice.

Field Naming: camelCase vs snake_case

Pick one convention and enforce it everywhere. Mixed casing is the most common API consistency failure.

// camelCase — standard in JavaScript ecosystems (Stripe, GitHub, Twilio)
{
  "userId": "usr_123",
  "firstName": "Ada",
  "lastName": "Lovelace",
  "createdAt": "2024-01-15T09:00:00Z",
  "isEmailVerified": true,
  "billingAddress": {
    "streetLine1": "123 Main St",
    "postalCode": "10001"
  }
}

// snake_case — common in Python/Ruby ecosystems (Slack, Twitter/X)
{
  "user_id": "usr_123",
  "first_name": "Ada",
  "last_name": "Lovelace",
  "created_at": "2024-01-15T09:00:00Z",
  "is_email_verified": true,
  "billing_address": {
    "street_line_1": "123 Main St",
    "postal_code": "10001"
  }
}

Common industry choices: camelCase — Stripe, GitHub, Twilio, Google APIs; snake_case — Slack, Twitter/X, Shopify (historically). If your team is mostly JavaScript/TypeScript, camelCase aligns naturally with the language. If Python, snake_case is more ergonomic.

Response Shapes

Single Resource

// GET /v1/users/usr_123
// Return the resource directly — no extra wrapper for single resources
{
  "id": "usr_123",
  "email": "ada@example.com",
  "name": "Ada Lovelace",
  "createdAt": "2024-01-15T09:00:00Z",
  "plan": "pro"
}

List Response with Envelope

// GET /v1/users?limit=20&cursor=eyJpZCI6MTAwfQ
{
  "data": [
    { "id": "usr_123", "email": "ada@example.com", "name": "Ada Lovelace" },
    { "id": "usr_124", "email": "alan@example.com", "name": "Alan Turing" }
  ],
  "pagination": {
    "hasMore": true,
    "nextCursor": "eyJpZCI6MTI0fQ",
    "count": 2
  }
}

Pagination Patterns

Offset Pagination (simple, but fragile)

// GET /v1/orders?page=2&limit=20
{
  "data": [...],
  "pagination": {
    "page": 2,
    "limit": 20,
    "total": 143,
    "totalPages": 8
  }
}
// Problem: if a new order is inserted on page 1 while the client is reading page 2,
// the last item from page 1 shifts to page 2 → duplicate in next request

Cursor Pagination (stable, recommended)

// GET /v1/orders?limit=20
{
  "data": [...],
  "pagination": {
    "hasMore": true,
    "nextCursor": "eyJpZCI6MTAwLCJjcmVhdGVkQXQiOiIyMDI0LTAzLTE1In0="
  }
}

// GET /v1/orders?limit=20&cursor=eyJpZCI6MTAwLCJjcmVhdGVkQXQiOiIyMDI0LTAzLTE1In0=
// Returns items strictly AFTER the cursor position — no duplicates regardless of inserts

// Server-side: decode cursor to get last seen ID/timestamp, then:
// WHERE (created_at, id) < (cursor.createdAt, cursor.id) ORDER BY created_at DESC LIMIT 21
// (fetch limit+1 to know if hasMore is true)

The cursor is opaque to the client — base64-encode a JSON object containing the sort key(s) and primary key. This makes cursor pagination compatible with any sort order.

Error Response Design

Consistent error shapes make client error handling vastly simpler. Return errors in the same structure regardless of the HTTP status code.

// Validation error — HTTP 422
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request body contains invalid fields",
    "details": [
      { "field": "email", "message": "Must be a valid email address", "value": "not-an-email" },
      { "field": "age", "message": "Must be a positive integer", "value": -1 }
    ]
  }
}

// Not found — HTTP 404
{
  "error": {
    "code": "NOT_FOUND",
    "message": "User usr_999 not found"
  }
}

// Unauthorized — HTTP 401
{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Missing or invalid API key"
  }
}

// Rate limited — HTTP 429 (include retry guidance in headers)
{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Too many requests",
    "retryAfter": 30
  }
}

RFC 9457 Problem Details

// Content-Type: application/problem+json
// Standardized format for HTTP API errors (RFC 9457, formerly RFC 7807)
{
  "type": "https://example.com/errors/validation-error",
  "title": "Validation Error",
  "status": 422,
  "detail": "The request body contains invalid fields",
  "instance": "/requests/123e4567-e89b-12d3-a456-426614174000",
  "errors": [
    { "field": "email", "message": "Must be a valid email address" }
  ]
}

Date and Time Formats

// Always use ISO 8601 UTC strings
{
  "createdAt": "2024-03-15T14:30:00Z",      // UTC (recommended)
  "scheduledAt": "2024-03-15T14:30:00+05:30", // with timezone offset
  "birthDate": "1990-07-12",                  // date only (no time component)
  "duration": "PT1H30M"                       // ISO 8601 duration
}

// Never use:
{
  "created_at": 1710509400,                   // Unix seconds — opaque to humans
  "created_at_ms": 1710509400000,             // Unix ms — confusing with seconds
  "created_at": "March 15, 2024",             // locale-specific — ambiguous
  "created_at": "15/03/2024"                  // D/M/Y vs M/D/Y — ambiguous
}

API Versioning

StrategyExampleProsCons
URL path/v1/usersExplicit, cacheable, easy to testURL pollution
Query param/users?v=2Clean URLsHard to cache, easy to forget
HeaderAccept: application/vnd.api.v2+jsonPurest REST semanticsHard to test in browser, complex
No versioningAdditive changes onlySimple, no migrationImpossible to make breaking changes
// URL path versioning — recommended for most teams
GET /v1/users/123
GET /v2/users/123    // new response shape

// Deprecate v1 using Sunset header
HTTP/1.1 200 OK
Sunset: Sat, 01 Jun 2025 00:00:00 GMT
Deprecation: Thu, 01 Jan 2025 00:00:00 GMT
Link: <https://api.example.com/v2/users>; rel="successor-version"

Non-breaking (safe) changes: adding optional fields, adding new endpoints, adding new enum values (with caution), relaxing validation. Breaking changes require a version bump: removing fields, changing field types, renaming fields, changing HTTP method, changing status codes, making optional fields required.

OpenAPI Specification

# openapi.yaml (design-first approach)
openapi: 3.1.0
info:
  title: My API
  version: 1.0.0

paths:
  /v1/users/{id}:
    get:
      summary: Get a user
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: User found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '404':
          $ref: '#/components/responses/NotFound'

components:
  schemas:
    User:
      type: object
      required: [id, email, createdAt]
      properties:
        id:         { type: string, example: "usr_123" }
        email:      { type: string, format: email }
        createdAt:  { type: string, format: date-time }
  responses:
    NotFound:
      description: Resource not found
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'

FAQ

Should JSON API fields use camelCase or snake_case?

Both are used by major APIs — camelCase (Stripe, GitHub, Twilio) and snake_case (Slack, Twitter/X). The most important rule is consistency: never mix them in the same API. For JavaScript/TypeScript teams, camelCase is ergonomic since it matches the language convention. For Python teams, snake_case is more natural. Whatever you choose, enforce it with an API linter or JSON Schema pattern validation to catch violations early.

What is the best pagination pattern for a JSON API — cursor or offset?

Cursor-based pagination is more stable and is recommended for most production APIs. Offset pagination (page=2&limit=20) breaks when items are inserted or deleted between requests — the client sees duplicates or skips items. Cursor pagination uses an opaque position marker that remains valid regardless of inserts and deletions. The tradeoff: cursor pagination doesn't support jumping to arbitrary pages. Use offset for admin dashboards with mostly static data; use cursor for user-facing feeds, search results, or any data that updates frequently.

What should a JSON error response look like?

At minimum: a machine-readable error code (a string constant like "VALIDATION_ERROR"), a human-readable message for developers, and for validation errors, a details array with per-field error objects. Never expose stack traces or internal database errors in production. Consider adopting RFC 9457 (Problem Details) for a standardized cross-API error format using application/problem+json content type. The critical requirement: every error response must follow the same shape so clients can write a single error handler.

How should I version a JSON API?

URL path versioning (/v1/, /v2/) is the most pragmatic approach: it's visible in URLs, easy to test with curl, cacheable by proxies, and understood by every HTTP tool. Add the Sunset header to deprecated versions to signal to clients when they must migrate. Never make breaking changes (removing fields, changing types) without a version bump. Additive changes (new optional fields, new endpoints) are safe to make without versioning.

How should I represent dates in a JSON API?

Always use ISO 8601 UTC strings: "2024-03-15T14:30:00Z". Avoid Unix timestamps (opaque to humans), locale-specific formats (ambiguous), and formats without timezone indicators. ISO 8601 is human-readable, lexicographically sortable, supported by every language's standard library, and the format expected by JSON Schema's format: date-time. For date-only fields, use "2024-03-15". Document your timezone policy clearly in the API specification.

Should I wrap API responses in an envelope?

A common hybrid: return single resources directly (no envelope), but wrap list responses in {"{ data: [...], pagination: {...} }"} to include pagination metadata. Pure REST purists return resources directly and use HTTP headers for metadata (Link, X-Total-Count), but most modern APIs use a lightweight envelope for lists. The key principle: whatever you choose, apply it consistently — don't return bare arrays for some endpoints and wrapped objects for others.

How do I handle null vs omitted fields in JSON API responses?

null and omitted have different semantics: null means "this field has no value"; omitted means "this field is not included in this response". Be explicit: define in your API contract whether optional fields are always present as null or may be omitted. Consistently returning null (rather than omitting) is safer — clients don't need to check if ('field' in response) before accessing a field. Document the difference between fields that are always present, fields that may be null, and fields only returned in specific response contexts (e.g., detail vs summary).

What is OpenAPI and how does it relate to JSON API design?

OpenAPI is a standard specification format (YAML or JSON) for describing REST APIs. From an OpenAPI spec you can generate: interactive documentation (Swagger UI, Redoc), client SDKs in any language, mock servers, and validation middleware. OpenAPI 3.1 uses JSON Schema 2020-12, so your schema validator and your API spec share the same dialect. The design-first workflow writes the OpenAPI spec before implementing the API, allowing frontend and backend teams to work in parallel against the spec. This approach surfaces design inconsistencies early and produces better APIs than code-first approaches where the spec is generated from implementation.

Further reading and primary sources