Skip to content
gohacks.dev
APIs

Deprecate API Endpoints in Go Without Breaking Clients

Retire a Go API endpoint safely: Deprecation and Sunset headers, successor links, per-client usage counts, a 410 problem response after the sunset date.

9 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

Adding to an API is easy. Removing from it is where trust is made or lost. A client that built against your endpoint a year ago has code in production that you cannot see, written by people you may never have spoken to, running on release cycles you do not control. If you change or remove something and it stops working, the failure is theirs in the moment and yours in the reputation.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

Deprecation is the process of removing something without that failure. It has a social half (announce, give time, help people move) and a technical half (tell machines, measure usage, enforce the date). This article covers the technical half in Go, with the standard headers, the counting that tells you whether the date is safe, and the response that clients get after the end. It extends the thinking in API versioning and backward compatible changes, and it applies equally to a whole version or a single field. The lab ran on Go 1.25.6.

The headers

Two HTTP headers have been standardised for this, plus link relations.

Deprecation says the resource is deprecated. RFC 9745 defines it as a structured-field date: Deprecation: @1772323200, the Unix time at which it became (or will become) deprecated. The leading @ is part of the syntax. Older drafts used true, and you will still see that in the wild.

Sunset says when the resource will stop responding. RFC 8594 defines it as an HTTP date: Sunset: Tue, 01 Sep 2026 00:00:00 GMT. It must be later than any Deprecation date and should not be moved earlier once published.

Link carries pointers: rel="successor-version" to the replacement and rel="deprecation" to documentation about the change. Software that understands these relations can raise warnings automatically; humans reading a response in a terminal see them as hints.

These headers go on every response from the deprecated endpoint, success and failure alike, and keep going after the sunset date. They are free to send and they reach the places a blog post will not: the logs of a client's integration test, the output of curl -i and the HTTP client of a developer who never reads your changelog.

The middleware

The lab wraps the old handler with a function that does four things: records who called, sets the headers, checks the clock against the sunset date and passes the request on, or ends it.

main.goGo
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"sort"
	"strings"
	"sync"
	"time"
)

// Deprecation describes the retirement of one endpoint.
type Deprecation struct {
	Since     time.Time // when it was announced as deprecated; sent as the Deprecation header
	Sunset    time.Time // after this the endpoint stops working
	Successor string    // where callers should go
	Docs      string    // migration guide
}

type usage struct {
	mu     sync.Mutex
	byUser map[string]int
}

func (u *usage) hit(client string) {
	u.mu.Lock()
	u.byUser[client]++
	u.mu.Unlock()
}

func (u *usage) report() string {
	u.mu.Lock()
	defer u.mu.Unlock()
	var parts []string
	for c, n := range u.byUser {
		parts = append(parts, fmt.Sprintf("%s=%d", c, n))
	}
	sort.Strings(parts)
	return strings.Join(parts, " ")
}

func deprecated(d Deprecation, now func() time.Time, u *usage, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		client := r.Header.Get("X-Client-Id")
		if client == "" {
			client = "anonymous"
		}
		u.hit(client) // who is still calling this? the only way to know if the sunset is safe
		h := w.Header()
		h.Set("Deprecation", fmt.Sprintf("@%d", d.Since.Unix())) // RFC 9745: a structured-field date
		h.Set("Sunset", d.Sunset.UTC().Format(http.TimeFormat))  // RFC 8594
		h.Add("Link", fmt.Sprintf("<%s>; rel=\"successor-version\"", d.Successor))
		h.Add("Link", fmt.Sprintf("<%s>; rel=\"deprecation\"; type=\"text/html\"", d.Docs))
		if now().After(d.Sunset) {
			h.Set("Content-Type", "application/problem+json")
			w.WriteHeader(http.StatusGone)
			fmt.Fprintf(w, `{"type":"https://api.example.test/problems/endpoint-retired","title":"This endpoint was retired on %s","status":410,"detail":"Use %s"}`,
				d.Sunset.Format("2006-01-02"), d.Successor)
			return
		}
		next.ServeHTTP(w, r)
	})
}

