JSON Best Practices: Naming, Versioning, Security & API Design

Last updated:

JSON best practices for production APIs center on 5 decisions: key naming convention (camelCase for JavaScript clients, snake_case for Python/Ruby clients), date format (always ISO 8601 string, never Unix timestamp integers for human-facing fields), numeric ID representation (string for IDs over 53 bits to prevent JavaScript precision loss), null vs missing field policy, and response envelope structure. The single most impactful JSON best practice is schema validation at API boundaries — validating incoming JSON with Zod or Ajv before processing eliminates an entire class of bugs including prototype pollution, type confusion, and unexpected null handling, reducing API error rates by 60-80% in production systems. This guide covers JSON key naming conventions, date/time serialization, ID and numeric type handling, null vs omission policy, response envelope design, pagination patterns, error response structure (RFC 7807), versioning, security hardening, and performance optimization best practices.

Key Naming Conventions: camelCase, snake_case, and kebab-case

The most important JSON naming rule is to pick one convention and never deviate from it within a single API. camelCase (userId, createdAt, isActive) is the standard for REST APIs consumed by JavaScript or TypeScript clients — it avoids the property accessor awkwardness of obj["user_id"] vs obj.userId. snake_case (user_id, created_at, is_active) is natural for Python (Django, FastAPI), Ruby on Rails, and PostgreSQL, where column names are snake_case by convention. kebab-case (user-id) must be avoided in JSON keys: hyphens are illegal in most language identifiers, requiring bracket notation everywhere.

// ── camelCase — Google, Stripe, GitHub, Twilio standard ────────
{
  "userId": "usr_01abc",
  "firstName": "Alice",
  "lastName": "Smith",
  "emailAddress": "alice@example.com",
  "createdAt": "2026-05-20T14:30:00Z",
  "isActive": true,
  "totalOrderCount": 42
}

// ── snake_case — Python/Ruby/PostgreSQL backend-to-backend ──────
{
  "user_id": "usr_01abc",
  "first_name": "Alice",
  "last_name": "Smith",
  "email_address": "alice@example.com",
  "created_at": "2026-05-20T14:30:00Z",
  "is_active": true,
  "total_order_count": 42
}

// ── SCREAMING_SNAKE_CASE — constants and enum values ────────────
{
  "status": "ORDER_PENDING",
  "errorCode": "VALIDATION_FAILED",
  "paymentMethod": "CREDIT_CARD"
}

// ── kebab-case — DO NOT USE in JSON keys ────────────────────────
// Illegal: requires bracket notation in JS/TS/Python attribute access
// { "user-id": "...", "first-name": "..." }  ← never do this

// ── Server-side transform: camelCase ↔ snake_case ───────────────
// Node.js: humps library
import humps from 'humps'

// Convert snake_case response from DB to camelCase for API output
const dbRow = { user_id: '123', created_at: '2026-05-20' }
const apiResponse = humps.camelize(dbRow)
// → { userId: '123', createdAt: '2026-05-20' }

// Convert camelCase request body from client to snake_case for DB
const requestBody = { firstName: 'Alice', lastName: 'Smith' }
const dbInput = humps.decamelize(requestBody)
// → { first_name: 'Alice', last_name: 'Smith' }

// ── JSON Schema rename with transform ──────────────────────────
// Ajv + ajv-keywords: apply transforms at validation time
// This approach ensures the field name convention is enforced in schema

