Skip to content
yutils

How API Versioning Actually Works

Where to put the version (URL / header / Accept media type / query), SemVer and compatibility policy, deprecation procedure with the Sunset header, how to avoid breaking changes, and how Stripe / GitHub / AWS actually run versioning in production.

~10 min read

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 mobile

Where 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 APIs

2. 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 feeds

4. 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 breaking

Breaking Changes — Where the Line Is

ChangeClassNotes
Add a fieldCompatible (usually)Strict-schema clients may still break
Add an endpointCompatibleSafe
Add an optional query / headerCompatibleSafe if the default matches existing behavior
Remove a fieldBreakingExisting readers break
Rename a fieldBreakingRemove + add = both sides break
Change a field typeBreakingint → string or introducing nullable both break
Change response structureBreakinge.g. object → array or new wrapping
Change HTTP status codeUsually breakingEven 200 → 201 breaks strict clients — see http-status-codes
Change error response shapeBreakingError-parsing code shatters
New required fieldBreakingOld client requests get 400s
Tighter rate limitsBreaking (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,billing to 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. Adding meta / pagination later doesn't touch data.
  • 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_str field.
  • "Improve" the error shape — every old client parsing error.code breaks. 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

  1. Put only the major in the URL — /v1/.... Header / Accept versioning costs more to operate.
  2. Use a response envelope — meta / pagination later are free.
  3. Always set an X-API-Version response header (for debugging / logging).
  4. Document the team's definition of "breaking change" so PR reviewers agree.
  5. Adopt Sunset + Deprecation headers from the start.
  6. 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.

Back to guides