func main() {
	clock := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
	now := func() time.Time { return clock }
	u := &usage{byUser: map[string]int{}}

	d := Deprecation{
		Since:     time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
		Sunset:    time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC),
		Successor: "https://api.example.test/v2/orders",
		Docs:      "https://docs.example.test/migrate-orders-v2",
	}
	mux := http.NewServeMux()
	mux.Handle("GET /v1/orders", deprecated(d, now, u, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		fmt.Fprint(w, `{"orders":[]}`)
	})))
	mux.HandleFunc("GET /v2/orders", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		fmt.Fprint(w, `{"data":[]}`)
	})

	call := func(label, path, client string) {
		rec := httptest.NewRecorder()
		req := httptest.NewRequest("GET", path, nil)
		req.Header.Set("X-Client-Id", client)
		mux.ServeHTTP(rec, req)
		fmt.Printf("%s -> %d\n", label, rec.Code)
		for _, k := range []string{"Deprecation", "Sunset", "Link", "Content-Type"} {
			for _, v := range rec.Header().Values(k) {
				fmt.Printf("    %s: %s\n", k, v)
			}
		}
		if rec.Code == http.StatusGone {
			fmt.Printf("    body: %s\n", strings.TrimSpace(rec.Body.String()))
		}
	}

	call("June, deprecated endpoint", "/v1/orders", "mobile-app")
	call("June, the replacement", "/v2/orders", "mobile-app")
	call("June, another caller", "/v1/orders", "partner-acme")
	call("June, partner again", "/v1/orders", "partner-acme")
	fmt.Println("callers of the old endpoint so far:", u.report())

	clock = time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC)
	call("September, after the sunset", "/v1/orders", "partner-acme")
}
Text
June, deprecated endpoint -> 200
    Deprecation: @1772323200
    Sunset: Tue, 01 Sep 2026 00:00:00 GMT
    Link: <https://api.example.test/v2/orders>; rel="successor-version"
    Link: <https://docs.example.test/migrate-orders-v2>; rel="deprecation"; type="text/html"
    Content-Type: application/json
June, the replacement -> 200
    Content-Type: application/json
June, another caller -> 200
    Deprecation: @1772323200
    Sunset: Tue, 01 Sep 2026 00:00:00 GMT
    Link: <https://api.example.test/v2/orders>; rel="successor-version"
    Link: <https://docs.example.test/migrate-orders-v2>; rel="deprecation"; type="text/html"
    Content-Type: application/json
June, partner again -> 200
    Deprecation: @1772323200
    Sunset: Tue, 01 Sep 2026 00:00:00 GMT
    Link: <https://api.example.test/v2/orders>; rel="successor-version"
    Link: <https://docs.example.test/migrate-orders-v2>; rel="deprecation"; type="text/html"
    Content-Type: application/json
callers of the old endpoint so far: mobile-app=1 partner-acme=2
September, after the sunset -> 410
    Deprecation: @1772323200
    Sunset: Tue, 01 Sep 2026 00:00:00 GMT
    Link: <https://api.example.test/v2/orders>; rel="successor-version"
    Link: <https://docs.example.test/migrate-orders-v2>; rel="deprecation"; type="text/html"
    Content-Type: application/problem+json
    body: {"type":"https://api.example.test/problems/endpoint-retired","title":"This endpoint was retired on 2026-09-01","status":410,"detail":"Use https://api.example.test/v2/orders"}

Deprecation is one value that describes the retirement: when it was announced, when it ends, where to go and where to read. Keeping this as data lets you list all deprecations in one place, expose them in your OpenAPI document (deprecated: true) and test them together. The handler reads the clock through a function, so a test can run June and September in the same program, as main does.

What the run shows

