Section C · Evolve & protect the contract

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.

BreakingWhy it breaks
Remove or rename a response fieldClients read it and get nothing.
Change a field's type or meaningcreated_at from an ISO string to epoch seconds fails every parser; a changed meaning fails silently.
Add a required request fieldEvery existing request is now invalid.
Tighten validationInput that passed yesterday gets a 422 today.
Remove an endpointCalls return 404.
Change an error's status or typeRetry and handling logic keys on these.
Change a defaultCallers who omitted the parameter get new behaviour.
Change pagination behaviourPage 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.

  1. 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.
  2. 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.
  3. 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.

  1. 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.
  2. 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.
  3. 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/json

    Deprecation (RFC 9745) says the endpoint is deprecated, from a date. Sunset (RFC 8594) says when it will stop responding. A Link header points to the migration guide. For a deprecated field in an otherwise live endpoint, add a warnings note to the body only if your schema already reserves a place for it; do not bolt one on later.

  4. 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.
  5. Remove, then keep the tombstone. After the sunset date, return 410 Gone with a problem-details body that links to the migration guide, instead of a bare 404.

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.

  1. Ignore unknown fields. Configure the JSON parser to skip them. Do not fail on extra keys.
  2. Tolerate unknown enum values. Map them to an "unknown" case and keep going. Never throw in a default branch for a value you merely have not seen.
  3. Do not depend on field order. JSON objects are unordered.
  4. Do not depend on string formats you were not promised. A code is an opaque string; its length may grow.
  5. Treat cursors as opaque. Pass them back unchanged; do not decode them.
  6. Read the deprecation headers. Log any response with a Deprecation or Sunset header, 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.

  1. Snip adds a new value, archived, to the status enum 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 switch with 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.

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

  3. A teammate proposes ?version=2 because 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.

  4. You run /v1 and /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 v2 changes semantics, not only shape. See Running two versions.

  5. Your logs show no /v1/links calls from key k_8812 in 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.

  6. Classify each as additive or breaking: adding total_clicks to the link; making tags required on create; changing the default page size from 20 to 50; adding GET /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 Deprecation and Sunset headers, 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