JSON Content Negotiation: Accept Headers, Media Types & API Versioning

Last updated:

Content negotiation is the HTTP mechanism that lets a single URL serve different representations of the same resource based on client capabilities. The client sends an Accept header declaring which media types it can process — Accept: application/json being the most common for APIs — and the server responds with Content-Type: application/json confirming what it sent. If the server cannot satisfy any format in the Accept header, it returns 406 Not Acceptable. This guide goes beyond the basics: it covers vendor media types like application/vnd.api+json (JSON:API) and application/problem+json (RFC 7807 error responses), API versioning via custom Content-Type, quality factor (q-value) priority algorithms, and implementing negotiation in Express and Next.js. Understanding content negotiation is essential for building APIs that interoperate correctly with diverse clients — browsers, mobile apps, CLI tools, and other services.

How Content Negotiation Works for JSON APIs

Content negotiation is defined in RFC 7231 as "proactive negotiation" — the client advertises its preferences and the server selects the best representation. The Accept header lists the media types the client can handle, optionally with quality values. The server picks the best match from its supported formats. If no match is possible, it returns 406. This mechanism is what makes REST APIs truly resource-oriented: the same URL /users/42 can return JSON for API clients, HTML for browsers, and CSV for data export tools.

# Client requests JSON — explicit
GET /api/users/42 HTTP/1.1
Accept: application/json

# Server confirms format in response
HTTP/1.1 200 OK
Content-Type: application/json
Vary: Accept

{"id": 42, "name": "Alice", "email": "alice@example.com"}

# ── Accept header formats ──────────────────────────────────────────────
# Single type (most common)
Accept: application/json

