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

Last updated:

A well-designed JSON REST API makes every response predictable — Stripe, GitHub, and Twilio all share 3 core conventions: camelCase field names, a consistent data envelope, and RFC 7807 application/problem+json error format. URL path versioning (/v1/, /v2/) is used by 90%+ of public APIs; Accept header versioning is rare in practice. All dates should be ISO 8601 UTC strings ("2024-03-15T14:30:00Z") to avoid timezone ambiguity. This guide covers camelCase vs snake_case field naming, response envelope design with data and meta keys, cursor-based vs offset pagination, ISO 8601 date format, HTTP status codes (200, 201, 400, 401, 403, 404, 422, 500), URL path versioning, and OpenAPI 3.1 schema documentation.

Format and Validate Your API JSON Responses

Paste any JSON API response to instantly pretty-print, validate, and inspect the structure against your design conventions.

Open JSON Formatter

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