Deprecation and Sunset headers: a practical guide
The Deprecation
header (RFC 9745) tells API consumers when an endpoint stopped being recommended, and the
Sunset
header (RFC 8594) tells them when it will stop working. Send both on every response from the
deprecated endpoint, with a Link
to the migration guide: Deprecation
as @
plus Unix seconds, Sunset
as an HTTP-date in GMT.
They beat an email for one reason: they reach whoever is running the code today. The engineer who built the integration may be gone, but every response still carries the date. This guide covers the exact syntax, the mistakes that make the headers useless, and copy-paste code for five frameworks.
What do the Deprecation and Sunset headers mean?
Deprecation
gives the time an endpoint stopped being recommended, and Sunset
gives the time it is expected to stop responding. Both are registered in the
IANA HTTP Field Name Registry, and their
Link
relations in the
IANA Link Relations registry:
| Header | Defined in | Format | Means |
|---|---|---|---|
Deprecation |
RFC 9745 (2025) | Structured-field date: @ + Unix seconds |
Deprecated as of this time. May be in the future. |
Sunset |
RFC 8594 (2019) | HTTP-date: Wed, 30 Jun 2027 23:59:59 GMT |
Expected to stop responding at this time. |
Link; rel="deprecation" |
RFC 9745 | URL | Human-readable deprecation or migration docs. |
Link; rel="sunset" |
RFC 8594 | URL | The retirement policy or migration plan. |
A deprecated endpoint with a retirement date sends them together:
HTTP/1.1 200 OK
Deprecation: @1798761599
Sunset: Wed, 30 Jun 2027 23:59:59 GMT
Link: <https://api.example.com/docs/migrate-to-v2>; rel="deprecation"; type="text/html"
Yes, the two dates use different formats. RFC 9745 says this is "for historical reasons":
Sunset
came first and used the classic HTTP-date.
What mistakes make the headers useless?
Mostly wrong date formats and headers missing from some responses. The full list:
-
Two date formats.
Deprecationis@plus Unix seconds.Sunsetis an HTTP-date in GMT. Mixing them up is the most common bug, and a strict client parser will ignore the header. -
Sunset can't come before deprecation.
RFC 9745 says the
Sunsettime must not be earlier than theDeprecationtime. - A past Sunset means "any moment now." RFC 8594 tells clients to treat a past date as the present. If you extend a deadline, update the header too.
- Send them on every response. Errors included. A client that only sees the header on some responses can't rely on it.
-
Headers apply to the resource that returns them.
If you mean the whole of
v1, send them from everyv1route, and say so on the linked page. -
Expose them to browsers.
For a CORS API, add both to
Access-Control-Expose-Headers, or front-end clients can't read them. -
After the sunset, return
410 Gone. Keep theLinkheader so the last straggler finds the migration guide from the error alone. See 410 Gone vs 404 for retired endpoints.
How do you add the headers in your framework?
Each example marks everything under /v1
as deprecated at the end of 2026, with a sunset on 30 June 2027. Set the headers before the
response is sent, so error responses carry them too.
Rails (a concern you call per controller):
# app/controllers/concerns/deprecated_endpoint.rb
module DeprecatedEndpoint
extend ActiveSupport::Concern
class_methods do
def deprecated(deprecated_at:, sunset_at:, link:, **options)
before_action(**options) do
response.headers["Deprecation"] = "@#{deprecated_at.to_i}"
response.headers["Sunset"] = sunset_at.httpdate
response.headers["Link"] = %(<#{link}>; rel="deprecation"; type="text/html")
end
end
end
end
# app/controllers/v1/users_controller.rb
class V1::UsersController < ApplicationController
include DeprecatedEndpoint
deprecated deprecated_at: Time.utc(2026, 12, 31, 23, 59, 59),
sunset_at: Time.utc(2027, 6, 30, 23, 59, 59),
link: "https://api.example.com/docs/migrate-to-v2"
end
Express:
function deprecated({ deprecatedAt, sunsetAt, link }) {
return (req, res, next) => {
res.set("Deprecation", `@${Math.floor(deprecatedAt.getTime() / 1000)}`);
res.set("Sunset", sunsetAt.toUTCString()); // "Wed, 30 Jun 2027 23:59:59 GMT"
res.append("Link", `<${link}>; rel="deprecation"; type="text/html"`);
next();
};
}
app.use("/v1", deprecated({
deprecatedAt: new Date("2026-12-31T23:59:59Z"),
sunsetAt: new Date("2027-06-30T23:59:59Z"),
link: "https://api.example.com/docs/migrate-to-v2",
}));
Phoenix (a plug in the /v1 pipeline):
defmodule MyAppWeb.Plugs.Deprecated do
import Plug.Conn
def init(opts), do: opts
def call(conn, opts) do
conn
|> put_resp_header("deprecation", "@#{DateTime.to_unix(opts[:deprecated_at])}")
|> put_resp_header("sunset", Calendar.strftime(opts[:sunset_at], "%a, %d %b %Y %H:%M:%S GMT"))
|> put_resp_header("link", ~s(<#{opts[:link]}>; rel="deprecation"; type="text/html"))
end
end
# router.ex
pipeline :api_v1 do
plug MyAppWeb.Plugs.Deprecated,
deprecated_at: ~U[2026-12-31 23:59:59Z],
sunset_at: ~U[2027-06-30 23:59:59Z],
link: "https://api.example.com/docs/migrate-to-v2"
end
FastAPI:
from datetime import datetime, timezone
from email.utils import format_datetime
DEPRECATED_AT = datetime(2026, 12, 31, 23, 59, 59, tzinfo=timezone.utc)
SUNSET_AT = datetime(2027, 6, 30, 23, 59, 59, tzinfo=timezone.utc)
MIGRATION_DOCS = "https://api.example.com/docs/migrate-to-v2"
@app.middleware("http")
async def deprecation_headers(request, call_next):
response = await call_next(request)
if request.url.path.startswith("/v1/"):
response.headers["Deprecation"] = f"@{int(DEPRECATED_AT.timestamp())}"
response.headers["Sunset"] = format_datetime(SUNSET_AT, usegmt=True)
response.headers.append("Link", f'<{MIGRATION_DOCS}>; rel="deprecation"; type="text/html"')
return response
Spring Boot:
@Component
public class DeprecationHeaderFilter extends OncePerRequestFilter {
private static final Instant DEPRECATED_AT = Instant.parse("2026-12-31T23:59:59Z");
private static final Instant SUNSET_AT = Instant.parse("2027-06-30T23:59:59Z");
private static final DateTimeFormatter HTTP_DATE = DateTimeFormatter
.ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.ENGLISH)
.withZone(ZoneOffset.UTC);
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().startsWith("/v1/");
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
res.setHeader("Deprecation", "@" + DEPRECATED_AT.getEpochSecond());
res.setHeader("Sunset", HTTP_DATE.format(SUNSET_AT));
res.addHeader("Link", "<https://api.example.com/docs/migrate-to-v2>; rel=\"deprecation\"; type=\"text/html\"");
chain.doFilter(req, res);
}
}
In Spring, prefer the explicit pattern over RFC_1123_DATE_TIME. That formatter
drops the leading zero on single-digit days, which the HTTP-date format requires.
How do you read the headers as a consumer?
Check every response for them and log a warning when either is present. A few lines in your HTTP client do it, months before anything breaks:
async function apiFetch(url, options) {
const res = await fetch(url, options);
const deprecation = res.headers.get("Deprecation");
const sunset = res.headers.get("Sunset");
if (deprecation || sunset) {
logger.warn({
event: "api_deprecated",
url,
deprecated_at: deprecation?.startsWith("@")
? new Date(Number(deprecation.slice(1)) * 1000).toISOString()
: deprecation,
sunset_at: sunset ? new Date(sunset).toISOString() : null,
docs: res.headers.get("Link"),
});
}
return res;
}
Route that event to an alert, and the sunset date lands in front of the team that owns the integration, not in an inbox nobody reads.
How do you check your own API sends them correctly?
One curl shows whether the headers are there and well-formed:
curl -sI https://api.example.com/v1/users \
-H "Authorization: Bearer $TOKEN" \
| grep -iE '^(deprecation|sunset|link):'
Check three things in the output:
Deprecationstarts with@, followed by digits only.-
Sunsetends inGMT, and its date is no earlier than theDeprecationdate. -
The same headers appear on a
404or422from the same route, not just on a200.
Sources
- RFC 9745, The Deprecation HTTP Response Header Field (IETF, 2025), and its IETF Datatracker record.
- RFC 8594, The Sunset HTTP Header Field (IETF, 2019).
-
RFC 8288, Web Linking, which
defines the
Linkheader both use. - The IANA HTTP Field Name Registry and Link Relations registry.
- MDN's reference for Access-Control-Expose-Headers.
Where EndPointBlank fits
The code above works, but it hard-codes dates into your app. Extending a deadline for one slow customer means a code change and a deploy.
With EndPointBlank, you deprecate a version in the portal instead. From the next request,
the client library adds Deprecation
to every response from that version, and Sunset
once you set a date. Change the date and the headers change with it, with no deploy, in
Ruby, Python, JavaScript, Java or Elixir.
The same record shows which clients are still calling the version, and reminds them 30, 7 and 1 day before the sunset.
See how version lifecycle works or start free. For the whole retirement process, read how to deprecate an API version.