# Multiple types with quality values
Accept: application/json;q=0.9, text/csv;q=0.8, */*;q=0.1

# Vendor type for JSON:API
Accept: application/vnd.api+json

# Any JSON (wildcard subtype)
Accept: application/*

# Browser default — accepts everything (JSON APIs should still check)
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8

# ── 406 Not Acceptable flow ──────────────────────────────────────────
# Client requests only XML — API only produces JSON
GET /api/users/42 HTTP/1.1
Accept: application/xml

# Server cannot satisfy the request
HTTP/1.1 406 Not Acceptable
Content-Type: application/json

{
  "error": "Not Acceptable",
  "message": "This API supports: application/json, application/vnd.api+json",
  "supported": ["application/json", "application/vnd.api+json"]
}

# ── Vary header — critical for caching ───────────────────────────────
# Without Vary: Accept, a CDN caches the first response (JSON)
# and serves it to all clients including those requesting CSV
HTTP/1.1 200 OK
Content-Type: application/json
Vary: Accept              # <-- CDN creates separate cache entries per Accept value
Cache-Control: max-age=300

The Vary: Accept header is non-negotiable when serving multiple formats from the same URL. Without it, CDNs and proxies will cache one representation and serve it to all clients regardless of their Accept header. Always include Vary: Accept on any endpoint that performs content negotiation. For JSON caching strategies including CDN configuration, see the linked guide.

JSON Media Types: application/json vs Vendor Types

application/json is the IANA-registered, generic JSON media type defined in RFC 8259. It means "the body is valid JSON" — nothing more. Vendor media types (prefixed with vnd.) are registered by organizations to indicate a more specific document structure. They follow the pattern application/vnd.{organization}.{format}+json. The +json suffix indicates the format is a specialization of JSON — parseable by any JSON parser, but with additional structural constraints.

# ── Registered vendor media types relevant to JSON APIs ──────────────

# application/json — generic JSON (RFC 8259)
# Use when: your response is JSON but follows no specific specification
Content-Type: application/json

# application/vnd.api+json — JSON:API specification (jsonapi.org)
# Use when: response conforms to JSON:API resource object structure
Content-Type: application/vnd.api+json

# application/problem+json — RFC 7807 Problem Details for HTTP APIs
# Use when: returning structured error responses
Content-Type: application/problem+json

# application/merge-patch+json — RFC 7396 JSON Merge Patch
# Use when: PATCH body is a merge-patch document
Content-Type: application/merge-patch+json

# application/json-patch+json — RFC 6902 JSON Patch operations
# Use when: PATCH body is an array of JSON Patch operations
Content-Type: application/json-patch+json

# ── Example: application/problem+json (RFC 7807) error response ───────
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type": "https://api.example.com/errors/validation-failed",
  "title": "Validation Failed",
  "status": 422,
  "detail": "The 'email' field must be a valid email address.",
  "instance": "/api/users",
  "invalid-params": [
    {"name": "email", "reason": "must match pattern ^[^@]+@[^@]+$"}
  ]
}

# ── Example: custom vendor type for your own API ──────────────────────
# Register with IANA or use as unregistered type for internal APIs
Content-Type: application/vnd.acme-corp.order.v2+json

# ── JSON:API required content type — no parameters allowed ───────────
# CORRECT: exact match required
Content-Type: application/vnd.api+json

# WRONG: JSON:API spec requires 415 Unsupported Media Type for this
Content-Type: application/vnd.api+json; charset=utf-8   # rejected

# ── Checking media type in JavaScript ─────────────────────────────────
function isJsonApiResponse(response) {
  const ct = response.headers.get('Content-Type') || ''
  return ct.split(';')[0].trim() === 'application/vnd.api+json'
}

function isProblemJson(response) {
  const ct = response.headers.get('Content-Type') || ''
  return ct.includes('application/problem+json')
}

Choosing between application/json and a vendor type is a commitment: if you advertise application/vnd.api+json, your clients will expect the full JSON:API document structure including data, type, id, attributes, and relationships keys. Mixing the content types signals unreliability. Use application/problem+json for all error responses regardless of your success response type — it is the RFC standard for structured API errors. For JSON API error handling with RFC 7807 patterns, see the linked guide.

API Versioning via Content-Type

Header-based API versioning encodes the version in the media type, keeping URLs stable across versions. The client sends Accept: application/vnd.myapi.v2+json and the server routes to the v2 handler, responding with Content-Type: application/vnd.myapi.v2+json. This treats version as a representation detail rather than a resource identifier — semantically correct in REST architecture. It also allows gradual migration: different clients can use different versions simultaneously via the same URL.

# ── Vendor type versioning pattern ──────────────────────────────────
# Client requests v1 response
GET /api/users/42 HTTP/1.1
Accept: application/vnd.myapi.v1+json

# Server responds with v1 structure
HTTP/1.1 200 OK
Content-Type: application/vnd.myapi.v1+json

{"id": 42, "name": "Alice"}   # v1: flat structure

# Client requests v2 response
GET /api/users/42 HTTP/1.1
Accept: application/vnd.myapi.v2+json

# Server responds with v2 structure
HTTP/1.1 200 OK
Content-Type: application/vnd.myapi.v2+json

{                              # v2: nested profile object
  "id": 42,
  "profile": {"name": "Alice", "displayName": "Alice Smith"},
  "createdAt": "2026-01-01T00:00:00Z"
}

# ── Alternative: Accept-Version header (non-standard, widely used) ────
GET /api/users/42 HTTP/1.1
Accept: application/json
Accept-Version: 2

# ── Express.js implementation — vendor type versioning ────────────────
import express from 'express'
const app = express()

const VERSION_REGEX = /^application/vnd.myapi.v(d+)+json$/

app.get('/api/users/:id', (req, res) => {
  const acceptHeader = req.get('Accept') || 'application/json'

  // Parse version from vendor media type
  const match = VERSION_REGEX.exec(acceptHeader)
  const version = match ? parseInt(match[1], 10) : 1

  if (version === 1) {
    res.set('Content-Type', 'application/vnd.myapi.v1+json')
    return res.json({ id: req.params.id, name: 'Alice' })
  }

  if (version === 2) {
    res.set('Content-Type', 'application/vnd.myapi.v2+json')
    return res.json({
      id: req.params.id,
      profile: { name: 'Alice', displayName: 'Alice Smith' },
      createdAt: '2026-01-01T00:00:00Z',
    })
  }

  // Unsupported version — 406
  res.status(406).json({
    error: 'Not Acceptable',
    supported: ['application/vnd.myapi.v1+json', 'application/vnd.myapi.v2+json'],
  })
})

The practical tradeoff: vendor type versioning is architecturally cleaner but harder to test in browsers (you cannot type a media type into the URL bar) and requires more complex OpenAPI documentation. URL versioning (/v2/users) is simpler and more debuggable. Many production APIs use URL versioning for external public APIs and header versioning for internal service-to-service communication. For a detailed comparison of versioning strategies, see the JSON API versioning guide.

Quality Factor (q) Values for Multiple Formats

Quality values (q-values) are decimal weights from 0.0 to 1.0 that express the client's preference ordering when it can accept multiple formats. The server uses these weights to select the best format it can produce. Understanding the q-value algorithm prevents subtle bugs in multi-format APIs — particularly when a wildcard entry */* appears in the Accept header.

