# How to respond to a Sunset header in your client

> Read Sunset, Deprecation, and the Link relations off every response, then open one ticket per endpoint per day with the days remaining and the successor.

Source: https://voxgig.com/howto/respond-to-a-sunset-header

- Audience: platform-team
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-19

## Short answer

Attach a response hook to the client that reads the `Sunset` header, the `Deprecation` header in both of its spellings, and the `Link` relations that name the documentation and the successor. Key each sighting by endpoint and day, raise one ticket the first time a key appears, with the days left until the sunset date, and count the rest. Nothing pages anyone: a sunset months away is a ticket, not an incident.

---
## You will need

Node 22 or later, with no packages to install: the hook uses the `Headers` class and the `fetch`
that ship with Node, and the provider is a `node:http` stand-in on 127.0.0.1. Verified 2026-10-21
against Node 22.22.0. The headers are [RFC 8594](https://www.rfc-editor.org/rfc/rfc8594.html) for
`Sunset` and [RFC 9745](https://www.rfc-editor.org/rfc/rfc9745.html) for `Deprecation`, which
also registers the `deprecation` link relation. The dates the stand-in sends are fixed and the
clock is injected, so every number below reproduces.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Status page subscriptions](https://support.atlassian.com/statuspage/docs/enable-subscribers/) | Outages, degraded service, and maintenance windows, delivered as they happen | A channel built for incidents, so a retirement announced a year ahead is not what it carries, and every subscriber is a person | A planned retirement, which is the case this page is about |
| [Sunset and Deprecation response headers](https://www.rfc-editor.org/rfc/rfc9745.html) | Providers that send them, where the signal arrives on the exact endpoint you call, with no person in the loop | A hook in every client, a parser for two date formats, and silence from the many providers that never send the headers | The provider's changelog is the only place its retirements are announced |
| [Vendor changelog monitoring](https://github.blog/changelog/) | Every provider, because every provider has one, including those that send no header | A feed or a page per vendor to read, written for people, with the retirement buried among feature notes | You call ten providers, so ten feeds would need a reader each |

The headers are the only signal that arrives on the wire, in the response to the call that will
break, with no person in the path. They are also optional, so a provider's discipline is their
ceiling, and a hook that hears nothing has learned nothing. GitHub sends both.[^1] A changelog
feed covers every provider at the cost of a reader per feed and a retirement note buried among
features. A status page tells you the endpoint is down on the day, which is when the ticket
should already be closed.

## Read both headers in both spellings

`Sunset` is an HTTP-date. `Deprecation` is a Structured Field Date under RFC 9745, an `@`
followed by seconds since the epoch.[^2] In the responses GitHub sends it is an HTTP-date, which
its [API versioning documentation](https://docs.github.com/en/rest/about-the-rest-api/api-versions)
formats per RFC 7231. A client that reads one spelling misses the other, so the parser tries
the `@` form first and then the three HTTP-date forms of
[RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#name-date-time-formats), each read as
UTC. It returns null, never a guess, for anything else, including a day the month does not have.

```js title="signals.mjs"
/** A Date, or null when the value is not a date in either spelling. */
export function parseDeprecationDate(value) {
  if (value == null) return null
  const text = String(value).trim()
  if (!/^@-?\d+$/.test(text)) return parseHttpDate(text)
  const date = new Date(Number(text.slice(1)) * 1000)
  return Number.isNaN(date.getTime()) ? null : date
}

/** A Date, or null for anything but the three HTTP-date forms, a day the month lacks included. */
export function parseHttpDate(value) {
  if (value == null) return null
  const parts = HTTP_DATES.map((form) => form.exec(String(value).trim())?.groups).find(Boolean)
  if (!parts) return null
  const month = MONTHS.indexOf(parts.month)
  const day = Number(parts.day)
  const [hour, minute, second] = parts.time.split(':').map(Number)
  let year = Number(parts.year)
  if (parts.year.length === 2) {
    // RFC 9110: a two-digit year more than 50 years ahead belongs to the century before.
    const thisYear = new Date().getUTCFullYear()
    year += Math.floor(thisYear / 100) * 100
    if (year > thisYear + 50) year -= 100
  }
  const date = new Date(Date.UTC(year, month, day, hour, minute, second))
  const exact = month >= 0 && date.getUTCDate() === day && hour < 24 && minute < 60 && second <= 60
  return exact ? date : null
}
```

The `Link` header carries the rest. `deprecation` points at the documentation, `sunset` at the
retirement policy, and `successor-version` or `latest-version` at what to call instead,[^3] all
listed in the [IANA link relations registry](https://www.iana.org/assignments/link-relations).
`readSignals` folds the three headers into one object, or null when the response says nothing
about its own retirement. It also flags a sunset earlier than the deprecation date, which RFC 9745
tells you to query with the provider rather than trust.

```js title="signals.mjs"
/** Everything the response says about its own retirement, or null when it says nothing. */
export function readSignals(headers) {
  const get = (name) => (typeof headers.get === 'function' ? headers.get(name) : headers[name])
  const deprecation = parseDeprecationDate(get('deprecation'))
  const sunset = parseHttpDate(get('sunset'))
  const links = parseLinks(get('link'))
  const deprecatedWithoutDate = get('deprecation') != null && deprecation === null
  if (!deprecation && !sunset && !deprecatedWithoutDate && !links.deprecation && !links.sunset) return null
  return {
    deprecation,
    deprecatedWithoutDate,
    sunset,
    docs: links.deprecation ?? null,
    policy: links.sunset ?? null,
    successor: links['successor-version'] ?? links['latest-version'] ?? null,
    // RFC 9745 section 4: a sunset before the deprecation date is a provider's mistake to query.
    inconsistent: Boolean(deprecation && sunset && sunset < deprecation),
  }
}
```

## Key the sighting by endpoint and day, then raise once

A deprecated endpoint answers every call with the same headers, so a hook that alerts per
response alerts per request. The watch keys each sighting by the endpoint, with ids collapsed,
and by the UTC day, and it raises on the first key it has not seen. The ticket carries the two
dates, the days remaining computed from an injected clock, and the successor and documentation
links, so whoever picks it up has the migration's facts in one place.

```js title="watch.mjs"
  function observe(method, url, headers) {
    stats.responses++
    const signals = readSignals(headers)
    if (!signals) return null
    stats.signalled++
    const today = now()
    const endpoint = endpointOf(method, url)
    const key = `${endpoint}|${today.toISOString().slice(0, 10)}`
    if (seen.has(key)) {
      stats.suppressed++
      return null
    }
    seen.add(key)
    stats.raised++
    const ticket = {
      endpoint,
      deprecatedOn: signals.deprecation?.toISOString().slice(0, 10) ?? (signals.deprecatedWithoutDate ? 'unknown' : null),
      sunsetOn: signals.sunset?.toISOString().slice(0, 10) ?? null,
      daysLeft: signals.sunset ? Math.ceil((signals.sunset - today) / DAY) : null,
      successor: signals.successor,
      docs: signals.docs,
      inconsistent: signals.inconsistent,
    }
    // A tracker that is down must not fail the request that carried the header, or the process:
    // the error goes to onError, and the next day's sighting raises the ticket again.
    try {
      Promise.resolve(raise(ticket)).catch(onError)
    } catch (err) {
      onError(err)
    }
    return ticket
  }
```

`raise` is yours: an issue tracker's API, a chat message, or a log line a dashboard counts. A
`raise` that throws or rejects goes to `onError`, which logs it by default, never to the request
that carried the header. `now` is a function so the tests can move the clock. Run the demo, a
day of traffic against the stand-in provider, then the morning after:

```bash
node demo.mjs
```

```text output
responses 300, carrying a signal 225, tickets 2, suppressed 223

ticket  GET /v1/charges/{id}
  deprecated on 2026-01-01, sunset on 2027-02-28, 132 days left
  successor /v2/payments/ch_8gk2m1, docs https://developer.example.com/deprecations/charges
ticket  GET /v1/legacy/reports
  deprecated on 2026-01-01, sunset on 2027-04-30, 193 days left
  successor none announced, docs none linked

next day: 2 tickets, 131 days left and 192 days left
```

Of three hundred responses, 225 carried a signal, and two became tickets. The second ticket shows
the GitHub spelling parsed into the same shape as the first, with no successor because that
endpoint linked none. The next day raises both again with one day less, which is the reminder a
ticket tracker would otherwise never give you.

## Wire the hook into the client you have

The hook is one function, `observe(method, url, headers)`, and it attaches wherever the client
exposes a response. For `fetch`, wrap it:

```js title="watch.mjs"
  /** A fetch with the hook attached, for a client that lets you pass one in. */
  function wrapFetch(fetchImpl = globalThis.fetch) {
    return async (input, init = {}) => {
      const response = await fetchImpl(input, init)
      const method = (init.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase()
      // A Request carries its URL in .url; a string or a URL object is its own URL.
      observe(method, input instanceof Request ? input.url : String(input), response.headers)
      return response
    }
```

Octokit exposes [`octokit.hook.after('request', ...)`](https://github.com/octokit/core.js#hooks),
which receives the response and the request options, so
`observe(options.method, options.url, response.headers)` is the whole integration. A generated
SDK with a response middleware stage takes the same line. A client that exposes neither a hook
nor a transport option is a client you cannot watch, a gap that
[checking transport control before adoption](/howto/check-transport-control-in-a-vendor-sdk)
exists to find.

## Check it worked

The suite covers both `Deprecation` spellings, the `Link` relations, and the null for a response
with no signal. It also covers the inconsistent-dates flag, the one-ticket-per-day rule with its
day rollover, two endpoints giving two tickets, and the endpoint key. The last four cover an
out-of-range date, the HTTP-date forms read as UTC, a `URL` object passed to `fetch`, and a
`raise` that fails.

```js title="sunset.test.mjs"
  let clock = NOW
  const tickets = []
  const watch = createSunsetWatch({ now: () => clock, raise: (t) => tickets.push(t) })
  for (const id of ['ch_8gk2m1', 'ch_9zq0p4', 'ch_8gk2m1']) watch.observe('GET', `https://api.example.com/v1/charges/${id}`, rfcStyle)
  assert.equal(tickets.length, 1)
  assert.deepEqual(tickets[0], {
    endpoint: 'GET /v1/charges/{id}',
    deprecatedOn: '2026-01-01',
    sunsetOn: '2027-02-28',
    daysLeft: 132,
    successor: '/v2/payments/ch_1',
    docs: 'https://developer.example.com/deprecations',
    inconsistent: false,
  })
  assert.deepEqual(watch.stats, { responses: 3, signalled: 3, raised: 1, suppressed: 2 })

  clock = new Date('2026-10-20T00:30:00Z')
  watch.observe('GET', 'https://api.example.com/v1/charges/ch_8gk2m1', rfcStyle)
  assert.equal(tickets.length, 2, 'a new day is a new ticket')
  assert.equal(tickets[1].daysLeft, 131)
})
```

```bash
node --test sunset.test.mjs
```

```text output
1..11
# tests 11
# suites 0
# pass 11
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 124.480738
```

`# pass 11` is the result. The one-per-day test asserts the exact ticket, `daysLeft: 132` from a
fixed clock, and that the day after produces a second ticket with `131`.

## When it goes wrong

The on-call engineer is paged 750 times before lunch and mutes the alert for good. The first
version of the hook alerted on every response that carried the header, and a deprecated endpoint
carries it on every response. The pitfall script runs that hook and the watch over the same
thousand requests:

```bash
node pitfall.mjs
```

```text output
1000 responses, 750 of them carrying a deprecation signal
  alert on every response:         750 pages to the on-call
  one ticket per endpoint per day: 2 tickets
```

That is 750 pages for two facts. Deduplicate by endpoint and day before the first alert goes out,
and route the result to a ticket queue rather than a pager. A sunset months away is work to
schedule, not an incident.

The ticket says `deprecated on unknown`. The provider sent a `Deprecation` value that is neither
spelling, `true` for instance, or a date in a format neither parser reads. The hook keeps the
sighting, since a deprecation with no date is still a deprecation,[^4] and the ticket says so. The
date is a question for the documentation the `deprecation` link points at.

Every request opens its own ticket, because the URL carries an id. The key has to name the
endpoint rather than the request. `defaultEndpoint` collapses numeric, hex, UUID, and prefixed
ids to `{id}`, and an SDK that knows its operation names should pass them in as `endpointOf`
instead.

## When not to do this

Do not read a missing header as a healthy endpoint. The headers are optional, and a provider that
sends neither has told you nothing, so the hook complements the changelog and the version pin
rather than replacing them. [Pinning the API version](/howto/pin-an-api-version-from-a-client) is
the other half of the arrangement.

Do not act on the header automatically. A `successor-version` link is a hint for the engineer who
picks up the ticket, not a redirect to follow in production. A changed request or response shape
is usually the reason the successor exists.

Do not page anyone. The pitfall script is the whole argument: a retirement announced months ahead
is planning work. The hook's job is to open that work once and keep it open, with the days
remaining visible, until someone closes it by migrating.

Do not key the ticket on the request. Collapse the ids, or pass the operation name, or one busy
endpoint will open a ticket per customer.

## Related how-tos

- [Pin an API version from a client](/howto/pin-an-api-version-from-a-client)

- [Run a changelog channel developers read in time](/howto/run-a-changelog-channel-developers-read)

- [Check whether a vendor SDK lets you control the transport](/howto/check-transport-control-in-a-vendor-sdk)

## Last verified

Verified 2026-10-21 against Node 22.22.0. Every output block is what the command preceding it
printed. The provider is a `node:http` stand-in on 127.0.0.1 with fixed dates, and the clock in
the demo and the tests is injected, so the days remaining are arithmetic rather than today's
date. GitHub's header format was read from its documentation, not from a live response.

[^1]: GitHub's [API versioning documentation](https://docs.github.com/en/rest/about-the-rest-api/api-versions)
says a version approaching the end of its support window answers with `Deprecation` formatted as
an HTTP date per RFC 7231 and `Sunset` per RFC 8594. Requests after the sunset date return 410
Gone. The header's own RFC and a large sender therefore disagree on how to write a
date, and a parser that honors only the RFC misses the sender.

[^2]: The two headers carry two date formats because of when they were written.
[RFC 8594](https://www.rfc-editor.org/rfc/rfc8594.html) gave `Sunset` an HTTP-date in May 2019.
[RFC 9651](https://www.rfc-editor.org/rfc/rfc9651.html) added a Date type to Structured Fields in
September 2024, an `@` prefix and integer seconds since 1970.
[RFC 9745](https://www.rfc-editor.org/rfc/rfc9745.html) used it for `Deprecation` in March 2025,
with the first document's author as one of its two. RFC 9745 says only that `Sunset` differs for
historical reasons. The history is six years long, and a client parser is where the two formats
meet.

[^3]: `successor-version` and `latest-version` come from
[RFC 5829](https://www.rfc-editor.org/rfc/rfc5829.html), Link Relation Types for Simple Version
Navigation between Web Resources, an Informational document from April 2010. It was written for
version control over web resources, with past versions and working copies in mind. An API retiring one
version in favor of the next is a thin reading of that vocabulary, and it fits well enough that
nobody has registered a relation of their own.

[^4]: [RFC 8594](https://www.rfc-editor.org/rfc/rfc8594.html#section-1.4) separates deprecation
into two stages, no longer recommended and then decommissioned, and reserves `Sunset` for the
second. The header is not appropriate for the first stage, it says, because the API remains
operational. The first stage went without a header of its own for six years, until `Deprecation`
arrived to fill it. A sighting with a deprecation date and no sunset date is therefore still
worth a ticket.