JSON API Design Best Practices
Last updated:
A well-designed JSON API uses consistent field naming (camelCase or snake_case — pick one and never mix), envelope wrappers for pagination metadata, and HTTP status codes that match the error semantics. camelCase is the JavaScript convention (userId, createdAt) while snake_case matches Python and PostgreSQL (user_id, created_at) — 71% of public APIs surveyed in 2023 used camelCase. The JSON:API specification (jsonapi.org) defines a complete standard for resource objects, relationships, and error formatting.
This guide covers field naming conventions, response envelope design, versioning strategies (URI path vs Accept header), pagination (offset vs cursor), error response formats, and the JSON:API specification. Every pattern includes before/after examples so you can apply each decision immediately. For related topics, see our guides on JSON error handling, JSON pagination, OpenAPI, and JSON Schema validation.
Field Naming Conventions: camelCase vs snake_case
Pick one field naming convention and enforce it everywhere — mixed conventions inside a single response are always a bug, not a style choice. The two dominant options are camelCase (userId, createdAt, isActive) and snake_case (user_id, created_at, is_active). A 2023 survey of 500 public REST APIs found 71% used camelCase, 21% used snake_case, and 8% were inconsistent. The inconsistent 8% generate the most developer complaints.
Choose camelCase when: your primary consumers are JavaScript or TypeScript frontends. JSON.parse() returns JavaScript objects, and JavaScript property access is camelCase by convention — user.userId reads naturally; user.user_id does not. Most TypeScript type generators (OpenAPI Generator, quicktype) default to camelCase output.
Choose snake_case when: your primary consumers are Python services or PostgreSQL queries. Python data classes, SQLAlchemy models, and Django serializers all use snake_case by default. If your API returns camelCase, Python consumers either accept mismatched names or add a transformation layer. PostgreSQL column names are snake_case (user_id) — serving them as-is avoids an extra mapping step.
// ── Before: mixed conventions (defect) ───────────────────────────
{
"userId": 42,
"user_name": "Alice", // snake_case mixed with camelCase
"CreatedAt": "2024-01-15", // PascalCase mixed in
"is-active": true // kebab-case — invalid JS property access
}
// ── After: consistent camelCase ──────────────────────────────────
{
"userId": 42,
"userName": "Alice",
"createdAt": "2024-01-15",
"isActive": true
}
// ── After: consistent snake_case ─────────────────────────────────
{
"user_id": 42,
"user_name": "Alice",
"created_at": "2024-01-15",
"is_active": true
}
// ── Enforcing camelCase in Node.js (Express middleware) ───────────
function toCamelCase(str) {
return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
}
function camelizeKeys(obj) {
if (Array.isArray(obj)) return obj.map(camelizeKeys);
if (obj !== null && typeof obj === "object") {
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => [toCamelCase(k), camelizeKeys(v)])
);
}
return obj;
}
// Converts snake_case DB results to camelCase API responses
app.use((req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (data) => originalJson(camelizeKeys(data));
next();
});
// ── Boolean naming: always use is/has/can prefix ──────────────────
// Good: isActive, hasPermission, canEdit, isEmailVerified
// Bad: active, permission, edit, emailVerified
// ── Dates: always ISO 8601 regardless of naming convention ────────
// Good: "createdAt": "2024-01-15T10:30:00Z"
// Bad: "createdAt": 1705316200 (Unix timestamp — ambiguous seconds vs ms)
// Bad: "createdAt": "Jan 15 2024" (locale-specific, unparseable)Beyond camelCase vs snake_case, enforce two more naming rules: (1) Boolean fields should use an is, has, or can prefix (isActive, hasPermission, canEdit) — this signals the field type without requiring documentation. (2) Date and time fields should always use ISO 8601 format ("2024-01-15T10:30:00Z") — never Unix timestamps or locale-specific strings. Document your naming convention in your OpenAPI spec so it is visible to all API consumers.
Response Envelope Design: data, meta, and errors
A response envelope wraps the payload in a consistent top-level structure so every API response — success and failure — follows the same shape. The canonical envelope has three keys: data (the primary payload), meta (pagination metadata, request IDs, counts), and errors (validation and server errors). Bare responses — a raw object or array at the top level — cannot be extended later without a breaking change.
// ── Before: bare array (cannot add meta later without breaking) ────
// GET /users
[{ "id": 42, "name": "Alice" }]
// ── After: envelope with data + meta ─────────────────────────────
// GET /users/42 — single resource
{
"data": {
"id": 42,
"name": "Alice",
"email": "alice@example.com",
"createdAt": "2024-01-15T10:30:00Z"
},
"meta": { "requestId": "req_01HX4K2J" }
}
// GET /users — collection with pagination
{
"data": [
{ "id": 42, "name": "Alice" },
{ "id": 43, "name": "Bob" }
],
"meta": {
"total": 1500,
"count": 2,
"page": 1,
"perPage": 2,
"nextCursor": "eyJpZCI6NDN9"
}
}
// ── Error envelope — errors array, data null ──────────────────────
// POST /users — 422 Unprocessable Entity
{
"data": null,
"errors": [
{ "code": "VALIDATION_FAILED", "field": "email", "message": "must be a valid email address" },
{ "code": "VALIDATION_FAILED", "field": "age", "message": "must be at least 18" }
],
"meta": { "requestId": "req_01HX4K2J" }
}
// ── TypeScript generic envelope interface ─────────────────────────
interface ApiResponse<T> {
data: T | null;
meta?: {
total?: number;
page?: number;
perPage?: number;
nextCursor?: string;
prevCursor?: string;
requestId?: string;
};
errors?: Array<{
code: string;
message: string;
field?: string;
}>;
}
// Express helpers
function sendSuccess<T>(res: Response, data: T, meta?: object, status = 200) {
res.status(status).json({ data, meta: meta ?? {}, errors: [] });
}
function sendError(res: Response, errors: object[], status: number) {
res.status(status).json({ data: null, errors, meta: {} });
}The errors key should always be an array — even for a single error. Clients that handle the array shape work for both single and multiple errors; clients that expect a single object break when multiple errors occur. Always include errors: [] on success responses and data: null on error responses — this ensures clients can write simple checks (if (response.errors.length > 0)) without guarding against missing keys. The meta.requestId field is invaluable for correlating client-side error reports with server-side logs — generate it on every request and log it before responding.
HTTP Status Codes for JSON APIs
HTTP status codes are the first layer of error communication — they tell the client whether to retry, fix the request, or escalate to a human. Using the wrong status code forces clients to parse the body to understand the error class, defeating the purpose of the protocol. The most commonly misused codes in JSON APIs are 400 vs 422, 401 vs 403, and the habit of returning 200 OK for everything including errors.
// ── Success codes ─────────────────────────────────────────────────
// 200 OK — GET, PUT, PATCH succeed; body contains the resource
// 201 Created — POST creates a resource; include Location header
// 204 No Content — DELETE or action succeeds; no body
app.post("/users", async (req, res) => {
const user = await createUser(req.body);
res.status(201).set("Location", `/users/${user.id}`).json({ data: user });
});
app.delete("/users/:id", async (req, res) => {
await deleteUser(req.params.id);
res.status(204).end(); // no body on 204
});
// ── Client error codes ────────────────────────────────────────────
// 400 Bad Request — malformed JSON, missing required header, invalid syntax
// 401 Unauthorized — missing or invalid authentication credentials
// 403 Forbidden — authenticated but not authorized for this resource
// 404 Not Found — resource does not exist
// 409 Conflict — unique constraint violated (duplicate email)
// 422 Unprocessable Entity — valid JSON, but fails business validation
// 429 Too Many Requests — rate limit exceeded; include Retry-After header
// ── 400 vs 422 — the critical distinction ────────────────────────
// 400: server cannot parse or understand the request structure
app.use(express.json({
verify: (req, res, buf) => {
try { JSON.parse(buf); }
catch {
res.status(400).json({ errors: [{ code: "INVALID_JSON", message: "Not valid JSON" }] });
throw new Error("Invalid JSON");
}
}
}));
// 422: JSON parsed successfully, but content fails business validation
app.post("/bookings", async (req, res) => {
const { startDate, endDate } = req.body;
if (new Date(endDate) <= new Date(startDate)) {
return res.status(422).json({
errors: [{ code: "INVALID_DATE_RANGE", field: "endDate", message: "endDate must be after startDate" }]
});
}
});
// ── 401 vs 403 ────────────────────────────────────────────────────
// 401: no token, expired token, invalid signature → "who are you?"
// 403: valid token, insufficient role/permission → "I know who you are, but no"
// ── Never return 200 OK for errors ───────────────────────────────
// Bad — forces clients to parse body to detect failure
res.status(200).json({ success: false, error: "User not found" });
// Good — HTTP status communicates the error class immediately
res.status(404).json({ data: null, errors: [{ code: "USER_NOT_FOUND" }] });
// ── 429 with Retry-After header ──────────────────────────────────
res.status(429).set("Retry-After", "60").json({
errors: [{ code: "RATE_LIMIT_EXCEEDED", message: "Retry after 60 seconds." }]
});The anti-pattern of returning 200 OK for all responses forces every client to implement a second layer of error detection by parsing the body — breaking HTTP clients, monitoring tools, and API gateways that rely on status codes. Always return semantically correct status codes. For server errors (5xx), do not include stack traces or internal error details in the response body — log them server-side with the requestId and return only the code and a generic message to the client.
API Versioning: URI Path vs Accept Header vs Query Parameter
API versioning lets you make breaking changes without disrupting existing clients. Three strategies are in common use: URI path versioning (/api/v1/users), Accept header versioning (Accept: application/vnd.myapi.v2+json), and query parameter versioning (/users?version=2). Each has different trade-offs for discoverability, caching, and developer experience.
// ── Strategy 1: URI path versioning (most widely adopted) ────────
// GET /api/v1/users
// GET /api/v2/users
// Pros: visible in URLs, easy to test in browser, trivial to route
// Cons: purists argue the URI should identify a resource not a version
// Express router setup
const v1Router = express.Router();
const v2Router = express.Router();
v1Router.get("/users", getUsersV1);
v2Router.get("/users", getUsersV2);
app.use("/api/v1", v1Router);
app.use("/api/v2", v2Router);
// Nginx routing (deploy each version independently)
// location /api/v1/ { proxy_pass http://api-v1-service/; }
// location /api/v2/ { proxy_pass http://api-v2-service/; }
// ── Strategy 2: Accept header versioning ─────────────────────────
// GET /users
// Accept: application/vnd.myapi.v2+json
// Pros: clean URIs, follows HTTP content negotiation (RFC 7231)
// Cons: invisible in browser, harder to test, requires Vary: Accept for CDN
function parseApiVersion(req, res, next) {
const accept = req.headers["accept"] || "";
const match = accept.match(/application\/vnd\.myapi\.v(\d+)\+json/);
req.apiVersion = match ? parseInt(match[1]) : 1;
next();
}
app.use(parseApiVersion);
app.get("/users", (req, res) => {
if (req.apiVersion === 2) return getUsersV2(req, res);
return getUsersV1(req, res);
});
// ── Strategy 3: Query parameter (avoid) ───────────────────────────
// GET /users?version=2
// Semantically wrong — query params should filter/sort, not select contracts
// Also breaks CDN caching
// ── Sunset header: announcing deprecation (RFC 8594) ─────────────
// Add to all v1 responses starting 6 months before removal
res.set("Sunset", "Sat, 31 Dec 2025 23:59:59 GMT");
res.set("Deprecation", "Mon, 01 Jan 2025 00:00:00 GMT");
res.set("Link", '</api/v2/users>; rel="successor-version"');
// Include version info in every response for debugging
{
"data": { /* ... */ },
"meta": { "apiVersion": "2", "sunsetAt": null }
}URI versioning wins on developer experience — it is the most widely adopted strategy and works out of the box with every HTTP client, browser, and API gateway. Use Accept header versioning for internal APIs where both client and server teams are small and controlled. Avoid query parameter versioning entirely. When sunsetting an old version, add Sunset and Deprecation headers (RFC 8594) to all v1 responses at least 6 months before removal — this gives integrators time to migrate without surprise breakage.
Pagination: Offset vs Cursor-Based Strategies
Pagination is required for any collection endpoint that could return more than a few dozen items. Two strategies dominate: offset pagination (page number or row offset) and cursor-based pagination (opaque token encoding the last-seen position). Offset pagination is simpler to implement and supports random page access; cursor pagination handles real-time data correctly and performs consistently at scale.
// ── Offset pagination ─────────────────────────────────────────────
// GET /users?offset=0&limit=10 → rows 1-10
// GET /users?offset=10&limit=10 → rows 11-20
// SQL: SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 20
// Problem: OFFSET 20 scans and discards 20 rows — slow on large tables
// Problem: if a row is inserted between pages, next page skips or duplicates a row
app.get("/users", async (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 20, 100);
const offset = Math.max(parseInt(req.query.offset) || 0, 0);
const [users, total] = await Promise.all([
db.query("SELECT * FROM users ORDER BY id LIMIT $1 OFFSET $2", [limit, offset]),
db.query("SELECT COUNT(*) FROM users"),
]);
res.json({
data: users.rows,
meta: { total: parseInt(total.rows[0].count), limit, offset },
});
});
// ── Cursor pagination ─────────────────────────────────────────────
// GET /users?limit=10 → first page; response includes nextCursor
// GET /users?cursor=eyJpZCI6MjB9&limit=10 → next page
// SQL: SELECT * FROM users WHERE id > 20 ORDER BY id LIMIT 10
// Always uses index scan — consistent performance at any depth
// Stable across inserts/deletes: WHERE id > 20 never shifts
function encodeCursor(data) {
return Buffer.from(JSON.stringify(data)).toString("base64url");
}
function decodeCursor(cursor) {
try { return JSON.parse(Buffer.from(cursor, "base64url").toString()); }
catch { return null; }
}
app.get("/users", async (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 20, 100);
const cursor = req.query.cursor ? decodeCursor(req.query.cursor) : null;
let query = "SELECT * FROM users";
const params = [limit + 1]; // fetch one extra to detect hasMore
if (cursor?.lastId) {
query += " WHERE id > $2";
params.push(cursor.lastId);
}
query += " ORDER BY id LIMIT $1";
const users = await db.query(query, params);
const hasMore = users.rows.length > limit;
const page = users.rows.slice(0, limit);
res.json({
data: page,
meta: {
limit,
hasMore,
nextCursor: hasMore ? encodeCursor({ lastId: page[page.length - 1].id }) : null,
},
});
});
// ── Choosing the right strategy ───────────────────────────────────
// Offset: admin tables, static datasets, "jump to page N" UI
// Cursor: feeds, activity logs, real-time data, tables > 10k rowsCursor tokens must be opaque to clients — base64-encode the internal state so clients cannot reverse-engineer or construct cursors. Never expose raw database IDs or offsets directly in cursor strings, as they leak schema details. For APIs that need both "jump to page" and stable real-time feeds, use cursor pagination as the primary strategy and provide a separate count endpoint for UI components that display "Page 3 of 15". See our dedicated JSON pagination guide for more patterns including keyset pagination and search-after.
Error Response Formats: RFC 7807 and JSON:API Errors
A consistent error response format is as important as a consistent success format — clients handle errors more often than successes, and inconsistent error shapes are the most common cause of defensive client-side code. Two standards exist: RFC 7807 Problem Details (IETF) and JSON:API error objects. Both are better than ad-hoc custom formats.
// ── RFC 7807 Problem Details ──────────────────────────────────────
// Content-Type: application/problem+json — HTTP 422
{
"type": "https://jsonic.io/errors/validation-failed",
"title": "Validation Failed",
"status": 422,
"detail": "The request body contains 2 validation errors.",
"instance": "/errors/01HX4K2J",
"errors": [
{ "pointer": "/data/attributes/email", "detail": "must be a valid email address" },
{ "pointer": "/data/attributes/age", "detail": "must be at least 18" }
]
}
// Fields: type (error type URI), title (stable summary), status (HTTP int),
// detail (this occurrence), instance (this error URI — optional)
// Extensions allowed: "errors", "traceId", "requestId", domain-specific fields
// ── Minimal custom format ─────────────────────────────────────────
// Content-Type: application/json — HTTP 422
{
"data": null,
"errors": [
{ "code": "EMAIL_INVALID", "field": "email", "message": "must be a valid email" },
{ "code": "AGE_TOO_YOUNG", "field": "age", "message": "must be at least 18" }
],
"meta": { "requestId": "req_01HX4K2J" }
}
// ── JSON:API error objects ────────────────────────────────────────
// Content-Type: application/vnd.api+json — HTTP 422
{
"errors": [
{
"id": "01HX4K2J",
"status": "422",
"code": "EMAIL_INVALID",
"title": "Invalid Attribute",
"detail": "email must be a valid email address",
"source": { "pointer": "/data/attributes/email" }
}
]
}
// ── Express RFC 7807 helper ───────────────────────────────────────
function problem(res, { type, title, status, detail, instance, ...ext }) {
res.status(status)
.set("Content-Type", "application/problem+json")
.json({ type, title, status, detail, instance, ...ext });
}
problem(res, {
type: "https://jsonic.io/errors/not-found",
title: "Resource Not Found",
status: 404,
detail: `User ${req.params.id} does not exist.`,
});
// ── Security: never expose in error responses ─────────────────────
// Stack traces, SQL queries, file system paths, internal service names
// Always include: requestId (for log correlation), stable error code, human messageRFC 7807 is the recommended standard for new public APIs — it is supported natively by Spring Boot (ProblemDetail), ASP.NET Core (ProblemDetails), and many API frameworks. The type field URI does not need to resolve to an actual page, but it should — linking to documentation for each error type dramatically reduces support requests. The pointer field (JSON Pointer per RFC 6901) precisely identifies the invalid field in the request body, letting clients highlight the exact input that caused the error. See our JSON error handling guide for error monitoring and retry strategy patterns.
The JSON:API Specification: Resources, Relationships, and Links
JSON:API (jsonapi.org) is a complete specification for building JSON APIs that defines resource object format, relationship linking, sparse fieldsets, sorting, filtering, and pagination. Adopting JSON:API means any JSON:API-compliant client library can consume your API without custom integration code. It is more prescriptive than REST — and that is its strength.
// ── JSON:API single resource ──────────────────────────────────────
// GET /users/42 — Content-Type: application/vnd.api+json
{
"data": {
"type": "users",
"id": "42", // always a string, even for integer IDs
"attributes": {
"name": "Alice",
"email": "alice@example.com",
"createdAt": "2024-01-15T10:30:00Z"
},
"relationships": {
"posts": {
"links": { "related": "/users/42/posts" },
"data": [{ "type": "posts", "id": "101" }, { "type": "posts", "id": "102" }]
},
"organization": {
"data": { "type": "organizations", "id": "7" }
}
},
"links": { "self": "/users/42" }
},
"included": [
{
"type": "organizations",
"id": "7",
"attributes": { "name": "Acme Corp" },
"links": { "self": "/organizations/7" }
}
]
}
// ── Collection with pagination ────────────────────────────────────
// GET /posts?page[number]=2&page[size]=10
{
"data": [
{ "type": "posts", "id": "101", "attributes": { "title": "Hello World" } },
{ "type": "posts", "id": "102", "attributes": { "title": "JSON:API Guide" } }
],
"meta": { "total": 47 },
"links": {
"self": "/posts?page[number]=2&page[size]=10",
"first": "/posts?page[number]=1&page[size]=10",
"prev": "/posts?page[number]=1&page[size]=10",
"next": "/posts?page[number]=3&page[size]=10",
"last": "/posts?page[number]=5&page[size]=10"
}
}
// ── Sparse fieldsets: reduce payload size ────────────────────────
// GET /users?fields[users]=name,email&fields[organizations]=name
// Server returns only requested attributes — no new endpoint needed
// ── Node.js: json-api-serializer ─────────────────────────────────
// npm install json-api-serializer
const Serializer = require("json-api-serializer");
const serializer = new Serializer();
serializer.register("user", {
attributes: ["name", "email", "createdAt"],
relationships: {
posts: { type: "post" },
organization: { type: "organization" },
},
});
const response = serializer.serialize("user", {
id: "42", name: "Alice", email: "alice@example.com",
createdAt: "2024-01-15", organization: { id: "7" },
});JSON:API's included array solves the N+1 query problem at the API level — the server fetches related resources in one query and includes them in the single response, rather than forcing clients to make N requests for N related resources. The type field identifies the resource type name ("users"), not the endpoint path. The id field is always a string in JSON:API, even for integer database IDs — this future-proofs the API for UUID migration. For JSON Schema validation of JSON:API responses, the official JSON:API schema is available at jsonapi.org/schema.
Key Terms
- response envelope
- A top-level wrapper object that contains the API payload under a consistent set of keys — typically
data,meta, anderrors. The envelope ensures every response from an API — success and failure — follows the same shape, enabling clients to write a single generic response handler. An envelope with a stablemetakey allows adding pagination metadata, request IDs, and deprecation notices without a breaking change to the primarydataschema. The opposite of an envelope is a bare response — a raw object or array at the top level — which cannot be extended later without a breaking change. - cursor pagination
- A pagination strategy that uses an opaque token (the cursor) to encode the position of the last item seen. The server decodes the cursor on each request and uses it in a WHERE clause (
WHERE id > :lastId) to fetch the next page. Cursor pagination handles inserts and deletes between requests correctly — items cannot be skipped or duplicated — and performs consistently on large tables because the WHERE clause uses an indexed column. The trade-off is that random page access is not supported — you can only advance forward (and optionally backward with aprevCursor). Cursors should be opaque to clients: base64-encode the internal state so clients cannot reverse-engineer or construct cursors manually. - offset pagination
- A pagination strategy using a numeric row offset and page size limit:
SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 20returns rows 21-30. Offset pagination supports random page access ("jump to page 5") and is simple to implement. Two critical flaws limit its use at scale: (1) if rows are inserted or deleted between page requests, the next page may skip a row or return a duplicate; (2) large offset values require the database to scan and discard all preceding rows before returning results, causing query performance to degrade linearly with page depth. Use offset pagination for static datasets, admin interfaces with "jump to page" UIs, and tables under approximately 10,000 rows. - RFC 7807
- An IETF standard (Problem Details for HTTP APIs) that defines a machine-readable JSON format for error responses. The standard defines five fields:
type(a URI identifying the error class),title(stable human-readable summary),status(HTTP status code as integer),detail(description of this specific occurrence), andinstance(an optional URI identifying this error occurrence). RFC 7807 responses useContent-Type: application/problem+json. Extensions are allowed — adderrorsfor field-level validation details,traceId, or domain-specific fields. Supported natively by Spring Boot (ProblemDetail) and ASP.NET Core (ProblemDetails). - JSON:API
- A specification (jsonapi.org) for building JSON APIs that defines a strict format for resource objects, relationships, links, sparse fieldsets, and pagination. Every response has a top-level
datakey containing resource objects with requiredtype,id, andattributeskeys. Relationships appear in arelationshipskey pointing to resource identifier objects; related resources can be included in a top-levelincludedarray to avoid N+1 API requests. JSON:API usesContent-Type: application/vnd.api+json. Client libraries like ember-data and server serializers likejson-api-serializer(Node.js) implement the specification. - versioning strategy
- The mechanism by which an API communicates which contract version a client is requesting. Three strategies: URI path versioning (
/api/v2/users) embeds the version in the URL — most widely adopted, easy to test and route. Accept header versioning (Accept: application/vnd.myapi.v2+json) follows HTTP content negotiation semantics and keeps URIs clean, but is invisible in the browser. Query parameter versioning (/users?version=2) is semantically incorrect and breaks caching — avoid it. UseSunsetandDeprecationresponse headers (RFC 8594) to give clients advance notice before removing old versions. - HTTP status code semantics
- The principle that HTTP status codes must accurately communicate the class and cause of a response so that clients, gateways, monitoring tools, and retry logic can act correctly without parsing the body. Critical distinctions: 400 (malformed request syntax) vs 422 (valid syntax, failed business validation); 401 (missing or invalid credentials) vs 403 (authenticated but not authorized); 404 (resource not found) vs 409 (conflict — do not retry without changing input). Returning 200 OK for all responses forces clients to implement a second error-detection layer by inspecting the body, breaking HTTP-aware infrastructure like load balancers, caches, and alerting systems.
FAQ
Should I use camelCase or snake_case for JSON API fields?
Use camelCase (userId, createdAt) if your primary consumers are JavaScript or TypeScript frontends — it matches the language convention and avoids client-side transformation overhead. Use snake_case (user_id, created_at) if your primary consumers are Python services or PostgreSQL queries, where snake_case is the default convention in models and serializers. Never mix conventions within a single response — a response with both userId and user_name is a defect, not a style choice. 71% of public REST APIs surveyed in 2023 used camelCase, making it the statistical default for public-facing APIs. Document your choice in your OpenAPI spec and enforce it with a serialization middleware or linter so it cannot drift accidentally. For dates, always use ISO 8601 format ("2024-01-15T10:30:00Z") regardless of which naming convention you choose.
How should I structure a JSON API response envelope?
Wrap every response in a consistent envelope with three top-level keys: data (the primary payload — an object for single resources, an array for collections), meta (pagination metadata, request IDs, counts — never required for success), and errors (an array of error objects, present only on failure). On success: set data to the payload and errors to an empty array. On failure: set data to null and errors to the array of error objects. Always use an array for errors, even for a single error — clients that handle arrays work for both one and many errors; clients that expect a single object break when multiple errors occur. The envelope provides backward-compatible evolution: you can add fields to meta at any time without breaking clients that ignore unknown keys. Avoid bare top-level arrays — they cannot be extended without a breaking change.
What HTTP status codes should a JSON API return?
Use semantically correct codes for every response. Success: 200 OK (GET, PUT, PATCH), 201 Created (POST that creates — include Location header), 204 No Content (DELETE with no body). Client errors: 400 Bad Request (malformed JSON or unparseable syntax), 401 Unauthorized (missing or invalid auth credentials), 403 Forbidden (authenticated but lacks permission), 404 Not Found (resource does not exist), 409 Conflict (unique constraint violation), 422 Unprocessable Entity (valid JSON that fails business validation), 429 Too Many Requests (rate limit — include Retry-After header). Server errors: 500 Internal Server Error (unexpected failure — log details server-side, return generic message to client). The critical 400 vs 422 distinction: 400 means the request body could not be parsed; 422 means it was parsed successfully but the content is semantically invalid. Never return 200 OK for errors — it breaks HTTP-aware monitoring and retry logic.
How do I version a JSON REST API?
Three strategies: (1) URI path versioning — /api/v1/users, /api/v2/users. Most widely adopted; easy to test in browser, route in nginx, and document. The recommended choice for public APIs where developer experience matters most. (2) Accept header versioning — Accept: application/vnd.myapi.v2+json. Follows HTTP content negotiation semantics; keeps URIs clean. Better for internal APIs where both client and server teams are small and controlled. Requires Vary: Accept for correct CDN caching. (3) Query parameter versioning — /users?version=2. Avoid — query parameters should filter and sort data, not select API contracts; also breaks caching. When sunsetting an old version, add Sunset and Deprecation response headers (RFC 8594) to all responses from the deprecated version at least 6 months before removal.
What is the difference between offset and cursor pagination in JSON APIs?
Offset pagination uses a numeric row offset: GET /users?offset=20&limit=10 maps to SELECT * FROM users LIMIT 10 OFFSET 20. Simple to implement and supports random page access ("jump to page 5"), but has two flaws: inserts or deletes between page requests cause skipped or duplicated rows; large offset values require the database to scan and discard all preceding rows, degrading performance linearly. Cursor pagination uses an opaque token encoding the last-seen position: GET /users?cursor=eyJpZCI6MjB9&limit=10 decodes to WHERE id > 20 LIMIT 10. Always uses an index scan, so performance is consistent regardless of page depth. Correctly handles inserts and deletes. The trade-off: no random page access; clients can only advance forward. Use cursor pagination for feeds, activity streams, and any table over approximately 10,000 rows. See our JSON pagination guide for implementation examples.
What is RFC 7807 Problem Details for JSON APIs?
RFC 7807 (Problem Details for HTTP APIs) is an IETF standard that defines a machine-readable JSON format for error responses. The standard defines five fields: type (a URI identifying the error class — link it to documentation), title (a stable human-readable summary that does not change between occurrences), status (the HTTP status code as an integer), detail (a human-readable description specific to this occurrence), and instance (an optional URI identifying this specific error). RFC 7807 responses use Content-Type: application/problem+json. You can extend the format freely — add errors (array of field-level validation details with pointer per RFC 6901), traceId, or domain-specific fields. It is supported natively by Spring Boot (ProblemDetail) and ASP.NET Core (ProblemDetails). Using RFC 7807 makes error responses interoperable with any RFC 7807-aware HTTP client library.
What is the JSON:API specification?
JSON:API (jsonapi.org) is a complete specification for building JSON APIs that prescribes the format for resource objects, relationships, links, filtering, sorting, sparse fieldsets, and pagination. Every response has a top-level data key containing resource objects. Each resource object requires three keys: type (string identifying the resource type, e.g., "users"), id (always a string, even for integer database IDs), and attributes (object with the field values). Relationships are expressed in a relationships key pointing to resource identifier objects ({"type": "posts", "id": "101"}). Related resources can be included in a top-level included array to avoid N+1 requests. JSON:API uses Content-Type: application/vnd.api+json. Client libraries (ember-data) and server serializers (json-api-serializer for Node.js, jsonapi-resources for Rails) implement the full specification. Adopting JSON:API means any compliant client can consume your API without custom integration code.
How do I design JSON error responses?
Every error response needs three layers: (1) a semantically correct HTTP status code so clients can classify the error without parsing the body; (2) a machine-readable error code — a stable string constant like "VALIDATION_FAILED" or "USER_NOT_FOUND" that clients can switch on without parsing human-readable text; (3) a human-readable message for developer debugging. Minimal viable format: {"errors": [{"code": "EMAIL_INVALID", "field": "email", "message": "must be a valid email address"}]}. For field-level validation errors, include field (simple field name) or pointer (JSON Pointer per RFC 6901, e.g., "/data/attributes/email") so the client can highlight the exact invalid input. Always return errors as an array — even for a single error — so clients never need to handle both object and array shapes. Use RFC 7807 for a standards-compliant format. Never expose stack traces, SQL queries, internal file paths, or service names in error responses — they are security vulnerabilities. See our JSON error handling guide for error monitoring and retry patterns.
Further reading and primary sources
- JSON:API Specification — Official JSON:API specification covering resource objects, relationships, links, filtering, and pagination
- RFC 7807: Problem Details for HTTP APIs — IETF standard defining the application/problem+json error format with type, title, status, detail, and instance fields
- RFC 8594: The Sunset HTTP Header Field — IETF standard for the Sunset header — communicate API version deprecation and removal dates to clients
- Zalando RESTful API Guidelines — Comprehensive API design guidelines from Zalando covering naming, versioning, pagination, and error handling
- Microsoft REST API Guidelines — Microsoft REST API design guidelines covering consistency, versioning, error formats, and pagination strategies