Skip to main content

Implementing Sunset and Deprecation Headers

Problem: an endpoint is deprecated, the migration guide is written, an email went out — and six months later, traffic has not moved. The reason is almost always that the deprecation was announced to people while the calls are made by code whose original author has moved on. The Deprecation and Sunset response headers put the warning where the code can see it. This guide is part of API Versioning and Deprecation Policies, which covers the surrounding policy and the retirement decision.

Two RFCs define the pair: Sunset (RFC 8594) carries the date after which the resource stops responding, and Deprecation (RFC 9745) states that it is deprecated. A Link header with rel="deprecation" points at the guide. Together they turn a policy into something a client can detect, log, and alert on.

The Three Headers

HTTP/1.1 200 OK
Content-Type: application/json
Deprecation: @1767225600
Sunset: Mon, 01 Mar 2027 00:00:00 GMT
Link: <https://docs.example.com/v2-migration>; rel="deprecation"

Deprecation is a structured-field date — the @ prefix marks a Unix timestamp — giving the moment deprecation took effect. Sunset is an HTTP-date, the same format as Expires. The mismatch in formats is unfortunate and is the most common implementation bug; the two RFCs were written years apart.

Three headers, three distinct facts Deprecation states when the warning began, Sunset states when responses stop, and the Link header says where to read the migration guide. Each answers a different question a client or an operator will ask. Deprecation "since when?" @1767225600 a structured-field date RFC 9745 Sunset "until when?" Mon, 01 Mar 2027 00:00:00 GMT an HTTP-date, not ISO 8601 RFC 8594 Link "what do I do about it?" rel="deprecation" points at the migration guide RFC 8288

Step 1: Emit Them From Middleware

Set the headers on the router rather than in each handler, so no response path can miss them. The example uses Express 4.

// middleware/deprecation.ts
import type { RequestHandler } from "express";

export interface DeprecationOptions {
  deprecatedAt: Date;   // when the warning began
  sunsetAt: Date;       // when responses stop
  guide: string;        // absolute URL of the migration guide
}

export function deprecation(o: DeprecationOptions): RequestHandler {
  // Precompute: these never change between requests.
  const deprecatedAt = `@${Math.floor(o.deprecatedAt.getTime() / 1000)}`;
  const sunsetAt = o.sunsetAt.toUTCString();   // IMF-fixdate, exactly what RFC 8594 wants
  const link = `<${o.guide}>; rel="deprecation"`;

  return (_req, res, next) => {
    res.set("Deprecation", deprecatedAt);
    res.set("Sunset", sunsetAt);
    res.set("Link", link);
    next();          // headers are set before the handler writes anything
  };
}
// routes/v1.ts — applied once, to the whole deprecated surface
v1Router.use(deprecation({
  deprecatedAt: new Date("2026-08-01T00:00:00Z"),
  sunsetAt: new Date("2027-03-01T00:00:00Z"),
  guide: "https://docs.example.com/v2-migration",
}));

Why this works: setting the headers in middleware, before next(), means they are present on every response the router produces — successes, validation failures, and the problem documents your error handler emits.

Step 2: Declare Them in the Contract

Headers that exist only in code are invisible to generated documentation and to linters. Declare them on each deprecated operation:

paths:
  /orders/{id}:
    get:
      operationId: getOrderV1
      deprecated: true
      summary: Retrieve an order (deprecated — use v2)
      responses:
        '200':
          description: The order.
          headers:
            Deprecation:
              description: Structured-field date when deprecation took effect.
              schema: { type: string }
            Sunset:
              description: HTTP-date after which this operation returns 410.
              schema: { type: string }
            Link:
              description: Migration guide, rel="deprecation".
              schema: { type: string }

Step 3: Lint That the Pair Is Never Half-Applied

The failure worth preventing is an operation marked deprecated: true with no sunset date — a warning with no deadline, which consumers correctly ignore. A custom Spectral rule catches it:

# .spectral.yaml
extends: ["spectral:oas"]
rules:
  deprecated-needs-sunset:
    description: A deprecated operation must declare a Sunset response header.
    given: "$.paths[*][get,post,put,patch,delete][?(@.deprecated == true)].responses[*].headers"
    severity: error
    then:
      field: Sunset
      function: truthy
What the endpoint returns, before and after the sunset moment Before the sunset date the endpoint answers normally with all three headers attached; after it, the same request receives 410 Gone with the Link header retained so a lagging client can still find the guide. sunset moment 200 OK + Deprecation + Sunset + Link 410 Gone + Link clients keep working; the warning accumulates in their logs a diagnosable failure, not a 404 The route stays deployed for one more release after the date — long enough for the 410 to reach a human.

Step 4: Return 410 After the Date

Do not delete the route on the sunset date. Replace its handler, so a lagging client receives an intentional, explainable error rather than a 404 that looks like a bug on their side.

// After the sunset moment, the same path answers with a problem document.
v1Router.all("/orders/:id", (_req, res) => {
  res.status(410)
     .type("application/problem+json")
     .json({
       type: "https://docs.example.com/errors/version-retired",
       title: "API version retired",
       status: 410,
       detail: "v1 was retired on 2027-03-01. Use /v2/orders/{id}.",
     });
});

