Published on

API Versioning Strategies — URL, Header, and How Netflix Does It

Authors

Introduction

Every API eventually changes. The question isn't whether to version, but how. Should /v1/ and /v2/ exist in the URL? Should clients specify version in headers? Should you avoid versioning entirely with graceful evolution? Each approach has tradeoffs. Netflix and Stripe prove that smart versioning is possible—and so is avoiding it through good design.

URL Versioning (/v1/ /v2/): Pros and Cons

URL versioning puts the version in the path. It's explicit, discoverable, and easy to deploy different implementations:

// URL versioning: version in path
// GET /v1/orders/123
// GET /v2/orders/123

app.get('/v1/orders/:id', async (req, res) => {
  // Old endpoint behavior
  const order = await ordersService.getOrder(req.params.id)
  res.json({
    id: order.id,
    status: order.status,
    total: order.total,
    // v1 shape only
  })
})

app.get('/v2/orders/:id', async (req, res) => {
  // New endpoint behavior
  const order = await ordersService.getOrder(req.params.id)
  const shipping = await shippingService.getTracking(req.params.id)
  res.json({
    id: order.id,
    status: order.status,
    total: order.total,
    // v2 adds new fields
    currency: order.currency,
    shippingInfo: shipping,
    estimatedDelivery: shipping?.eta
  })
})

// Pros:
// - Explicit versioning in URL
// - Each version can have completely different implementation
// - Clients see version clearly
// - Easy to deprecate (set sunset header)

// Cons:
// - URL proliferation (endpoint duplication)
// - Business logic duplication (DRY violation)
// - Clients must change URL to upgrade
// - Hard to coordinate: does v1 client need v2 fix?

// When to use:
// - Very large schema changes
// - Long-term support needed (v1 stays running for 5+ years)
// - Different teams managing different versions

Accept Header Versioning

Clients specify version in Accept or Accept-Version header. The API returns appropriate version without URL changes:

// Header versioning: Same URL, different responses based on header
// GET /orders/123 with Accept-Version: 1
// GET /orders/123 with Accept-Version: 2

app.get('/orders/:id', async (req, res) => {
  const version = req.headers['accept-version'] || '1'
  const order = await ordersService.getOrder(req.params.id)

  if (version === '1') {
    res.json({
      id: order.id,
      status: order.status,
      total: order.total
    })
  } else if (version === '2') {
    const shipping = await shippingService.getTracking(req.params.id)
    res.json({
      id: order.id,
      status: order.status,
      total: order.total,
      currency: order.currency,
      shippingInfo: shipping,
      estimatedDelivery: shipping?.eta
    })
  } else {
    res.status(400).json({ error: 'Unsupported version' })
  }
})

// Alternative: Custom version header
app.get('/orders/:id', async (req, res) => {
  const version = req.headers['x-api-version'] || '1'
  // Same logic
})

// Pros:
// - Single URL to maintain
// - Business logic can be shared
// - Cleaner URL structure
// - Version in header feels RESTful

// Cons:
// - Hidden versioning (not obvious from URL)
// - Hard to test different versions (need header tools)
// - Browser can't access different versions without tools
// - Still requires version branching in code

Custom Version Header

A custom header like X-API-Version provides explicit control:

// src/middleware/version-selector.ts
export function selectApiVersion(req: Request, res: Response, next: NextFunction) {
  const version = req.headers['x-api-version'] || req.headers['accept-version'] || '1.0.0'

  // Parse semantic version
  const [major, minor, patch] = version.split('.').map(Number)

  req.apiVersion = { major, minor, patch, raw: version }

  // Set in response so client sees what version was used
  res.set('X-API-Version', version)

  next()
}

app.use(selectApiVersion)

app.get('/orders/:id', async (req, res) => {
  const order = await ordersService.getOrder(req.params.id)

  // Respond based on version
  const response: any = {
    id: order.id,
    status: order.status,
    total: order.total
  }

  // v2.0+ adds currency
  if (req.apiVersion.major >= 2) {
    response.currency = order.currency
  }

  // v2.1+ adds shipping
  if (req.apiVersion.major > 2 || (req.apiVersion.major === 2 && req.apiVersion.minor >= 1)) {
    response.shippingInfo = await shippingService.getTracking(req.params.id)
  }

  res.json(response)
})

Sunset and Deprecation Headers

Communicate version lifecycle to clients via headers:

