API Versioning Without Tears: Sunset Headers, Major Versions, and a Go Implementation

Sooner or later every API team hits the wall: a client depends on behavior you need to change. The field is full of strong opinions — version in the URL, version in the header, never version at all — but production services settle this question with boring pragmatism. This post is a tour of how that pragmatism actually looks in 2026: what counts as breaking, which versioning mechanics real APIs use, how to retire versions without torching your users, and a complete implementation in Go that supports URL versioning alongside graceful deprecation signals.

Exhaust the Compatible Options First

Versioning is a cost you push onto every client, so the first move is always to check whether the change can avoid a version at all. The compatibility taxonomy is stable across the industry: adding optional response fields, adding new endpoints, and adding optional request parameters are non-breaking. Removing or renaming fields, making an optional parameter required, changing validation rules, changing field semantics, or altering error shapes are breaking. If your change lands in the first bucket, ship it and skip everything else in this post.

Two client-side disciplines make an enormous amount of headroom here. Servers should practice tolerant reading — ignore fields in requests you do not recognize — and clients should practice tolerant writing — ignore fields in responses they do not need. A tolerant-reading server can accept payloads from clients written years apart. When two teams share one deployment, coordinated release can also defer the problem: ship the server change first, migrate clients, and never expose a breaking seam.

Even renames can often be absorbed. If name must become first_name and last_name, keep accepting and returning name as a computed join, add the two new fields, and let old clients run indefinitely. The cost lands on your serializer, not on a thousand downstream codebases.

When You Must Version: Pick Your Mechanism

When a genuinely breaking change survives review, there are three mechanisms in common use, and each encodes a different idea about what a version is.

  • URL path versioning — GET /v2/users/123. The version is visible in every log line, curl command, and browser tab. Routing is trivial. This is what most public HTTP APIs do.
  • Media-type versioning — Accept: application/vnd.acme.v2+json. The resource URI stays stable and the representation is versioned, which is the more purist REST reading. The cost is discoverability: you cannot see the version in a URL, and debugging requires inspecting headers.
  • Custom header versioning — X-API-Version: 2026-10-15. Common in payment-style APIs that date-stamp their contract; the API’s behavior is pinned to the date the client integrated, and fixes flow to everyone without version bumps.

The honest recommendation: for internal and partner APIs, use URL path versioning with major versions only (/v1, /v2 — never /v1.3.2). The Speakeasy versioning guide documents the same trade space, and their summary matches what large public APIs converged on — the visibility of a version in the path is worth more than the URI purity of header-based schemes, especially when support engineers are debugging a customer’s integration at 2am. If you use SemVer anywhere, reserve the full three-part scheme for SDK packages, not the wire protocol.

Deprecation Is a Protocol, Not a Blog Post

The underrated half of API versioning is getting out of a version cleanly. RFC 8594 defines the Sunset header for exactly this: an HTTP response carrying

HTTP/2 200 OK
Sunset: Wed, 01 Jul 2026 23:59:59 GMT
Link: <https://api.example.com/docs/migrating-v1-to-v2>; rel="sunset"

tells every client, in a machine-readable way, when the endpoint they are calling will stop existing — and the rel="sunset" link points at migration documentation. Because the header appears on every response, clients discover it without reading changelogs, and monitoring tooling can alert on approaching deadlines automatically.

A workable retirement protocol has four steps. First, announce a deprecation window long enough for the slowest meaningful customer — twelve months is a common floor for paid APIs; internal services can run shorter. Second, emit Sunset and Deprecation headers on every response from the old version, from day one of the window. Third, log which API keys still call the old version — deprecation is not done when the calendar says so, it is done when your traffic report says zero. Fourth, when the deadline arrives, return 410 Gone with a pointer to the migration guide rather than a bare 404; a 410 tells the client’s engineers exactly what happened and what to read.

There is a final trick worth knowing for structural changes: instead of replacing an endpoint, widen it. When a /trees resource needs to become a generic /units collection, ship /units and reimplement /trees as a filtered view over it. Old clients never notice; new clients get the better model. Versioning then becomes something you do two or three times a decade, not every quarter.

