Backend & APIs No. 075

API Versioning and Deprecation skill for Claude Code

Change public APIs without surprising a single client. Claude classifies every API change as additive or breaking before it ships, turns most breaks into expand-and-contract steps, and picks URL or date-based versioning only when needed. Deprecations get RFC 9745 and RFC 8594 headers, OpenAPI markers, per-client usage telemetry, a migration guide, brownouts, and a 410 Gone after sunset, with removal gated on data and your confirmation.

  • nameapi-versioning-deprecation
  • size216 lines · ≈2,700 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Evolves public APIs without breaking clients - classifying changes as additive or breaking, preferring expand-and-contract over new versions, choosing URL, header or date-based versioning (Stripe and GitHub style), announcing with Deprecation (RFC 9745) and Sunset (RFC 8594) headers and OpenAPI deprecated markers, measuring per-client usage before removal, migration guides, brownouts, and 410 Gone after sunset. Use when renaming or removing a field or endpoint, planning v2 of an API, deprecating an endpoint, writing a migration guide, or checking whether a change is breaking.

Try asking

  • We need to rename total to totalAmount in our public API. Plan it without breaking anyone
  • Deprecate GET /v1/orders: add the headers, changelog entry and a migration guide
  • Is adding a new enum value to order status a breaking change for our clients?

Use it when

  • Renaming, removing or retyping a field or endpoint clients use
  • Planning a v2 or choosing between URL and date-based versions
  • Announcing a deprecation and setting a sunset date
  • Deciding whether an old endpoint is safe to turn off

Skip it when

  • You are designing new endpoints, status codes or error formats — use rest-api-design
  • The API is GraphQL and evolves through @deprecated fields — use graphql-schema
  • You are writing release notes for an app rather than an API contract — use changelog-release-notes

How to check the result

oasdiff breaking passes or lists only intentional breaks, curl -sI on deprecated routes shows valid Deprecation, Sunset and Link headers, and a dashboard shows remaining calls per client.

The common mistake it prevents

Changing a default such as sort order or page size inside the same version, which breaks every client that never sent the parameter while no schema diff notices.

~/.claude/skills/api-versioning-deprecation/SKILL.md

--- name: api-versioning-deprecation description: Evolves public APIs without breaking clients - classifying changes as additive or breaking, preferring expand-and-contract over new… license: MIT ---

A public API evolves well when every change is classified before it merges, most changes ship additively inside the current version, a breaking change gets a new version or a negotiated migration, removal is announced in headers, docs, changelog and direct notice, the team knows from telemetry exactly which clients still use the old path, and nothing is removed while real traffic depends on it without an explicit decision.

Ground rules

  • Clients you do not control will depend on every observable behavior: field order, default sort, error codes, timing. Treat “nobody uses that” as a hypothesis to check in logs.
  • Prefer making a change additive over versioning it. A new version doubles the surface you support until the old one is gone.
  • Never remove or change behavior without telemetry showing who is affected and an explicit user confirmation. Removal is the destructive step of this workflow.
  • Webhooks, SDKs and event payloads are API surface too; version and deprecate them with the same rules.
  • Dates in every announcement are absolute (2027-05-01), never “in six months”.

What is breaking

ChangeBreaking?
Add an endpoint, an optional request field, an optional query param, a response headerNo
Add a response fieldNo, if your docs say clients must ignore unknown fields (say so from day one)
Add an enum value to a response fieldYes for clients with exhaustive switches, unless enums are documented as open
Remove or rename an endpoint, field, param or enum valueYes
Add a required request field or header; make an optional field requiredYes
Change a type or format (int to string, ID length, date format, money representation)Yes
Make a response field nullable or sometimes absentYes
Tighten validation (shorter max length, new pattern)Yes
Change status codes, error type values, default page size, default sortYes
Change semantics with the same schema (amount now includes tax)Yes, and the most dangerous: no diff tool sees it
Stricter rate limits or auth scopes for existing callsYes, operationally
Loosen validation, accept more inputUsually no; check downstream systems