// src/middleware/deprecation-headers.ts
export function addDeprecationHeaders(req: Request, res: Response, next: NextFunction) {
  const version = req.apiVersion.raw

  // Indicate version is deprecated
  if (version === '1.0.0') {
    res.set('Deprecation', 'true')
    res.set('Sunset', new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toUTCString())
    // 90 days until shutdown
    res.set('Link', '</orders?x-api-version=2.0.0; rel="successor-version">')
    // Link to newer version
  }

  // Warning header with sunset date
  if (version === '2.0.0') {
    res.set('Warning', '299 - "This version will be sunset on 2026-06-15"')
  }

  next()
}

app.use(addDeprecationHeaders)

// Example response headers:
// Deprecation: true
// Sunset: Fri, 15 Jun 2026 00:00:00 GMT
// Link: </orders?x-api-version=2.0.0; rel="successor-version">
// Warning: 299 - "This version will be sunset on 2026-06-15"

Maintaining Multiple Versions: Shared vs Forked Handlers

Share logic between versions or fork completely. Choose based on changes:

// SHARED HANDLERS: For minor additions
// Most logic shared, small differences

const getOrderHandler = async (req: Request, res: Response) => {
  const version = req.apiVersion
  const order = await ordersService.getOrder(req.params.id)

  // Shared response
  const response: any = {
    id: order.id,
    status: order.status,
    total: order.total
  }

  // Only add v2 fields if requested
  if (version.major >= 2) {
    const shipping = await shippingService.getTracking(req.params.id)
    response.currency = order.currency
    response.shippingInfo = shipping
  }

  if (version.major >= 3) {
    // v3 includes v2 fields plus more
    response.estimatedDelivery = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
    response.trackingUrl = `https://track.example.com/${order.id}`
  }

  res.json(response)
}

// FORKED HANDLERS: For large differences
// Separate implementations for major versions

app.get('/v1/orders/:id', async (req, res) => {
  const order = await ordersServiceV1.getOrder(req.params.id)
  res.json({
    orderId: order.id,  // v1 field names
    orderStatus: order.status,
    orderTotal: order.total
  })
})

app.get('/v2/orders/:id', async (req, res) => {
  const order = await ordersServiceV2.getOrder(req.params.id)
  res.json({
    id: order.id,  // v2 changed field names
    status: order.status,
    total: order.total
  })
})

// Use forked handlers when:
// - Field names change (not just additions)
// - Behavior changes significantly
// - Schema incompatible

// Use shared handlers when:
// - Only new optional fields
// - Behavior compatible
// - Minor version bumps

API Changelog and Communication

Document changes clearly for your clients:

// docs/CHANGELOG.md
# API Changelog

## [2.1.0] - 2026-03-15
### Added
- `shippingInfo` object with tracking details
- `trackingUrl` field for direct shipment tracking

### Changed
- Improved error messages with actionable guidance

### Deprecated
- Nothing new

## [2.0.0] - 2026-01-01
### Added
- `currency` field to Order response
- Accept-Version and X-API-Version header support

### Changed
- Field `statusCode` renamed to `status`

### Deprecated
- v1.x endpoints (sunset date: 2026-06-15)

## [1.0.0] - 2025-01-01
### Added
- Initial API release

// docs/UPGRADE_GUIDE.md
# Upgrading from v1 to v2

## Breaking Changes
None - v2 is backwards compatible

## New Features

### Currency Support
v2 adds a `currency` field to all monetary amounts:

\`\`\`json
{
  "id": "123",
  "total": 99.99,
  "currency": "USD"
}
\`\`\`

### Shipping Information
Orders now include shipping status:

\`\`\`json
{
  "shippingInfo": {
    "status": "shipped",
    "trackingNumber": "1Z123ABC",
    "estimatedDelivery": "2026-03-20T00:00:00Z"
  }
}
\`\`\`

## Migration Path
1. Update your client to accept new fields (no required changes for read operations)
2. (Optional) Use new `currency` field for multi-currency support
3. (Optional) Use new `shippingInfo` for shipment tracking

Semantic Versioning for APIs

Use Semantic Versioning for API versions consistently:

// MAJOR.MINOR.PATCH semantic versioning

// MAJOR version bump: Breaking changes
// - Field removed
// - Type changed
// - Required parameter added
// - Required field in response removed
// Examples: 1.0.0 → 2.0.0

// MINOR version bump: Backwards-compatible changes
// - New optional field in response
// - New optional parameter
// - New endpoint
// - Field added to response
// Examples: 1.0.0 → 1.1.0

// PATCH version bump: Bug fixes, non-breaking improvements
// - Documentation clarification
// - Error message change
// - Performance improvement
// Examples: 1.0.0 → 1.0.1

export interface ApiVersion {
  major: number
  minor: number
  patch: number
}

export function isBackwardsCompatible(
  currentVersion: ApiVersion,
  newVersion: ApiVersion
): boolean {
  // Same major version = backwards compatible
  return currentVersion.major === newVersion.major
}

// Versioning strategy
const v1_0_0 = { major: 1, minor: 0, patch: 0 }  // Initial
const v1_1_0 = { major: 1, minor: 1, patch: 0 }  // New field added
const v1_1_1 = { major: 1, minor: 1, patch: 1 }  // Bug fix
const v2_0_0 = { major: 2, minor: 0, patch: 0 }  // Breaking change

// Clients on v1.0 can use v1.1 (backwards compatible)
// Clients on v1.0 cannot use v2.0 (breaking change)

Feature Flags as Alternative to Versioning

Instead of versioning, use feature flags to control behavior:

// src/middleware/feature-flags.ts
export async function getFeatures(userId: string): Promise<Record<string, boolean>> {
  return {
    'shipping_info': await flagService.isEnabled('shipping_info', userId),
    'currency_field': await flagService.isEnabled('currency_field', userId),
    'new_tracking_api': await flagService.isEnabled('new_tracking_api', userId)
  }
}

app.get('/orders/:id', async (req, res) => {
  const features = await getFeatures(req.user.id)
  const order = await ordersService.getOrder(req.params.id)

  const response: any = {
    id: order.id,
    status: order.status,
    total: order.total
  }

  // Feature flags control response shape
  if (features.currency_field) {
    response.currency = order.currency
  }

  if (features.shipping_info) {
    response.shippingInfo = await shippingService.getTracking(req.params.id)
  }

  res.json(response)
})

// Pros:
// - No versioning URLs or headers
// - Gradual rollout per user/customer
// - Easy A/B testing
// - Single codebase

// Cons:
// - Complex feature flag management
// - Code cruft from old flags (cleanup required)
// - Harder to understand flow (many branches)

// Netflix approach: Heavy feature flag usage
// - Versions exist only internally
// - Clients always use /api/orders (no v1, v2 in URL)
// - Server decides what features each client gets
// - Seamless upgrades (clients don't know version)

Automated Breaking Change Detection

In CI, detect when your changes break backwards compatibility:

# .github/workflows/api-breaking-changes.yml
name: Check for Breaking Changes

on:
  pull_request:
    paths:
      - 'src/api/**'

jobs:
  check-breaking-changes:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0

      - name: Compare API specs
        run: |
          # Get base spec from main
          git show origin/main:openapi.yaml > openapi.base.yaml

          # Compare with PR spec
          npx openapi-diff \
            openapi.base.yaml \
            openapi.yaml \
            --fail-on-breaking-changes

      - name: List safe changes
        run: |
          npx openapi-diff \
            openapi.base.yaml \
            openapi.yaml \
            --list-safe-changes

      - name: Comment on PR
        if: failure()
        uses: actions/github-script@v6
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '⚠️ Breaking API changes detected. Review and update version.'
            })

Versioning Complexity Matrix

ApproachURL DuplicationCode DuplicationClient ComplexityDeprecation Difficulty
URL versioningHighMediumLowMedium
Header versioningNoneMediumMediumHard
Feature flagsNoneLowNoneEasy
Proper designNoneNoneNoneMinimal

Checklist

  • API versioning strategy documented clearly
  • Semantic versioning used consistently
  • Deprecation timeline communicated via headers
  • Changelog maintained and published
  • Upgrade guide provided for major versions
  • Backwards compatibility tested in CI
  • Feature flags used for gradual rollout when appropriate
  • Breaking changes detected in CI pipeline
  • Old versions have documented sunset date
  • Sunset date communicated 6+ months in advance
  • Clients able to specify version preference
  • Monitoring tracks version usage per client

Conclusion

Versioning isn't about creating v1, v2, v3 forever. It's about managing change gracefully. The best APIs avoid breaking changes through thoughtful evolution: new optional fields, new endpoints, careful schema design. When breaking changes are unavoidable, communicate clearly and provide a migration path. Netflix proves you don't need URL versions—just good feature flags and design. Stripe shows URL versions work when supported seriously. The worst is half-hearted versioning: unclear semantics, poor communication, no sunset dates.