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.
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).
2. Emit Sunset, Deprecation and Link headers
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',
});
});
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.
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.
Related
- API Versioning & Deprecation in OpenAPI — the parent guide and the full lifecycle.
- Documenting deprecated endpoints in OpenAPI — the specification markup these headers are generated from.
- Versioning OpenAPI specs with Git tags — publishing the contract each notice refers to.
- Breaking Change Detection & Spec Diffing — requiring a deprecation before a removal is allowed.