Deprecation Lifecycle Management

Retiring an API version is a routing problem before it is a code problem. The version’s endpoints still resolve, its consumers still send traffic, and the gateway is the one place that sees every call and can shape the retirement without touching upstream services. Done well, a deprecation is a quiet, scheduled sequence of signals — a header here, a brownout there, a final 410 — that consumers absorb on their own timeline. Done badly, it is a route deleted on a Friday that pages three teams by Saturday. This page sits within Advanced Routing & API Versioning and treats end-of-life as a first-class stage of the version’s life: how to announce, deprecate, sunset, and decommission a version at the gateway edge using the Deprecation and Sunset response headers from RFC 8594, brownout testing, traffic-shedding, and the consumer-usage telemetry that tells you when it is actually safe to pull the plug.

Deprecation is the mirror image of adoption. Where API versioning strategies decide how a new version is introduced, deprecation decides how the old one leaves — and the two must be co-designed, because the compatibility promises encoded in your backward-compatibility contracts are exactly what you are unwinding. A version cannot be deprecated faster than its consumers can migrate, and the gateway is the instrument that both measures that migration and applies increasing pressure to complete it.

The mental model: four irreversible-until-the-last-one stages

Treat end-of-life as a state machine the version moves through in one direction, where every stage except the last is reversible:

  • Announce — the successor version is generally available and documented as preferred. The old version keeps working with no behavioural change. You communicate intent in changelogs and docs. No response headers change yet; this is a documentation and relationship stage.
  • Deprecate — the gateway begins stamping every response for the old version with a Deprecation header and a Link pointing at the migration guide. The version is fully functional but formally on notice. New adoption should stop; existing consumers get a machine-readable and human-readable warning on every call.
  • Sunset — a firm removal date is set and advertised via the Sunset header (RFC 8594). You run scheduled brownouts and begin traffic-shedding to convert the abstract date into felt pressure. This is where laggards are flushed out while recovery is still possible.
  • Decommission — the route stops serving business traffic. The gateway returns a terminal 410 Gone (with a request-termination-style response) that still carries documentation links, then the route is removed from configuration after a short grace period.

The critical property of this model is that decommission is the only irreversible step, and you should not reach it until telemetry says residual traffic is either zero or confined to consumers you have explicitly written off. Every stage before it exists to make the last one boring.


Lifecycle Timeline: From Announce to Decommission

The diagram traces the four stages along a single timeline, showing which signal the gateway emits at each stage and how the share of traffic still served declines as brownouts and shedding ramp up.

API deprecation lifecycle timeline A left-to-right timeline with four stages. Announce: documentation only, no headers, full traffic served. Deprecate: gateway injects a Deprecation header and Link to docs, full traffic still served. Sunset: gateway adds an RFC 8594 Sunset date header and runs scheduled brownouts, traffic served begins to drop as shedding ramps. Decommission: gateway returns 410 Gone and the route is removed, traffic served reaches zero. time → 1. Announce successor is GA docs + changelog no response headers change 2. Deprecate inject header: Deprecation: true Link rel= "deprecation" 3. Sunset add RFC 8594 header: Sunset: <date> scheduled brownouts + traffic shedding 4. Decommission terminate at gateway: 410 Gone route removed after grace period traffic still served on the deprecated version: 100% 100% shedding ramps 0%

Primary concept: emitting deprecation signals at the gateway

The gateway is the correct enforcement point for deprecation signals because it owns the route and can stamp headers uniformly across every method and path of a version without asking upstream teams to ship code. Two response headers do the work. The Deprecation header (from the IETF draft-ietf-httpapi-deprecation-header) announces that a resource is deprecated; its value is either the boolean-like token true or an @-prefixed Unix timestamp marking when deprecation took effect. The Sunset header (RFC 8594) carries an HTTP-date after which the resource is expected to stop responding. Both should be accompanied by a Link header — rel="deprecation" pointing at human documentation and rel="sunset" pointing at the migration guide — so automated clients can resolve next steps.

Kong 3.x — inject deprecation and sunset headers with response-transformer

In Kong, the response and request transformation plugin family is the natural fit. Attach a response-transformer plugin scoped to the deprecated version’s route so it stamps every response, including error responses, with the lifecycle headers:

# Kong 3.x declarative (kong.yaml) — deprecate the v1 route
_format_version: "3.0"

services:
  - name: orders-api
    url: http://orders.internal:8080
    routes:
      - name: orders-v1          # the version being retired
        paths: ["/v1/orders"]
        strip_path: true
    plugins:
      # Stamp lifecycle headers on EVERY response for this route
      - name: response-transformer
        config:
          add:
            headers:
              - "Deprecation: true"
              - "Sunset: Wed, 31 Dec 2026 23:59:59 GMT"
              - 'Link: </docs/migrations/orders-v2>; rel="deprecation"; type="text/html"'
              - 'Link: </docs/migrations/orders-v2>; rel="sunset"; type="text/html"'
              - 'Warning: 299 - "Deprecated API version; migrate to /v2/orders before the Sunset date"'

add.headers appends rather than overwrites, so a route that already sets Link gets both values. The legacy Warning: 299 header (miscellaneous persistent warning) is included for older clients and proxies that surface warnings in logs even though they ignore Deprecation. Because the plugin runs in the response phase, the headers appear on 200s, 4xxs, and upstream 5xxs alike, which is what you want — a consumer debugging an error should still see they are on a dead-end version.

Envoy 1.32+ — header mutation on the deprecated route

Envoy applies the same signals declaratively through response_headers_to_add on the route, so there is no plugin hop:

# Envoy 1.32+ — stamp lifecycle headers on the v1 virtual host
route_config:
  name: orders_route
  virtual_hosts:
    - name: orders_v1
      domains: ["api.example.com"]
      routes:
        - match: { prefix: "/v1/orders" }
          route:
            cluster: orders_v1_cluster
            timeout: 2s
      # append_action keeps any upstream-set values instead of clobbering
      response_headers_to_add:
        - header: { key: "Deprecation", value: "true" }
          append_action: APPEND_IF_EXISTS_OR_ADD
        - header: { key: "Sunset", value: "Wed, 31 Dec 2026 23:59:59 GMT" }
          append_action: OVERWRITE_IF_EXISTS_OR_ADD
        - header:
            key: "Link"
            value: '</docs/migrations/orders-v2>; rel="sunset"; type="text/html"'
          append_action: APPEND_IF_EXISTS_OR_ADD

Setting these at the virtual_hosts[].routes[] level scopes them precisely to the retiring version. OVERWRITE_IF_EXISTS_OR_ADD on Sunset guarantees a single authoritative date even if an upstream tries to set its own; APPEND_IF_EXISTS_OR_ADD on Link preserves other link relations. Keeping this on the route rather than the whole listener means the successor /v2/orders route is untouched.


Secondary concept: brownouts, traffic-shedding, and decommission

Headers are advisory. A meaningful fraction of consumers will never read them, so the sunset stage needs mechanisms that produce felt consequences before the irreversible cut. Two techniques bridge the gap: brownouts and traffic-shedding.

A brownout is a pre-announced, time-boxed failure of the deprecated version — for example, returning 503 for a five-minute window at 10:00 UTC, repeated with growing frequency as the sunset date nears. The failure is short and recoverable, so a consumer that has not migrated experiences a brief, survivable outage that generates a support ticket or an alert on their side rather than a silent 200. Brownouts are how you find the callers your telemetry cannot attribute.

Envoy fault injection is the cleanest way to run a brownout because it can abort a controlled percentage of requests without touching the upstream:

# Envoy 1.32+ — brownout the v1 route via HTTP fault filter
# Applied on the v1 listener's HttpConnectionManager filter chain.
http_filters:
  - name: envoy.filters.http.fault
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.filters.http.fault.v3.HTTPFault
      # Abort 100% of matching traffic during the brownout window.
      abort:
        http_status: 503
        percentage:
          numerator: 100
          denominator: HUNDRED
      # Only brownout requests carrying the deprecated version header,
      # so v2 traffic sharing the listener is never affected.
      headers:
        - name: "x-api-version"
          string_match: { exact: "v1" }
  - name: envoy.filters.http.router
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