# ── Q-value syntax ───────────────────────────────────────────────────
# format: media-type;q=weight  (weight: 0.0 to 1.0, default 1.0)

Accept: application/json;q=0.9, text/csv;q=0.8, */*;q=0.1
#       ^^^ most preferred    ^^^ second         ^^^ last resort

# Explicit rejection: q=0 means "do not send this"
Accept: */*;q=0, application/json
# Means: ONLY send application/json — reject everything else

# No q-value = q=1.0 (most preferred)
Accept: application/json                  # q=1.0 implied
Accept: application/json, text/csv        # both at q=1.0; server picks preference

# ── Server-side priority algorithm ────────────────────────────────────
# Given: Accept: application/json;q=0.9, text/*;q=0.7, */*;q=0.1
# Supported: ["application/json", "text/csv", "application/xml"]
#
# For each supported type, find best matching Accept entry:
#   application/json  -> matches "application/json;q=0.9" exactly  -> 0.9
#   text/csv          -> matches "text/*;q=0.7"                    -> 0.7
#   application/xml   -> matches "*/*;q=0.1"                       -> 0.1
#
# Best match: application/json (highest weight 0.9)

# ── Implementation: manual q-value parser ─────────────────────────────
function parseAccept(header) {
  // Returns sorted array of {type, q} objects, highest q first
  return header
    .split(',')
    .map(part => {
      const [mediaType, ...params] = part.trim().split(';')
      const qParam = params.find(p => p.trim().startsWith('q='))
      const q = qParam ? parseFloat(qParam.split('=')[1]) : 1.0
      return { type: mediaType.trim(), q }
    })
    .sort((a, b) => b.q - a.q)   // descending by q
}

function negotiate(acceptHeader, supported) {
  const preferences = parseAccept(acceptHeader || '*/*')

  for (const { type, q } of preferences) {
    if (q === 0) continue  // explicitly rejected

    const match = supported.find(s => {
      if (type === '*/*') return true
      const [tType, tSub] = type.split('/')
      const [sType, sSub] = s.split('/')
      if (tType !== sType && tType !== '*') return false
      if (tSub !== sSub && tSub !== '*') return false
      return true
    })

    if (match) return match
  }

  return null  // no match -> 406
}

// Usage
const best = negotiate(
  'application/json;q=0.9, text/csv;q=0.8',
  ['text/csv', 'application/json']
)
// Returns 'application/json' (q=0.9 > q=0.8)

const none = negotiate('application/xml', ['application/json'])
// Returns null -> send 406

The specificity rule applies when q-values are equal: application/json (exact match) beats application/* (wildcard subtype) beats */* (full wildcard), even at the same q-value. This prevents the wildcard from "stealing" preference from a more specific type the server supports. Most API clients send simple Accept: application/json without q-values, so the algorithm rarely needs to handle complex cases in practice — but implementing it correctly matters for browser-facing APIs where the browser sends a full multi-type Accept header.

Implementing Content Negotiation in Express/Next.js

