API Design
API design is the work of deciding what other programs may send to yours, what comes back, and which of those promises you will keep for years. Anything a consumer can observe becomes part of the promise, and you cannot take it back alone. The page opens with sixteen decisions every HTTP API makes, the default to take and what changing each later costs, then covers what the contract includes, methods, status codes and errors, behaviour under retries and load, who may do what, changing a published API, five documented cases, and where it degrades.
- Described with
- OpenAPI 3.2 (September 2025)
- Error format
- RFC 9457 problem details
- Most common breach
- Object-level authorization (OWASP API1)
Sixteen decisions every HTTP API makes — the default to take, and what changing it later costs
An API gets designed whether or not anybody sits down to design it. Every endpoint that ships answers the same sixteen questions. How does it name things? What does a failure look like? What happens when a request arrives twice? Who may read which record? What will a change do to the people calling it? Left alone, each question is answered by whatever the framework did by default. That answer becomes a promise on the day the first consumer parses it. Here are the sixteen, with the default worth taking, the reason, and what it costs to change your mind after release. Later sections cite the rows by number.
| # | Decision | The default to take | Why | Changing it later |
|---|---|---|---|---|
| 1 | Names and methods | Nouns in the path, and the method says the action: GET /orders/42, POST /orders | Caches, proxies, client libraries and logs already understand methods; verbs in the path (/getOrder) throw that away | Breaking: every caller has the path in its code |
| 2 | Status codes | The right class first: 2xx done, 4xx the caller’s mistake, 5xx yours | Retry policies, load balancers and alerts branch on the class and never read the body | Breaking: clients branch on the code |
| 3 | Errors | RFC 9457 problem details: a stable type for programs, a detail for people | A program needs a token to branch on; a person at 2 a.m. needs a sentence | Breaking: clients already parse the old shape |
| 4 | Identifiers | Opaque strings that cannot be guessed — never sequential integers | Sequential ids invite enumeration and reveal your volume; 64-bit integers lose precision in JavaScript | Breaking: the type of every id field changes |
| 5 | Data formats | Timestamps in RFC 3339 with an offset; money as an integer in minor units plus a currency; one naming style | A local time without an offset and a floating-point amount are both wrong in ways no test catches | Breaking, field by field |
| 6 | A request sent twice | PUT and DELETE idempotent by design; an Idempotency-Key header on every POST that creates or charges | After a timeout the caller does not know whether the action happened; a retry without a key does it twice | Additive: an optional header, but every client has to start sending it |
| 7 | Lists | Cursor pagination with a capped limit and a stable sort order | Offset pages skip and repeat rows while the data changes, and slow down with depth | Breaking: the paging parameters change |
| 8 | Limits | Per-client rate limits from day one; 429 with Retry-After | Without them one buggy script is everyone’s outage | Breaking in practice: someone already relies on there being none |
| 9 | Slow work | 202 Accepted with a link to an operation the caller can poll, or a webhook | A request held open for minutes dies at the first proxy timeout | Breaking: a synchronous response becomes asynchronous |
| 10 | Concurrent edits | An ETag on every resource; If-Match on updates, 412 when the version is stale | Otherwise two clients editing one record silently overwrite each other | Additive: optional headers |
| 11 | Authentication | A standard scheme — OAuth 2.0 or API keys — in the Authorization header, on every route, no exceptions | Credentials in a URL end up in logs and browser history; one open route is all an attacker needs | Breaking for the routes that were open |
| 12 | Authorization per object | Every request that names a record checks that this caller may touch this record | Checking only that the caller is logged in is the most common way APIs leak data (OWASP API1) | Not a contract change — a fix you owe today |
| 13 | The description | An OpenAPI document written before the code, reviewed by a consumer, checked in CI | Written after the code, it documents accidents as decisions | Cheap now; later every endpoint has to be described from its behaviour |
| 14 | Compatibility rules | Published: clients ignore unknown fields, enums may grow, nothing is removed without notice | Adding a field is safe only if clients were told to ignore what they do not know | Free on day one; impossible to impose afterwards |
| 15 | Versions | A major version in the path, or a dated version per account — and as few of them as you can manage | Every live version doubles every fix and every support conversation | The mechanism is cheap to pick and expensive to change |
| 16 | Removal | A published window, Deprecation and Sunset headers, and usage measured per client before anything is switched off | A notice on a web page reaches the people who read it; a header and a metric reach everyone else | This is the procedure for changing every other row |
What the contract is — Hyrum’s law, the written description, and the style that carries it
An API is a promise about how something behaves. It is made to people who are not in the room and cannot be asked to redeploy on your schedule. That last part is the whole difficulty. Internal code can be refactored whenever you understand it better. An API cannot, because somebody built a payroll integration against it eighteen months ago and has not thought about you since.
So what exactly did you promise? Less than you think, and more than you meant. Hyrum Wright, then an engineer at Google, put the second half into one sentence that his colleague Titus Winters named Hyrum’s law: “With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviors of your system will be depended on by somebody.” Observable means anything a caller can see. The order of items in an array. The fact that ids happen to grow by one. The exact wording of an error message that somebody matches with a regular expression. How long a call takes. None of it was documented, and all of it will break someone when it changes.
The other half of the contract is who must be forgiving. The old advice is Jon Postel’s robustness principle: be conservative in what you send and liberal in what you accept. Half of it has aged well. A client should be a tolerant reader — Martin Fowler’s term from 2011: read only the fields it needs and ignore everything else, so that a new field cannot break it. The other half has not. A server that quietly accepts malformed input — a date in three formats, a misspelt field it guesses at — turns each guess into a feature that someone now depends on. RFCRequest for Comments — a numbered internet standardA numbered document published by the IETF, the body that standardises internet protocols; HTTP, TLS and JSON are all defined in RFCs. A published RFC never changes — a correction is a new RFC with a new number, which is why “RFC 9110” names one exact text.All terms 9413, published by the IETF’s architecture board in June 2023, makes exactly this argument: tolerance lets mistakes become the protocol. So be strict where you receive and tolerant only where you read responses.
| Who | Strict or tolerant | What that means in practice |
|---|---|---|
| The server, receiving a request | Strict | Validate against the description; reject with a clear 400 or 422; never guess what the caller meant |
| The server, sending a response | Conservative | Only documented fields, in documented formats, in a documented order |
| The client, reading a response | Tolerant | Read what you need, ignore unknown fields, handle an enum value you have never seen |
| The client, sending a request | Conservative | Send only documented fields; never rely on the server forgiving a mistake |
Write the description before the code
The practice that changes outcomes more than any single rule is row 13: write the specification first. An OpenAPI document is one file that describes every path, parameter, request body and response in a form machines read. The current version, 3.2, was announced on 23 September 2025. It builds on 3.1’s alignment with JSONJavaScript Object Notation — text format for dataA text format for passing data around: key–value pairs, lists, numbers and strings. It came out of JavaScript, but every language reads it now, and that is why most APIs answer in it rather than in anything more capable.All terms Schema and adds the new QUERY method, streaming responses such as server-sent events, and nested tags for large APIs. Written first, the document can be reviewed by the people who will consume it before anything is built. The same day it generates a mock server they can build against. Later it generates client libraries and request validation, so the code cannot drift from it.
Written afterwards, generated from the code, the same document records whatever was built — including the parts nobody would have agreed to in a review. The second practice closes the remaining gap: contract tests. In Ian Robinson’s consumer-driven contracts, described in 2006, each consumer publishes the part of your API it actually uses, and your build runs those expectations against the provider. Tools such as Pact automate it. The effect is that “is this change breaking?” stops being a judgement call and becomes a build result. That matters most for changes that look harmless, which is where the expensive mistakes live.
REST, GraphQL, gRPC — and events
Most of this page assumes HTTP with JSON, because that is what most APIs between organisations are. The other styles are not better or worse. They make different trade-offs about who decides the shape of a response, and the right one depends on who is calling. Every row of the table at the top still applies to them in some form.
| Style | Fits | Strength | Cost | Its compatibility rule |
|---|---|---|---|---|
| HTTP + JSON (REST) | Public and partner APIs | Cacheable, debuggable with curl, understood by every proxy | Over- and under-fetching; many round trips for one screen | Rows 14–16 of this page |
| GraphQL | Several rich clients needing different slices of one graph | The client asks for exactly the fields it needs, in one request | HTTP caching is lost; query cost and depth must be limited (row 8) | No versions: fields are added, and old ones marked @deprecated |
| gRPC | Calls between services you run | Fast, typed, generated clients, streaming in both directions | Not readable in a browser or with curl; needs HTTP/2 end to end | Never reuse a field number; mark removed ones reserved |
| Events and webhooks | Telling others that something happened | The caller does not poll; producers and consumers are decoupled in time | Delivered at least once, sometimes out of order; consumers must be idempotent | The event’s shape is a contract too — describe it, for example in AsyncAPI |
The pattern in practice is HTTP at the edge, where consumers are unknown and the API must last for years. gRPC goes between services you control and deploy together. GraphQL fits where a handful of rich clients each need a different slice of the same data, and the alternative is twenty bespoke endpoints. Its specification gained its first new edition in four years in September 2025. Mixing styles is normal: a style is a property of one boundary, not of the whole system.
Methods, status codes and errors — what every layer between you and the caller reads
Rows 1–3 look like style. They are not, because the request passes through software that knows nothing about your API and acts on the method and the status code alone. A browser prefetches links. A crawler follows them. A proxy caches responses. A client library retries some failures and not others. Each of them reads the method and the status code, and none of them reads your documentation.
Two properties of a method are worth knowing exactly, because retries depend on them. A safe method changes nothing on the server, so anyone may call it freely. An idempotentsafe to repeatAn operation that has the same effect whether it runs once or five times. It is what makes a retry safe — and since any network call may be retried without you knowing, the alternative is charging a card twice.All terms method has the same effect whether it runs once or five times. RFC 9110, the current definition of HTTP semantics, assigns both properties method by method. In June 2026 the IETF added the first new standard method since PATCH in 2010.
| Method | Safe | Idempotent | Use it for | The trap |
|---|---|---|---|---|
| GET | Yes | Yes | Reading | A GET that changes state gets triggered by prefetchers and crawlers |
| POST | No | No | Creating, and any action that is not a replace | A retry creates a second one — hence the idempotency key (row 6) |
| PUT | No | Yes | Replacing a whole resource at a known address | A partial body wipes the fields it left out |
| PATCH | No | Not by definition | Changing part of a resource | In JSON Merge Patch, null deletes a field and a missing field is left alone |
| DELETE | No | Yes | Removing | The second call returns 404 or 204; either is fine if documented |
| QUERY | Yes | Yes | A search whose filter is too large for a URL (RFC 10008, June 2026) | New: proxies and frameworks are still catching up, so keep POST as a fallback |
Status codes are the part everyone half-knows. The class carries the meaning: 2xx it worked, 3xx look elsewhere, 4xx the caller made a mistake and repeating the request unchanged will not help, 5xx we made a mistake and a retry might work. Getting the class right matters more than choosing between two plausible codes inside it. A dozen codes cover almost every API:
| Code | What it tells the caller | Use it when |
|---|---|---|
| 200 / 201 / 204 | Done; created (with a Location); done, nothing to return | The request succeeded |
| 202 | Accepted, not finished | The work continues after the response (row 9) |
| 400 | I could not read your request | Malformed JSON, a wrong type, a missing required header |
| 401 | I do not know who you are | No credentials, or invalid ones — despite its name, “Unauthorized” means unauthenticated |
| 403 | I know who you are, and you may not do this | The operation is forbidden for this caller (row 12) |
| 404 | There is nothing here — for you | The record does not exist, or the caller must not learn that it does |
| 409 | The current state does not allow this | A duplicate, an order already shipped, a retry while the first attempt is running |
| 412 | Your copy is out of date | If-Match did not match the current ETag (row 10) |
| 422 | I read it, and it is not valid | Well-formed but wrong: an end date before the start date |
| 429 | Too many requests — wait | A rate limit was hit; send Retry-After (row 8) |
| 500 / 503 | Our fault; we are overloaded or down for now | Anything the caller could not have prevented; 503 with Retry-After when it will pass |
The choice between 403 and 404 is a security decision, not a style one. Suppose a caller asks for an invoice that exists but belongs to another customer. A 403 tells them the invoice exists. With sequential ids, that is a map of every invoice you have. GitHub documents the same choice for private repositories: an unauthenticated request gets 404 Not Found rather than 403 Forbidden, so the response does not confirm that the repository exists.
An error response has two audiences with opposite needs. A program needs a stable token it can branch on. A person debugging at 2 a.m. needs a sentence. There is a standard shape that serves both, so you do not have to invent one: problem details, RFC 9457, served as application/problem+json. It replaced RFC 7807 in July 2023 and kept its fields. type is a URI that names the kind of error and never changes. title is a short summary of that kind. status repeats the code. detail explains this occurrence. instance points at it. Anything else you need goes in extra members beside them.
Retries, lists, limits and slow work — behaviour callers rely on without reading about it
A request sent twice
Row 6 starts with a question every client eventually faces. A POST /payments times out. Did the payment happen? The client cannot know. The request may have died on the way in, or the payment may have gone through and the response died on the way out. Retrying risks charging twice. Not retrying risks not charging at all. A network failure on a request that is not idempotent is genuinely ambiguous, and no amount of care on the client removes the ambiguity.
The server can remove it. The client generates a unique key — a random UUIDUniversally Unique Identifier — a 128-bit id made anywhereA 128-bit identifier that any machine can generate without asking a central counter, with practically no chance of two colliding. Version 4 is random, which keeps it unguessable but scatters database inserts; version 7 starts with a timestamp, so new ids sort in the order they were created.All terms — and sends it in an Idempotency-Key header with the request, and again with every retry of the same request. The server stores the key together with the result of the first execution. When the same key arrives again, it does not run the request again. It returns the stored result. Stripe, whose API made the pattern familiar, documents the details worth copying. It saves the status and body of the first execution whether it succeeded or failed, 500 errors included. Keys may be pruned after 24 hours. A key reused with different parameters is an error, because that is a client bug and not a retry. And nothing is saved when the request failed validation or collided with a request carrying the same key that was still running — those can simply be retried.
The header name is a de facto standard, not yet a formal one. An IETF draft has described it since 2021. Its seventh revision, of October 2025, proposes 422 for a key reused with a different payload and a conflict error for a retry that arrives while the original is still running. That draft expired in April 2026 without becoming an RFC. Follow its behaviour anyway, because client libraries already expect it. And remember the limit of the pattern: it makes one endpoint safe to retry. It does nothing for a chain of services behind that endpoint, each of which needs its own protection — the Microservices page covers that.
Lists
Pagination has one decision in it, and most APIs make it wrong. Offset pagination — ?page=3&size=20 — is easy to build and easy to picture. It breaks under exactly the conditions real data has. Insert a row while a client is reading page three, and one item shifts to page four: it is seen twice, or never. It also gets slower the deeper the client goes, because the database counts past every row it skips — the SQL page shows why. Cursor pagination hands the client an opaque token that means “continue from exactly here”. It stays correct while the data changes and stays fast at any depth. The cost is that the client cannot jump to page seven. For an API consumed by programs, nobody wanted to.
Three details make cursors work. Sort by something unique — the creation time plus the id as a tie-breaker — or two rows with the same timestamp will be split across pages unpredictably. Encode the cursor so callers cannot build one by hand, which leaves you free to change what is inside it. And cap limit: a caller who asks for a million rows in one page is an outage in waiting, which is row 8’s business. A total count is the last trap. Counting every matching row costs the database a full pass on each request, so make it optional, or an estimate, or absent.
Limits
An API without limits works until the first buggy script in a loop. Then one caller consumes the capacity everyone shares. Rate limits per client, from day one, turn that into a 429 Too Many Requests for the one caller, with a Retry-After header saying when to try again. Day one matters. Once some partner’s nightly import depends on sending ten thousand requests a minute, introducing a limit breaks them, and they will call it a breaking change, correctly. Limit what is expensive, not only what is frequent: page size, payload size, the depth and cost of a GraphQL query, and any request that triggers something that costs real money, such as an SMS. There is an IETF draft for standard RateLimit headers that tell callers how much they have left. In October 2026 it is still a draft, so most APIs use their own X-RateLimit-* headers, and that is fine if they are documented.
Slow work and concurrent edits
Some operations take minutes: a report, a video conversion, a bulk import. Holding the HTTP request open for that long fails at the first proxy or load balancerspreads requests across serversA server or managed service that receives incoming requests and spreads them across several copies of an application, skipping any copy that fails its health check. It is also one more network hop with its own timeouts, so its settings and the application’s have to agree.All terms with a shorter timeout, and the client cannot tell whether the work continues. Row 9’s answer is to return 202 Accepted at once, with a link to an operation resource. The client polls it, or receives a webhook when it finishes. The operation has a status — pending, running, succeeded, failed — and, when it is done, a link to the result. Google’s API guidelines call this a long-running operation and give every one of them the same shape, so a client written for one works for all.
Row 10 handles two people editing the same record. Both read version 5, both change it, and the second save silently erases the first. HTTP has had the answer for decades. Every response carries an ETag, a token for the current version. An update sends it back in If-Match. If the record has changed since, the server refuses with 412 Precondition Failed, and the client reloads instead of overwriting. A server that insists on this answers updates without If-Match with 428 Precondition Required. It is the same optimistic locking the SQL page describes inside the database, carried out to the caller.
Who may do what — the ten API risks, and the one behind most breaches
Two words get used as if they meant the same thing. Authentication answers “who are you?”. Authorization answers “may you do this?”. Row 11 is authentication, and it is mostly a matter of not inventing anything. Use a standard scheme — OAuth 2.0 tokens or API keys — in the Authorization header, never in the URL, where they end up in server logs, proxy logs and browser history. Then apply it to every route. A single route left open “because it is internal” is all an attacker needs.
Row 12 is where authorization fails, and it fails in a specific, repeatable way. A route checks that the caller is logged in. The handler then reads an id from the path — /invoices/9081 — and loads that record. Nothing checks that invoice 9081 belongs to the caller. Change the number and you read someone else’s invoice. OWASP calls this broken object level authorization and has ranked it first among API risks in both editions of its list. Frameworks do not prevent it, because the framework knows who the user is and does not know who owns invoice 9081. Only your code knows that.
OWASP’s API Security Top 10, in its 2023 edition — still the current one — lists ten risks. Every one of them is a design decision before it is a vulnerability, so here are all ten, each with what it looks like and the control that prevents it:
| # | Risk | What it looks like | The control |
|---|---|---|---|
| API1 | Broken object level authorization | GET /accounts/43 works for the owner of account 42 | Ownership in every query; a test per endpoint (row 12) |
| API2 | Broken authentication | Tokens that never expire; a password reset with no attempt limit | A standard scheme, short-lived tokens, limits on login and reset (row 11) |
| API3 | Broken object property level authorization | The response includes is_admin, and a PATCH can set it | Explicit schemas for what goes out and what may come in; no mass assignment |
| API4 | Unrestricted resource consumption | ?limit=1000000; an endpoint that sends an SMS with no cap | Caps on page size, payload and cost; per-client rate limits (row 8) |
| API5 | Broken function level authorization | An ordinary user calls DELETE /admin/users/7 and it works | Deny by default; a role check on every operation |
| API6 | Unrestricted access to sensitive business flows | Bots buy out a product launch; a contact lookup is used to match millions of phone numbers | Limits per business flow, not only per request; detection of automation |
| API7 | Server-side request forgery | A “fetch this URL” feature reaches internal addresses | An allow-list for outbound destinations |
| API8 | Security misconfiguration | Stack traces in error responses, permissive CORS, plain HTTP | Hardened defaults, checked in CI |
| API9 | Improper inventory management | An old version or a test host still live and unpatched | A list of every host and version, with an owner each (row 16) |
| API10 | Unsafe consumption of APIs | A partner’s response is trusted and passed on unchecked | Validate what partners send as strictly as what users send |
Read the list as a set of questions for a design review rather than a checklist for the security team. Five of the ten — API1, API3, API4, API5 and API6 — are decisions about the shape of the API itself. A penetration test finds them after release, at which point fixing them is often a breaking change. Two of the cases below, Optus and Facebook, are API9 and API6 at the scale of a country.
Changing a published API — compatibility, versions and removal
Versioning is usually discussed as a choice of mechanism. The decision that matters more is how rarely you need a new version at all. Most changes need none. Adding a field, adding an endpoint, adding an optional parameter, accepting a value you used to reject — all are additive, and a tolerant client keeps working. Publishing row 14 is half the work. State that clients must ignore unknown fields, and adding one stops being a breaking change by agreement rather than by luck. GitHub shows how far this goes: its REST API ran on a single dated version, 2022-11-28, for more than three years before the next one, 2026-03-10, appeared.
| Change | Breaking? | The safe way to make it |
|---|---|---|
| Add a response field | No — if clients were told to ignore unknown fields | Just add it |
| Add an optional request parameter | No | Its absence keeps the old behaviour |
| Add a required request field | Yes | Make it optional with a default, or add a new endpoint |
| Rename a field | Yes — a removal and an addition at once | Add the new name, return both, deprecate the old one, remove it after the window |
| Change a field’s type | Yes | A new field with the new type; Twitter’s id_str is the classic |
| Make a field nullable | Yes — clients that never saw null will crash on it | A new field, or a documented value instead of null |
| Tighten validation | Yes — requests that worked now fail | Log or warn first; enforce in the next version |
| Add an enum value | Usually | Say on day one that values may be added, and what a client should do with an unknown one |
| Change a default | Yes — behaviour changes with no change to the call | Offer the new behaviour behind an explicit parameter |
Change an error’s type or status code | Yes | Treat errors as part of the response contract (row 3) |
| Remove an endpoint or field | Yes | The removal procedure: row 16, below |
The enum row catches people out and is worth internalising. In most typed languages a client handles an enum with an exhaustive switch: three cases, compiled, tested. Then you add a fourth status, and its code meets a value it has no branch for. Depending on the language that is a crash, a silent default, or a deserialisation error that drops the whole response. If a set is going to grow — and order statuses, payment methods and event types always do — say so in the documentation from the first day, and tell clients what an unknown value should mean.
When a breaking change is genuinely needed, there are four common mechanisms. None of them is free; each moves the cost somewhere different.
| Mechanism | Example | Strength | Cost |
|---|---|---|---|
| Major version in the path | /v2/orders | Visible in logs, browsers and support tickets | A whole new tree for what may be one change; v1 lives for years |
| Dated version per account or request | Stripe-Version: 2024-09-30.acacia, X-GitHub-Api-Version: 2026-03-10 | Small steps; callers upgrade when they choose | The provider maintains a transformation for every past change |
| Media type | Accept: application/vnd.example.v2+json | Precise, per resource | Invisible in a browser and in most logs |
| No versions — additive change only | GraphQL fields marked @deprecated | Nothing to choose between | Works until a change cannot be made additive |
Whichever mechanism you choose, the cost is not creating a version. It is running two. Every fix has to be applied twice. Every support conversation begins by establishing which version the caller is on. And the old version never empties on its own. That is why row 16 is a procedure rather than a date.
Removing something
A removal has to reach callers who never read your changelogthe record of what changed, per versionA file, usually CHANGELOG.md, that lists the notable changes in each version of a piece of software, newest first, grouped as added, changed, deprecated, removed, fixed and security. It is written for the person deciding whether to upgrade, which is why a dump of commit messages does not count as one.Full article →All terms, and that means telling them in the response itself. Two standard headers exist for it. Deprecation, RFC 9745 of March 2025, carries the date from which something is deprecated, with a link to the explanation. Sunset, RFC 8594 of May 2019, carries the date after which it will stop working. Client libraries and API gateways can turn both into warnings in the caller’s own logs. Meanwhile you measure who still calls the old thing, per client, every week. Without that number you cannot answer the only question that matters on the removal date: who will break?
The last few callers are the ones nobody can reach: an integration whose author left, a script on a server nobody remembers. A brownout finds them while the change can still be undone. Switch the old behaviour off for a few scheduled hours, then switch it back on. Their alerts fire, and they come to you. GitHub did exactly this before removing password authentication from its API. It ran brownouts on 30 September and 28 October 2020, two three-hour windows each day, and the final switch-off came on 13 November 2020. Every one of those steps assumes something else: that you know who your callers are. That is row 16 again, and OWASP’s API9.
Five cases, and what each one was missing
Each case below is one row of the table at the top, played out in public, with the control that was missing or that worked.
| Case | What happened | What it shows |
|---|---|---|
| Twitter, 2010 | Tweet ids grew past 53 bits, the largest integer JavaScript can hold exactly; string copies of every id were added a month before | An id is a string even when it looks like a number (row 4) |
| Stripe, 2011–2024 | Every account pinned to the version of its first request; about 100 breaking changes in six years absorbed by transformations | Versioning is a cost the provider pays so callers do not have to (row 15) |
| Kubernetes, 2019–2021 | Old API versions stopped being served two years after their replacements shipped, and clusters broke anyway; warnings moved into the responses | A notice has to arrive in the response and be counted (row 16) |
| Optus, 2022 | The regulator alleges a 2018 coding error left an internet-facing API domain without working access control for four years | Every host in the inventory gets the same checks (rows 11–12, API9) |
| Facebook, 2018–2019 | A contact importer was used to match phone numbers to 533 million profiles; the regulator fined Meta €265 million | Limit the business flow, not just the request rate (row 8, API6) |
Twitter’s problem began as a success. In 2010 the company replaced its database-generated tweet ids with Snowflake, a service that produced 64-bit ids in many places at once. The API returned ids as JSON numbers. JavaScript stores every number as a 64-bit float, which holds integers exactly only up to 53 bits, so a JavaScript client reading a larger id silently got a different one. Twitter could not change the field’s type without breaking every client that worked. So, as reported on 18 October 2010, it added a second field, id_str, carrying the same id as a string, from 22 October, and warned that ids would pass 53 bits by 26 November. Clients had five weeks to switch. The lesson is row 4 and the type-change row of the table above: the safe change was an additive one, and it was only needed because the first version made a number of something nobody would ever do arithmetic on.
Stripe is the opposite story: a company that decided versioning was its cost, not its callers’. Brandur Leach described the system in August 2017. Each account is pinned to the newest version on its first request, and stays there until its owner upgrades. Versions are dates. Inside, the API builds every response at the current version, then walks it back through a chain of “version change modules”, one per breaking change, until it matches the version the caller is pinned to. By then Stripe had made about a hundred backwards-incompatible changes since 2011, and every old version still worked. In 2024 it added a rhythm on top: from 2024-09-30.acacia, monthly releases with no breaking changes and two named major releases a year. The lesson is row 15. The cheapest version for a caller is the one they never have to think about, and someone pays for that — Stripe chose to be the one.
Kubernetes shows that notice is not the same as migration. Its API has formal versions — v1beta1, v1 — and a published deprecation policy. In July 2019 the project announced that release 1.16 would stop serving the old beta versions of core resources such as Deployment and DaemonSet. Their stable replacement, apps/v1, had been available since release 1.9, two years earlier. Clusters broke anyway, because the people running old manifests and tools had never read the release notes that warned them. The fix came in release 1.19 and moved the notice into the response itself. Every request to a deprecated API now gets a warning header that kubectl prints in the user’s terminal. A metric, apiserver_requested_deprecated_apis, tells the cluster’s operator which deprecated APIs are still being called and in which release each will be removed. The lesson is row 16: a removal reaches people through the response and the metric, not through the announcement.
Where it degrades
API design rarely fails in one decision. It fails in a habit that nobody notices until the first consumer depends on it. Almost every painful API has several of these:
200 OKwith an error in the body, which hides the failure from every layer that reads status codes (row 2).- Database columns exposed as response fields, so every schema change becomes a breaking API change.
- Error messages as the only machine-readable signal, so clients match on your English (row 3).
- Sequential integer ids, which invite enumeration and break JavaScript at 53 bits (row 4).
- A
POSTthat charges money with no idempotency key, so every timeout is a support ticket (row 6). - Offset pagination on data that changes, which silently skips and repeats rows (row 7).
- A route that checks login but not ownership, which is the most common breach of all (row 12).
- A specification generated from the code after the fact, which records accidents as decisions (row 13).
- A new version for a change that could have been additive, which doubles maintenance for nothing (row 15).
- A deprecation announced only on a web page, so the old version still carries traffic three years later (row 16).
The honest limit is that good design buys time, not immunity. Hyrum’s law guarantees that with enough callers someone depends on behaviour you never promised, and no review catches what a caller has not done yet. Contract tests cover only the consumers who publish contracts, and the integration that breaks is usually the one whose author never heard of yours. So the measure of a well-designed API is not that it never breaks anyone. It is that a breaking change is rare, announced in the response, measured per caller, and undone in minutes when the measurement was wrong.
The practical check takes an afternoon. Request another account’s record through every endpoint that takes an id, and count the ones that answer. Send the same POST twice after a simulated timeout and see whether two things are created. Page through a list while inserting rows and see whether any are skipped. Find out whether a list of everyone calling the API — every host, every version, every client — exists anywhere. And search the codebase for 200 returned together with an error.
When to use it
Use it when
- Consumers are outside your deployment — another team, a partner, the public.
- The interface will outlive the implementation behind it, which is the normal case.
- You can write the specification first and have a consumer review it before anything is built.
- Contract tests can run in CI/CDContinuous Integration / Continuous Delivery — automatic build, test and deliveryEvery commit is built and put through the tests automatically (CI), and a build that passes travels to the servers the same way (CD). The point is not automation for its own sake: it is that a breakage shows up in minutes instead of on release day.Full article →All terms, so “is this breaking?” is answered by the build.
Avoid it when
- Two modules inside one deployable — a function signature is the contract, and the compiler checks it.
- A single consumer you deploy together with — you can change both at once, so the ceremony buys nothing.
- The shape is still changing weekly — stabilise the domain before publishing a promise about it.
- Versioning a public interface as a substitute for deciding what belongs in the contract.
Sources and method
What this page stands on
- 28 sources
- 2 documented failures
- Checked October 13, 2026
Practices and where the terms come from
the sixteen decisions: methods, errors, retries and the rules for change- StandardRFC 9110 — HTTP Semantics (opens in a new tab)IETF · Jun 2022
- StandardRFC 10008 — The HTTP QUERY Method (opens in a new tab)IETF · Jun 2026
- StandardRFC 9457 — Problem Details for HTTP APIs (opens in a new tab)IETF · Jul 2023
- StandardRFC 6585 — Additional HTTP Status Codes (opens in a new tab)IETF · Apr 2012
- StandardRFC 7396 — JSON Merge Patch (opens in a new tab)IETF
- StandardRFC 9745 — The Deprecation HTTP Response Header Field (opens in a new tab)IETF · Mar 2025
- StandardRFC 8594 — The Sunset HTTP Header Field (opens in a new tab)IETF · May 2019
- StandardRFC 9413 — Maintaining Robust Protocols (opens in a new tab)IETF · Jun 2023
- StandardOpenAPI Specification v3.2.0 (opens in a new tab)OpenAPI Initiative · Sep 2025
- StandardGraphQL — September 2025 edition (opens in a new tab)GraphQL Foundation · Sep 2025
- StandardThe Idempotency-Key HTTP Header Field (draft-ietf-httpapi-idempotency-key-header-07) (opens in a new tab)IETF · Oct 15, 2025
- StandardRateLimit header fields for HTTP (draft-ietf-httpapi-ratelimit-headers-11) (opens in a new tab)IETF
- DocumentationStripe API Reference — Idempotent requests (opens in a new tab)Stripe
- StandardOWASP Top 10 API Security Risks – 2023 (opens in a new tab)OWASP · 2023
- ArticleHyrum Wright — Hyrum’s Law (opens in a new tab)hyrumslaw.com
- ArticleMartin Fowler — TolerantReader (opens in a new tab)martinfowler.com · May 9, 2011
- ArticleIan Robinson — Consumer-Driven Contracts: A Service Evolution Pattern (opens in a new tab)martinfowler.com · Jun 12, 2006
- DocumentationGitHub REST API — API Versions (opens in a new tab)GitHub Docs
- DocumentationTroubleshooting the REST API (opens in a new tab)GitHub Docs
The failures this page learns from
Optus and Facebook, in “Five cases, and what each one was missing”Twitter, Stripe and Kubernetes
the other three cases: changing a contract people depend on- ArticleSnowflake: An update and some very important information (opens in a new tab)Twitter Development Talk · Oct 2010
- ArticleTwitter’s Snowflake Project To Update Tweet IDs Really Is More Like A Blizzard Now (opens in a new tab)TechCrunch · Oct 18, 2010
- ArticleBrandur Leach — APIs as infrastructure: future-proofing Stripe with versioning (opens in a new tab)Stripe · Aug 15, 2017
- ArticleIntroducing Stripe’s new API release process (opens in a new tab)Stripe · 2024
- ArticleDeprecated APIs Removed In 1.16: Here’s What You Need To Know (opens in a new tab)Kubernetes Blog · Jul 18, 2019
- ArticleJordan Liggitt — Warning: Helpful Warnings Ahead (opens in a new tab)Kubernetes Blog · Sep 3, 2020
- ArticleKubernetes API and Feature Removals In 1.22: Here’s What You Need To Know (opens in a new tab)Kubernetes Blog · Jul 14, 2021
Researched with Claude. Finding and cross-checking the primary sources was done with Claude (Anthropic). Selection, writing and fact-checking: the Anatomy of IT Product editors.
Found this useful?
Share it with someone who is working on the same problem.