You enable this filter for the announced window and remove it afterward — ideally driven by your control plane on a schedule. To shed rather than fully brown out, lower numerator to a growing percentage (10, then 25, then 50) across successive weeks, converting the sunset ramp into a smooth pressure curve rather than a cliff. The delay block of the same fault filter lets you inject latency instead of errors when you want to degrade rather than break.

Kong models the terminal decommission with the request-termination plugin, which short-circuits the route at the gateway and never reaches the upstream. This is what you switch to at the decommission stage — and, run for short windows, it also serves as a brownout:

# Kong 3.x — decommission the v1 route with a terminal 410 Gone
plugins:
  - name: request-termination
    route: orders-v1
    config:
      status_code: 410
      content_type: "application/problem+json"
      body: >
        {"type":"https://api.example.com/errors/version-decommissioned",
         "title":"API v1 has been decommissioned",
         "detail":"Migrate to /v2/orders. See /docs/migrations/orders-v2.",
         "sunset":"2026-12-31"}

request-termination still lets a preceding response-transformer add the Sunset and Link headers, so even the tombstone response teaches automated clients where to go. Return 410 Gone, not 404410 states the resource intentionally and permanently no longer exists, which suppresses retries and re-crawls, whereas 404 invites confused debugging on the consumer side. Keep the terminating route in place for a grace period (weeks, not hours) after cutting traffic before you delete the route definition entirely, so late callers get the informative 410 rather than a bare gateway 404.

Consumer-usage tracking: knowing when it is safe

None of the above is safe without telemetry that answers one question: who still calls this version? The gateway must emit a per-version, per-consumer signal on every request to the deprecated route. In Kong, attach a prometheus plugin (which exposes kong_http_requests_total labelled by route and service) and enrich structured logs with the authenticated consumer via the file-log plugin, so residual traffic can be broken down by API key. In Envoy, the route’s stat_prefix and access-log %REQ(x-consumer-id)% field give the same breakdown. Ship both to a decommission-readiness dashboard — this is exactly the structured access logging discipline the observability layer already provides. The decommission decision is then data-driven: when residual traffic by consumer is zero or confined to callers you have explicitly abandoned, you cut. Deleting a route with unknown residual callers is how a planned deprecation becomes an incident.


Comparative implementation

Gateway Config approach Trade-off
Kong 3.x response-transformer to inject Deprecation/Sunset/Link; request-termination for the terminal 410; prometheus + file-log for usage Declarative and GitOps-native via deck; brownouts need scheduled config swaps or a short-lived request-termination, so timed windows are control-plane-driven rather than built-in
Envoy 1.32+ response_headers_to_add on the route for signals; envoy.filters.http.fault (abort/delay) for brownouts and percentage shedding; access-log fields for usage Native fault injection gives precise percentage shedding with no plugin hop, but header/fault config lives in xDS and demands strict schema validation and a control plane to schedule windows
Tyk 5.x API-definition global_response_headers for signals; version_data expiry to hard-stop a version; analytics for usage Version expiry is first-class in the API definition, but graduated brownouts and percentage shedding need a custom Go middleware — less flexible than Envoy fault injection
NGINX Plus R32+ add_header (with always) for signals; return 410 in a location for decommission; split_clients for shedding Simple and fast, but no native scheduled brownout — timed windows require config reloads driven externally

Operational gotchas

Deleting the route before residual traffic is zero. The most common — and most damaging — mistake is treating the sunset date as permission to delete the route. If telemetry still shows callers, deleting the route replaces an informative 410 with a bare gateway 404 or connection error and turns a scheduled event into a pager storm. Gate deletion on the usage dashboard, not the calendar.

No usage telemetry at all. If you cannot answer “who still calls v1?” you are flying blind and every deprecation is a gamble. Instrument per-consumer usage before you begin the deprecate stage, not after — you need a baseline to measure the migration against. This is the single highest-leverage prerequisite for the whole lifecycle.

Headers only on 2xx responses. If deprecation headers are injected upstream in application code, error responses generated by the gateway (auth failures, rate-limit rejections, upstream 5xx) often lack them. Inject at the gateway response phase so every response carries the signal — a consumer debugging a 429 should still learn the version is dying.

