Backend10 min

API versioning strategies that don't hurt later

A practical comparison of URL, header, and date-based API versioning — why Stripe uses dates, when feature flags beat versions, and how to run deprecations without burning trust.

Every API ships a versioning strategy on day one, whether its authors noticed or not. The strategy is either a URL prefix, a header, a date, or an unstated promise that nothing will ever change. The fourth option is the most common, and it fails the first time a customer's integration breaks on a Tuesday. The good news is that versioning is a problem with well-understood trade-offs and a handful of teams — Stripe most famously — who have been running the experiment in public for a decade. Here's how to pick a strategy that survives three years of shipping without forcing a rewrite on every integrator.

The three schools of thought

Three approaches dominate production APIs in 2026. Each makes a different bet about who bears the cost of change — the API maintainer, the integrator, or the tooling in between.

StrategyExampleProConBest fit
URL path/v1/invoicesObvious, easy to route, CDN-cache friendlyEvery change of consequence becomes a v2 — big cliff, hard upgradePublic APIs with few breaking changes
HeaderAPI-Version: 2Clean URLs, easier to run many versionsLess visible, harder to debug, cache variance harderInternal APIs, partner APIs
Date-basedStripe-Version: 2026-03-25Small, incremental changes without cliffsMore maintenance work on the server sideAPIs that evolve continuously
Feature flagX-Features: new-webhook-schemaOpt-in adoption per capabilityCombinatorial test surfaceComplements the above; not a substitute

URL versioning — the default, for better and worse

Most APIs start with /v1/. It's obvious, it's trivial to route, and it's trivial to document. The problem shows up when the first batch of breaking changes lands. Do you ship them as v2? If you do, every integrator has a migration project on their hands — usually one that sits at the bottom of a backlog for eighteen months. Do you wait and batch changes into a bigger v2? Now the changes pile up, the diff between v1 and v2 becomes overwhelming, and the upgrade is painful for a different reason.

The v2 trap: every URL-versioned API that ships a v2 does so reluctantly, typically two to three years in, and typically with a changelog long enough that integrators treat it as a reintegration from scratch. If you're on your way to a v2, strongly consider whether you actually want a second living version of your API or whether you're really overdue on shipping incremental changes a different way.

URL versioning is the right default when your API has few breaking changes — a payment API where the core model is stable, a public REST API for a product with a long-settled data shape. It's the wrong default when your product is evolving quickly and you're going to need to ship breaking changes every few months. Stripe itself started with /v1/ and never changed the URL prefix; their actual versioning happens in a header.

Header versioning — cleaner URLs, more moving parts

Header-based versioning keeps the URL stable and moves the version into a request header (API-Version, Accept, or a custom header). The wins are real: a clean URL is a good URL, and routing the same resource to different version handlers is straightforward. The losses are also real: headers are invisible in a browser address bar, logs and error reports need to surface the header explicitly, and CDN caching needs to key on the version header or you'll serve the wrong response to the wrong client.

Header versioning shines for internal or partner APIs where the consumer is sophisticated and the URLs are consumed by other services, not humans. For a public API where developers debug in the browser and learn the shape from the URL, headers add friction.

Date-based versioning — the Stripe approach

Stripe's versioning is the most copied approach in the industry for a reason. The URL stays /v1/ forever. The actual version is a date, passed as a Stripe-Version header — 2026-03-25 being the current one at time of writing. When an account is created, it's pinned to whatever version is current. Integrations against an older version continue to work unchanged until the account is explicitly upgraded, even as the rest of the API moves forward.

The mechanism under the hood is a chain of compatibility transforms. Every breaking change ships with a pair of transforms — one that upgrades requests from old to new, one that downgrades responses from new to old. An account pinned to 2025-06-01 hitting an endpoint running on 2026-03-25 code walks its request through every intermediate transform, runs on the latest handler, and walks the response back through every intermediate downgrade. The account owner sees the response shape they signed up for; the API team ships forward without being blocked on customer upgrades.

// A sketch of what date-versioned request handling looks like.
// Real implementations (Stripe, Shopify) are significantly more elaborate.

type ApiVersion = `${number}-${number}-${number}`; // "2026-03-25"

const REQUEST_TRANSFORMS: Array<{
  since: ApiVersion;
  up: (req: unknown) => unknown;
}> = [
  { since: "2025-09-01", up: (r) => renameField(r, "client_id", "customer_id") },
  { since: "2026-01-15", up: (r) => defaultField(r, "currency", "USD") },
];

const RESPONSE_TRANSFORMS: Array<{
  since: ApiVersion;
  down: (res: unknown) => unknown;
}> = [
  { since: "2025-09-01", down: (r) => renameField(r, "customer_id", "client_id") },
  { since: "2026-01-15", down: (r) => stripField(r, "currency") },
];

export async function handle(req: Request, accountVersion: ApiVersion) {
  let body = await req.json();

  // Upgrade request to current shape by applying every transform since the client's version.
  for (const t of REQUEST_TRANSFORMS) if (t.since > accountVersion) body = t.up(body);

  let response = await currentHandler(body);

  // Downgrade response to the shape the client's version expects.
  for (const t of [...RESPONSE_TRANSFORMS].reverse()) {
    if (t.since > accountVersion) response = t.down(response);
  }
  return response;
}

