Guides

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. Deprecation is @ plus Unix seconds. Sunset is 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 Sunset time must not be earlier than the Deprecation time.
  • 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 every v1 route, 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 the Link header 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:

  • Deprecation starts with @, followed by digits only.
  • Sunset ends in GMT, and its date is no earlier than the Deprecation date.
  • The same headers appear on a 404 or 422 from the same route, not just on a 200.

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.

Ready to deprecate with confidence?

Sign up in under a minute. No credit card, no sales call.