
API versioning in 2026 means picking one of four patterns (URI, header, query, date) and committing to additive-only changes plus a 12-month deprecation runway. URI versioning (/v1/, /v2/) is the right answer for most teams because it is debuggable from a single curl line. Date-based versioning (Stripe's 2026-04-22.dahlia model) is the gold standard at scale, but the engineering tax is real and most teams do not need to pay it.
The rest of this post is the playbook: the four patterns ranked, what Stripe, Square, and Shopify actually ship in production, the additive-only ruleset, and working code for both URI and date-based versioning.
The job of an API version is to give you a knob to evolve your contract without breaking the clients already calling it. That has been true since SOAP. What changed in 2026 is who is on the other end of the wire.
LLM agents now consume APIs as much as humans do. A Cursor or Claude Code session generates client code in seconds, hard-codes the response shape, and ships it. When you change a field name silently, an agent-generated integration breaks at runtime, not at compile time, and the founder who wrote it has no idea why. Versioning is the only honest way to keep that contract stable while you keep shipping.
The good news is that the toolchain converged. OpenAPI 3.1 plus JSON Schema 2020-12 is the lingua franca for describing a versioned API. Postman, Mintlify, and Speakeasy all generate SDKs and docs from the same spec. If you publish an OpenAPI file per version, every downstream tool inherits your versioning policy for free. Versioning is one slice of the broader API design best-practices stack; pagination, error envelopes, and idempotency keys all interact with how you version.
Every API versioning post lists the same four patterns. Here they are with a 2026 verdict on each.
| Pattern | Example | Used by | Best for | Tax |
|---|---|---|---|---|
| URI | /v2/customers | GitHub, Twilio | Most teams, public APIs | Two URLs per resource |
| Header (media type) | Accept: application/vnd.api.v2+json | GitHub v3 (legacy) | REST purists | Hard to curl, hard to cache |
| Query string | ?v=2 | Mostly internal APIs | Prototypes | Easy to forget, breaks caches |
| Date-based | Stripe-Version: 2026-04-22 | Stripe, Square, Shopify | Scale, payments, commerce | Transformer chains, real eng tax |
/v1/ in the path. The version is impossible to miss, every request is debuggable from a curl line, browsers and CDNs cache cleanly, and your router just mounts a new sub-app per version. GitHub, Twilio, and Stripe's public REST surface all expose URI versions. If you are still settling on the underlying contract before you version it, our writeup on REST API design in 2026 covers resource modeling and status code choices.
The "REST purist" complaint is that the same logical resource now has two canonical URIs. In practice, no one cares. Pick this unless you have a concrete reason not to.
Pass the version in Accept: application/vnd.api+json;version=2 or a custom API-Version header. The URL stays clean, and the response shape can vary without the URL changing. This is what HATEOAS purists wanted and what almost no one ships.
The cost is debuggability. Your support engineer sees a customer's curl and cannot tell which version was hit. Caches need a Vary header that everyone forgets. The URL is no longer self-documenting, which matters more in 2026 than ever because LLM agents copy URLs out of docs and skip the headers.
/customers?v=2. Easy to add, easy to forget. CDN caches treat each query value as a different cache key, which is fine, but the version becomes optional in a way that bites you. A client that drops the param silently rolls forward to the latest. Use this for an internal prototype, never for a public API.
The gold standard at scale, but the gold standard for a reason. Instead of v2, the client sends Stripe-Version: 2026-04-22.dahlia. Each date is a snapshot of the contract on that day, and you can release a breaking change every week without ever asking an existing customer to migrate.
This is how Stripe, Square, and Shopify run their APIs. It is also why those three companies have entire infra teams maintaining the version chain. More on the cost in the implementation section below.
Three companies are worth studying because they each chose a different point on the cost-versus-stability curve.
Stripe. Current API version is 2026-04-22.dahlia. New accounts pin to whatever version is current the day they sign up, and that pinning is permanent unless the account explicitly upgrades in Workbench. Per-request, you can override with the Stripe-Version header. Stripe has maintained backward compatibility with every API version since 2011, which is the wildest stat in API design. They achieve this with a stack of "request transformer" modules: each breaking change is encapsulated in a module that translates new internal data back to the old wire shape, applied in reverse-chronological order based on the caller's pinned version.
Square. Date-based since the first version (2019-08-15). Every month gets a new optional version, every release is documented, and SDKs pin to a release-time version. If you want to scale-test before adopting, you set Square-Version per request.
Shopify. Quarterly stable releases, named like 2026-04. Each stable version is supported for 12 months, then sunset. Release notes call out every breaking change. The X-Shopify-API-Version header lets you target a specific quarter, and Shopify ships an X-Shopify-API-Deprecated-Reason response header any time you call a deprecated field. That second header is the unsung hero: clients can log it, alert on it, and migrate before sunset day.
Copy the parts that match your scale. If you are 5 engineers, copy Shopify's deprecation notes format. If you are 50, copy Square's monthly date model. If you are 500, copy Stripe's transformer chain.
Whichever pattern you pick, two rules are universal in 2026.
Rule 1: additive-only changes inside a version. Adding a new optional field to a response is fine. Adding a new optional query param is fine. Anything else is breaking and forces a new version. Specifically:
The trap most teams fall into is treating bug fixes as non-breaking. They are not. If a customer wrote code that depends on the bug, fixing it is a breaking change. This is the boring, painful truth of running a public API.
Rule 2: deprecate with the RFC 8594 Sunset header and a 12-month runway. When you deprecate an endpoint or version, return:
Sunset: Sat, 31 Oct 2026 23:59:59 GMT
Deprecation: true
Link: <https://docs.example.com/migrate/v3>; rel="deprecation"
12 months is the industry baseline (Shopify, Twilio, Atlassian, Slack). Anything shorter and you will lose enterprise customers. Anything longer is fine but expensive. If your post on Next.js project structure is the spiritual cousin of this discipline on the frontend, the API equivalent is "every breakable thing has a versioned, dated, header-announced replacement."
The minimal-pain implementation. TypeScript, Express 5, Zod for validation.
// routes/v1/customers.ts
import { Router } from "express";
import { listCustomersV1 } from "../../controllers/customers";
const router = Router();
router.get("/", listCustomersV1);
export default router;
// routes/v2/customers.ts
import { Router } from "express";
import { listCustomersV2 } from "../../controllers/customers";
const router = Router();
router.get("/", listCustomersV2);
export default router;
// app.ts
import express from "express";
import v1Customers from "./routes/v1/customers";
import v2Customers from "./routes/v2/customers";
const app = express();
app.use("/v1/customers", v1Customers);
app.use("/v2/customers", v2Customers);
The two controllers share a service layer. Only the response-shaping function differs. Each version gets its own contract-test file in CI; if a v1 test ever fails, you know you broke an old client.
Pair this with an OpenAPI spec per version under /openapi/v1.json and /openapi/v2.json, and you get auto-generated SDKs through Speakeasy or Stainless without any extra work.
This is where the engineering tax shows up. The minimum viable shape:
// middleware/version.ts
const VERSIONS = ["2025-01-15", "2025-06-01", "2026-04-22"] as const;
const transformers: Record<string, (res: any) => any> = {
"2025-06-01": (res) => {
// before this version, `customer.email` was top-level
if (res.customer?.contact?.email) {
res.customer.email = res.customer.contact.email;
delete res.customer.contact;
}
return res;
},
"2026-04-22": (res) => {
// before this version, `amount` was an integer in cents
if (typeof res.amount === "string") {
res.amount = Math.round(parseFloat(res.amount) * 100);
}
return res;
},
};
export function applyVersionTransformers(
responseBody: any,
clientVersion: string,
) {
const targetIdx = VERSIONS.indexOf(clientVersion);
return VERSIONS.slice(targetIdx + 1).reduceRight(
(acc, v) => transformers[v]?.(acc) ?? acc,
responseBody,
);
}
A request with Stripe-Version: 2025-01-15 runs through both transformers in reverse order and gets the ancient response shape. A request with no header gets account default. A request on the latest version pays no transformer cost at all.
The tax is real: every breaking change adds a transformer module that lives forever, your test suite multiplies by the number of supported versions, and you need careful logging to know which versions are still in use. Stripe does it. You probably should not, unless your API is the product.
A versioned API is only half the system. The other half is the SDK.
Every modern SDK pins to a specific API version at install time. Stripe Node v12+ pins to whatever was current on its release date; Stripe Node v11 and older defer to the account's pinned version. Square does the same. The principle: a customer who runs npm install should never get a different API contract than a customer who ran npm install last week.
Webhook events get their own pinning rule. When you create a webhook endpoint, you choose the version that endpoint receives, independent of the account default. This is essential because webhook handlers tend to live longer than the rest of the codebase.
For your own API, the rule is: ship typed SDKs from your OpenAPI spec, version the SDK alongside the API (@yourco/sdk@2.0.0 for /v2), and document the API version each SDK release targets. The same discipline that applies when you scale an MVP to production-ready applies here: every external interface needs a contract, a version, and a migration path.
Patterns that look correct and break in production.
/v2/customers and the response object is whatever your ORM returns this week. Without a typed response contract, you have not actually versioned anything.Best practices have ROI curves. A few cases where versioning is overkill in 2026:
The line moves the moment you have one external consumer. The day a customer's integration depends on you, you owe them additive-only changes and a deprecation runway.
A concrete checklist:
Sunset header to every deprecated route.If steps 1 to 6 take longer than a week to design, the work usually justifies a senior engineer. Cadence's senior tier ($1,500/week) is the right shape for this: it is one to two weeks of architecture and rollout, not a six-month hire. Every engineer on Cadence is AI-native by default (Cursor, Claude Code, and Copilot used daily, vetted on prompt-as-spec discipline before they unlock bookings), drawn from a pool of 12,800 engineers. If you need someone in the seat by Wednesday to design your versioning policy, book a senior engineer on Cadence and use the 48-hour free trial to validate the design before committing to a week.
Want a brutal read on whether your current API is ready for a versioning rollout? Run your stack through Cadence's Ship-or-Skip audit for a 10-minute honest grade on what to fix first.
URI for almost any team under 50 engineers. Header (specifically date-based) only if you have the engineering bandwidth to maintain a transformer chain like Stripe does. The "REST purist" argument for header versioning loses to the operational reality that URLs are debuggable and headers are not.
12 months minimum after announcing deprecation. Twilio, Shopify, Slack, and Atlassian all converged on this number. Add an RFC 8594 Sunset header from day one of the deprecation window so clients can alert on the deadline programmatically.
Anything that can make a previously valid request fail or return a different shape. That includes removed or renamed fields, tightened validation, changed enum values, different status codes, and bug fixes that customers depend on. If in doubt, version it.
GraphQL is designed for additive evolution, so most teams use field-level deprecation (@deprecated(reason: "use newField")) instead of versioning the whole schema. URI versioning (/graphql/v2) is still common for major rewrites or when the type system itself changes incompatibly.
LLM-generated client code hard-codes endpoint shapes more than human-written code does, because the model writes a working integration in one shot and ships it. Stable URIs, machine-readable OpenAPI 3.1 specs, and explicit Sunset headers all reduce silent agent breakage. The teams that adopt these in 2026 will lose fewer customers to "the AI wrote it and now it broke" tickets.