The most surprising truth about HTTP content negotiation is that the server has absolute control, even when the client appears to be dictating terms.

Let’s see this in action. Imagine a simple API endpoint that can serve data as JSON or XML.

GET /resource/123 HTTP/1.1
Host: api.example.com
Accept: application/json, application/xml;q=0.8

The client is saying, "I prefer JSON, but I’ll take XML if you absolutely must." The q parameter is a quality value, with 1.0 being the highest preference.

Now, the server receives this request. It looks at the Accept header. The server’s internal logic might be:

  1. Does it support application/json? Yes.
  2. Does it support application/xml? Yes.
  3. Which has the higher q value? application/json (1.0) vs application/xml (0.8).

So, the server chooses to send back JSON.

HTTP/1.1 200 OK
Content-Type: application/json
Vary: Accept
Content-Length: 123

{
  "id": 123,
  "name": "Example Resource"
}

The Vary: Accept header is crucial here. It tells caching proxies (and the client’s own cache) that the response depends on the Accept header. If another client requests the same URL but with Accept: application/xml, it should get a different response and not a cache hit from the first request.

But what if the server doesn’t want to serve JSON, even if the client asks for it?

Consider this scenario: The server can serve JSON, but it’s currently undergoing maintenance, or it’s a deprecated format it wants to phase out.

GET /resource/123 HTTP/1.1
Host: api.example.com
Accept: application/json, application/xml;q=0.8

The server’s logic might look like this:

  1. Does it support application/json? Yes, technically.
  2. Is application/json currently available/preferred? No.
  3. Does it support application/xml? Yes.
  4. Which has the higher q value among available formats? application/xml (0.8).

The server then sends XML.

HTTP/1.1 200 OK
Content-Type: application/xml
Vary: Accept
Content-Length: 150

<resource>
  <id>123</id>
  <name>Example Resource</name>
</resource>

The server selected the best available format from its perspective, respecting the client’s preferences but not being strictly bound by them if its own constraints (availability, deprecation) come into play. The Accept header is a suggestion, not a command.

This mechanism is how you build APIs that can evolve. You can add new formats (application/vnd.api+json, application/msgpack) without breaking existing clients. Clients that understand the new format will request it using the Accept header, and clients that don’t will fall back to formats they understand.

The core of implementing this involves two main parts:

  1. Server-side routing and response generation: Your web framework or custom server logic needs to inspect the Accept header. Libraries like negotiator in Node.js, or built-in features in frameworks like Spring Boot (Java) or Flask (Python), can parse this header. Based on the parsed preferences and your server’s capabilities, you select the best media type to serve. Then, you serialize your data into that format and set the Content-Type header accordingly.
  2. Client-side Accept header construction: Clients must correctly populate the Accept header. This involves listing the media types they can handle, ordered by preference, using quality values (q) for disambiguation.

Here’s a peek at how a simplified server might handle this in Python using Flask:

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

app = Flask(__name__)

# Sample data
RESOURCES = {
    "123": {"id": "123", "name": "Example Resource"}
}

def generate_xml(data):
    root = ET.Element("resource")
    ET.SubElement(root, "id").text = data["id"]
    ET.SubElement(root, "name").text = data["name"]
    return ET.tostring(root, encoding='unicode')

@app.route('/resource/<id>')
def get_resource(id):
    if id not in RESOURCES:
        return Response("Not Found", status=404)

    resource_data = RESOURCES[id]

    # Content negotiation logic
    # Flask's request.accept_mimetypes is a list of accepted types,
    # ordered by q value.
    # We check our supported types in order of preference.

    supported_types = {
        "application/json": lambda d: jsonify(d),
        "application/xml": lambda d: generate_xml(d)
    }

    best_match = None
    for mime_type in request.accept_mimetypes:
        if mime_type in supported_types:
            best_match = mime_type
            break

    if not best_match:
        # If no match, default to JSON or return an error
        # For this example, we'll error if no common type is found
        return Response("Not Acceptable", status=406)

    # Generate the response
    serializer = supported_types[best_match]
    response_body = serializer(resource_data)

    # Create a Flask Response object
    response = Response(response_body, status=200, mimetype=best_match)

    # Crucially, add Vary header
    response.headers['Vary'] = 'Accept'

    return response

if __name__ == '__main__':
    # Example usage:
    # To test:
    # curl -H "Accept: application/json" http://127.0.0.1:5000/resource/123
    # curl -H "Accept: application/xml" http://127.0.0.1:5000/resource/123
    # curl -H "Accept: text/plain" http://127.0.0.1:5000/resource/123 (will get 406)
    app.run(debug=True)

The request.accept_mimetypes in Flask is a powerful helper. It parses the Accept header and gives you an ordered list of what the client wants, ranked by their q values. Your job is to iterate through this list and find the first one that your server supports.

The Vary: Accept header is non-negotiable for correct caching behavior. Without it, a cache might serve a JSON response to a client that later requests XML for the same URL, leading to incorrect data.

What most people miss is how to handle server-side deprecation or unavailability gracefully within the negotiation. A common pattern is to have a hardcoded preference order on the server. For instance, even if a client requests application/json;q=1.0 and application/xml;q=0.9, but your server has decided JSON is now legacy and only serves XML, you’d check your internal availability before strictly adhering to the client’s q values. You might still return 200 OK but send XML, perhaps with a Warning header or even a Link header pointing to the new format. This is where the server’s "absolute control" truly shines – it can guide clients towards preferred formats even when the client doesn’t explicitly ask for them, by simply not offering the older ones.

The next step in building a robust API is handling custom media types and versioning.

Want structured learning?

Take the full API Architecture course →