When inheriting an existing API that mixes conventions, do not mix further — apply a consistent transform at the API boundary and document it. Renaming fields in an existing API is a breaking change: add a deprecation period where both the old and new names are accepted (via the schema's anyOfor a middleware aliasing layer), then remove the old names in the next major version. Google's API Design Guide, Stripe's API reference, and GitHub's REST API all use camelCase — following this convention maximizes interoperability with existing tooling and client code generators.

Date and Time Serialization: ISO 8601 and Timezone Rules

ISO 8601 is the only correct format for dates and datetimes in JSON. A full datetime is "2026-05-20T14:30:00Z" — always include the timezone indicator. Z means UTC and is the clearest choice for machine-to-machine APIs. Offset notation ("2026-05-20T14:30:00+05:30") is valid for preserving the original local time when that matters. Date-only fields use "2026-05-20". Never use Unix timestamp integers (1716220200) in public APIs: they are impossible to read in logs, they encode no timezone context, and they cause subtle bugs when milliseconds vs seconds are confused.

// ── ISO 8601 datetime formats ───────────────────────────────────
{
  // Full datetime with UTC (always prefer Z for APIs)
  "createdAt": "2026-05-20T14:30:00Z",

  // Full datetime with milliseconds (for sub-second precision)
  "processedAt": "2026-05-20T14:30:00.123Z",

  // Full datetime with UTC offset (preserves local time context)
  "scheduledAt": "2026-05-20T09:30:00-05:00",

  // Date only — no time component (birthdays, calendar dates)
  "birthDate": "1990-04-15",

  // Year-month only (billing periods)
  "billingPeriod": "2026-05",

  // ISO 8601 duration (P<date>T<time>)
  "trialDuration": "P14D",          // 14 days
  "subscriptionTerm": "P1Y",        // 1 year
  "sessionTimeout": "PT30M",        // 30 minutes
  "complexDuration": "P1Y2M3DT4H"   // 1 year, 2 months, 3 days, 4 hours
}

// ── DO NOT use Unix timestamps in public APIs ───────────────────
// Bad — requires mental conversion, timezone context lost:
{ "createdAt": 1716220200 }

// Bad — milliseconds vs seconds ambiguity:
{ "createdAt": 1716220200000 }

// ── JSON Schema validation for date/time fields ─────────────────
const dateTimeSchema = {
  type: 'object',
  properties: {
    createdAt: { type: 'string', format: 'date-time' },
    birthDate:  { type: 'string', format: 'date' },
    trialDuration: { type: 'string', format: 'duration' },
  },
}

// Ajv with formats plugin
import Ajv from 'ajv'
import addFormats from 'ajv-formats'

const ajv = new Ajv()
addFormats(ajv)  // enables format: "date-time" validation
const validate = ajv.compile(dateTimeSchema)

// ── Node.js: serializing dates correctly ───────────────────────
const now = new Date()
now.toISOString()  // "2026-05-20T14:30:00.123Z" — always correct
now.getTime()      // 1716220200123 — millisecond timestamp, avoid in JSON
JSON.stringify(now) // calls toISOString() automatically — safe

Store all datetimes internally as UTC and convert to the user's local timezone only at the display layer (never in the API response). If the user's local time matters for the resource — for example, a calendar event that should be shown at 9:00 AM regardless of timezone — store both the UTC datetime and a separate timezone field ("America/New_York"). See the JSON date format guide for a complete comparison of date serialization approaches.

Numeric IDs and Large Number Handling

JavaScript's Number.MAX_SAFE_INTEGER is 2^53 - 1 (9,007,199,254,740,991). Any integer larger than this value is silently corrupted when parsed by JSON.parse() in a browser or Node.js. This is not a theoretical concern: Twitter Snowflake IDs (used for tweet and user IDs since 2010) are 64-bit integers that regularly exceed this limit. The Twitter API famously had to add an id_str string field alongside the numeric id field after JavaScript clients started silently truncating IDs. The safe rule: serialize IDs as strings if they come from distributed systems, snowflake generators, or bigint database columns.

// ── The JavaScript precision problem ───────────────────────────
// This integer exceeds Number.MAX_SAFE_INTEGER:
const tweetId = 1234567890123456789n  // BigInt — safe in JS
JSON.stringify({ id: tweetId })       // TypeError! BigInt not serializable
JSON.stringify({ id: String(tweetId) }) // → { "id": "1234567890123456789" }

// Silent corruption when parsing large integers as JSON numbers:
const raw = '{ "id": 1234567890123456789 }'
JSON.parse(raw).id  // → 1234567890123456800 ← corrupted (rounds to nearest float)

// Safe: return ID as a string
const safe = '{ "id": "1234567890123456789" }'
JSON.parse(safe).id  // → "1234567890123456789" ← exact

// ── ID format comparison ────────────────────────────────────────
{
  // UUID v4 — 128-bit random, string by nature, globally unique
  "id": "550e8400-e29b-41d4-a716-446655440000",

  // ULID — 128-bit, lexicographically sortable, URL-safe string
  "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",

  // nanoid — URL-safe, compact, configurable length
  "id": "V1StGXR8_Z5jdHi6B-myT",

  // Snowflake — 64-bit int, MUST be string in JSON
  "id": "1234567890123456789",

  // Database bigserial — 64-bit int, MUST be string in JSON
  "id": "9876543210"
}

// ── Financial data: use string or integer cents ─────────────────
// NEVER use floating point for money — 0.1 + 0.2 !== 0.3 in IEEE 754
{
  // Bad: floating-point money
  "amount": 10.30,         // may serialize as 10.299999999999999

  // Good: integer cents (multiply by 100)
  "amountCents": 1030,     // 10.30 USD = 1030 cents

  // Good: string decimal (for display and arbitrary precision)
  "amount": "10.30",
  "currency": "USD"
}

// ── Node.js: safely handle BigInt in JSON ───────────────────────
// Custom JSON.stringify replacer for BigInt values
JSON.stringify(
  { id: 1234567890123456789n, name: 'Alice' },
  (key, value) => typeof value === 'bigint' ? String(value) : value
)
// → '{"id":"1234567890123456789","name":"Alice"}'

For financial data, never use JSON floating-point numbers — IEEE 754 cannot represent 0.1 + 0.2 exactly. The two safe patterns are integer cents (store amounts in the smallest currency unit as an integer: 1030 for $10.30) or string decimals ("10.30"). Stripe's API uses integer cents; many financial APIs use string decimals. See the JSON number precision guide for a detailed analysis.

Null vs Omission: Missing Field Policy

The distinction between null and a missing field is semantic: null means "this field is applicable but has no value"; an absent key means "this field is not applicable to this resource". For a user resource, middleName: null means the user was asked for their middle name and has none; omitting middleName entirely might mean the API does not track middle names for this type of user. Choose one policy and apply it uniformly — inconsistency is the leading cause of defensive ?. chains and undefined checks in client code.

// ── Policy 1: Always-include (recommended for most REST APIs) ───
// Every field always appears; absent value = null
{
  "id": "usr_01abc",
  "firstName": "Alice",
  "middleName": null,       // has no middle name (field is applicable)
  "lastName": "Smith",
  "phoneNumber": null,      // has not provided a phone number
  "deletedAt": null,        // not deleted
  "avatarUrl": null         // has not uploaded an avatar
}

// ── Policy 2: Omit-if-null (common in partial-update APIs) ──────
// Fields only appear when they have a value
{
  "id": "usr_01abc",
  "firstName": "Alice",
  "lastName": "Smith"
  // middleName, phoneNumber, deletedAt, avatarUrl omitted entirely
}

// ── Optional vs Nullable matrix ────────────────────────────────
// A field can be:
// 1. Required + not null:  always present, never null
// 2. Required + nullable:  always present, may be null
// 3. Optional + not null:  may be absent, never null when present
// 4. Optional + nullable:  may be absent OR null when present (avoid — ambiguous)

// ── JSON Schema for nullable fields ────────────────────────────
const userSchema = {
  type: 'object',
  required: ['id', 'firstName', 'lastName'],
  properties: {
    id:          { type: 'string' },
    firstName:   { type: 'string' },
    lastName:    { type: 'string' },
    // Required but nullable: field always present, value may be null
    middleName:  { type: ['string', 'null'] },
    deletedAt:   { type: ['string', 'null'], format: 'date-time' },
    // Optional and not null: key only present if value exists
    avatarUrl:   { type: 'string', format: 'uri' },
  },
  additionalProperties: false,
}

// ── TypeScript types matching the matrix ───────────────────────
type User = {
  id: string
  firstName: string
  lastName: string
  middleName: string | null   // required + nullable
  deletedAt: string | null    // required + nullable
  avatarUrl?: string          // optional + not null
}

// ── PATCH vs PUT: partial update field policy ───────────────────
// PUT — send all fields; missing = reset to default
// PATCH — send only changed fields; missing = unchanged; null = clear
const patchBody = {
  middleName: null,     // explicitly clear the middle name
  phoneNumber: "+15555550100"  // update phone number
  // firstName not included = unchanged
}

The always-include policy simplifies client code dramatically: clients never need to distinguish undefined (key absent) from null (key present, value null). TypeScript types align cleanly with string | null rather than string | null | undefined. The only exception is PATCH endpoints, where absent fields mean "do not change" and nullmeans "clear this field" — document this distinction explicitly in your API spec.

Response Envelope and Pagination Design

Every JSON API response should be wrapped in an envelope object — never return a bare JSON array as the top-level response. A bare array ([{ "id": 1 }, { "id": 2 }]) is a breaking constraint: adding pagination metadata, request tracing IDs, or warnings in the future requires wrapping in an object, which is a breaking change for all existing clients. Start with an envelope from day one. The standard envelope uses a data key for the payload, a meta key for pagination and counts, and a links key for navigation URLs.

// ── Single-resource envelope ────────────────────────────────────
// GET /users/123
{
  "data": {
    "id": "usr_01abc",
    "firstName": "Alice",
    "lastName": "Smith",
    "email": "alice@example.com",
    "createdAt": "2026-05-20T14:30:00Z"
  }
}

// ── List envelope with cursor-based pagination ───────────────────
// GET /users?limit=20&cursor=eyJpZCI6MTAwfQ==
{
  "data": [
    { "id": "usr_01abc", "firstName": "Alice", "email": "alice@example.com" },
    { "id": "usr_02def", "firstName": "Bob",   "email": "bob@example.com" }
  ],
  "meta": {
    "count": 20,
    "hasMore": true,
    "nextCursor": "eyJpZCI6MTIwfQ=="
  },
  "links": {
    "self":  "/users?limit=20&cursor=eyJpZCI6MTAwfQ==",
    "next":  "/users?limit=20&cursor=eyJpZCI6MTIwfQ==",
    "first": "/users?limit=20"
  }
}

// ── List envelope with offset/page pagination ───────────────────
// GET /users?page=2&perPage=20
{
  "data": [ /* 20 user objects */ ],
  "meta": {
    "page": 2,
    "perPage": 20,
    "totalCount": 483,
    "totalPages": 25
  },
  "links": {
    "self":  "/users?page=2&perPage=20",
    "next":  "/users?page=3&perPage=20",
    "prev":  "/users?page=1&perPage=20",
    "first": "/users?page=1&perPage=20",
    "last":  "/users?page=25&perPage=20"
  }
}

// ── RFC 7807 error envelope ─────────────────────────────────────
// Content-Type: application/problem+json
{
  "type": "https://jsonic.io/errors/validation-failed",
  "title": "Validation Failed",
  "status": 422,
  "detail": "The request body failed schema validation.",
  "instance": "/users/register",
  "code": "VALIDATION_FAILED",
  "errors": [
    { "field": "email",    "message": "Must be a valid email address." },
    { "field": "password", "message": "Must be at least 8 characters." }
  ]
}

Use cursor-based pagination for feeds, activity streams, and any list that changes frequently — offsets are unstable when rows are inserted or deleted between pages. Use offset pagination for admin tables and user-browsable lists where jumping to a specific page is a UX requirement. Always include links.next so clients can paginate without constructing URLs. See the JSON API design guide and the JSON API pagination guide for extended envelope patterns.

Security Best Practices for JSON APIs

JSON API security requires defense in depth: schema validation, Content-Type enforcement, size limits, algorithm pinning, and HMAC verification each address different attack vectors. The most impactful single control is schema validation with additionalProperties: false — it closes the prototype pollution and parameter injection attack surface in one rule. Prototype pollution via JSON is a real threat: if JSON.parse() produces { "__proto__": { "isAdmin": true } } and the server merges this object into an existing object without validation, it can escalate privileges across all subsequent requests.

import Ajv from 'ajv'
import addFormats from 'ajv-formats'
import express from 'express'

const ajv = new Ajv({ allErrors: true })
addFormats(ajv)

// ── 1. Schema validation with additionalProperties: false ────────
const createUserSchema = {
  type: 'object',
  required: ['email', 'password', 'name'],
  properties: {
    email:    { type: 'string', format: 'email' },
    password: { type: 'string', minLength: 8, maxLength: 128 },
    name:     { type: 'string', minLength: 1, maxLength: 100 },
  },
  additionalProperties: false,  // reject unknown fields — prevents injection
}
const validateCreateUser = ajv.compile(createUserSchema)

// ── 2. Content-Type enforcement ─────────────────────────────────
app.use((req, res, next) => {
  if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
    if (!req.is('application/json')) {
      return res.status(415).json({
        type: 'https://jsonic.io/errors/unsupported-media-type',
        title: 'Unsupported Media Type',
        status: 415,
        detail: 'Content-Type must be application/json',
      })
    }
  }
  next()
})

