API Versioning with Claude Code: A Practical Guide to Safe Contracts
Design API versioning with Claude Code using OpenAPI, compatibility tests, deprecation headers, and rollout prompts.
API versioning is not just adding /v2 to a route. It is a compatibility promise to every mobile app, partner integration, internal service, webhook consumer, and batch job that already depends on your API. If one field is renamed without a migration path, a client can fail even though the new endpoint looks cleaner.
Claude Code can speed up this work because it reads the codebase, edits files, and runs commands, as described in the Claude Code docs. The risk is that a vague request like “modernize this API” often encourages the assistant to converge on the current shape and forget legacy consumers. The fix is to give Claude Code the contract, compatibility rules, rollout plan, and verification commands before it edits code.
This guide covers URL, header, and media-type versioning tradeoffs, OpenAPI contracts, backward-compatible transformations, Deprecation and Sunset headers, changelog policy, consumer tests, rollout and fallback, and prompts that make Claude Code look for breaking changes. For official references, use the OpenAPI Specification, RFC 9745 for the Deprecation header, and RFC 8594 for the Sunset header.
For related implementation depth, pair this with production API development with Claude Code, Claude Code code review, and Changesets version management.
Start With The Compatibility Contract
The point of versioning is not to keep old code forever. The point is to let consumers migrate on a known schedule. Masa tested this on a small orders API: when the prompt only said “add v2 and rename the customer fields”, the generated code passed the new dashboard but broke an older CSV export. The missing instructions were specific: keep the v1 response shape, publish a deprecation date, add a consumer test, and document the migration.
Three common use cases drive the design:
| Use case | Constraint | Usually suitable versioning style |
|---|---|---|
| Public REST API for mobile apps | Old app versions remain in the wild for months | URL path versioning |
| B2B SaaS partner API | Customers migrate on their own calendar | URL path or explicit header |
| Internal microservices | Clients can often be upgraded together | Header or media-type versioning |
Before asking Claude Code to implement anything, write down the current consumers, the sunset window, the definition of “breaking”, and the metrics you will watch. A breaking change is not only a removed route. It can be a renamed response field, a new required request field, a different error envelope, a changed default sort order, or a pagination shape that old clients cannot parse.
Choose URL, Header, Or Media Type
The versioning surface affects routing, caching, documentation, SDK generation, and support. For most public APIs, URL path versioning is the pragmatic default because it is visible in logs, simple in API gateways, and easy for humans to test. The downside is that the resource URI now contains a product version, so /api/v1/orders/123 and /api/v2/orders/123 look like separate resources.
| Style | Example | Strength | Common failure mode |
|---|---|---|---|
| URL path | /api/v1/orders | Clear routing, clear docs, easy curl examples | Old paths linger and duplicate router code grows |
| Custom header | API-Version: 2 | Stable URL, convenient for controlled clients | Header is easy to forget; caches need Vary: API-Version |
| Media type | Accept: application/vnd.acme.orders.v2+json | Uses HTTP content negotiation for representations | Harder OpenAPI setup, SDK generation, and support debugging |
If you use media-type versioning, send Vary: Accept so intermediaries do not mix v1 and v2 responses. If you use a custom version header, send Vary: API-Version. Even with URL versioning, treat v1 and v2 as separate contracts in OpenAPI when response compatibility changes.
Make OpenAPI The Source Of Truth
OpenAPI is a machine-readable description for HTTP APIs: paths, methods, parameters, request bodies, responses, and security. In plain terms, it is the API promise before implementation. The openapi field describes the OpenAPI specification version, while info.version describes your API document version. Tell Claude Code not to confuse those two.
The example below keeps v1 documented and deprecated while adding v2. It uses openapi: 3.1.0 because many validators and generators support it well; check the official OpenAPI site when deciding whether your tooling is ready for newer specification versions.
openapi: 3.1.0
info:
title: Acme Orders API
version: 2.0.0
servers:
- url: https://api.example.com
paths:
/api/v1/orders/{orderId}:
get:
operationId: getOrderV1
summary: Get an order in the legacy v1 shape
deprecated: true
x-deprecated-at: "2026-03-31T00:00:00Z"
x-sunset-at: "2026-12-31T23:59:59Z"
parameters:
- name: orderId
in: path
required: true
schema:
type: string
responses:
"200":
description: Legacy order response
headers:
Deprecation:
schema:
type: string
description: RFC 9745 structured date, for example @1774915200
Sunset:
schema:
type: string
description: RFC 8594 HTTP-date when v1 may stop responding
content:
application/json:
schema:
$ref: "#/components/schemas/OrderV1Envelope"
/api/v2/orders/{orderId}:
get:
operationId: getOrderV2
summary: Get an order in the current v2 shape
parameters:
- name: orderId
in: path
required: true
schema:
type: string
responses:
"200":
description: Current order response
content:
application/json:
schema:
$ref: "#/components/schemas/OrderV2Envelope"
components:
schemas:
OrderV1Envelope:
type: object
required: [data]
properties:
data:
type: object
required: [id, customerName, totalCents, currency]
properties:
id:
type: string
customerName:
type: string
totalCents:
type: integer
currency:
type: string
OrderV2Envelope:
type: object
required: [data]
properties:
data:
type: object
required: [id, customer, amount, status]
properties:
id:
type: string
customer:
type: object
required: [displayName]
properties:
displayName:
type: string
amount:
type: object
required: [value, currency]
properties:
value:
type: integer
currency:
type: string
status:
type: string
enum: [paid, shipped]
Give this file to Claude Code first, then ask for implementation. The instruction should be explicit: do not remove v1 fields, do not change v1 status codes, and update tests and changelog whenever the contract changes.
Implement Backward Compatibility In Node
The following TypeScript server is intentionally small. It uses only built-in Node APIs, so readers can copy it into api-versioning-demo.ts and test path, header, and media-type versioning without a database or framework. v1 keeps the legacy shape; v2 exposes the current shape; v1 responses include the official deprecation signals.
import { createServer } from "node:http";
import { parse } from "node:url";
type ApiVersion = "v1" | "v2";
type OrderRow = {
id: string;
customerName: string;
totalCents: number;
currency: "JPY" | "USD";
status: "paid" | "shipped";
createdAt: string;
};
const orders = new Map<string, OrderRow>([
[
"o_100",
{
id: "o_100",
customerName: "Masa Tanaka",
totalCents: 129800,
currency: "JPY",
status: "paid",
createdAt: "2026-06-02T09:00:00.000Z",
},
],
]);
function detectVersion(req: { headers: Record<string, string | string[] | undefined> }, pathname: string) {
const pathVersion = pathname.match(/^\/api\/(v[12])\//)?.[1] as ApiVersion | undefined;
if (pathVersion) return { version: pathVersion, source: "path" };
const header = req.headers["api-version"];
if (typeof header === "string") {
const normalized = header.startsWith("v") ? header : `v${header}`;
if (normalized === "v1" || normalized === "v2") {
return { version: normalized, source: "header" };
}
throw new Error(`Unsupported API-Version: ${header}`);
}
const accept = req.headers.accept;
if (typeof accept === "string") {
const mediaMatch = accept.match(/application\/vnd\.acme\.orders\.v([12])\+json/);
if (mediaMatch) {
return { version: `v${mediaMatch[1]}` as ApiVersion, source: "media-type" };
}
}
return { version: "v1" as ApiVersion, source: "default" };
}
function orderIdFrom(pathname: string) {
return pathname.match(/^\/api\/(?:v[12]\/)?orders\/([^/]+)$/)?.[1];
}
function toV1(row: OrderRow) {
return {
data: {
id: row.id,
customerName: row.customerName,
totalCents: row.totalCents,
currency: row.currency,
},
};
}
function toV2(row: OrderRow) {
return {
data: {
id: row.id,
customer: { displayName: row.customerName },
amount: { value: row.totalCents, currency: row.currency },
status: row.status,
createdAt: row.createdAt,
},
};
}
function addDeprecationHeaders(res: import("node:http").ServerResponse) {
const deprecatedAt = Math.floor(Date.parse("2026-03-31T00:00:00Z") / 1000);
res.setHeader("Deprecation", `@${deprecatedAt}`);
res.setHeader("Sunset", new Date("2026-12-31T23:59:59Z").toUTCString());
res.setHeader(
"Link",
[
'<https://docs.example.com/api/deprecations/v1-to-v2>; rel="deprecation"; type="text/html"',
'<https://docs.example.com/api/sunset-policy>; rel="sunset"; type="text/html"',
].join(", "),
);
}
function sendJson(res: import("node:http").ServerResponse, status: number, body: unknown) {
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify(body, null, 2));
}
const server = createServer((req, res) => {
const pathname = parse(req.url ?? "/").pathname ?? "/";
const orderId = orderIdFrom(pathname);
if (!orderId) {
return sendJson(res, 404, { error: "not_found", message: "Route not found" });
}
let detected: ReturnType<typeof detectVersion>;
try {
detected = detectVersion(req, pathname);
} catch (error) {
return sendJson(res, 400, {
error: "unsupported_version",
message: error instanceof Error ? error.message : "Unsupported API version",
supportedVersions: ["v1", "v2"],
});
}
const row = orders.get(orderId);
if (!row) {
return sendJson(res, 404, { error: "order_not_found", orderId });
}
res.setHeader("Vary", "Accept, API-Version");
res.setHeader("X-API-Version", detected.version);
res.setHeader("X-API-Version-Source", detected.source);
if (detected.version === "v1") {
addDeprecationHeaders(res);
return sendJson(res, 200, toV1(row));
}
return sendJson(res, 200, toV2(row));
});
const port = Number(process.env.PORT ?? 18080);
server.listen(port, () => {
console.log(`API versioning demo: http://localhost:${port}`);
});
Run it like this:
npm init -y
npm install -D tsx typescript @types/node
PORT=18080 npx tsx api-versioning-demo.ts &
SERVER_PID=$!
sleep 1
curl -i http://localhost:18080/api/v1/orders/o_100
curl -i -H "API-Version: 2" http://localhost:18080/api/orders/o_100
curl -i -H "Accept: application/vnd.acme.orders.v2+json" http://localhost:18080/api/orders/o_100
kill "$SERVER_PID"
The important design choice is the transformer layer. v1 does not call the v2 response and then hope old clients tolerate it. Each version maps from the internal row to the exact public shape promised in the contract.
Publish Deprecation And Version Policy
Old examples often show Deprecation: true. Use the current RFC instead. RFC 9745 defines Deprecation as a structured Date value, such as @1774915200. RFC 8594 defines Sunset as an HTTP-date that tells clients when a resource may become unresponsive. These headers are runtime signals, not a full migration plan.
Keep the policy in the repository so Claude Code, reviewers, and humans all see the same rules.
currentApiVersion: v2
minimumSupportWindowMonths: 12
breakingChangeRequires:
- new-major-version
- migration-guide
- consumer-test
- owner-approval
deprecatedVersions:
- version: v1
deprecatedAt: "2026-03-31T00:00:00Z"
sunsetAt: "2026-12-31T23:59:59Z"
replacement: "/api/v2/orders/{orderId}"
migrationGuide: "https://docs.example.com/api/deprecations/v1-to-v2"
The changelog should separate added, changed, deprecated, and removal entries. A useful entry says who is affected, what must change, which replacement endpoint to use, and when the old version may stop responding. A vague “v1 is deprecated” entry is not enough for a partner team scheduling migration work.
Add Consumer Tests Before Refactoring
Consumer tests express what clients still need. They are especially valuable when Claude Code is refactoring code that looks redundant. The test below proves that v1 still has customerName and does not accidentally return the v2 customer object.
import assert from "node:assert/strict";
import test from "node:test";
const baseUrl = process.env.API_BASE_URL ?? "http://localhost:18080";
test("v1 keeps the legacy response shape", async () => {
const res = await fetch(`${baseUrl}/api/v1/orders/o_100`);
assert.equal(res.status, 200);
assert.match(res.headers.get("deprecation") ?? "", /^@\d+$/);
assert.match(res.headers.get("sunset") ?? "", /GMT$/);
const body = await res.json();
assert.equal(body.data.customerName, "Masa Tanaka");
assert.equal(body.data.customer, undefined);
});
test("v2 returns the current response shape", async () => {
const res = await fetch(`${baseUrl}/api/orders/o_100`, {
headers: { "API-Version": "2" },
});
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.data.customer.displayName, "Masa Tanaka");
assert.equal(body.data.amount.currency, "JPY");
assert.equal(body.data.customerName, undefined);
});
PORT=18080 npx tsx api-versioning-demo.ts &
SERVER_PID=$!
sleep 1
API_BASE_URL=http://localhost:18080 node --test version-contract.test.mjs
kill "$SERVER_PID"
Add OpenAPI linting to the same verification path when possible:
npx @redocly/cli lint openapi.yaml
If these commands are part of the prompt, Claude Code has a concrete target. If the prompt only says “make it backwards compatible”, you still need to manually discover what backwards compatible means.
Roll Out With Fallback
Most API versioning incidents are predictable. The team changes the database schema and response shape in the same deploy, so rollback is hard. The team announces a sunset date before measuring real v1 traffic. The SDK is updated but raw HTTP users are forgotten. Or the docs say “deprecated” while metrics and alerts never identify remaining consumers.
Rollout should be staged: add v2, add v1 deprecation headers, measure version usage, publish the migration guide, release updated SDKs, notify partners, enforce sunset behavior, and only then remove v1. A fallback plan should prove that v1 keeps working if v2 is disabled, that old clients ignore new fields, and that database changes preserve read compatibility.
Use response snapshots during rollout reviews:
mkdir -p tmp/version-snapshots
BASE_URL=${BASE_URL:-http://localhost:18080}
for order_id in o_100 missing; do
curl -sS -D "tmp/version-snapshots/${order_id}.v1.headers" \
"$BASE_URL/api/v1/orders/$order_id" \
> "tmp/version-snapshots/${order_id}.v1.json" || true
curl -sS -D "tmp/version-snapshots/${order_id}.v2.headers" \
-H "API-Version: 2" \
"$BASE_URL/api/orders/$order_id" \
> "tmp/version-snapshots/${order_id}.v2.json" || true
done
Attach those snapshots to the pull request or paste them into Claude Code and ask for a compatibility summary. The point is not to replace tests; it is to make behavioral differences visible to reviewers.
Prompts That Prevent Breaking Changes
Claude Code performs best when the prompt includes the contract, the forbidden changes, and the required checks.
Add v2 to the existing API. Treat the OpenAPI files as the source of truth. Do not change the v1 response shape, status codes, or deprecation headers.
Before editing, list:
- possible breaking changes
- fields that must remain in v1
- fields added or changed in v2
- consumer tests you will add
After editing, run:
- npm test
- npx @redocly/cli lint openapi.yaml
- curl comparisons for v1 and v2
In the final response, include compatibility risk, migration-guide notes, and rollback steps.
Use this review prompt before merging:
Review this diff for API compatibility.
Check:
- v1 required response fields were not removed, renamed, or type-changed
- error envelopes, HTTP statuses, pagination, and sort order did not change unexpectedly
- Deprecation, Sunset, Link, and Vary headers match the policy
- OpenAPI, implementation, tests, and CHANGELOG agree
- rollback will not break v1 consumers
Return findings with file names and concrete fixes.
These prompts push Claude Code away from “make the code cleaner” and toward “preserve the public contract.” That difference matters more than the versioning style you choose.
Conclusion
Safe API versioning starts with a contract. Choose URL, header, or media-type versioning based on your consumers and infrastructure. Document v1 and v2 in OpenAPI, keep legacy transforms explicit, publish Deprecation and Sunset signals, write changelog entries that humans can act on, and run consumer tests before refactoring.
If your team wants to introduce Claude Code into API development, Claude Code consultation and training can help turn your contracts, CI gates, prompts, and rollout checklist into a repeatable workflow. If you want to start smaller, use the free cheatsheet and adapt the prompts from this article.
I verified the core pattern with the small Node server above: v1 and v2 can share the same internal row while keeping separate public shapes, and the consumer test catches a field rename immediately. The easiest details to miss were the RFC 9745 Deprecation date format, the Vary header for header/media-type versioning, and checking OpenAPI, code, tests, and changelog together.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Permission Receipt Pattern: Record Scope, Proof, and Rollback
A permission receipt pattern for Claude Code: allowed actions, approval boundaries, proof commands, rollback, and revenue CTA checks.
Safe Agent Harness Design for Claude Code and Codex: Permissions, Checks, and Rollback
Build a practical agent harness for Claude Code and Codex with policy, planning, verification, and recovery layers.
Claude Code Subagents: A Practical Guide to Safe Agent Delegation
Claude Code subagent guide for safe parallel article and code work: delegation rules, prompts, pitfalls, and checks.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.