Express provides req.accepts() which implements the full RFC 7231 negotiation algorithm including q-values and wildcards. Next.js Route Handlers require manual Accept header parsing since there is no built-in negotiation helper. Both approaches should always set Vary: Accept and return a proper 406 response when no match is found.

// ── Express: built-in negotiation with req.accepts() ─────────────────
import express from 'express'
import { stringify } from 'csv-stringify/sync'

const app = express()

app.get('/api/reports/sales', (req, res) => {
  // req.accepts() handles q-values, wildcards, and specificity automatically
  // Returns the best match from your supported list, or false if none match
  const format = req.accepts(['application/json', 'text/csv'])

  if (!format) {
    return res.status(406).json({
      error: 'Not Acceptable',
      supported: ['application/json', 'text/csv'],
    })
  }

  const data = getSalesData()  // [{product, revenue, units}, ...]

  // Always set Vary for negotiated endpoints
  res.set('Vary', 'Accept')

  if (format === 'text/csv') {
    const csv = stringify(data, { header: true })
    return res
      .set('Content-Type', 'text/csv; charset=utf-8')
      .set('Content-Disposition', 'attachment; filename=sales.csv')
      .send(csv)
  }

  // Default: JSON
  res.json({ data, total: data.length })
})

// ── Express: vendor type negotiation ─────────────────────────────────
app.get('/api/orders/:id', (req, res) => {
  const accept = req.get('Accept') || ''

  // Check for JSON:API vendor type first
  if (accept.includes('application/vnd.api+json')) {
    const order = getOrder(req.params.id)
    res.set('Content-Type', 'application/vnd.api+json')
    res.set('Vary', 'Accept')
    return res.json({
      data: {
        type: 'orders',
        id: order.id,
        attributes: { status: order.status, amount: order.amount },
      },
    })
  }

  // Fall back to plain JSON
  if (req.accepts('application/json')) {
    const order = getOrder(req.params.id)
    res.set('Vary', 'Accept')
    return res.json(order)
  }

  res.status(406).json({ error: 'Not Acceptable' })
})

// ── Next.js App Router: Route Handler ────────────────────────────────
// app/api/reports/sales/route.ts
import { NextRequest, NextResponse } from 'next/server'

export async function GET(req: NextRequest) {
  const accept = req.headers.get('accept') || '*/*'

  // Simple parse: check for specific types (no full q-value algorithm needed
  // for most APIs — add parseAccept() from Section 4 for full compliance)
  const wantsJson = accept.includes('application/json') || accept.includes('*/*')
  const wantsCsv  = accept.includes('text/csv')

  // CSV takes precedence if explicitly requested
  if (wantsCsv && !accept.includes('application/json')) {
    const data = getSalesData()
    const csv  = toCSV(data)
    return new NextResponse(csv, {
      headers: {
        'Content-Type': 'text/csv; charset=utf-8',
        'Content-Disposition': 'attachment; filename=sales.csv',
        'Vary': 'Accept',
      },
    })
  }

  if (wantsJson) {
    const data = getSalesData()
    return NextResponse.json(
      { data, total: data.length },
      { headers: { 'Vary': 'Accept' } }
    )
  }

  // 406 Not Acceptable
  return NextResponse.json(
    { error: 'Not Acceptable', supported: ['application/json', 'text/csv'] },
    { status: 406 }
  )
}

In Express, req.accepts() is the recommended approach — it handles the full q-value algorithm and specificity rules automatically. In Next.js Route Handlers, parse the Accept header manually for simple cases or import a library like accepts (the same package Express uses internally) for full RFC compliance. Always test 406 handling: send Accept: application/xml to your endpoint and verify it returns 406 with a helpful error message listing supported types. For JSON API design patterns including response structure conventions, see the linked guide.

JSON:API Specification Content-Type

The JSON:API specification (jsonapi.org) defines strict rules around the application/vnd.api+json media type that differ from general content negotiation. The spec requires exact matching with no parameters — any parameter (including charset) makes the media type non-conformant. This strictness exists to enable unambiguous identification of JSON:API documents and compound document parsing.

# ── JSON:API strict Content-Type rules ───────────────────────────────