// ── 3. Request body size limit ──────────────────────────────────
app.use(express.json({ limit: '1mb' }))  // reject payloads > 1 MB

// ── 4. Prototype pollution prevention ───────────────────────────
function safeParse(raw: string): unknown {
  const parsed = JSON.parse(raw)
  // Reject objects with __proto__, constructor, or prototype keys
  const dangerous = ['__proto__', 'constructor', 'prototype']
  const hasDangerous = (obj: unknown): boolean => {
    if (typeof obj !== 'object' || obj === null) return false
    return dangerous.some(k => Object.prototype.hasOwnProperty.call(obj, k)) ||
      Object.values(obj as Record<string, unknown>).some(hasDangerous)
  }
  if (hasDangerous(parsed)) throw new Error('Prototype pollution attempt detected')
  return parsed
}

// ── 5. JWT algorithm pinning ────────────────────────────────────
import jwt from 'jsonwebtoken'

// Always specify algorithms — prevents "none" algorithm attack
const payload = jwt.verify(token, publicKey, {
  algorithms: ['RS256'],  // never use 'none' or allow multiple algorithms
})

// ── 6. HMAC signature verification for webhooks ─────────────────
import crypto from 'crypto'

function verifyWebhookSignature(
  rawBody: Buffer,
  signature: string,
  secret: string
): boolean {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex')
  // Constant-time comparison prevents timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature, 'hex'),
    Buffer.from(expected,  'hex')
  )
}

