Why do integrations break when you change your API? Versioning and backward compatibility
APIs do not break because someone picked the wrong versioning scheme. They break because three things are missing: a written definition of what counts as a breaking change, data showing which client still calls which version, and an announced retirement date. With those three in place, where you put the version number stays a technical preference. Without them, the same scene repeats on every release: nobody objects, so the change ships, and two weeks later a customer's nightly integration stops quietly and that customer notices before you do.
If "breaking" isn't written down, you relitigate it every release
The most concrete public reference is AIP-180 in Google's API design guidelines. The list is unambiguous: existing components (interfaces, methods, messages, fields, enums, enum values) must not be removed, renaming counts as remove-and-add so it is also out, field types must not change even when the wire format is compatible, you must not add a required field to an existing request, you must not change a field's default value, you must not change the format or algorithm used to produce an existing field's value, and you must not change resource names. There is a subtler entry too: raising the upper bound on a string field's length counts as incompatible, because clients sized their columns for the old bound. Behind all of it sits one test. An old client must keep working against a new server.
Most teams do not need to adopt AIP-180 wholesale. What they need is a one-page document that lists "we can ship these any time, these require a new version" and names the person who decides. Without that page, "is this breaking?" gets answered in a pull request comment by whoever sounds most certain.
A change your provider calls non-breaking can still break you
Stripe publishes exactly what it treats as backward-compatible: adding new API resources, adding new optional request parameters to existing methods, adding new properties to existing responses, changing the order of properties in responses, changing the length or format of opaque strings such as object IDs and error messages (including adding or removing fixed prefixes like ch_), and adding new event types. By contract, all of those can arrive without notice.
Two entries on that list will take down a careless client. Stripe says object IDs can contain up to 255 characters and tells you to store them in a VARCHAR(255) column; an integration that declared VARCHAR(32) because today's IDs fit breaks the day a longer one shows up. New event types knock over any webhook handler that throws on the default branch, which is why Stripe spells that out too: make sure your listener gracefully handles unfamiliar event types.
The rule cuts both ways. As a consumer, be a tolerant reader. Ignore fields you don't know, don't blow up on an unknown enum value, don't validate a length the provider never promised. As a provider, publish your own version of that list, so your consumers know what they are expected to tolerate.
Where to put the version
There are three common places: the path (/v1/orders), a custom header or the Accept media type, and a query parameter. For public APIs the path is the practical default. Each version gets its own URI so caching just works, the version shows up in gateway logs and CDN rules, and routing is trivial. Header and media type versioning keeps URLs stable and sits closer to REST orthodoxy, but the version disappears from every tool that only looks at URLs.
What matters more than the choice: whichever you pick, the version has to land in your access logs and metrics as a dimension. You cannot retire a version whose traffic you cannot count. If the gateway logs the path while the version lives in a header nobody records, you will arrive at the sunset date with a guess instead of a number.
Semantic versioning isn't the only model: dates and release trains
GitHub's REST API uses date-based versions. 2022-11-28 was the first one after they moved to date-based versioning, it travels in a header, and the policy is that when a new version ships the previous one stays supported for at least 24 more months.
Stripe combines both, a date plus a codename: 2025-03-31.basil, with 2026-07-29.dahlia current. The interesting part is the release train. Monthly releases contain only backward-compatible changes; breaking changes land only in the major releases (Acacia, Basil, Clover, Dahlia), roughly twice a year. Your account is pinned to a version, upgrading is something you choose, and you get a 72 hour window to roll an upgrade back. Two lessons transfer to a much smaller API: put breaking changes on a calendar instead of sprinkling them across the year, and give consumers a way to both test forward and undo.
The best versioning is not shipping a new version
Every version you publish is a branch you now maintain: a wider test matrix, two sets of docs, two support paths. Before opening v2, check whether the change fits the additive set: a new optional parameter, a new field in the response, a new endpoint alongside the old one. Two rules make that sustainable for years. First, never repurpose an existing field. "We now put the net amount in total" is the worst kind of breaking change, because the type is unchanged, nothing raises an error, and the number is simply wrong from then on. Second, never tighten input validation after the fact; rejecting a payload you accepted yesterday is breaking whether or not the version number moved.
When a change really is unavoidable, expand-contract usually splits it into compatible steps: add the new field, write both for a while, migrate readers, then drop the old field once the consumers are gone. It is the same manoeuvre that lets database schema changes ship without a maintenance window, which we covered in zero-downtime deployments.
"Breaking" should be a failed build, not an opinion
If an OpenAPI spec lives in the repo, this check can be automated. oasdiff compares two specs and flags the changes that break consumers, and its GitHub Action annotates the offending lines right in the pull request's Files Changed tab. On the gRPC and protobuf side, buf breaking does the same against a previous Git tag or a registry module, with rule sets ranging from wire compatibility to generated-source compatibility.
The value is not in the tooling, it is in moving where the decision happens. Once a failing check blocks the merge, "is this breaking?" stops being a debate and becomes a shared, recorded answer. Add contract tests on top so the consumer's expectations run inside the provider's pipeline. For integration-heavy products that is usually the highest-return layer of the test pyramid, and if you already have a working CI pipeline it is a few lines of setup.
Decide the support window before you need it
Kubernetes writes its rule down: GA APIs are supported for at least 12 months or 3 releases after the deprecation is announced, whichever is longer; beta APIs get 9 months or 3 releases; alpha gets nothing. GitHub says 24 months. The numbers differ and that is fine. What matters is that the number exists before your first deprecation notice rather than being invented after a customer escalates.
Zalando's open-source REST API guidelines turn it into a process. Rule 185 requires you to obtain approval from clients before shutting an API down, 186 to collect external partners' consent on the deprecation timespan, and 188 to monitor usage of any deprecated API scheduled for sunset. If your consumers are a handful of named integrators, consent is a realistic and cheap step. If they are anonymous public clients, consent gets replaced by a longer window plus telemetry.
Deprecation is a protocol, not an announcement email
Since March 2025 there is a standards-track answer. RFC 9745 defines the Deprecation response header. The value is a structured-field date in Unix time, so on the wire it reads Deprecation: @1688169599, and the same RFC registers a deprecation link relation so the response itself can point at your migration guide. It pairs with the older Sunset header from RFC 8594, which uses an HTTP date: Sunset: Thu, 31 Dec 2026 23:59:59 GMT. Together they mark the start and the end of the window, and the sunset timestamp cannot be earlier than the deprecation timestamp.
Add a Link header next to them pointing at the changelog and the migration guide. And don't skip RFC 9745's own caveat, which is unusually candid: this information is only a hint, it cannot be depended on, and clients should be written to work without ever reading it. Headers make deprecation machine-readable for the consumers who monitor headers. They tell the ones who don't absolutely nothing. That is what the next two steps are for.
Who is still calling v1? If you can't answer, you can't turn it off
Every request should log both the version and the identity of the caller: API key, client ID, tenant. Turn that into a weekly figure, calls per version per consumer. From that point deprecation stops being a broadcast and becomes a list of names with a number next to each. Knowing who is actually affected changes both the tone of the notice and the calendar.
Then comes the tool that genuinely works: the brownout. You take the deprecated endpoint out of service for a pre-announced, bounded period, so consumers who ignored the headers get a visible and recoverable failure while there is still time to fix it. GitHub did this in escalating steps before removing query-parameter API authentication and the OAuth Applications API: 12 hours in May 2021, 24 hours in June, and a final 48 hour brownout in August 2021. The stated goal was exactly this, to trigger alerts on consumers' services so unmigrated calls would surface. Silence is not consent. A brownout is how you convert silence into a support ticket you can still resolve.
Sunset day: 410, not 404
410 Gone is the correct status. It says "this existed and was deliberately removed", where 404 says "I don't recognise this address" and sends the other team hunting for a typo that isn't there. Salesforce does this on its retired platform API versions, where REST returns 410.
That same retirement is also the cautionary tale about dates. Shutting off versions 21.0 through 30.0 was first announced for the Summer '23 release, then pushed to Summer '25, where it finally happened. Two years of slippage, because consumers had not migrated. Setting a date is not enough; what makes a date hold is telemetry and brownouts. One more detail: put the migration link and the name of the successor version in the error body, so whoever is reading the log at 2am doesn't have to go hunting through the docs.
A first step that fits in this week
Pick your busiest API and try to answer four questions in one sentence each. One: is the version a field in your access logs, and which consumer called which version how many times last week? Two: is your definition of "breaking change" written down, and who decides? Three: is that definition checked automatically in CI, or discussed in code review? Four: when you deprecate a version, is the support window written down, and do you have a brownout on the calendar?
Whichever question you cannot answer in a sentence is where your next outage comes from. For the wider case on designing interface contracts up front, see the API-first approach; for delivery guarantees and duplicate records, data synchronization between systems; for authentication and authorization design in the new version, API security. Mobile clients are the hard case here: you cannot force an upgrade on the version sitting in someone's pocket, so keep the window long for mobile-facing APIs and build the forced-update mechanism into the first release (native or cross-platform).
Need help with this topic?