API Versioning Strategies That Work

Should you version APIs with /v1 in URLs, headers, or content negotiation? Compare strategies with pros, cons, and real-world examples.

TRY NANO BANANA FOR FREE

API Versioning Strategies That Work

TRY NANO BANANA FOR FREE
Contents

You need to make a breaking change to your API. Maybe you're renaming a field, changing response structure, or removing a deprecated endpoint.

How do you roll out the change without breaking existing clients?

You need API versioning. But there are multiple approaches, each with tradeoffs. Let's compare them.

The Three Versioning Strategies

1. URL Versioning

Put the version in the URL path:

GET /v1/pets/123
GET /v2/pets/123

Used by: Stripe, Twitter, GitHub, Twilio

Pros: - Explicit and visible - Easy to test (just change the URL) - Simple to route (different versions can hit different servers) - Works with all HTTP clients - Easy to cache (different URLs = different cache entries)

Cons: - Pollutes URL space - Requires maintaining multiple URL structures - Can't version individual resources differently - Looks less "clean" to REST purists

2. Header Versioning

Put the version in a custom header:

GET /pets/123
API-Version: 2

Or use the Accept header:

GET /pets/123
Accept: application/vnd.petstore.v2+json

Used by: GitHub (also supports URL), Azure, Atlassian

Pros: - Cleaner URLs - Follows REST principles (URLs identify resources, not versions) - Can version individual resources - Supports content negotiation

Cons: - Less visible (hidden in headers) - Harder to test (must set headers) - More complex routing - Caching requires Vary header - Some clients don't support custom headers easily

3. Content Negotiation

Use the Accept header with media types:

GET /pets/123
Accept: application/vnd.petstore.v2+json

Used by: GitHub API (preferred method)

Pros: - Most RESTful approach - Standard HTTP mechanism - Supports multiple formats (JSON, XML) and versions - Fine-grained control

Cons: - Most complex to implement - Requires understanding of media types - Not intuitive for developers - Harder to debug

URL Versioning: The Pragmatic Choice

Most APIs use URL versioning because it's simple and explicit.

Implementation

Put the version as the first path segment:

/v1/pets
/v1/orders
/v1/users

/v2/pets
/v2/orders
/v2/users

Or after the domain:

https://api.petstore.com/v1/pets
https://api.petstore.com/v2/pets

Routing

URL versioning makes routing straightforward:

// Express.js example
app.use('/v1', v1Router);
app.use('/v2', v2Router);

// v1Router
router.get('/pets/:id', getPetV1);

// v2Router
router.get('/pets/:id', getPetV2);

You can even route different versions to different servers:

# nginx config
location /v1/ {
    proxy_pass http://api-v1-servers;
}

location /v2/ {
    proxy_pass http://api-v2-servers;
}

Versioning Strategy

Major versions only: Use integers (v1, v2, v3) for breaking changes only.

/v1/pets  → Original structure
/v2/pets  → Breaking changes (renamed fields, different structure)

Don't version minor changes: Additive changes (new fields, new endpoints) don't need a new version.

// v1 response (original)
{
  "id": "123",
  "name": "Max"
}

// v1 response (with new field - no version bump)
{
  "id": "123",
  "name": "Max",
  "breed": "Golden Retriever"  ← New field added
}

Clients ignore fields they don't recognize. Adding fields is backward compatible.

Deprecation timeline: Support old versions for a defined period:

v1: Released 2024-01-01, deprecated 2025-01-01, sunset 2026-01-01
v2: Released 2025-01-01, current version

Give clients 12-24 months to migrate before sunsetting old versions.

Header Versioning: The REST Approach

Header versioning keeps URLs clean but adds complexity.

Implementation

Use a custom header:

GET /pets/123
API-Version: 2

Or the Accept header:

GET /pets/123
Accept: application/vnd.petstore.v2+json

Routing

Header-based routing is more complex:

app.use((req, res, next) => {
  const version = req.headers['api-version'] || '1';

  if (version === '1') {
    req.apiVersion = 'v1';
  } else if (version === '2') {
    req.apiVersion = 'v2';
  } else {
    return res.status(400).json({
      error: 'Unsupported API version'
    });
  }

  next();
});

router.get('/pets/:id', (req, res) => {
  if (req.apiVersion === 'v1') {
    return getPetV1(req, res);
  } else {
    return getPetV2(req, res);
  }
});

Caching Considerations

Header versioning requires the Vary header for proper caching:

GET /pets/123
API-Version: 2