# REQUEST: client must send exactly this (no parameters)
POST /api/articles HTTP/1.1
Content-Type: application/vnd.api+json    # correct
Accept: application/vnd.api+json

# These MUST be rejected with 415 Unsupported Media Type (spec §7.1):
Content-Type: application/vnd.api+json; charset=utf-8    # parameter: reject
Content-Type: application/vnd.api+json; ext=bulk         # parameter: reject

# If client sends Accept: application/vnd.api+json with parameters,
# server must respond 406 Not Acceptable (spec §7.1)
Accept: application/vnd.api+json; version=1   # parameters: 406

# ── JSON:API document structure ───────────────────────────────────────
# RESPONSE: server returns application/vnd.api+json
HTTP/1.1 200 OK
Content-Type: application/vnd.api+json

{
  "data": {
    "type": "articles",
    "id": "1",
    "attributes": {
      "title": "JSON Content Negotiation",
      "body": "Content negotiation allows...",
      "created": "2026-01-29T10:00:00Z"
    },
    "relationships": {
      "author": {
        "links": { "related": "/api/users/42" },
        "data": { "type": "people", "id": "42" }
      }
    },
    "links": { "self": "/api/articles/1" }
  },
  "included": [
    {
      "type": "people",
      "id": "42",
      "attributes": { "name": "Alice Smith" }
    }
  ]
}

// ── Express middleware: enforce JSON:API content type ─────────────────
function enforceJsonApi(req, res, next) {
  // Check request Content-Type for mutation methods
  if (['POST', 'PATCH', 'PUT'].includes(req.method)) {
    const ct = req.get('Content-Type') || ''
    const mediaType = ct.split(';')[0].trim()

    if (ct.includes(';') || mediaType !== 'application/vnd.api+json') {
      return res.status(415).json({
        errors: [{
          status: '415',
          title: 'Unsupported Media Type',
          detail: 'Content-Type must be exactly application/vnd.api+json with no parameters',
        }],
      })
    }
  }

  // Check Accept header — reject if it has parameters
  const accept = req.get('Accept') || ''
  const hasParamVndApiJson = /application/vnd.api+jsons*;/.test(accept)
  if (hasParamVndApiJson) {
    return res.status(406).json({
      errors: [{ status: '406', title: 'Not Acceptable' }],
    })
  }

  next()
}

// Apply to all JSON:API routes
app.use('/api', enforceJsonApi)

The no-parameters rule is the most common source of JSON:API integration bugs. Many HTTP clients and frameworks automatically append ; charset=utf-8 to Content-Type headers — this must be stripped before sending to a JSON:API server. In fetch, set the header explicitly: headers: { 'Content-Type': 'application/vnd.api+json' } rather than relying on automatic Content-Type detection. For JSON:API specification implementation patterns including relationships and pagination, see the linked guide.

Content Negotiation with Compression

Content negotiation applies to encoding (compression) as well as media types. The Accept-Encoding header lets clients request compressed responses — gzip, br (Brotli), and deflate are the common options. The server responds with Content-Encoding indicating the applied compression. JSON compresses exceptionally well — 70–90% size reduction is typical — making Accept-Encoding negotiation high-value for JSON APIs.

# ── Client requests compression ──────────────────────────────────────
GET /api/products HTTP/1.1
Accept: application/json
Accept-Encoding: br, gzip;q=0.8, deflate;q=0.5

# Server responds with Brotli (highest q-value and supported)
HTTP/1.1 200 OK
Content-Type: application/json
Content-Encoding: br        # compressed with Brotli
Vary: Accept, Accept-Encoding

# ── Vary must include Accept-Encoding ────────────────────────────────
# CDN must cache separate copies: one gzip, one br, one uncompressed
Vary: Accept, Accept-Encoding

# ── Express: enable automatic gzip/br compression ─────────────────────
import compression from 'compression'

// compression() automatically handles Accept-Encoding negotiation
// and sets Content-Encoding + updates Vary
app.use(compression({
  // Only compress responses above 1KB (not worth it for small JSON)
  threshold: 1024,
  // Filter: compress JSON, not binary files
  filter: (req, res) => {
    const ct = res.getHeader('Content-Type') || ''
    return /json|text/.test(ct.toString())
  },
}))

