Advanced (Updated: 6/2/2026)

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 with Claude Code: A Practical Guide to Safe Contracts

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 caseConstraintUsually suitable versioning style
Public REST API for mobile appsOld app versions remain in the wild for monthsURL path versioning
B2B SaaS partner APICustomers migrate on their own calendarURL path or explicit header
Internal microservicesClients can often be upgraded togetherHeader 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.

StyleExampleStrengthCommon failure mode
URL path/api/v1/ordersClear routing, clear docs, easy curl examplesOld paths linger and duplicate router code grows
Custom headerAPI-Version: 2Stable URL, convenient for controlled clientsHeader is easy to forget; caches need Vary: API-Version
Media typeAccept: application/vnd.acme.orders.v2+jsonUses HTTP content negotiation for representationsHarder 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.

#Claude Code #API design #API versioning #OpenAPI #TypeScript
Free

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.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.