HTTP Content Negotiation in REST APIs

How clients and servers agree on response format with Accept headers — media types, quality factors, language negotiation, and compression. Defined in RFC 9110, Section 12.

Last Updated:

What Is Content Negotiation?

Content negotiation is the mechanism by which an HTTP client and server agree on the format, language, and encoding of the response. The client advertises its capabilities and preferences via Accept headers. The server selects the best match. The server then gives the chosen format in the Content-Type header.

# Client requests JSON, but also accepts XML (lower priority)
GET /products/42 HTTP/1.1
Host: api.example.com
Accept: application/json, application/xml;q=0.9
Accept-Language: en-US, en;q=0.8, es;q=0.5
Accept-Encoding: gzip, br

# Server responds with best match
HTTP/1.1 200 OK
Content-Type: application/json
Content-Language: en-US
Content-Encoding: gzip

{ "id": 42, "name": "Widget" }

Server-Driven vs Client-Driven Negotiation

Type How It Works Example
Server-driven (proactive) The client sends preferences. The server selects the best representation. Accept: application/json
Client-driven (reactive) The server returns 300 Multiple Choices with a list of the available representations. The client selects one. Rarely used in practice
Transparent Proxy or CDN negotiates on behalf of client A CDN gives WebP or PNG, based on browser support

Almost all REST APIs use server-driven negotiation — the client sends Accept headers, the server decides.

The Accept Header: Media Type Negotiation

The Accept request header tells the server what media type(s) the client can handle. Defined in RFC 9110 §12.5.1.

Quality Factors (q values)

Each media type can have a ;q= parameter (quality factor) from 0 to 1 that shows preference. Default is 1.0 (highest). q=0 means "do not send this".

# Prefers JSON, accepts XML as fallback, accepts anything as last resort
Accept: application/json, application/xml;q=0.9, */*;q=0.1

# Only accepts JSON — return 406 if unavailable
Accept: application/json

# Browser-style Accept (sent automatically by browsers)
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8

# Vendor-specific media types (JSON:API, HAL)
Accept: application/vnd.api+json
Accept: application/hal+json

Precedence Rules

When the server matches the Accept header against its available representations, specificity wins (RFC 9110 §12.5.1):

  1. Exact match (application/json) beats wildcard subtype (application/*)
  2. Wildcard subtype (application/*) beats full wildcard (*/*)
  3. Among equal specificity, higher q value wins
  4. Among equal specificity and q value, server preference applies

Node.js: Implementing Content Negotiation

const express = require('express');
const app = express();

app.get('/products/:id', async (req, res) => {
  const product = await db.products.findById(req.params.id);

  // Express req.accepts() parses the Accept header
  const format = req.accepts(['json', 'xml', 'csv']);

  switch (format) {
    case 'json':
      res.set('Content-Type', 'application/json');
      return res.json(product);

    case 'xml':
      res.set('Content-Type', 'application/xml');
      return res.send(toXml(product));

    case 'csv':
      res.set('Content-Type', 'text/csv');
      return res.send(toCsv(product));

    default:
      // Cannot satisfy Accept header — return 406
      return res.status(406).json({
        error: 'Not Acceptable',
        supported: ['application/json', 'application/xml', 'text/csv']
      });
  }
});

Python/Flask: Implementing Content Negotiation

Python's Flask makes content negotiation straightforward using request.accept_mimetypes. The best_match() method applies the q-value priority rules automatically:

from flask import Flask, request, jsonify, Response
import xml.etree.ElementTree as ET

app = Flask(__name__)

@app.route('/products/<int:product_id>')
def get_product(product_id):
    product = db.get_product(product_id)  # {'id': 1, 'name': 'Widget', 'price': 9.99}

    # best_match() respects q values and returns the best Accept header match
    best = request.accept_mimetypes.best_match(
        ['application/json', 'application/xml', 'text/csv']
    )

    if best == 'application/json':
        return jsonify(product)

    elif best == 'application/xml':
        root = ET.Element('product')
        for key, val in product.items():
            child = ET.SubElement(root, key)
            child.text = str(val)
        return Response(ET.tostring(root, encoding='unicode'),
                        mimetype='application/xml')

    elif best == 'text/csv':
        csv_data = f"id,name,price\n{product['id']},{product['name']},{product['price']}"
        return Response(csv_data, mimetype='text/csv')

    else:
        # 406 — no acceptable format
        return jsonify({
            'error': 'Not Acceptable',
            'supported': ['application/json', 'application/xml', 'text/csv']
        }), 406

For more on API authentication, versioning strategies, and HTTP methods, see the related guides. Content negotiation pairs naturally with REST API design fundamentals.

Accept-Language: Language Negotiation

The Accept-Language header lets clients request responses in a preferred language. This is useful for internationalized APIs that return translated content, error messages, or date formats. Defined in RFC 9110 §12.5.4.

Accept-Language: en-US           # US English only
Accept-Language: en-US, en;q=0.9 # US English preferred, then any English
Accept-Language: es, en;q=0.7    # Spanish preferred, English fallback
Accept-Language: *               # Any language

