Media-Type Versioning with Content Negotiation

This guide sits under the URL Path vs Header Versioning section of the API Versioning & Deprecation reference. It covers the third option in that comparison: versioning the representation rather than the resource by negotiating a vendor media type through the Accept header. Instead of GET /v2/orders, the client sends Accept: application/vnd.example.v2+json against a single, version-free URL and the server returns whichever representation it agreed to.

Media-type versioning treats a version as a property of the payload, which keeps URLs canonical and lets a resource evolve without minting new paths. That elegance comes with real cost — proxy caches, browsers, and generated SDKs all handle custom Accept headers less predictably than a path segment. This page walks the mechanics: vendor media types, q-values, the Vary header, 406 Not Acceptable, and default-version resolution, then contrasts them with path versioning so you can decide when the tradeoff pays off.

When to reach for media-type versioning

The decision trigger is representational churn on a stable resource. If /orders/{id} always identifies the same order but the shape of an order changes between releases, versioning the media type keeps one URL and one bookmarkable identity. Path versioning, covered in the parent section, is the better fit when whole resource hierarchies restructure between versions. Use this table to locate your situation:

Symptom / decision trigger Media-type versioning URL-path versioning
Same resource identity, changing field shape Strong fit — one canonical URL Duplicates the URL per version
Consumers include unsophisticated HTTP clients or browsers Weak — Accept is easy to omit Strong — version is visible in the URL
Shared/CDN caching is heavy Requires disciplined Vary: Accept Cache keys on URL naturally
Hypermedia (HATEOAS) links must stay stable across versions Strong — links never carry a version Links leak /v1/ into responses
Quick manual testing in a browser address bar Weak — cannot set Accept easily Strong

If two or more rows push you toward the left column, media-type negotiation is worth the extra plumbing. Because the mechanism leans entirely on HTTP caching semantics, read it alongside Statelessness & Caching Strategies before you ship — a missing Vary header is the single most common way this pattern corrupts a shared cache. If your versioning story also needs a deprecation timeline, pair it with implementing the Sunset HTTP header.

The vendor media type

RFC 6838 reserves the vnd. tree for vendor-specific media types and defines the structured-suffix convention (+json, +xml) that lets generic tooling still recognize the underlying syntax. A versioned type looks like this:

application/vnd.example.v2+json
│           │   │       │  └── structured syntax suffix (parse as JSON)
│           │   │       └───── version token
│           │   └───────────── vendor / product name
│           └───────────────── vnd. tree (RFC 6838 §3.2)
└───────────────────────────── top-level type

The +json suffix matters: a generic client that does not understand the vendor type can still fall back to JSON parsing, and intermediaries know the body is JSON. Never drop the suffix (application/vnd.example.v2 alone is opaque to every tool).

Minimal OpenAPI 3.1 contract

Declare each versioned media type explicitly under the operation’s responses. OpenAPI 3.1 lets one operation advertise multiple representation versions side by side:

# openapi.yaml (OpenAPI 3.1.0)
openapi: 3.1.0
info:
  title: Orders API
  version: "1.0.0"
paths:
  /orders/{id}:
    get:
      operationId: getOrder
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Order representation, version-negotiated via Accept.
          content:
            application/vnd.example.v1+json:
              schema: { $ref: "#/components/schemas/OrderV1" }
            application/vnd.example.v2+json:
              schema: { $ref: "#/components/schemas/OrderV2" }
            application/json:
              schema: { $ref: "#/components/schemas/OrderV1" }  # default
        "406":
          description: No supported representation matches the Accept header.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
components:
  schemas:
    OrderV1:
      type: object
      required: [id, total]
      properties:
        id: { type: string }
        total: { type: number }
    OrderV2:
      type: object
      required: [id, amount]
      properties:
        id: { type: string }
        amount:
          type: object
          required: [value, currency]
          properties:
            value: { type: number }
            currency: { type: string }

Listing application/json as an explicit content key documents the default representation, so codegen tools and reviewers can see which version an unversioned request resolves to.

Step-by-step: negotiate the representation

Step 1 — Parse the Accept header and q-values

RFC 9110 §12.5.1 defines Accept as a comma-separated list of media ranges, each with an optional quality weight q between 0 and 1 (default 1). The server must pick the acceptable representation with the highest quality. A client that prefers v2 but tolerates v1 sends:

Accept: application/vnd.example.v2+json;q=1.0, application/vnd.example.v1+json;q=0.5

TypeScript (Express):

interface MediaRange { type: string; q: number; }

function parseAccept(header = ""): MediaRange[] {
  return header
    .split(",")
    .map((part) => {
      const [type, ...params] = part.trim().split(";").map((s) => s.trim());
      const qParam = params.find((p) => p.startsWith("q="));
      const q = qParam ? Number(qParam.slice(2)) : 1;
      return { type: type.toLowerCase(), q: Number.isFinite(q) ? q : 1 };
    })
    .filter((r) => r.type.length > 0)
    .sort((a, b) => b.q - a.q); // highest quality first
}

Python (FastAPI / Starlette):

def parse_accept(header: str = "") -> list[tuple[str, float]]:
    ranges = []
    for part in header.split(","):
        part = part.strip()
        if not part:
            continue
        media, *params = [p.strip() for p in part.split(";")]
        q = 1.0
        for p in params:
            if p.startswith("q="):
                try:
                    q = float(p[2:])
                except ValueError:
                    q = 1.0
        ranges.append((media.lower(), q))
    # highest quality first; stable sort preserves header order on ties
    return sorted(ranges, key=lambda r: r[1], reverse=True)

Step 2 — Match against supported representations

Walk the sorted candidates and return the first one the server can produce. Treat a q=0 entry as an explicit rejection of that type.

const SUPPORTED = [
  "application/vnd.example.v2+json",
  "application/vnd.example.v1+json",
];
const DEFAULT_VERSION = "application/vnd.example.v1+json";

function selectRepresentation(accept: string): string | null {
  const ranges = parseAccept(accept);
  for (const { type, q } of ranges) {
    if (q === 0) continue; // client refuses this type
    if (type === "*/*" || type === "application/*" || type === "application/json") {
      return DEFAULT_VERSION; // generic → pinned default
    }
    if (SUPPORTED.includes(type)) return type;
  }
  // No Accept header at all also resolves to the default.
  return ranges.length === 0 ? DEFAULT_VERSION : null;
}

Step 3 — Resolve the default for generic Accept

A request carrying Accept: application/json, Accept: */*, or no Accept header at all is not asking for a specific version. Resolve these to a pinned default — typically the oldest supported major — not the newest. Pinning to the latest means every unversioned client silently breaks the day you ship v3. Pinning to a fixed default keeps legacy consumers stable and makes upgrades an explicit opt-in via the vendor type.

Step 4 — Return 406 on an unknown version

When the client asks explicitly for a vendor type the server cannot produce (for example application/vnd.example.v9+json), RFC 9110 §15.5.7 says the correct response is 406 Not Acceptable. Do not silently downgrade to a version the client did not request — that hides the mismatch. Return a machine-readable body listing what you support:

app.get("/orders/:id", (req, res) => {
  const chosen = selectRepresentation(req.header("accept") ?? "");
  res.setHeader("Vary", "Accept"); // ALWAYS, even on 406

  if (!chosen) {
    return res.status(406).type("application/problem+json").json({
      type: "https://api-contract.com/problems/unsupported-version",
      title: "Not Acceptable",
      status: 406,
      detail: "No supported representation matches the Accept header.",
      supported: SUPPORTED,
    });
  }

  const order = loadOrder(req.params.id);
  res.status(200).type(chosen).json(renderOrder(order, chosen));
});

Step 5 — Set Vary: Accept on every response

Because one URL now yields many representations, any shared cache must key its stored copies on the Accept header. Emit Vary: Accept on every negotiated response — success, 406, and error alike. Omitting it lets a proxy hand a cached v1 body to a client that asked for v2.

HTTP/1.1 200 OK
Content-Type: application/vnd.example.v2+json
Vary: Accept
Cache-Control: max-age=60

RFC and standard compliance

Media-type versioning is well-grounded in the HTTP specifications — use the correct clause for each behavior:

Behavior Standard Clause
Vendor media type in the vnd. tree RFC 6838 §3.2
+json structured syntax suffix RFC 6838 §4.2.8
Accept header and q-value negotiation RFC 9110 §12.5.1
406 Not Acceptable for no producible match RFC 9110 §15.5.7
Vary response header for cache keying RFC 9110 §12.5.5
Problem+json error body RFC 9457 whole

Registering a proprietary media type formally with IANA is optional for internal APIs but expected for public standards-track work.

Caching and safety implications

GET under media-type negotiation stays safe and idempotent, but its cacheability now depends entirely on Vary: Accept. Three failure modes recur:

The deeper treatment of cache keys, stale-while-revalidate, and edge normalization lives in Statelessness & Caching Strategies. The rule of thumb: if you version through Accept, you own Vary correctness end to end.

SDK and codegen downstream effect

Path versioning gives every generated client a distinct base URL per version, which most generators handle cleanly. Media-type versioning instead requires the generated client to set the right Accept header per call — and many generators default to Accept: application/json, quietly landing every request on your default version. Pin the header explicitly in the client layer:

// Generated client wrapper — force the negotiated version
export async function getOrderV2(id: string): Promise<OrderV2> {
  const res = await fetch(`https://api.example.com/orders/${id}`, {
    headers: { Accept: "application/vnd.example.v2+json" },
  });
  if (res.status === 406) {
    throw new Error("Server does not support Order v2");
  }
  return (await res.json()) as OrderV2;
}

Verify in CI that the generated client emits the vendor Accept header rather than a bare application/json, or consumers will drift onto the default representation without noticing.

Common mistakes

Mistake Correct approach
Omitting Vary: Accept, letting caches cross-serve versions Emit Vary: Accept on every negotiated response, including 406
Resolving generic Accept: application/json to the newest version Pin generic requests to a fixed default major so upgrades stay opt-in
Returning 200 with a downgraded body when the asked-for version is missing Return 406 Not Acceptable and list supported media types
Dropping the +json suffix (application/vnd.example.v2) Keep the structured suffix so generic tooling can still parse the body
Generated SDK sending bare application/json and drifting to default Force the vendor Accept header in the client wrapper and test it in CI

FAQ

Do I need to send Vary: Accept when versioning through media types?

Yes. Because the same URL returns different representations depending on the Accept header, any shared cache — CDN, reverse proxy, or browser cache — must key its stored copies on that header. Emit Vary: Accept on every negotiated response; otherwise a cache can store a v1 body and hand it to the next client that asked for v2. This is a correctness requirement under RFC 9110 §12.5.5, not merely an optimization.

What status code should I return for an unknown media-type version?

Return 406 Not Acceptable when the client explicitly requests a vendor media type the server cannot produce, such as application/vnd.example.v9+json. Do not silently downgrade to a version the client did not ask for, because that hides the mismatch and produces confusing client-side bugs. Include a application/problem+json body that lists the supported media types so the client can renegotiate.

Which version should a generic Accept: application/json resolve to?

Resolve a generic Accept header — application/json, */*, or a missing header — to a pinned default version, not the latest. Pinning to the oldest supported major keeps unversioned clients stable across releases: a v3 launch never silently changes the payload for a client that only sends application/json. Upgrading then becomes an explicit act of sending the newer vendor media type.