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 Convention | JSON Example | Common 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
- 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. - Useless abbreviations: don’t use
"desc"for"description"or"config"for"configuration". Explicit names reduce documentation lookups. Bytes are cheap; confusion is expensive. - Ambiguous booleans: prefix booleans with
is,has, orcan."active": trueis acceptable, but"isActive": trueis clearer. - 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
| Approach | Pros | Cons | Best for |
|---|---|---|---|
Envelope (data wrapper) | Extensible, consistent, room for meta. | Slightly verbose, deeper nesting in client state. | Public SaaS APIs, paginated endpoints. |
| Flat Response | Simple, smaller payload, fast to parse. | Hard to add metadata later without breaking the schema. | Internal low-latency microservices. |
| JSON:API Specification | Standardized, 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
errorroot 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
messagefor debugging logs. - Use the
detailsarray for field-level validation failures, so the UI can highlight the exact bad inputs.
Mapping JSON errors to HTTP status codes
| HTTP Status Code | Trigger Condition | JSON Error Code Example |
|---|---|---|
400 Bad Request | Malformed syntax, invalid JSON, unparsable strings. | INVALID_REQUEST_BODY |
401 Unauthorized | Missing, invalid, or expired token. | UNAUTHENTICATED |
403 Forbidden | Authenticated, but insufficient permissions. | FORBIDDEN_ACCESS |
404 Not Found | Resource doesn’t exist or isn’t visible to the user. | RESOURCE_NOT_FOUND |
409 Conflict | Constraint conflict (e.g., registering an existing email). | CONFLICT |
422 Unprocessable | Valid JSON, but fails business-logic validation. | VALIDATION_ERROR |
429 Too Many Req | Rate limit exceeded. | RATE_LIMIT_EXCEEDED |
500 Internal Error | Unhandled 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
Zsuffix) for all server-generated timestamps. - Never use localized formats like
03/22/2026(US) or22/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
- ETag caching: return an
ETagheader (a hash of the response body). If a client later sendsIf-None-Matchwith that ETag and the data hasn’t changed, return304 Not Modifiedwith an empty body. This saves CDN bandwidth and mobile battery. - Minify production responses: strip whitespace via
JSON.stringify(data)(no indentation) before sending. Use our local JSON Formatter to expand minified payloads while debugging. - Brotli over Gzip: configure your edge proxy to use Brotli. JSON compresses well — often a 90% reduction.
- 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
- API Rate Limiting Algorithms: Token Buckets vs Sliding Windows
- Time & Date Programming: UTC and Timestamp Mechanics
- Cross-Site Scripting (XSS): Protecting JSON Payloads
- UUID vs NanoID vs ULID: Architecting Primary Keys
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.