Sunset date in the wrong format. RFC 8594 requires an IMF-fixdate HTTP-date (Wed, 31 Dec 2026 23:59:59 GMT), not ISO 8601. A 2026-12-31T23:59:59Z value is technically a malformed Sunset header and tooling that parses it will silently ignore the date.

Brownouts with no announcement. An unannounced brownout is indistinguishable from an outage and will be escalated as one. Every brownout window must be published in advance on the same channel as the deprecation notice, with the exact times and the version affected, so the resulting tickets are expected rather than alarming.

Overwriting an upstream’s own Link header. Using an overwrite action on Link (rather than append) clobbers any link relations the upstream legitimately sets. Append lifecycle link relations; only overwrite the single-valued Sunset header.


Production Configuration Checklist

  • Per-version, per-consumer usage telemetry is emitted and dashboarded before the deprecate stage begins.
  • Deprecation header is injected at the gateway response phase so it appears on 2xx, 4xx, and 5xx alike.
  • Sunset header uses an RFC 8594 IMF-fixdate HTTP-date, not ISO 8601.
  • Link relations rel="deprecation" and rel="sunset" resolve to a live migration guide.
  • Lifecycle headers are scoped to the retiring route only — the successor version is untouched.
  • Brownout windows are pre-announced with exact times and the affected version.
  • Traffic-shedding ramps as a growing percentage, not a single cliff, across the sunset window.
  • Decommission returns 410 Gone with a problem+json body pointing at the migration guide — never 404.
  • The terminating route is retained for a grace period after traffic is cut before the route definition is deleted.
  • Route deletion is gated on the usage dashboard showing zero (or explicitly abandoned) residual traffic.
  • Deprecation timing is aligned with the version’s backward-compatibility commitments, not an arbitrary date.
  • Brownout and shedding config swaps are driven by the control plane / GitOps, with rollback tested.

FAQ

What is the difference between the Deprecation and Sunset HTTP headers?

The Deprecation header signals that a resource is deprecated and optionally when deprecation took effect; it means the version still works but should not be adopted for new integrations. The Sunset header, defined by RFC 8594, carries an HTTP-date marking when the resource will stop responding. Deprecation is the earlier, softer signal about intent; Sunset is the hard commitment with a specific removal date. A well-run lifecycle emits Deprecation first, then adds Sunset once the removal date is fixed. The Sunset header and deprecation response patterns page covers the exact wire format for both.

How long should an API deprecation window be before decommissioning?

The window should be at least as long as your slowest consumer’s release cycle, and for public APIs a common floor is six to twelve months. Internal APIs with a known set of first-party consumers can move faster — often 30 to 90 days — because you can track exactly who still calls the version and drive migrations directly. The window is a business commitment, not a technical one: set it against consumer contracts and the observed migration rate from your usage telemetry, not an arbitrary calendar date.

What is brownout testing and why run it before sunset?

A brownout is a scheduled, temporary period during which the gateway returns errors or injects latency for a deprecated version — for example, failing all traffic for five minutes at a pre-announced time. Brownouts flush out consumers who ignored deprecation headers by producing a visible, recoverable failure well before the permanent sunset. Because the outage is short and reversible, laggard integrations surface as support tickets and dashboards while there is still time to migrate, rather than at the moment of irreversible decommission.

How do I know which consumers still call a deprecated API version?

Emit a per-version, per-consumer metric from the gateway on every request to the deprecated route — a counter keyed by the authenticated consumer or API key and the route name. Pair that with structured access logs that include the consumer identity, so you can build a decommission-readiness dashboard showing residual traffic by consumer over time. Without this telemetry you are guessing, and deleting a route with unknown residual callers is how you turn a planned deprecation into an incident.

Should I return 410 Gone or 404 Not Found after decommissioning a version?

Return 410 Gone. 410 tells the client the resource intentionally and permanently no longer exists, which is semantically accurate for a decommissioned version and discourages retries and re-crawls. 404 implies the resource might return or was never there, which invites confused debugging on the consumer side. Configure the terminal response with a clear body pointing to the migration guide and, if useful, keep the Sunset and Link headers on the 410 so automated clients can still resolve documentation.



← Advanced Routing & API Versioning