Response:
Vary: API-Version
Cache-Control: public, max-age=3600

The Vary header tells caches to store separate entries for different header values.

Without Vary, caches might return v1 responses to v2 requests (or vice versa).

Content Negotiation: The Purist Approach

Content negotiation uses standard HTTP mechanisms but requires more setup.

Implementation

Use media type versioning:

GET /pets/123
Accept: application/vnd.petstore.v2+json

The media type format: - application = top-level type - vnd.petstore = vendor-specific subtype - v2 = version - json = format

Routing

app.use((req, res, next) => {
  const accept = req.headers['accept'] || 'application/json';

  if (accept.includes('vnd.petstore.v2+json')) {
    req.apiVersion = 'v2';
  } else if (accept.includes('vnd.petstore.v1+json')) {
    req.apiVersion = 'v1';
  } else {
    req.apiVersion = 'v1'; // Default
  }

  next();
});

Supporting Multiple Formats

Content negotiation shines when you support multiple formats:

GET /pets/123
Accept: application/vnd.petstore.v2+json
→ Returns JSON

GET /pets/123
Accept: application/vnd.petstore.v2+xml
→ Returns XML

GET /pets/123
Accept: application/vnd.petstore.v2+protobuf
→ Returns Protocol Buffers

Hybrid Approaches

Some APIs combine strategies.

GitHub's Approach

GitHub supports both URL and header versioning:

# URL versioning (deprecated)
GET https://api.github.com/repos/owner/repo

# Header versioning (preferred)
GET https://api.github.com/repos/owner/repo
Accept: application/vnd.github.v3+json

This gives clients flexibility while encouraging the preferred method.

Stripe's Approach

Stripe uses URL versioning but allows header overrides:

# URL version
GET https://api.stripe.com/v1/customers

# Header override
GET https://api.stripe.com/v1/customers
Stripe-Version: 2024-03-01

The header lets clients test new versions without changing URLs.

Choosing Your Strategy

Use URL Versioning If:

  • You want simplicity
  • Your team is small
  • You have few versions
  • Caching is important
  • You need easy testing

Use Header Versioning If:

  • You want clean URLs
  • You follow REST strictly
  • You need fine-grained versioning
  • You have complex routing needs

Use Content Negotiation If:

  • You support multiple formats
  • You want maximum REST compliance
  • You have sophisticated clients
  • You need per-resource versioning

Best Practices

1. Version Only Breaking Changes

Don't bump versions for: - New endpoints - New optional fields - New query parameters - Bug fixes

Do bump versions for: - Renamed fields - Removed endpoints - Changed response structures - Different behavior

2. Document Changes Clearly

Maintain a changelog:

## v2.0.0 (2025-01-01)

### Breaking Changes
- Renamed `status` to `petStatus` in Pet resource
- Removed deprecated `/pet/findByStatus` endpoint
- Changed date format from Unix timestamp to ISO 8601

### New Features
- Added `/pets/search` endpoint with advanced filtering
- Added `breed` field to Pet resource

3. Support Multiple Versions

Don't force immediate migration. Support at least two versions: - Current version (v2) - Previous version (v1)

4. Communicate Deprecation

Give advance notice:

GET /v1/pets/123

Response:
Deprecation: true
Sunset: Sat, 01 Jan 2026 00:00:00 GMT
Link: <https://docs.petstore.com/migration/v1-to-v2>; rel="deprecation"

The Sunset header tells clients when the version will be removed.

5. Default to Latest Stable

If no version is specified, use the latest stable version:

GET /pets/123
→ Uses v2 (latest)

GET /v1/pets/123
→ Uses v1 (explicit)

This encourages adoption of new versions while maintaining backward compatibility.

Migration Path

When releasing a new version:

  1. Announce the new version 3-6 months in advance
  2. Release v2 while keeping v1 available
  3. Deprecate v1 after 6-12 months
  4. Sunset v1 after 12-24 months
  5. Remove v1 code after sunset

Give clients ample time to migrate. Enterprise clients need longer timelines than consumer apps.

Conclusion

For most APIs, URL versioning is the right choice. It's simple, explicit, and works everywhere.

Use /v1, /v2, /v3 for major versions. Keep URLs clean by versioning only breaking changes. Support multiple versions with clear deprecation timelines.

Header versioning and content negotiation are valid alternatives if you need cleaner URLs or fine-grained control. But they add complexity that most APIs don't need.

Pick a strategy, document it clearly, and stick with it. Consistency matters more than the specific approach.