How-to › Operate integrations in production

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.

Audience
Platform team
Level
intermediate
Topic
Evaluate, adopt and upgrade SDKs
Languages
TypeScript and JavaScript
Verified

A provider announced the retirement of an endpoint six months ago, in the headers of every response your client received. The first you hear of it is the 410 Gone on the morning it stops answering. Nothing read the Sunset header. The announcement also went into a changelog that nobody on the team reads, and planned retirements do not appear on status pages.

What you get

You will end up with a response hook that turns deprecation headers into one ticket per endpoint per day, with the days remaining and the successor the provider named. This is for you if you operate integrations whose providers retire endpoints on a schedule.

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 for Sunset and RFC 9745 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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Status page subscriptionsOutages, degraded service, and maintenance windows, delivered as they happenA channel built for incidents, so a retirement announced a year ahead is not what it carries, and every subscriber is a personA planned retirement, which is the case this page is about
Sunset and Deprecation response headersProviders that send them, where the signal arrives on the exact endpoint you call, with no person in the loopA hook in every client, a parser for two date formats, and silence from the many providers that never send the headersThe provider’s changelog is the only place its retirements are announced
Vendor changelog monitoringEvery provider, because every provider has one, including those that send no headerA feed or a page per vendor to read, written for people, with the retirement buried among feature notesYou 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 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, each read as UTC. It returns null, never a guess, for anything else, including a day the month does not have.

/** 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. 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.

/** 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.

  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:

node demo.mjs
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:

  /** 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', ...), 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 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.

  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)
})
node --test sunset.test.mjs
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:

node pitfall.mjs
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 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.

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.

Footnotes

  1. GitHub’s API versioning documentation 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. ↩︎ Back to text

  2. The two headers carry two date formats because of when they were written. RFC 8594 gave Sunset an HTTP-date in May 2019. RFC 9651 added a Date type to Structured Fields in September 2024, an @ prefix and integer seconds since 1970. RFC 9745 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. ↩︎ Back to text

  3. successor-version and latest-version come from RFC 5829, 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. ↩︎ Back to text

  4. RFC 8594 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.