// Now all JSON responses > 1KB are automatically compressed
app.get('/api/products', (req, res) => {
  const products = getProducts()  // large array
  res.json(products)              // compression middleware handles gzip/br
})

// ── Manual gzip in Node.js (without middleware) ───────────────────────
import { gzip } from 'node:zlib'
import { promisify } from 'node:util'
const gzipAsync = promisify(gzip)

app.get('/api/bulk-export', async (req, res) => {
  const acceptEncoding = req.get('Accept-Encoding') || ''
  const acceptsGzip = /gzip/.test(acceptEncoding)
  const acceptsBr   = /br/.test(acceptEncoding)

  const data = JSON.stringify(await getLargeDataset())

  if (acceptsBr) {
    // Brotli via zlib (Node 10.16+)
    const { brotliCompress } = await import('node:zlib')
    const compressed = await promisify(brotliCompress)(data)
    return res
      .set('Content-Type', 'application/json')
      .set('Content-Encoding', 'br')
      .set('Vary', 'Accept, Accept-Encoding')
      .send(compressed)
  }

  if (acceptsGzip) {
    const compressed = await gzipAsync(data)
    return res
      .set('Content-Type', 'application/json')
      .set('Content-Encoding', 'gzip')
      .set('Vary', 'Accept, Accept-Encoding')
      .send(compressed)
  }

  // No compression — send plain JSON
  res
    .set('Content-Type', 'application/json')
    .set('Vary', 'Accept, Accept-Encoding')
    .send(data)
})

// ── JSON + gzip compression ratios ────────────────────────────────────
// Typical JSON payload sizes:
// Original:    100 KB  (repetitive field names, string values)
// gzip:         15 KB  (85% reduction)
// brotli:       12 KB  (88% reduction — better for text)
// msgpack:      70 KB  (30% reduction — no compression)
// msgpack+gzip: 12 KB  (similar to brotli on JSON)

// Conclusion: JSON + Brotli ≈ binary formats + gzip
// For most APIs, JSON with Accept-Encoding: br is the pragmatic choice

The interaction between Accept and Accept-Encoding means the Vary header must list both: Vary: Accept, Accept-Encoding. This tells CDNs to store separate cache entries for every combination of media type and encoding. For most APIs, the Express compression middleware handles all of this automatically — use manual gzip only when you need fine-grained control over which responses are compressed or when running in an environment that does not support middleware. For JSON compression benchmarks including msgpack and protocol buffer comparisons, see the linked guide.

Key Terms

