TL;DR: Retire an API version in four stages: announce, signal, throttle and shut down. Put machine-readable signals on every response (Deprecation per RFC 9745, Sunset per RFC 8594, and a Link to the migration guide). Measure who still calls the old endpoint, by client rather than by total traffic. Give each of those clients a named migration window. Treat the shutdown date as a commitment you can move later but never earlier.

Why deprecation goes wrong

Most API deprecations fail in the same dull way. A team writes a changelog entry, sends one email to a distribution list nobody reads, and picks a shutdown date that suits the roadmap. On that date a partner's nightly job starts failing. Their engineer opens a ticket that begins with "you broke production".

Nothing in that story is a technical failure. It is a communication failure with a technical trigger. The API worked as designed, and the people depending on it did not know, in a form they could act on, that it was going away.

Good deprecation is therefore half protocol and half operations. The protocol half is well specified: two IETF documents give you standard response headers. The operations half is where most teams under-invest: telemetry on who is still calling, a named owner for each straggler, and a shutdown that degrades in steps rather than falling off a cliff.

This article walks through both halves for engineering teams running public or partner APIs, with a header implementation you can drop into a Node service, a rollout timeline, a trade-off table, common mistakes and a checklist. If you are still deciding how to version in the first place, our piece on API-first architecture for fintech startups covers the design side.

Table of contents

  1. Deprecation versus sunset: two different events
  2. The standard signals: Deprecation, Sunset and Link
  3. A header implementation you can test
  4. Usage telemetry: know who is still calling
  5. Migration windows that match the client, not the calendar
  6. The staged shutdown
  7. Architecture of the whole pipeline
  8. Trade-offs
  9. Common mistakes
  10. Checklist
  11. FAQ

1. Deprecation versus sunset: two different events

People use the words interchangeably, and the standards do not.

Deprecation means a resource is no longer recommended. RFC 9745 is explicit that "the act of deprecation does not change any behavior of the resource". A deprecated endpoint keeps working exactly as before. The signal tells clients to plan a move.

Sunset means the resource is expected to stop responding normally at a specific time. RFC 8594 describes the header as indicating that a URI "is likely to become unresponsive at a specified point in the future".

Keeping the two apart gives you a useful property: you can deprecate early and cheaply (a header and a doc page), and you only commit to the harder date, the sunset, once you have telemetry showing the migration is realistic. The RFC 9745 text also requires an ordering: the Sunset timestamp "MUST NOT be earlier than" the Deprecation timestamp.

Two details worth knowing before you build anything:

  • RFC 9745 is a Standards Track document published in March 2025. RFC 8594, the Sunset header, is Informational and dates from May 2019. Both are widely implemented in practice, but only one is a formal standard. If your enterprise customers ask whether this is "standardised", the honest answer has two parts.
  • Both treat the headers as hints. RFC 8594 says clients "SHOULD treat Sunset timestamps as hints". A header cannot replace direct communication with the clients you know are affected.

Sources: RFC 9745, RFC 8594.

The Deprecation header

Deprecation is a structured header whose value is a date, written as @ followed by Unix seconds:

http
Deprecation: @1688169599

That example comes from the RFC and corresponds to 30 June 2023, 23:59:59 UTC. The date may be in the future (the resource will be deprecated then) or in the past (it was deprecated then). In practice, most teams set it to the day they announce, which is already past by the time clients see it.

The Sunset header

Sunset uses the older HTTP-date format, which is a different format from Deprecation. RFC 9745 acknowledges this: "for historical reasons the Sunset HTTP header field uses a different data format for date".

http
Sunset: Sun, 30 Jun 2024 23:59:59 UTC

RFC 8594 says the timestamp "SHOULD be a timestamp in the future". It also warns that "it is safest to consider timestamps in the past mean the present time", so do not leave a stale Sunset value in place after the date passes. By then you should be returning an error.

Headers tell a machine that something is changing. They do not tell a human how to fix it. Both RFCs register link relations for this:

http
Link: <https://developer.example.com/deprecation>; rel="deprecation"; type="text/html"

The deprecation relation, per RFC 9745, refers to documentation intended for human consumption about the deprecation. RFC 8594 registers sunset for a resource describing the retirement policy. Point at one stable migration page. Do not point at a changelog that will be edited and moved.

Scope

Both headers apply to the resource that returns them. If you retire a whole version prefix such as /v1/, you either send the headers from every endpoint under it or document, as RFC 9745 allows, that the signal covers a wider scope. The first option is simpler and survives reverse proxies and client libraries that only log a single response.

A word on security

RFC 8594 notes that information about upcoming sunsets can leak information that is not otherwise available. If a sunset date derives from something sensitive, such as a contract expiry for one customer, think before exposing it on a response that others can see. For per-tenant dates, send the header only on authenticated responses for that tenant.

3. A header implementation you can test

The middleware below keeps the policy in one table and emits all three headers for matching routes. We ran it against a local HTTP server and confirmed the output shown afterwards.

js
// deprecation.mjs
const policy = {
  '/v1/orders': {
    deprecatedAt: new Date('2026-09-01T00:00:00Z'),
    sunsetAt: new Date('2027-03-01T00:00:00Z'),
    docs: 'https://api.example.com/docs/migrate-orders-v2',
  },
};

export function deprecationHeaders(req, res, next) {
  const rule = policy[req.path];
  if (rule) {
    const secs = Math.floor(rule.deprecatedAt.getTime() / 1000);
    res.setHeader('Deprecation', `@${secs}`);
    res.setHeader('Sunset', rule.sunsetAt.toUTCString());
    res.setHeader('Link', `<${rule.docs}>; rel="deprecation"; type="text/html"`);
  }
  next();
}

Wire it into Express with app.use(deprecationHeaders). It uses only req.path, so it also works with any framework that exposes a path and a setHeader call. The response for /v1/orders carried:

http
Deprecation: @1788220800
Link: <https://api.example.com/docs/migrate-orders-v2>; rel="deprecation"; type="text/html"
Sunset: Mon, 01 Mar 2027 00:00:00 GMT

Three implementation notes:

  1. Date.prototype.toUTCString() produces the HTTP-date format that Sunset requires, with GMT. Do not hand-format it.
  2. If a response already has a Link header (pagination, for example), append instead of overwriting. res.append('Link', value) does that in Express.
  3. Keep the policy table in version control, reviewed like code. The dates in it are public promises.

If you front your API with a gateway, the same three headers can usually be added by a response-header rule instead. The advantage of the gateway is that it covers services you cannot easily change. The disadvantage is that the policy then lives outside the service repository, which is easy to forget when the service is rewritten.

4. Usage telemetry: know who is still calling

Total request count for the old endpoint is a poor guide. A million calls a day from one forgotten cron job and a million from fifty active integrations are very different migration problems. What you need is calls per client per endpoint, over time.

Identify the client by the strongest identifier you already have: API key, OAuth client ID, or mTLS certificate subject. The User-Agent header is a weak fallback, not an identity.

js
// usage.mjs
const usage = new Map();

export function recordUsage(req, res, next) {
  res.on('finish', () => {
    const client = req.get('x-client-id') ?? 'anonymous';
    const route = `${req.method} ${req.route?.path ?? req.path}`;
    const key = `${client}|${route}`;
    usage.set(key, (usage.get(key) ?? 0) + 1);
  });
  next();
}

This in-memory version only illustrates the shape. In production, emit the same dimensions (client, method, route template, status, API version) as metrics or structured logs and aggregate them in your observability stack. Use the route template (/v1/orders/:id), not the raw URL, or your cardinality will explode.

From that data, build four views before you announce anything:

  • Active clients on the old version, with last-seen time.
  • Per-client share of traffic, so you can see whether one integration dominates.
  • Error rate on the new version for clients who have started calling it. A migrating client with rising errors needs help now, not at the deadline.
  • Clients who read your Sunset header. You cannot see this directly, but you can see whether anyone's call pattern changes after announcement. If nothing changes after a month, the header is not reaching a human.

