API Versioning Without Pain: Patterns That Don't Break Clients

Every API hits the moment when a change is necessary, the change is breaking, and clients in the wild cannot be updated atomically. The wrong response is to deploy and pray. The right response is a versioning strategy chosen before the first breaking change, not after it.

The first version of an API is the easy version. There are no clients yet, the contract is provisional, and breaking changes cost nothing. The hard version is the second one, deployed when real customers depend on the first, and made worse if no versioning strategy was decided in advance.

The classic mistake is to ship v1 with no version in the URL or headers, hit a breaking change six months in, and improvise. The improvisation is always worse than any of the standard patterns would have been, because the standard patterns are designed for exactly this moment.

What counts as a breaking change

The vocabulary matters. A breaking change is one that, deployed unilaterally, causes existing clients to fail. The non-exhaustive list:

  • Removing a field from a response
  • Renaming a field
  • Changing a field's type (string to integer, scalar to array)
  • Adding a required parameter to a request
  • Removing an endpoint
  • Tightening a validation rule that previously accepted a value
  • Changing the meaning or unit of a field (cents to dollars)
  • Changing the default behavior of an optional parameter

The tricky ones are the last two: a meaning change ("price was cents, now it is dollars") looks like the same field but breaks every client that does not change in lockstep. A default change ("if you do not pass include_archived, we used to default to false; now we default to true") breaks every client relying on the previous default.

The non-breaking-change list is shorter than people think:

  • Adding new endpoints
  • Adding new optional fields to requests
  • Adding new fields to responses (assuming clients ignore unknown fields)
  • Adding new error codes
  • Loosening a validation rule

The "assuming clients ignore unknown fields" caveat is real: some statically-typed clients fail to deserialize responses with unexpected fields. Document the expectation that clients must tolerate new fields and assume some never will.

Three versioning strategies

URI versioning. The version is in the path: /v1/orders, /v2/orders. This is what Stripe, GitHub, and Twilio use in some form. It is the most legible: the version is visible in every request, in every log, in every cURL example. It is also the easiest to implement: route by path prefix.

The downside is that v1 and v2 of the same logical resource have different URIs. Bookmarks, links, and integrations all reference a specific version. Migrating clients off v1 means touching every place the URL is hardcoded.

Header versioning. The URI stays stable; clients send API-Version: 2024-01-15 or Accept: application/vnd.api+json; version=2 in headers. Stripe famously does this with date-based versions. The URI is canonical; the version is metadata.

Header versioning lets you keep clean, RESTful URIs and avoid the v2-bookmark problem. It is also less visible: the version is not in the URL, so cURL examples need explicit headers, logs need to record the header, and developers have to remember it. Header versioning works best when the versions accumulate slowly and the breaking changes are small.

Date-based versioning. A variation: instead of v1, v2, v3, use the date the version was released. API-Version: 2024-01-15. The advantage is granularity: many small breaking changes can be released independently, each with its own date. Clients pin to a specific date and the server emulates the behavior of that date.

This is the Stripe model, and it is the most flexible at scale. It is also the most expensive to implement: you have to keep the behavior of every released date alive in the codebase, which means the code accumulates compatibility shims indefinitely. Stripe makes this work because their API is large and their customers are valuable enough to justify the engineering. Most APIs do not need this.

The deprecation lifecycle

Versioning is only half the story. The other half is deprecation: how a version moves from current to deprecated to retired. The lifecycle that works:

  1. Announce. New version is current. Old version is deprecated. Send Sunset headers in responses (RFC 8594). Email known customers. Update the docs to mark the old version deprecated and link to the migration guide.
  2. Wait. Give clients time. The right amount depends on your customer base; for B2B with a long-tail of integrations, six to twelve months. For internal APIs, one or two release cycles.
  3. Warn aggressively. As the sunset approaches, increase the warning channels. Send emails to active users on the old version. Add deprecation notices to dashboards. Track the volume per customer and reach out to the heaviest users individually.
  4. Retire. Return 410 Gone for old endpoints, with a clear message pointing to the new version. Keep the 410 response indefinitely; do not let the path 404. The 410 is documentation.

The migration cost

The hidden cost of every breaking change is not the engineering work to build it. It is the migration cost paid by every client. A breaking change to an API used by 100 customers is 100 small projects: each customer has to schedule the work, do the migration, test it, and deploy. Most of them have something more urgent than your migration.

This is why the better-than-versioning answer, when possible, is to avoid the breaking change. Add a new endpoint instead of changing an existing one. Add a new field instead of renaming an existing one. Translate the old format to the new one server-side, so old clients keep working. The cost of compatibility code is paid once by you; the cost of a breaking change is paid 100 times by your customers.

What to put in v1

The most useful thing about versioning is what it teaches you to avoid in v1. Knowing that v1 will live forever (in the sense that some customers will be on it for years) changes the design. Every field becomes a commitment. Every default becomes a contract. Every error code becomes documented behavior.

The discipline this imposes is healthy. The trick is to apply it from the first commit, not the first deprecation. Treat the first version as if breaking changes will be expensive, because they will be.

The four APIs we run at DocuMint, CronPing, FlagBit, and WebhookVault all use URI versioning (/api/v1/) because we are early enough that the simplest legible pattern wins. If we ever need v2, the v1 endpoints stay live and v2 is added alongside. The migration plan is written before the breaking change, not after.

Read more