Breaking an API is one of those mistakes you only need to make once to take versioning seriously from that point forward. I made it a few years into my career: renamed a field in a JSON response because the old name was confusing, deployed it, and spent the next two hours fielding support tickets from mobile app users who couldn’t use the app until they updated. The server was fine. The clients – which I didn’t control – were not.
API versioning is the set of practices that let you evolve your API over time without silently breaking clients. Done well, it’s nearly invisible to clients that don’t need to upgrade. Done poorly, it creates a maintenance nightmare of parallel codepaths that never get cleaned up. Here’s what I’ve learned about the middle path.
What counts as a breaking change
The first step is knowing what you’re trying to avoid. Breaking changes are changes that cause existing clients to fail without modification:
– Removing a field from a response
– Renaming a field
– Changing a field’s type (string to integer, object to array)
– Removing an endpoint
– Changing the meaning of a field (e.g., changing a status field from free-text to an enum)
– Requiring a new field in a request
– Changing authentication schemes
Non-breaking changes are things existing clients can ignore: adding new optional fields to a response, adding new optional request parameters, adding new endpoints, relaxing validation (accepting more input than before).
This distinction matters because most changes you want to make are actually non-breaking, and non-breaking changes don’t require a version bump.
URL path versioning
The most common approach: embed the version in the URL path.
GET /api/v1/users/123
GET /api/v2/users/123
It’s explicit, easy to route in any framework, easy to document, and easy to test. Clients know exactly which version they’re talking to. The downside is that it couples the version to the resource URL, which feels wrong from a REST purity standpoint – the version isn’t a property of the resource. In practice, this rarely matters.
One decision you need to make: does /api/v1/ mean the version of the entire API, or the version of a specific resource? A single major version for the whole API is simpler operationally – one codebase, one set of docs. Per-resource versioning gives more granularity but is much harder to maintain.
Header versioning
An alternative: accept the version in a request header rather than the URL.
GET /api/users/123
Accept: application/vnd.myapi.v2+json
# or a custom header
GET /api/users/123
Api-Version: 2026-04-01
The date-based versioning (used by Stripe) is interesting – instead of integer versions, each version corresponds to the API as it existed on a specific date. New clients get the latest behavior; existing clients keep the behavior as of their integration date.
# Express: extract version from header and route accordingly
app.use((req, res, next) => {
req.apiVersion = req.headers['stripe-version'] ?? DEFAULT_VERSION
next()
})
app.get('/users/:id', (req, res) => {
if (req.apiVersion >= '2026-01-01') {
return res.json(formatUserV2(user))
}
return res.json(formatUserV1(user))
})
Header versioning keeps URLs clean but is harder to test in a browser, harder to document, and easy for clients to get wrong. I prefer URL versioning for public APIs for exactly this reason.
Sunset policies: how to actually retire old versions
Versioning only works if you’re willing to eventually retire old versions. Otherwise you end up maintaining v1 forever because someone, somewhere, is still using it. A sunset policy makes the contract explicit.
The HTTP Sunset header (RFC 8594) lets you signal that a version is going away:
HTTP/1.1 200 OK
Sunset: Sat, 31 Dec 2026 23:59:59 GMT
Deprecation: true
Link: <https://docs.example.com/migration/v2>; rel="deprecation"
Send this header in responses for deprecated versions starting six to twelve months before the sunset date. Good API clients will log or surface the warning. Also document it prominently in the developer portal and email clients who’ve called the deprecated version in the last 30 days.
The sunset timeline depends on your client mix. Public APIs with many independent third-party integrations need 12-18 months notice. Internal APIs consumed by your own teams can move faster. Mobile app APIs need to account for the app store review delay and the long tail of users who don’t update.
Implementation: keep version branching shallow
The failure mode in versioning is having the version branch deep in your business logic. When a version check shows up inside a database query or a domain model, the codebase becomes hard to reason about and hard to clean up.
Keep versioning at the serialization layer. The internal representation of your data doesn’t change; only the format you return to clients changes.
# versioned serializers in Python
class UserSerializerV1:
def serialize(self, user):
return {
'id': user.id,
'full_name': user.full_name, # old field name
'email': user.email,
}
class UserSerializerV2:
def serialize(self, user):
return {
'id': user.id,
'name': { # new structure
'first': user.first_name,
'last': user.last_name,
},
'email': user.email,
}
def get_user_serializer(version):
return UserSerializerV2() if version >= 2 else UserSerializerV1()
The domain logic – fetching the user, checking permissions, applying business rules – is the same for all versions. Only the output format differs. This makes old versions cheap to maintain and easy to delete.
API changelog and documentation
Versioning without documentation is incomplete. Maintain a changelog that lists every version, what changed, what’s deprecated, and when deprecated versions sunset. Stripe and Twilio both do this well – their changelogs are worth reading as examples of the standard.
The RFC on the Sunset header is short and worth reading: rfc-editor.org/rfc/rfc8594. The MDN documentation on HTTP headers covers the related Deprecation header and Link relation types: developer.mozilla.org/en-US/docs/Web/HTTP/Headers.
The meta-principle: version only when you have to, communicate changes clearly, give clients enough time to migrate, and enforce sunsets on the schedule you committed to. Most API versioning failures are not technical – they’re communication failures.