Content Negotiation
The HTTP mechanism by which a client and server agree on the representation format for a resource. Defined in RFC 7231, proactive (server-driven) negotiation uses the Accept, Accept-Encoding, Accept-Language, and Accept-Charset request headers to communicate client capabilities. The server selects the best matching representation and confirms the choice in the Content-Type and Content-Encoding response headers. If no match is possible, the server returns 406 Not Acceptable. Reactive (agent-driven) negotiation is an alternative where the server returns a 300 Multiple Choices response listing available representations and the client selects one — rarely used in practice.
Media Type
A two-part identifier for the format of data transmitted over HTTP, structured as type/subtype with optional parameters. Registered with IANA (Internet Assigned Numbers Authority). The type is a category (application, text, image, audio, video, multipart). The subtype identifies the specific format. Vendor types use the vnd. prefix (e.g., application/vnd.api+json). The +json suffix suffix indicates the format is a JSON-based specialization, parseable by any JSON parser. Parameters follow a semicolon: text/csv; charset=utf-8.
Accept Header
An HTTP request header that declares the media types the client can process in the response. Sent by the client to drive proactive content negotiation. Syntax: Accept: type/subtype;q=weight, type2/subtype2;q=weight2. Multiple types are comma-separated. Each type can carry a quality value (q-value) from 0.0 to 1.0 indicating preference — omitting q defaults to 1.0. The wildcard */* matches any type; type/* matches any subtype of the given type. The server selects the highest-weight type it can produce from the intersection of accepted and supported types.
Content-Type
An HTTP header that describes the media type of the body in a request or response. As a request header (on POST, PUT, PATCH): tells the server what format the request body is in. As a response header: tells the client what format the response body is in, confirming the result of content negotiation. Format: Content-Type: media-type; parameter=value. Common values: application/json, application/vnd.api+json, text/csv; charset=utf-8, application/problem+json. Distinct from the Accept header: Accept expresses a preference for the future response, Content-Type describes the current body.
Vendor Media Type
A media type prefixed with vnd. that identifies a format specific to a vendor or organization. Registered with IANA under the vendor tree. Examples: application/vnd.api+json (JSON:API), application/vnd.github+json (GitHub API), application/vnd.ms-excel. The +json structured syntax suffix indicates the format is JSON-based. Vendor types signal a specific document structure beyond generic JSON — clients that understand the type can use its structure (resource objects, pagination links, error objects) rather than treating the body as opaque JSON. Using a vendor type is a commitment to implement the full specification it identifies.
Quality Factor (q-value)
A decimal weight from 0.000 to 1.000 appended to a media type in the Accept header to express preference ordering. Syntax: Accept: application/json;q=0.9, text/csv;q=0.8. A q-value of 1.0 is the default (most preferred); values are compared numerically with higher being more preferred. A q-value of 0 means "not acceptable" — the client explicitly rejects that format. When multiple accepted types match a server's capabilities, the server selects the one with the highest q-value. When q-values are equal, specificity breaks ties: application/json beats application/* beats */*.
406 Not Acceptable
The HTTP status code returned when the server cannot produce a response in any format listed in the client's Accept header. Defined in RFC 7231 Section 6.5.6. The response body should describe which formats the server supports, ideally in a format the client might understand (plain text or minimal JSON). Distinct from 415 Unsupported Media Type (which means the server cannot process the request body format) and 400 Bad Request (which means the request itself was malformed). Correct implementation: if the client sends no Accept header, the server should default to its preferred format and not return 406. If the client sends Accept: */*, the server should respond with its preferred format.
Proactive vs Reactive Negotiation
Two content negotiation models defined in RFC 7231. Proactive (server-driven) negotiation: the client sends preference headers (Accept, Accept-Encoding, Accept-Language) and the server selects the best representation unilaterally. This is the dominant model for JSON APIs — fast, requires one round-trip, but the server may not always know the optimal choice. Reactive (agent-driven) negotiation: the server returns 300 Multiple Choices with a list of available representations; the client selects one and sends a second request. Rarely used because it requires two round-trips and browser support for 300 responses is inconsistent. Most modern APIs use proactive negotiation exclusively.

FAQ

What is the difference between Accept and Content-Type headers in JSON APIs?

Accept and Content-Type serve opposite roles. Accept is a request header the client sends to declare which response formats it can process — Accept: application/json says "please respond with JSON". Content-Type describes the body of an actual message: as a request header it tells the server the format of the request body (e.g., Content-Type: application/json on a POST body); as a response header it confirms what format the server sent. In a typical JSON API interaction: the client sends Accept: application/json and the server responds with Content-Type: application/json. If the server cannot produce a format matching Accept, it returns 406 Not Acceptable. If the server cannot process a request body format indicated by Content-Type, it returns 415 Unsupported Media Type. The distinction matters for debugging: a 406 means change your Accept header; a 415 means change your Content-Type and request body format.

When should I use application/vnd.api+json instead of application/json?

Use application/vnd.api+json only when your API fully conforms to the JSON:API specification (jsonapi.org). JSON:API defines a strict document structure: a top-level data key containing resource objects with id, type, attributes, and relationships; a links object for pagination and self-links; included for compound documents (side-loaded related resources); and an errors array for error responses. By using the vendor type, you signal to clients (like Ember Data or json-api-serializer) that they can apply spec-compliant parsing — including automatic relationship resolution and pagination link following. The JSON:API spec has a critical no-parameters rule: Content-Type: application/vnd.api+json; charset=utf-8 must be rejected with 415. Use application/json for all other JSON responses. Never mix the two within the same API surface.

