Sunset & Deprecation Headers
Part of the API Versioning & Deprecation reference. This section covers how to announce that an endpoint or an entire API version is going away — using the in-band Deprecation and Sunset response headers, a Link header that points at the successor, and a 410 Gone response once the retirement date passes. If you want a focused walkthrough of just the retirement date signal, see implementing the Sunset HTTP header; this page covers the full three-header contract and its lifecycle.
Problem framing
Most teams announce deprecations out of band: a blog post, a changelog entry, a line in the API portal, or an email to whoever happened to be on the mailing list two years ago. None of those reach the machine that actually calls the endpoint. A batch job scheduled by an engineer who has since left the company keeps hitting GET /v1/orders every night, and the first anyone hears about the retirement is a 404 storm at 3 a.m. on the cutoff date. The out-of-band channel and the traffic never intersect.
The fix is to put the retirement signal on the response itself, where every client already looks. RFC 8594 standardises two response headers for exactly this: Deprecation announces that a resource is deprecated, and Sunset announces the date after which it may stop working. Paired with an RFC 8288 Link header carrying a successor-version relation, a single HTTP response now tells a client three things at once: this endpoint is deprecated, it disappears on this date, and here is where to go instead. A generic client library can act on that without knowing anything about your specific API — the same reason a shared error contract works across an estate, as covered in Error Contracts & Resilience Mapping.
This is an in-band complement to, not a replacement for, your changelog and versioning strategy. The headers tell the machine; your API Versioning & Deprecation policy and changelog tell the human. Both must agree on the dates. The rest of this guide shows how to document the headers in OpenAPI, emit them from server middleware, detect them in clients, and enforce the whole contract in CI.
Deprecation timeline diagram
The three headers are not emitted all at once — they appear as an endpoint moves through its retirement lifecycle. The diagram below shows which header is on the wire at each stage, from a live endpoint through deprecation and a committed sunset date to permanent removal.
Documenting the headers in OpenAPI
Mark the operation deprecated: true and document all three response headers plus the eventual 410 response. OpenAPI 3.1 lets you describe response headers per status code, which is what SDK generators and mock servers read.
# openapi.yaml (OpenAPI 3.1.0)
openapi: 3.1.0
info:
title: Orders API
version: "1.4.0"
paths:
/v1/orders/{orderId}:
get:
operationId: getOrderV1
deprecated: true # drives @deprecated in generated SDKs
summary: Retrieve an order (deprecated — use /v2/orders/{orderId})
parameters:
- name: orderId
in: path
required: true
schema: { type: string }
responses:
"200":
description: The order. This operation is deprecated.
headers:
Deprecation:
description: RFC 8594 — the resource is deprecated.
schema:
type: string
example: "true"
Sunset:
description: >
RFC 8594 — IMF-fixdate (RFC 7231 §7.1.1.1, GMT) after which
this operation may return 410 Gone.
schema:
type: string
example: "Sun, 30 Nov 2026 23:59:59 GMT"
Link:
description: >
RFC 8288 — rel="deprecation" points at the policy;
rel="successor-version" points at the replacement.
schema:
type: string
example: '</v2/orders/{orderId}>; rel="successor-version"'
content:
application/json:
schema: { $ref: "#/components/schemas/Order" }
"410":
description: The operation has been retired past its Sunset date.
content:
application/problem+json:
schema: { $ref: "#/components/schemas/ProblemDetail" }
example:
type: "urn:api:errors:v1:endpoint-sunset"
title: "Endpoint Retired"
status: 410
detail: "GET /v1/orders was retired on 2026-11-30. Use /v2/orders."
successor: "/v2/orders/{orderId}"
The 410 body reuses the shared ProblemDetail schema described in RFC 7807 Problem+JSON Implementation; do not invent a bespoke error shape for retirements. A single error contract means the same client parser that handles a validation error also handles a sunset — you get one code path, not two.
RFC and standard alignment
| Standard / clause | What it defines | Implementation note |
|---|---|---|
| RFC 8594 §2 | The Sunset response header field |
Value is a single HTTP-date; the resource may stop responding after it |
| RFC 8594 §3 | The deprecation link relation type |
Use in a Link header pointing at human-readable deprecation docs |
| RFC 8594 §3 | successor-version link relation |
Points at the replacement resource so clients can migrate automatically |
| RFC 8288 §3 | The Link header field syntax |
Carriage for both rel="deprecation" and rel="successor-version" |
| RFC 7231 §7.1.1.1 | IMF-fixdate — the required HTTP-date form | Always GMT, e.g. Sun, 30 Nov 2026 23:59:59 GMT; never ISO 8601 |
| RFC 9110 §15.5.11 | 410 Gone — permanent, intentional removal |
Preferred over 404 after sunset; explicitly non-retryable |
| Deprecation draft | The Deprecation response header field |
Widely deployed; value "true" or an IMF-fixdate marking when deprecation began |
A common trap: RFC 8594 does not define the Deprecation header — that comes from a separate IETF draft (draft-ietf-httpapi-deprecation-header) that is broadly implemented in the wild. RFC 8594 defines Sunset and the two link relations. Treat them as a set, but know which document owns which field when someone challenges the citation in review.
Implementation step 1 — server middleware (Node.js / Express)
The middleware attaches the three headers to every response from a deprecated operation. Centralise the retirement metadata in one registry so the dates live in a single place and the changelog can read from the same source.
// src/middleware/deprecation.ts
import { Request, Response, NextFunction } from 'express';
interface RetirementPolicy {
deprecatedSince: Date; // when the endpoint became deprecated
sunsetAt: Date; // when it may start returning 410
successorPath: string; // rel="successor-version" target
policyUrl: string; // rel="deprecation" documentation
}
// Single source of truth — the changelog generator reads the same map.
const RETIREMENTS: Record<string, RetirementPolicy> = {
'getOrderV1': {
deprecatedSince: new Date('2026-05-01T00:00:00Z'),
sunsetAt: new Date('2026-11-30T23:59:59Z'),
successorPath: '/v2/orders/{orderId}',
policyUrl: '/docs/deprecations/orders-v1',
},
};
// RFC 7231 §7.1.1.1 IMF-fixdate — Node's toUTCString() emits exactly this form.
function imfFixdate(d: Date): string {
return d.toUTCString(); // e.g. "Sun, 30 Nov 2026 23:59:59 GMT"
}
export function deprecationHeaders(operationId: string) {
const policy = RETIREMENTS[operationId];
return (_req: Request, res: Response, next: NextFunction): void => {
if (!policy) return next();
// Deprecation: IMF-fixdate marking when deprecation started.
res.set('Deprecation', imfFixdate(policy.deprecatedSince));
// Sunset: IMF-fixdate after which 410 may be returned (RFC 8594 §2).
res.set('Sunset', imfFixdate(policy.sunsetAt));
// Link: rel="deprecation" (docs) + rel="successor-version" (replacement).
res.set('Link', [
`<${policy.policyUrl}>; rel="deprecation"; type="text/html"`,
`<${policy.successorPath}>; rel="successor-version"`,
].join(', '));
next();
};
}
// src/routes/orders.ts — wiring the middleware onto the deprecated route
import { Router } from 'express';
import { deprecationHeaders } from '../middleware/deprecation';
const router = Router();
router.get(
'/v1/orders/:orderId',
deprecationHeaders('getOrderV1'), // headers emitted before the handler
(req, res) => {
// After the sunset date, refuse with 410 instead of serving.
const sunset = new Date('2026-11-30T23:59:59Z');
if (Date.now() > sunset.getTime()) {
return res.status(410)
.set('Content-Type', 'application/problem+json')
.json({
type: 'urn:api:errors:v1:endpoint-sunset',
title: 'Endpoint Retired',
status: 410,
detail: 'GET /v1/orders was retired on 2026-11-30. Use /v2/orders.',
successor: `/v2/orders/${req.params.orderId}`,
});
}
res.json({ id: req.params.orderId, status: 'CONFIRMED' });
},
);
export default router;
The 410 branch and the header middleware read the same sunsetAt value in production — factor it out of the literal shown here into the shared RETIREMENTS registry so the header you advertise and the date you enforce can never drift apart.
Implementation step 2 — client interceptor (Python + TypeScript)
A client library should treat Deprecation and Sunset as first-class signals: log a structured warning, emit a metric so a dashboard can count deprecated calls per endpoint, and parse the successor-version relation so operators know where to migrate. It must not silently reroute traffic — surfacing the signal is the job; deciding what to do with it is a human decision.
# api_client/deprecation.py
import logging
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime # parses IMF-fixdate
from typing import Optional
import httpx
logger = logging.getLogger("api.deprecation")
def _parse_link_successor(link_header: str) -> Optional[str]:
"""Extract the URL of the rel="successor-version" link, if present."""
for part in link_header.split(","):
segments = part.split(";")
if any('rel="successor-version"' in s or "rel=successor-version" in s
for s in segments):
return segments[0].strip().strip("<>")
return None
def inspect_deprecation(resp: httpx.Response, metrics) -> None:
deprecation = resp.headers.get("Deprecation")
sunset = resp.headers.get("Sunset")
if not deprecation and not sunset:
return
successor = _parse_link_successor(resp.headers.get("Link", ""))
days_left = None
if sunset:
# RFC 7231 IMF-fixdate → aware datetime.
sunset_dt = parsedate_to_datetime(sunset)
days_left = (sunset_dt - datetime.now(timezone.utc)).days
logger.warning(
"Deprecated endpoint called",
extra={
"endpoint": str(resp.request.url),
"sunset": sunset,
"days_until_sunset": days_left,
"successor": successor,
},
)
# Emit a metric so a dashboard can alert as the sunset date approaches.
metrics.increment(
"api.client.deprecated_call",
tags={"endpoint": resp.request.url.path,
"successor": successor or "unknown"},
)
// src/client/deprecation-interceptor.ts
interface Metrics { increment(name: string, tags: Record<string, string>): void; }
// Parse the Link header for a rel="successor-version" target.
function successorFromLink(link: string | null): string | undefined {
if (!link) return undefined;
for (const part of link.split(',')) {
if (/rel="?successor-version"?/.test(part)) {
const m = part.match(/<([^>]+)>/);
if (m) return m[1];
}
}
return undefined;
}
export function inspectDeprecation(res: Response, metrics: Metrics): void {
const deprecation = res.headers.get('Deprecation');
const sunset = res.headers.get('Sunset');
if (!deprecation && !sunset) return;
const successor = successorFromLink(res.headers.get('Link'));
// Date.parse understands the IMF-fixdate (RFC 7231) format directly.
const daysLeft = sunset
? Math.round((Date.parse(sunset) - Date.now()) / 86_400_000)
: undefined;
console.warn('[deprecation]', {
endpoint: res.url, sunset, daysLeft, successor,
});
metrics.increment('api.client.deprecated_call', {
endpoint: new URL(res.url).pathname,
successor: successor ?? 'unknown',
});
}
Both interceptors are read-only: they observe, log, and count. The successor URL is surfaced so a migration ticket can be filed with the exact target, but neither client rewrites the request. That restraint is deliberate and is discussed under edge cases below.
Edge-case handling
Per-endpoint vs whole-version sunset. Retiring a single operation and retiring an entire API version look identical on the wire — the same three headers — but the blast radius differs. For a whole-version sunset, attach the middleware at the router level so every /v1/* response carries the headers, and point successor-version at the version root (/v2/orders/{orderId}, not a generic /v2/). For a single endpoint, scope the middleware to that route only, or clients on sibling endpoints will see a Sunset that does not apply to them and file spurious migration tickets.
Caching a deprecated response. Deprecation and Sunset are response headers, so a shared cache (CDN, reverse proxy) will store and replay them. That is usually fine, but if the Sunset date is recomputed per request (for example, a rolling window), pin it to a fixed calendar date instead so cached copies do not advertise a stale relative deadline. Add Sunset to the Vary-independent header set and make sure your cache does not strip it — some gateways whitelist headers and silently drop unknown ones.
Honoring Sunset in a shared client library. A library used by dozens of services must not fail closed. If it started throwing when it saw a Sunset header, a single upstream deprecation would take down every consumer at once — the opposite of a graceful retirement. The correct behavior is to warn and meter (as the interceptors above do) and let each service owner act. Reserve hard failures for after the endpoint actually returns 410, which the normal error path already handles.
What to do after the sunset date. The date in Sunset is a may, not a must — RFC 8594 says the resource may stop responding after it, not that it must. Give yourself a grace window: keep serving with the headers for a week or two past the date while you watch the deprecated-call metric fall to zero, then flip to 410 Gone. The 410 body should carry an application/problem+json payload naming the successor, so a client that ignored every warning still gets a machine-readable, non-retryable pointer to the replacement. This pairs with retry classification — a 410 is definitively non-retryable, so a well-behaved client backs off permanently rather than hammering the removed endpoint.
Redirect vs Gone. Resist the temptation to 301/308 a retired endpoint onto its successor. A permanent redirect implies the two resources are equivalent; a v1→v2 migration almost never is (the response shape changed, which is why there is a v2). Return 410 and let the client migrate deliberately. Reserve redirects for pure path renames where the representation is byte-identical.
Validation and testing
Gate the contract so a deprecated operation can never ship without a documented Sunset. The Spectral rule below fails linting if any operation with deprecated: true omits a Sunset response header on its success response.
# .spectral.yaml
rules:
deprecated-operation-needs-sunset:
description: Any deprecated operation must document a Sunset response header.
message: "{{path}} is deprecated but does not document a Sunset header"
severity: error
given: "$.paths.*[?(@.deprecated == true)].responses.200.headers"
then:
field: "Sunset"
function: truthy
deprecated-operation-needs-successor:
description: Deprecated operations should point at a successor via Link.
message: "{{path}} is deprecated but documents no successor Link header"
severity: warn
given: "$.paths.*[?(@.deprecated == true)].responses.200.headers"
then:
field: "Link"
function: truthy
A contract test then asserts the header is actually emitted at runtime and that its value parses as an IMF-fixdate — a string that Date.parse (or Python’s parsedate_to_datetime) accepts and that carries the GMT zone.
// tests/contract/sunset-header.test.ts
import request from 'supertest';
import app from '../../src/app';
describe('Sunset header contract', () => {
it('emits Deprecation, Sunset (IMF-fixdate GMT), and a successor Link', async () => {
const res = await request(app).get('/v1/orders/ord_123');
expect(res.headers['deprecation']).toBeDefined();
const sunset = res.headers['sunset'];
expect(sunset).toBeDefined();
// IMF-fixdate is GMT and round-trips through Date.parse.
expect(sunset).toMatch(/GMT$/);
expect(Number.isNaN(Date.parse(sunset))).toBe(false);
expect(res.headers['link']).toContain('rel="successor-version"');
});
});
Run the Spectral rule in the same pull-request job that lints the rest of the spec, and run the contract test against the real app (or a Prism mock generated from the OpenAPI document) so the documented headers and the emitted headers are checked against each other on every merge.
SDK generation impact
The deprecated: true flag is the one piece of this contract that propagates automatically into generated clients. Every major generator turns it into a language-native deprecation annotation, so consumers who regenerate their SDK see the warning in their IDE long before the sunset date — no manual changelog reading required.
TypeScript (openapi-generator-cli, typescript-fetch): the operation method is emitted with a @deprecated JSDoc tag, which surfaces as a strikethrough in editors and can be escalated to a lint error via @typescript-eslint/no-deprecated.
/**
* Retrieve an order (deprecated — use /v2/orders/{orderId})
* @deprecated
*/
getOrderV1(orderId: string): Promise<Order> { /* ... */ }
Python (openapi-python-client): the generated method is wrapped so that calling it raises a DeprecationWarning, which surfaces under python -W error::DeprecationWarning in CI.
import warnings
def get_order_v1(client, order_id: str) -> Order:
warnings.warn(
"get_order_v1 is deprecated; use get_order_v2",
DeprecationWarning, stacklevel=2,
)
...
Go (oapi-codegen): the flag becomes a // Deprecated: comment on the generated method, which staticcheck’s SA1019 check flags at build time.
// GetOrderV1 retrieves an order.
//
// Deprecated: use GetOrderV2.
func (c *Client) GetOrderV1(ctx context.Context, orderID string) (*Order, error) {
// ...
}
Because the annotation is generated, keeping deprecated: true in the spec is what makes the runtime Sunset header and the compile-time SDK warning tell a consistent story. Skip the flag and consumers only learn at runtime; skip the header and they only learn at compile time. You want both.
Anti-patterns quick-reference
| Anti-pattern | Correct approach |
|---|---|
Sunset value in ISO 8601 or Unix epoch |
Emit an IMF-fixdate in GMT: Sun, 30 Nov 2026 23:59:59 GMT (RFC 7231 §7.1.1.1) |
| Announcing retirement only in a changelog or email | Emit Deprecation + Sunset on the response so the client sees it in-band |
404 Not Found after the sunset date |
Return 410 Gone — permanent, intentional, and explicitly non-retryable |
301/308 redirect from v1 to v2 |
Redirect implies equivalence; return 410 and let clients migrate deliberately |
Shared client library throwing on Sunset |
Warn and emit a metric; fail only once the endpoint returns 410 |
successor-version pointing at a bare /v2/ |
Point at the specific replacement resource so migration is unambiguous |
| Header middleware and 410 branch using separate dates | Read both from one retirement registry so they cannot drift |
Deprecated operation with no deprecated: true in the spec |
Set the flag so SDK generators emit @deprecated annotations |
FAQ
What is the difference between the Deprecation and Sunset headers?
Deprecation announces that a resource is deprecated and optionally when it became so; Sunset announces the future date after which the resource may stop responding. A resource can be deprecated for months before a Sunset date is committed, so the two headers are emitted independently and Sunset is added once a removal date is fixed.
What date format do the Sunset and Deprecation headers use?
Sunset uses an HTTP-date in the IMF-fixdate form defined by RFC 7231 §7.1.1.1, always in GMT — for example Sun, 30 Nov 2026 23:59:59 GMT. The Deprecation header value is an IMF-fixdate as well. Never emit an ISO 8601 date or a Unix timestamp; RFC 8594 requires the HTTP-date production.
What status code should I return after the sunset date passes?
Return 410 Gone with an application/problem+json body when the removal is permanent and you want clients to stop retrying. Use 404 Not Found only if you cannot distinguish a removed resource from one that never existed. 410 is preferred because it is explicitly non-retryable and signals intentional, permanent removal.
Should clients act on the Sunset header automatically?
A shared client library should log a structured warning and emit a metric when it sees Deprecation or Sunset, but it must not fail closed or auto-switch endpoints in ways that surprise the caller. Automatically following a Link rel="successor-version" is only safe when the successor is a drop-in replacement; otherwise surface the signal to operators and let a human schedule the migration.
Related
- API Versioning & Deprecation — parent reference covering the full versioning and retirement strategy
- Implementing the Sunset HTTP header (RFC 8594) — a focused walkthrough of just the
Sunsetdate signal - URL Path vs Header Versioning — choosing where the version lives before you plan its retirement
- API Changelog Automation — the out-of-band human channel that must agree with your in-band dates
- RFC 7807 Problem+JSON Implementation — the error envelope your
410 Goneresponse body should reuse