Run a spec diff in CI on every change:

oasdiff breaking --fail-on ERR base.yaml openapi.yaml

oasdiff catches schema breaks only. Review semantic changes by reading the PR.

Workflow

- [ ] 1. Classify the change (spec diff + semantic review)
- [ ] 2. Try expand-and-contract inside the current version
- [ ] 3. If a version is unavoidable, follow the versioning strategy
- [ ] 4. Instrument usage per client for the old path
- [ ] 5. Announce: OpenAPI markers, headers, changelog, docs, direct notice
- [ ] 6. Publish the migration guide and SDK releases
- [ ] 7. Run the timeline: reminders, brownouts
- [ ] 8. Remove only with usage data and explicit confirmation; serve 410 after

2. Expand and contract

Turn a breaking change into a sequence of additive ones:

  1. Expand: add the new field or endpoint next to the old (totalAmount beside total; /v1/orders/search beside the old filter). Return both. Accept both on write, with a documented precedence rule if both are sent.
  2. Migrate: update SDKs, docs and examples to the new shape; mark the old one deprecated.
  3. Contract: remove the old field only after telemetry shows usage gone or the sunset date passes with explicit sign-off.

Renames, type changes and pagination changes almost always fit this pattern. Reserve new versions for changes to many endpoints at once (auth model, resource model, error format).

3. Versioning strategy

StrategyExampleChoose when
Major version in URL path (default)/v1/orders -> /v2/ordersMost public and partner APIs; easy to route, cache, log and explain
Date-based version header with per-account pinningGitHub X-GitHub-Api-Version: 2026-03-10; Stripe Stripe-VersionFrequent small breaking changes, a platform team able to maintain version transforms
Media-type versioningAccept: application/vnd.example.v2+jsonRarely worth it; poor tooling and cache support
No versioning, additive onlyGraphQL schemas with @deprecatedGraphQL (see graphql-schema) and internal APIs with known consumers

Default: URL major version, additive changes within it, a new major only for broad breaks, at most two majors live at once.

How the date-based model works, for teams choosing it:

  • Each account is pinned to the version current at its first call; a request header overrides per call. GitHub defaults header-less requests to 2022-11-28, supports a previous version for at least 24 months after its successor ships, and returns 410 Gone for unsupported versions.
  • Stripe ships monthly versions with no breaking changes and twice-yearly major releases that carry breaking changes (format 2026-09-30.endive); SDK versions pin an API version.
  • Implement as one current handler plus a chain of version-change modules: upgrade the request from the client’s version to current, run the handler, downgrade the response back step by step. Each module owns one change and its tests.
# Ordered newest first; each module downgrades a response from its version to the previous.
VERSION_CHANGES = [
    ("2026-09-01", RenameTotalToTotalAmount),   # downgrade: copy total_amount -> total
    ("2026-03-01", CursorPagination),           # downgrade: emulate page/per_page
]

def render(resource_json, client_version):
    for version, change in VERSION_CHANGES:
        if client_version < version:            # ISO dates compare lexicographically
            resource_json = change.downgrade_response(resource_json)
    return resource_json

4. Usage telemetry before anything else

Log per request: client_id, api_version, route template, whether it hit a deprecated route or sent a deprecated field, SDK name and version from the user agent. Build a dashboard of deprecated calls by client and a “last seen” per client. Without this, the removal date is a guess and the outreach list is empty. Queries are in the kit below.

5. Announce

All of these, same dates everywhere:

  • OpenAPI: deprecated: true on the operation, parameter or schema property, plus x-sunset: "2027-05-01" on operations so oasdiff breaking --deprecation-days-stable=180 blocks early removal in CI.
  • Response headers on every call to a deprecated endpoint:
Deprecation: @1793491200
Sunset: Sat, 01 May 2027 00:00:00 GMT
Link: <https://developer.example.com/migrate/orders-v2>; rel="deprecation"; type="text/html"

Deprecation (RFC 9745, Standards Track, March 2025) is a Structured Field Date (@ + Unix seconds). Sunset (RFC 8594, Informational) is an HTTP-date. The sunset must not be earlier than the deprecation. Both are hints; behavior does not change.

  • Changelog entry under “Deprecated”, docs banner on the old reference page, dashboard notice for logged-in developers, and email to every client seen in telemetry.

For header middleware (Express, FastAPI), OpenAPI markers, telemetry SQL, changelog, migration guide and notice email templates, and the 410 response, read references/deprecation-kit.md when you reach this step.

6. Migration guide

Every deprecation links one page with: deadline, who is affected and how to tell, side-by-side mapping table, before/after code for curl and the top SDKs, how to test in sandbox, brownout dates, and a contact for extensions. Release SDK versions that use the new path before announcing.

7. Timeline

AudienceMinimum announce-to-sunsetNotes
Internal consumers you can list2-4 weeksCoordinate directly; still use headers
Public self-serve API6 months12 months if the change touches billing or core resources
Enterprise contractsPer contract, often 12 monthsCheck agreements before announcing
Security fix forcing a breakAs short as neededExplain why; offer help

Reminders at announcement, halfway, 30 days and 7 days before sunset, only to clients still calling. Run brownouts in the last third: the old path returns 410 for a scheduled hour (announced in advance, at low-traffic times), then longer windows. Brownouts find the integrations whose owners ignore email.

8. Removal

Before removing, show the user: current call share and client count on the old path, the clients still calling with last-seen dates, and outreach status. Remove only with explicit confirmation. After removal, return 410 Gone with a problem-details body naming the replacement; keep that response for months. Delete the server code for the old path after that, along with its version-change module.

Gotchas

  • Changing a default (sort order, page size, included fields) without a version: clients that never sent the parameter break silently.
  • Deprecating without telemetry by client, so the sunset date arrives and nobody knows whether turning it off breaks the largest customer.
  • SDKs that hide the version: a client upgrades the SDK for a bug fix and inherits a breaking API version. Note the pinned API version in SDK release notes.
  • Response headers on deprecated endpoints are invisible to most developers; they help tools and logs, but direct notice is what moves people.
  • Webhook payload versions drifting from request versions; pin webhooks per endpoint configuration and document which version each endpoint receives.
  • Announcing a sunset in a time zone other than UTC.
  • Living with three or more major versions because nobody owns removal; assign an owner and a date when the new version launches.

Verification

  1. oasdiff breaking --fail-on ERR passes, or every reported break is intentional and listed in the migration guide.
  2. A request to each deprecated endpoint returns Deprecation, Sunset and Link headers with the documented dates (curl -sI), and the values parse (Sunset is GMT, Deprecation starts with @).
  3. The deprecated-usage dashboard shows calls by client for the old path.
  4. A contract test pins the old version’s response shape until removal day.

Output contract

## Change classification (additive | breaking: list, with oasdiff output)
## Plan: expand-and-contract steps or new version (why)
## Dates: deprecation, brownouts, sunset (UTC)
## Telemetry: who calls the old path today (clients, share of traffic)
## Announcement checklist: OpenAPI markers, headers, changelog, docs, emails
## Migration guide link and SDK versions
## Removal criteria and owner

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/deprecation-kit.md 221 lines · 7.6 KB
# Deprecation kit

Copy-ready pieces for announcing, measuring and completing a deprecation. Read this when
step 5 or later of the workflow is reached.

## 1. Response headers

```http
HTTP/1.1 200 OK
Deprecation: @1793491200
Sunset: Sat, 01 May 2027 00:00:00 GMT
Link: <https://developer.example.com/migrate/orders-v2>; rel="deprecation"; type="text/html"
```