For CORS configuration, specify an explicit allowlist of origins — never use Access-Control-Allow-Origin: * for authenticated APIs. For cookie-authenticated APIs, add JSON CSRF protection by requiring a custom request header (e.g., X-Requested-With: XMLHttpRequest) — browsers do not send custom headers with cross-origin requests by default. See the JSON security guide for a complete threat model and the Zod JSON validation guide for TypeScript-first schema validation.

Performance Best Practices for JSON APIs

JSON API performance improvements compound: compression reduces payload size by 70-85%, sparse fieldsets cut payload by 50-80% for wide objects, and caching eliminates round trips entirely. The highest-ROI optimization is enabling gzip or Brotli compression — JSON compresses extremely well because field names repeat on every object in a list response. A list of 1,000 user objects with 20 fields each might be 200 KB uncompressed and 15 KB gzip-compressed. The second-highest ROI is adding ETag and Cache-Control headers so unchanged responses return 304 Not Modified instead of re-downloading the full payload.

import express from 'express'
import compression from 'compression'
import crypto from 'crypto'

const app = express()

// ── 1. Gzip/Brotli compression — reduces JSON by 70-85% ─────────
app.use(compression({
  level: 6,       // compression level (1=fast, 9=max; 6 is the sweet spot)
  threshold: 1024 // only compress responses > 1 KB
}))

