Appearance
9.6.3 — Writes That Survive Reality: Idempotency, ETags, Versioning
A failed read is annoying. You try again and get your data. A failed write is a genuinely hard problem, because trying again might be the right move or might charge the customer twice, and from where the client is standing those two situations look identical.
Three specific things go wrong with writes, and each has a mechanism that fixes it.
The network times out after you sent a payment, and now nobody knows whether the charge happened. The fix is an idempotency key. Two people edit the same record at the same time, and the second save quietly erases the first. The fix is an ETag with a conditional request. And your API has to change while forty clients you cannot update are still calling it — 3.6.1's permanence problem again, this time with a version number attached. The fix is versioning discipline, most of which turns out to be restraint rather than mechanism.
All three are small. All three get asked about in backend interviews, because they are exactly the things that separate an API that demonstrates well from one that is allowed to move money.
1. The retry problem, and idempotency by design
Someone taps "Pay ₹499" on a phone. The app sends POST /payments. Thirty seconds pass and no response arrives.
Sit inside the client for a second, because this is the whole problem. Two completely different things could have happened. The request may never have reached the server, in which case nothing was charged and retrying is not just safe but necessary. Or the request arrived, the card was charged, and the response was lost coming back — in which case retrying charges the customer a second time. From the client's side these are indistinguishable. There is no observation it can make, no timeout it can tune, that tells them apart. 2.7 calls this the lost-reply problem, and it has no client-side solution.
So the client is stuck between two bad options: do not retry and possibly lose a real order, or retry and possibly double-charge someone. Every network hop in the world manufactures this dilemma. A phone on a train manufactures it several times an hour.
The server has to be the one that fixes it, and there are two ways.
The free way: design operations that do not care how many times they run. This is where 9.6.1's verb contract stops being pedantry and starts being money. PUT /carts/current/items/7 {"quantity": 3} says set the quantity to 3. Send that twice, five times, fifty times, and the cart still holds three. Now compare POST /carts/current/items/7/increment, which says add one. Send it twice on a flaky connection and the customer bought two.
The two endpoints do the same job from the user's point of view. Only one of them survives a retry. Whenever the domain lets you express an operation as "make it be X" instead of "change it by X", take it — you get retry safety for free and write no code at all. ⚑Idempotency keys. [EQ-968]
Where it doesn't (create/charge/send), add the machinery — the idempotency key:
http
POST /payments
Idempotency-Key: 3f2a8c1e-order-9142-attempt # client-generated, per logical operation
Content-Type: application/json
{"orderId": "9142", "amount": 49900, "currency": "INR"}Server-side mechanics, precisely — each step exists for a failure mode:
typescript
async function handlePayment(req: Request): Promise<Response> {
const key = req.headers["idempotency-key"];
if (!key) return Response.badRequest("Idempotency-Key required"); // (1)
const prior = await idemStore.claim(key, requestHash(req.body)); // (2) ATOMIC
if (prior.state === "completed") return prior.response; // (3) replay
if (prior.state === "in-flight") return Response.conflict( // (4) concurrent dup
{ code: "REQUEST_IN_FLIGHT", retryAfter: 2 });
if (prior.state === "mismatch") return Response.unprocessable( // (5) same key,
{ code: "IDEMPOTENCY_KEY_REUSED" }); // different body
const result = await chargeAndRecord(req.body); // (6) the real work
await idemStore.complete(key, result); // (7) store response
return result;
}Walk the numbered lines, because each one exists because of a specific way this breaks.
(1) The key is required, not optional. An optional safety mechanism is one that the client which most needs it will forget.
(2) This claim has to be atomic — one indivisible operation that either succeeds or finds the key already taken. If you instead write the obvious two lines, "look up the key, and if it is absent create it", you have built 2.4's check-then-act race. Two retries arriving in the same millisecond both look, both find nothing, both proceed, both charge. The correct implementations are an insert against a unique constraint, where the database rejects the second one, or Redis SET NX, which sets the value only if it does not already exist. In both cases exactly one caller wins and the other is told so.
(3) If this key already completed, do not run anything — send back the response you stored the first time, byte for byte, including its 201 and its body. This is the line that dissolves the client's dilemma entirely. The client can now retry as often as it likes; the effect happens once and the client always sees the same answer as if it had happened cleanly the first time. That is the actual promise, and it is worth being precise about it: the operation may execute once but be observed many times, and every observation agrees.
(4) If the key is claimed but not yet finished, the first attempt is still running right now. Do not start a second one. Tell the client to come back in a moment.
(5) The stored hash of the request body catches a different bug: the same key sent with different content. That is never a legitimate retry — it means the client is reusing keys, perhaps generating one per app session instead of one per payment. Reject it loudly, because the alternative is replaying the wrong stored response and telling the client a payment succeeded that never existed.
Three operational details separate a working implementation from a demo.
Scope keys per caller. Store them keyed by (apiKey, idempotencyKey), not the key alone. Two different customers can generate the same key, and if they share a namespace one customer's payment can return the other customer's response.
Give keys an expiry. Twenty-four to forty-eight hours covers any realistic retry, including a client that queued the request offline overnight. Keeping them forever means a table that only grows, and eventually the lookup that guards every payment is the slowest query you have.
Complete the key in the same transaction as the effect. Look at the gap between line (6) and line (7): the charge is recorded, then the key is marked complete. If the process dies between those two lines, the money moved and no record says so, and the next retry charges again. Doing both in one transaction closes that window.
When the effect lives in another company's system — an actual payment provider — you cannot put their work in your transaction. What you do instead is pass an idempotency key to them as well, and store it, so their deduplication protects the hop you do not control. That is the important idea to carry forward: retry safety only works if every hop in the chain participates. Part 10.4 treats this as a distributed-systems problem in its own right.
2. ETags and conditional requests: versions per resource
An ETag (entity tag) is an opaque version identifier the server attaches to a resource's current state: ⚑ETags. [EQ-969]
http
GET /articles/42
→ 200, ETag: "v17-8c1d" # hash of content, or a version counter — server's choiceOne mechanism, two superpowers, chosen by which conditional header the client sends: ⚑Conditional requests. [EQ-970]
Cache validation — If-None-Match (the read side): "give me the body only if it changed":
http
GET /articles/42
If-None-Match: "v17-8c1d"
→ 304 Not Modified # empty body — bandwidth ≈ 0, client reuses its copyThis is the revalidation half of HTTP caching ([5.6] owns the full story with Cache-Control); for APIs it means list-heavy mobile clients skip re-downloading unchanged data.
Optimistic concurrency — If-Match (the write side): this is the cure for the lost update problem, which deserves to be spelled out slowly because it is invisible when it happens.
Two admins open the same product page at 10:00. Both get ETag: "v17". One fixes the description; the other corrects the price. At 10:05 the first saves, and the product becomes v18 with the new description. At 10:06 the second saves — and their form still contains the old description, because they loaded it before the change. Their save overwrites the whole record. The first admin's work is gone.
Notice what did not happen. No error was raised. Nothing was logged as a failure. Both admins saw a success message. The bug surfaces days later as "I definitely fixed that description, it must not have saved". With preconditions, none of this happens: ⚑Optimistic concurrency. [EQ-971]
http
PUT /products/9
If-Match: "v17-8c1d" # "apply ONLY if it's still the version I edited"
→ 200, ETag: "v18-2ab0" # admin A wins; version advances
PUT /products/9 # admin B, still holding v17
If-Match: "v17-8c1d"
→ 412 Precondition Failed # [!code highlight] — conflict SURFACED, not swallowedThe second admin now gets a 412 instead of a false success, and their client can do the useful thing: fetch the current version, show what changed while they were editing, and let them merge or confirm. The collision is still a collision — you have not prevented two people from editing at once — but it has turned into a moment in the interface instead of a hole in the data.
On the server this is one SQL statement:
sql
UPDATE products
SET description = $3, version = version + 1
WHERE id = $1 AND version = $2; -- $2 is the version from the ETagIf that reports one row updated, the caller's assumption held and you return 200 with the new ETag. If it reports zero rows updated, someone else changed the row first, and you return 412. The check and the write happen in the same statement, so no other transaction can slip between them — the same conditional-write trick that fixed the oversold-inventory problem in 9.5.2. At the database layer this technique is called optimistic locking ([7.4]); the ETag is simply its face on the HTTP side.
If a resource is genuinely contested and you want to force clients to participate, answer 428 Precondition Required when a write arrives with no If-Match at all. Otherwise the careless client keeps its silent-overwrite behaviour and only the careful one is protected.
Why is it called optimistic? Because nothing is locked while a human thinks. The pessimistic alternative takes a lock when the admin opens the edit form and holds it until they save — which works right up until someone opens the form and goes to lunch, and the record stays locked for an hour ([7.4] covers when that trade is still worth making). The optimistic version assumes collisions are rare, lets everyone work freely, and detects the collision at the moment of writing. When conflicts really are rare, redoing the occasional lost edit costs far less than managing locks across a fleet of servers.
One detail on tags themselves: an ETag written as W/"v17" is a weak tag, meaning "this is equivalent enough for caching purposes" — the content may differ in ways that do not matter, like whitespace. That is fine for If-None-Match. It is not fine for concurrency, where you need to know the state is exactly what you read. Preconditions want strong tags.
3. Versioning: evolving under consumers you can't update
Before choosing how to spell a version, learn the discipline that means you rarely need one. It is called additive evolution, and it is just this: only ever add.
These changes do not break a client that was written sensibly. Adding a new field to a response. Adding a new optional field to a request, with the server filling in a default when it is absent. Adding a whole new endpoint. Accepting input you previously rejected. And adding a new value to an enum — but only if you said on day one that the enum was open, meaning clients must handle values they have never seen. Write that sentence into your documentation before you have any users. It costs nothing then and is impossible to add later, because by then clients exist whose code crashes on an unknown status.
These changes break clients, every time: removing a field, renaming a field, changing a field's type or format, rejecting input you used to accept, changing the status code or error code for a situation clients already handle, and adding a required request field.
The discipline has two halves, and it only works when both sides hold. The server only adds. The client reads tolerantly — it ignores response fields it does not recognise instead of throwing, it does not assume it has seen every enum value, and it never depends on the order of keys. Put both halves in the documentation. Together they push the versioning question years into the future, and either side alone is not enough: a strict client parser that throws on unknown fields makes even a safe addition a breaking change. ⚑API versioning strategies. [EQ-966]
When a break is unavoidable, the strategies, honestly compared:
| Strategy | Looks like | Visible in logs | Best for |
|---|---|---|---|
| In the URI | /v2/orders | Yes | Most APIs |
| In a header | Api-Version: 2026-07-01 | Only if you log it | Large partner APIs |
| Query parameter | ?version=2 | Yes | Internal tools |
Version in the URI unless you have a reason not to. It shows up in every log line, every bookmark, and every support ticket where someone pastes a curl command. It routes trivially, so /v1 and /v2 can be entirely separate deployments during a migration. Purists object that the same underlying thing now has two addresses, which is philosophically true and has never once cost anyone a weekend.
Version in a header when you want the URLs to stay clean, and accept the cost: the version is now invisible unless someone thinks to look, and any cache in front of you must be told to vary its stored copies by that header or it will serve v1 responses to v2 clients.
There is a stronger variant of the header approach worth knowing by name. Instead of v2, the version is a date, and each customer is pinned to the date they signed up. Their integration keeps behaving exactly as it did on the day they built it, forever, and they upgrade when they choose to. This is the best experience a partner can have, and it is expensive: you are committing to a layer inside your server that transforms today's responses back into the shape each old date expects, and to keeping those transforms correct for years. Choose it when you have thousands of integrations and a team to maintain it.
Three rules apply no matter which spelling you pick.
Version the contract, not the code. /v1 and /v2 must be two thin translation layers over one set of business logic. The moment you fork the logic, every bug is fixed twice and eventually only once. This is the adapter shape from 9.6.1's modernization answer.
Make deprecation an operation, not an announcement. Send Deprecation and Sunset headers with real dates on old responses. Measure who is still calling, per customer, so you know exactly whom to contact. Then before shutting anything off, run a brown-out: make v1 fail deliberately for fifteen minutes at a scheduled time. The teams who ignored six emails will discover their dependency, and they will discover it during business hours rather than at your shutdown deadline.
Version coarsely. One version for the whole API, or one date. Versioning each endpoint separately sounds tidy and produces a matrix nobody can reason about, where a client is on v3 of orders and v1 of payments and no one can say which combinations were ever tested.
4. The expert lens
All three mechanisms do the same thing: they turn a silent assumption into something the server can check.
Look at what each header is really saying. An idempotency key says "I believe this operation has not run yet." If-Match says "I believe the state I read is still current." A version says "I believe the contract I wrote my code against is still in force." In every case the client was already assuming that. The assumption was simply invisible, so the server had no way to notice when it was false.
That is the shift worth taking away. Without these headers the server is guessing what the client meant; with them the server is verifying what the client declared. And every failure this page fixes — the double charge, the erased edit, the integration that broke on a Tuesday — is the same kind of bug: a real assumption nobody wrote down. When a class of API bug keeps recurring, that is the question to ask. Which unstated assumption needs to become a header?
Retry safety belongs to the whole chain, not to one endpoint. You can make POST /payments perfectly idempotent and still double-charge people, if the payment provider you call behind it is not. All you did was move the duplicate one hop further away, where it is harder to see. An adapter around a downstream service (9.4.7) must pass the guarantee along, not quietly absorb it.
So trace any path that touches money from end to end and check every hop: the client's key, your deduplication store, the key you send to the payment provider, the reference the bank uses. Safety exists only where every link participates, and a single link without it is the only link that matters. Part 10.4 treats this as a distributed-systems primitive. In an interview, drawing that chain before anyone asks you to is one of the strongest signals you can give.
Versioning is mostly restraint and only slightly mechanism. The recurring team failure is spending three weeks arguing about URI versus header, and then breaking every client on a Tuesday by renaming a field because it read better.
Rank the three things by what they actually save you. Additive evolution plus tolerant clients prevents most versions from ever being needed. A real deprecation practice — headers, dates, per-consumer telemetry, brown-outs — makes the versions you cannot avoid survivable. The choice of where the version string lives matters least of all; pick the URI and move on unless you are running a partner platform large enough to staff date-pinning. The underlying law does not bend: an interface with users you cannot update cannot be un-shipped. It can only be managed.
What the interviewer will push on
This page is the densest interview surface in the folder, because these three mechanisms are exactly what senior interviewers use to find out whether you have run something in production.
"Your payment endpoint times out. What does the client do?" They are checking whether you notice that the client genuinely cannot decide alone. Say that first: request-lost and response-lost are indistinguishable from outside, so the client has no correct policy — the server must make retrying safe. Then describe the key. The weak answer is "retry with exponential backoff", which is the right transport behaviour attached to the wrong layer of thinking, and it double-charges people.
"Where do you store idempotency keys, and what breaks if you get it wrong?" They want the atomicity. Say unique-constrained insert or SET NX, and say why: a read-then-write check loses to two simultaneous retries, which is the exact scenario the whole mechanism exists for. Then name the second trap — the gap between doing the work and recording that you did it — and close it with one transaction. Mentioning per-customer scoping and a TTL is what makes it sound like something you have operated rather than designed on a whiteboard.
"How long do you keep the keys, and what does a client get if it retries after they expire?" This one catches people who memorised the happy path. The honest answer names the trade: keys are kept long enough to cover realistic retries, typically a day or two, and after that the same key looks brand new, so the operation would run again. That is acceptable precisely because no legitimate client retries a payment two days later — and if yours might, that is a business decision about the window, not a detail to leave undecided.
"Two users edit the same record. What happens?" They are looking for you to name the lost update and to notice it is silent. The full answer walks the ETag, the If-Match, the 412, and the UPDATE ... WHERE version = ? underneath, and finishes by describing what the losing user sees — because the goal was never to prevent the conflict, it was to turn it into something a person can resolve. Mentioning 428 shows you have thought about clients that simply do not send the header.
"Optimistic or pessimistic locking here?" They want a decision driven by conflict rate and by how long the work takes. Optimistic when collisions are rare and redoing work is cheap, which covers nearly all web editing. Pessimistic when collisions are common and losing the work is expensive, or when a human is inside the transaction and cannot be asked to redo their input. The tell of experience is mentioning the lock held by someone who went to lunch.
"How do you add a required field to an existing request?" A small question with a trap in it: you cannot, not to an existing version. Add it optional with a sensible default, watch adoption in your telemetry, and only make it required in a new version once the old clients are gone. Anyone who answers "just add it, we'll tell the clients" has never watched a partner integration break at midnight.
Volunteer this one, because nobody asks: say that you would run a scheduled brown-out before retiring an old version — deliberately failing it for a short, announced window while people are awake. Every team sends deprecation emails; almost nobody verifies that anyone read them, and the alternative to a brown-out is finding out on shutdown day, at the worst possible hour, from a customer.
Next: 9.6.4 — turning the contract into a real artifact: OpenAPI, generated types and mocks, and the checklist that closes the folder.
Recall
- The retry dilemma: after a timeout the client can't know if the write landed — so make writes retry-safe. First: naturally idempotent semantics (set-to-N over increment, PUT over POST where honest). Then: idempotency keys — client-generated per logical op; server atomically claims (unique insert/
SET NX), replays stored responses for completed keys,409s in-flight duplicates,422s body-mismatch reuse; keys scoped per consumer, TTL'd, completed in the same transaction as the effect. - ETag = opaque per-resource version.
If-None-Match→304cache revalidation (read side).If-Match→ optimistic concurrency: stale write →412(lost update becomes a UI conflict, not silent loss); compiles to compare-and-setWHERE version = ?; strong tags for preconditions;428to mandate them. - Versioning: additive evolution + tolerant readers defer it for years (adding fields/endpoints/documented-open enums = safe; rename/retype/tighten = break). When forced: URI
/v2default (visible, routable) · date-pinned headers for Stripe-shaped B2B · version the contract over one domain core; deprecate operationally (Sunsetheaders, telemetry, brown-outs). - Lens: all three = client assumptions made explicit (hasn't-run / state-I-read / contract-I-coded); retry-safety must compose down the whole call chain; restraint beats mechanism in versioning.
Self-test: Why can't the client resolve the timeout dilemma alone? Walk the five idempotency-store states and why the claim is atomic. Show the lost update and its 412 cure end to end. Which changes are additive — and what must clients promise for that to work? When is date-pinning worth its machinery?
Quiz Bank
FoundationalWhat problem do idempotency keys solve, and how does a production implementation actually work?
The retry dilemma: after a timeout, the client cannot distinguish request-lost (safe to retry) from response-lost (retry = double effect) — so for non-idempotent writes (charges, creates, sends) the server must make retries safe. Mechanism: client sends a per-logical-operation key (Idempotency-Key: <uuid>); the server atomically claims it (unique-constrained insert / Redis SET NX — atomicity is non-negotiable: two simultaneous retries racing a check-then-act would both execute, 2.4). Claim outcomes: completed → replay the stored original response (the retry observes the first outcome — effectively-once effects); in-flight → 409/retry-later (no second execution while the first runs); mismatch (same key, different body-hash) → 422 (client bug surfaced); fresh → execute, then store the response against the key — ideally in the same transaction as the business effect, or a crash between effect and store re-opens the window. Production details: scope keys per consumer, TTL them (24–48 h), and forward idempotency downstream (your PSP call carries its key) — safety composes or it doesn't exist.
FoundationalExplain the lost update problem and how ETag + If-Match cures it — including what the server executes.
Two clients read the same resource (both receive ETag: "v17"), edit concurrently, and save; with unconditional PUTs the later save overwrites the earlier silently — no error anywhere, one admin's work simply vanishes (the nastiest kind of bug: invisible at commit time, discovered as "my changes disappeared"). Cure — optimistic concurrency via preconditions: writes carry If-Match: "v17" ("apply only if this is still the version I edited"); the server compiles it to an atomic compare-and-set — UPDATE products SET …, version = version + 1 WHERE id = $1 AND version = $2 — affected-rows-1 ⇒ 200 + new ETag; affected-rows-0 ⇒ 412 Precondition Failed, and the losing client refetches, shows a merge/confirm UI, resubmits. The conflict becomes a designed moment instead of data loss. Supporting cast: 428 Precondition Required rejects unconditional writes to contested resources (opt-out impossible); strong ETags (not W/-weak) for preconditions; and note the same conditional-write shape solved 9.5.2's oversell — HTTP's ETag is the API face of [7.4]'s optimistic locking.
AppliedList the additive-vs-breaking change catalog, and the two-sided contract that makes additive evolution safe.
Additive (safe under the contract below): new response fields; new optional request fields (with server defaults); new endpoints/verbs on new paths; new enum values iff the enum was documented open ("clients must handle unknown values" — declare this on day one, it costs nothing then and is unretrofittable later); loosening validation (accepting more).
Breaking: removing or renaming anything; changing types/formats (string→number, date format shifts); tightening validation (rejecting what was accepted); changing status codes or error codes for existing situations; changing defaults or semantics of existing fields; adding required request fields.
The two-sided contract: servers promise additive-only evolution within a version; clients promise tolerant reading — ignore unknown response fields, don't exhaustively switch over open enums (or route unknowns to a default arm), never depend on field order. Both sides written into the API docs make most "versioning" unnecessary for years; either side defecting (client strict: true deserialization that throws on new fields; server renaming a field "just this once") collapses it. This restraint layer outranks the version-spelling debate — most broken integrations are additive-discipline failures, not missing /v2s.
InterviewCompare URI, header, and date-pinned versioning — and say what should be versioned regardless of spelling.
URI (/v2/orders): explicit in every log/curl/bookmark; trivially routable (v1/v2 as separate deployments — useful during migrations); caches split naturally. Objection: "same resource, two URIs" offends REST purity — the industry shipped it anyway; the pragmatic default.
Header (Api-Version: or media-type): clean URIs; but versions vanish from casual observation (curl, logs, browser), caches need Vary, and docs/debugging carry the header everywhere. Date-pinned (Stripe's model): each account pins the version current at signup (2026-07-01); every request runs under the pinned date; upgrades are per-account opt-in. Gold standard for huge B2B surfaces — nobody breaks, ever — but it's machinery: server-side transform layers mapping requests/responses across dozens of dated versions, tested combinatorially; adopt only with that staffing reality.
Regardless of spelling: version the contract, not the business logic — v1/v2 are adapters over one domain core (9.6.1's strangler shape), or you fork your domain and die of drift; deprecate operationally (Deprecation/Sunset headers, per-consumer telemetry, outreach, brown-outs before shut-off); and version coarsely — per-API, never per-endpoint soup.
StaffA payments API you review: POST /charges with client retries enabled, no idempotency keys ('the PSP dedupes for us'), unconditional PUTs on merchant config, and a planned /v2 that renames amount to amountMinor 'for clarity'. Deliver the review.
Three findings, each a section-mapped incident-in-waiting. (1) "The PSP dedupes for us" is a half-truth that fails exactly when it matters: PSP dedupe keys off their request identity — if your service crashes after the PSP call but before recording the charge, your retry constructs a new PSP request (new reference, new dedupe identity) and double-charges; and duplicates arriving at your layer (mobile retry storms) each become distinct PSP calls. The chain composes or it doesn't (section 4): require Idempotency-Key at your edge, atomic claim + response replay (section 1's five states), completion recorded in the same transaction as your charge row, and your PSP call carrying a key derived from yours — end-to-end effectively-once, sketchable as client→edge→ledger→PSP with a key at every arrow.
(2) Unconditional config PUTs = lost updates on money-routing settings: two ops editing payout schedules will eventually silently erase each other (section 2's exact scenario, with compliance stakes); mandate ETags + If-Match (CAS on a version column), 428 on unconditional writes, and a conflict UI in the dashboard.
(3) The rename is a breaking change purchasing nothing: "clarity" renames are the standard permanence-law violation — amountMinor can be added beside amount (additive, section 3), documented as preferred, with amount deprecated over a measured horizon; a /v2 justified only by renames is all migration cost, zero consumer value — spend /v2 budget on breaks that buy something, batched. Close the review with the policy trio: idempotency keys mandatory on every non-idempotent money route (lint the route table); preconditions mandatory on shared mutable config; and a breaking-change checklist (who breaks, what they gain, why additive can't) required before any version bump — the 9.6.4 review gate, applied.
Flashcards
FlashThe retry dilemma
Timeout ⇒ client can't distinguish request-lost from response-lost. Cure order: naturally idempotent semantics first; idempotency keys where creation/charging makes that impossible.
FlashIdempotency store states
Fresh → execute + store (same tx as effect) · completed → replay stored response · in-flight → 409 retry-later · body-hash mismatch → 422. Claim atomically; scope per consumer; TTL.
FlashETag's two faces
If-None-Match → 304 (cache revalidation). If-Match → 412 on stale write (optimistic concurrency = CAS WHERE version = ?). Strong tags for preconditions; 428 to mandate.
FlashAdditive vs breaking
Add fields/endpoints/open-enum values = safe (with tolerant readers). Rename/retype/tighten/re-code = break. Contract: server adds only; client ignores unknowns.
FlashVersioning spellings
URI /v2 = visible, routable, default · header = clean but invisible (+Vary) · date-pinned = per-account, B2B gold, heavy machinery. Version the contract over ONE domain core.
FlashThe unifying lens
Idempotency key / If-Match / version = client assumptions (hasn't-run / state-held / contract) made explicit and server-checkable. Retry-safety composes down the chain or doesn't exist.
Scenario Drill
DrillDesign the write-path contract for a ticket-booking API: POST /bookings (seat + payment, mobile clients on flaky networks), PATCH /bookings/{id} (change seat before event), admin bulk price updates, and a partner reseller integration pinned to your API for 3 years. Specify every header, status, and store — and the one place you'd deliberately relax the machinery.
POST /bookings — the full section 1 stack, because it's money + creation + flaky networks: Idempotency-Key required (428-style rejection without it — 400 IDEMPOTENCY_KEY_REQUIRED); atomic claim keyed (apiKey, key); the booking row, seat decrement (the conditional atomic write from 9.5.4 — WHERE seats_left > 0 — overselling stays unrepresentable independently of idempotency), payment record, and key-completion all in one transaction; stored response replayed on retry (201 with the same booking body — the user who retried three times in a tunnel has one seat and one charge); in-flight duplicates 409 REQUEST_IN_FLIGHT retryAfter: 2 (mobile clients auto-retry politely); PSP call carries a derived key (chain composition, section 4). TTL 48 h.
PATCH /bookings/{id} — optimistic concurrency, because seat changes race (the user's two devices; the user vs an admin): booking responses carry ETag; PATCH requires If-Match (428 Precondition Required otherwise — contested mutable resource); stale → 412 + current representation in the error body so clients render "this booking changed — reload"; server-side CAS on a version column. Seat-change also re-runs the seat-availability conditional write — two mechanisms, two different races (lost update vs oversell), both closed.
Admin bulk price updates — the deliberate relaxation: per-row If-Match on a 5,000-row bulk update is ceremony that admins will script around; instead the bulk endpoint takes a snapshot token (the export's cursor/timestamp) and applies server-side conflict policy (skip-changed + report, or overwrite with an explicit force: true and an audit entry — 9.4.15's Command audit). Relaxation is chosen and logged, not defaulted — the machinery's absence is itself a documented decision.
The 3-year partner — versioning as a contract of restraint: pin them on /v1 with the additive-evolution + tolerant-reader contract written into the partnership agreement (enum fields documented open; unknown-field tolerance required of their client — section 3's two-sided promise made legally boring); Sunset/Deprecation headers wired from day one even with nothing deprecated (so their tooling learns to watch); per-partner version-usage telemetry; and any future break batched into a dated /v2 with a 12-month overlap.
The envelope everywhere: Problem-Details errors with machine codes (SEAT_UNAVAILABLE vs PRECONDITION_FAILED vs IDEMPOTENCY_KEY_REUSED — clients branch, support reads traceId). Summary line for the design doc: every write names its assumption — hasn't-run (key), state-held (ETag), contract-held (version) — and the server verifies instead of guessing; the one relaxation is explicit, audited, and admin-only.