- `Deprecation` (RFC 9745) is a Structured Field Date: `@` plus Unix seconds.
  `@1793491200` is 2026-11-01T00:00:00Z. A future value announces a planned deprecation.
- `Sunset` (RFC 8594) is an HTTP-date (IMF-fixdate, always `GMT`). It must not be earlier
  than the deprecation date.
- `rel="deprecation"` links human-readable migration docs. `rel="sunset"` (RFC 8594) may
  link a retirement policy page.
- Both headers are hints. They change nothing about behavior until removal.

Compute values:

```bash
python3 -c "import datetime as d;t=d.datetime(2026,11,1,tzinfo=d.timezone.utc);print('@%d'%t.timestamp())"
python3 -c "import datetime as d;print(d.datetime(2027,5,1,tzinfo=d.timezone.utc).strftime('%a, %d %b %Y %H:%M:%S GMT'))"
```

## 2. Middleware

Express:

```ts
// deprecations.ts: single source of truth, also rendered into docs
export const DEPRECATIONS: Record<string, { deprecation: Date; sunset: Date; doc: string }> = {
  "GET /v1/orders": {
    deprecation: new Date("2026-11-01T00:00:00Z"),
    sunset: new Date("2027-05-01T00:00:00Z"),
    doc: "https://developer.example.com/migrate/orders-v2",
  },
};

export function deprecationHeaders(req, res, next) {
  const key = `${req.method} ${req.route?.path ? req.baseUrl + req.route.path : req.path}`;
  const d = DEPRECATIONS[key];
  if (d) {
    res.set("Deprecation", `@${Math.floor(d.deprecation.getTime() / 1000)}`);
    res.set("Sunset", d.sunset.toUTCString());           // IMF-fixdate, "GMT"
    res.append("Link", `<${d.doc}>; rel="deprecation"; type="text/html"`);
    metrics.increment("api.deprecated_call", { route: key, client: req.auth?.clientId ?? "anon" });
  }
  next();
}
```

Register it after routing resolves (or per router) so `req.route` is set; otherwise key by
a route-matching helper. Express `toUTCString()` returns the IMF-fixdate format.

FastAPI:

```python
from datetime import datetime, timezone
from email.utils import format_datetime
from fastapi import Request, Response

DEPRECATIONS = {
    ("GET", "/v1/orders"): (
        datetime(2026, 11, 1, tzinfo=timezone.utc),
        datetime(2027, 5, 1, tzinfo=timezone.utc),
        "https://developer.example.com/migrate/orders-v2",
    ),
}

@app.middleware("http")
async def deprecation_headers(request: Request, call_next):
    response: Response = await call_next(request)
    route = request.scope.get("route")
    key = (request.method, getattr(route, "path", request.url.path))
    if key in DEPRECATIONS:
        dep, sunset, doc = DEPRECATIONS[key]
        response.headers["Deprecation"] = f"@{int(dep.timestamp())}"
        response.headers["Sunset"] = format_datetime(sunset, usegmt=True)
        response.headers.append("Link", f'<{doc}>; rel="deprecation"; type="text/html"')
        log.info("deprecated_call", extra={"route": key, "client": request.state.client_id})
    return response
```

Field-level deprecations cannot be signaled by headers precisely; mark them in OpenAPI and
log when a request sends the field or a client reads it (if you serve per-client field
selection) instead.

## 3. OpenAPI markers

```yaml
paths:
  /v1/orders:
    get:
      deprecated: true
      x-sunset: "2027-05-01"           # oasdiff reads this; RFC 3339 date
      description: Deprecated. Use GET /v2/orders. Migration guide - https://developer.example.com/migrate/orders-v2
components:
  schemas:
    Order:
      properties:
        total:
          type: integer
          deprecated: true
          description: Deprecated, use totalAmount (minor units + currency). Removed in v2.
```