// ── 2. Sparse fieldsets — return only requested fields ───────────
// GET /users?fields=id,name,email
app.get('/users', async (req, res) => {
  const requestedFields = req.query.fields
    ? (req.query.fields as string).split(',')
    : null

  const users = await db.user.findMany()

  const data = users.map(user => {
    if (!requestedFields) return user
    // Return only the requested fields
    return requestedFields.reduce((acc, field) => {
      if (field in user) acc[field] = (user as Record<string, unknown>)[field]
      return acc
    }, {} as Record<string, unknown>)
  })

  res.json({ data })
})

// ── 3. ETag caching — 304 Not Modified for unchanged data ────────
app.get('/users/:id', async (req, res) => {
  const user = await db.user.findUnique({ where: { id: req.params.id } })
  if (!user) return res.status(404).json({ error: 'Not found' })

  const body = JSON.stringify({ data: user })
  const etag = `"${crypto.createHash('md5').update(body).digest('hex')}"`

  res.setHeader('ETag', etag)
  res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate')

  // Client sends If-None-Match: "<previous etag>"
  if (req.headers['if-none-match'] === etag) {
    return res.status(304).end()  // no body — saves bandwidth
  }

  res.setHeader('Content-Type', 'application/json')
  res.send(body)
})

// ── 4. Streaming large JSON arrays ──────────────────────────────
import { Transform } from 'stream'

