Once someone else's code depends on yours, the only safe way to change it is to make the new thing true before the old thing stops being true.
An API stops being yours the moment a second team ships against it. From then on the question is not “is this change better?” but “can every caller keep working while it happens?”
Two rules cover almost everything. On the way in, you may accept more than before, never less. On the way out, you may promise more than before, never less. Break either direction and someone's build turns red for reasons they can't fix.
Most changes that feel like they need a /v2 don't. They need a wider door for a while: add the new thing beside the old one, move the callers across with a signal they can actually act on, then take the old one away once nobody is standing on it.
Pick a schema change, then pick how you roll it out. Two callers keep hammering the endpoint the whole time — an old one built against v1, and your new one. Watch which of them survives.
Auto-play is off because your system asks for reduced motion — step through it instead.
Start by getting the direction right. A request is something you accept; a response is something you promise. Compatibility runs the opposite way on each side, and almost every argument about “is this breaking?” dissolves once you say out loud which side you're on.
direction of safety
-------------------------------------------------
requests (what you accept) accept MORE, never less
responses (what you promise) promise MORE, never less
change request response
-------------------------------------------------------
add an optional field safe safe *
add a required / always-present
field BREAKING safe *
remove a field safe if
ignored BREAKING
required -> optional safe BREAKING
optional -> required BREAKING safe
nullable -> non-null BREAKING safe
non-null -> nullable safe BREAKING
widen a type (int -> int|str) safe BREAKING
narrow a type (int|str -> int) BREAKING safe
add an enum value safe depends **
remove an enum value BREAKING safe
* unless the caller validates with additionalProperties:false
** safe only if you documented that new values may appear
When a change lands in a BREAKING cell, you have two options. One is a version bump, which doesn't remove the migration — it only gives it a URL. The other is to run both shapes for a while:
1. EXPAND ship the new shape ALONGSIDE the old one.
accept both inputs; return both outputs.
deploy order: server first, always.
-> nothing has broken; nothing has moved yet.
2. MIGRATE give callers a signal they can act on, then move them.
HTTP/1.1 200 OK
Deprecation: true
Sunset: Wed, 01 Oct 2025 00:00:00 GMT
Link: <https://api.example.com/docs/country>;
rel="deprecation"
and measure, per API key:
old_shape_requests_per_min: 4,200 -> 480 -> 0
3. CONTRACT when that number is 0, and has stayed 0 for longer
than your slowest caller's release cycle,
delete the old branch.
The end state is identical to the version bump. The difference is that no request ever failed, and you never had to run two copies of a route. The contract step is the one people skip — which is how you end up maintaining every shape you have ever shipped.
| situation | what to do | the trade-off |
|---|---|---|
| Additive change — a new optional field, a new endpoint | Just ship it. Expand is the change. | Schema drift: if you never contract anything, the payload only grows. |
| Breaking shape change, callers you can see (internal, a handful of keys) | Expand, migrate, contract. Chase the top keys directly. | Two code paths and two sets of tests for a few weeks. |
| Breaking shape change, callers you can't see (public API, shipped SDKs, mobile apps) | Expand, publish a long sunset (two release cycles or more), contract only on telemetry. | You may carry the old branch for years, or forever. Price that in before you start. |
| Semantic change — same field, different meaning or units | Never edit in place. Add a new, differently named field and deprecate the old one. | Redundant data for a while, but the caller's compiler does your migration for you. |
| Whole-surface redesign — new auth, new pagination, new resource model | A real version. Run both surfaces, date the old one, migrate deliberately. | Doubles the test matrix and the on-call surface. Reserve it for changes you cannot express as “both at once”. |
amount quietly moves from dollars to cents. Every type check passes, every schema validates, and every caller is now wrong by a factor of a hundred. Ship amount_cents instead and let their compiler find the call sites.additionalProperties:false, a generated enum that throws on unknown values, or a database column with a CHECK constraint all turn your additive change into an outage.Deprecation and Sunset headers, a warning in the response body, a log line in their SDK, and an email to the owners of the top twenty keys — those are signals. One more subtlety: making a response field non-null is safe for readers, but not for a caller that reads your object and posts it straight back to you.You own POST /v1/payments. Product wants currency to become required; today it defaults to USD, and about 8% of traffic omits it. An interviewer asks how you'd ship it.
Don't reach for /v2 — the resource isn't changing, one field's validation is. Expand: keep accepting the omission, keep the default, and start returning Deprecation: true, a Sunset ninety days out, and a warnings[] entry in the body for exactly those requests. Add a counter payments.currency_omitted tagged by API key, because the plan is only as good as the number that ends it.
Migrate: the counter shows three keys account for 97% of the omissions — two internal services, fixed within a sprint, and one partner on a 2021 integration, who gets an email and the ninety days. Contract: when the counter has sat at zero for two weeks and the partner has shipped a release, make the field required and delete the default.
The end state is byte-for-byte what the version bump would have produced: currency required, no fallback, one route. The difference is that zero requests failed and no caller had to change their URL. And if the partner genuinely can't move? Keep the default alive for that one key behind a per-caller compatibility flag, log it as debt with an owner and a date, and let everyone else move on — a scoped exception is cheaper than a frozen API.
A response field goes from "discount": number | null to "discount": number — always present, never null. Breaking?
Some callers legitimately send {"phone": null}. You want to stop accepting null. What ships first?