API Versioning Best Practices: Breaking Changes to Sunset

The core of API versioning best practices for a SaaS API is one rule: only breaking changes get a new version, and every customer stays on the version they integrated against until they choose to move. Announce each retirement in the response itself with the Deprecation header (RFC 9745, a Standards Track RFC since March 2025) and the Sunset header (RFC 8594), measure who still calls the old version, and only then switch it off.
The big public APIs agree on the first part and disagree on the last. Stripe pins each account to a version, GitHub returns 410 Gone for a retired version, and Shopify quietly serves the oldest accessible stable version instead (its docs say it "falls forward"). Below we compare the three from their own docs, show the headers, and run a breaking-change check in CI.
This post completes our set on running a public API for a SaaS, next to per-tenant rate limiting and usage-based billing. Versioning is part of the OpenAPI-first API and SaaS development work we do for clients. We don't publish a public API of our own, so every vendor behavior below comes from the vendor's documentation, read on 2026-10-14.
API versioning best practices: the short version
Version from day one, bump only for breaking changes, pin each customer, announce retirement with headers, measure, then retire.
- Version from the first public request. Otherwise your first clients sit on an unnamed version you can never change.
- Only breaking changes get a new version. As GitHub puts it, "Any additive (non-breaking) changes will be available in all supported API versions."
- Pin a version per customer. Store it on the tenant or the API key, so a request without a version header still gets the contract that customer built against.
- Announce with Deprecation and Sunset headers, plus a changelog, a migration guide and email.
- Measure who still calls the old version, per tenant, and contact them by name.
- Retire on the published date, with a documented behavior for late callers: an error, a fall forward, or an indefinite pin.
Breaking vs non-breaking API changes
A breaking change is any change that can make a correctly written existing client fail. GitHub's API versions page gives the clearest published list we've found. It treats these as breaking:
- removing an entire operation;
- removing or renaming a parameter;
- removing or renaming a response field;
- adding a new required parameter, or making an optional parameter required;
- changing the type of a parameter or response field;
- removing enum values;
- adding a new validation rule to an existing parameter;
- changing authentication or authorization requirements.
And these as additive: adding an operation, an optional parameter, an optional request header, a response field, a response header, or enum values.
Two more belong on the breaking side even though they never touch the schema: changed defaults (a list that used to return 100 items now returns 20) and changed error codes (a client that retries on 409 now gets a 422 it doesn't handle). Neither shows up in a spec diff, so they need a human reviewer.
Bug fixes are the hard case: if clients may depend on the buggy output, treat the fix as breaking and ship it in a new version; if the old behavior contradicted your documentation, fix it in place and note it in the changelog.
The additive list assumes tolerant clients that ignore unknown fields and handle unseen enum values. A client that rejects unknown fields turns every new response field into an outage, and a switch over status that throws on an unknown value breaks when you add one. The oasdiff run later in this post flags a new response enum value as an error, while GitHub calls it additive. Pick one rule, publish it, and if you treat new values as additive, tell clients to code for them.
API versioning strategies: URL path vs header vs date-based
Put the version where your clients and infrastructure can see it. A path version is the most visible; a header keeps URLs stable. A date-based name works in either place.
URL path (/v1/invoices) | Request header | Date-based name (2026-10-01) | |
|---|---|---|---|
| Visible in logs and browser | Yes | Only if you log the header | Depends where you put it |
| Caching | Works by default, the URL differs | Shared caches need Vary on the header | Same as its carrier |
| Routing | Easy at a gateway or load balancer | Needs header-aware routing | Same as its carrier |
| Client effort | Change the base URL | Set one header | Pick a date, often in config |
| How many versions it implies | Few, large jumps (v1, v2) | Any | Many small ones, each with a changelog |
| Used by (pages read 2026-10-14) | Stripe's /v1 prefix; Shopify puts its date version in the URL | GitHub's X-GitHub-Api-Version; Stripe's Stripe-Version | Stripe, GitHub and Shopify all name versions by date |
The columns aren't exclusive. Stripe combines all three: a /v1 path, with dated versions such as 2026-09-30.endive selected by header or account default. That combination is a good default for a SaaS: the path names the API family and the date names the contract.
Two other carriers exist: a query parameter (Azure Resource Manager requires ?api-version= on its requests) and a media type in the Accept header (application/vnd.example.v2+json). Both work, but query parameters are easy to drop when URLs are copied, and media types are the hardest for clients to get right.
A subdomain (v2.api.example.com) is a path version moved into DNS. It lets you run a major version on separate infrastructure, but every version is another hostname to certify and for clients to configure, so it suits rare, large jumps.
gRPC APIs put the version in the protobuf package. Google's API design guidance (AIP-185) says the major version "is encoded at the end of the protobuf package", and Buf's PACKAGE_VERSION_SUFFIX lint rule requires the last package component to look like v1. With package invoices.v1;, the service is called as invoices.v1.InvoiceService, so a v2 package can be served next to it from the same server.
How Stripe, GitHub and Shopify version their APIs
All three name versions by date, and all three keep breaking changes out of existing versions. They differ most on what happens at the end. This table is our compilation from each vendor's own documentation, read on 2026-10-14: Stripe's versioning reference and upgrade guide, GitHub's API versions page, and Shopify's versioning page.
| Stripe | GitHub REST API | Shopify | |
|---|---|---|---|
| Version name | Date plus release name, e.g. 2026-09-30.endive (current on the check date) | Release date, e.g. 2026-03-10 | Year and quarter, e.g. 2026-10 |
| How a request picks one | Stripe-Version header; otherwise the account's default version, set in Workbench. Recent server SDKs send the version current at their release | X-GitHub-Api-Version header | In the request URL; official libraries set it for you |
| No version sent | Account default | 2022-11-28 | Not applicable, the version is in the URL |
| Release cadence | Monthly releases "with no breaking changes"; twice a year a major release that starts with breaking changes | New dated version when breaking changes ship; two versions supported on the check date | Every three months, at 5pm UTC on the first day of the quarter |
| Support window | Not stated on the pages read | "At least 24 more months" after a newer version is released | "A minimum of 12 months, with at least nine months of overlap" |
| Retirement signal | Changelog; Workbench shows which versions your integration uses | Deprecation and Sunset response headers while a version approaches closing down | Dev Dashboard alerts, warnings in GraphiQL, changelog, API reference |
| Request to a retired version | Not described on the pages read | 410 Gone | Falls forward to the oldest accessible stable version; the X-Shopify-API-Version response header shows which one |
| Webhook payload version | The endpoint's API version set at creation, otherwise the account default | Not covered on the versions page | Versioned like API responses, falls forward too |
A few details from those pages are worth reading closely.
Stripe's monthly releases are non-breaking by design. Since 2024-09-30.acacia, Stripe releases "new API versions monthly with no breaking changes," and twice a year a major release (such as Basil) "that starts with an API version containing breaking changes." Its upgrade guide recommends setting the version in code, "so your code determines the API version instead of your Dashboard settings," and the basil upgrade steps say you can roll an upgrade back for 72 hours.
GitHub tells unversioned callers their behavior will change. After a version's window ends, "Requests that do not specify an API version default to the next oldest supported version, not the closing down version." Today the default is 2022-11-28, which GitHub lists with an end of support date of March 10, 2028. An integration that never sends the header moves to a newer contract on that date without changing a line.
Shopify separates "supported" from "accessible." Its release table on the check date listed 2026-01 as accessible until January 16, 2027 15:00 UTC, and each version stays accessible until the 16th of its release month a year later (2026-10, released October 1, 2026, until October 16, 2027 15:00 UTC). Shopify also says that when part of an API is deprecated, it's deprecated "across all supported stable versions" and removed in a later release; for example, something deprecated in 2026-10 might be removed in 2027-01.
A real breaking change: Stripe's basil release
Stripe's 2025-03-31.basil version removed legacy usage-based billing, including billing_thresholds, aggregate_usage and the UsageRecord endpoints, while accounts pinned to earlier versions kept them. Versioning is easiest to judge from the consumer side of a removal like this. Under "Why is this a breaking change?", the changelog entry lists the removal of billing_thresholds from subscription endpoints and aggregate_usage from price endpoints, an error on creating a metered price without a meter, and no more attaching such prices to subscriptions, subscription schedules and quotes. The removed UsageRecord endpoints are UsageRecord create and UsageRecordSummary list.
The break was survivable because it was tied to a version. The entry says you can keep calling the legacy UsageRecords API after upgrading your SDK "by calling it via RawRequest with a pinned pre-Basil API version," so customers could upgrade the SDK and migrate billing as two separate jobs. The migration path was also written down: the entry links a migration guide from usage records to meters, plus numbered upgrade steps per SDK.
We walk through what replaced usage records, and how to build the usage ledger underneath, in our usage-based billing guide.
Pinning a version per customer
Store a default API version on each tenant (or each API key) and resolve every request against it. In a pooled Postgres database, that column sits on the same tenant record our multi-tenant SaaS architecture guide secures with row-level security. That's the model Stripe described in its 2017 engineering post APIs as infrastructure: future-proofing Stripe with versioning: on a user's first API request, "their account is automatically pinned to the most recent version available."
The resolution order we'd use for a SaaS API:
- If the request carries a version header, use it, after checking that it's a version you still serve.
- Otherwise use the version stored on the API key, if keys can carry one.
- Otherwise use the tenant's default version.
- A brand-new tenant gets the latest version, written to its record on the first request.
Keep it on the same tenant record your rate limiter and billing read, so tenant, plan and version resolve together, and log the resolved version on every request. That log line is what makes deprecation targeting possible.
Pinning has a cost, and Stripe's post names it: "Providing extensive backwards compatibility isn't free; every new version is more code to understand and maintain." Stripe keeps older versions out of core code with what it calls version change modules: resources are written for the current version, and to answer an older caller the system "walks back through time and applies each version change module" until it reaches that caller's version. Each change is written once instead of as a branch in every endpoint. The same post says Stripe had "maintained compatibility with every version of our API since the company's inception in 2011." Few teams can afford that; most should publish a support window instead.
How to deprecate an API version: Deprecation and Sunset headers
Send two headers on every response from the old version: Deprecation says it is (or will be) deprecated, and Sunset says when it's likely to stop responding. Then back them with direct contact.
Deprecation is defined in RFC 9745, "The Deprecation HTTP Response Header Field" (S. Dalal and E. Wilde, March 2025), an Internet Standards Track document. Its value "MUST be a Date" structured field, which is an @ followed by Unix seconds. The date can be in the future (the resource "will be deprecated at that date") or the past (it "was deprecated at that date"). Most important, "The act of deprecation does not change any behavior of the resource."
Sunset is defined in RFC 8594 (E. Wilde, May 2019), an Informational RFC, as an HTTP-date. It "indicates that a URI is likely to become unresponsive at a specified point in the future." RFC 9745 ties the two: the Sunset date "MUST NOT be earlier than the one given in the Deprecation header field."
A response from a version you plan to retire might look like this (illustrative values; the dates are December 31, 2026 and June 30, 2027):
HTTP/1.1 200 OK
Content-Type: application/json
Api-Version: 2025-10-01
Deprecation: @1798761599
Sunset: Wed, 30 Jun 2027 23:59:59 GMT
Link: <https://developer.example.com/changelog/2025-10-01-retirement>; rel="deprecation"; type="text/html"
Link: <https://developer.example.com/api-versioning-policy>; rel="sunset"
The first Link line uses the deprecation link relation that RFC 9745 registers, which points to human-readable documentation about the deprecation. The second uses the sunset link relation from Section 6 of RFC 8594, which can point to your sunset policy, details of an upcoming sunset, or migration options; the RFC "places no constraints on the scope or the type of the linked resource." Api-Version here is our own response header naming the version that served the request, the job Shopify's X-Shopify-API-Version does. Neither RFC defines it.
Both headers apply to the resource that returned them. RFC 9745 lets an API define a wider scope, but clients that don't know your rule read it per resource.
Clients are free to ignore them. RFC 9745 calls deprecation-related information "optional information that cannot be depended on." The headers help SDKs and gateways log warnings, and you still have to tell people directly.
Formats also differ in the wild. GitHub's versions page describes its Deprecation value as an HTTP date (example: Wed, 27 Nov 2019 14:34:29 GMT), while RFC 9745 requires the @ plus seconds form. Follow the RFC when you send, and accept both when you parse.
Keep a changelog and a migration guide with a dated entry per breaking change. Query the version log for tenants that called the old version in the last 30 days and email their technical contacts; Shopify does a version of this, attributing deprecated calls to the app whose access token made them and raising alerts in its Dev Dashboard. Send reminders at fixed points before the Sunset date. If your contract allows it, run a brownout: short, announced windows of errors before the Sunset date, so unwatched integrations fail while someone can still fix them.
How much notice to give
There's no standard number, so this is our recommendation rather than a benchmark: give at least six months between the Deprecation date and the Sunset date for a small SaaS API, and twelve if your customers are enterprises with quarterly release trains. GitHub's and Shopify's windows, in the table above, are longer. Whatever you pick, write it on the page your sunset link points to, and enforce it in CI with --deprecation-days-stable (see Enforcing a deprecation window in CI).
What happens when a client calls a retired version
There are three options, one per vendor: return an error (GitHub), serve a newer version (Shopify), or keep the old version running (Stripe's stated practice in its 2017 post; the current pages we read don't describe retiring a version).
| Behavior | Who | Upside | Downside |
|---|---|---|---|
Error, 410 Gone | GitHub REST API | The client knows exactly why it failed | Integrations break on the Sunset date, even ones nobody maintains |
| Fall forward to the oldest accessible version | Shopify | Most requests keep working | Responses change shape silently; clients must check the version header to notice |
| Keep the pin indefinitely | Stripe's 2017 stated practice | Nothing ever breaks | Every old version is code you maintain forever |
For most SaaS APIs we'd pick the 410, sent with a Problem Details body (RFC 9457) that names the retired version, the oldest supported one and the migration guide. A silent fall forward is kind only if your changes between versions are small and clients can see the served version, as Shopify's response header lets them. Whichever you choose, unversioned requests should follow a documented rule, as GitHub's do.
Catching breaking changes in CI
Diff your OpenAPI spec against the base branch on every pull request and fail the build on breaking changes. We used oasdiff, an open-source OpenAPI diff tool, for a small reproducible run: version 1.33.0 (released October 1, 2026, and still the latest stable release on October 14), against two hand-written specs of a toy GET /invoices endpoint. We first ran it on 2026-10-12 and re-ran it on 2026-10-14 with identical output. The specs are samples we wrote for this post; no client code is involved.
Between the base and the revision we made four changes: made the optional status query parameter required, renamed the required response field amount to amount_cents, added an optional currency field, and added a void value to the status enum in the response. We left info.version unchanged on purpose, to show what CI catches before anyone bumps it. Here is base.yaml:
openapi: 3.0.3
info:
title: Invoices API
version: "2026-10-01"
paths:
/invoices:
get:
operationId: listInvoices
parameters:
- name: status
in: query
required: false
schema:
type: string
enum: [draft, open, paid]
responses:
"200":
description: A list of invoices
content:
application/json:
schema:
type: array
items:
type: object
required: [id, amount, status]
properties:
id:
type: string
amount:
type: integer
status:
type: string
enum: [draft, open, paid]
And revision.yaml:
openapi: 3.0.3
info:
title: Invoices API
version: "2026-10-01"
paths:
/invoices:
get:
operationId: listInvoices
parameters:
- name: status
in: query
required: true
schema:
type: string
enum: [draft, open, paid]
responses:
"200":
description: A list of invoices
content:
application/json:
schema:
type: array
items:
type: object
required: [id, amount_cents, status]
properties:
id:
type: string
amount_cents:
type: integer
currency:
type: string
status:
type: string
enum: [draft, open, paid, void]
Then the check:
oasdiff breaking base.yaml revision.yaml --fail-on ERR
The output, verbatim:
3 changes: 3 error, 0 warning, 0 info
error [request-parameter-became-required] at revision.yaml
in API GET /invoices
the `query` request parameter `status` became required
error [response-property-enum-value-added] at revision.yaml
in API GET /invoices
added the new `void` enum value to the `items/status` response property for the response status `200`
The server may now return a value the previous contract excluded, so a client written against it may not handle the response. If the value set is meant to grow, declare it with x-extensible-enum.
error [response-required-property-removed] at revision.yaml
in API GET /invoices
removed the required property `items/amount` from the response with the `200` status
The command exited with code 1, which is what fails a CI job. The rename shows up as a removal, which is the right reading for a client. The optional currency field isn't listed; oasdiff changelog on the same pair reports it, and the added amount_cents, as info-level changes. And the enum error is the disagreement with GitHub's list noted above.
The CLI also accepts a git reference as a spec: oasdiff breaking main:openapi.yaml openapi.yaml --fail-on ERR gave the same three errors and exit code 1 in a scratch repository. For GitHub Actions, the project's oasdiff-action wraps the same check. Its quick start, trimmed:
name: oasdiff
on:
pull_request:
branches: [ "main" ]
permissions:
contents: read
jobs:
breaking-changes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: git fetch --depth=1 origin ${{ github.base_ref }}
- uses: oasdiff/oasdiff-action/breaking@v0
with:
base: 'origin/${{ github.base_ref }}:openapi.yaml'
revision: 'HEAD:openapi.yaml'
fail-on: ERR
review: false
We dropped the README's github-token input and pull-requests: write permission (the action then puts its output in the job summary), and changed two inputs: fail-on: ERR to match our CLI run (the README uses the stricter WARN), and review: false, because by default the action uploads the encrypted comparison to oasdiff.com and this keeps the specs inside CI. We haven't run this workflow itself, so check it against the README before relying on it.
When the check fails, someone has to decide: ship the change behind a new version, or accept it deliberately and record why in the pull request.
Enforcing a deprecation window in CI
OpenAPI has a deprecated: true field on operations, parameters, headers and schemas, but no field for deprecating a whole version. oasdiff fills part of that gap: it reads deprecated: true together with an x-sunset date on the operation, and checks removals against them. A deprecated operation looks like this:
/invoices/legacy:
get:
operationId: listInvoicesLegacy
deprecated: true
x-sunset: "2027-01-31"
responses:
"200":
description: OK
We ran seven checks on 2026-10-14 across five spec pairs, each differing only in GET /invoices/legacy, all with --fail-on ERR:
| Change | Flag | Result | Exit code |
|---|---|---|---|
| Endpoint removed, never deprecated | none | api-path-removed-without-deprecation | 1 |
Endpoint removed before its x-sunset (2027-01-31) | none | api-path-removed-before-sunset | 1 |
Endpoint removed after its x-sunset (2026-09-30) passed | none | Passes | 0 |
Endpoint deprecated with no x-sunset | none | Passes | 0 |
Endpoint deprecated with no x-sunset | --deprecation-days-stable 90 | api-deprecated-sunset-missing | 1 |
Endpoint deprecated with x-sunset 6 days out (2026-10-20) | none | Passes | 0 |
Endpoint deprecated with x-sunset 6 days out (2026-10-20) | --deprecation-days-stable 90 | api-sunset-date-too-small | 1 |
The early removal, verbatim:
1 changes: 1 error, 0 warning, 0 info
error [api-path-removed-before-sunset] at dep-future.yaml
in API GET /invoices/legacy
api path removed before the sunset date `2027-01-31`
Without the flag, oasdiff only guards the removal: you can deprecate with no date, or a date next week, and the build passes (oasdiff changelog lists the deprecation as info). With --deprecation-days-stable 90, deprecating requires a sunset date at least 90 days out, which is how your published notice period becomes a build rule.
Keep one date. The x-sunset in the spec and the Sunset header the endpoint sends should name the same day, so the operation above would answer with Sunset: Sun, 31 Jan 2027 23:59:59 GMT. Generate the header from the spec, or add a test that compares them, so the two can't drift apart.
Versioning webhooks and SDKs too
Webhooks are API responses you push, so they need a version too. Stripe and Shopify both tie the payload to a version the customer controls.
Stripe's docs say "Webhook events use the API version that's set during your webhook's endpoint creation. Otherwise, they use your Stripe account's default API version." To upgrade, you create a second endpoint on the new version, run both and process one copy of each event, then disable the old one. Every event arrives twice during the overlap, which is why Stripe says handlers "must be idempotent" when two event destinations are active. We tested which receiver designs actually stay idempotent under duplicate and concurrent delivery in our webhook idempotency and retries guide.
Shopify's docs say "Webhook payloads are versioned the same way as API responses." They fall forward like API calls, and each webhook carries the X-Shopify-API-Version header naming the version used.
For your own SaaS: store a version on each webhook subscription, render payloads in it, send it in a header on every delivery, and let customers change it with the parallel-endpoint flow Stripe documents.
SDKs need their own rule. Recent Stripe server SDKs send the API version that was current at their release, and in strongly typed languages (Java, Go, .NET) Stripe says you "can't target a different API version." Tying each SDK major to an API version makes the two upgrades one event. Version the SDK with semver and state in its changelog which API version it pins.
When you don't need versioning yet
If your API has one client and you ship both sides, you don't need a versioning scheme yet. An internal API behind your own web app, or a backend for a single first-party mobile app you can force-update, can change in lockstep with its client.
What you do need from day one is the habit that makes versioning cheap later: additive-only changes. Add fields instead of renaming them, add optional parameters instead of required ones, deprecate before you delete, and keep an OpenAPI spec in the repo with the breaking-change check running in CI. The day a customer or partner starts calling your API, name the current contract as your first version. If that moment arrives with a bigger rebuild, our post on what breaks when a SaaS hits product-market fit covers sequencing that work without a big-bang rewrite.
Where this approach falls short
A spec diff only sees the spec. Changed defaults, error codes, sort orders and timing never appear in an OpenAPI diff, and contract tests against recorded traffic catch more of them. We tested oasdiff's deprecation checks on single operations, and since OpenAPI has no version-level deprecation field, retiring a whole version still means tracking its dates outside the spec.
Pinning multiplies your test matrix, because every supported version is a set of responses to test. It also multiplies patching: every version you still serve needs security fixes, so a vulnerability in shared code has to be fixed and tested on each one. A short, published support window keeps both costs bounded.
Deprecation and Sunset headers help only if someone logs them. Direct contact, driven by your version logs, moves more tenants than headers.
Vendor pages change. The tables here are a snapshot from 2026-10-14; Stripe's current version name changes monthly, and GitHub and Shopify update their supported lists.
If you're about to publish a public API, or about to break one, Keplaris's API and SaaS development practice designs OpenAPI-first contracts with versioning and consistent error contracts built in from the first release.
Frequently asked questions
Should I put v1 in the URL?
A /v1 path prefix is a reasonable default for a new public API because it is visible in logs, easy to route and easy to cache. Treat it as a namespace you rarely change, and ship breaking changes some other way. Stripe does exactly this: its URLs start with /v1, and the breaking changes ship as dated versions selected by account default or by the Stripe-Version header. If you expect frequent breaking changes, a date-based version in a header (GitHub's X-GitHub-Api-Version) or in the path (Shopify's quarterly versions) scales better than bumping v1 to v2.
How long should I support an old API version?
Publish a number and keep it. On the pages we read on 2026-10-14, GitHub supports a previous REST API version for at least 24 months after a newer one is released, and Shopify supports each stable version for at least 12 months with at least nine months of overlap between consecutive versions. Smaller SaaS teams can choose a shorter window, but customers plan their roadmaps around the number you write down, so pick one your team can afford to run.
Is GraphQL versionless?
Not necessarily. GraphQL lets you add fields without breaking clients and mark old fields as deprecated, which removes many reasons to version. But removing a field still breaks whoever queries it. Shopify's GraphQL Admin API and Storefront API are date-versioned on the same quarterly schedule as the rest of its versioned APIs, and Shopify deprecates fields across supported versions before removing them in a later release.
Should a public API use semantic versioning?
Semver works well for SDKs and libraries, where a major number tells a developer an upgrade needs code changes. For a hosted API the client never installs anything, so the useful question is which contract a request gets, and dates answer that more clearly than numbers. Stripe, GitHub and Shopify all name their API versions by date. A common split is a date-based API version plus semver for each SDK.
Does a Deprecation header mean the endpoint stops working?
No. RFC 9745 says plainly that the act of deprecation does not change any behavior of the resource. Deprecation tells clients they should migrate; the separate Sunset header from RFC 8594 says when the resource is likely to become unresponsive. RFC 9745 requires that the Sunset date is not earlier than the Deprecation date.
How do you version webhooks?
Store an API version on each webhook subscription, render payloads in that version, and send the version in a header on every delivery. Stripe ties webhook events to the version set when the endpoint was created, and Shopify versions webhook payloads the same way as API responses.
Get in touch.
Whether you have questions or just want to explore what's possible, we're here to help.
