REST API JSON Response Format: Envelope, Errors, Pagination, and Versioning
A REST API JSON response should use a consistent envelope — a top-level data field for success payloads, error for failure details, and meta for pagination — so clients can reliably parse any response without guessing the structure. RFC 9457 (Problem Details for HTTP APIs) standardizes error responses with five fields: type, title, status, detail, and instance; default pagination page sizes should be 20 items with a hard maximum of 100 to prevent resource exhaustion. This guide covers response envelope patterns, RFC 9457 error formatting, cursor vs. offset pagination, property naming conventions (camelCase vs. snake_case), ISO 8601 timestamps, and URL vs. header API versioning — everything you need to ship a consistent, client-friendly JSON API.
Need to validate or prettify your API response payloads? Jsonic formats and checks JSON instantly.
Open JSON Formatter →The response envelope pattern
Every REST API JSON response should wrap its payload in a consistent envelope so that clients apply the same parsing logic regardless of endpoint. The 3-field envelope is the most widely adopted structure: data holds the success payload, error holds failure details, and meta holds pagination and request metadata. Exactly 1 of data or error should be present per response — never both at the same time.
// ✅ Success — single resource
{
"data": {
"id": "u_123",
"name": "Alice",
"email": "alice@example.com",
"createdAt": "2024-01-15T10:30:00Z"
},
"meta": null
}
// ✅ Success — collection with pagination
{
"data": [
{ "id": "u_123", "name": "Alice" },
{ "id": "u_124", "name": "Bob" }
],
"meta": {
"page": 1,
"pageSize": 20,
"total": 347,
"totalPages": 18
}
}
// ✅ Error — RFC 9457 format (see next section)
{
"error": {
"type": "https://jsonic.io/problems/not-found",
"title": "Not Found",
"status": 404,
"detail": "User u_999 does not exist."
},
"meta": null
}The envelope adds 3 predictable keys at the cost of 3 extra bytes per response — a worthwhile tradeoff. Without an envelope, clients must infer success vs. failure from the HTTP status code alone and then guess the shape of the body. With an envelope, the parsing algorithm is always: check for error key first; if absent, read data; check meta for pagination cursors if iterating a collection. This structure scales to every endpoint without special-casing.
Include a request ID in meta on every response — even errors — so clients can include it in bug reports: "meta": {"requestId": "req_abc123"}. The overhead is negligible and eliminates the need to correlate logs manually. Use JSON Schema validation to enforce the envelope shape on both server output and in integration tests.
RFC 9457 error responses (Problem Details)
RFC 9457 (Problem Details for HTTP APIs, March 2023) standardizes the JSON error body so clients in any language can parse failures consistently. It defines exactly 5 fields and is served with Content-Type: application/problem+json. The 3 required fields are type (a URI identifying the error class), title (a short, stable human-readable summary), and status (the HTTP status integer). The 2 optional fields are detail (an explanation specific to this occurrence) and instance (a URI for the specific occurrence, useful for log correlation).
// RFC 9457 — validation error (422)
{
"type": "https://example.com/problems/validation-error",
"title": "Validation Error",
"status": 422,
"detail": "The 'email' field must be a valid email address.",
"instance": "/users/register#req_7f3a1c"
}
// RFC 9457 — insufficient funds (422) with custom extension fields
{
"type": "https://example.com/problems/insufficient-funds",
"title": "Insufficient Funds",
"status": 422,
"detail": "Balance is $10.00 but transfer requires $50.00.",
"instance": "/accounts/acc_1/transfers/txn_9",
"balance": 10.00,
"required": 50.00
}
// RFC 9457 — rate limit exceeded (429)
{
"type": "https://example.com/problems/rate-limit-exceeded",
"title": "Rate Limit Exceeded",
"status": 429,
"detail": "You have made 100 requests in the last 60 seconds.",
"retryAfter": 45
}The type URI should resolve to a human-readable documentation page explaining the error, its causes, and how to fix it — but RFC 9457 does not require it to be dereferenceable. Using a URL you control (e.g., https://your-api.com/problems/not-found) lets you update the docs without changing the API. You can add custom extension fields (like balance or retryAfter) alongside the 5 standard fields. RFC 9457 replaces RFC 7807 with only minor editorial changes — adopting either is equivalent in practice.
Map HTTP status codes consistently: 400 for malformed syntax, 401 for missing or invalid authentication, 403 for authorization failures, 404 for missing resources, 409 for conflicts, 422 for semantic validation errors, 429 for rate limits, and 500 for unexpected server errors. Never return 200 with an error body — it breaks clients that check HTTP status before parsing JSON.
Cursor vs. offset pagination
Pagination comes in 2 main styles: offset pagination (page-based) and cursor pagination (token-based). Offset pagination is easier to implement and supports random access; cursor pagination is more reliable for real-time data where records are inserted or deleted between page loads. Choose based on your data's volatility, not convenience.
// Offset pagination — GET /users?page=2&pageSize=20
{
"data": [
{ "id": "u_021", "name": "Charlie" },
{ "id": "u_022", "name": "Diana" }
],
"meta": {
"page": 2,
"pageSize": 20,
"total": 347,
"totalPages": 18
}
}
// Cursor pagination — GET /events?cursor=eyJpZCI6MTAwfQ&pageSize=20
{
"data": [
{ "id": "e_101", "type": "click" },
{ "id": "e_102", "type": "view" }
],
"meta": {
"nextCursor": "eyJpZCI6MTIwfQ",
"prevCursor": "eyJpZCI6MTAwfQ",
"hasMore": true,
"pageSize": 20
}
}| Offset pagination | Cursor pagination | |
|---|---|---|
| Random access | Yes — jump to any page | No — must traverse from start |
| Consistency | Poor — inserts shift pages | Strong — cursor anchors position |
| Performance | Slow at high offsets (OFFSET 10000) | Fast — index seek on cursor field |
| Best for | Static data, admin tables | Feeds, logs, real-time data |
| Total count | Easy to include | Expensive to compute |
Apply the same limits regardless of pagination style: default to 20 items per page and enforce a hard maximum of 100. Requests for pageSize=500 should return HTTP 400 with an RFC 9457 error explaining the limit. Encode cursor tokens in Base64 (opaque to clients) so you can change the underlying cursor implementation — row ID, created-at timestamp, or composite key — without a breaking API change.
Property naming conventions: camelCase vs. snake_case
Pick 1 naming convention and apply it to every field across every endpoint. The 2 dominant choices are camelCase (createdAt, userId, isActive) and snake_case (created_at, user_id, is_active). Neither is technically superior — the right choice is the one that requires the fewest transformations in your primary client.
// camelCase — preferred for JavaScript/TypeScript clients
{
"userId": "u_123",
"firstName": "Alice",
"createdAt": "2024-01-15T10:30:00Z",
"isEmailVerified": true,
"lastLoginAt": "2024-03-01T08:00:00Z"
}
// snake_case — preferred for Python/Ruby on Rails clients
{
"user_id": "u_123",
"first_name": "Alice",
"created_at": "2024-01-15T10:30:00Z",
"is_email_verified": true,
"last_login_at": "2024-03-01T08:00:00Z"
}Public APIs serving a wide range of clients (GitHub, Stripe, Twilio) use camelCase because JSON originated in JavaScript and most HTTP client libraries in every language can consume camelCase without transformation. Rails and Django APIs commonly use snake_case to match their ORM column names with zero mapping code on the server side.
Never mix the 2 conventions within an API — 3 of the top 5 complaints in API developer surveys cite inconsistent naming as a major pain point. Document your convention on page 1 of your API reference. Use JSON Schema with a linter (e.g., Spectral) to enforce naming conventions in CI before they reach production.
ISO 8601 timestamps in JSON API responses
Always use ISO 8601 strings with UTC in JSON API responses. The correct format is 2024-01-15T10:30:00Z — the trailing Z means UTC (Zulu time). Never use Unix epoch integers: they are ambiguous between seconds and milliseconds, invisible to humans reading logs, and require an extra parsing step in every client. Store timestamps in UTC in your database, transmit them in UTC over the API, and let the client convert to the user's local timezone for display.
// ✅ Correct — ISO 8601 UTC
{
"id": "order_456",
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-03-01T08:15:30.123Z",
"cancelledAt": null
}
// ❌ Wrong — Unix epoch (ambiguous seconds vs milliseconds)
{
"id": "order_456",
"createdAt": 1705315800,
"updatedAt": 1709281530123
}
// ❌ Wrong — non-UTC offset (forces client to handle timezone math)
{
"id": "order_456",
"createdAt": "2024-01-15T05:30:00-05:00"
}In JavaScript, new Date().toISOString() produces the correct format automatically (2024-01-15T10:30:00.000Z). In Python, use datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"). Millisecond precision (2024-01-15T10:30:00.123Z) is acceptable when sub-second ordering matters (event logs, audit trails). For more on JSON date formatting options, see the dedicated guide. The key rule: 100% of timestamp fields in your API must use the same format — mixing ISO strings with epoch integers in the same API is as harmful as mixing camelCase with snake_case.
API versioning strategies: URL path vs. header
Versioning lets you evolve your JSON response format without breaking existing clients. There are 2 primary strategies: URL path versioning and header versioning (content negotiation). A 3rd option — query parameter versioning (?version=2) — is used but considered an anti-pattern because query parameters imply filters, not resource identity.
// URL path versioning — GET /v2/users/u_123
// Client sends:
GET /v2/users/u_123 HTTP/1.1
Host: api.example.com
Authorization: Bearer token_abc
// Header versioning (content negotiation) — same URL, different Accept
// Client sends:
GET /users/u_123 HTTP/1.1
Host: api.example.com
Accept: application/vnd.example.v2+json
Authorization: Bearer token_abc
// Deprecation header — signal old version is being retired (RFC 8594)
// Server response for v1 endpoint:
HTTP/1.1 200 OK
Deprecation: true
Sunset: Sat, 31 Dec 2026 23:59:59 GMT
Link: <https://api.example.com/v2/users>; rel="successor-version"URL path (/v2/) | Header (Accept: vnd.api.v2) | |
|---|---|---|
| Visibility | Version in URL — easy to see in logs | Version in header — hidden from URL |
| Browser testing | Yes — paste URL in browser | No — must set custom header |
| Caching | Standard — URL is the cache key | Complex — must use Vary header |
| REST purity | Debated — version in resource ID | Higher — URL identifies resource |
| Adoption | Stripe, Twilio, GitHub v3 | GitHub v4 (GraphQL), Accept header |
For most teams, URL path versioning is the right default: it requires zero special HTTP client configuration, version mismatches are immediately visible in error logs, and every curl example in your docs just works. Maintain at least 2 versions in parallel during a transition and send the Deprecation and Sunset headers (RFC 8594) for at least 6 months before removing a version. Use fetch JSON in JavaScript patterns to build clients that respect versioned endpoints and handle deprecation gracefully.
Serializing API responses correctly with JSON.stringify
How you serialize the response matters as much as how you structure it. 3 common serialization bugs cause client parsing failures: accidental undefined values (silently dropped by JSON.stringify), circular references (throw at runtime), and unintended prototype properties. Using a serializer with explicit field allowlists avoids all 3.
// ❌ Bug: undefined values are silently dropped
const user = { id: "u_1", name: "Alice", phone: undefined }
JSON.stringify(user)
// '{"id":"u_1","name":"Alice"}' — phone key disappears entirely
// ✅ Fix: use null for absent optional fields
const user = { id: "u_1", name: "Alice", phone: null }
JSON.stringify(user)
// '{"id":"u_1","name":"Alice","phone":null}'
// ✅ Envelope builder — always produces consistent structure
function successResponse(data, meta = null) {
return JSON.stringify({ data, meta })
}
function errorResponse(type, title, status, detail, instance = undefined) {
const err = { type, title, status, detail }
if (instance !== undefined) err.instance = instance
return JSON.stringify({ error: err, meta: null })
}
// Usage
res.setHeader('Content-Type', 'application/json')
res.end(successResponse({ id: "u_1", name: "Alice" }))Use null (not undefined) for fields that have no value — null serializes to JSON null and signals "explicitly absent," while undefined causes the key to vanish silently. This matters for optional fields like cancelledAt or deletedAt: always include them as null so clients know the field exists even when it has no value. For deep control over serialization, JSON.stringify supports a replacer function that lets you transform, filter, or rename keys during output. Validate the final JSON with JSON Schema in integration tests to catch envelope violations before they reach clients.
Definitions
Envelope pattern
A consistent top-level wrapper object applied to every API response. The envelope provides named slots for the success payload (data), error details (error), and metadata (meta). Clients learn 1 parsing algorithm and reuse it across all endpoints instead of learning the shape of each response individually. The envelope adds predictability at the cost of a few extra bytes per response.
RFC 9457 (Problem Details)
An IETF standard (March 2023) defining a JSON format for HTTP error responses. It specifies 5 fields — type, title, status, detail, instance — and the MIME type application/problem+json. RFC 9457 supersedes RFC 7807 with minor editorial corrections. Adopting it means error-aware HTTP clients and middleware can parse your errors without reading API-specific documentation.
Cursor pagination
A pagination strategy where each page response includes an opaque token (the cursor) identifying the position in the dataset. The client passes the cursor as a query parameter to retrieve the next page. Unlike offset pagination, cursor pagination produces consistent results when records are inserted or deleted between page loads because the cursor anchors to a stable identifier (e.g., the last seen row ID or timestamp), not a numeric offset.
Offset pagination
A pagination strategy using page / pageSize or limit / offset parameters. The server skips the first offset rows and returns the next limit rows. Simple to implement and supports jumping to any page, but suffers from page drift when rows are inserted or deleted between requests. Performance degrades at high offsets because the database must scan and discard offset rows before returning results.
Content negotiation
An HTTP mechanism where the client declares acceptable response formats in the Accept request header and the server selects the best match. In API versioning, header versioning uses content negotiation: Accept: application/vnd.api.v2+json requests the v2 JSON format of a resource without changing the URL. The server responds with the selected format in the Content-Type header and should include a Vary: Accept header so caches key responses by both URL and Accept header.
Idempotency
A property of an HTTP operation where sending the same request multiple times produces the same result as sending it once. GET, PUT, and DELETE are idempotent by the HTTP specification; POST is not. In JSON API design, idempotency keys are often added to POST requests — clients include a unique Idempotency-Key header, and the server caches the response for that key so retried requests (e.g., after a network timeout) return the original response instead of creating duplicate records.
Frequently asked questions
What is the standard JSON response structure for a REST API?
The most widely adopted structure uses a 3-field envelope: data for the success payload (single object or array), error for failure details (RFC 9457 format), and meta for pagination and request metadata. Exactly 1 of data or error should be present per response — never both. Example success: {"data": {"id":"u_1","name":"Alice"}, "meta": null}. Example error: {"error": {"type":"...","title":"Not Found","status":404}}. This envelope means every client applies the same parsing algorithm: check for error first, then read data, then check meta for pagination cursors. Include a requestId in meta on every response so clients can correlate errors with server logs. Consistent structure reduces client code complexity and makes it easy to add cross-cutting concerns — deprecation notices, rate-limit metadata — without touching the data field.
How should I format error responses in JSON (RFC 9457 Problem Details)?
Use RFC 9457 (Problem Details for HTTP APIs). The 3 required fields are: type (a URI identifying the error class, should resolve to docs), title (a short stable summary — do not vary it per occurrence), and status (the HTTP status integer). The 2 optional fields are detail (occurrence-specific explanation) and instance (a URI for this specific occurrence, useful for log lookup). Serve with Content-Type: application/problem+json. You can extend the object with domain-specific fields alongside the 5 standard ones. Never return HTTP 200 with an error body — it breaks clients that check HTTP status before parsing. Map errors consistently: 400 for bad syntax, 401 for auth missing, 403 for authorization failure, 404 for missing resource, 422 for semantic validation errors, 429 for rate limits, 500 for unexpected server errors.
Should REST API JSON property names use camelCase or snake_case?
Choose the convention that matches your primary client ecosystem and apply it to 100% of your API. Use camelCase (createdAt, userId) for JavaScript/TypeScript-first APIs — it matches JS object conventions and requires no client-side key transformation. Use snake_case (created_at, user_id) for Python/Ruby on Rails APIs — both ecosystems default to snake_case. For public APIs serving many languages, camelCase is more common in modern APIs: GitHub, Stripe, Twilio all use camelCase. The worst outcome is mixing both within a single API — clients must handle 2 different conventions and bugs from accidentally using the wrong one are subtle. Document your convention prominently in the API reference. Use a linter (Spectral + OpenAPI) in CI to enforce naming rules before they reach production. Never use PascalCase or kebab-case for property names.
How do I implement pagination in a REST API JSON response?
Choose offset or cursor pagination based on data volatility. Offset pagination uses page/pageSize parameters and returns total count in meta: {"meta": {"page":2,"pageSize":20,"total":347}}. It supports random page access but suffers from drift when records are inserted or deleted between requests. Cursor pagination uses an opaque token: {"meta": {"nextCursor":"eyJ...","hasMore":true}}. Clients pass ?cursor=eyJ... for the next page. Cursor pagination produces consistent results for real-time feeds because the cursor anchors to a stable row position, not a numeric offset. Default page size should be 20 items; enforce a hard maximum of 100 and return HTTP 400 for requests exceeding that limit. Always include a hasMore boolean so clients detect the last page without fetching an empty response. Encode cursor tokens in Base64 so you can change the implementation without a breaking change.
What timestamp format should I use in JSON API responses?
Always use ISO 8601 strings with UTC: 2024-01-15T10:30:00Z. Never use Unix epoch integers — they are ambiguous between seconds and milliseconds and invisible to humans reading logs. Never use non-UTC offsets like 2024-01-15T05:30:00-05:00 in API responses — store and transmit in UTC and let the client convert to local time for display. ISO 8601 with milliseconds (2024-01-15T10:30:00.123Z) is acceptable when sub-second precision matters. In JavaScript, new Date().toISOString() produces the correct format. In Python, use datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"). Apply the same format to every timestamp field: createdAt, updatedAt, deletedAt, expiresAt. See the full JSON date format guide for parsing and serialization examples across 5 languages.
How do I version a REST API without breaking existing JSON clients?
Use URL path versioning (/v1/, /v2/) as the default — it is visible in logs, requires no special HTTP client configuration, and makes every curl example self-documenting. Header versioning (Accept: application/vnd.api.v2+json) is cleaner architecturally but harder to test manually. Regardless of strategy, follow 3 rules: (1) never remove or rename fields in an existing version — add new optional fields instead; (2) run at least 2 versions in parallel during a transition; (3) send RFC 8594 Deprecation: true and Sunset headers for at least 6 months before removing a version. Additive changes — new optional response fields, new endpoints — are backward-compatible and do not require a version bump. Use fetch JSON in JavaScript patterns to build version-aware clients that read the Deprecation header and log warnings before migrating.
Ready to build better JSON APIs?
Use Jsonic's JSON Formatter to validate and prettify your response payloads, and JSON Schema to enforce your envelope structure in tests.
Open JSON Formatter →