How do I implement API versioning using Accept headers?

Use a custom vendor media type that encodes the version: Accept: application/vnd.myapi.v2+json. The server parses the Accept header with a regex like /vnd\.myapi\.v(\d+)\+json/, extracts the version number, and routes to the appropriate handler. The response includes Content-Type: application/vnd.myapi.v2+json to confirm which version was served. This keeps URLs stable — /users/42 refers to the same resource regardless of version, and the version is a representation negotiation detail. An alternative non-standard approach is a custom Accept-Version: 2 header, used by services like Stripe. The tradeoff against URL versioning: header versioning is architecturally cleaner but harder to test in browsers and requires OpenAPI extensions to document properly. Use URL versioning for public APIs where developer experience matters; use header versioning for internal service-to-service APIs where strictness is preferred.

What does HTTP 406 Not Acceptable mean for JSON APIs?

HTTP 406 Not Acceptable means the server cannot produce a response in any format the client listed in its Accept header. Common causes: the client requests Accept: application/xml but the API only produces JSON; the client requests a vendor type the server does not support (e.g., Accept: application/vnd.api+json when the server only sends application/json); the client requests an API version that has been retired. The response body should list supported media types, though it is ironic that the body itself must be in a format the client may not accept — best practice is to return plain text or minimal JSON as a last resort. Do not confuse 406 with 415 Unsupported Media Type (wrong request body format) or 400 Bad Request (malformed request). If the client sends no Accept header, assume they accept anything and return your default format — never return 406 for a missing Accept header.

How do I negotiate between JSON and CSV output in the same API endpoint?

Use the Accept header to select the format. In Express, call req.accepts(['application/json', 'text/csv']) — it returns the best match considering q-values, wildcards, and specificity, or false if no match. Return JSON for application/json, CSV with Content-Disposition: attachment; filename=report.csv for text/csv, and 406 if false. Critically, always add Vary: Accept to the response so CDNs cache separate copies per format — without it, a CDN caches the first response (say, JSON) and serves it to all clients including those requesting CSV. If the client sends Accept: */*, respond with your default (typically JSON). Test with explicit curl commands: curl -H "Accept: text/csv" /api/reports should return CSV, and curl -H "Accept: application/xml" /api/reports should return 406 with a helpful error listing supported types.

What are q-values in the Accept header and how does priority work?

Q-values are decimal weights from 0.0 to 1.0 appended to media types in the Accept header using a semicolon: Accept: application/json;q=0.9, text/csv;q=0.8, */*;q=0.1. A higher value means higher preference; the default is 1.0 when omitted. The server finds the intersection between the client's Accept list and the formats it supports, then selects the highest-weight match. Specificity breaks ties at equal weights: application/json beats application/* beats */*. A q-value of exactly 0 means "do not send this format" — Accept: */*;q=0, application/json means "only JSON, no fallback". In practice, most API clients send a simple Accept: application/json (implicit q=1.0) without q-values. Q-values matter most for browser-facing APIs where browsers send complex Accept headers like text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 — your JSON API should match the */* entry and respond with JSON, not 406.

Does content negotiation affect browser caching of JSON API responses?

Yes — content negotiation requires correct Vary headers for caching to work correctly. When a URL can return different formats based on the Accept header, caches must store separate entries per format. The mechanism: include Vary: Accept in every negotiated response. Without it, a CDN or browser cache may store a JSON response and serve it to subsequent requests asking for CSV — incorrect behavior. If you also negotiate encoding (Accept-Encoding: gzip, br), add that too: Vary: Accept, Accept-Encoding. CDNs like Cloudflare and CloudFront respect Vary but may limit cache efficiency for dynamic Vary values. For JSON APIs that only serve application/json regardless of Accept, you can safely omit Vary: Accept — it only matters when the response actually differs by Accept value. Always test caching behavior after adding content negotiation to an existing endpoint.

Further reading and primary sources