app.get('/export/users', async (req, res) => {
  res.setHeader('Content-Type', 'application/json')
  res.write('{"data":[')  // open envelope

  let first = true
  const cursor = db.user.findManyCursor()  // streaming cursor

  for await (const user of cursor) {
    if (!first) res.write(',')
    res.write(JSON.stringify(user))
    first = false
  }

  res.write(']}')  // close envelope
  res.end()
})

// ── 5. Avoid JSON.stringify bottleneck for large payloads ────────
// fast-json-stringify — 2-5x faster for known schemas
import fastJson from 'fast-json-stringify'

const stringifyUser = fastJson({
  type: 'object',
  properties: {
    id:        { type: 'string' },
    firstName: { type: 'string' },
    email:     { type: 'string' },
    createdAt: { type: 'string' },
  },
})
// stringifyUser(user) is 2-5x faster than JSON.stringify(user)

Profile before optimizing — JSON serialization is rarely the bottleneck in production APIs. Database queries and N+1 patterns (fetching related records one by one in a loop) typically dominate response time. Use autocannon or clinic.js to measure actual throughput before and after each optimization. For very large JSON payloads (exports, reports), streaming with chunked transfer encoding avoids buffering the full response in memory and allows clients to start processing before the full payload arrives. See the JSON parsing performance guide for benchmarks and profiling techniques.

Key Terms

