API Versioning & Backward Compatibility

📘 Chapter 11: API Design ⏱️ 8 min read 🏗️ Lesson 048

APIs evolve. New features require new fields, business logic changes, and old designs need fixing. But here's the constraint: existing clients can't break. You might have thousands of integrations running in production — each pinned to your current API contract. How do you move forward without leaving them behind?

Why Versioning Matters

Unlike internal code where you can refactor freely, a public API is a contract. When you change it:

  • Mobile apps in app stores can't be force-updated instantly
  • Third-party integrations may not have active maintainers
  • Enterprise clients have change-control processes that take months
  • Breaking a partner's integration costs trust (and revenue)

The goal: evolve your API continuously while giving clients time and tools to migrate.

Versioning Strategies

Three Approaches

Strategy Example Pros / Cons
URL path /v1/users
/v2/users
✅ Obvious, easy to route
❌ Pollutes URL namespace, hard to deprecate gradually
Query param /users?version=2 ✅ Optional, single URL
❌ Easy to forget, caching complications
Header Accept: application/vnd.api+json;version=2
Stripe-Version: 2024-01-15
✅ Clean URLs, per-request override
❌ Less discoverable, harder to test in browser
Multiple API Versions Running Simultaneously API Gateway / Router v1 (deprecated) Sunset: 2024-06-01 Legacy clients still using v2 (stable) Current default version Most clients here v3 (beta) Opt-in for early adopters Breaking changes from v2 Mobile app v2.1 (2022) Partner X integration Web app (current) Partner Y integration Internal team testing v3
Figure 1: Multiple API versions coexist — different client generations use different versions. The gateway routes each request to the appropriate version.

Backward Compatible vs Breaking Changes

✅ Safe (Backward Compatible)

  • Adding new fields to responses (clients ignore unknown fields)
  • Adding new endpoints (doesn't affect existing routes)
  • Adding optional parameters (existing requests still work without them)
  • Adding new enum values (if clients handle unknown gracefully)
  • Widening a type (int32 → int64, if wire format allows)

❌ Dangerous (Breaking)

  • Removing fields from responses — clients parsing them will crash
  • Renaming fields — same as removing + adding
  • Changing field types"id": 42"id": "uuid-abc"
  • Removing endpoints — 404 for active clients
  • Making optional params required — existing requests become invalid
  • Changing error formats — client error handling breaks

Deprecation Strategy

The Four-Phase Sunset

  1. Announce: Document the deprecation, email affected developers, add to changelog
  2. Sunset Header: Add Sunset: Sat, 01 Jun 2024 00:00:00 GMT header to deprecated responses
  3. Grace Period: Keep the old version running for 6-12 months minimum. Log usage to track remaining clients.
  4. Remove: After the sunset date, return 410 Gone with a migration guide link
HTTP/1.1 200 OK
Sunset: Sat, 01 Jun 2024 00:00:00 GMT
Deprecation: true
Link: <https://api.example.com/docs/migration-v2>; rel="deprecation"

Evolution Without Versioning

The Best Version Is No Version

If you design carefully from the start, you can often avoid versioning entirely:

  • Additive-only changes: Only add fields, never remove or rename
  • Optional fields with defaults: New params default to previous behavior
  • Feature flags: New capabilities gated behind opt-in headers
  • Tolerant readers: Document that clients MUST ignore unknown fields
  • Expand/contract pattern: Add new field → migrate clients → remove old field (over months)

🌍 Stripe's Versioning Model

Stripe uses date-based versions (e.g., 2024-01-15) with a unique approach:

  • Each Stripe account is pinned to the API version from when it was created
  • Developers can override per-request with the Stripe-Version header to test newer versions
  • When ready, they upgrade their account's pinned version in the dashboard
  • Internally, Stripe maintains a version compatibility layer — a chain of transformations that converts between any two versions. Old versions are never truly removed.
  • Each version change is documented with exact migration steps

This means a client from 2019 still works today without any changes — Stripe's gateway transparently converts between versions.

🌍 Slack's Backward Compatibility

Slack maintains backward compatibility across thousands of third-party integrations:

  • They almost never remove fields — deprecated fields return empty/default values
  • New features use new method names (additive approach)
  • Breaking changes get 18+ months notice
  • They maintain a "app directory" that lets them contact affected developers directly

Consumer-Driven Contract Testing

Pact: Don't Break Your Consumers

Consumer-driven contracts flip the testing model: instead of the API provider defining tests, each consumer defines what they expect from the API:

// Consumer (mobile app) defines its contract:
{
  "description": "get user profile",
  "request": { "method": "GET", "path": "/users/42" },
  "response": {
    "status": 200,
    "body": {
      "id": 42,       // I need this
      "name": "any",  // I need this
      "email": "any"  // I need this
    }
  }
}
// Provider runs ALL consumer contracts on every deploy
// If any break → deploy is blocked

Tools like Pact collect contracts from all consumers and verify them against the provider on every CI run. You know before deploying if a change will break any client.

Interactive: Breaking or Safe?

🎮 Compatibility Checker

For each proposed API change, decide: is it backward compatible (safe) or breaking? Then see the explanation.