Treat anonymous traffic as its own risk class. If a meaningful share of calls has no client identity, you cannot contact anyone, so either require identification on the old version going forward or plan a longer, louder brownout (section 6).

5. Migration windows that match the client, not the calendar

A single global date is easy to publish and hard to meet. Instead, define windows by client class.

Client classTypical situationSuggested approach
Internal servicesYou control the codeShort window, tracked in your own backlog
Active partnersNamed contacts, paid integrationsDirect outreach, agreed date, engineering-to-engineering contact
Self-serve developersMany small integrationsPublic headers, docs, email to the account owner, longer window
Anonymous or unknownNo contact pathLongest warning, loudest signals, brownouts
Regulated or contract-boundChange control, audit trailsAlign the window with their change-freeze calendars, and put the date in writing

We deliberately do not prescribe a number of months. The right length depends on how long your slowest legitimate client needs to schedule, build, test and release a change. For some consumer apps that is a store review cycle. For regulated enterprise integrations it can include a change-advisory board. Ask them, then publish the answer.

Two rules keep windows honest:

  • The date can move later; it cannot move earlier. Extending a window costs you some maintenance. Shortening it costs you trust.
  • Every exception has an owner and an expiry. "We will keep v1 alive for Client X" without an end date means v1 lives forever.

6. The staged shutdown

A cliff-edge shutdown converts every unmigrated client into an outage at once. A staged one turns them into noisy warnings that arrive before the outage.

  1. Announce. Publish the migration guide, add the Deprecation and Link headers, email known contacts, post to the developer changelog. Nothing changes for callers.
  2. Signal. Add the Sunset header once you are ready to commit to a date. Keep tracking per-client usage weekly.
  3. Nudge. Contact each remaining client directly with their own call volumes and a link to the guide. Offer a call. This is the stage that actually moves the long tail.
  4. Brownout. For short, announced windows, return errors on the old endpoint, then restore service. Clients who ignored every previous signal discover the problem while rollback is still possible. Announce the brownout times in advance and keep them short.
  5. Retire. After the sunset date, stop serving the endpoint. RFC 8594 notes that interactions after the sunset time are likely to result in 4xx errors, redirects, or no ability to interact at all, and its example mentions a 410 Gone response (or 404 Not Found if old URIs are not tracked). 410 is the more informative choice, because it says the resource is intentionally gone.

Make the retirement response self-explaining. A 410 with a machine-readable body helps whoever is on call at the client. RFC 9457 defines the application/problem+json format for this kind of error, with type, title, status, detail and instance members:

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

{
  "type": "https://api.example.com/problems/version-retired",
  "title": "API version retired",
  "status": 410,
  "detail": "v1/orders was retired on 2027-03-01. See the migration guide."
}

Add a Link header with the migration page here too. The person reading the failure at 3 a.m. should be one click from the fix.

Source: RFC 9457.

7. Architecture of the whole pipeline

mermaid
flowchart LR
  C[API clients] --> G[API gateway]
  G -->|adds Deprecation, Sunset, Link| S[Service v1]
  G --> S2[Service v2]
  G -->|client id, route, status| M[Metrics and logs]
  M --> D[Usage dashboard per client]
  D --> O[Outreach list with owners]
  O -->|emails, calls, extensions| C
  P[Policy table in git] --> G
  G -->|after sunset| R[410 with problem+json]

The diagram makes one point: the loop from telemetry to outreach and back to the client is the thing that retires an API. The headers are just one input to that loop. The policy table sits in version control and drives both what the gateway emits and what the outreach list contains, so your public promises and your internal tracking cannot drift apart.

8. Trade-offs

