Changing an API people already use

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.

The idea

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.

Watch it happen

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.

old caller 4,200 req/min 200 ok new caller your updated client not deployed api server contract: v1 Deprecation: true Sunset: 2025-10-01 no deprecation signal in play old-shape traffic 4,200/min POST POST
call succeeds call fails not calling
the change
the rollout
stage
1 of 2 · before
old caller
200 ok
new caller
not deployed
failing req/min
0

How it works

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.

When to use it

situationwhat to dothe 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”.

Watch out for

Worked example

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.

Check yourself

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?