Deprecating API Endpoints Without Breaking Consumers: Deprecation and Sunset Headers

Every API with more than a handful of consumers eventually faces the same problem: an endpoint or field you designed three years ago is now awkward, insecure, or expensive to maintain, and you need it gone. What you do next separates APIs that teams enjoy consuming from APIs that teams quietly build escape hatches around. Deleting the route and shipping a changelog entry is the aggressive option. The mature option is a deprecation lifecycle: a machine-readable announcement, a hard deadline, and a graceful end.

Until recently, that lifecycle had no standard wire format — every vendor invented their own X- headers and custom payloads. That changed with two RFCs that together cover the whole arc: RFC 9745 defines the Deprecation response header, and RFC 8594 defines the Sunset header. This post shows how they fit together, how to implement them server-side in Go, and what a workable deprecation policy looks like in practice.

The three stages of an endpoint’s life

Think of any resource — a route, a query parameter, a response field — as moving through three states:

  • Active. The preferred way to do the thing. Fully supported, documented, and what your examples show.
  • Deprecated. Still fully functional, but no longer recommended. New integrations should not use it, and existing ones should start migrating. Nothing breaks yet.
  • Sunset. A specific date after which the endpoint will stop responding. After that date you may return 410 Gone, redirect to the successor, or drop the route entirely — but the deadline itself was published long in advance.

The critical insight behind splitting this into two headers is that deprecation and removal are different events with different audiences. Deprecation is a signal to developers planning their roadmap: “stop building on this.” Sunset is a signal to operators with migration tickets open: “this is the exact date it disappears.” Collapsing them into one vague “this will be removed eventually” notice is why so many API deprecations drag on for years — consumers can’t distinguish a suggestion from a deadline.

The headers, precisely

Deprecation marks the moment a resource became (or will become) deprecated. Its value is a timestamp in structured field Date format — an @ followed by a Unix timestamp:

Deprecation: @1688169599

Sunset marks when the resource will become unresponsive. Its value is a plain HTTP-date:

Sunset: Wed, 11 Nov 2026 11:11:11 GMT

Sunset responses can also carry a Link header with the sunset relation type, pointing at a human-readable page that explains the retirement plan — what replaces the endpoint, how to migrate, and what the response will look like after the date:

Link: <https://api.example.com/sunset/v1-orders>; rel="sunset"

A couple of details that are easy to get wrong. Both headers apply to the specific resource that returns them, not globally to the API — if the whole /v1/ surface is going away, you set the headers per resource (or define and document a broader scope, because consumers who don’t know your special rule will assume the narrow one). And after the sunset date passes, keep returning something informative — a 410 Gone with a pointer to the migration guide teaches stragglers instantly; a generic 404 makes them think they typed the URL wrong.

Serving them in Go

The server side is a small middleware. Wrap the handlers for deprecated routes with it, and every response carries the full signal set:

package main

import (
	"log"
	"net/http"
	"strconv"
	"time"
)

// DeprecationMiddleware marks a handler as deprecated per RFC 9745
// and announces its removal date per RFC 8594.
func DeprecationMiddleware(sunset time.Time, sunsetInfoURL string, next http.Handler) http.Handler {
	deprecation := "@" + strconv.FormatInt(sunset.Unix(), 10)
	sunsetDate := sunset.UTC().Format(http.TimeFormat)
	link := "<" + sunsetInfoURL + `>; rel="sunset"`

	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		h := w.Header()
		h.Set("Deprecation", deprecation)
		h.Set("Sunset", sunsetDate)
		h.Set("Link", link)
		next.ServeHTTP(w, r)
	})
}

func main() {
	v1Orders := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})

	sunset := time.Date(2027, time.March, 31, 23, 59, 59, 0, time.UTC)
	handler := DeprecationMiddleware(sunset,
		"https://api.example.com/sunset/v1-orders", v1Orders)

	log.Fatal(http.ListenAndServe(":8080", handler))
}

Two implementation notes. First, compute the header values once at construction time — the timestamps are static per deployment, so there is no reason to format them per request. Second, resist the urge to also stuff a human-readable message into a response body field for every deprecated call. The headers are for machines; the linked page is for people. Doubling the signal in the body just gives you two more strings to keep in sync.

On the client side

If you consume APIs, these headers only help if something notices them. A minimal Go check turns the signal into a log line your team will actually see:

import (
	"log"
	"net/http"
)

func warnOnDeprecation(resp *http.Response) {
	if resp == nil {
		return
	}
	if d := resp.Header.Get("Deprecation"); d != "" {
		sunset := resp.Header.Get("Sunset")
		log.Printf("deprecated endpoint in use: %s %s (sunset: %s)",
			resp.Request.Method, resp.Request.URL, sunset)
	}
}

In a larger codebase, route this into your tracking system instead of a log — a metric or audit event per deprecated call gives you an accurate inventory of which of your own services still depend on the endpoint, which is exactly the checklist your migration project needs. Generic HTTP clients increasingly surface these headers automatically, but your integration code is where the accountability lives.

Don’t forget the contract

Headers reach running clients; your API description document reaches the people writing them. In OpenAPI, operations and parameters accept a deprecated: true flag, and most code generators and documentation renderers surface it as a warning in generated clients and reference pages:

paths:
  /v1/orders/{id}:
    get:
      deprecated: true
      summary: Fetch an order (v1)
      description: >
        Deprecated since 2026-06-01. Sunset 2027-03-31.
        Use /v2/orders/{id} instead.

Keep the three surfaces consistent: the runtime headers, the spec flag, and the human migration page. An endpoint that is deprecated in the docs but emits no header will sail past every automated check a consumer runs; the reverse silently misleads whoever reads the spec.

A policy that actually works

The mechanics above are the easy part. What makes deprecations succeed or fail is the policy around them:

  • Publish a minimum runway and honor it. Something like: deprecation announced at least 6 months before sunset for internal APIs, 12 for public ones. The number matters less than never moving it after publication — one extended deadline teaches every consumer that deadlines are negotiable, and you will re-announce forever.
  • Measure adoption before you cut. Count requests to deprecated routes by API key or client ID. Migration chase-downs go from weeks of guessing to a single sorted list. If a partner is still sending traffic the day before sunset, you know exactly who to call — and if traffic is zero, you can cut early with confidence.
  • Announce where developers actually look. Headers and changelogs reach active integrations. For dormant ones, email the registered technical contact and set the spec flag — a consumer who hasn’t called your API in a year will never see your response header.
  • Return 410 after the date, with the migration link. It converts a silent integration failure into a self-explanatory one, and it keeps working for stragglers who discover the breakage months later.
  • Prefer additive evolution where you can. Adding a field, a route, or an optional parameter needs no deprecation at all. Reserve the full lifecycle for the changes that genuinely break clients — removing fields, changing types, altering semantics.

The legacy alternative worth knowing about: the Warning header with code 299 was used for deprecation notices for years, but it was obsoleted in the current HTTP semantics specifications and shouldn’t appear in new designs.

Wrapping up

An API deprecation is a distributed-systems problem disguised as a communication problem: you are coordinating state changes across clients you don’t control, on timescales of months. The two RFC headers give you a standard vocabulary — Deprecation to start the clock, Sunset to set the deadline, a sunset link to explain the migration, and 410 Gone to close it out. Wire them into your framework once, add adoption metrics, publish the runway policy, and endpoint retirement stops being a fire drill and becomes routine maintenance.

Leave a Reply

Your email address will not be published. Required fields are marked *