DecisionOption AOption BWhat to weigh
Where to emit headersIn each serviceAt the gatewayServices give precision per route; the gateway covers many services at once but separates policy from code
Version schemeURL prefix (/v1/)Header or date-based versionURL versions are easy to see and route; header versions keep URLs stable but are easier to miss in logs
Announce early vs lateLong runwayShort runwayEarly notice reduces risk to clients but extends your dual-maintenance cost
BrownoutsUse themSkip themThey surface ignored warnings, but they cause real failures, so they need clear announcement
Per-client datesIndividual extensionsOne date for allExtensions protect key relationships but add tracking work and precedent
Retire response410 Gone with a problem body404 or redirect410 is explicit; redirects can silently send old payloads to a new shape

9. Common mistakes

  1. Sunsetting without telemetry. If you cannot list the clients still calling, you cannot defend your date or help anyone.
  2. Only emailing. Email goes to whoever signed up two years ago. Headers reach the running code, and log lines reach whoever operates it. You need both.
  3. Putting the headers on only some responses. Clients that hit an uncovered path never see the signal.
  4. Mixing up the date formats. Deprecation takes @ plus Unix seconds; Sunset takes an HTTP-date. Copying one into the other is an easy mistake to make.
  5. Leaving a stale Sunset header after the date. Clients are told to treat past timestamps as "now". Replace it with an actual error.
  6. Breaking the new version's contract during migration. If v2 changes weekly while clients are porting, they are chasing a moving target. Freeze the destination first.
  7. No equivalent for every old behaviour. Clients stall when they find one field or filter with no replacement. Publish a field-by-field mapping.
  8. Changing the date to an earlier one. This is the fastest way to teach partners to ignore your next notice.
  9. Redirecting instead of retiring. A 301 that lands on a differently shaped payload turns a loud failure into silent data corruption.
  10. Forgetting SDKs and docs. If the official SDK or the first search result still shows v1, you are creating new v1 users while trying to remove them.

10. Checklist

Before announcing

  • [ ] Per-client usage for the old version is available and tested
  • [ ] A migration guide exists with a field-by-field mapping
  • [ ] The replacement version is stable enough to migrate to
  • [ ] Windows are defined per client class
  • [ ] SDKs and docs default to the new version

At announcement

  • [ ] Deprecation and Link headers are on every response under the old version
  • [ ] Known client contacts are emailed with their own usage
  • [ ] The changelog and developer docs carry the plan

During the window

  • [ ] Sunset header added once the date is committed
  • [ ] Weekly review of remaining clients and error rates on the new version
  • [ ] Direct outreach to each client over a defined threshold
  • [ ] Brownouts announced in advance, short, and logged
  • [ ] Exceptions have an owner and an expiry

At retirement

  • [ ] Old version returns 410 with a problem body and a migration link
  • [ ] Stale headers removed
  • [ ] Post-retirement monitoring for retries and support tickets
  • [ ] Old code, routes and docs removed in the following release

How Syslabs can help

Retiring an API cleanly touches the gateway, the observability stack, the documentation and the client relationships all at once. Our team works on API development and integration, including versioning strategy and migration tooling, and on the DevOps and CI/CD pipelines that keep policy tables and gateway rules under review. If you are planning the wider roadmap around a platform change, IT strategy consulting is where we usually start. For integrations with many external consumers, the patterns in our EdTech integration guide show how partner-facing contracts look in a real ecosystem.

Want a second pair of eyes on your deprecation plan? Book a meeting with our engineers.

References

  • Dalal, S., & Wilde, E. (2025). The Deprecation HTTP Response Header Field (RFC 9745). IETF. https://www.rfc-editor.org/rfc/rfc9745.html
  • Wilde, E. (2019). The Sunset HTTP Header Field (RFC 8594). IETF. https://www.rfc-editor.org/rfc/rfc8594.html
  • Nottingham, M., Wilde, E., & Dalal, S. (2023). Problem Details for HTTP APIs (RFC 9457). IETF. https://www.rfc-editor.org/rfc/rfc9457.html