You Cannot Un-publish an API: Contracts, Compatibility, and Retirement
A team stops populating a field nobody was supposed to depend on. The schema still declares it, the responses still contain it, and it is now always null. Three days later two consumers are broken: one was displaying it, and one was using it to decide whether to retry. Neither had asked permission, and the change went through review as a cleanup.
Designing an API is the easy part. The difficulty is that publishing one creates an obligation you cannot withdraw unilaterally, and the obligation is wider than the schema. What follows is what the contract actually covers, who gets to decide what “compatible” means, how errors and retries belong in it, and how a version is retired. Standards quoted here are RFC 9110, RFC 9457, and RFC 8594, checked in September 2026.
The contract is larger than the schema
A schema is the part that tooling can check, which is why it gets mistaken for the whole agreement. Consumers build against six things, and five of them are usually undocumented.
- Shape and types. The fields, their types, what is optional. The checkable part.
- Meaning. What
status = activeincludes, whetheramountis gross or net, which timezone a date is in, whether a list is complete or a page. A schema validator cannot decide these, and they are the source of the worst incidents — but once decided they can often be pinned with tests, which is a different thing from being expressible in a schema. - Error behaviour. Which failures are permanent and which are worth retrying, and how the client tells the difference. Consumers program against this whether or not you documented it.
- Retry safety. Whether repeating a call is harmless. Addressed below, because the standards are specific and widely ignored.
- Ordering and timing. Whether responses reflect writes immediately, whether events arrive in order, how stale a read may be. A consumer that tested against a fast, empty environment has assumed something here.
- Limits. Page sizes, maximum payloads, rate limits, timeouts. Tightening any of these is a change to what callers may do.
Write the second one down if nothing else. A field whose meaning is undocumented will be interpreted, and the interpretation becomes the contract — at which point the provider has an obligation nobody agreed to and cannot see.
For data delivered rather than called, the equivalent agreement is a data contract, and the reasoning is the same with different mechanics.
Consumers decide what counts as breaking
The useful definition of a breaking change is behavioural rather than structural: a change is breaking if a consumer that worked before stops working, or starts being wrong. That relocates the judgment from the provider’s intent to the consumers’ code, which is the whole point.
| Change | Usually safe | Breaks someone |
|---|---|---|
| Adding an optional field to a response | Yes — provided consumers ignore unknown fields, which is a property of their code rather than your intent | A consumer with strict validation, or one that persists the whole payload into a fixed schema |
| Adding a required request field | No | Every existing caller, immediately |
| Removing a field, or always returning null in it | No | Anyone reading it. Deprecated in the docs is not the same as unused |
| Adding a new enum value | Sometimes | Consumers that switch exhaustively on the old set — common, and usually undiscovered until it happens |
| Tightening validation | No | Callers whose previously accepted input is now rejected, including ones relying on a bug |
| Changing a field’s meaning while keeping its type | No | Everyone, silently. Nothing errors; numbers are simply wrong afterwards |
The last row is the one to fear most, and the reason is visibility rather than category. Redefining active users to exclude trials produces correct-looking numbers that disagree with last month’s, and the disagreement surfaces in a meeting rather than in a log.
But structural changes can be just as quiet, so do not read the table as structure fails loudly, meaning fails silently. Remove an optional field and a consumer that substitutes a default keeps running with a different answer. Add an enum value and a consumer whose switch has a catch-all branch classifies it as something else without raising anything. In both cases parsing succeeded and the result changed. Visibility depends on what the consumer does with the thing you changed — reads it, branches on it, validates it, persists it — which is why the judgment in the next section asks that question rather than sorting changes into structural and semantic.
Four provider-side errors follow from getting this backwards. Treating a bug fix as automatically non-breaking, when consumers have adapted to the bug. Assuming a documented deprecation means nobody is using it. Believing a field is unused because no ticket mentions it. And reasoning about “the client” as a single system when there are eleven, one of which is a partner you cannot call. The direction of the compatibility check — old data against new readers, or new data against old readers — is also a choice with consequences, which is the same reasoning as schema compatibility applied to an interface.
Versions are a cost, so postpone them honestly
A new version is the answer when a change cannot be made compatibly and the consumers cannot all move at once. It is not free: every version in production is a code path to maintain, a set of tests to keep green, a behaviour to reason about during an incident, and a thing someone must eventually retire. Two versions is the normal cost of doing business; five means the retirement process does not work.
Before adding one, four techniques cover most cases.
- Add rather than change. A new field alongside the old one, with both populated during the transition. Ugly and cheap.
- Make the new behaviour opt-in, through a parameter or a header, so existing callers are unaffected and new ones get the better interface.
- Expand, migrate, contract. Accept both shapes, move consumers, then remove the old one — the same three-step move used for database schema changes, and it works here for the same reason.
- Version the resource, not the API. Where only one endpoint has to change incompatibly, a new endpoint is a smaller commitment than a new version of everything.
Where a version is genuinely needed, two decisions matter more than the syntax of it. Decide what a version number promises: if it follows semantic versioning, then a major bump is precisely the signal that consumers must act, and shipping a breaking change without one destroys the value of the scheme. And decide the support commitment at the same time as the version — how long the previous one lives — because deciding that later means deciding it under pressure from whoever has not migrated.
Errors are an interface, not a message
Consumers write code against your failures: retry this, alert on that, show the user something. So the error format is part of the contract, and RFC 9457 exists because the status code alone is not enough — status codes “cannot always convey enough information about errors to be helpful,” and while a person can read an HTML page, “non-human consumers of HTTP APIs have difficulty doing so.”
The problem details format answers that with a small JSON object, and three of its rules are worth adopting whatever format you use.
- One stable, machine-readable identifier per problem type. In RFC 9457 this is the
typemember, a URI reference, and “consumers MUST use the ‘type’ URI (after resolution, if necessary) as the problem type’s primary identifier.” The point is that clients branch on something that does not change — not on a message. - Human text is advisory and must not be parsed. The
title“SHOULD NOT change from occurrence to occurrence of the problem, except for localization” and is “advisory”; of the per-occurrencedetail, the specification says “consumers SHOULD NOT parse the ‘detail’ member for information; extensions are more suitable and less error-prone.” Any client matching on message text has built a dependency on your copywriting. - Structured extras, ignored when unknown. Problem types may add their own members, and “clients consuming problem details MUST ignore any such extensions that they don’t recognize; this allows problem types to evolve.” That is what makes adding detail to an error a compatible change.
Two design consequences. Introducing a new error type is generally compatible only if clients treat unrecognized types as some failure of this class rather than crashing — worth stating in your documentation as an expectation of consumers. And the retry guidance has to be explicit somewhere: which failures are transient, and how long to wait. HTTP has a field for the second half of that, and how to use it is covered under Retry-After and backoff.
Retry safety is specified, and usually assumed instead
Networks fail after the server acted and before the client heard about it, so every caller eventually faces the question of whether to send the request again. RFC 9110 defines the property precisely: a method is idempotent “if the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request,” and “of the request methods defined by this specification, PUT, DELETE, and safe request methods are idempotent.”
Three details in that section are more useful than the definition.
- Idempotency is about the requested effect, not about the server doing nothing. The specification is explicit that “a server is free to log each request separately, retain a revision control history, or implement other non-idempotent side effects for each idempotent request.” So a repeated PUT may legitimately produce two audit entries; what it must not produce is two applied changes.
- It is what licenses automatic retry. “Idempotent methods are distinguished because the request can be repeated automatically if a communication failure occurs before the client is able to read the server’s response.”
- Non-idempotent methods have explicit rules. “A client SHOULD NOT automatically retry a request with a non-idempotent method unless it has some means to know that the request semantics are actually idempotent, regardless of the method, or some means to detect that the original request was never applied.” And for intermediaries: “a proxy MUST NOT automatically retry non-idempotent requests.” There is also a line worth quoting to anyone building retry logic — “a client SHOULD NOT automatically retry a failed automatic retry.”
The middle clause of that third rule is the interesting one, because it is an invitation. An operation can be made safe to repeat regardless of its method, and the usual mechanism is an idempotency key: the client supplies an identifier with the request, and the server treats a second request bearing the same key as the same operation rather than a new one. That converts a create into something a client may retry, and it belongs in the contract — the header name, how long the key is honoured, and what happens if the same key arrives with a different body.
Providers who skip this are not avoiding the problem, only moving it: clients will retry anyway when the alternative is losing a customer’s order, and duplicates will appear in your data. Making the retry safe is cheaper than reconciling the result, which is the practical content of idempotency as a design property.
Retirement needs a measurement, not an announcement
Every version is eventually retired, and the reason it so often is not is that nobody can answer who is still calling this. Deprecation announcements without usage data produce a standoff: the provider cannot prove it is safe to remove, and the consumers have no deadline they believe.
So the order matters, and measurement comes first.
- Instrument by consumer, not by endpoint. Calls per version per client identity, so the question becomes these four teams and this partner rather than some traffic remains.
- Announce with a date and a migration path. A deprecation notice whose instruction is please migrate without saying to what is a notice nobody can act on.
- Signal it in the responses themselves, so the information reaches the running code rather than an archived email. HTTP has a field for this: RFC 8594’s Sunset header “allows a server to communicate the fact that a resource is expected to become unresponsive at a specific point in time,” carrying a single timestamp that “SHOULD be a timestamp in the future.”
- Contact the remaining callers individually once the long tail is small. This is the step that actually finishes migrations, and it is why step one exists.
- Retire in a way you can reverse for a short window — a brownout before the shutdown, and the ability to re-enable briefly if something unexpected breaks.
One caution about the header, from the specification itself: “clients SHOULD treat Sunset timestamps as hints: it is not guaranteed that the resource will, in fact, be available until that time and will not be available after that time.” It is a communication channel, not a commitment — the commitment is your support policy, and the header advertises it. Note also that a timestamp in the past is best read as meaning now, which is a reason to keep the value current rather than leaving a stale date in place.
Events change three of the answers
Everything above applies to a published event stream, with three differences that catch teams moving from request-response.
- You cannot see your consumers. Nobody calls you, so usage measurement has to come from the platform’s consumer-group data rather than from your own logs — and an unknown consumer is the normal case rather than an exception.
- Old events persist. A retained topic holds messages produced under earlier versions, so a consumer reading from the beginning meets every shape you ever published. That makes the retention period part of the compatibility window, and an event envelope carrying an explicit version the thing that keeps it tractable.
- Delivery and ordering are contract terms. Whether duplicates are possible, whether order holds, and how late an event may arrive are promises consumers build on — and they are easy to weaken accidentally when the producer is re-partitioned or re-implemented.
The same questions recur in other protocols with different vocabulary — a field’s nullability in a typed schema, a removed argument in a graph query — and the answers follow the same rule: whether a change is breaking depends on what the consumers do, not on what the specification permits.
What to put in place before the first consumer
Most of this is cheap in advance and expensive retrofitted. Five things, in the order they pay off.
- Per-consumer usage metrics. Without these, nothing can be retired and no impact can be assessed.
- A written statement of meaning for every field whose interpretation could be argued about — which is most of them.
- A stable error identifier scheme and a documented statement of which errors are retryable.
- A retry-safety position per operation, with an idempotency key mechanism for the ones that create or charge something.
- A support policy: how long a version lives after the next one ships, and how deprecation is communicated.
Two closing judgments, offered as mine. Compatibility is a discipline rather than a tool — but the part that does not automate is narrower than it sounds. Deciding what “active” means is a business agreement that no tool will make for you. Holding that decision still afterwards is ordinary regression testing: a fixture of representative customers and orders with the expected output pinned will fail the moment someone excludes trials or changes how a refund is deducted. Tools built for exactly this exist — dbt’s unit tests, for instance, “validate your SQL modeling logic on a small set of static inputs before you materialize your full model in production.” So: the meaning is agreed by people and documented, and the agreed rules are then defended by tests. And the cost of an interface is dominated by how many consumers it has and how little you know about them — which argues for keeping the published surface small, since an endpoint nobody uses is still an endpoint you cannot change. Where the interface sits at a bounded context boundary, that surface is also the translation between two models, and keeping it narrow is the same decision as keeping the boundary clean.
One consequence of all this is worth stating for readers designing their first public interface: reads that return lists need a paging contract from the start — see keyset pagination — and any read served from a replica needs its staleness stated, since eventual consistency that consumers discover experimentally becomes an undocumented part of the contract.
References: RFC 9110, HTTP Semantics, Idempotent Methods; RFC 9457, Problem Details for HTTP APIs; RFC 8594, The Sunset HTTP Header Field; dbt Documentation, Unit tests.
Discover more from Insightful Data Lab
Subscribe to get the latest posts sent to your email.
