---
title: API Versioning Without the Pain
description: "URL versioning, header versioning, or query params? We chose headers with 12-month overlaps and automated migration guides. Here is why and how it works."
canonical: https://nowah.xyz/blog/api-versioning-without-pain
lastModified: "2026-08-07T08:09:33.542Z"
---

# API Versioning Without the Pain

URL versioning, header versioning, or query params? We chose headers with 12-month overlaps and automated migration guides. Here is why and how it works.

The versioning debate consumed an entire sprint. Five engineers in a room, three whiteboards covered in diagrams, and no consensus. URL versioning felt familiar. Query parameter versioning felt flexible. Header versioning felt clean but unfamiliar. We went around in circles until someone proposed a decision framework that actually settled it.

The framework was simple: evaluate each approach against five criteria that matter for a travel API serving both human developers and AI agents. Cacheability. Resource identity. Client complexity. Discoverability. Migration friction. Header-based versioning won on four of five, losing only on discoverability (URLs are easier to see at a glance). We shipped headers and have not regretted it.

## Why URL versioning creates problems

![Illustration for this section](https://pics.nowah.xyz/website-media/developer-experience-004-img-1-header-flow.webp)

URL versioning puts the version number in the path: \`/v1/flights\`, \`/v2/flights\`\. It is the most common approach and the most immediately understandable\. But it has a fundamental problem for REST APIs: it fragments resource identity\.

In REST, a URL identifies a resource\. \`/flights/flt\_abc123\` is a specific flight offer\. If you introduce \`/v2/flights/flt\_abc123\`, you have created a second URL for the same resource\. Which one is canonical? Can both be cached independently? If a webhook references \`flt\_abc123\`, which version's URL do you include in the payload?

This matters in practice\. CDN caching becomes complicated because \`/v1/flights\` and \`/v2/flights\` are treated as entirely different resources even though they might return identical data\. HATEOAS links in responses need to be version\-aware, which means every link in every response carries version context\. Logging and monitoring have to account for the same logical endpoint appearing under multiple URL prefixes\.

For a travel API, where cacheability directly affects performance and cost, fragmenting cache keys across version prefixes is a real problem. Flight search results that are identical across versions get cached separately, doubling cache storage and halving cache hit rates.

## Header-based versioning

We version through a request header: \`X\-API\-Version: 2024\-01\-15\`\. The version is a date string representing the API snapshot the client expects\.

The server reads the header, routes the request to the correct handler, and includes version metadata in the response headers. If no version header is sent, the server defaults to the latest stable version. This means new developers get the current API automatically without configuration, while existing integrations can pin their version and migrate on their own schedule.

The routing is handled by a middleware layer that sits between the HTTP framework and the route handlers. When a request arrives, the middleware reads the version header, validates it against the list of supported versions, and attaches the appropriate handler set to the request context. Route handlers can then branch on version when behavior differs, or share logic when it does not.

```
Client Request
 ↓
Version Middleware (reads X-API-Version header)
 ↓
Route Handler (version-aware logic)
 ↓
Response (includes Deprecation and Sunset headers)
```

This keeps the URL clean and the resource identity stable\. \`/flights/flt\_abc123\` is always the same resource, regardless of which API version the client is using\. Caching works naturally\. Links are stable\. Logging is simpler\.

## The 12-month overlap guarantee

![Supporting diagram](https://pics.nowah.xyz/website-media/developer-experience-004-img-2-url-vs-header.webp)

When we introduce a breaking change, both the old and new versions run simultaneously for at least twelve months. This is a hard guarantee we make to developers, and it shapes our technical decisions.

Running two versions simultaneously sounds like it requires maintaining two codebases. In practice, most of the code is shared. [Breaking changes](/blog/cost-of-breaking-changes-data-driven) typically affect response shapes, field names, or endpoint semantics — not the underlying business logic. We use version-aware serializers that transform the same internal data model into different response shapes based on the requested version.

For example, if version 2024\-01\-15 returns \`departureTime\` as a string and version 2025\-01\-15 returns it as an object with \`local\` and \`utc\` fields, both versions call the same search logic\. The difference is only in the serialization layer that formats the response\.

This approach keeps the overlap cost manageable. We are not maintaining two parallel systems. We are maintaining one system with a thin version-translation layer at the edge.

## Deprecation headers and sunset dates

When we decide to retire a version, we start communicating months before the actual removal.

First, the version's responses gain a \`Deprecation: true\` header\. This is a machine\-readable signal that the version is marked for retirement\. Well\-built client libraries can detect this header and log a warning\.

Second, we add a \`Sunset\` header with the specific date the version will be removed: \`Sunset: Sat, 01 Mar 2027 00:00:00 GMT\`\. This gives developers a concrete deadline and a machine\-readable date they can use to set calendar reminders or build automated alerts\.

Third, we update the changelog and send direct emails to every API key owner that has made requests to the deprecated version in the last 90 days. We do not guess who might be affected. We check usage data and notify the people who are actually using the version being retired.

This three-layer communication — headers, changelog, direct email — ensures that no active integration is surprised by a version removal. We monitor adoption of the new version throughout the deprecation period and will extend the sunset date if significant traffic remains on the old version.

## Automated migration guides

When we release a new API version, we auto-generate a migration guide by diffing the schemas between the old and new versions. The guide lists every changed field, every renamed endpoint, and every new required parameter, with before-and-after [code examples](/blog/documentation-as-product-test-code-examples).

The generation process compares the OpenAPI specifications for both versions and produces a structured diff. Added fields, removed fields, renamed fields, type changes, and new required parameters all get their own sections with specific code change examples.

For simple renames, the migration guide shows a one-line change:

```
// Before (2024-01-15)
const time = offer.departureTime;

// After (2025-01-15)
const time = offer.departure.utc;
```

For structural changes, it shows the full transformation:

```
// Before (2024-01-15)
const price = offer.price; // string: "$149.99"

// After (2025-01-15)
const price = offer.price.amount; // integer: 14999
const currency = offer.price.currency; // string: "USD"
```

These guides live in our documentation alongside the changelog entry for the new version. Developers can read through the guide, apply the changes, and verify their integration against the new version while the old one is still running.

## Making the decision for your own API

If you are choosing a versioning strategy, here is the framework we used:

**Cacheability.** Header versioning wins. One URL per resource means standard HTTP caching works without modification. URL versioning fragments cache keys.

**Resource identity.** Header versioning wins. Resources have one canonical URL. URL versioning creates multiple URLs for the same resource.

**Client complexity.** Roughly a tie. Header versioning requires clients to set a header. URL versioning requires clients to know the right URL prefix. Both are trivial in practice.

**Discoverability.** URL versioning wins. The version is visible in the URL, which makes it easy to see in browser address bars, curl commands, and documentation links. Header versioning requires inspecting request headers.

**Migration friction.** Header versioning wins slightly. Changing a header value is less disruptive than changing every URL in a codebase. But both require touching client code, so the difference is marginal.

For most APIs, header versioning is the better choice. The cacheability and resource identity benefits outweigh the discoverability advantage of URL versioning. For APIs where version visibility in URLs is critical (public APIs used heavily in browser address bars), URL versioning is defensible.

Whatever you choose, the overlap guarantee matters more than the mechanism. Giving developers twelve months to migrate at their own pace, with clear communication and automated guides, is what actually prevents breakage. The versioning mechanism is a technical detail. The migration experience is what developers remember.

---

Nowah is an AI travel agent that searches and books real flights and hotels through conversation — no filters, no thirty open tabs. [Plan your next trip](https://app.nowah.xyz).
