# Each version maps to a distinct deployment
api-v1.example.com -> deployment-abc123
api-v2.example.com -> deployment-def456
api-v3.example.com -> deployment-ghi789
```

If you apply that idea to APIs, you get:

- `deployment_id` (or `commit_sha`) becomes the version key
- routing layer maps that key to the correct deployment

**Pros**

- No "compatibility code" inside a deployment — each one behaves consistently.
- SDK versions can align 1:1 with API versions.
- Rollbacks are easy (just route traffic differently).

**Cons**

- Security patching becomes painful. If old deployments still exist, you need a policy for patching or killing them.
- You risk giving customers no incentive to upgrade.
- Database schema changes become complex (which schema version does each deployment use?)
- Shared resources (queues, caches) need to handle multiple API versions simultaneously

If you go down this route, borrow from [immutable infrastructure thinking: AWS describes immutable infrastructure](https://docs.aws.amazon.com/wellarchitected/latest/framework/rel_tracking_change_management_immutable_infrastructure.html) as "no in-place modifications", replacing resources on change to improve reliability and reproducibility. That gives you a framework for why this works — and what operational controls you'll need.

---

### The "shove it under the rug" approach

A translation layer sits in front:

- requests come in
- layer transforms them to what the backend understands
- responses get transformed back to what the client expects

This can be code-driven, config-driven, or (increasingly) "AI-driven".

A simple architecture sketch:

```mermaid
flowchart LR
  C[Client / SDK] -->|Request v=2024-01-01| T[Translation Layer]
  T -->|Canonical request| B[Backend]
  B -->|Canonical response| T
  T -->|Transformed response| C
```

**Pros**

- Mostly plug and play – and it's just getting easier with AI-driven translation layers.
- You can use this to build a "compatibility layer" for legacy APIs or third-party APIs.

**Cons**

- If you're hosting your own compatibility layer, or building your own translation layer, you're adding a lot of complexity to your architecture.
- If you're using a third-party compatibility layer, you're adding cost and a new critical point of failure.
- Some transformations cannot be expressed in a static configuration.
- Can't express side-effects or conditional logic.

My take: this is a reasonable last resort when you have low internal discipline or a messy legacy API. But if you have the chance to build a principled versioning model, do that instead.

---

## How to send version info

You've basically got four knobs. None are perfect.

| Mechanism          | Example                                | Pros                                            | Cons                                                           |
| ------------------ | -------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------- |
| URL path           | `/v1/users`                            | Visible, cache-friendly, easy routing           | Version becomes "part of the URL", hard to evolve gradually    |
| Header (dedicated) | `X-Api-Version: 2024-06-01`            | Explicit, easy to test, plays well with one URL | Some tooling hides headers; needs docs discipline              |
| Header (Accept)    | `Accept: application/vnd.acme.v2+json` | Uses content negotiation semantics              | Verbose, annoying in browsers, harder SDK ergonomics           |
| Query param        | `?api_version=2024-06-01`              | Easy to try manually                            | Often abused, sometimes cached badly, feels less "contractual" |

If I'm building a serious public API today, I default to a **dedicated header**. It's explicit without turning versioning into URL sprawl.

GitHub's REST API uses this exact pattern with `X-GitHub-Api-Version` and date-based versions. Stripe does the same conceptually with `Stripe-Version`. That's a pretty good signal that the ergonomics work at scale.

---

## How to sunset and deprecate

Versioning without a lifecycle is just hoarding.

You need:

- monitoring (who is still using old versions?)
- deprecation signals (tell them early)
- a sunset plan (turn it off eventually, or you'll maintain it forever)
- communication (docs, changelogs, email, dashboard banners)

### Use standard headers where possible

There are now RFCs for this.

- [**Deprecation** header (RFC 9745)](https://www.rfc-editor.org/rfc/rfc9745.html) communicates that a resource is (or will be) deprecated, and it carries a deprecation date. It's a structured header; the RFC example uses a Unix timestamp format like `@1688169599`.
- [**Sunset** header (RFC 8594)](https://www.rfc-editor.org/rfc/rfc8594.html) communicates when a resource is expected to become unavailable (HTTP-date format).

Example response headers:

```http
Deprecation: @1767139200
Sunset: Wed, 31 Dec 2025 00:00:00 GMT
Link: <https://developer.example.com/deprecation>; rel="deprecation"; type="text/html"
```

The RFC also calls out an important constraint: sunset shouldn't be earlier than deprecation.

### Have a process, not a hope

A process sketch:

```mermaid
flowchart TD
  A[Detect old version usage] --> B[Announce deprecation]
  B --> C[Add Deprecation header + docs link]
  C --> D[Add Sunset date]
  D --> E[Escalate comms: email + dashboards]
  E --> F[Block new signups on old versions]
  F --> G[Sunset: return 410 / hard fail]
  G --> H[Remove support + delete code paths]