```bash
# Fails CI on breaking changes, and on removals before 180 days of deprecation
oasdiff breaking --fail-on ERR --deprecation-days-stable=180 base.yaml openapi.yaml
oasdiff changelog base.yaml openapi.yaml          # human-readable list for the changelog draft
```

## 4. Usage telemetry queries

Log one row per request with `client_id`, `api_version`, `route`, `deprecated` (bool),
`deprecated_fields` (array), `user_agent`, `sdk_version`, `timestamp`.

```sql
-- Who still calls deprecated routes, last 30 days
SELECT client_id, route, api_version, count(*) AS calls, max(ts) AS last_seen
FROM api_requests
WHERE deprecated AND ts > now() - interval '30 days'
GROUP BY client_id, route, api_version
ORDER BY calls DESC;

-- Readiness for removal: share of traffic and of active clients still on the old path
SELECT
  count(*) FILTER (WHERE deprecated)::float / count(*)                         AS call_share,
  count(DISTINCT client_id) FILTER (WHERE deprecated)::float
    / count(DISTINCT client_id)                                                AS client_share
FROM api_requests
WHERE ts > now() - interval '7 days';
```

Join `client_id` to the account owner's contact for direct outreach. SDK version in the
user agent tells you whether an SDK upgrade alone fixes them.

## 5. Changelog entry

```markdown
## 2026-11-01

### Deprecated
- `GET /v1/orders` is deprecated and will stop working on **2027-05-01**.
  Use `GET /v2/orders`, which returns cursor-paginated results and `totalAmount` in minor
  units. Migration guide: https://developer.example.com/migrate/orders-v2
  Affected requests now carry `Deprecation` and `Sunset` headers.

### Added
- `GET /v2/orders` with `limit`/`cursor` pagination.
```

## 6. Migration guide template

```markdown
# Migrating from GET /v1/orders to GET /v2/orders

**Deadline:** 2027-05-01 (requests after this date return 410 Gone)
**Who is affected:** integrations calling GET /v1/orders. Check your dashboard's
"Deprecated API usage" panel or look for the `Deprecation` response header.
**Effort:** typically one code path; SDK users upgrade to >= 4.2.0.

## What changed and why
One paragraph: the old behavior, the new behavior, the reason.

## Side-by-side
| v1 | v2 | Notes |
|---|---|---|
| `?page=3&per_page=50` | `?limit=50&cursor=<nextCursor>` | Offset paging removed |
| `total` (float dollars) | `totalAmount` (integer minor units) + `currency` | No rounding errors |

## Before / after code
(curl plus the two most-used SDK languages)

## Testing your migration
- Sandbox already serves v2; set header X to test.
- Brownout dates when v1 returns 410 for one hour: 2027-03-03, 2027-04-07.

## Need more time?
Contact address and what information to include.
```

## 7. Notice email (to clients seen in telemetry)

```text
Subject: Action needed by 2027-05-01: GET /v1/orders is being retired

Your integration (client ID cl_123) called GET /v1/orders 48,210 times in the last 30
days. This endpoint stops working on 2027-05-01.

What to do: switch to GET /v2/orders. Guide: https://developer.example.com/migrate/orders-v2
Planned brownouts (v1 returns 410 for one hour): 2027-03-03 15:00 UTC, 2027-04-07 15:00 UTC.

Reply to this email if you cannot meet the date.
```

Send at announcement, at the halfway point, 30 days before, 7 days before, and after each
brownout, only to clients still calling.

## 8. After sunset

```http
HTTP/1.1 410 Gone
Content-Type: application/problem+json

{
  "type": "https://developer.example.com/problems/endpoint-retired",
  "title": "This endpoint has been retired",
  "status": 410,
  "detail": "GET /v1/orders was retired on 2027-05-01. Use GET /v2/orders.",
  "migrationGuide": "https://developer.example.com/migrate/orders-v2"
}
```

Keep the 410 response in place for months after removal; a 404 tells late clients
nothing.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc