Topic 17

Versioning and Change

API Design

The scanner app in an organizer's pocket was installed eight months ago and will not be updated tonight. The web client was deployed this morning, the mobile app is on three versions across its users, and a partner's integration was written once by a contractor who has left. Every change to the API is made against clients that will not change with it. The first rule follows from that: most changes must not need a version at all. The second follows from the first: a version, when it is finally needed, is a promise about how long the old one lives, and a promise with no date is not one.

Stagedoor has never shipped a v2 and this topic explains what it does instead. Additive changes ship without ceremony, because the contract obliges clients to ignore what they do not know. Breaking changes are rare, and when one is unavoidable it is made as an expansion followed by a contraction, with a header that says the old surface is deprecated, a header that says when it goes, and a count of who is still calling it before the day arrives.

Additive Change Needs No Version

A new optional field in a response, a new endpoint, a new enum value, a new query parameter with a default that preserves the old behaviour: each one leaves every existing client correct. The client that does not know held_until does not read it. The client that does not know partially_refunded treats it as "other," because Topic 13 made that a requirement of the contract. The client that never sends the new parameter gets the default, which is what it got before. Stagedoor ships an additive change most weeks, and none of them has a version, an announcement or a sunset, because nothing that worked yesterday stops working.

The contract does its half of the work. "Clients must ignore unknown fields and tolerate unknown enum values" is a sentence in the API document, and it is there for exactly this reason: it converts a whole class of changes from breaking to additive, before any of them is made. A client that fails on an unknown field has broken the contract, and the fix is on the client's side, which is the only time the service gets to say that.

Breaking Change, Defined

Removing a field. Renaming a field. Changing a field's type. Tightening validation, so that a value that was accepted is now rejected. Changing a status code, so that a client's branch on 409 now sees 422. Changing the meaning of a value while keeping its name. Each one breaks a client that was correct yesterday, and the list is the definition of "needs a version." Nothing else does. A team that keeps the list in front of it makes fewer breaking changes than a team that reasons case by case, because most proposed changes turn out to have an additive spelling: a new field beside the old one instead of a rename, a wider validation instead of a tighter one, a new endpoint instead of a changed response.

The list that decides whether a change needs a version
New optional field, endpoint, enum value or defaulted parameterAdditive · ship it
Remove or rename a fieldBreaking · expand, then contract
Change a type or the meaning of a valueBreaking · new field or new version
Tighten validation or change a status codeBreaking · behind the version header
Loosen validation, add a status the contract already allowsAdditive · ship it

Where the Version Goes

Three places. In the path, /v2/orders: visible in every URL, coarse, and it turns every breaking change into a copy of the whole API. In the media type, through content negotiation: precise and almost nobody's client sends it correctly. In a header, Stagedoor-Version: 2026-10-01: the version is a date, the date is the day the client was written against, and a client pins the day it was tested on and sees the API as it was that day for as long as the sunset allows. Stripe has done this since 2011, and it is what Stagedoor does.

The part that makes a date header workable is where the translation happens. The handlers are written once, against the current shape. A routing layer reads the version header and applies the translations for every dated change after the client's date: rename this field back, re-add that removed one, map the new status code to the old. Each dated change is a small function, and a client from eight months ago passes through eight months of them. Handlers never see the version. The alternative, a handler per version, is how /v7/ happens.

Expand, Then Contract

To rename starts_at to begins_at: add begins_at beside it and emit both. Migrate the clients the team controls. Mark starts_at deprecated with the headers of the next section. Watch the per-client counter until the callers that read it are gone or have been told. Then remove it, after the sunset. Five steps, and at no point does a correct client see a response it cannot read. The shape is the same as the schema migration of Chapter 6, add the column, backfill, switch the readers, drop the old column, and it is the same for the same reason: two versions of the reader exist at once, and the writer has to satisfy both until the old one is gone.

The step people skip is the middle. A rename that adds the new name and drops the old one in the same release has expanded and contracted at once, which is a rename in place, which is the breaking change. The expansion has to live long enough for the slowest client to move, and the slowest client is the scanner app, and its release cycle is the sunset's lower bound.

Deprecation Is a Header and a Date

Every response from a deprecated surface carries two headers. Deprecation, from RFC 9745, says that the surface is deprecated and since when, as a Unix timestamp with an at-sign in front. Sunset, from RFC 8594, says the date after which it will stop responding, as an HTTP date. A client library that knows the headers logs a warning on the first one; a client that does not is at least visible, because the service logs every request that hit a deprecated surface with the client's identity, and the count per client is the number the sunset is decided on.

What every response from the old field's surface carries for the last month of its life
HTTP/1.1 200 OK
Stagedoor-Version: 2026-02-14
Deprecation: @1790812800                       # deprecated since 2026-10-01T00:00:00Z
Sunset: Sun, 01 Nov 2026 00:00:00 GMT          # gone after this instant
Link: <https://stagedoor.example/docs/changes/begins-at>; rel="deprecation"

{ "id": "8812", "starts_at": "2026-10-04T19:30:00Z", "begins_at": "2026-10-04T19:30:00Z", ... }

The response is pinned to a client date from February, so it still carries starts_at, and it carries begins_at beside it because the expansion is in effect. The Deprecation header says the old field has been deprecated since the first of October, the Sunset header says it stops on the first of November, and the Link header points at the page that says what to do. A sunset without a measured caller count is a guess; the header is the promise and the counter is what makes the promise keepable.

The Client That Never Updates

The scanner app is pinned to the version it shipped with, and the service serves that version until the sunset. The sunset is set longer than the app's release cycle, which for a mobile app in organizers' pockets is three months from store release to the last device updating, so a sunset shorter than that is a sunset the team will extend under pressure. On the day the old version goes, a request pinned to it gets a 410 Gone with a Problem Details body whose type is problems/version-retired and whose extension points at the update. The app shows "update required" instead of an undated event list, which is what a silently renamed field would have given it.

Common Mistakes
  • Versioning every change — /v7/ after a year, seven copies of every handler, and nobody can say which version the largest organizer's integration calls without reading the load balancer's logs.
  • Renaming a field in place — the scanner app reads null for starts_at, shows every event as undated, and the organizer finds out at the door.
  • A version with no sunset — the old version lives forever because removing it was never planned, and every handler change has to be made twice from then on.
  • Tightening validation in the same version — the field that accepted 200 characters now accepts 100, a client that was correct yesterday gets 422s, and no release note on the client's side explains why.
  • No per-client usage data — the sunset date arrives and nobody knows whether the last caller of the old field is a forgotten test script or the largest organizer's box-office system.
Best Practices
  • Make every change additive if it possibly can be, and put "clients must ignore unknown fields and tolerate unknown enum values" in the API document before the first client is written.
  • Version by date in a Stagedoor-Version header, translated at the routing layer, so handlers are written once against the current shape.
  • Expand, migrate, deprecate with Deprecation and Sunset headers, then contract, and count callers per client and per version before every sunset.
  • Set every sunset longer than the slowest client's release cycle, and answer the retired version with a 410 that points at the update.
Comparable toolsStripe date-based versioning with per-account pinning, the model this topic followsGitHub X-GitHub-Api-Version, the same idea with a dateKubernetes API groups, path-versioned with a deprecation policy measured in releasesRFC 9745 Deprecation and RFC 8594 Sunset, the two headers

Knowledge Check

Which of these changes to GET /orders/{public_id} can ship without a version, given Stagedoor's contract?

  • Changing total_cents from an integer to a decimal string, since both represent money
  • Adding an optional refunded_cents field that old clients will simply not read
  • Returning 422 instead of 409 for a seat that is held, since both are client errors
  • Renaming created_at to placed_at in the same release, since the meaning is unchanged

Stagedoor versions by a date header translated at the routing layer. What does that buy over a version in the path?

  • Responses can be cached per version by the CDN, because the version header becomes part of the cache key
  • The old version never needs a sunset, because the routing layer serves it at no cost forever
  • Clients are pinned automatically to the day they first called, with nothing more to send on each request
  • Handlers are written once against the current shape, and each dated change is one translation function

To rename starts_at to begins_at, why must the old field keep being emitted after the new one is added, rather than dropping both in one release?

  • Because the database column cannot be renamed until every replica has caught up with the primary
  • Because the routing layer cannot translate a renamed field for a client pinned to an older date
  • Because the slowest client still reads the old name, and dropping it at once is the breaking change
  • Because the API document must list both names for one full release cycle before either one can change

What does a Sunset header on a deprecated surface commit the service to?

  • Retiring the surface after the stated date, with a 410 for callers that remain
  • Marking the surface deprecated as of the stated date, without saying when it goes
  • Serving the old response shape until the date, then switching the body to the new one
  • Keeping the surface alive for as long as any client still calls it after the date

You got correct