Why API Versioning Matters More Than You Think
Every SaaS product eventually faces the same problem: your API needs to change, but existing customers depend on the current behavior. Ship a breaking change without a versioning strategy and you will get angry support tickets, broken integrations, and churned accounts. Ship it with a well-designed versioning system and your customers barely notice.
The cost of getting this wrong is real. A poorly versioned API forces your team to maintain undocumented backward compatibility hacks, deal with support escalations from partners whose integrations broke overnight, and sometimes even roll back releases because you did not realize how many consumers depended on a specific response shape.
Stripe, GitHub, Twilio, and Shopify all have sophisticated versioning strategies. They did not adopt these systems for fun. They adopted them because at scale, API changes without versioning become existential threats to platform trust. If you are building a SaaS product that other systems will integrate with, you need a versioning strategy before you ship your first public endpoint.
The three dominant approaches are URL path versioning, header-based versioning, and query parameter versioning. Each has tradeoffs in discoverability, caching, complexity, and developer experience. There is also the question of how semantic versioning principles apply to APIs, how to deprecate old versions gracefully, and how GraphQL changes the equation entirely.
URL Path Versioning: The Industry Default
URL path versioning embeds the version number directly in the endpoint URL. Your users call /v1/users, /v2/users, or /v3/users. It is the most common approach in production and the easiest for developers to understand at a glance.
How It Works
You prefix your API routes with a version identifier. When you need to introduce breaking changes, you create a new version prefix and keep the old one running. Consumers explicitly choose which version they want by changing the URL they call.
Stripe is the most famous example, though they use a hybrid approach. Their base URLs use /v1/, but they also layer date-based API versions on top (like 2024-06-20) via headers. The URL prefix signals the major version, while the date-based version handles incremental breaking changes within that major version.
Twilio uses date-based URL versioning directly: /2010-04-01/Accounts. Each dated version is a complete snapshot of the API at that point. New accounts get the latest version by default, but existing integrations continue working against the version they were built for.
Advantages
- Maximum discoverability: Developers see the version in every URL, in documentation, in curl commands, and in browser address bars. There is zero ambiguity about which version is being called.
- Simple caching: CDNs and HTTP caches treat different URL paths as different resources. You get cache isolation between versions for free without any Vary header complexity.
- Easy testing: Developers can switch between versions by editing the URL. No special headers or client configuration required. You can test both versions in a browser tab.
- Clear routing: Your API gateway or load balancer can route /v1/ traffic to one service and /v2/ traffic to another. This makes gradual migration straightforward at the infrastructure level.
Disadvantages
- URL pollution: Your resource URIs are no longer stable identifiers. The "user" resource lives at a different URL depending on the version, which violates REST purist principles about resource identity.
- Code duplication risk: Naive implementations duplicate entire controller layers for each version. Smart implementations share logic and only override what changed, but that requires discipline.
- Version proliferation: Without a clear sunset policy, you end up maintaining v1 through v7 simultaneously, each with subtly different behavior and its own set of bugs.
Despite the disadvantages, URL path versioning remains the right default for most SaaS products. The discoverability and simplicity benefits outweigh the theoretical concerns, especially when your API consumers are external developers who value clarity over architectural purity. If you are following an API-first development approach, URL versioning integrates naturally with your OpenAPI specs since each version gets its own specification document.
Header-Based Versioning: The Purist Approach
Header-based versioning keeps the URL clean and moves version selection into HTTP headers. There are two flavors: using the standard Accept header with a custom media type, or using a completely custom header like X-API-Version.
Accept Header (Content Negotiation)
GitHub uses this approach. Their API accepts headers like Accept: application/vnd.github.v3+json. The version is embedded in the media type, and the server uses standard HTTP content negotiation to serve the right response. If you omit the header, you get the latest stable version.
This is technically elegant. It follows HTTP semantics correctly. The resource URL stays the same regardless of version because you are requesting a different representation of the same resource. REST purists love it.
Custom Header
Some APIs use a custom header like Stripe-Version: 2024-06-20 or X-API-Version: 2. Stripe's date-based versioning works this way on top of their URL prefix. You set the header and every request in that session uses that version's behavior. Your account has a default version pinned to whatever was current when you created your API key, so you only need the header when you want to override that default.
Advantages
- Clean URLs: Resource identifiers stay stable. /users/123 always refers to user 123 regardless of which version of the response format you want.
- Granular versioning: You can version individual endpoints or even individual fields without changing the URL structure. Stripe versions specific behaviors, not entire endpoint trees.
- Follows HTTP standards: Content negotiation via Accept headers is exactly how HTTP was designed to handle this. Your API is more standards-compliant.
Disadvantages
- Poor discoverability: Developers cannot see the version in a URL. They have to read documentation to know the header exists, remember to include it, and debug issues when they forget.
- Harder to test casually: You cannot just change the URL in your browser or a basic curl command. You need to add headers, which adds friction for exploration and debugging.
- Caching complexity: If your CDN or proxy caches responses by URL alone, two versions of the same endpoint will collide. You must add Vary headers and configure cache keys to include the version header.
- Documentation friction: Every code example in your docs needs to show the header. New developers will miss it and get unexpected behavior from the default version.
Header-based versioning works well when your consumers are sophisticated (other engineering teams, well-funded partners with dedicated integration engineers) and your versioning needs are granular. It does not work well when you need to onboard lots of developers quickly or when your consumers are less technical teams using tools like Zapier or Postman.
Query Parameter Versioning and Hybrid Models
The third approach puts the version in a query parameter: /users?version=2 or /users?api-version=2024-06-20. Microsoft Azure uses this pattern extensively across their cloud APIs.
How Query Parameter Versioning Works
The consumer appends a version parameter to every request. The server reads that parameter and routes to the appropriate version handler. If the parameter is missing, the server either returns an error or defaults to the latest version, depending on the implementation.
Azure's pattern looks like: GET /subscriptions?api-version=2023-07-01. Every single Azure REST API call requires this parameter. There is no default, so if you omit it, you get a 400 error telling you to include it.
Advantages
- Visible in URL: Like path versioning, the version is right there in the request URL. Developers can see it in logs, browser dev tools, and shared curl commands.
- Optional by default: You can make the parameter optional and default to the latest version, which means new consumers do not need to know about versioning until they need to pin.
- No URL structure changes: Your path hierarchy stays clean. Adding versioning does not require restructuring your routes or creating new path prefixes.
Disadvantages
- Caching issues: Query parameters are part of the cache key for most CDNs, but some older proxies strip query parameters. You may get cache collisions or cache misses depending on your infrastructure.
- Mixes concerns: The version parameter sits alongside your actual resource filters and pagination parameters. It is metadata about the request format, not about the resource itself.
- Easy to forget: Unlike path versioning where the version is baked into the base URL configured once in an SDK, query parameters must be appended to every request. SDKs handle this, but raw HTTP consumers might forget.
Hybrid Models
In practice, the most successful APIs use hybrid approaches. Stripe combines URL path versioning (/v1/) with date-based header versioning (Stripe-Version: 2024-06-20). The path version signals the major generation of the API, while the header version handles incremental changes within that generation.
This hybrid gives you the discoverability of URL versioning for major changes with the granularity of header versioning for minor adjustments. It is more complex to implement but scales better than a pure approach when your API surface grows large.
For most SaaS products under 50 endpoints, a pure URL path approach is sufficient. You do not need Stripe's complexity until you have Stripe's scale. Start simple, add sophistication when the pain demands it.
Semantic Versioning for APIs: When to Bump What
Semantic versioning (SemVer) gives you a framework for communicating the impact of changes: MAJOR.MINOR.PATCH. Applying it to APIs requires some interpretation since the original spec was designed for libraries, not HTTP services.
Major Version Bumps
Bump the major version when you introduce breaking changes. In API terms, breaking changes include:
- Removing an endpoint entirely
- Removing a field from a response that consumers depend on
- Changing the type of a response field (string to integer, object to array)
- Changing authentication requirements
- Changing error response formats
- Renaming URL paths or query parameters
- Changing the meaning of a status code for a given endpoint
Major versions map directly to your URL path versions. Going from /v1/ to /v2/ signals that consumers will need to update their integration code.
Minor Version Bumps
Bump the minor version when you add new functionality in a backward-compatible way:
- Adding a new endpoint
- Adding an optional query parameter to an existing endpoint
- Adding a new field to a response (consumers should ignore unknown fields)
- Adding a new enum value to a response field
- Adding a new webhook event type
Minor versions rarely show up in URL paths. They appear in your API changelog, your OpenAPI spec version field, and your release notes. Consumers do not need to change their code for minor bumps, but they might want to update their SDKs to access new features.
Patch Version Bumps
Bump the patch version for bug fixes that do not change the API contract:
- Fixing a validation bug that was incorrectly accepting invalid input
- Correcting a response field that was returning wrong data
- Performance improvements that do not change behavior
- Documentation corrections
Be careful with "bug fixes" that consumers might be depending on. If your API was returning a field as a string due to a bug and consumers built their parsing around that string, fixing it to return an integer is technically a breaking change regardless of what the spec says. Hyrum's Law applies: with enough consumers, every observable behavior becomes a dependency.
Date-Based Versioning as an Alternative
Stripe and Twilio use date-based versions (2024-06-20 or 2010-04-01) instead of SemVer numbers. This has a practical advantage: it tells consumers exactly when the version was released without needing to look up a changelog. It also avoids the "what counts as breaking" debates that plague SemVer discussions.
Date-based versioning works especially well for APIs with frequent small breaking changes. Instead of accumulating them into a big v2 release, you ship them individually with dated versions. Consumers can upgrade one change at a time rather than dealing with a massive migration. If you want to understand how this fits into a broader API architecture decision, consider how your versioning strategy interacts with your choice of REST vs GraphQL.
Deprecation Policies and Sunset Headers
Versioning without deprecation is just hoarding. Every version you keep alive costs engineering time to maintain, test, monitor, and secure. You need a clear deprecation policy that balances customer stability with your team's capacity.
Setting a Deprecation Timeline
Industry norms vary by customer type:
- Developer-focused SaaS (Stripe, Twilio): 12-24 months of overlap between old and new versions. These companies serve developers who update regularly and expect clear migration paths.
- Enterprise platforms (Salesforce, Azure): 24-36 months or more. Enterprise customers have long procurement cycles and cannot update quickly.
- Startup APIs (early stage): 3-6 months is acceptable if you communicate clearly. Your early adopters expect rapid change and signed up knowing the API is evolving.
Whatever timeline you choose, publish it in your developer documentation and stick to it. Breaking your own deprecation promises destroys trust faster than any technical issue.
Sunset Headers (RFC 8594)
The Sunset HTTP header is a standard way to communicate deprecation in-band. You add it to responses from deprecated endpoints:
Sunset: Sat, 01 Mar 2025 00:00:00 GMT
This tells consumers programmatically when the endpoint will stop working. Well-built SDKs and monitoring tools can detect this header and alert developers before the deadline. Combine it with a Deprecation header that links to migration documentation:
Deprecation: true
Link: <https://docs.example.com/migration/v2>; rel="sunset"
A Practical Deprecation Workflow
- Month 0: Ship the new version. Old version continues working with no changes. Add Sunset header to old version responses with a date 12+ months out.
- Month 3: Send email notification to all consumers of the old version. Include migration guide and offer support.
- Month 6: Add deprecation warnings to old version documentation. Return a custom warning header on old version responses.
- Month 9: Begin rate-limiting the old version or returning degraded responses. Contact remaining consumers directly.
- Month 12: Shut down the old version. Return 410 Gone with a body linking to the new version.
Monitoring Deprecated Version Usage
Before you can deprecate, you need to know who is still using the old version. Track version usage per API key or OAuth client. Build a dashboard showing request volume per version over time. Send targeted communications to specific consumers rather than blast emails that everyone ignores.
Stripe does this exceptionally well. Their dashboard shows each merchant exactly which API version their integration uses, which deprecated features they depend on, and what will break when they upgrade. This level of specificity turns a scary upgrade into a clear checklist.
Maintaining Backward Compatibility and Changelog Practices
The best versioning strategy is one you rarely need to use. If you design your API for backward compatibility from the start, you can avoid breaking changes for years, reducing the number of major versions you need to maintain.
Design Principles for Backward Compatibility
- Additive-only changes: Add new fields, never remove or rename existing ones. Consumers that do not know about the new field simply ignore it.
- Tolerant readers: Document that consumers should ignore unknown fields. This is the Robustness Principle (Postel's Law) applied to APIs.
- Optional parameters: New request parameters should always be optional with sensible defaults. If a new parameter is required, that is a breaking change.
- Envelope responses: Wrap your responses in a consistent envelope ({"data": ..., "meta": ...}) so you can add metadata without changing the shape of the resource itself.
- Avoid enums in requests: If you validate request fields against a fixed list of values, adding a new value is backward-compatible. But removing a value is breaking. Be conservative with enums.
The Expand Pattern
Stripe uses an "expand" parameter that lets consumers request nested objects inline rather than making separate calls. This pattern lets you add new relationships without breaking existing responses. By default, related objects come back as IDs. Consumers that want the full object pass ?expand[]=customer. This keeps the default response lean while allowing richer queries when needed.
API Changelog Best Practices
Your changelog is how developers decide whether to upgrade. Make it useful:
- Date every entry. Developers need to know when changes shipped, not just what changed.
- Categorize changes as Added, Changed, Deprecated, Removed, Fixed, or Security. The Keep a Changelog format works well for APIs too.
- Include migration steps. Do not just say "Response format changed." Show the old format, the new format, and the code change required.
- Link to affected endpoints. Developers should be able to click through to the relevant API reference page.
- Separate breaking from non-breaking. Make breaking changes visually prominent. Use warning callouts or red labels so they do not get lost in a list of minor additions.
Publish your changelog as both a human-readable page and a machine-readable feed (RSS or JSON). Automated tools can monitor the feed and notify teams when changes land that affect their integration. For teams building SaaS platforms, investing in a solid changelog workflow early pays dividends as your API surface grows.
Versioning in GraphQL: Schema Evolution vs Explicit Versions
GraphQL takes a fundamentally different stance on versioning. The official position from the GraphQL Foundation is that you should not version your GraphQL API. Instead, you evolve the schema continuously, using deprecation annotations on individual fields.
Why GraphQL Discourages Versioning
In REST, a version bump typically changes the shape of responses. Consumers get different fields, different nesting, different types. In GraphQL, consumers explicitly request the fields they want. If you add a new field, nobody sees it unless they query for it. If you deprecate a field, consumers that do not query it are unaffected.
This means most changes that would be "breaking" in REST are non-breaking in GraphQL:
- Adding a field: Non-breaking. Nobody receives it until they ask for it.
- Adding an argument: Non-breaking if optional. Existing queries without the argument still work.
- Deprecating a field: Non-breaking. Mark it with @deprecated(reason: "Use newField instead"). Existing queries still work but tooling shows warnings.
When You Still Need Versions in GraphQL
Schema evolution breaks down when you need to:
- Change the type of an existing field (String to Int)
- Remove a field that some consumers still query
- Fundamentally restructure your schema graph
- Change authentication or authorization models
For these cases, some teams run parallel schemas at different endpoints (/graphql/v1, /graphql/v2), which is just URL path versioning applied to GraphQL. Others use schema stitching or federation to gradually migrate portions of the schema while keeping old fields alive as proxied resolvers.
Practical GraphQL Deprecation
GitHub's GraphQL API demonstrates schema evolution at scale. They deprecate fields with clear reasons, maintain deprecated fields for extended periods, and use their schema introspection to track which consumers query deprecated fields. Their approach works because their tooling (GraphiQL, generated SDKs) surfaces deprecation warnings prominently.
If your SaaS uses GraphQL, lean into schema evolution for most changes. Reserve explicit versioning for true architectural shifts that cannot be handled incrementally. And always, always track which deprecated fields are still being queried before you remove them.
Practical Recommendation: What to Pick for Your SaaS
After working with dozens of SaaS teams on their API architecture, here is the recommendation: start with URL path versioning unless you have a specific, well-articulated reason not to.
Why URL Versioning Wins for Most Teams
The simplicity advantage compounds over time. New developers joining your team understand the system immediately. External consumers do not need to read a guide to figure out how versions work. Your documentation is clearer because every example shows the version right in the URL. Your testing is simpler because you can hit both versions from a browser.
The "URL pollution" argument against path versioning sounds compelling in architecture meetings but rarely causes real problems in production. Your consumers do not care that /v1/users and /v2/users are technically different URIs for the same resource concept. They care that the API is easy to call, well-documented, and does not break unexpectedly.
Implementation Checklist
- Use /v1/ as your prefix from day one, even if you never plan a v2. It costs nothing and signals to consumers that you take versioning seriously.
- Share code between versions using a layered approach. Do not copy-paste controllers. Instead, have version-specific handlers that override only the changed behavior and delegate everything else to the latest shared implementation.
- Pin new API keys to the current version so consumers have a stable target. Let them opt into newer versions explicitly rather than surprising them with changes.
- Publish a deprecation policy in your developer docs before you need it. State how much notice you will give and how long old versions will remain available.
- Set up version usage tracking from launch. You need to know which consumers call which versions before you can make deprecation decisions.
- Write migration guides, not just changelogs. Show consumers exactly what changed in their code, not just what changed in your API.
When to Consider Header Versioning Instead
Pick header-based versioning if your API has very frequent small breaking changes (more than 4 per year), your consumers are all sophisticated engineering teams with custom SDK wrappers, or you are building an internal API where you control both sides. Stripe's hybrid approach makes sense at their scale because they ship breaking changes constantly and need consumers to upgrade incrementally rather than in big jumps.
The Bottom Line
Perfect versioning strategy does not exist. What exists is a strategy that your team can implement correctly and your consumers can understand without confusion. For 90% of SaaS products, that means URL path versioning with a clear deprecation timeline, a solid changelog, and version usage analytics. Ship that, iterate on it, and only add complexity when real pain demands it.
Need help designing your API versioning strategy or building out your SaaS platform's developer experience? Book a free strategy call and we will walk through your specific architecture together.
Need help building this?
Our team has launched 50+ products for startups and ambitious brands. Let's talk about your project.