"We don't need versioning, we'll just be careful." This is the sentence I say in every API design meeting that ends with someone reading it back to me as evidence I should be ignored. Then a year passes, three customers integrate, the marketing team adds a contract clause that includes the API behavior verbatim, and the day comes when one of the original endpoints needs to change. Now you have a real choice: break customers, build a versioning story under pressure, or freeze the design forever. None of these are good on a deadline.
I have lived through three API versioning stories and watched two more happen to teams adjacent to mine. My stance: pick a versioning strategy on the day you ship the first version, even if the strategy is "we have one version and a clear deprecation policy". Retrofitting versioning under deadline is the painful version. You can avoid it by spending an hour before launch.
What "versioning" actually has to solve
The job of an API versioning strategy is to answer four questions:
- How does a client say which version it wants?
- How does the server route the request to the right implementation?
- How does the server announce that an old version is going away?
- How does the client find out a new version exists?
A real strategy answers all four. Most teams' "strategy" answers question 1 ("we'll put /v2 in the URL") and stops there, which is why deprecation is always a fire drill.
The four common shapes
There are four shapes I see in the wild. Each handles the four questions differently.
1. URI versioning. The version is part of the URL path: /v1/orders, /v2/orders. The most visible, the most common, and the easiest to debug. Caches and proxies can route on the path, browser history shows the version, and any developer can read it.
The downside is that resource URLs are no longer canonical. The same logical resource has multiple URLs. From a strict REST purity perspective this is a violation, but in practice nobody cares. Twilio (/2010-04-01/), AWS API Gateway, and many older public APIs use URI versioning.
2. Header versioning. The version goes in a custom header: X-API-Version: 2025-11-01 or Accept: application/vnd.example.v2+json. The URL stays canonical. The server reads the header to decide which version to serve.
This is what RESTful purists prefer and what Stripe famously uses (with date-stamped versions in Stripe-Version). The downside is that the version is invisible in URLs, browser history, and naive logging tools. New developers will not realize the API has multiple versions until something goes wrong.
3. Query parameter versioning. GET /orders?version=2. Easier to call from a browser than header versioning. Less mainstream than URI versioning. Some teams use it for opt-in feature flags rather than versions; the line gets blurry.
4. No versioning, with backward-compatible evolution only. The API has one URL forever, and every change is required to be backward compatible. New fields are added; existing fields are not removed or repurposed. New behaviors are opt-in via parameters. Stripe's ethos is essentially this, layered on top of the date-stamped version header (Stripe-Version: 2025-11-01) which lets you pin response shape if you want to. You can think of Stripe as both option 4 and option 2 simultaneously.
I should also call out the hybrid Stripe popularized: calendar versioning. The version is a date (2025-11-01), and any breaking change ships under a new date. Clients pin to a specific date, and the server returns the response shape that date dictated. Stripe maintains compatibility with dozens of historical versions; their "tooling" for this is real and not free, but it gives clients almost-zero migration pressure.
The trade-offs that decide
| Concern | URI versioning | Header versioning | Query param | No versioning |
|---|---|---|---|---|
| Caller debuggability | High | Low | Medium | High |
| Caching with CDN | Per-version cache key | Vary header (works but tricky) | Per-version cache key | Single cache, easy |
| Canonical URLs | No (multiple URLs per resource) | Yes | No | Yes |
| Mainstream familiarity | Very high | Lower | Medium | Medium |
| Migration tooling needed | Manual or mass-find/replace | Header swap | Param swap | None (clients keep working) |
| Server complexity | Low (URL routing) | Higher (header parsing, version negotiation) | Low (param parsing) | Highest (every change has to be back-compat) |
| Cleanup story | "Drop /v1 at end of life" | "Reject Accept header" | "Reject param" | "There is no cleanup; you live with everything forever" |
The biggest determinant is who your callers are. Internal services with one version and one client? No versioning, just evolve. Public API with thousands of integrators? URI versioning, because debuggability matters more than purity. SDK-mediated API where clients always go through your library? Header versioning is fine because the SDK hides it. Date-stamped header (Stripe-style) only if you have the tooling and the institutional discipline to maintain many simultaneous versions.
What "breaking change" means, exactly
A working versioning strategy depends on a clear definition of what is breaking. The Hyrum's Law worry is real here: clients depend on observable behavior, including behavior you did not document. My working list of breaking vs non-breaking, in roughly the order I check them in code review:
Always breaking:
- Removing a field from a response that clients might be reading.
- Renaming a field in either request or response.
- Changing a field's type (string to number, scalar to array).
- Removing an endpoint.
- Changing the default value of an optional input field.
- Changing the meaning of a status code (e.g., from 200 to 201 on create).
- Tightening validation (rejecting input that previously was accepted).
- Changing pagination semantics (default page size, parameter names).
Usually non-breaking:
- Adding a new optional input field.
- Adding a new field to a response (clients should ignore unknown fields, but see Hyrum's Law).
- Adding a new endpoint.
- Loosening validation (accepting input that previously was rejected).
- Adding a new optional response header.
Always check with real callers:
- Adding a required input field (breaking for clients that didn't send it).
- Removing a deprecated field after a long deprecation period (breaking for the long tail that ignored the deprecation warning).
- Changing the order of items in an array if order was not documented (you would be surprised how many clients depend on the order; this is the canonical Hyrum's Law).
The "usually non-breaking" list is non-breaking only if your clients follow the protobuf-style "ignore unknown fields" discipline. JSON clients that strictly type their responses (TypeScript clients with no extra fields, mobile apps with strict serializers) treat new fields as breaking. The fix is to test additions against your largest clients before shipping.
A migration plan that has worked for me
When I am introducing a v2 (or a new dated version), the plan I follow:
The deprecation signal lives across two RFCs: RFC 9745 (April 2024) defines the Deprecation header, and RFC 8594 defines the Sunset header. Together they are the standard. Honor it. Some clients have automation that watches for these headers and surfaces the warning to engineering teams; if you skip them, you skip the only mechanism the spec defines for telling clients "you are on a path".
The 6-month minimum between deprecation and removal is the norm for B2B APIs. Public APIs often go a year. Internal APIs with one or two known clients can compress this to weeks if the migration is real-time-coordinated.
The deprecation header most teams forget
Concrete shape:
The Deprecation header is when the deprecation was announced. The Sunset header is when the endpoint goes away. The Link header points at the migration guide. Clients (and their automated checks) can pick up these headers without reading docs.
The rest of the world does not implement this perfectly. Some clients ignore deprecation headers entirely. The header is still useful because it establishes a paper trail: "we told you in the response itself, with a machine-readable header, on this date". When the day comes that you turn off v1, "you had six months of warnings on every response" is a much stronger position than "we sent an email".
What I do for internal APIs
The advice above is calibrated for public APIs. Internal APIs are different. My defaults:
- No versioning in the URL. The cost of v1/v2 in internal traffic is not worth the operational complexity.
- Backward-compatible evolution. Add fields, do not rename. If you must rename, ship both fields for a release, update consumers, then remove the old one in the next release.
- Schema in a shared repo. OpenAPI for REST, .proto files for gRPC. PRs to the schema fail CI if they break backward compatibility (tools like
buf breakingfor protobuf,oasdifffor OpenAPI). - Deprecation via PR comments and Slack. Internal teams can be coordinated; the public-API formality of
Sunsetheaders is overkill.
The ratio of "internal API" to "public API" is huge in most companies. Reserve the heavy versioning machinery for the surfaces that justify it.
The mistake I made on my first public API
The mistake I made on my first public API was treating v1 as "we'll iterate". We added fields, renamed two of them when we realized the old names were wrong, and changed the meaning of a status code on a single endpoint. Nine months in, our top integrator's PagerDuty went off because we silently changed customer.created_at from a unix timestamp to an ISO 8601 string in a "minor" update. Their parser broke and stayed broken until someone in their on-call shift figured out we had changed the type.
The fix was a real versioning story added retroactively, an apology email, and a written commitment to the deprecation playbook above. The lesson: treat your first release as if every line of the response shape is a contract, because in practice it is. Anything you change later, even a "small fix", is a breaking change to someone. Plan for that on day one.
Versioning is a contract, not a feature
The phrase that finally landed for me, after enough on-call pages, is this: versioning is not a feature, it is a contract. The contract says "if you call this URL with this header, you get this response shape". Anything you do that changes that contract is breaking. The version label is the unique key for the contract. If you have one URL with no version, you have one contract that has to be honored forever, and every "small change" is either compatible or a breach.
The right time to think about versioning is the day before launch, not the day a customer's parser breaks at 2am. URI versioning is the safe default; calendar versioning is the gold standard if you have the engineering muscle for it; backward-compatible-only evolution is fine for internal APIs and small consumer counts. Pick one, write it down, and treat it like a real interface contract from the first response onward. The version is the interface; honor it like one.
