Implementing the Sunset HTTP Header (RFC 8594)
This guide sits under Sunset & Deprecation Headers within the API Versioning & Deprecation reference, and walks the full lifecycle of retiring an endpoint over HTTP: announcing deprecation, scheduling a Sunset date, pointing clients at a successor, and finally returning 410 Gone with a structured body. The signal is only useful if it is machine-readable, correctly formatted, and paired with client code that actually reacts to it — most teams get the header syntax subtly wrong and the countdown is silently ignored.
When You Need This
You reach for Sunset the moment you commit to removing something a client depends on. The trigger is a concrete decision, not a vague plan:
| Symptom / decision trigger | What the header must communicate |
|---|---|
| You are deleting a route in a future release | The exact instant the route stops answering |
A v1 resource is superseded by v2 |
Where the replacement lives (successor-version) |
| Clients keep integrating against a doomed field | That the resource is already deprecated |
| A partner asks “how long do I have?” | A single authoritative date, machine-readable |
If any of these apply, encode the answer in headers rather than a changelog note, so SDKs and gateways can act on it without a human reading release notes. Deprecation planning also feeds directly into how you communicate the change downstream — see Generating API Changelogs from OpenAPI Diffs for automating the human-readable side, and error responses should follow the RFC 7807 Problem+JSON Implementation format once the endpoint is gone.
The Exact Header Syntax
RFC 8594 defines a single response header, Sunset, whose value is an HTTP-date as defined by RFC 7231 §7.1.1.1. That production is the IMF-fixdate: a fixed-length, English, GMT-anchored timestamp. Everything about it is rigid.
HTTP/1.1 200 OK
Content-Type: application/json
Deprecation: true
Sunset: Sat, 31 Oct 2026 23:59:59 GMT
Link: <https://api.example.com/v2/orders>; rel="successor-version",
<https://developer.example.com/deprecation-policy>; rel="sunset"
Three headers cooperate here:
Deprecation(from the Deprecation HTTP header draft) — value is either the boolean tokentrue, meaning “deprecated as of now”, or an IMF-fixdate marking when deprecation started. It answers is this deprecated?Sunset(RFC 8594) — a single IMF-fixdate answering when does it stop working? There is at most oneSunsetvalue per response.Linkwithrel="successor-version"(RFC 5988 / 8288) points to the replacement resource;rel="sunset"points to a human-readable policy page explaining the retirement.
The date format is where implementations break. Sunset must be exactly Sat, 31 Oct 2026 23:59:59 GMT — the weekday and month are three-letter English abbreviations, the day is zero-padded, and the zone is the literal token GMT. ISO 8601 (2026-10-31T23:59:59Z) is invalid here, as is any numeric offset like +00:00.
Server Implementation
Step 1 — Format an IMF-fixdate correctly
Do not hand-roll the string. Both runtimes have primitives that emit the exact format.
Node (Express, TypeScript):
// The native toUTCString() emits an RFC 7231 IMF-fixdate:
// "Sat, 31 Oct 2026 23:59:59 GMT" — exactly what Sunset requires.
export function imfFixdate(d: Date): string {
return d.toUTCString();
}
const SUNSET_AT = new Date(Date.UTC(2026, 9, 31, 23, 59, 59)); // month is 0-based
Python (FastAPI / Starlette):
from email.utils import format_datetime
from datetime import datetime, timezone
# format_datetime with usegmt=True produces the GMT token, not +00:00.
def imf_fixdate(dt: datetime) -> str:
return format_datetime(dt.astimezone(timezone.utc), usegmt=True)
SUNSET_AT = datetime(2026, 10, 31, 23, 59, 59, tzinfo=timezone.utc)
Python’s strftime("%a, %d %b %Y %H:%M:%S GMT") is locale-dependent and will emit non-English weekday names under a non-C locale — format_datetime(..., usegmt=True) is locale-safe and correct.
Step 2 — Emit the headers while the endpoint still works
Before the date arrives the endpoint keeps serving 200, but every response carries the countdown.
// Express middleware attached to the deprecated router
import { Request, Response, NextFunction } from 'express';
const SUCCESSOR = 'https://api.example.com/v2/orders';
const POLICY = 'https://developer.example.com/deprecation-policy';
export function sunsetHeaders(req: Request, res: Response, next: NextFunction) {
res.setHeader('Deprecation', 'true');
res.setHeader('Sunset', SUNSET_AT.toUTCString());
res.setHeader(
'Link',
`<${SUCCESSOR}>; rel="successor-version", <${POLICY}>; rel="sunset"`,
);
next();
}
Python equivalent:
from starlette.requests import Request
from starlette.responses import Response
SUCCESSOR = "https://api.example.com/v2/orders"
POLICY = "https://developer.example.com/deprecation-policy"
async def sunset_middleware(request: Request, call_next):
response: Response = await call_next(request)
response.headers["Deprecation"] = "true"
response.headers["Sunset"] = imf_fixdate(SUNSET_AT)
response.headers["Link"] = (
f'<{SUCCESSOR}>; rel="successor-version", '
f'<{POLICY}>; rel="sunset"'
)
return response
Step 3 — Return 410 Gone after the date
Once now >= SUNSET_AT, the resource is retired. Respond 410 Gone and carry the reason in an application/problem+json body so the machine-readable explanation is not lost. 410 (not 404) tells caches and clients that the removal is intentional and permanent, so they should stop retrying.
import { Request, Response, NextFunction } from 'express';
export function enforceSunset(req: Request, res: Response, next: NextFunction) {
if (Date.now() < SUNSET_AT.getTime()) return next();
res.status(410)
.type('application/problem+json')
.setHeader('Sunset', SUNSET_AT.toUTCString())
.setHeader('Link', `<${SUCCESSOR}>; rel="successor-version"`)
.json({
type: 'https://api.example.com/problems/endpoint-sunset',
title: 'Endpoint retired',
status: 410,
detail: 'GET /v1/orders was retired on 2026-10-31. Use /v2/orders.',
successor: SUCCESSOR,
});
}
from starlette.responses import JSONResponse
def gone_response() -> JSONResponse:
return JSONResponse(
status_code=410,
media_type="application/problem+json",
headers={
"Sunset": imf_fixdate(SUNSET_AT),
"Link": f'<{SUCCESSOR}>; rel="successor-version"',
},
content={
"type": "https://api.example.com/problems/endpoint-sunset",
"title": "Endpoint retired",
"status": 410,
"detail": "GET /v1/orders was retired on 2026-10-31. Use /v2/orders.",
"successor": SUCCESSOR,
},
)
The type URI and problem shape follow the conventions covered in RFC 7807 Problem+JSON Implementation; keep the same registry of type URIs you use for every other error.
Deprecation Timeline
OpenAPI 3.1 Documentation
Document the deprecation in the contract so it propagates into generated docs and SDKs. OpenAPI has a first-class deprecated: true flag, and you declare the response headers explicitly.
openapi: 3.1.0
info:
title: Orders API
version: "1.7.0"
paths:
/v1/orders:
get:
summary: List orders (deprecated)
deprecated: true
responses:
"200":
description: Orders page. Deprecated; migrate to /v2/orders.
headers:
Sunset:
description: RFC 8594 retirement date (IMF-fixdate, GMT).
schema:
type: string
example: "Sat, 31 Oct 2026 23:59:59 GMT"
Deprecation:
description: RFC deprecation marker.
schema:
type: string
example: "true"
"410":
description: Endpoint retired past its Sunset date.
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
components:
schemas:
Problem:
type: object
properties:
type: { type: string }
title: { type: string }
status: { type: integer }
detail: { type: string }
successor: { type: string }
Client Detection
The header is worthless if nobody parses it. Every SDK should inspect Sunset and Deprecation on each response and surface an active countdown to operators — a log line, a metric, or a CI warning.
// Runs on every response in a fetch wrapper
export function checkSunset(res: Response): void {
const sunset = res.headers.get('Sunset');
const deprecation = res.headers.get('Deprecation');
if (!sunset && !deprecation) return;
const when = sunset ? new Date(sunset) : null; // toUTCString round-trips
const days = when
? Math.ceil((when.getTime() - Date.now()) / 86_400_000)
: null;
const link = res.headers.get('Link') ?? '';
const successor = /<([^>]+)>;\s*rel="successor-version"/.exec(link)?.[1];
console.warn(
`[deprecation] ${res.url} sunsets in ${days ?? '?'} day(s).` +
(successor ? ` Successor: ${successor}` : ''),
);
}
from email.utils import parsedate_to_datetime
from datetime import datetime, timezone
def check_sunset(headers) -> None:
sunset = headers.get("Sunset")
if not sunset and not headers.get("Deprecation"):
return
when = parsedate_to_datetime(sunset) if sunset else None
days = (when - datetime.now(timezone.utc)).days if when else None
print(f"[deprecation] endpoint sunsets in {days} day(s)")
Caching and Safety Implications
Sunset is a response header, so it is subject to the usual caching rules. Two things matter:
- A cached
200can outlive the sunset instant. If you serve deprecated responses withCache-Control: max-age=3600, a client may hold a fresh copy for up to an hour past the retirement moment. Shortenmax-ageon deprecated routes as the date approaches, or addCache-Control: no-storein the final days. 410 Goneis cacheable by default under RFC 9110 §15.5.11. That is usually desirable — you want intermediaries to remember the endpoint is gone — but include an explicitCache-Controlso the behavior is intentional rather than accidental.
Because GET remains safe and idempotent throughout, retries during the deprecation window are harmless; the retirement changes the status code, not the method semantics.
RFC and Standard Alignment
| Standard | Role in this implementation |
|---|---|
| RFC 8594 | Defines the Sunset response header and its HTTP-date value |
| RFC 7231 §7.1.1.1 | Defines the IMF-fixdate format Sunset must use |
| RFC 8288 | Defines the Link header and the successor-version / sunset relations |
| RFC 9110 §15.5.11 | 410 Gone semantics for a permanently removed resource |
| RFC 9457 (obsoletes 7807) | application/problem+json body returned after retirement |
SDK and Codegen Downstream Effect
The deprecated: true flag in the OpenAPI document flows straight into generated clients — most generators emit a @deprecated annotation, which surfaces as an editor strikethrough and a compiler or linter warning at the call site.
// Emitted by openapi-generator / openapi-typescript from deprecated: true
export class OrdersApi {
/**
* List orders.
* @deprecated Retires 2026-10-31 (Sunset). Use ordersV2.list().
*/
listOrders(params: ListOrdersParams): Promise<OrdersPage> {
/* ... */
}
}
Wire the checkSunset handler into the generated client’s response pipeline so the runtime warning and the compile-time @deprecated annotation reinforce each other. That combination is what actually drives migrations — a header nobody reads changes nothing.
Common Mistakes
| Mistake | Correct approach |
|---|---|
Emitting Sunset: 2026-10-31T23:59:59Z (ISO 8601) |
Use IMF-fixdate: Sat, 31 Oct 2026 23:59:59 GMT |
Using +00:00 instead of the GMT token |
The zone is always the literal GMT |
strftime under a non-C locale (localized weekday) |
Use format_datetime(dt, usegmt=True) / toUTCString() |
Returning 404 after the sunset date |
Return 410 Gone for intentional, permanent removal |
| Sending the header but shipping no client handling | Parse Sunset/Deprecation in the SDK and alert |
FAQ
What date format does the Sunset header require?
RFC 8594 reuses the HTTP-date production from RFC 7231, so the Sunset value must be an IMF-fixdate: a fixed-length string like Sat, 31 Oct 2026 23:59:59 GMT. The timezone is always the literal token GMT, never an offset such as +00:00, and the day-of-week and month are English three-letter abbreviations. ISO 8601 values are not valid. Use Date.toUTCString() in Node or email.utils.format_datetime(dt, usegmt=True) in Python to generate it safely.
What is the difference between the Deprecation and Sunset headers?
Deprecation signals that a resource is deprecated now — its value is either the boolean token true or an IMF-fixdate marking when deprecation began. Sunset signals a future point in time at which the resource is expected to become unresponsive. Deprecation describes the present state; Sunset describes a scheduled end date. They are independent headers and are typically sent together so a client learns both that a resource is deprecated and exactly how long it has.
What status code should I return after the Sunset date passes?
Return 410 Gone when the retirement is permanent and you want caches and clients to stop retrying. Pair it with an application/problem+json body so the machine-readable reason travels with the response, and keep the Sunset and Link headers on that 410 for clients that only inspect the final response. Use 404 only if you genuinely cannot confirm the resource ever existed; 410 is the correct, intentional signal for an endpoint you deliberately removed.
Related
- Sunset & Deprecation Headers — the section framing header-driven deprecation signalling
- API Versioning & Deprecation — the parent reference covering versioning schemes and retirement policy
- Generating API Changelogs from OpenAPI Diffs — automate the human-readable announcement that accompanies a sunset
- Semver Ranges for Generated SDK Clients — how SDK consumers pin versions through a deprecation window
- RFC 7807 Problem+JSON Implementation — the structured body format for the
410 Goneresponse