The server should send the Content-Language header. This header shows the actual language of the response body:

Content-Language: en-US
Content-Language: es

Node.js: Language Negotiation

const i18n = {
  'en': { greeting: 'Hello' },
  'es': { greeting: 'Hola' },
  'fr': { greeting: 'Bonjour' }
};

app.get('/greet', (req, res) => {
  // req.acceptsLanguages() returns best match
  const lang = req.acceptsLanguages(['en', 'es', 'fr']) || 'en';

  res.set('Content-Language', lang);
  res.json({ message: i18n[lang].greeting });
});

Practical note: Most REST APIs default to English and use the lang or locale query parameter instead (GET /messages?lang=es). This is less pure REST but more cache-friendly (different URLs = different cache keys).

Accept-Encoding: Compression Negotiation

The Accept-Encoding header lets the client advertise which compression algorithms it supports. The server then compresses the response body. Compression makes the payload much smaller for large JSON responses. Defined in RFC 9110 §12.5.3.

Accept-Encoding: gzip, deflate, br
Accept-Encoding: br;q=1.0, gzip;q=0.8  # Prefer Brotli, fall back to gzip
Accept-Encoding: identity               # No compression (raw body)
Algorithm Token Compression Notes
Gzip gzip ~60-70% reduction Universal support. Fast.
Brotli br ~15-25% better than gzip Modern browsers and clients. HTTPS only in browsers.
Deflate deflate Similar to gzip Inconsistent implementations. Do not use.
Identity identity None Explicit no-compression request

Enabling Compression in Express

const compression = require('compression');

app.use(compression({
  // Only compress responses larger than 1KB
  threshold: 1024,
  // Prefer Brotli for modern clients
  brotli: { enabled: true, zlib: { level: 4 } },
  filter: (req, res) => {
    // Don't compress already-compressed or streaming responses
    if (req.headers['x-no-compression']) return false;
    return compression.filter(req, res);
  }
}));

When to enable: Always enable compression for JSON APIs. A typical 50KB JSON response compresses to ~8KB with gzip. The CPU cost is negligible compared to the bandwidth and latency savings, especially on mobile networks.

API Versioning via Accept Header

Some APIs use the Accept header for versioning instead of URL paths. This keeps URLs clean but makes testing harder (you cannot simply paste a URL in a browser).

# GitHub API v3 style — vendor media type with version
Accept: application/vnd.github.v3+json

# Custom version parameter
Accept: application/json; version=2

# Full vendor type with version
Accept: application/vnd.myapi.v2+json

Node.js: Header-Based Versioning

app.get('/users/:id', (req, res, next) => {
  const accept = req.headers['accept'] || 'application/json';

  // Extract version from vendor media type
  const v2Match = accept.match(/application\/vnd\.myapi\.(v\d+)\+json/);
  const version = v2Match ? v2Match[1] : 'v1';

  if (version === 'v2') {
    return res.json(serializeUserV2(user));
  }
  return res.json(serializeUserV1(user));
});

For most APIs, URL versioning (/v2/users) is simpler, more cacheable, and easier to document. Use header versioning only if clean URLs are a strict requirement. See our Versioning Guide for a full comparison.

406 Not Acceptable: When Negotiation Fails

If the server cannot produce a representation that satisfies the client's Accept headers, it must return 406 Not Acceptable. The response body (in the server's default format) should list the formats the server does support:

GET /products/42
Accept: application/msgpack   # Server doesn't support this

HTTP/1.1 406 Not Acceptable
Content-Type: application/json

{
  "error": "Not Acceptable",
  "message": "Cannot serve the requested media type",
  "supported_types": [
    "application/json",
    "application/xml",
    "text/csv"
  ]
}

Practical caveat: Many APIs ignore the Accept header and always return JSON. This is pragmatic for single-format APIs but will cause issues if clients send strict Accept: application/xml and expect a 406 on unsupported formats. Document your behaviour explicitly.

Frequently Asked Questions

Should I always implement content negotiation?

For most REST APIs, no. If your API serves only JSON, always return Content-Type: application/json. Use content negotiation when you must serve multiple formats (JSON, XML, CSV). Also use it for a versioned API that uses vendor media types.

What happens if a client sends no Accept header?

Per RFC 9110 §12.5.1, an absent Accept header is equivalent to Accept: */* — the client accepts any media type. The server should return its default format (typically JSON for REST APIs).

Is content negotiation the same as API versioning?

No, but they can be combined. Content negotiation selects the format of the response (JSON vs XML). Versioning selects the schema/structure of the response (v1 vs v2 fields). Some APIs combine both with vendor media types such as application/vnd.myapi.v2+json. This media type specifies the format (JSON), the vendor, and the version together.

Does content negotiation affect HTTP caching?

Yes. A resource can have multiple formats, which the Accept header selects. Thus the cache key must include the Accept header value. If it does not, a cache can give a JSON response to a client that requested XML. The Vary: Accept response header tells caches to include the Accept value in the cache key:

Vary: Accept, Accept-Language, Accept-Encoding