Sunset Header and Deprecation Policy Docs

This guide is part of API Versioning & Deprecation in OpenAPI within OpenAPI & AsyncAPI Schema Authoring. It covers the half of a deprecation that lives outside the specification: the HTTP headers a running API sends on every call to a retiring endpoint, and the policy page consumers plan against. The situation that makes this urgent is the one every API team eventually hits — the deprecation was announced properly, and a year later traffic on the old version has barely moved, because nothing the client actually touches ever mentioned it.

Channels that carry a deprecation notice A changelog and a portal banner reach people who read them, while response headers reach every caller on every request including automated ones. which channel actually reaches an automated integration? changelog + email reaches: people who subscribed and read it misses the integration nobody owns any more portal banner reaches: people who revisit the docs misses everyone who already integrated response headers reaches: every caller, on every request the only channel that finds unowned clients

Problem & Context

Deprecation notices fail because of who they reach. A changelog entry reaches subscribers. A portal banner reaches people who revisit the documentation — which, for a working integration, is nobody: the whole point of finishing an integration is that you stop reading the docs. An email reaches whoever was the technical contact when the account was created, who may have left.

The integrations that need the notice most are precisely the ones nobody is actively working on. They run, they work, and the only thing anyone on the consumer side still touches is the HTTP response. That is why the response is where the notice has to be.

Two RFCs standardise this. Deprecation (RFC 9745) declares the state and when it began; Sunset (RFC 8594) declares the deadline. Alongside them, Link relations point at the replacement and the migration guide. Together they turn a notice nobody read into a fact every caller receives, and they do it in a form a monitoring rule or an API gateway can act on automatically.

Step-by-Step Solution

1. Derive the runtime config from the specification

Never hand-maintain the header values. Generate them from the same document that carries deprecated: true, so the two cannot disagree:

# scripts/emit-deprecations.sh
set -euo pipefail
yq -o=json '
  [ .paths | to_entries[] as $p
    | $p.value | to_entries[]
    | select(.value.deprecated == true)
    | { path: $p.key,
        method: (.key | upcase),
        deprecatedSince: .value["x-deprecated-since"],
        sunset: .value["x-sunset"],
        successor: .value["x-replaced-by"] } ]
' openapi/api.yaml > config/deprecations.json

echo "Emitted $(yq -r 'length' config/deprecations.json) deprecation(s)."
Emitted 2 deprecation(s).

Middleware reads that file and attaches the headers to every matching response:

// middleware/deprecation.js
const deprecations = require('../config/deprecations.json');

// Index by "METHOD path" for O(1) lookup per request.
const index = new Map(
  deprecations.map((d) => [`${d.method} ${d.path}`, d]),
);

module.exports = function deprecationHeaders(req, res, next) {
  // req.route.path is the TEMPLATE (/invoices/{id}), not the concrete URL.
  const key = `${req.method} ${req.route?.path ?? req.path}`;
  const d = index.get(key);
  if (!d) return next();

  // RFC 9745: an @-prefixed Unix timestamp naming when deprecation began.
  res.set('Deprecation', `@${Math.floor(Date.parse(d.deprecatedSince) / 1000)}`);
  // RFC 8594: an HTTP-date naming when the endpoint stops working.
  res.set('Sunset', new Date(`${d.sunset}T23:59:59Z`).toUTCString());
  res.append('Link', `<${d.successorUrl}>; rel="successor-version"`);
  res.append('Link', '<https://docs.acme.com/deprecation-policy>; rel="deprecation"; type="text/html"');

  // One structured log line per call — this is what makes outreach targetable.
  req.log?.warn({ deprecated: key, sunset: d.sunset, client: req.auth?.clientId },
                'deprecated endpoint called');
  next();
};

Confirm the headers reach the wire:

curl -sI https://api.acme.com/v1/invoices/search | grep -iE '^(deprecation|sunset|link):'
Deprecation: @1785110400
Sunset: Sun, 31 Jan 2027 23:59:59 GMT
Link: </v2/invoices>; rel="successor-version"
Link: <https://docs.acme.com/deprecation-policy>; rel="deprecation"; type="text/html"

3. Publish a policy page consumers can plan against

One stable URL, linked from every Link header, answering the questions an integrator will ask. The content matters less than its stability and specificity:

https://docs.acme.com/deprecation-policy

- How long a major version is supported after its successor ships: 12 months.
- Minimum notice before an operation is retired: 6 months.
- What "supported" means for an old major: security fixes and data-loss bugs
  only; no new features; documentation frozen with a banner.
- What happens at sunset: 410 Gone with a problem document for 90 days,
  then the route is removed.
- Where to see current deprecations: https://api.acme.com/deprecations (JSON).

4. Measure who is still calling

The log line in the middleware is the input to outreach. Without it, the retirement conversation is guesswork:

# Top clients still calling deprecated operations, last 30 days
jq -r 'select(.msg=="deprecated endpoint called") | [.client, .deprecated] | @tsv' app.log \
  | sort | uniq -c | sort -rn | head
  4821	acct_9f2	GET /invoices/search
   112	acct_31b	GET /invoices/search
     7	acct_a04	GET /invoices/{id}/legacy-pdf

Three named accounts is a conversation. “Some traffic remains” is not.

5. Return 410 at sunset, not 404

At the deadline, replace the handler before removing the route:

app.get('/v1/invoices/search', (req, res) => {
  res.status(410).type('application/problem+json').send({
    type: 'https://docs.acme.com/problems/endpoint-retired',
    title: 'This endpoint was retired',
    status: 410,
    detail: 'GET /v1/invoices/search was retired on 2027-01-31. ' +
            'Use GET /v2/invoices with the query parameter.',
    successor: 'https://api.acme.com/v2/invoices',
    documentation: 'https://docs.acme.com/v1-migration',
  });
});
What the endpoint returns at each stage Before deprecation a normal response, during the notice period a normal response with headers, after sunset a 410 with a problem document, and finally nothing at all. 200 OK no extra headers 200 OK + Deprecation + Sunset + Link: successor-version 410 Gone problem+json body names the replacement 404 route gone notice period — at least 6 months 90 days of explanation the 410 window is what turns a stranded integrator into a self-service fix

Complete Working Example

A single script that regenerates the runtime configuration and verifies the deployed API agrees with the specification — safe to run as a scheduled job:

#!/usr/bin/env bash
# verify-deprecation-notices.sh — the spec promises headers; prove the API sends them.
set -euo pipefail

: "${API_BASE:?set API_BASE, e.g. https://api.acme.com/v1}"
SPEC="${SPEC:-openapi/api.yaml}"

fail=0

# Every operation the spec marks deprecated must answer with both headers.
while IFS=$'\t' read -r method path sunset; do
  probe="${path//\{*\}/probe}"                 # substitute a value for {id} style params
  headers=$(curl -sI -X "${method}" "${API_BASE}${probe}" | tr -d '\r')

  if ! grep -qi '^sunset:' <<<"${headers}"; then
    echo "FAIL ${method} ${path} — no Sunset header (spec says ${sunset})" >&2
    fail=1
  fi
  if ! grep -qi '^deprecation:' <<<"${headers}"; then
    echo "FAIL ${method} ${path} — no Deprecation header" >&2
    fail=1
  fi
  if ! grep -qi 'rel="successor-version"' <<<"${headers}"; then
    echo "FAIL ${method} ${path} — no successor-version Link" >&2
    fail=1
  fi
  [ "${fail}" -eq 0 ] && echo "OK   ${method} ${path} — notice headers present"
done < <(
  yq -r '
    .paths | to_entries[] as $p | $p.value | to_entries[]
    | select(.value.deprecated == true)
    | [(.key | ascii_upcase), $p.key, .value["x-sunset"]] | @tsv
  ' "${SPEC}"
)

exit "${fail}"

Expected output when the runtime matches the contract:

OK   GET /invoices/search — notice headers present
OK   GET /invoices/{id}/legacy-pdf — notice headers present

Run it on a schedule rather than only at deploy time. The failure this catches — a middleware refactor that quietly stops attaching headers — produces no error anywhere else and would otherwise be discovered when a consumer complains they were never warned.

Gotchas & Edge Cases

Matching on the concrete URL instead of the route template. /invoices/inv_123 will never equal /invoices/{id}, so a middleware keyed on req.path silently matches nothing. Use the framework’s route template, and assert in a test that at least one deprecation actually matches — a lookup table that matches zero routes looks identical to one with no deprecations.

Sunset must be an HTTP-date; Deprecation must not be. RFC 8594 specifies an HTTP-date for Sunset; RFC 9745 specifies an @-prefixed Unix timestamp for Deprecation. Sending the same format for both is the most common implementation error and makes the headers unparseable by anything that validates them.

Proxies and gateways strip unknown headers. A CDN or API gateway configured with an allowlist of response headers will drop both of these before they reach the client, so the API is correct and the consumer still learns nothing. Verify from outside your network perimeter rather than from a host sitting behind it.

Caching a deprecated response caches its headers too. If the response is cacheable, the notice is cached with it — usually fine, but a long-lived cache entry created before you added the headers will keep serving responses without them until it expires.

A policy page that changes silently is worse than none. Integrators plan against it. Version it, keep old versions reachable, and announce changes to the policy itself with the same notice period you promise for endpoints.

Escalate the notice as the deadline approaches. A single unchanging header for six months is easy to keep ignoring. Some teams add a Warning header in the final month, or begin returning the notice in the response body for operations whose consumers are known, or introduce brief scheduled “brownouts” where the endpoint returns 410 for a few minutes at an announced time. The last technique is heavy-handed and extremely effective: an integration that has ignored every passive notice will not ignore a monitored outage, and a five-minute brownout six weeks before the deadline turns a surprise into a planned migration. Announce brownouts in advance and keep them short — the goal is to be noticed, not to cause an incident.

Escalating the deprecation notice Passive headers run for the whole notice period, a warning header is added in the final month, and short announced brownouts follow if traffic has not moved. headers on every response the whole notice period + targeted outreach final month announced brownout only if traffic persists intensity rises only when the measured traffic says it must

FAQ

What is the difference between the Deprecation and Sunset headers?

Deprecation (RFC 9745) says the endpoint is deprecated and since when; Sunset (RFC 8594) says when it stops working. One is a state, the other is a deadline, and consumers need both to decide whether to act now or schedule it for next quarter.

Do clients actually read these headers?

Automated ones can, and some SDKs surface them as warnings. More importantly they make the deprecation visible in any curl -i, any proxy log, and any API gateway rule, so the notice reaches whoever looks first rather than only whoever reads the changelog.

Should a sunset endpoint return 404 or 410?

410 Gone, with a problem document naming the replacement. A 404 is indistinguishable from a typo and tells a stranded integrator nothing; a 410 that explains what happened converts a support ticket into a self-service fix.