```

And you need a support window.

GitHub commits to supporting the previous REST API version for **at least 24 months** after a new one is released. That's a clear promise customers can plan around — and it forces internal discipline.

How many breaking changes do you ship in a year? How about 2 years? Do you have the bandwidth to support all of those versions?

---

## How we help solve these issues at Speakeasy

At Speakeasy, we help some of the most popular software platforms generate world-class SDKs that work seamlessly. Versioning, forward and backward compatible changes are always top-of-mind. You can [read more about how we implement forward compatibility here](https://www.speakeasy.com/docs/sdks/manage/forward-compatibility).

### SDK behaviour drift

[David's TypeScript forward-compatibility article](https://www.speakeasy.com/blog/typescript-forward-compatibility) describes a painful user experience: server returns `200 OK`, SDK throws anyway. Causes include API evolution and inaccurate OpenAPI specs, and strict enum/union/required-field validation is a common trigger.

The response is to build SDKs that degrade gracefully:

- forward-compatible enums (accept unknown values in a type-safe way)
- forward-compatible unions
- "lax mode" for missing/mistyped fields
- smarter union deserialisation strategies

There's a difference between:

- "SDK is a strict contract enforcer"
- "SDK is a resilient integration tool"

Most customers want the second. We support both.

### Explicit versioning and metadata propagation

We use a hash of your full spec to track changes across versions, so you're not required to explicitly update your version to communicate changes. However, I do recommend **versioning your OpenAPI spec**.

This allows you to make that version visible at runtime.

In Speakeasy SDKs, you can [use hooks to inject cross-cutting behaviour](https://www.speakeasy.com/docs/sdks/customize/code/sdk-hooks) (headers, tracing, auth tweaks, etc.).

A practical pattern is:

- send OpenAPI doc version on every request
- send SDK version on every request
- use those values for feature negotiation and observability

Illustrative TypeScript hook:

```typescript
import { SDK_METADATA } from "../lib/config.js";
import { Hooks } from "./types.js";

export function initHooks(hooks: Hooks) {
  hooks.registerBeforeRequestHook({
    beforeRequest(_, request) {
      request.headers.set(
        "x-openapi-doc-version",
        SDK_METADATA.openapiDocVersion,
      );
      request.headers.set("x-sdk-version", SDK_METADATA.sdkVersion);
      return request;
    },
  });
}
```

Why this helps:

- **OpenAPI doc version** can map to feature sets (server-side) without guessing.
- **SDK version distribution** tells you who's stuck, who upgrades, and which customers will be hurt by a breaking change.

```go
func trackVersionMetrics(r *http.Request) {
	sdkVersion := r.Header.Get("x-sdk-version")
	openapiVersion := r.Header.Get("x-openapi-doc-version")
	// NOTE: you should validate the versions, so malicious or malfunctioning clients
	// don't bork your metrics system with high-cardinality columns

	metrics.Increment("api.requests", map[string]string{
		"sdk_version":     sdkVersion,
		"openapi_version": openapiVersion,
	})
}
```

You can't manage what you can't see.

### Maintaining backwards compatibility with continuous testing

If you're not already doing this, don't worry. This is where most teams are weakest. They "try not to break stuff", but they don't continuously prove it.

One solid approach is **workflow-based end-to-end tests**.

If you haven't already, I recommend reading Brian's article on [Arazzo-based E2E testing](https://www.speakeasy.com/blog/e2e-testing-arazzo). It describes in great detail how we've implemented Arazzo-based E2E testing for our generated SDKs. A similar setup can enable your team to move more confidently across version changes.

What makes this powerful, is that it's testing the things customers actually do.

A practical model:

1.  Keep historical versions of your OpenAPI spec (git tags are fine).
2.  Keep Arazzo workflows for your critical customer journeys.
3.  On every deploy (or nightly):
    - check out older spec versions
    - run the workflows against your current API
    - fail fast if you broke an older contract

This turns backward compatibility from painful chore into an executable guarantee.

---

## Core principles I'd actually follow

If you only remember a handful of things, make it these:

1.  **Define the stable core of your API**
    Be explicit about what's truly contract, and what's best-effort.
2.  **Treat "additive" changes as dangerous unless you've designed for resilience**
    Adding an enum value is dangerous (GitHub says so).
    Adding new fields is "backward-compatible" only if clients don't validate strictly (Stripe's docs assume this).
3.  **Make changes opt-in where you can**
    New fields, new behaviours, new defaults — opt-in beats surprise.
4.  **Versioning strategy is part of your interface forever**
    Pick something you're willing to support for years, not months.
5.  **Have an explicit deprecation and sunset policy**
    Use runtime signals like Deprecation/Sunset headers, and back them with real comms.
6.  **Harden observability around versions and features**
    You need to know who is on what, or you're flying blind.
7.  **Automate backwards compatibility testing**
    Prefer workflow-level tests (Arazzo-style) over "unit tests of handlers", because customers don't call endpoints in isolation.

And the meta-rule, courtesy of Hyrum's Law: **assume customers depend on everything you didn't mean to expose.**

Once you accept it, you stop being surprised by breakages — and you start designing so they don't happen in the first place.