DevelopmentAll levels

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.

#DecisionThe default to takeWhyChanging it later
1Names and methodsNouns in the path, and the method says the action: GET /orders/42, POST /ordersCaches, proxies, client libraries and logs already understand methods; verbs in the path (/getOrder) throw that awayBreaking: every caller has the path in its code
2Status codesThe right class first: 2xx done, 4xx the caller’s mistake, 5xx yoursRetry policies, load balancers and alerts branch on the class and never read the bodyBreaking: clients branch on the code
3ErrorsRFC 9457 problem details: a stable type for programs, a detail for peopleA program needs a token to branch on; a person at 2 a.m. needs a sentenceBreaking: clients already parse the old shape
4IdentifiersOpaque strings that cannot be guessed — never sequential integersSequential ids invite enumeration and reveal your volume; 64-bit integers lose precision in JavaScriptBreaking: the type of every id field changes
5Data formatsTimestamps in RFC 3339 with an offset; money as an integer in minor units plus a currency; one naming styleA local time without an offset and a floating-point amount are both wrong in ways no test catchesBreaking, field by field
6A request sent twicePUT and DELETE idempotent by design; an Idempotency-Key header on every POST that creates or chargesAfter a timeout the caller does not know whether the action happened; a retry without a key does it twiceAdditive: an optional header, but every client has to start sending it
7ListsCursor pagination with a capped limit and a stable sort orderOffset pages skip and repeat rows while the data changes, and slow down with depthBreaking: the paging parameters change
8LimitsPer-client rate limits from day one; 429 with Retry-AfterWithout them one buggy script is everyone’s outageBreaking in practice: someone already relies on there being none
9Slow work202 Accepted with a link to an operation the caller can poll, or a webhookA request held open for minutes dies at the first proxy timeoutBreaking: a synchronous response becomes asynchronous
10Concurrent editsAn ETag on every resource; If-Match on updates, 412 when the version is staleOtherwise two clients editing one record silently overwrite each otherAdditive: optional headers
11AuthenticationA standard scheme — OAuth 2.0 or API keys — in the Authorization header, on every route, no exceptionsCredentials in a URL end up in logs and browser history; one open route is all an attacker needsBreaking for the routes that were open
12Authorization per objectEvery request that names a record checks that this caller may touch this recordChecking 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
13The descriptionAn OpenAPI document written before the code, reviewed by a consumer, checked in CIWritten after the code, it documents accidents as decisionsCheap now; later every endpoint has to be described from its behaviour
14Compatibility rulesPublished: clients ignore unknown fields, enums may grow, nothing is removed without noticeAdding a field is safe only if clients were told to ignore what they do not knowFree on day one; impossible to impose afterwards
15VersionsA major version in the path, or a dated version per account — and as few of them as you can manageEvery live version doubles every fix and every support conversationThe mechanism is cheap to pick and expensive to change
16RemovalA published window, Deprecation and Sunset headers, and usage measured per client before anything is switched offA notice on a web page reaches the people who read it; a header and a metric reach everyone elseThis is the procedure for changing every other row
Six of the sixteen decisions from the table at the top of the page, placed along one request. Everything above the line is the contract: consumers depend on it, so changing any of it is a breaking change other people have to act on. Everything below the line is implementation, which you can rewrite whenever you like. A decision that ends up on the wrong side of that line is what makes an API expensive to live with.

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. 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.

WhoStrict or tolerantWhat that means in practice
The server, receiving a requestStrictValidate against the description; reject with a clear 400 or 422; never guess what the caller meant
The server, sending a responseConservativeOnly documented fields, in documented formats, in a documented order
The client, reading a responseTolerantRead what you need, ignore unknown fields, handle an enum value you have never seen
The client, sending a requestConservativeSend 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 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.

StyleFitsStrengthCostIts compatibility rule
HTTP + JSON (REST)Public and partner APIsCacheable, debuggable with curl, understood by every proxyOver- and under-fetching; many round trips for one screenRows 14–16 of this page
GraphQLSeveral rich clients needing different slices of one graphThe client asks for exactly the fields it needs, in one requestHTTP caching is lost; query cost and depth must be limited (row 8)No versions: fields are added, and old ones marked @deprecated
gRPCCalls between services you runFast, typed, generated clients, streaming in both directionsNot readable in a browser or with curl; needs HTTP/2 end to endNever reuse a field number; mark removed ones reserved
Events and webhooksTelling others that something happenedThe caller does not poll; producers and consumers are decoupled in timeDelivered at least once, sometimes out of order; consumers must be idempotentThe 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 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.

MethodSafeIdempotentUse it forThe trap
GETYesYesReadingA GET that changes state gets triggered by prefetchers and crawlers
POSTNoNoCreating, and any action that is not a replaceA retry creates a second one — hence the idempotency key (row 6)
PUTNoYesReplacing a whole resource at a known addressA partial body wipes the fields it left out
PATCHNoNot by definitionChanging part of a resourceIn JSON Merge Patch, null deletes a field and a missing field is left alone
DELETENoYesRemovingThe second call returns 404 or 204; either is fine if documented
QUERYYesYesA 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:

CodeWhat it tells the callerUse it when
200 / 201 / 204Done; created (with a Location); done, nothing to returnThe request succeeded
202Accepted, not finishedThe work continues after the response (row 9)
400I could not read your requestMalformed JSON, a wrong type, a missing required header
401I do not know who you areNo credentials, or invalid ones — despite its name, “Unauthorized” means unauthenticated
403I know who you are, and you may not do thisThe operation is forbidden for this caller (row 12)
404There is nothing here — for youThe record does not exist, or the caller must not learn that it does
409The current state does not allow thisA duplicate, an order already shipped, a retry while the first attempt is running
412Your copy is out of dateIf-Match did not match the current ETag (row 10)
422I read it, and it is not validWell-formed but wrong: an end date before the start date
429Too many requests — waitA rate limit was hit; send Retry-After (row 8)
500 / 503Our fault; we are overloaded or down for nowAnything 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 — 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.

A timeout on a POST leaves the client unable to tell whether the payment happened: the request may have died on the way in, or the payment may have gone through and only the response was lost. Without a key, a retry is a new request and the card is charged twice. With an Idempotency-Key, the client sends the same random key with the request and with every retry; the server stores the status and body of the first execution under that key and returns them to any retry instead of running the request again. Stripe saves the result even when it is an error, may prune keys after 24 hours, and treats a key reused with different parameters as an error.

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 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.

Authentication answers who the caller is; authorization answers what they may do — first whether they may call the operation at all, then whether they may touch the particular record the request names. Frameworks answer the first two questions, so both pass. The third is object-level authorization, and only application code can answer it, because only the application knows who owns invoice 9081. Without it, changing the number in the path reads someone else’s data — broken object level authorization, first in both editions of the OWASP API Security Top 10. Putting the owner into the query itself means another account’s record is simply not found, and the 404 does not reveal that it exists.

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:

#RiskWhat it looks likeThe control
API1Broken object level authorizationGET /accounts/43 works for the owner of account 42Ownership in every query; a test per endpoint (row 12)
API2Broken authenticationTokens that never expire; a password reset with no attempt limitA standard scheme, short-lived tokens, limits on login and reset (row 11)
API3Broken object property level authorizationThe response includes is_admin, and a PATCH can set itExplicit schemas for what goes out and what may come in; no mass assignment
API4Unrestricted resource consumption?limit=1000000; an endpoint that sends an SMS with no capCaps on page size, payload and cost; per-client rate limits (row 8)
API5Broken function level authorizationAn ordinary user calls DELETE /admin/users/7 and it worksDeny by default; a role check on every operation
API6Unrestricted access to sensitive business flowsBots buy out a product launch; a contact lookup is used to match millions of phone numbersLimits per business flow, not only per request; detection of automation
API7Server-side request forgeryA “fetch this URL” feature reaches internal addressesAn allow-list for outbound destinations
API8Security misconfigurationStack traces in error responses, permissive CORS, plain HTTPHardened defaults, checked in CI
API9Improper inventory managementAn old version or a test host still live and unpatchedA list of every host and version, with an owner each (row 16)
API10Unsafe consumption of APIsA partner’s response is trusted and passed on uncheckedValidate 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.

ChangeBreaking?The safe way to make it
Add a response fieldNo — if clients were told to ignore unknown fieldsJust add it
Add an optional request parameterNoIts absence keeps the old behaviour
Add a required request fieldYesMake it optional with a default, or add a new endpoint
Rename a fieldYes — a removal and an addition at onceAdd the new name, return both, deprecate the old one, remove it after the window
Change a field’s typeYesA new field with the new type; Twitter’s id_str is the classic
Make a field nullableYes — clients that never saw null will crash on itA new field, or a documented value instead of null
Tighten validationYes — requests that worked now failLog or warn first; enforce in the next version
Add an enum valueUsuallySay on day one that values may be added, and what a client should do with an unknown one
Change a defaultYes — behaviour changes with no change to the callOffer the new behaviour behind an explicit parameter
Change an error’s type or status codeYesTreat errors as part of the response contract (row 3)
Remove an endpoint or fieldYesThe 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.

MechanismExampleStrengthCost
Major version in the path/v2/ordersVisible in logs, browsers and support ticketsA whole new tree for what may be one change; v1 lives for years
Dated version per account or requestStripe-Version: 2024-09-30.acacia, X-GitHub-Api-Version: 2026-03-10Small steps; callers upgrade when they chooseThe provider maintains a transformation for every past change
Media typeAccept: application/vnd.example.v2+jsonPrecise, per resourceInvisible in a browser and in most logs
No versions — additive change onlyGraphQL fields marked @deprecatedNothing to choose betweenWorks 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 , 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?

Removing something from a published API is a procedure, not a date. The deprecation goes into every response from the first day, in the Deprecation header (RFC 9745) and the Sunset header (RFC 8594), and calls to the old version are counted per client every week. Most callers migrate early; a long tail does not, because nobody reads the notice — an integration whose author left, a forgotten script. A brownout switches the old version off for a few scheduled hours, so those callers’ alerts fire while the change can still be undone; GitHub ran two before removing password authentication in November 2020. On the sunset date the old version answers 410 Gone. The curve is illustrative; the shape is the procedure.

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.

CaseWhat happenedWhat it shows
Twitter, 2010Tweet ids grew past 53 bits, the largest integer JavaScript can hold exactly; string copies of every id were added a month beforeAn id is a string even when it looks like a number (row 4)
Stripe, 2011–2024Every account pinned to the version of its first request; about 100 breaking changes in six years absorbed by transformationsVersioning is a cost the provider pays so callers do not have to (row 15)
Kubernetes, 2019–2021Old API versions stopped being served two years after their replacements shipped, and clusters broke anyway; warnings moved into the responsesA notice has to arrive in the response and be counted (row 16)
Optus, 2022The regulator alleges a 2018 coding error left an internet-facing API domain without working access control for four yearsEvery host in the inventory gets the same checks (rows 11–12, API9)
Facebook, 2018–2019A contact importer was used to match phone numbers to 533 million profiles; the regulator fined Meta €265 millionLimit 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 OK with 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 POST that 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 , 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

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.