An API contract is a set of promises about behavior, not only about shapes. A schema tells a client which fields exist; the contract tells it what happens when it sends the same request twice, what a 409 means versus a 422, and whether an order of results is guaranteed. Clients do not read your documentation at 3 a.m. — they read your status codes and your retry behavior. Everything they infer from those becomes the real contract, whether or not you wrote it down.
What the contract actually specifies
HTTP gives you coarse intent for free:
- Safe methods (
GET,HEAD,OPTIONS) promise no server-side state change. A client can prefetch them, replay them, and send them through a proxy without thinking. - Idempotent methods (
GET,PUT,DELETE,HEAD,OPTIONS) promise that applying the request twice has the same effect as applying it once.POSTis neither.
Everything resource-specific is yours to define: which fields are required, what
preconditions you honor (If-Match for optimistic concurrency), what ordering a list
guarantees, what the error taxonomy looks like, and what the side effects of an operation
are beyond the resource it names.
Two implementations of the same schema can have wildly different semantics:
PUT /invoices/inv_42 replace the entire resource with the body; repeat = same state
PATCH /invoices/inv_42 merge the fields you sent; repeat = same state
POST /invoices create a NEW resource every time; repeat = N resourcesThe trap is that clients learn semantics from observation. If GET /orders returns
newest-first today and someone "optimizes" it to sort by primary key, every client with
offset pagination silently starts duplicating and skipping rows. Undocumented behavior
becomes de-facto contract the moment the first client depends on it.
Idempotency: making POST safe to retry
The classic failure: the client sends POST /invoices, your server creates the invoice,
and the response is lost — the client's 500 ms timeout fired 200 ms after the work
committed. The client retries. You now have two invoices for one purchase.
The fix is an idempotency key: the client generates a unique identifier once per logical operation and sends it on every attempt of that operation.
POST /v1/invoices HTTP/1.1
Host: api.example.com
Idempotency-Key: 3f6c1d0e-8a41-4c3f-9d2b-7f1a5e0b9c11
Content-Type: application/json
{"customer":"cus_812","amount_cents":4999}key := r.Header.Get("Idempotency-Key")
prev, err := store.Get(ctx, key)
if err == nil && prev.RequestFingerprint == fingerprint(r.Body) {
// Replay the stored result instead of doing the work twice.
w.WriteHeader(prev.Status)
w.Write(prev.ResponseBody)
return
}
if err == nil && prev.InFlight {
w.WriteHeader(409) // same key, concurrent attempt: tell the client to retry
return
}
// Claim the key, run the work, store status + body, then release.Four details decide whether this actually works:
- Persist the response, not only a "seen" flag — a client that lost the response needs the same body back, including the created resource's ID.
- Fingerprint the request body. Reusing one key with a different body is a client bug;
answer it with
422rather than silently returning the first request's result. - Make claiming atomic — a unique constraint or a short-lived lock, so two concurrent retries collapse into one execution instead of both passing the check.
- Retain keys at least as long as clients retry. 24 hours covers essentially every retry loop that exists; shorter retention re-opens the duplicate window.
See idempotency keys in depth and the idempotency trace for the full flow.
Errors are part of the contract too
A client's retry policy is a function of two things: the status class and whether the
operation is idempotent. If your API collapses everything into 400 Bad Request or
500 Internal Server Error, clients either retry forever or never retry — both wrong.
| Status | Meaning | Client should |
|---|---|---|
| 400 / 422 | request will never succeed as written | fix and resend, no retry |
| 401 | not authenticated | refresh credentials, then retry |
| 403 | authenticated, not authorized | do not retry |
| 404 | resource unknown (or hidden) | stop, surface to user |
| 409 | conflict with current state | re-read state, then retry |
| 429 | rate limited | retry after Retry-After seconds |
| 503 / 504 | transient, try later | retry with backoff and jitter |
Retry storms are what happen when this is vague. Ten thousand clients that all wait a flat 500 ms after an outage ends deliver roughly 20,000 requests inside one second — enough to knock the recovering service back down. The standard mitigation is full jitter, which spreads the herd instead of synchronizing it:
delay = random_between(0, min(cap, base * 2^attempt))
# base 100ms, cap 30s, attempt counting from 0Your side matters too: return Retry-After on 429 and 503, and put a request ID in
every error body so a support ticket can be tied to one server log line.
What you can change without a new version
Backward compatibility is about behavior as much as field names. The usual breakers:
- Renaming or removing a response field, or changing its type (
42to"42"). - Adding a response field — safe for parsers that ignore unknown keys, fatal for strict
decoders (Go's
DisallowUnknownFields, strict TypeScript schemas). - Adding or removing an enum value. Strict clients reject values they don't recognize, so adding one can break them too.
- Changing a default sort order, a default page size, or a default filter.
- Tightening validation: requests that used to succeed now return
400, and old clients have no way to discover why.
Error code strings belong to the contract. Clients switch on "INSUFFICIENT_FUNDS"; if
you rename it, their branch silently stops matching and falls through to generic handling.
Versioning only when policy says so
Version the surface only when a break is unavoidable, and pick the mechanism knowing how caching works:
- URL versioning (
/v2/users) — easy to spot in a log, cacheable by URL, simple to route and deprecate; the cost is two code paths living side by side. - Header negotiation (
Accept: application/vnd.example.v2+json) — clean URLs, but every cache between you and the client must sendVary: Accept. Miss that once and a CDN serves v1 bodies to v2 clients, which produces a genuinely confusing incident. - Query parameter (
?version=2) — trivial to implement, easy to lose in links and logs.
Whatever you choose, publish a deprecation policy with dates: send the Sunset header
(RFC 8594) with a real date, keep the old version serving for 6–12 months, and measure
traffic before removing anything. A good rule for retirement is old-version traffic below
1% of total for 30 consecutive days.
Where this bites in production
- Retries without idempotency keys duplicate money movements; retries with keys and no body fingerprint hide client bugs. Both show up as support tickets, not as errors.
- "We only changed the sort order" is the most common silent break in offset-paginated APIs.
- A missing
Vary: Acceptturns header versioning into a cache-poisoning bug across every CDN you use. - Every undocumented default is a contract you signed without reading it. Write down the semantics that clients will otherwise infer — ordering, retries, errors, side effects — because they will infer them anyway.