UseToolSuite UseToolSuite

REST API JSON Best Practices

A practical guide to designing JSON responses in REST APIs: RFC 7807 problem details, cursor-based pagination, semantic versioning, and ETag caching.

Necmeddin Cunedioglu Necmeddin Cunedioglu 7 min read
Part of the JSON: The Complete Developer Guide series

Practice what you learn

JSON Formatter & Validator

Try it free →

REST API JSON Best Practices

Designing consistent, predictable JSON responses is one of the most consequential decisions you make for an API. A messy payload creates technical debt in every client that consumes it — web frontends, mobile apps, and third-party integrations all have to work around it.

This guide covers the conventions used by large API platforms like Stripe, GitHub, Twilio, and AWS: envelope payloads, cursor-based pagination, RFC 7807 error formatting, and ETag caching.

Stop hand-formatting payloads. Minify production responses and validate nested payloads with our local JSON Formatter. Generate typed frontend interfaces with our JSON to TypeScript Converter.

1. Naming Conventions

Pick one naming convention and apply it across every endpoint.

String ConventionJSON ExampleCommon Adoption
camelCase"firstName", "createdAt"JavaScript, TypeScript, Stripe, Google Cloud
snake_case"first_name", "created_at"Python, Ruby, GitHub, X (Twitter), AWS
kebab-case"first-name", "created-at"Not used in JSON bodies (only in HTTP URLs)

Rule of thumb: match the convention of your primary consumer. If your API is consumed mostly by React/Next.js frontends, use camelCase so frontend developers don’t have to write mapping adapters. If it’s a data-science API consumed by Python (Pandas, Django), use snake_case.

Four common naming mistakes

  1. Mixing conventions: returning { "first_name": "Alice", "lastName": "Smith" } in one payload is an API-governance failure and causes deserialization errors in strictly typed languages like Go or Java.
  2. Useless abbreviations: don’t use "desc" for "description" or "config" for "configuration". Explicit names reduce documentation lookups. Bytes are cheap; confusion is expensive.
  3. Ambiguous booleans: prefix booleans with is, has, or can. "active": true is acceptable, but "isActive": true is clearer.
  4. Singular arrays: use plural nouns for arrays — "addresses": [...], not "address": [...].

2. The Envelope Response Pattern

Wrap responses in a consistent JSON envelope.

{
  "data": {
    "id": "usr_987654321",
    "name": "Alice Johnson",
    "email": "alice@engineering.com"
  },
  "meta": {
    "request_id": "req_abc123xyz",
    "timestamp": "2026-03-22T14:30:00.000Z",
    "api_version": "v2.1",
    "rate_limit_remaining": 498
  }
}

This separates the business data (data) from system metadata (meta). The separation lets you extend the response — adding to meta — without breaking clients that map the core data object.

Envelope vs flat responses

ApproachProsConsBest for
Envelope (data wrapper)Extensible, consistent, room for meta.Slightly verbose, deeper nesting in client state.Public SaaS APIs, paginated endpoints.
Flat ResponseSimple, smaller payload, fast to parse.Hard to add metadata later without breaking the schema.Internal low-latency microservices.
JSON:API SpecificationStandardized, large tooling ecosystem.Complex, steep learning curve.Large enterprise organizations.

3. Standardizing Error Formats (RFC 7807)

Returning a bare 500 with an empty body or a string like "Database Error" isn’t acceptable in production.

Use a consistent JSON error format across all failure cases. The RFC 7807 Problem Details for HTTP APIs spec is a good default.

{
  "error": {
    "type": "https://api.example.com/errors/validation-failure",
    "title": "Unprocessable Entity",
    "status": 422,
    "code": "VALIDATION_ERROR",
    "message": "The JSON request body contains invalid fields.",
    "instance": "/users/usr_987654321",
    "details": [
      { "field": "email", "message": "Must be a valid RFC 5322 email address." },
      { "field": "age", "message": "Must be a positive integer greater than 18." }
    ]
  }
}

Error rules:

  • Always return a dedicated error root object.
  • Include a machine-readable code (e.g., VALIDATION_ERROR) so the frontend can branch on it. Don’t make clients parse English strings.
  • Include a human-readable message for debugging logs.
  • Use the details array for field-level validation failures, so the UI can highlight the exact bad inputs.

Mapping JSON errors to HTTP status codes

HTTP Status CodeTrigger ConditionJSON Error Code Example
400 Bad RequestMalformed syntax, invalid JSON, unparsable strings.INVALID_REQUEST_BODY
401 UnauthorizedMissing, invalid, or expired token.UNAUTHENTICATED
403 ForbiddenAuthenticated, but insufficient permissions.FORBIDDEN_ACCESS
404 Not FoundResource doesn’t exist or isn’t visible to the user.RESOURCE_NOT_FOUND
409 ConflictConstraint conflict (e.g., registering an existing email).CONFLICT
422 UnprocessableValid JSON, but fails business-logic validation.VALIDATION_ERROR
429 Too Many ReqRate limit exceeded.RATE_LIMIT_EXCEEDED
500 Internal ErrorUnhandled backend exception or database failure.INTERNAL_SERVER_ERROR