The Implementation: Versioned APIs in Go

Here is a self-contained Go service that implements the whole scheme: URL-prefixed major versions, per-version response models, deprecation and Sunset headers on old versions, and 410 responses for retired routes. The standard library is enough — no router framework required.

package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"time"
)

// UserV1 is the original wire contract. Frozen: additive changes only.
type UserV1 struct {
	ID    string `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email"`
}

// UserV2 breaks the contract: name split into two fields.
type UserV2 struct {
	ID        string `json:"id"`
	FirstName string `json:"first_name"`
	LastName  string `json:"last_name"`
	Email     string `json:"email"`
}

var sunsetV1 = time.Date(2026, time.December, 31, 23, 59, 59, 0, time.UTC)

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}

// deprecationInstantV1 is when v1 was declared deprecated. RFC 9745
// defines the Deprecation value as a timestamp (@),
// not a boolean.
var deprecationInstantV1 = time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)

func handleUserV1(w http.ResponseWriter, r *http.Request) {
	// Deprecated version: advertise the deadline on every response.
	w.Header().Set("Deprecation", fmt.Sprintf("@%d", deprecationInstantV1.Unix()))
	w.Header().Set("Sunset", sunsetV1.Format(http.TimeFormat))
	w.Header().Set("Link", `<https://api.example.com/docs/v1-to-v2>; rel="sunset"`)

	writeJSON(w, http.StatusOK, UserV1{
		ID:    "usr_7f3a",
		Name:  "Dana Reyes",
		Email: "dana@example.com",
	})
}

func handleUserV2(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, UserV2{
		ID:        "usr_7f3a",
		FirstName: "Dana",
		LastName:  "Reyes",
		Email:     "dana@example.com",
	})
}

func handleGone(migrationURL string) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Link", "<"+migrationURL+">; rel=\"sunset\"")
		writeJSON(w, http.StatusGone, map[string]string{
			"error":     "this API version was retired",
			"migration": migrationURL,
		})
	}
}

func main() {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /v2/users/{id}", handleUserV2)

	// Retired versions answer 410 with a migration pointer,
	// not a bare 404.
	mux.HandleFunc("GET /v0/users/{id}", handleGone("https://api.example.com/docs/v0-to-v2"))

	// The live-but-deprecated version keeps serving.
	mux.HandleFunc("GET /v1/users/{id}", handleUserV1)

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}
	log.Println("listening on :8080")
	log.Fatal(srv.ListenAndServe())
}

The interesting design decisions are all in what is not shared. UserV1 and UserV2 are separate structs, not one struct with optional fields, because a shared struct is how accidental coupling creeps in — someone adds validation to the shared type and silently breaks v1 clients. Internally your domain model stays single; the version boundary exists only at the serializer. And the deprecated handler is deliberately small: its only extra job is stamping every response with the retirement metadata, so any client’s HTTP logs reveal the deadline without anyone reading a changelog.

The 410 Gone handler is equally load-bearing. When v0 finally goes dark, former callers get a structured error that names the problem and links the migration guide — turning your breaking change into the most helpful response their integration ever received.

Operational Footnotes

  • Version-selection headers still matter at the edge. If you run an API gateway, you can offer header-based selection there while keeping path versions internally — the two schemes compose.
  • Log the version with every request. Deprecation gets measurable the moment api_version is a first-class field in your request logs; the retirement decision becomes a query, not a guess.
  • Put deprecation metadata in your OpenAPI document. The OpenAPI 3.1 specification supports deprecated: true on operations and schemas, which surfaces in generated docs and SDK linters — the contract speaks even when the blog post is unread.
  • Never break silently. Every entry in this post — separate structs, Sunset headers, 410s with links — exists to convert an invisible breaking change into a visible, schedulable migration.

Version less than you think you need to: absorb what you can with additive fields and tolerant readers, version majors only in the URL when you cannot, and treat retirement as a first-class protocol with deadlines in headers and measured traffic, not a paragraph in release notes. The teams whose APIs last a decade are the ones that made life easy for the clients they will never meet.

Leave a Reply

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