The cost of this model is real. Every breaking change carries a maintenance tax: you write the transform, you test it against every prior version, and you keep that test green forever. For a two-person team on a small API, that tax is too high. For a payments company with tens of thousands of integrations that can't move in lockstep, it's the only approach that scales without constant customer friction.

Feature flags — versioning at a finer grain

A lot of what looks like a versioning problem is really an adoption problem. You want a new webhook schema, or a richer error response, or an extra field on the primary resource. You don't need to bump the version for the whole API — you need a way for individual integrations to opt into the new behavior on their schedule. That's what feature flags on the API surface do.

A feature-flag header like X-Features: new-webhook-schema, richer-errors lets integrators adopt new capabilities one at a time. It pairs well with any of the other three strategies: Stripe uses beta flags on top of its date-versioned API to gate unreleased features to early adopters. The one thing feature flags don't replace is the hard version boundary — you still need that for fundamental shape changes. But a surprising share of breaking changes can be demoted to feature flags if you think about them early.

Backward compatibility — the rules that make any strategy work

Versioning strategy is half the answer. The other half is a set of discipline rules about what constitutes a breaking change and what doesn't. Without these rules, every change feels risky, every release becomes a negotiation, and the team ends up bumping versions to avoid thinking about compatibility.

  • Adding an optional field to a response is not breaking. Consumers should ignore fields they don't recognize; if yours don't, that's a bug in the SDK, not a version event.
  • Adding a required field to a request is breaking. Always make new fields optional with a server-side default.
  • Removing or renaming a field is breaking. Deprecate first, remove after a long runway — and only on a version bump.
  • Tightening validation is breaking. Loosening is not. Accept-more, emit-less is the safe direction.
  • Changing an enum's set of valid values is breaking if you add to responses, safe if you add to requests. Deprecate and telegraph before removing.

Deprecation — the part everyone gets wrong

A deprecation without a hard sunset is not a deprecation; it's a note in the changelog that nobody reads. The shape of a credible deprecation has four parts: a public announcement with a date, a runtime signal (a Deprecation or Sunset response header, or a warning in logs), targeted outreach to the integrations actually using the deprecated surface, and a final removal on the announced date. Skip any of these and the removal will feel to integrators like a surprise outage, because it is one.

Deprecation stageWhat you doDuration
AnnouncementBlog post, changelog, docs banner, email to adminsT+0
Runtime warningDeprecation and Sunset headers on affected responsesT+0 onwards
Targeted outreachIdentify top users of the deprecated surface, email them with migration guideT+30 to T+90
Final noticeEmail 30 and 7 days before sunset; dashboard noticeT+150
RemovalReturn 410 Gone or switch behavior to the new shapeT+180 minimum

Six months is the floor for a public API. For an API that charges money or moves money, twelve to eighteen months is more defensible. The cost of a longer runway is more code living in maintenance; the cost of a shorter runway is a trust problem you can't buy your way out of.

Changelog discipline

Every change to the API surface — additive, breaking, or behavioral — belongs in a changelog. Every version gets an entry. Every entry links to a migration note when one applies. The changelog is how integrators triage whether a release affects them, and a disciplined changelog is worth more than any number of release blog posts.

Keep the changelog in the same repo as the API. Require a changelog entry in the PR template for any change to the public surface. Reviewers enforce it. The changelog is not a marketing artifact — it's part of the API contract.

What we'd pick today

For a small or mid-sized SaaS shipping a public API in 2026, URL versioning plus strict additive-only discipline plus feature flags covers 90% of what a team needs, and the maintenance cost is low. Keep /v1/ alive for as long as possible. Add fields freely, deprecate with long runways, and feature-flag anything that's ambiguous. Reserve a v2 for a moment when the data model is genuinely different — a pivot, a major resource rename, a new authentication model. If you reach the point where a date-versioned API would pay for itself, you'll know, because you'll be shipping breaking changes often enough that Stripe's approach starts to look like a bargain.

Key takeaways

  • URL versioning is the right default for most SaaS APIs. It's obvious, routable, and cache-friendly — the v2 trap is real but avoidable with additive-only discipline.
  • Date-based versioning scales to APIs that change constantly, at the cost of continuous transform maintenance. It's the right call when customer-led migration would stall the roadmap.
  • Feature flags are the underused third option. Most breaking changes can be demoted to opt-in flags with a little up-front design.
  • Backward compatibility rules matter more than the strategy. Additive changes, optional fields, loosened validation — these keep v1 alive for years.
  • A deprecation without a sunset date is just a note. Six months minimum for public APIs, more for anything that charges money, with runtime headers and targeted outreach before removal.
#api-design#versioning#rest#stripe#backward-compatibility#deprecation#saas
Working on something similar?

Let's build it together.

We ship production SaaS, marketplaces, and web apps. If you want an engineering partner — not a consultancy — let's talk.