4. Cursor-Based Pagination

For endpoints returning large arrays, avoid OFFSET/LIMIT pagination. Offset-based pagination (?page=500) gets slower as the table grows, because the database has to scan and discard all preceding rows. It also causes UI bugs if a record is inserted or deleted mid-pagination — you get skipped or duplicated items.

Use cursor-based pagination instead.

{
  "data": [
    { "id": "usr_001", "name": "Alice Developer" },
    { "id": "usr_002", "name": "Bob Engineer" }
  ],
  "pagination": {
    "has_more": true,
    "next_cursor": "eyJpZCI6InVzcl8wMDIiLCJjcmVhdGVkX2F0IjoxNzExMzI0ODAwMDAwfQ==",
    "total_count": 150000
  }
}

How opaque cursors work

A cursor shouldn’t be a raw database integer ID. Make it a Base64URL-encoded string representing the sort keys of the last item in the array.

For example, if you sort by created_at and break ties with the primary id, the unencoded cursor looks like: {"id": "usr_002", "created_at": 1711324800000}

Base64-encode that to produce next_cursor. When the client requests the next page via ?cursor=eyJpZ..., the backend decodes it and runs a fast query using a composite B-Tree index: SELECT * FROM users WHERE (created_at, id) > (1711324800000, 'usr_002') ORDER BY created_at ASC, id ASC LIMIT 20;

Performance stays constant whether the user is on page 1 or page 50,000.

5. API Versioning

Choose a versioning strategy before the API hits production and apply it consistently.

# 1. URL Path Versioning (the common default)
GET /v1/users
GET /v2/users

# 2. Header Content Negotiation Versioning
GET /users
Accept: application/vnd.myapi.v2+json

# 3. Query Parameter Versioning (discouraged)
GET /users?version=2

Recommendation: URL path versioning (/v1/, /v2/) is the most visible, cacheable, and easiest to debug. Header versioning is “cleaner” by REST purist standards, but it’s harder to test in a browser or share over Slack.

Sunset deprecation

When deprecating a version, communicate the shutdown in the payload:

{
  "data": { "id": "usr_123" },
  "meta": {
    "deprecation": {
      "message": "API v1 will be shut down on 2027-01-01. Please migrate to v2.",
      "sunset_date": "2027-01-01T00:00:00Z",
      "migration_guide": "https://docs.example.com/migrate-v1-to-v2"
    }
  }
}

Also send the standard Sunset HTTP header: Sunset: Sat, 01 Jan 2027 00:00:00 GMT.

6. Dates and Timestamps (ISO 8601)

Use the ISO 8601 string format with a UTC timezone.

{
  "created_at": "2026-03-22T14:30:00.000Z",
  "updated_at": "2026-03-22T15:45:30.000Z"
}

Rules:

  • Use UTC (the Z suffix) for all server-generated timestamps.
  • Never use localized formats like 03/22/2026 (US) or 22/03/2026 (EU) in a payload — they’re ambiguous.
  • For date-only values where time is irrelevant (e.g., birthdays), use YYYY-MM-DD: "birth_date": "1990-05-15".

7. Nulls vs Absent Fields vs Partial Updates (PUT vs PATCH)

Be explicit about null values.

{
  "name": "Alice Developer",
  "avatar_url": null,
  "bio": "Senior Software Architect"
}

The rule: include fields with null values rather than omitting the key. null says “this field exists but has no value”; an absent key says “this field isn’t part of this schema.”

PATCH vs PUT

REST defines PUT as a full, idempotent replacement of the document, and PATCH as a partial update.

For PATCH endpoints, clients send only the fields being changed. The server treats absent fields as “no change” and an explicit null as “clear this field.”

// HTTP PATCH /users/usr_123
{
  "bio": "Principal Software Architect",
  "avatar_url": null
}
// Result: bio is updated, avatar is cleared, name is untouched.

8. Performance: ETags and Compression

  1. ETag caching: return an ETag header (a hash of the response body). If a client later sends If-None-Match with that ETag and the data hasn’t changed, return 304 Not Modified with an empty body. This saves CDN bandwidth and mobile battery.
  2. Minify production responses: strip whitespace via JSON.stringify(data) (no indentation) before sending. Use our local JSON Formatter to expand minified payloads while debugging.
  3. Brotli over Gzip: configure your edge proxy to use Brotli. JSON compresses well — often a 90% reduction.
  4. Field masking: let clients request only the fields they need: ?fields=id,name,email. This avoids sending a 50-key user object to a client that only needs the name.

Further Reading


Stop writing JSON validation by hand. Validate, format, and minify entirely in your browser with our JSON Formatter, and generate typed interfaces with the JSON to TypeScript Converter.

Necmeddin Cunedioglu
Necmeddin Cunedioglu Author
7 min read
-- views

Software developer and the creator of UseToolSuite. I write about the tools and techniques I use daily as a developer — practical guides based on real experience, not theory.