camelCase convention
A naming style where the first word is lowercase and each subsequent word starts with an uppercase letter: userId, createdAt, isActive. camelCase is the de facto standard for JSON API keys in REST APIs consumed by JavaScript and TypeScript clients. It is used by Google, Stripe, GitHub, Twilio, and most major public APIs. The convention aligns with JavaScript property access syntax — obj.userId is valid, while obj.user_id requires no special handling but obj['user-id'] requires bracket notation. In JSON, camelCase keys look natural when parsed into JavaScript objects.
ISO 8601
An international standard for date and time representation, published by the International Organization for Standardization. The full datetime format is YYYY-MM-DDTHH:MM:SS.sssZ where T separates the date and time components, Z indicates UTC, and milliseconds are optional. ISO 8601 is the only correct format for dates in JSON APIs — it is sortable lexicographically, unambiguous across locales (unlike M/D/Y vs D/M/Y), includes timezone context, and is parseable by every programming language's standard library. JSON Schema supports ISO 8601 validation via the format: "date-time" keyword (for datetimes) and format: "date" (for date-only fields).
Number.MAX_SAFE_INTEGER
The largest integer that can be exactly represented as a JavaScript Number (IEEE 754 double-precision float): 2^53 - 1, which equals 9,007,199,254,740,991. Integers larger than this value lose precision when stored as a JavaScript Number — the value is silently rounded to the nearest representable float. This affects JSON parsing: JSON.parse() parses all JSON numbers into JavaScript Number values, so any integer ID larger than Number.MAX_SAFE_INTEGER in a JSON response will be corrupted in JavaScript clients. The solution is to serialize such IDs as JSON strings. Node.js 22+ supports JSON.parse(text, { numberMode: 'bigint' }) to parse large integers as BigInt without loss of precision.
sparse fieldset
A mechanism that allows API clients to request only specific fields in a JSON response, reducing payload size and over-fetching. Implemented via a fields query parameter: GET /users?fields=id,name,email returns only those three fields per user object. The JSON:API specification formalizes sparse fieldsets as fields[type]=field1,field2. Sparse fieldsets reduce payload size by 50-80% for wide objects with many fields, and they reduce database load by limiting the columns that need to be fetched. They require the server to dynamically construct the response object from the requested field set, which adds a small amount of server-side complexity.
envelope pattern
A JSON API design convention that wraps the response payload in a top-level object with a data key, rather than returning a bare value or array. A single-resource envelope is { "data": { "id": "1", ... } }; a list envelope is { "data": [...], "meta": {...}, "links": {...} }. The envelope pattern is critical for extensibility: it allows pagination metadata, rate-limit headers, warnings, and request IDs to be added to the response without breaking existing clients. Returning a bare JSON array is the most common early API design mistake — it permanently prevents adding top-level metadata without a breaking change.
RFC 7807
An IETF standard (published 2016, updated as RFC 9457 in 2023) that defines a standard JSON format for HTTP API error responses, called "Problem Details." The format defines five standard fields: type (a URI identifying the error class, links to documentation), title (a short human-readable summary), status (the HTTP status code), detail (a specific, actionable description of the error), and instance (a URI reference to the specific occurrence of the problem). RFC 7807 responses use the Content-Type: application/problem+json header. The standard allows extension with additional fields for machine-readable error codes and validation error arrays. Adopting RFC 7807 makes error responses consistent, machine-readable, and compatible with API tooling that understands the standard.

FAQ

Should JSON API keys use camelCase or snake_case?

