Skip to content

Lesson 2 of 9 · 25m

Versioning Without Tears: Breaking Changes, Sunset Headers, Migration

What actually breaks clients, when to cut v2, and how to retire v1 without making enemies.

You shipped /v1 on a deadline. Eighteen months later, billing needs a breaking change and four hundred consumers are live. Versioning is not about the URL scheme — it's about the discipline of what you're allowed to change without cutting a new version, and how you retire the old one without making enemies.

What actually breaks clients

A breaking change is anything that makes a previously-working client fail:

  • Removing or renaming a field (display_name → displayName)
  • Changing a type ("price": 1999 → "price": {"cents": 1999})
  • Changing semantics — the field still exists but means something different; this is the worst kind, because nothing crashes loudly
  • Tightening validation on inputs clients already send
  • New required fields on request bodies

Safe changes: new endpoints, new optional response fields, new optional request parameters. New enum values are safe only if your documentation has always told clients to tolerate unknown values — which is why "ignore unknown fields" belongs in your contract from day one. The bug that ships is the change everyone believed was non-breaking because the field name didn't change.

Pick one versioning scheme

URL versioning — /v1/ — is the pragmatic default: visible in logs, trivial to route at a gateway, trivial to ban per-version. Header versioning (Accept: application/vnd.you.v2+json) is purer REST and a worse operational life: invisible in access logs, harder to test in a browser, harder for consumers to get right. Per-resource microversions — Stripe's dated versions — are the most precise and the highest tax on every consumer. Choose once, document it, apply it to the whole surface. Don't version some endpoints by URL and others by header.

Retiring a version is a protocol, not an event

HTTP/1.1 200 OK
Deprecation: @1735689600
Sunset: Sat, 03 Jan 2027 00:00:00 GMT
Link: <https://api.example.com/docs/migrations/v2>; rel="deprecation"

Deprecation (RFC 9745) says "this stops being the current version"; Sunset (RFC 8594) says when it dies. Then: a published support policy (a common one is "N and N−1, 12 months overlap"), a migration guide with a diff of the contract, proactive email to the consumers you can see in your access logs, and usage dashboards that tell you when the last straggler has moved. Killing a version while it still serves traffic is how you end up on someone's incident postmortem — and shadow v1 deployments that nobody remembers are how old auth bugs stay reachable (that's OWASP API9).

Try this

Classify each as breaking or safe: (a) adding avatar_url to the user response, (b) changing limit's default from 50 to 20, (c) making email required on signup, (d) adding value suspended to the status enum, (e) reordering fields in the JSON body. Answers: only (a) and (e) are unconditionally safe — (b) changes behavior for clients that relied on the default, (c) breaks existing requests, (d) breaks clients with exhaustive switch statements unless your contract said otherwise.

Pitfalls

  • V1 forever: the zombie version that outlives its auth model. Set the sunset date when you ship v2, not when you're embarrassed.
  • Beta surface: /v1beta/ accrues production traffic anyway. It needs the same policy or an explicit "no guarantees" contract.
  • Silent semantic drift: documenting a changed meaning in the changelog is not a versioning strategy — cut the version.