API Versioning: Complete Implementation Guide for Production APIs

by API Status Check Team

TL;DR

API versioning prevents breaking existing clients when you ship changes. This guide covers 4 production-ready strategies (URI path, query parameter, header, content negotiation), semantic versioning, real-world examples from Stripe/GitHub/AWS, breaking vs non-breaking change classification, deprecation timelines, migration patterns, and comprehensive testing approaches.

API Versioning: Complete Implementation Guide

API versioning is how you evolve your API without breaking existing integrations. Get it wrong, and you'll wake up to angry customers and broken applications. Get it right, and you can ship improvements confidently while maintaining backwards compatibility.

This guide covers production-ready versioning strategies, real-world patterns from Stripe, GitHub, and AWS, and comprehensive implementation patterns with TypeScript/Node.js.

Why API Versioning Matters

The cost of breaking changes:

  • Stripe processes $1 trillion annually — one breaking change could cost millions
  • GitHub serves 100M+ developers — breaking their CI/CD pipelines creates chaos
  • Your API might power critical business operations for customers

Without versioning:

  • Every change risks breaking existing clients
  • You're locked into bad design decisions forever
  • Innovation becomes impossible
  • Customer trust erodes with each unexpected break

With versioning:

  • Ship improvements confidently
  • Maintain backwards compatibility
  • Communicate changes clearly
  • Give clients time to migrate
  • Preserve customer relationships

Versioning Strategies Comparison

1. URI Path Versioning (Most Common)

Pattern: /v1/users, /v2/users

Pros:

  • Extremely visible and explicit
  • Easy to route in API gateways
  • Simple for clients to understand
  • Cache-friendly (different URLs)
  • Easy to test multiple versions side-by-side

Cons:

  • Creates URL pollution
  • Violates REST principles (resource identity changes)
  • Can't version individual fields

Best for: Public APIs, major version increments

Used by: Stripe, Twilio, Twitter API

// Express routing for URI versioning
import express from 'express';

const app = express();

// Version 1 routes
app.get('/v1/users/:id', async (req, res) => {
  const user = await getUserV1(req.params.id);
  res.json({
    id: user.id,
    name: user.full_name, // V1 used "full_name"
    created: user.created_at
  });
});

// Version 2 routes (breaking changes)
app.get('/v2/users/:id', async (req, res) => {
  const user = await getUserV2(req.params.id);
  res.json({
    id: user.id,
    firstName: user.first_name, // V2 split name
    lastName: user.last_name,
    email: user.email, // V2 added email
    createdAt: user.created_at, // V2 uses camelCase
    profile: user.profile // V2 added nested object
  });
});

2. Query Parameter Versioning

Pattern: /users?version=2 or /users?api-version=2026-03-11

Pros:

  • Keeps resource URLs clean
  • Easy to implement
  • Optional (can default to latest)
  • Works with existing routing

Cons:

  • Less visible than URI versioning
  • Can break caching (query params ignored by some caches)
  • Easy to forget in requests
  • Harder to document

Best for: Internal APIs, gradual migrations

Used by: Azure, some Microsoft Graph endpoints

// Query parameter versioning middleware
app.get('/users/:id', async (req, res) => {
  const version = req.query.version || req.query['api-version'] || '1';
  
  if (version === '2') {
    return handleUserV2(req, res);
  }
  
  // Default to V1
  return handleUserV1(req, res);
});