camelCase (userId, createdAt) is the de facto standard for public REST APIs consumed by JavaScript clients — it is used by Google, Stripe, GitHub, and Twilio. snake_case (user_id, created_at) is preferred for Python/Ruby/PostgreSQL backend-to-backend APIs because those languages use snake_case natively and ORM field names map directly without transformation. The most important rule is consistency: never mix conventions within a single API. If you must support both JavaScript and Python clients, apply a server-side transform (e.g., humps/decamelize in Node.js, or Django REST Framework's CamelCaseJSONParser) to convert between conventions at the boundary rather than exposing two separate field name sets.

What is the correct format for dates in JSON?

The correct format for dates in JSON is ISO 8601 as a string: "2026-05-20T14:30:00Z" for datetime with UTC timezone, or "2026-05-20" for date-only fields. Always include the timezone offset — Z for UTC is the clearest choice for machine-to-machine APIs. Never use Unix timestamp integers (1716220200) in public or human-facing APIs: they are impossible to read without conversion and cause debugging nightmares in logs. For durations, use ISO 8601 duration format: "P1Y2M3DT4H5M6S". Validate date fields in JSON Schema using format: "date-time". Store datetimes in UTC internally and convert to the user's local timezone only at the display layer.

Why should large integer IDs be stored as strings in JSON?

JavaScript's Number type uses 64-bit IEEE 754 floating-point, which can only safely represent integers up to Number.MAX_SAFE_INTEGER (2^53 - 1 = 9,007,199,254,740,991). Any integer larger than this value loses precision when parsed by JSON.parse() in a browser or Node.js — the value is silently rounded to the nearest representable float. Twitter Snowflake IDs regularly exceed this limit, which is why the Twitter API returns both id (a number, may be truncated) and id_str (a string, always exact). The safe rule: if your ID source is a distributed system, a snowflake generator, or a database with bigint primary keys in a high-traffic system, always serialize IDs as JSON strings. UUIDs are already strings and are the safest ID format for JSON APIs.

What is the difference between null and a missing field in JSON?

In JSON, null means the field exists but has no value — "this field is applicable to this resource, but currently has no data." A missing field (key absent from the object) means "this field is not applicable to this resource at all." For example, middleName: null means a user has no middle name; omitting middleName entirely might mean the API does not know whether they have one. The critical rule is to pick one convention and apply it consistently: either always include all fields (with null for absent values) or only include fields that have values. The always-include policy is safer for clients — they never need to distinguish undefined from null. Document your chosen convention explicitly in your API spec and validate it with JSON Schema using the nullable pattern: type: ["string", "null"].

How should a JSON REST API paginate results?

Use cursor-based pagination for feeds and timelines: the response includes { "data": [...], "meta": { "nextCursor": "eyJpZCI6MTAwfQ==", "hasMore": true } }. Cursors are stable across inserts and deletes — unlike offset pagination, which skips or duplicates items when the list changes between pages. Use offset pagination for admin tables and user-browsable lists where jumping to a specific page is required: { "data": [...], "meta": { "page": 2, "perPage": 20, "totalCount": 483, "totalPages": 25 } }. Always wrap paginated responses in a data envelope — never return a bare JSON array as the top-level response, because it prevents adding pagination metadata without a breaking API change. Include links.next so clients can paginate without constructing URLs manually.

What should a JSON error response look like?

JSON error responses should follow RFC 7807 (Problem Details for HTTP APIs): { "type": "https://example.com/errors/validation-failed", "title": "Validation Failed", "status": 422, "detail": "The email field must be a valid email address.", "instance": "/users/register" }. The type field is a URI identifying the error class. title is a short human-readable summary. status mirrors the HTTP status code. detail is a specific, actionable description. Extend RFC 7807 with a code field for machine-readable error classification and an errors array for field-level validation errors: [{ "field": "email", "message": "Invalid format" }]. Use the Content-Type: application/problem+json header. Never return plain string error messages — clients cannot reliably parse them.

How do I make a JSON API secure?

Securing a JSON API requires multiple layers. First, validate all incoming JSON with a schema library (Zod, Ajv, or JSON Schema) with additionalProperties: false — rejecting unknown fields prevents prototype pollution and unexpected field injection. Second, validate the Content-Type header is application/json before parsing. Third, set a request body size limit (express.json({ limit: "1mb" })). Fourth, pin JWT algorithms explicitly (algorithms: ["RS256"]) to prevent the "none" algorithm attack. Fifth, verify HMAC signatures on webhooks before processing the payload. Sixth, implement rate limiting on all endpoints. Seventh, use CORS with an explicit origin allowlist — never wildcard (*) for authenticated APIs. Enable JSON CSRF protection for cookie-authenticated APIs by requiring a custom request header.

How do I improve JSON API performance?

JSON API performance improvements stack. Enable gzip/Brotli compression first — JSON compresses by 70-85% due to repetitive key names, making this the single highest-impact optimization. Implement sparse fieldsets — allow clients to request only the fields they need (e.g., ?fields=id,name,email), reducing payload size by 50-80% for wide objects. Add ETag and Cache-Control headers for GET responses — clients can revalidate with If-None-Match to get 304 Not Modified responses instead of re-downloading unchanged data. For large collections, stream JSON responses instead of buffering in memory. Avoid JSON.stringify() on large payloads in the hot path — use fast-json-stringify for 2-5x faster serialization with known schemas. Profile with clinic.js or autocannon before optimizing — database queries and N+1 patterns usually dominate, not JSON serialization itself.

Further reading and primary sources