Keep this in place for at least one further release cycle before removing the route entirely.

Step 5: Make Clients Notice

A header nothing reads is a header nobody acts on. In a generated client, read it once in the response interceptor:

From a response header to an answerable question The interceptor reads the Sunset header on every response and emits a structured log line; the aggregated logs then answer which services still call the retiring endpoint, without a survey. response headers Sunset + Link interceptor one place, every call structured warning in the logs url, sunset, daysLeft, guide "which of our services still call it?" becomes a log query rather than a survey.
// client/interceptors.ts
client.use({
  onResponse({ response, request }) {
    const sunset = response.headers.get("Sunset");
    if (!sunset) return response;
    const daysLeft = Math.ceil((Date.parse(sunset) - Date.now()) / 86_400_000);
    // Log through the host app's logger so it reaches the same place as everything else.
    logger.warn({
      msg: "calling a deprecated API endpoint",
      url: request.url,
      sunset,
      daysLeft,
      guide: response.headers.get("Link"),
    });
    return response;
  },
});

Once this is in place, “which of our services still call the retiring endpoint?” becomes a log query rather than a survey.

Verification

# The three headers are present, in the right formats.
curl -sD - https://api.example.com/v1/orders/42 -o /dev/null | grep -iE '^(deprecation|sunset|link)'
# deprecation: @1767225600
# sunset: Mon, 01 Mar 2027 00:00:00 GMT
# link: <https://docs.example.com/v2-migration>; rel="deprecation"

# The Sunset value parses as an HTTP-date (an ISO string would fail here).
date -d "$(curl -sD - https://api.example.com/v1/orders/42 -o /dev/null \
  | grep -i '^sunset' | cut -d' ' -f2-)" +%s
# 1772323200

# They are present on failures too, not just on 200s.
curl -sD - https://api.example.com/v1/orders/does-not-exist -o /dev/null | grep -ci '^sunset'
# 1

Edge Cases and Caveats

  • Header stripping by intermediaries. Some gateways forward only an allowlist of response headers. Verify from outside the network; a deprecation signal that never leaves your perimeter is worse than none, because you will believe it was sent.
  • Sunset on a resource, not on a version. Under header-based versioning both versions share one URL, so emit the headers only when the request resolves to the deprecated version — otherwise you warn clients who already migrated.
  • Caching a deprecated response. A cached response carries its headers with it, including a Sunset date that keeps counting down. That is usually fine, but shorten the max-age as the date approaches so a client is not told it has ninety days when it has nine.
  • Timezone slips. Always build the date in UTC. A sunset built from a local-time string moves by an hour twice a year, which is enough to break an assertion in someone’s contract test.

What Consumers Should Do With the Headers

Publishing the headers is half the contract; the other half is telling consumers what to do when they see them, because most integrations have no convention for it.

Log, do not fail. A deprecated endpoint still works. A client that treats the Sunset header as an error breaks itself early for no benefit. The correct reaction is a warning through the application’s normal logging, with enough structure to aggregate.

Alert on the approach, not the arrival. A warning that fires on every call is noise by the second day. Alert once when the remaining window drops below a threshold you choose — thirty days is a reasonable default — and again at seven.

Surface it where the team already looks. A log line in a service nobody watches achieves nothing. Route the deprecation warning to the same channel as dependency vulnerability alerts, because it is the same class of problem: a dependency with a known end date.

Record it as work, not as information. The most reliable pattern is a scheduled job that reads the aggregated warnings and opens a ticket per deprecated endpoint, with the sunset date as the due date. That converts a passive signal into something a planning process can see.

Publishing this guidance alongside the headers costs a paragraph in the documentation and measurably shortens migrations, because most consumers have simply never decided what to do with a deprecation signal.

Frequently Asked Questions

What date format does the Sunset header use?

An HTTP-date, the same IMF-fixdate format used by Expires and Last-Modified — for example Mon, 01 Mar 2027 00:00:00 GMT. An ISO 8601 string is a common mistake and will be ignored or misparsed by well-behaved clients.

Should Deprecation be a boolean or a date?

RFC 9745 defines Deprecation as a structured-field date giving the moment deprecation took effect. Many existing implementations send the literal string true, which older tooling understands. Sending the date is the forward-looking choice; if you must support both, send the date and keep the documentation explicit.

Do I return a 410 after the sunset date, or a 404?

410 Gone. It tells the caller the resource existed and was intentionally removed, which is diagnostically different from 404, meaning it may never have existed. Keep returning the Link header pointing at the migration guide alongside the 410.

Should these headers appear on error responses too?

Yes. A client that only ever receives 4xx responses from a deprecated endpoint still needs the warning, and applying the headers in middleware before the handler runs is simpler than remembering to add them to each response path.

How do I stop client SDKs from swallowing the warning?

Read the headers in the generated client’s response interceptor and emit a runtime warning through the host application’s logger, not to standard output. Failing that, expose the sunset date on the response object so callers can assert on it in their own tests.