3. Header-Based Versioning (GitHub's Approach)

Pattern: Accept: application/vnd.myapi.v2+json

Pros:

  • URLs stay clean and RESTful
  • Resource identity doesn't change
  • Follows HTTP standards (content negotiation)
  • Can version individual representations
  • Professional and standards-compliant

Cons:

  • Less visible to developers
  • Harder to test in browsers
  • More complex client configuration
  • Not cache-friendly by default

Best for: RESTful purists, APIs with multiple response formats

Used by: GitHub API, Salesforce

// Header-based versioning middleware
function versionMiddleware(req, res, next) {
  const acceptHeader = req.headers.accept || '';
  
  // Parse version from Accept header
  // Accept: application/vnd.myapi.v2+json
  const versionMatch = acceptHeader.match(/vnd\.myapi\.v(\d+)/);
  req.apiVersion = versionMatch ? versionMatch[1] : '1';
  
  next();
}

app.use(versionMiddleware);

app.get('/users/:id', async (req, res) => {
  if (req.apiVersion === '2') {
    return handleUserV2(req, res);
  }
  return handleUserV1(req, res);
});

GitHub's actual pattern:

# GitHub uses custom media types
curl -H "Accept: application/vnd.github.v3+json" \
  https://api.github.com/users/octocat

4. Content Negotiation (Stripe's Date-Based Versioning)

Pattern: Stripe-Version: 2026-03-11

Pros:

  • URLs stay clean
  • Granular version control (date-based)
  • Can specify version per-request
  • Works well for gradual deprecation
  • Clients control upgrade timing

Cons:

  • Requires custom header support
  • More complex to implement
  • Date versioning can be confusing
  • Requires good documentation

Best for: SaaS platforms with many versions in production simultaneously

Used by: Stripe

// Stripe-style date versioning
const API_VERSIONS = {
  '2026-03-11': handleV20260311,
  '2025-12-01': handleV20251201,
  '2025-01-15': handleV20250115,
  // Default to oldest for safety
  'default': handleV20250115
};

app.use((req, res, next) => {
  const requestedVersion = req.headers['stripe-version'] || 'default';
  req.apiVersion = API_VERSIONS[requestedVersion] || API_VERSIONS.default;
  next();
});

app.post('/charges', async (req, res) => {
  // Version-specific handler
  return req.apiVersion(req, res);
});

Stripe's actual versioning:

curl https://api.stripe.com/v1/charges \
  -H "Authorization: Bearer sk_test_..." \
  -H "Stripe-Version: 2026-03-11"

Semantic Versioning for APIs

Use semantic versioning (MAJOR.MINOR.PATCH) to communicate the impact of changes.

Format: v{MAJOR}.{MINOR}.{PATCH}

Examples:

  • v1.0.0 — Initial release
  • v1.1.0 — Added optional fields (backwards compatible)
  • v1.1.1 — Bug fix (no API changes)
  • v2.0.0 — Breaking changes (removed/renamed fields)

When to increment:

Version Change Type Examples
MAJOR Breaking changes Remove field, rename field, change type, change validation
MINOR Backwards-compatible additions Add optional field, new endpoint, new enum value
PATCH Bug fixes, no API change Fix typo in response, improve performance, security patch

Important: Only increment MAJOR version in production URLs (/v1/, /v2/). Use MINOR/PATCH for internal tracking and changelog.

Breaking vs Non-Breaking Changes

✅ Non-Breaking Changes (Safe)

Can ship without version increment:

  • Adding new optional fields to request
  • Adding new fields to response
  • Adding new endpoints
  • Adding new query parameters (optional)
  • Adding new enum values (if client handles unknowns)
  • Making required fields optional
  • Relaxing validation rules
  • Bug fixes that don't change behavior

Example (safe):

// V1 response
{
  "id": "user_123",
  "name": "John Doe"
}

// V1.1 response (backwards compatible)
{
  "id": "user_123",
  "name": "John Doe",
  "email": "john@example.com", // NEW optional field (safe!)
  "created_at": "2026-03-11T10:00:00Z" // NEW field (safe!)
}

❌ Breaking Changes (Require Major Version)

Require new version (e.g., v1 → v2):

  • Removing fields from response
  • Renaming fields
  • Changing field types (stringnumber)
  • Changing response structure (flat → nested)
  • Removing endpoints
  • Making optional fields required
  • Changing authentication method
  • Stricter validation rules
  • Changing error response format
  • Changing URL structure

Example (breaking):

// V1 response
{
  "id": "user_123",
  "name": "John Doe", // Single field
  "created": "2026-03-11" // Different format
}

// V2 response (BREAKING!)
{
  "id": "user_123",
  "first_name": "John", // BREAKING: name split
  "last_name": "Doe",
  "created_at": "2026-03-11T10:00:00Z", // BREAKING: field renamed
  "profile": { // BREAKING: new structure
    "bio": "Developer"
  }
}

Real-World Examples

Stripe's Versioning Strategy

Stripe uses date-based versioning with a custom Stripe-Version header.

Why it works:

  • 100+ versions in production simultaneously
  • Clients upgrade on their timeline
  • No forced migrations
  • Account-level version pinning
  • Gradual deprecation (12+ month notice)

Backwards compatibility guarantee:

  • Old versions supported indefinitely for existing integrations
  • Breaking changes only in new versions
  • Changelog published for every version

Migration example:

// Old version (2025-01-15)
const stripe = require('stripe')('sk_test_...', {
  apiVersion: '2025-01-15' // Pin to specific version
});

// Upgrade to new version (2026-03-11)
const stripe = require('stripe')('sk_test_...', {
  apiVersion: '2026-03-11' // Test new version in dev
});

GitHub's API Versioning

GitHub uses header-based versioning with custom media types.

Current approach (v3 REST API):

# Specify version in Accept header
curl -H "Accept: application/vnd.github.v3+json" \
  https://api.github.com/repos/octocat/hello-world

Why it works:

  • RESTful URLs stay clean
  • Version embedded in content type
  • Easy to version individual endpoints
  • Default version if header omitted

Breaking change example:

  • GitHub API v3 deprecated username/password auth (breaking)
  • Required migration to personal access tokens
  • 12-month deprecation notice
  • Migration guide + timeline published
  • Old auth method returned 410 Gone after sunset

AWS API Versioning

AWS uses service-specific versioning with date-based identifiers.

Pattern: Action-based with Version parameter

# AWS S3 API call with version
aws s3api list-buckets --version 2006-03-01

Why it works:

  • Service-level versioning (S3, EC2, Lambda separate)
  • Date-based versions (e.g., 2006-03-01)
  • API actions can evolve independently
  • SDKs abstract version complexity

Implementation: Complete Versioning System

Directory Structure

src/
  api/
    v1/
      routes/
        users.ts
        posts.ts
      controllers/
        userController.ts
    v2/
      routes/
        users.ts
        posts.ts
      controllers/
        userController.ts
    middleware/
      versioning.ts
    app.ts

Versioning Middleware

// src/api/middleware/versioning.ts
import { Request, Response, NextFunction } from 'express';

export type ApiVersion = '1' | '2';

declare global {
  namespace Express {
    interface Request {
      apiVersion: ApiVersion;
    }
  }
}

export function versioningMiddleware(req: Request, res: Response, next: NextFunction) {
  // Strategy 1: URI path (primary)
  const pathMatch = req.path.match(/^\/v(\d+)\//);
  if (pathMatch) {
    req.apiVersion = pathMatch[1] as ApiVersion;
    return next();
  }
  
  // Strategy 2: Query parameter (fallback)
  if (req.query.version) {
    req.apiVersion = req.query.version as ApiVersion;
    return next();
  }
  
  // Strategy 3: Custom header (fallback)
  const headerVersion = req.headers['api-version'];
  if (headerVersion) {
    req.apiVersion = headerVersion as ApiVersion;
    return next();
  }
  
  // Strategy 4: Accept header (content negotiation)
  const acceptHeader = req.headers.accept || '';
  const acceptMatch = acceptHeader.match(/vnd\.myapi\.v(\d+)/);
  if (acceptMatch) {
    req.apiVersion = acceptMatch[1] as ApiVersion;
    return next();
  }
  
  // Default to v1
  req.apiVersion = '1';
  next();
}

// Version validation middleware
export function requireVersion(...versions: ApiVersion[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!versions.includes(req.apiVersion)) {
      return res.status(400).json({
        error: 'invalid_api_version',
        message: `API version ${req.apiVersion} not supported for this endpoint`,
        supportedVersions: versions
      });
    }
    next();
  };
}

Version-Specific Routes

// src/api/app.ts
import express from 'express';
import { versioningMiddleware } from './middleware/versioning';
import v1Routes from './v1/routes';
import v2Routes from './v2/routes';

const app = express();

app.use(express.json());
app.use(versioningMiddleware);

// Mount versioned routes
app.use('/v1', v1Routes);
app.use('/v2', v2Routes);

// Fallback: route based on detected version
app.use((req, res, next) => {
  if (req.apiVersion === '2') {
    return v2Routes(req, res, next);
  }
  return v1Routes(req, res, next);
});

export default app;

Version-Specific Controllers

// src/api/v1/controllers/userController.ts
export async function getUser(req: Request, res: Response) {
  const user = await prisma.user.findUnique({
    where: { id: req.params.id }
  });
  
  if (!user) {
    return res.status(404).json({ error: 'user_not_found' });
  }
  
  // V1 response format
  res.json({
    id: user.id,
    name: user.full_name, // V1 uses full_name
    created: user.created_at
  });
}

// src/api/v2/controllers/userController.ts
export async function getUser(req: Request, res: Response) {
  const user = await prisma.user.findUnique({
    where: { id: req.params.id },
    include: { profile: true } // V2 includes profile
  });
  
  if (!user) {
    return res.status(404).json({
      error: {
        code: 'user_not_found',
        message: 'No user found with this ID'
      }
    });
  }
  
  // V2 response format (breaking changes)
  res.json({
    id: user.id,
    firstName: user.first_name, // BREAKING: split name
    lastName: user.last_name,
    email: user.email, // NEW field
    createdAt: user.created_at, // BREAKING: camelCase
    profile: user.profile ? { // NEW nested object
      bio: user.profile.bio,
      avatar: user.profile.avatar_url
    } : null
  });
}

Deprecation Strategy

Timeline Template

Stripe's deprecation timeline (recommended):

  1. T+0 (Today): Announce deprecation

    • Publish changelog entry
    • Email affected customers
    • Add deprecation warning to docs
    • Return deprecation headers on API responses
  2. T+6 months: Sunset warning

    • Increase email frequency
    • Add warning banners to dashboard
    • Log deprecation warnings in customer accounts
  3. T+12 months: Sunset date

    • Old version returns 410 Gone
    • Migration guide prominently featured
    • Support team prepared for questions

Deprecation Headers

// Add deprecation warnings to responses
app.use((req, res, next) => {
  if (req.apiVersion === '1') {
    res.set('Deprecation', 'true');
    res.set('Sunset', 'Sat, 11 Sep 2027 23:59:59 GMT');
    res.set('Link', '</docs/migration-v2>; rel="deprecation"');
  }
  next();
});

Sunset Response

// After sunset date, return 410 Gone
app.use((req, res, next) => {
  const sunsetDate = new Date('2027-09-11');
  
  if (req.apiVersion === '1' && new Date() > sunsetDate) {
    return res.status(410).json({
      error: 'api_version_sunset',
      message: 'API v1 was sunset on September 11, 2027',
      migration_guide: 'https://docs.example.com/migration-v2',
      current_version: 'v2'
    });
  }
  
  next();
});

Migration Guide Template

What to include in migration docs:

1. Summary of Changes

# Migrating from v1 to v2

## Breaking Changes

- **User name field split:** `name` → `firstName` + `lastName`
- **Date format:** `created` → `createdAt` (ISO 8601)
- **New required field:** `email` (all users must have email)
- **Profile nested:** User profile moved to `profile` object
- **Error format:** Errors now return structured object

## New Features

- Email field now included
- Profile data available in user response
- Better error messages with error codes

2. Code Examples

## Before (v1)

\`\`\`javascript
const response = await fetch('/v1/users/123');
const user = await response.json();

console.log(user.name); // "John Doe"
console.log(user.created); // "2026-03-11"
\`\`\`

## After (v2)

\`\`\`javascript
const response = await fetch('/v2/users/123');
const user = await response.json();

console.log(\`$\{user.firstName} $\{user.lastName}\`); // "John Doe"
console.log(user.createdAt); // "2026-03-11T10:00:00Z"
console.log(user.email); // "john@example.com"
\`\`\`

3. Migration Checklist

## Migration Checklist

- [ ] Update API base URL from `/v1` to `/v2`
- [ ] Update name parsing (split `name` → `firstName`/`lastName`)
- [ ] Update date parsing (handle ISO 8601 format)
- [ ] Add email field handling
- [ ] Update error handling (new error format)
- [ ] Test in development environment
- [ ] Update production gradually (percentage rollout)

Testing Versioned APIs

Test Multiple Versions

// tests/users.test.ts
import request from 'supertest';
import app from '../src/api/app';

describe('User API Versioning', () => {
  // Test V1
  describe('GET /v1/users/:id', () => {
    it('returns V1 format', async () => {
      const res = await request(app)
        .get('/v1/users/123')
        .expect(200);
      
      expect(res.body).toMatchObject({
        id: expect.any(String),
        name: expect.any(String), // V1 uses single name field
        created: expect.any(String)
      });
      
      expect(res.body.firstName).toBeUndefined(); // V1 doesn't have firstName
    });
  });
  
  // Test V2
  describe('GET /v2/users/:id', () => {
    it('returns V2 format with breaking changes', async () => {
      const res = await request(app)
        .get('/v2/users/123')
        .expect(200);
      
      expect(res.body).toMatchObject({
        id: expect.any(String),
        firstName: expect.any(String), // V2 split name
        lastName: expect.any(String),
        email: expect.any(String), // V2 added email
        createdAt: expect.any(String), // V2 renamed field
        profile: expect.any(Object) // V2 added profile
      });
      
      expect(res.body.name).toBeUndefined(); // V2 doesn't have name field
    });
  });
  
  // Test header-based versioning
  describe('Header-based versioning', () => {
    it('accepts version via header', async () => {
      const res = await request(app)
        .get('/users/123')
        .set('API-Version', '2')
        .expect(200);
      
      expect(res.body.firstName).toBeDefined(); // Should use V2
    });
  });
  
  // Test deprecation warnings
  describe('Deprecation headers', () => {
    it('returns deprecation warning for V1', async () => {
      const res = await request(app)
        .get('/v1/users/123')
        .expect(200);
      
      expect(res.headers.deprecation).toBe('true');
      expect(res.headers.sunset).toBeDefined();
    });
  });
});

Contract Testing

// Ensure V1 and V2 can coexist
describe('Version contract tests', () => {
  it('V1 and V2 serve same data with different formats', async () => {
    const v1Res = await request(app).get('/v1/users/123');
    const v2Res = await request(app).get('/v2/users/123');
    
    // Same user ID
    expect(v1Res.body.id).toBe(v2Res.body.id);
    
    // V1 name = V2 firstName + lastName
    const v2FullName = `${v2Res.body.firstName} ${v2Res.body.lastName}`;
    expect(v1Res.body.name).toBe(v2FullName);
  });
});

Common Mistakes

❌ Mistake 1: Not Planning for Versioning from Day One

Problem: Retrofit versioning into production API is painful

Solution: Start with /v1/ even for initial release

// Bad: No versioning
app.get('/users', handler); // Can't version later without breaking clients

// Good: Versioned from start
app.get('/v1/users', handler); // Can add v2 later

❌ Mistake 2: Using Versioning as a Bandaid for Bad Design

Problem: Shipping v2, v3, v4 every 6 months due to poor initial design

Solution: Design API thoughtfully upfront

  • Accept optional fields from day one
  • Use extensible formats (don't hardcode field order)
  • Plan for growth (nested objects better than flat)
  • Get feedback before locking design

❌ Mistake 3: Breaking Backwards Compatibility Without Major Version

Problem: Shipping breaking changes in minor versions (v1.1, v1.2)

Solution: Use semantic versioning strictly

  • Removing field = major version (v2.0)
  • Renaming field = major version (v2.0)
  • Adding optional field = minor version (v1.1)
  • Bug fix = patch version (v1.0.1)

❌ Mistake 4: Not Communicating Deprecation Timelines

Problem: Customers surprised when API stops working

Solution: Clear, aggressive communication

  • Changelog entries
  • Email campaigns
  • Dashboard banners
  • Deprecation headers in API responses
  • 12+ month notice for major versions

❌ Mistake 5: Maintaining Too Many Versions

Problem: Supporting v1, v2, v3, v4 simultaneously creates technical debt

Solution: Aggressive deprecation schedule

  • Maintain at most 2 major versions simultaneously
  • 12-month sunset timeline
  • Force migration with 410 Gone after sunset

❌ Mistake 6: Versioning Individual Fields

Problem: /users?fields=v2.name,v1.email creates complexity

Solution: Version entire response format, not individual fields

  • If you need field-level control, use GraphQL instead
  • Keep versioning granularity at endpoint or API level

❌ Mistake 7: No Testing Across Versions

Problem: V1 breaks when you add V2

Solution: Test all active versions in CI

  • Contract tests ensuring V1/V2 compatibility
  • Integration tests for each version
  • Monitoring alerts per version

Production Checklist

Before shipping a new API version:

  • Changelog published with breaking changes highlighted
  • Migration guide with code examples created
  • Deprecation timeline announced (12+ months)
  • Tests cover all versions (V1 and V2)
  • Monitoring separated by version (track V1/V2 usage)
  • Documentation updated (V1 marked deprecated, V2 featured)
  • SDK libraries updated (if applicable)
  • Customer emails sent to affected users
  • Deprecation headers added to old version responses
  • Support team trained on migration questions
  • Rollback plan ready if V2 has issues
  • Sunset date set in calendar (12 months out)

Monitoring metrics:

  • Track requests per version (v1_requests, v2_requests)
  • Alert on V1 usage spike (customers not migrating)
  • Track 410 Gone responses (customers hitting sunset API)
  • Monitor error rates per version separately

Tools and Libraries

Versioning Libraries

Node.js/Express:

  • express-api-versioning — URI, header, query param support
  • restify-versioning — Semantic versioning for Restify

Validation:

  • joi — Schema validation per version
  • zod — TypeScript-first validation

API Gateways:

Monitoring

Track version adoption and deprecation progress:

  • Datadog APM — Track requests by API version tag
  • New Relic — Custom attributes for version tracking
  • Sentry — Error tracking per version
  • PostHog — Version adoption analytics

Conclusion

API versioning isn't optional — it's how you ship improvements without breaking customer trust.

Key takeaways:

  1. Choose a strategy early: URI path (Stripe/Twilio), headers (GitHub), or date-based (AWS)
  2. Plan for versioning from day one: Start with /v1/ even for initial release
  3. Communicate aggressively: 12+ month deprecation timelines with clear migration guides
  4. Test all versions: Don't break V1 when you ship V2
  5. Sunset old versions: Don't maintain 5+ versions forever

Real-world examples:

  • Stripe maintains 100+ versions simultaneously (date-based)
  • GitHub uses header-based versioning (clean URLs)
  • AWS versions per service (granular control)
  • Twilio uses URI versioning (maximum visibility)

Choose the approach that fits your API's needs, communicate clearly, and give customers time to migrate. Your future self (and your customers) will thank you.

Related Resources


Having trouble with API versioning decisions? Check API Status Check to see how major platforms handle versioning and monitor their API health in real-time.

API Status Check

Stop checking API status pages manually

Get instant email alerts when OpenAI, Stripe, AWS, and 100+ APIs go down. Know before your users do.

Get Alerts — $9/mo →

Free dashboard available · 14-day trial on paid plans · Cancel anytime

Browse Free Dashboard →