Once you publish an API, clients start depending on its shape. Rename a field or rearrange a response and a mobile app you've never heard of breaks five hours later. Hence versioning. This guide covers where to put the version (URL / header / Accept), SemVer + compatibility policy, the deprecation process, and how Stripe / GitHub / AWS actually operate.
Why Versioning
v1 response:
{ "user_id": 42, "name": "jade" }
→ "let's shorten user_id to id" + "split name into first/last"
v2 response:
{ "id": 42, "first_name": "jade", "last_name": "kim" }
The old mobile app (v1.0.5) expects user_id → null → crash
→ A shape, once published externally, is almost a permanent contract.
Options:
1. Never break (additive only) — add OK, change / remove not OK
2. Branch by version — run v1 and v2 in parallel, then sunset v1
3. (Impossible) force every client to update — not for SaaS, not for mobileWhere to Put the Version — Four Places
1. URL path (the most common)
GET /v1/users/42
GET /v2/users/42
Pros:
- Visible. Branch in curl / logs / routers immediately
- Cache keys split automatically (different URLs → CDN separates them)
- Simplest client code
Cons:
- Feels like a resource "moved" (REST purists object)
- Bumping v3 / v4 for minor changes is awkward → only major in practice
Adopted by: Twitter (v1.1, v2), GitHub (v3 URL + media type), most
public SaaS APIs2. Custom header
GET /users/42
X-API-Version: 2
Pros:
- Clean URL
- Switching headers can branch some client code
- Plays well with date-style versions (Stripe)
Cons:
- Invisible in curl — debugging requires inspecting headers
- If the header isn't part of the cache key, CDNs serve the wrong version
- No standard name — every vendor invents (X-Api-Version / Api-Version / Stripe-Version ...)
Adopted by: Stripe (Stripe-Version: 2024-06-20),
Shopify (X-Shopify-Api-Version)3. Accept media type (content negotiation)
GET /users/42
Accept: application/vnd.github.v3+json
Accept: application/vnd.example.v2+json
Pros:
- Matches HTTP standard (RFC 6838 vendor tree)
- Same URL, negotiate representation — fits REST principles
- Response Content-Type can mirror it
Cons:
- Most verbose, every client must set it explicitly
- Mixing with wildcards / q-values complicates routing
- Steep learning curve — new joiners / external integrators struggle
Adopted by: GitHub v3 (now the default), some Atom / RSS feeds4. Query parameter
GET /users/42?api-version=2
Pros:
- Easy to try / debug (paste into a browser bar)
Cons:
- Verbose URLs
- Query parameters usually mean "filter / search" — semantic confusion
- Implicitly in cache keys but unsorted queries miss
- Ambiguous default when omitted
Adopted by: some Azure APIs, Microsoft Graph (?api-version=...)SemVer + Compatibility Policy
Semantic Versioning MAJOR.MINOR.PATCH:
MAJOR — backward-incompatible (breaking) changes
MINOR — backward-compatible additions (new endpoint / new field)
PATCH — backward-compatible bug fixes
Applied to APIs:
- Public APIs typically expose only MAJOR in the URL — /v1, /v2
- MINOR / PATCH are add-only changes inside the same v1
→ adding a field OK; removing / renaming / retyping NOT OK
The "backward-compatible addition" pitfall:
- Adding a field → assumes existing clients ignore unknown fields
→ in reality, strict JSON-schema validators break (e.g. protobuf
unknown-field handling, certain Swift Codable configurations)
- Adding an enum value → existing clients without a default in their
switch / match crash
→ adding enum values should be treated as effectively breakingBreaking Changes — Where the Line Is
| Change | Class | Notes |
|---|---|---|
| Add a field | Compatible (usually) | Strict-schema clients may still break |
| Add an endpoint | Compatible | Safe |
| Add an optional query / header | Compatible | Safe if the default matches existing behavior |
| Remove a field | Breaking | Existing readers break |
| Rename a field | Breaking | Remove + add = both sides break |
| Change a field type | Breaking | int → string or introducing nullable both break |
| Change response structure | Breaking | e.g. object → array or new wrapping |
| Change HTTP status code | Usually breaking | Even 200 → 201 breaks strict clients — see http-status-codes |
| Change error response shape | Breaking | Error-parsing code shatters |
| New required field | Breaking | Old client requests get 400s |
| Tighter rate limits | Breaking (operationally) | Same code, but 429s spike |
Stripe's Date Versioning — An Interesting Variant
Stripe-Version: 2024-06-20
Properties:
- Every breaking change is bundled under a release-date label
- Pinned to the account's signup date — that version is used unless overridden
- Upgrade explicitly to get new features / compatible changes
- No major jumps like v1, v2 → continuous gradual evolution
Pros:
- Almost no forced "move within N months" deprecation
- Clients upgrade at their own pace
Cons:
- The server simultaneously supports years of versions
- The codebase accumulates if (version >= "2024-06-20") branches
- Onboarding is hard — new engineers must internalize every branch
Why Stripe can afford this: a large slice of revenue comes through this
API, so they absorb the cost of every breaking change themselves. A
typical SaaS imitating this hits ops cost ceiling fast.GitHub's Evolution
GitHub API:
- v1, v2 (initial) → v3 (REST, longest run) → GraphQL (a separate route, not v4)
- v3 has no URL prefix; the host itself is api.github.com (default v3)
- preview features: Accept: application/vnd.github.<feature>-preview+json
→ try features before official release, with explicit "may break"
Lesson: major jumps every 5-10 years. In between, additive + preview headers
evolve the API. Truly breaking jumps (REST → GraphQL) are cheaper to
ship as a separate product than to retrofit.Deprecation — The Sunset Header
IETF RFC 8594's Sunset header is a standard way to say "this endpoint disappears on this date" in the response itself.
HTTP/1.1 200 OK
Content-Type: application/json
Sunset: Sat, 31 Dec 2024 23:59:59 GMT
Deprecation: true
Link: <https://api.example.com/v2/users/42>; rel="successor-version"
→ Clients can detect this and warn / log automatically
→ Operators track migration via the "% of responses with Deprecation: true"
metric
Standard procedure:
1. Decide — v1 → v2 migration starts
2. Announce — blog + email + dashboard banner
3. Set the Deprecation header on every v1 response
4. Set Sunset date — usually 6-12 months out
5. Monitor usage — daily v1 requests / per-customer distribution
6. Personal outreach — direct help for the top N% customers
7. Sunset approaching — return 410 Gone for 50% of v1 traffic (canary)
8. Sunset — every v1 hit becomes 410 Gone or 308 Permanent Redirect
Recommended: minimum 6-month deprecation window. Enterprise customers
often need 12-24 months.Avoiding Breaking Changes — Practical Patterns
- "Add, don't change" — introduce new fields, keep the old ones around. Mark old fields deprecated but retain them for a long time.
- Response expansion — keep the default small, allow
?expand=user,billingto attach extra fields. Adding new sections doesn't disturb the base shape. - Add nullable fields with a stable default — new nullable fields are fine, but populate them consistently (null on every legacy item). Inconsistent presence trips clients up.
- String + an OpenAPI enum list, not enum — avoids "adding a new enum value breaks clients" at the cost of less type safety.
- Response envelope —
{ "data": {...}, "meta": {...} }style. Addingmeta/ pagination later doesn't touchdata. - Feature flags — opt-in via
X-Feature-Flag: new-billing. Promote to default once stable.
Common Pitfalls — Real Incidents
- "Nobody uses this field" → remove → mobile crashes spike next day. Externally exposed fields should be measured before removal.
- "Let's widen int to long" — JavaScript's Number has 53-bit precision. IDs over 2^53 need string encoding (or be strings from day one). Twitter's classic incident: 64-bit tweet IDs got truncated in JS, forcing the addition of an
id_strfield. - "Improve" the error shape — every old client parsing
error.codebreaks. Be most conservative here. - Changing date / timezone format — Unix timestamp → ISO 8601 type swaps. Either emit both (during deprecation) or hold it for a major version.
- "403 is more accurate than 401, let's swap" — strict clients refresh tokens only on 401. With 403 they fail instantly without trying.
- Tightening CORS without notice — browser clients on the old origin break immediately. For CORS behavior and debugging see
cors-explained. - "It's a private endpoint, no rules apply" → third parties reverse-engineer it. Treat private APIs the same way once they're externally visible.
Recommendations for Day One
- Put only the major in the URL —
/v1/.... Header / Accept versioning costs more to operate. - Use a response envelope — meta / pagination later are free.
- Always set an
X-API-Versionresponse header (for debugging / logging). - Document the team's definition of "breaking change" so PR reviewers agree.
- Adopt Sunset + Deprecation headers from the start.
- Once exposed externally, policy is "add freely, change only with PR review + usage check".
Wrap-up
API versioning is the operational machinery that makes the reality "a published shape is almost a permanent contract" survivable. URL prefix is the simplest and most practical. Expose only the major in SemVer; keep MINOR / PATCH add-only inside the same version.
The hard part isn't the protocol — it's deprecation. 6-12 months of window + Sunset headers + usage monitoring + direct outreach to top customers. That operational stamina is the real value of an API.