Versioning & Evolution
Every API has a version two. This chapter is the line between a change that is safe and one that breaks callers, the versioning strategies and what each really costs, how to add without breaking, and how to deprecate and sunset.
The core tension
You ship a server. Callers ship clients. You cannot deploy both in the same minute, and some callers will never update: a cron job someone wrote in 2024, a mobile build still on a phone, a partner's integration with no owner left. Your API is the only place the two sides meet, so whatever you change there lands on code you cannot see.
That is why most of versioning is not about version numbers. It is the discipline of changing the contract by adding to it. A version number is the expensive fallback for the changes you could not make additively.
Snip's API gives a concrete case. Callers create short links with POST /v1/links and read them back as a link object. Thousands of scripts parse that object. Every field in it is a promise, and so are the status codes and error types around it (errors are contract too).
Insight. The contract is everything a client can observe, not only the schema. Field names, types, meaning, defaults, ordering guarantees, pagination behaviour, status codes and error type URIs all count. If a client can depend on it, assume one does.
Breaking or additive
Decide this per change, with a fixed list, not by feel. A change is breaking if a correct client written against the old behaviour can fail after it.
| Breaking | Why it breaks |
|---|---|
| Remove or rename a response field | Clients read it and get nothing. |
| Change a field's type or meaning | created_at from an ISO string to epoch seconds fails every parser; a changed meaning fails silently. |
| Add a required request field | Every existing request is now invalid. |
| Tighten validation | Input that passed yesterday gets a 422 today. |
| Remove an endpoint | Calls return 404. |
Change an error's status or type | Retry and handling logic keys on these. |
| Change a default | Callers who omitted the parameter get new behaviour. |
| Change pagination behaviour | Page size, ordering or cursor format changes can skip or repeat items (see resource modeling). |
A change is additive if a correct client does not notice it: a new optional response field, a new endpoint, a new optional request parameter, a new optional enum value.
The last one needs care. A new enum value is additive on the wire and breaking in a client that does switch (link.status) with no default branch, or that deserialises into a closed enum type and throws. So the rule has two halves, and you must publish both: you may add enum values at any time, and clients must tolerate values they do not know. Put that sentence in the reference docs next to every enum. Without it, you promised nothing and the strict client is within its rights.
Loosening is usually safe and tightening is not. Accepting a longer tags list is fine. Rejecting a url you used to accept is a break, even when the old behaviour was a bug. Fix bugs that way only with the deprecation process below.
The robustness principle
RFC 1122 states Postel's rule: be conservative in what you send, liberal in what you accept. For an evolving API it gives two useful halves.
- Server, conservative in what you send. Emit exactly the documented shape. Never leak a field you may want to drop; once it is in a response, someone reads it.
- Client, liberal in what you accept. Ignore fields you do not know. Tolerate enum values you do not know. This is what makes additive change safe.
The principle has a limit. A server that is liberal in what it accepts hides bugs: a misspelled tagz field is silently dropped, a string "true" is coerced, and callers build on the accident. Later you cannot tighten without a break. So be liberal on responses (clients ignore the unknown) and strict on requests (reject unknown fields and wrong types with a clear 422, as in Errors & Validation). Strictness on day one is free. Strictness on day 900 is a version bump.
The working rule is short: add, don't change. If you need different behaviour, add a new field or endpoint beside the old one, and deprecate the old one later.
Versioning strategies
When an additive path does not exist, you need a way to ship two behaviours at once. There are four common ways to name them.
URL path
GET https://api.snip.ly/v1/links/q7Xk2
GET https://api.snip.ly/v2/links/q7Xk2
The version is visible in logs, docs, support tickets and browser address bars. Routing is trivial, caches key on the URL with no extra work, and any developer can try it with curl. The cost is bluntness: the version covers the whole surface, so a break in one resource tends to mean a v2 of everything, or a confusing mix where /v2/links sits beside /v1/domains.
Header or media type
GET /links/q7Xk2 HTTP/1.1
Accept: application/vnd.snip.v2+json
This is granular: you can version one representation without touching the others, and the URL names the resource, not its format. It fits the HTTP model well. In practice it is harder to try in a browser, easy for callers to forget (what happens when the header is missing?), invisible in most logs, and needs Vary: Accept so caches do not mix versions. Routing and docs get more complex.
Query parameter
GET /links/q7Xk2?version=2
Simple to add and simple to try. It clutters URLs, muddies cache keys (is ?version=2 the same resource as the bare URL?), and callers drop it when they build links by hand. It is the weakest of the four.
Date-based versions
Stripe pins each account to the date of its first request and lets callers override per request with a version header. Changes ship behind a date, and each old behaviour stays available through small transforms. It gives very fine-grained, low-drama upgrades, and it needs serious machinery: a registry of changes, a transform per change, and tests across every date. Consider it once you have the staff to run it.
Why Snip uses the path
Snip's API is at https://api.snip.ly/v1, with the major version in the path. For a public API that is the right default, for three reasons.
- Visibility. A support engineer reading a log line, or a user pasting a failing request, sees the version at once. With a header you ask them to paste more.
- Cheap to operate. A gateway routes
/v1/*and/v2/*to different handlers with one rule, and rate limits, auth and analytics split by prefix for free. - Honest cost. The bluntness is a feature: a new major version is expensive to ship and to retire, so it should be rare and a little painful. That pressure keeps you adding in
v1.
Only the major version goes in the path. There is no v1.3. Minor and patch changes are additive by definition, so they need no address; they go in the changelog.
Exceptions: if your callers are a small set of known partners, a header costs little and buys granularity. If you expect frequent breaking change across many independent resources, date-based pinning scales better than a stream of majors. Most public APIs are in neither case.
One boundary matters: the version belongs to the control plane. The redirect itself, snip.ly/q7Xk2, is the data plane. It is a public URL printed on posters and embedded in old emails, and it has no version and no sunset. Break the API and you annoy developers. Break a redirect and you break every link ever published.
Running two versions at once
The wrong way is to copy the codebase into v1/ and v2/ directories. Every bug fix then lands twice, and the two drift apart within a quarter.
The right way is one core and a thin adapter per version:
request /v1/links → v1 adapter → core domain model → v1 adapter → response
request /v2/links → v2 adapter → core domain model → v2 adapter → response
The core holds the business rules, storage and authorisation. Each adapter does three things: parse that version's request shape into core types, call the core, and render the core's result in that version's response shape. Keep adapters free of logic. If an adapter starts making decisions, the decision belongs in the core.
This also tells you what a break costs. If v2 differs only in shape, its adapter is a few hundred lines. If v2 changes what the system does, the core must support both behaviours, and you are carrying a real fork behind a flag. Prefer breaks of shape over breaks of semantics.
Test both versions against the same core scenarios. A contract test suite per version, run on every change, is the only reliable way to know a core change did not alter v1.
Deprecation and sunset
You cannot delete what you cannot measure. Deprecation is a process with steps you can run, not a notice you post.
- Instrument first. Log every request with version, endpoint, API key or client ID, and, for field-level changes, which deprecated fields or parameters it used. You need to answer "who still calls this?" with a list of names, before you announce anything.
- Announce with a date. Publish a changelog entry that says what changes, why, what to use instead, and when the old behaviour stops. Email the owners of keys that call it.
- Signal in every response. Machines read headers; humans read logs. Send both:
HTTP/1.1 200 OK Deprecation: @1803859200 Sunset: Wed, 01 Mar 2028 00:00:00 GMT Link: <https://snip.ly/docs/migrate-v2>; rel="deprecation"; type="text/html" Content-Type: application/jsonDeprecation(RFC 9745) says the endpoint is deprecated, from a date.Sunset(RFC 8594) says when it will stop responding. ALinkheader points to the migration guide. For a deprecated field in an otherwise live endpoint, add awarningsnote to the body only if your schema already reserves a place for it; do not bolt one on later. - Chase the stragglers. Watch the usage numbers fall. Contact the keys that remain. Brown-outs (short, scheduled periods of errors a few weeks before the sunset) turn a missed email into a failing build someone notices.
- Remove, then keep the tombstone. After the sunset date, return
410 Gonewith a problem-details body that links to the migration guide, instead of a bare404.
Pick the timeline for the slowest honest caller, not the average one. For a public API, twelve months from deprecation to sunset is a reasonable floor; for a mobile client you cannot force to update, longer. Whatever you promise, keep it. Callers forgive a long window and never forgive a short one.
Watch out. The "no traffic" signal can lie. A job that runs once a quarter or once a year will not show in a two-week window. Measure over at least one full cycle of your slowest caller, and email key owners regardless of what the logs say.
Events follow the same rules. A webhook payload is a response that someone else's server parses, so version it and deprecate it the same way. See Async, Webhooks & Streaming.
Snip in practice
Here is a v1 link on launch day:
{
"code": "q7Xk2",
"url": "https://example.com/pricing",
"short_url": "https://snip.ly/q7Xk2",
"created_at": "2026-03-02T09:14:00Z"
}
Customers ask for organisation and for click counts. Both are additive, so both ship inside v1 with no new version:
{
"code": "q7Xk2",
"url": "https://example.com/pricing",
"short_url": "https://snip.ly/q7Xk2",
"created_at": "2026-03-02T09:14:00Z",
"tags": ["launch", "pricing"],
"total_clicks": 1284
}
tags is an optional request field on create and update (PATCH /v1/links/q7Xk2) and an array that is empty when unset. total_clicks is read-only and appears in responses only. Old clients see two extra fields and ignore them. The changelog gets two lines. Nothing else happens, which is the point.
Now a true break. Snip wants a link to carry more than one destination rule, plus metadata such as a title and preview image. The honest model is to split url:
{
"code": "q7Xk2",
"destination": { "url": "https://example.com/pricing" },
"metadata": { "title": "Pricing", "image": null },
"short_url": "https://snip.ly/q7Xk2",
"created_at": "2026-03-02T09:14:00Z",
"tags": ["launch", "pricing"],
"total_clicks": 1284
}
You cannot do this in v1. Removing url breaks every reader; keeping url as an alias means two sources of truth for one value. This change finally forces /v2. Because the core already stores a destination and metadata separately, the work is a new adapter: v2 renders the split, v1 keeps rendering url from destination.url. Then the deprecation process above runs on /v1/links, with the headers and dates shown there.
Notice what did not need v2: the two additive changes, and everything on the redirect plane.
Forward-compatible clients
The additive rule only works if clients cooperate. Put this in your docs as a short, numbered "what we promise, what we ask" list, and put it in your SDKs by default.
- Ignore unknown fields. Configure the JSON parser to skip them. Do not fail on extra keys.
- Tolerate unknown enum values. Map them to an "unknown" case and keep going. Never
throwin adefaultbranch for a value you merely have not seen. - Do not depend on field order. JSON objects are unordered.
- Do not depend on string formats you were not promised. A
codeis an opaque string; its length may grow. - Treat cursors as opaque. Pass them back unchanged; do not decode them.
- Read the deprecation headers. Log any response with a
DeprecationorSunsetheader, so the warning reaches a human before the sunset date does.
Ship your own SDKs with these defaults on. Most callers use the SDK, so the SDK is where the promise is kept or lost. For modelling choices that make these changes easier to absorb, see Resource Modeling.
✓ Check yourself
Answer before you open the folds. Each answer points back to the section that covers it.
Snip adds a new value,
archived, to thestatusenum of a link. The change is additive on the wire. Under what condition can it still break a caller, and what should you have published beforehand?Show answer
A client that treats the enum as closed (a
switchwith no default, or a strict deserialiser) fails on the unknown value. You should have documented that clients must tolerate unknown enum values. See Breaking or additive.Why does the robustness principle argue for strict request validation on day one, even though it says to be liberal in what you accept?
Show answer
A liberal server hides caller bugs, and callers then build on the accident. Tightening later is a breaking change. The liberal half belongs to the client reading responses. See The robustness principle.
A teammate proposes
?version=2because it is the quickest to ship. Give two concrete costs you would raise, and say which strategy you would pick for a public API and why.Show answer
It muddies cache keys and clutters URLs; callers also drop it when they build URLs by hand. For a public API, the path is the default: visible in logs, one routing rule, easy to try with
curl. See Versioning strategies and Why Snip uses the path.You run
/v1and/v2. A bug fix in link creation must apply to both. How should the code be arranged so you fix it once, and where would a business rule in an adapter signal a problem?Show answer
One core holds the rules; each version has a thin adapter that translates request and response shapes. A rule in an adapter means the rule belongs in the core, or
v2changes semantics, not only shape. See Running two versions.Your logs show no
/v1/linkscalls from keyk_8812in the past two weeks, and the sunset is next week. What could make removal still unsafe, and what do you do?Show answer
The key may belong to a job that runs quarterly or yearly. Measure over the slowest caller's full cycle, email the key owner, and use a brown-out before removal. See Deprecation and sunset.
Classify each as additive or breaking: adding
total_clicksto the link; makingtagsrequired on create; changing the default page size from 20 to 50; addingGET /v1/links/{code}/qr.Show answer
Additive, breaking, breaking (a changed default and pagination behaviour), additive. See Breaking or additive and Snip in practice.
Takeaway
- The contract is everything a client can observe. Most evolution should be additive: add fields, endpoints and optional parameters, and leave the old ones alone.
- Adding an enum value is additive only if you told clients to tolerate unknown values. Publish that rule next to every enum.
- Be strict on requests from day one and liberal on responses. Loosening later is safe; tightening is a break.
- Put the major version in the path for a public API. It is visible, cheap to route and cache, and its cost keeps new majors rare.
- Run versions as one core plus thin adapters, never as a forked codebase.
- Deprecate by measuring first, then announce with a date, send
DeprecationandSunsetheaders, and hold the timeline. You cannot delete what you cannot measure.
Next, who may call these endpoints, and with what rights. Authentication & Scopes →
Further reading
- RFC 8594: The Sunset HTTP Header Field — the header that tells callers when a resource will stop responding.
- RFC 9745: The Deprecation HTTP Response Header Field — the standard form of the deprecation signal, including the date format.
- RFC 1122: Requirements for Internet Hosts — section 1.2.2 states the robustness principle.
- Stripe: APIs as infrastructure, future-proofing with versioning — how date-based versions and per-change transforms work in production.
- Stripe API reference: Versioning — account pinning and the per-request override.
- Google AIP-180: Backwards compatibility — Google's list of what counts as a compatible change.
- GitHub REST API: API versions — a header-based, date-named version scheme and its support policy.