JSON API Versioning: URL vs Header Strategy, Migration Plans & Sunset Policy
Last updated:
Most API versioning articles explain the difference between URL versioning (/v2/users) and header versioning (Accept: application/vnd.api+json;version=2) — then stop. The harder problems are what most teams get wrong: when exactly does a JSON response change require a new version, how do you design a migration timeline that doesn't strand clients, how do you communicate a sunset in a machine-readable way, and how do you test that a new release doesn't break any of the N consumer versions still in production? This guide covers the full lifecycle of a JSON API versioning strategy, from the initial version scheme through the sunset policy, with concrete code examples, before/after JSON payloads, and a multi-version testing approach using consumer-driven contracts.
URL Versioning vs Header Versioning
The two dominant approaches to JSON API versioning each make a different trade-off between cacheability and semantic correctness. URL versioning embeds the version in the path; header versioning signals it via request headers. Understanding the caching implication is the key to making the right choice for your use case.
# ── URL versioning ──────────────────────────────────────────────────────
GET /v1/users/42 HTTP/1.1
Host: api.example.com
HTTP/1.1 200 OK
Content-Type: application/json
{
"user_id": 42,
"full_name": "Alice Chen",
"email": "alice@example.com"
}
# Breaking change: rename user_id → id, full_name → name → bump to v2
GET /v2/users/42 HTTP/1.1
Host: api.example.com
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": 42,
"name": "Alice Chen",
"email": "alice@example.com"
}
# ── Header versioning ────────────────────────────────────────────────────
# Vendor media type (RFC 6838 — most semantically correct)
GET /users/42 HTTP/1.1
Host: api.example.com
Accept: application/vnd.example.api+json;version=2
# Custom header (simpler, not standard)
GET /users/42 HTTP/1.1
Host: api.example.com
X-API-Version: 2
# ── Caching implication — URL versioning ─────────────────────────────────
# CDN cache key = URL → /v1/users and /v2/users cached as separate entries
# No Vary header needed. Works with default CDN configuration.
# curl, Postman, browser — every client just works.
# ── Caching implication — header versioning ──────────────────────────────
# CDN cache key = URL by default → /users/42 with version=1 and version=2
# collide in the cache (CDN ignores the Accept header unless configured).
# Must add:
Vary: Accept
# or:
Vary: X-API-Version
# Most CDNs support Vary but with caveats (Cloudflare ignores Vary for
# some cache tiers; Fastly supports it but may fragment the cache heavily).
# ── Pros/cons matrix ──────────────────────────────────────────────────────
# Criterion URL versioning Header versioning
# -----------------------------------------------------------------
# CDN cacheability Excellent Requires Vary config
# Visible in logs Yes (URL) No (header only)
# Developer experience Simple / obvious Requires header setup
# REST semantics Debated (URL = Correct (representation
# resource identity) concern in Accept header)
# Bookmark / link share Version is stable Clean URL
# SDK generation Easy (base URL) Needs header injection
# ── Practical hybrid approach ─────────────────────────────────────────────
# Use URL versioning for major breaking changes (/v1/ → /v2/)
# Use response headers to signal minor deprecations within a major version:
HTTP/1.1 200 OK
Deprecation: true
Sunset: Wed, 31 Dec 2026 23:59:59 GMT
Link: <https://api.example.com/docs/v2-migration>; rel="successor-version"
Content-Type: application/jsonFor public JSON APIs, URL versioning is the correct default. The developer experience benefit — no custom header configuration required in curl, Postman, browser fetch, or any SDK — outweighs the semantic argument. The caching benefit is concrete and immediately valuable. The only cases where header versioning is preferable: internal microservice APIs where all consumers are under your control, or APIs where clean URLs are a hard product requirement and you are willing to manage CDN Vary configuration carefully.
Semantic Versioning for JSON APIs
Semantic versioning (SemVer — MAJOR.MINOR.PATCH) was designed for software libraries, but its three-level classification maps cleanly onto JSON API changes. The key insight: only MAJOR changes (breaking) require a new API version number in the URL or header. MINOR and PATCH changes are communicated through changelogs and optional response headers, not new version routes.
# ── SemVer applied to JSON APIs ──────────────────────────────────────────
# MAJOR (breaking — requires new version endpoint)
# ─────────────────────────────────────────────────
# v1 → v2: rename a field
# Before (v1): { "user_id": 42, "full_name": "Alice" }
# After (v2): { "id": 42, "name": "Alice" }
# v1 → v2: change a field's type
# Before (v1): { "price": "19.99" } ← string
# After (v2): { "price": 19.99 } ← number
# v1 → v2: remove a field
# Before (v1): { "id": 1, "legacy_code": "XYZ", "name": "Widget" }
# After (v2): { "id": 1, "name": "Widget" }
# v1 → v2: change nested structure
# Before (v1): { "address": "123 Main St, Austin TX 78701" } ← flat string
# After (v2): { "address": { "street": "123 Main St", ← nested object
# "city": "Austin",
# "state": "TX",
# "zip": "78701" } }
# MINOR (additive — backward-compatible, no new endpoint required)
# ────────────────────────────────────────────────────────────────
# Add a new optional field — existing clients ignore it (if coded defensively)
# Before: { "id": 42, "name": "Alice" }
# After: { "id": 42, "name": "Alice", "avatar_url": "https://..." }
# Add a new endpoint
# Before: GET /v1/users, POST /v1/users, GET /v1/users/:id
# After: + GET /v1/users/:id/activity ← new, doesn't affect existing routes
# Add a new enum value — clients should not switch() exhaustively
# Before: status ∈ ["pending", "paid", "cancelled"]
# After: status ∈ ["pending", "paid", "cancelled", "refunded"] ← additive
# Add optional query parameters
# GET /v1/orders?page=1&limit=20 ← existing
# GET /v1/orders?page=1&limit=20&sort=asc ← new param, old requests still work
# PATCH (bug fix — corrects documented behavior, no client code change needed)
# ─────────────────────────────────────────────────────────────────────────
# Fix: timestamps were returned in local time, corrected to UTC ISO 8601
# Before (bug): { "created_at": "2026-01-25 14:30:00" } ← ambiguous
# After (fix): { "created_at": "2026-01-25T14:30:00Z" } ← RFC 3339
# Fix: error response body was missing the "code" field in some paths
# Before (bug): { "message": "Not found" }
# After (fix): { "code": "NOT_FOUND", "message": "Not found" } ← additive fix
# ── Version number in response (informational) ────────────────────────────
HTTP/1.1 200 OK
Content-Type: application/json
X-API-Version: 2.3.1 # current version serving this response
X-API-Minimum-Version: 1.8 # oldest version still accepted
{
"data": { ... }
}The practical rule: if a client compiled against the current JSON Schema would need to change any code to handle the new response, it is a breaking (MAJOR) change. If existing client code continues to work without modification, it is additive (MINOR). Apply this test before deciding whether to bump the version route. For JSON Schema validation tooling that helps verify backward compatibility automatically, see the linked guide.
Response Evolution: Additive Changes
The safest way to evolve a JSON API without a version bump is through additive-only changes: adding new fields, new endpoints, or new enum values. Additive changes are backward-compatible if — and only if — clients are coded defensively. The server-side contract for additive safety is that new fields must be optional and must not replace existing fields.
# ── Before: v1.2 response ────────────────────────────────────────────────
GET /v1/products/101
{
"id": 101,
"name": "Wireless Headphones",
"price": 79.99,
"in_stock": true
}
# ── After: v1.3 additive changes (no version bump) ───────────────────────
GET /v1/products/101
{
"id": 101,
"name": "Wireless Headphones",
"price": 79.99,
"in_stock": true,
"currency": "USD", ← new optional field
"sku": "WH-2024-BLK", ← new optional field
"categories": ["audio", "wireless"], ← new optional array
"availability": { ← new optional nested object
"status": "in_stock",
"quantity": 43
}
}
# ── JSON Schema: additionalProperties: true makes additive changes safe ───
# Clients that validate against this schema accept new fields automatically:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["id", "name", "price", "in_stock"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"price": { "type": "number", "minimum": 0 },
"in_stock": { "type": "boolean" }
},
"additionalProperties": true ← allows new fields without schema error
}
# ── Expanding an enum: additive but requires client defensive coding ───────
# Before: status ∈ ["active", "inactive"]
# After: status ∈ ["active", "inactive", "pending_review", "archived"]
#
# Safe client code (TypeScript):
type Status = 'active' | 'inactive' | string // accept unknown values
# or:
function isKnownStatus(s: string): s is 'active' | 'inactive' {
return s === 'active' || s === 'inactive'
}
// Handle unknown status values gracefully instead of throwing
# ── Expanding a type: string → string | number ────────────────────────────
# BEFORE (additive-safe example):
# { "discount": "10%" } ← always string
# AFTER (BREAKING — type changed):
# { "discount": 0.10 } ← now a number
#
# Correct additive approach — add a new field, keep the old one:
# { "discount": "10%", ← keep for backward compat
# "discount_rate": 0.10 } ← new typed field for new clients
# Then deprecate "discount" with a Sunset timeline
# ── Safe enum expansion in OpenAPI ───────────────────────────────────────
# openapi.yaml — use x-extensible-enum for values that may grow:
components:
schemas:
OrderStatus:
type: string
enum: [pending, paid, shipped, cancelled]
x-extensible-enum: true # signals clients to handle unknown valuesThe discipline of additive-only changes depends on clients being coded defensively. Publish this expectation explicitly in your API documentation: "This API follows an open-world assumption — response objects may contain fields not listed in this documentation. Clients must not fail on unexpected fields." For foundational JSON API versioning concepts and URL vs header strategy basics, see the linked guide.
Sunset Policy and Deprecation Timeline
A sunset policy defines the formal process for removing an API version: when deprecation begins, how consumers are notified, and the guaranteed minimum window before removal. RFC 8594 standardizes the Sunset HTTP response header for machine-readable removal dates, paired with the Deprecation header for machine-readable deprecation signals.
# ── RFC 8594 Sunset header ────────────────────────────────────────────────
# Included on every response from a deprecated endpoint:
HTTP/1.1 200 OK
Content-Type: application/json
Sunset: Wed, 31 Dec 2026 23:59:59 GMT # RFC 7231 HTTP-date format
Deprecation: true # RFC 9745 draft — deprecated now
Link: <https://api.example.com/docs/v2-migration>; rel="successor-version"
Link: <https://api.example.com/docs/deprecation-policy>; rel="deprecation"
# ── Deprecation with a specific start date ────────────────────────────────
# If deprecation began on a specific date (not just "true"):
Deprecation: Mon, 01 Jan 2026 00:00:00 GMT # when deprecation period started
Sunset: Thu, 30 Jun 2027 23:59:59 GMT # when the endpoint will be removed
# ── Express.js middleware: inject deprecation headers ─────────────────────
function deprecationHeaders(sunsetDate: string, successorUrl: string) {
return (req: Request, res: Response, next: NextFunction) => {
res.setHeader('Deprecation', 'true')
res.setHeader('Sunset', sunsetDate)
res.setHeader(
'Link',
`<${successorUrl}>; rel="successor-version"`
)
next()
}
}
// Apply to all v1 routes after releasing v2:
app.use('/v1', deprecationHeaders(
'Wed, 31 Dec 2026 23:59:59 GMT',
'https://api.example.com/docs/v2-migration'
))
# ── Fastify plugin example ─────────────────────────────────────────────────
fastify.addHook('onSend', async (request, reply) => {
if (request.url.startsWith('/v1/')) {
reply.header('Deprecation', 'true')
reply.header('Sunset', 'Wed, 31 Dec 2026 23:59:59 GMT')
reply.header(
'Link',
'<https://api.example.com/docs/v2-migration>; rel="successor-version"'
)
}
})
# ── Sunset window guidelines ──────────────────────────────────────────────
# API Type Minimum Sunset Window
# ─────────────────────────────────────────────────────
# Public API (external devs) 12 months (6 months absolute floor)
# Partner API (known consumers) 6 months
# Internal API (same org) 2–4 weeks (if continuous delivery)
# SLA-guaranteed API Per contract (12–36 months typical)
#
# Announce sunset date when you RELEASE the new version — not retroactively.
# Set Sunset header from day 0 of the deprecation period.
# ── Client-side: read and log Sunset headers ──────────────────────────────
async function apiFetch(url: string): Promise<Response> {
const res = await fetch(url)
const sunset = res.headers.get('Sunset')
const deprecation = res.headers.get('Deprecation')
if (sunset || deprecation) {
console.warn(`API deprecation warning for ${url}:`, {
deprecation,
sunset,
successor: res.headers.get('Link'),
})
// In production: emit a metric / alert to your monitoring system
}
return res
}The Sunset header is most valuable when consumed programmatically. API gateways and monitoring tools like Kong, AWS API Gateway, and Datadog can be configured to alert when a Sunset date is within 90 days. Encourage your consumers to add Sunset header monitoring to their HTTP clients from day one — it turns a passive header into an active migration reminder.
JSON API Migration Guide Design
A migration guide is not a changelog. A changelog describes what changed; a migration guide tells developers exactly what to do to move from the old version to the new one. The difference is the target reader: changelogs are written for people who want to understand what happened, migration guides are written for developers who need to ship a migration this sprint.
# ── Migration guide structure ─────────────────────────────────────────────
## Migrating from v1 to v2
**v1 sunset date:** 2026-12-31
**v2 release date:** 2026-01-01
**Minimum support window:** 12 months
## Breaking changes
### 1. User object field renames
v1 response:
{
"user_id": 42,
"full_name": "Alice Chen",
"created_ts": 1706227200
}
v2 response:
{
"id": 42,
"name": "Alice Chen",
"created_at": "2024-01-26T00:00:00Z"
}
Field mapping:
| v1 field | v2 field | Notes |
|-------------|------------|--------------------------------|
| user_id | id | Renamed for consistency |
| full_name | name | Shortened |
| created_ts | created_at | Unix timestamp → ISO 8601 UTC |
Migration code (TypeScript):
// Before (v1):
const userId = response.user_id
const displayName = response.full_name
const createdDate = new Date(response.created_ts * 1000)
// After (v2):
const userId = response.id
const displayName = response.name
const createdDate = new Date(response.created_at)
### 2. Order status enum expansion + removal
v1 response: { "status": "active" | "inactive" }
v2 response: { "status": "active" | "suspended" | "closed" }
# "inactive" is removed — map to "suspended" in client code
Migration code:
// Before (v1):
if (order.status === 'inactive') showSuspendedBanner()
// After (v2):
if (order.status === 'suspended') showSuspendedBanner()
# ── Migration validation endpoint ─────────────────────────────────────────
# Provide a test endpoint where developers can send a v1 payload and receive
# a validation report showing what would change under v2 rules:
POST /v2/migration-check
Content-Type: application/json
{
"v1_payload": {
"user_id": 42,
"full_name": "Alice",
"created_ts": 1706227200
}
}
Response:
{
"valid": false,
"issues": [
{ "field": "user_id", "action": "rename", "v2_field": "id" },
{ "field": "full_name", "action": "rename", "v2_field": "name" },
{ "field": "created_ts", "action": "transform", "v2_field": "created_at",
"note": "Unix timestamp → ISO 8601" }
],
"v2_equivalent": {
"id": 42,
"name": "Alice",
"created_at": "2024-01-26T00:00:00Z"
}
}
# ── Migration checklist ────────────────────────────────────────────────────
# □ Update base URL from /v1/ to /v2/ in all API clients
# □ Replace user_id references with id
# □ Replace full_name references with name
# □ Replace Unix timestamp parsing with ISO 8601 Date constructor
# □ Update status switch statements to handle 'suspended' and 'closed'
# □ Run test suite against v2 sandbox (api-sandbox.example.com/v2/)
# □ Deploy to staging, verify end-to-end flows
# □ Remove v1 base URL configuration
# □ Update API key if migrating to new auth schemeThe migration validation endpoint is a high-leverage addition: it lets developers paste a v1 payload and immediately see what changes are required, without reading the full migration guide. Host the migration guide at a stable URL (never redirect it) and link to it from the Sunset/Deprecation headers so it is discoverable from any monitoring tool that reads response headers.
Version Negotiation and Fallback
Version negotiation is the server-side logic that maps an incoming request to the correct handler version. For URL-versioned APIs this is straightforward (the version is in the path), but the fallback behavior — what happens when a client requests a version that no longer exists, or specifies no version at all — requires deliberate design.
# ── Express.js: version routing middleware ───────────────────────────────
import express from 'express'
const SUPPORTED_VERSIONS = ['v1', 'v2'] as const
const DEFAULT_VERSION = 'v1' // oldest supported = safest default
const LATEST_VERSION = 'v2'
type ApiVersion = typeof SUPPORTED_VERSIONS[number]
function resolveVersion(req: express.Request): ApiVersion | null {
// 1. URL path: /v2/users → 'v2'
const urlMatch = req.path.match(/^\/(v\d+)\//)
if (urlMatch) {
const v = urlMatch[1] as ApiVersion
return SUPPORTED_VERSIONS.includes(v) ? v : null
}
// 2. Custom header
const headerVersion = req.headers['x-api-version']
if (headerVersion) {
const v = `v${headerVersion}` as ApiVersion
return SUPPORTED_VERSIONS.includes(v) ? v : null
}
// 3. No version specified → default (oldest supported)
return DEFAULT_VERSION
}
app.use((req, res, next) => {
const version = resolveVersion(req)
if (version === null) {
return res.status(400).json({
error: 'UNSUPPORTED_VERSION',
message: `Requested API version is no longer supported. ` +
`Use one of: ${SUPPORTED_VERSIONS.join(', ')}`,
supported_versions: SUPPORTED_VERSIONS,
latest_version: LATEST_VERSION,
})
}
req.apiVersion = version // attach to request context
// Inject deprecation headers if serving non-latest version
if (version !== LATEST_VERSION) {
res.setHeader('Deprecation', 'true')
res.setHeader('Sunset', 'Wed, 31 Dec 2026 23:59:59 GMT')
res.setHeader(
'Link',
'<https://api.example.com/docs/v2-migration>; rel="successor-version"'
)
res.setHeader('Warning', `299 - "API ${version} is deprecated. Migrate to ${LATEST_VERSION}."`)
}
// Warn on unversioned requests (no URL prefix, no header)
if (!req.path.match(/^\/v\d+\//) && !req.headers['x-api-version']) {
res.setHeader('Warning', `299 - "No API version specified. Defaulting to ${DEFAULT_VERSION}. Specify version explicitly to suppress this warning."`)
}
next()
})
# ── Graceful degradation: unsupported version → 410 Gone ─────────────────
# When a version is fully removed (past sunset date):
GET /v0/users HTTP/1.1
HTTP/1.1 410 Gone
Content-Type: application/json
Link: <https://api.example.com/docs/migration-v0-to-v2>; rel="successor-version"
{
"error": "VERSION_GONE",
"message": "API v0 was removed on 2025-06-30 (sunset date passed).",
"migrate_to": "/v2/users",
"migration_guide": "https://api.example.com/docs/migration-v0-to-v2"
}
# ── Header-versioned API: server-side version routing ─────────────────────
function getVersionFromAccept(acceptHeader: string): ApiVersion {
// Parse: application/vnd.example.api+json;version=2
const match = acceptHeader.match(/version=(\d+)/)
if (match) {
const v = `v${match[1]}` as ApiVersion
if (SUPPORTED_VERSIONS.includes(v)) return v
}
return DEFAULT_VERSION // fall back to oldest supported
}
# ── API gateway: version routing config (Kong example) ────────────────────
# Route /v1/* to v1 upstream, /v2/* to v2 upstream:
services:
- name: api-v1
url: http://api-v1-backend:3001
routes:
- paths: [/v1]
- name: api-v2
url: http://api-v2-backend:3002
routes:
- paths: [/v2]
- name: api-unversioned
url: http://api-v1-backend:3001 # default to oldest
routes:
- paths: [/users, /orders, /products]
plugins:
- name: response-transformer
config:
add:
headers:
- "Deprecation: true"
- "Warning: 299 - No version specified; serving v1"The 410 Gone status code is the correct HTTP response for a fully removed API version — it signals that the resource is gone permanently and clients should not retry. Use 400 Bad Request for an actively unsupported or malformed version string. Logging unversioned requests by API key is critical: it is your primary signal for which consumers have not yet migrated, enabling targeted outreach before the sunset deadline.
Multi-Version Testing Strategy
Testing a JSON API across multiple active versions requires two complementary approaches: provider-side contract testing (verifying that each version of the API still fulfills the JSON shapes it promised) and consumer-driven contract testing (letting each consumer define the exact JSON shape it depends on and having the provider verify those expectations in CI).
# ── Consumer-driven contracts with Pact ──────────────────────────────────
# Consumer (client app) defines the expected JSON shape:
// consumer/pact.test.ts
import { Pact, Matchers } from '@pact-foundation/pact'
const { like, term } = Matchers
const provider = new Pact({
consumer: 'OrderDashboard',
provider: 'OrderAPI',
pactBrokerUrl: 'https://pactbroker.example.com',
})
describe('Order API v2 contract', () => {
it('returns an order by ID', async () => {
await provider.addInteraction({
state: 'order 42 exists',
uponReceiving: 'GET /v2/orders/42',
withRequest: {
method: 'GET',
path: '/v2/orders/42',
headers: { Accept: 'application/json' },
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: like(42), // any integer
status: term({
generate: 'paid',
matcher: '^(pending|paid|shipped|cancelled)$'
}),
amount: like(149.99), // any number
created_at: like('2026-01-26T00:00:00Z'), // any string
// consumer only cares about these fields — additional fields OK
},
},
})
// Run the actual consumer code against the mock provider
const order = await fetchOrder(42)
expect(order.id).toBe(42)
expect(order.status).toBe('paid')
})
})
# ── Provider verification (runs in CI on every deploy) ────────────────────
// provider/pact-verify.test.ts
import { Verifier } from '@pact-foundation/pact'
describe('OrderAPI provider verification', () => {
it('satisfies all consumer pacts', async () => {
await new Verifier({
provider: 'OrderAPI',
providerBaseUrl: 'http://localhost:3000',
pactBrokerUrl: 'https://pactbroker.example.com',
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
// Verify against all consumer versions tagged with active version tags:
consumerVersionSelectors: [
{ tag: 'v1', latest: true }, // latest consumer pacts targeting v1
{ tag: 'v2', latest: true }, // latest consumer pacts targeting v2
{ mainBranch: true }, // consumers on their main branch
],
publishVerificationResult: true,
providerVersion: process.env.GIT_SHA,
}).verifyProvider()
})
})
# ── can-i-deploy: block deployment if any contract fails ──────────────────
# In CI pipeline (before deploying to production):
pact-broker can-i-deploy --pacticipant OrderAPI --version $GIT_SHA --to-environment production --broker-base-url https://pactbroker.example.com
# Returns exit 0 if safe to deploy, exit 1 if any consumer contract fails.
# Integrate into GitHub Actions:
- name: Can I deploy?
run: |
npx pact-broker can-i-deploy \
--pacticipant OrderAPI \
--version ${{ github.sha }} \
--to-environment production
env:
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
# ── Version matrix test: N versions × M consumers ─────────────────────────
# Test matrix (run in parallel in CI):
# Consumer v1 contract v2 contract
# ─────────────────────────────────────────────
# OrderDashboard PASS PASS
# MobileApp PASS PASS
# ReportingService PASS N/A (not yet on v2)
# PartnerWebhook PASS PASS
# ── JSON Schema contract testing (simpler alternative) ────────────────────
# Without Pact: version-pinned JSON Schema files per version:
# schemas/
# v1/order-response.json ← JSON Schema for v1 order response
# v2/order-response.json ← JSON Schema for v2 order response
// Integration test: verify API response matches pinned schema
import Ajv from 'ajv'
import v2Schema from './schemas/v2/order-response.json'
const ajv = new Ajv()
const validate = ajv.compile(v2Schema)
test('GET /v2/orders/42 matches v2 schema', async () => {
const res = await fetch('http://localhost:3000/v2/orders/42')
const body = await res.json()
expect(validate(body)).toBe(true)
if (!validate(body)) console.error(validate.errors)
})The key discipline in multi-version testing is that provider verification runs on every CI build — not just when the API team makes changes, but on every commit to every service. This catches breaking changes that are introduced accidentally: a refactor that changes a field name, a database migration that changes a column type. Consumer-driven contract testing with Pact is the most robust approach; JSON Schema snapshot testing is a practical lighter-weight alternative for teams not ready to adopt a full CDCT setup.
Key Terms
- API Versioning
- The practice of maintaining multiple concurrent versions of a JSON API so that clients using older versions continue to work after breaking changes are introduced. Versioning allows the API to evolve — renaming fields, changing types, restructuring responses — without forcing all consumers to migrate simultaneously. The two primary strategies are URL versioning (embedding the version in the path:
/v1/orders,/v2/orders) and header versioning (signaling the version via a request header:Accept: application/vnd.api+json;version=2). URL versioning is more widely adopted for public APIs because it is CDN-cache-friendly and requires no special client configuration. Versioning decisions should follow semantic versioning principles: only breaking (non-backward-compatible) changes require a new major version; additive changes (new optional fields, new endpoints) do not. Every version must have a defined sunset date announced at the time of deprecation. - Sunset Header
- An HTTP response header defined in RFC 8594 that communicates the date and time after which an API endpoint will no longer be available. Format:
Sunset: Sat, 31 Dec 2026 23:59:59 GMT(RFC 7231 HTTP-date format). The header should be included on every response from a deprecated endpoint so that any HTTP client, monitoring tool, or API gateway that reads response headers can detect the upcoming removal automatically. The Sunset header is machine-readable — unlike a notice in documentation or an email, it can trigger automated alerts in monitoring systems. Best practice: set the Sunset header from the moment a new version is released (day zero of the deprecation period), giving consumers the maximum possible migration window. Pair with theDeprecationheader and aLinkheader pointing to the migration guide. - Deprecation Header (RFC 8594 / RFC 9745)
- An HTTP response header that signals a deprecated API endpoint. Two forms:
Deprecation: true(deprecated, deprecation date unknown or not applicable) andDeprecation: Mon, 01 Jan 2026 00:00:00 GMT(deprecated as of this specific date). Defined initially in RFC 8594 alongside Sunset and formalized in RFC 9745. Typically paired with aSunsetheader (removal date) and aLinkheader withrel="successor-version"pointing to migration documentation. Unlike the Sunset header which focuses on removal, the Deprecation header focuses on when the deprecation period began — useful for SLA compliance and audit trails. Client-side monitoring should alert when either Deprecation or Sunset headers appear in responses so engineering teams have time to plan migrations. - Consumer-Driven Contract
- A testing pattern where API consumers (clients) formally define the JSON request and response shapes they depend on, and the API provider verifies those definitions as part of its CI pipeline. The consumer writes a "pact" — a machine-readable contract specifying the minimum JSON fields it needs from the provider — and publishes it to a shared broker (e.g., PactFlow). The provider fetches all consumer pacts and runs "provider verification" to confirm each consumer's expectations are still met by the current provider implementation. If any consumer contract fails verification, the provider deployment is blocked. This inverts traditional API testing: instead of the provider testing its own output, consumers test that the provider still serves their specific needs. It catches breaking changes early (before deployment) and is the most effective way to manage a multi-version API with multiple consumers.
- Semantic Versioning
- A versioning scheme (semver.org) using three numbers: MAJOR.MINOR.PATCH. Applied to JSON APIs: MAJOR increments for breaking changes that require client code changes (removed fields, renamed fields, changed types, removed enum values); MINOR increments for backward-compatible additions (new optional fields, new endpoints, new enum values); PATCH increments for backward-compatible bug fixes (correcting undocumented behavior, fixing response format errors). Only MAJOR changes require a new API version URL or header value. MINOR and PATCH changes should be communicated through changelogs and release notes but do not require consumers to change their client code if they are coded defensively. The key principle: a client compiled against MAJOR.x.y should work correctly with MAJOR.x+1.y and MAJOR.x.y+1 without any changes.
- Sunset Window
- The period between when an API version is deprecated (new version released, deprecation announced) and when it is removed (Sunset date passed). The sunset window gives consumers time to migrate to the new version. Recommended minimums: 12 months for public APIs with external developers (some major providers maintain deprecated versions for 24+ months); 6 months for partner APIs with a known consumer set and direct communication channels; 2–4 weeks for internal APIs within the same organization using continuous delivery. The sunset window should be announced at the moment the new version is released — setting the Sunset header from day zero. During the window, operators should log traffic to the deprecated version by API key and proactively contact consumers who have not yet migrated as the deadline approaches.
- Breaking Change
- Any modification to a JSON API response that requires existing client code to be updated to continue functioning correctly. Examples: removing a field that clients read; renaming a field (e.g.,
user_idtoid); changing a field's type (string to number); changing a nested object's structure; removing an enum value that clients may send or receive; changing HTTP status codes for existing scenarios; changing authentication requirements. A practical test: if a client compiled against the current JSON Schema would throw an error, return wrong data, or silently fail after the change, it is breaking. Breaking changes require a new major API version with a defined migration path and sunset window. Contrast with additive changes, which are backward-compatible. - Additive Change
- A modification to a JSON API response that does not break existing client code — it only adds new information without removing or changing existing fields. Examples: adding a new optional field to a response object; adding a new endpoint or resource; adding a new optional query parameter; adding new enum values (when clients handle unknown values gracefully); adding a new nested object alongside existing fields. Additive changes are backward-compatible and do not require a new major API version. For additive changes to be truly safe, two conditions must hold: the server must not remove any existing fields, and clients must be coded defensively — JSON parsers must ignore unknown fields (
additionalProperties: truein JSON Schema), and client switch statements must handle unknown enum values. Additive-only API evolution is the ideal state: it allows the API to grow without forcing consumer migrations.
FAQ
Should I use URL versioning (/v1/) or header versioning for my JSON API?
URL versioning is the recommended default for public JSON APIs. CDNs use the URL as the cache key, so /v1/products and /v2/products are cached as completely separate entries without any special Vary header configuration — this means URL-versioned APIs get full CDN caching out of the box. Logs, browser history, and monitoring dashboards show the version explicitly in the URL, making debugging straightforward. Every HTTP client — curl, Postman, browser fetch, any SDK — works correctly without requiring custom header configuration. Header versioning (Accept: application/vnd.api+json;version=2 or X-API-Version: 2) is semantically cleaner — versions are a representation concern, not a resource identity concern — and produces clean URLs without version leakage. However, header-versioned responses require a Vary: Accept header for correct CDN caching, which many CDN configurations do not handle reliably. For internal microservice APIs where all consumers are under your control and caching is managed at the service mesh layer, header versioning is a reasonable choice. For public APIs consumed by external developers, URL versioning wins on developer experience. A practical hybrid: use URL path versioning for major breaking changes and Deprecation/Sunset response headers for communicating minor deprecation signals within a major version.
What JSON API changes require a new version and which are backward-compatible?
Breaking changes that require a new major version: removing any field existing clients read; renaming a field (even if semantically equivalent — user_id to id breaks all code reading response.user_id); changing a field's type (string "19.99" to number 19.99); removing an enum value clients may send or receive; restructuring a nested object; changing an HTTP method, status code, or authentication scheme. Additive changes that are backward-compatible and do not require a new version: adding a new optional field to a response (existing clients ignore it if coded with additionalProperties: true); adding a new endpoint; adding a new optional query parameter; adding a new enum value (when clients handle unknown values gracefully rather than throwing on unrecognized strings). The practical test: if a client built against the current API schema would need to change any line of code to handle the new response correctly, it is breaking. Apply this test before deciding whether to bump the version route. Communicate additive changes in changelogs so consumers can opt in to new fields when ready.
How do I communicate API deprecation and sunset to JSON API consumers?
Use a three-header combination on every deprecated endpoint response. The Sunset header (RFC 8594) provides the machine-readable removal date: Sunset: Sat, 31 Dec 2026 23:59:59 GMT. The Deprecation header signals that the endpoint is deprecated: Deprecation: true or Deprecation: Mon, 01 Jan 2026 00:00:00 GMT. The Link header points to the migration guide: Link: <https://api.example.com/docs/v2-migration>; rel="successor-version". These three headers together create a machine-readable deprecation signal that monitoring tools and API gateways can process automatically. For human communication: email all registered API key holders at the start of the sunset window, at the midpoint, and 30 days before the deadline. Publish a deprecation notice in your changelog and developer portal. Log which clients are still calling the deprecated endpoint — use this data to proactively contact teams that have not migrated. Never remove an endpoint without first observing zero traffic for at least 2 weeks after the announced sunset date.
How do I design a migration guide for a breaking JSON API change?
A migration guide must answer one question for each breaking change: "What exactly do I need to change in my code?" Structure it in four parts. First, a before/after JSON comparison — show the exact response from the old version alongside the exact response from the new version so developers can see at a glance what changed. Second, a field mapping table listing every renamed, moved, removed, or retyped field with its new equivalent and the reason for the change. Third, migration code snippets in popular languages showing how to update the parsing logic (before/after). Fourth, a validation step: provide a test endpoint (POST /v2/migration-check) where developers can send a v1 payload and receive a validation report showing what would need to change. Host the guide at a stable permanent URL and link to it from every Sunset and Deprecation header via the Link: rel="successor-version" header. Include an explicit checklist that developers can copy into their task tracker. Specify the sunset date prominently at the top of the guide.
How long should I maintain old JSON API versions after releasing a new one?
The minimum sunset window depends on your consumer base. For public APIs with external developers: 12 months is the industry standard minimum — major platforms like Stripe and Twilio maintain deprecated versions for 12–24 months. Six months is the absolute floor for a public API with any meaningful consumer base; clients may have quarterly release cycles or annual change freezes that prevent rapid migration. For partner APIs with a known set of consumers and direct communication channels: 6 months is reasonable if you can track migration progress per consumer and file tickets in their backlogs. For internal APIs within a single organization using continuous delivery: 2–4 weeks is acceptable if all consumers are reachable and can prioritize the migration quickly. For APIs with SLA-guaranteed version stability: honor the contracted window regardless of internal timelines (typically 12–36 months). Critical principle: announce the sunset date at the exact moment you release the new version — set the Sunset header from day one of the deprecation period, not retroactively after consumers have already missed part of the window. Track traffic to the deprecated version by API key and proactively contact teams that have not migrated as the deadline approaches.
How do I implement consumer-driven contract testing across JSON API versions?
Consumer-driven contract testing (CDCT) with Pact works in three steps. First, each consumer writes a Pact test that describes the minimum JSON response it needs — this generates a pact file (a JSON contract document) that is published to a Pact Broker. Second, the API provider fetches all consumer pacts from the broker and runs "provider verification" in its CI pipeline — it replays each consumer's request against the real provider implementation and validates that the response matches the contract. Third, the can-i-deploy CLI command blocks deployment if any consumer contract fails: pact-broker can-i-deploy --pacticipant OrderAPI --version $GIT_SHA --to-environment production. For multi-version support, tag pacts by the API version the consumer targets: consumers on v1 publish pacts tagged "v1", consumers on v2 publish pacts tagged "v2". The provider verifies against all active version tags simultaneously — if a v1 consumer contract fails after a code change, the deployment is blocked even if v2 contracts pass. This setup catches breaking changes before they reach production, regardless of which team introduced them.
How do I handle clients that don't specify an API version?
Serve the oldest supported version to unversioned requests — never silently upgrade an unversioned client to the latest version, as this breaks the client without warning. If a request to GET /users (no version prefix) arrives, route it to v1 behavior and include headers signaling that the client should specify a version: Warning: 299 - "No API version specified; defaulting to v1. Specify version explicitly." plus Deprecation: true and the sunset date. For URL-versioned APIs, require a version prefix on all production endpoints and return a clear error for unversioned requests after a grace period. Log all unversioned requests by API key so you can identify which consumers are not setting the version — this list is your migration target list. Consider making version specification mandatory for new API keys issued after a cutoff date: include it as a required step in the developer onboarding checklist. For APIs that must support unversioned legacy clients indefinitely, document explicitly that unversioned requests map to v1 and that v1 has its own sunset date — this gives legacy clients a defined exit path rather than an indefinite pass on migration.
Further reading and primary sources
- RFC 8594: The Sunset HTTP Header Field — IETF standard defining the Sunset header for machine-readable API endpoint removal dates
- Pact Documentation: Consumer-Driven Contract Testing — Official Pact framework docs for implementing consumer-driven contract testing across API versions
- Stripe API Versioning — Stripe's production API versioning strategy — a reference implementation for long-lived public API versioning
- Semantic Versioning 2.0.0 — The semver specification: MAJOR.MINOR.PATCH versioning rules applicable to JSON API change classification
- API Change Management: Avoiding Breaking Changes — Phil Sturgeon's analysis of API versioning tradeoffs — additive evolution vs version bumps