The first call, in June, is an ordinary success, with the deprecation headers attached: the Deprecation date for 1 March, the Sunset for 1 September, and the two links. The replacement route right after it, /v2/orders, carries none of those headers; only what is being retired is flagged.

The next two calls come from a second client. After four calls, the usage report reads mobile-app=1 partner-acme=2. That is the output you actually need for a retirement, because it tells you who is still on the old path. A deprecation without usage data is a guess, and a sunset without it is a gamble.

Then the clock moves to 2 September, after the sunset. The same request now returns 410 Gone, still with the deprecation headers, and a application/problem+json body that says the endpoint was retired on 2026-09-01 and points to the replacement. The format follows problem details, so clients that already parse your errors can show something useful.

Why 410 and not 404

A 404 says "I don't know such a thing". A 410 says "it existed and has been deliberately removed". The second is truthful and gives a developer who hits it the right clue: the integration is not broken by a typo, it was retired, and the body says where to go. Some teams prefer a softer end: keep the endpoint working but slow, or return 410 only to clients that have not migrated and keep serving the ones granted an extension. Whatever you choose, the behaviour after the date must match what you announced.

Measuring who is left

The per-client counter in the lab is the simplest useful form. In production, log the client identifier (API key id, OAuth client id, or user agent when nothing else exists) with each call to a deprecated route, and chart calls per client over time. Three things to do with that chart:

  • Contact the stragglers directly, weeks before the date. Large clients usually have an account contact; small ones can be reached through the email attached to their API key.
  • Watch the total fall. If the line is flat a month before the sunset, the migration is not happening, and either the message is not reaching anyone or the successor is not good enough. Extending the date is better than breaking people.
  • Know the unidentified. Calls with no client id are the ones you cannot warn. Treat them as the highest risk and consider requiring identification on the old endpoint.

Do not put the client id in a metric label if you have thousands of clients; use logs, or aggregate the top N.

A timeline that works

There is no universal duration. For a public API, six months to a year between announcement and sunset is normal; for an internal service with a handful of consumers, a few weeks. A shape that has worked:

  1. Announce. Changelog, email to API key holders, documentation banner, deprecated: true in the OpenAPI document. The successor must exist and be documented before this step.
  2. Mark. Start sending the headers the same day.
  3. Remind. Mail clients still calling the endpoint at three months, one month and one week, with their own usage numbers.
  4. Brown out (optional). Return 410 for a few minutes on a published schedule, a week or two before the end. Clients that had ignored every notice find out while there is still time to fix it.
  5. Sunset. Return 410 with the problem body.
  6. Remove the code after a quiet period, but keep the route answering 410 for as long as stragglers keep appearing. Removing the route entirely gives them 404, which is less helpful.

Deprecating less than a route

The same pattern works for smaller things.

A field. Keep sending it, document it as deprecated, and optionally add a response header listing deprecated fields in use. Removing a field is a breaking change for clients that read it, so it needs the same notice. Check backward compatible changes for what counts.

A parameter or enum value. Accept it with a warning header, then reject it with a 422 and a clear message after the date.

A whole version. Apply the middleware to the version's route group and use the same dates; the usage counts decide whether the date holds.

A webhook event type (webhook design): announce it, send both the old and new for a period, then stop.

Telling clients inside their own tools

Generated clients can surface deprecation if the OpenAPI document marks the operation deprecated: true: most generators add a // Deprecated: comment to the generated Go method, which staticcheck and gopls then flag in the caller's editor. That is the most direct way to reach a Go developer, and it costs one line in the spec. The Go convention is a paragraph starting with Deprecated: in a doc comment, described in the Go documentation conventions, and it works the same for your Go SDK's functions.

What not to do

Do not remove silently, even if you believe nobody uses it. Do not shorten a published sunset date. Do not reuse a retired path for something with different semantics: the old clients will send requests to the new meaning. Do not announce a deprecation before the replacement exists. And do not rely on one channel: a changelog nobody reads, headers nobody inspects and an email that lands in spam are each ignored by some of your clients, so use all three.

Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.