Skip to content

HATEOAS Content Negotiation — Serving Multiple Hypermedia Formats from One API

DodaTech Updated 2026-06-28 3 min read

In this tutorial, you will learn about HATEOAS Content Negotiation. We cover key concepts, practical examples, and best practices to help you master this topic.

HATEOAS content negotiation allows a single API endpoint to serve different hypermedia formats (HAL, Siren, JSON:API, Collection+JSON) based on the client's Accept header, enabling broad client compatibility without maintaining separate endpoints.

What You'll Learn

  • Media type negotiation with Accept headers
  • Serving HAL, Siren, JSON:API, and Collection+JSON from one endpoint
  • Custom media types for HATEOAS versions
  • Format-specific serializers and link builders
  • Testing content negotiation across clients

Why It Matters

Clients built with different hypermedia libraries consume different formats. Supporting negotiation means one API serves all clients. DodaTech's public threat API serves HAL for Traverson clients, Siren for custom dashboards, and JSON:API for automated tooling — all from the same endpoint.

Real-World Use

A security API serves multiple integration partners who each use different hypermedia libraries. Content negotiation lets each partner consume their preferred format without API changes. A single /threats/{id} endpoint returns HAL, Siren, or JSON:API depending on the client.

flowchart LR
    A["Client A: Traverson
(Accept: application/hal+json)"] --> B["API Gateway"] C["Client B: Custom
(Accept: application/vnd.siren+json)"] --> B D["Client C: Automation
(Accept: application/vnd.api+json)"] --> B B --> E["HAL Serializer"] B --> F["Siren Serializer"] B --> G["JSON:API Serializer"] E --> H["Common Resource Model"] F --> H G --> H

Code Examples

Example 1: Accept Header Routing

from flask import Flask, request, jsonify

app = Flask(__name__)

MEDIA_TYPE_FORMATS = {
    'application/hal+json': 'hal',
    'application/vnd.siren+json': 'siren',
    'application/vnd.api+json': 'jsonapi',
    'application/vnd.collection+json': 'collectionjson',
    'application/json': 'hal'  # default
}

@app.route('/threats/<threat_id>')
def get_threat(threat_id):
    threat = find_threat(threat_id)
    best_format = request.accept_mimetypes.best_match(
        MEDIA_TYPE_FORMATS.keys()
    )
    fmt = MEDIA_TYPE_FORMATS.get(best_format, 'hal')
    serializer = get_serializer(fmt)
    response = serializer.serialize(threat)
    return jsonify(response)

Example 2: Format-Agnostic Resource Model

class ThreatResource:
    def __init__(self, threat):
        self.id = threat.id
        self.name = threat.name
        self.severity = threat.severity
        self.status = threat.status
        self.actions = {
            'analyze': {'method': 'POST', 'href': f'/threats/{threat.id}/analyze'},
            'remediate': {'method': 'POST', 'href': f'/threats/{threat.id}/fix'},
            'ignore': {'method': 'POST', 'href': f'/threats/{threat.id}/ignore'}
        }
        self.related = {
            'reports': f'/threats/{threat.id}/reports',
            'indicators': f'/threats/{threat.id}/iocs'
        }

# Usage
threat = ThreatResource(find_threat('abc123'))
print(threat.actions['analyze']['href'])
# Output: /threats/abc123/analyze

Example 3: HAL Serializer

class HALSerializer:
    def serialize(self, resource):
        result = {
            'id': resource.id,
            'name': resource.name,
            'severity': resource.severity,
            'status': resource.status,
            '_links': {
                'self': {'href': f'/threats/{resource.id}'}
            },
            '_embedded': {}
        }
        for rel, href in resource.related.items():
            result['_links'][rel] = {'href': href}
        for name, action in resource.actions.items():
            result['_links'][name] = {
                'href': action['href'],
                'method': action['method']
            }
        return result

serializer = HALSerializer()
output = serializer.serialize(threat)
print(json.dumps(output, indent=2))
# Output:
# {
#   "id": "abc123",
#   "name": "SuspiciousProcess",
#   "_links": {
#     "self": {"href": "/threats/abc123"},
#     "analyze": {"href": "/threats/abc123/analyze", "method": "POST"}
#   }
# }

Common Mistakes

1. Ignoring Quality Values in Accept Headers

Clients send q parameters to indicate preference. Ignoring these may serve the wrong format.

2. Returning Wrong Content-Type

Set the response Content-Type to match the format served, not just application/json.

3. Format-Specific Business Logic

Business logic should be format-agnostic. Only Serialization differs by format.

4. Not Caching Per Format

Cache responses by (URL, Accept) pairs, not by URL alone.

5. Missing Format Default

Always specify a default format for clients that send Accept: */*.

Practice Questions

  1. What HTTP header controls content negotiation?
  2. How do you serve both HAL and Siren from the same endpoint?
  3. What Content-Type should a HAL response use?
  4. Why should business logic be format-agnostic?
  5. How do you cache format-negotiated responses?

Answers:

  1. The Accept header specifies which media types the client can Process.
  2. Inspect the Accept header and route to the appropriate serializer.
  3. application/hal+json for HAL responses.
  4. Business logic doesn't change with format; only serialization does.
  5. Include the Accept header as a cache key dimension (Vary: Accept).

Challenge: Build a format router that supports HAL, Siren, JSON:API, and Collection+JSON from one endpoint. Accept quality values and return the best match.

FAQ

Can I use URL suffixes instead of Accept headers?

: Yes, but Accept headers are the HTTP-standard way. URL suffixes like .hal violate REST principles.

How do I test content negotiation?

: Send curl requests with different Accept headers: curl -H "Accept: application/hal+json".

What about custom media types?

: Use application/vnd.dodatech.threat.v2+hal+json for versioned custom types.

Does content negotiation affect performance?

: Minimally. Serialization cost is the same regardless of format.

Can the client request a specific format in the URL?

: Some APIs support ?format=hal as a fallback when Accept headers are impractical.

What's Next

After implementing content negotiation, explore HATEOAS Conditional Requests for caching, and review HATEOAS Link Formats for format-specific details.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro