Deprecation and Sunset headers: a practical guide
Two HTTP response headers tell API consumers that an endpoint is going away.
Deprecation
says when it stopped being recommended. Sunset
says when it will stop working.
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.
The headers at a glance
| 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.
Rules that trip people up
-
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.
Adding 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.
Reading the headers as a consumer
If you call other people's APIs, a few lines in your HTTP client turn these headers into a warning in your own logs, 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.
Checking your own API
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.
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.