REST API design that clients can rely on
How do you design an HTTP API whose methods, status codes, errors, pagination and versions let clients retry safely, avoid lost updates and survive change?
Good REST is less about URLs and more about letting HTTP semantics do work for you: safe and idempotent methods tell clients and proxies what may be retried, status codes and problem details tell them what to do next, and validators, idempotency keys and cursors make concurrent, unreliable traffic correct.
THE MENTAL MODEL
Treat every response as an instruction to a program, not a message to a human. The client, its HTTP library, caches and gateways all branch on the method, the status code and a few headers: may I retry, should I wait (Retry-After), is my copy stale (ETag), did someone else change this (412), is this endpoint going away (Deprecation, Sunset). Design resources around nouns the client cares about, keep the semantics of each method honest (GET never mutates, PUT and DELETE are idempotent, POST is made retryable with an Idempotency-Key), and publish the whole thing as an OpenAPI contract that your tests enforce. Fielding's REST also asks for hypermedia (HATEOAS); almost nobody ships it, and that is fine as long as the contract, not link-following, is what clients depend on.
HOW IT FITS TOGETHER
- Client A → APIGET /orders/42
- API → Client A200, ETag: "v7"The ETag is derived from a row version column, not from hashing the JSON.
- Client B → APIGET /orders/42
- API → Client B200, ETag: "v7"
- Client A → APIPATCH /orders/42, If-Match: "v7"
- API → DatabaseUPDATE ... WHERE id=42 AND version=7Compare and set in one statement; checking first and writing later reintroduces the race.
- Database → API1 row updated, version=8
- API → Client A200, ETag: "v8"
- Client B → APIPATCH /orders/42, If-Match: "v7"
- API → DatabaseUPDATE ... WHERE version=7
- Database → API0 rows updated
- API → Client B412 Precondition Failed + problem+jsonNot retryable as-is: B must GET again, merge or ask the user, then resend with the new ETag.
- Client B → APIPATCH without If-Match
- API → Client B428 Precondition RequiredRequiring the precondition (RFC 6585) stops clients that forget it from causing lost updates.
| Meaning | Client action | Retry? | |
|---|---|---|---|
| 200 / 201 / 204 | Done; 201 carries Location of the new resource | Use the body or follow Location | No |
| 202 Accepted | Queued; a status resource tracks progress | Poll the Location URL, honour Retry-After | Poll, do not resubmit |
| 304 Not Modified | Cached copy still valid (If-None-Match) | Reuse the cached body | No |
| 400 / 422 | Malformed or semantically invalid input | Fix the request; show field errors | No |
| 401 / 403 | Not authenticated / not allowed | Refresh credentials once (401); stop (403) | 401 once after refresh |
| 404 / 410 | Missing / gone for good | Stop; drop local references on 410 | No |
| 409 Conflict | State conflict, or same Idempotency-Key in flight | Re-read state; for an in-flight key, wait | Key in flight: yes, later |
| 412 / 428 | Stale If-Match / precondition missing | GET, merge, resend with the new ETag | Only after re-reading |
| 429 Too Many Requests | Rate limited | Wait Retry-After or RateLimit reset | Yes, after the wait |
| 500 | Server bug; outcome unknown | Report; treat the write as maybe applied | Only if idempotent |
| 502 / 503 / 504 | Upstream or capacity problem; often transient | Back off, honour Retry-After on 503 | Yes, if idempotent |
KEY TERMS
- Resources and method semantics
- URLs name things (/orders/42/items), methods name actions. RFC 9110 defines GET, HEAD, OPTIONS and TRACE as safe, and those plus PUT and DELETE as idempotent; POST and PATCH are neither by default. Clients, proxies and HTTP libraries retry idempotent requests automatically after a dropped connection, so a GET that mutates or a non-idempotent PUT breaks under ordinary network failure. For complex read queries that do not fit in a URL, the new QUERY method (RFC 10008, June 2026) is a safe, idempotent, cacheable request with a body; until your stack supports it, a POST to a search resource is the pragmatic fallback.
- Problem details (RFC 9457)
- One error shape for the whole API: application/problem+json with type (a stable URI clients switch on), title, status, detail and instance, plus extension members such as errors[] for field validation or traceId. RFC 9457 obsoletes RFC 7807. Clients must ignore unknown extensions, so you can add fields without breaking anyone. Never put the only machine-readable signal in detail; that text is for humans and will change.
- Idempotency keys for POST
- The client generates a unique Idempotency-Key per logical operation and resends the same key on retry. The server stores key, request fingerprint and final response atomically: a replay returns the stored response, a concurrent duplicate gets 409, and the same key with a different body gets 422 (draft-ietf-httpapi-idempotency-key-header-08, still an Internet-Draft but widely implemented, e.g. by Stripe). Scope keys per client or account and expire them after a documented window.
- Conditional requests
- ETag plus If-None-Match turns polling and cache revalidation into cheap 304s. ETag plus If-Match turns writes into compare-and-set: the server applies the change only if the current representation still matches, otherwise 412. If-Match uses strong comparison, so weak validators (W/"...") cannot guard writes. Return 428 when a write arrives without a precondition you require. Set Cache-Control deliberately on every GET (private for per-user data), and pair 429 with Retry-After plus, if you want clients to pace themselves, the RateLimit and RateLimit-Policy fields from draft-ietf-httpapi-ratelimit-headers (still a draft, -11).
- Pagination, filtering, field selection
- Offset/limit is simple and lets users jump to page 37, but it degrades linearly and skips or duplicates rows when data changes between pages. Cursor (keyset) pagination encodes the last seen sort key in an opaque token: stable under inserts and index-friendly, at the cost of no random access. Keep filters and sorts explicit and allow-listed (?status=paid&sort=-created_at), require a unique tiebreaker in every sort, and offer sparse fieldsets (?fields=id,total) or expansions (?expand=customer) for over- and under-fetching instead of inventing new endpoints per screen.
- Versioning and deprecation
- The cheapest version is the one you never cut: additive changes (new optional fields, new endpoints, new enum values clients were told to tolerate) need no version. For breaking changes, version the whole API in the path (/v2) or with a dated header (Stripe-style), not per-endpoint. Announce removal with Deprecation (RFC 9745, a structured date such as @1767225599), Sunset (RFC 8594, an HTTP date not earlier than Deprecation) and a Link rel="deprecation" to the migration guide, then watch traffic by client before switching anything off.
- Long-running and bulk operations
- If work outlives a request timeout, return 202 Accepted with Location pointing to an operation resource (/operations/abc) that reports pending, succeeded with a link to the result, or failed with a problem document. For bulk writes, accept an array, process each item independently and return per-item results; RFC 9457 discourages a generic batch problem type, so report item failures inside the success body or a single problem with an errors[] extension.
IN YOUR STACK
TypeScript · Hono Server
import { Hono } from "hono";
const app = new Hono();
const problem = (status: number, type: string, title: string, detail?: string) =>
new Response(JSON.stringify({ type, title, status, detail }), {
status, headers: { "content-type": "application/problem+json" } });
app.get("/orders/:id", async (c) => {
const order = await db.orders.find(c.req.param("id"));
if (!order) return problem(404, "https://api.example.com/problems/not-found", "Order not found");
c.header("ETag", `"v${order.version}"`);
return c.json(order);
});
app.patch("/orders/:id", async (c) => {
const ifMatch = c.req.header("If-Match");
if (!ifMatch) return problem(428, "https://api.example.com/problems/precondition-required", "Send If-Match");
const version = Number(ifMatch.replace(/^"v|"$/g, ""));
// Compare-and-set in one statement: UPDATE ... WHERE id = $1 AND version = $2
const updated = await db.orders.updateIfVersion(c.req.param("id"), version, await c.req.json());
if (!updated) return problem(412, "https://api.example.com/problems/stale-version",
"Order changed since you read it", "GET the order again and reapply your change.");
c.header("ETag", `"v${updated.version}"`);
return c.json(updated);
});- Derive the ETag from a version column so the precondition check and the write are one atomic statement. Hashing the serialized body works for caching but not for compare-and-set.
- db.orders is your data layer; updateIfVersion returns null when zero rows matched.
- Expose ETag in Access-Control-Expose-Headers if browsers on another origin need to read it.
Python · FastAPI Server
import hashlib, json
from typing import Annotated
from fastapi import FastAPI, Header, Request
from fastapi.responses import JSONResponse
app = FastAPI()
def problem(status: int, slug: str, title: str) -> JSONResponse:
return JSONResponse({"type": f"https://api.example.com/problems/{slug}", "title": title, "status": status},
status_code=status, media_type="application/problem+json")
@app.post("/payments", status_code=201)
async def create_payment(request: Request, idempotency_key: Annotated[str | None, Header()] = None):
if not idempotency_key:
return problem(400, "idempotency-key-missing", "Idempotency-Key is missing")
body = await request.body()
fingerprint = hashlib.sha256(body).hexdigest()
# INSERT ... ON CONFLICT DO NOTHING: only one request wins the key
record = await store.claim(account_id(request), idempotency_key, fingerprint)
if record.fingerprint != fingerprint:
return problem(422, "idempotency-key-reused", "Key reused with a different payload")
if record.state == "in_progress" and not record.claimed_by_me:
return problem(409, "idempotency-key-in-flight", "A request with this key is still running")
if record.state == "done":
return JSONResponse(record.response_body, status_code=record.response_status) # replay
payment = await charge(json.loads(body))
await store.complete(record, 201, payment)
return payment- store, account_id and charge are your persistence, auth and domain layers. claim must be atomic (unique constraint on account + key), or two concurrent retries both charge.
- Status codes follow draft-ietf-httpapi-idempotency-key-header: 400 missing, 422 reused with a different payload, 409 still in flight.
- Persist the final response, including failures you decided are terminal, so a replay returns exactly what the first caller would have seen.
Go · net/http (Go 1.22+ routing) Server
type cursor struct {
CreatedAt time.Time `json:"c"`
ID int64 `json:"i"`
}
func listOrders(w http.ResponseWriter, r *http.Request) {
limit := max(1, min(parseIntOr(r.URL.Query().Get("limit"), 50), 200))
after := cursor{CreatedAt: time.Now().Add(time.Hour), ID: math.MaxInt64}
if raw := r.URL.Query().Get("cursor"); raw != "" {
b, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil || json.Unmarshal(b, &after) != nil {
writeProblem(w, 400, "invalid-cursor", "Cursor is malformed or expired")
return
}
}
// Keyset: (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT $3
rows := queryOrders(r.Context(), after, limit+1)
page := map[string]any{"data": rows[:min(len(rows), limit)]}
if len(rows) > limit {
last := rows[limit-1]
b, _ := json.Marshal(cursor{last.CreatedAt, last.ID})
page["next_cursor"] = base64.RawURLEncoding.EncodeToString(b)
}
writeJSON(w, 200, page)
}
// mux.HandleFunc("GET /v1/orders", listOrders)- Fetch limit+1 rows to know whether a next page exists without a COUNT(*). parseIntOr, queryOrders, writeJSON and writeProblem are small local helpers.
- The id tiebreaker makes the order total; without it rows sharing a timestamp are skipped or repeated across pages. Back it with an index on (created_at DESC, id DESC).
- Treat cursors as opaque. Signing or encrypting them stops clients from hand-crafting values you then have to support forever.
Java · Spring Boot 3 / Spring MVC Server
@RestControllerAdvice
class ApiErrors {
@ExceptionHandler(InsufficientStockException.class)
ProblemDetail outOfStock(InsufficientStockException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT, "Only " + ex.available() + " left of " + ex.sku());
pd.setType(URI.create("https://api.example.com/problems/insufficient-stock"));
pd.setTitle("Not enough stock");
pd.setProperty("sku", ex.sku());
pd.setProperty("available", ex.available());
return pd;
}
}
@RestController
@RequestMapping("/v1/reports")
class ReportController {
@PostMapping
ResponseEntity<Void> start(@RequestBody ReportRequest req) {
String opId = reports.enqueue(req); // work continues in a job runner
return ResponseEntity.accepted()
.location(URI.create("/v1/operations/" + opId))
.header("Retry-After", "5")
.build();
}
}- spring.mvc.problemdetails.enabled=true renders Spring's own exceptions (validation, 405, 415) as RFC 9457 too, so framework and domain errors share one shape.
- instance is filled from the request path when you leave it unset.
- The operation resource should return 200 with status pending/succeeded/failed and, on success, a link to the produced report.
TypeScript · fetch (browser, Node, Deno) Client
const RETRYABLE = new Set([429, 502, 503, 504]);
export async function send(url: string, init: RequestInit & { idempotent?: boolean } = {}) {
const headers = new Headers(init.headers);
const method = (init.method ?? "GET").toUpperCase();
if (method === "POST" && init.idempotent) headers.set("Idempotency-Key", crypto.randomUUID());
const canRetry = ["GET", "HEAD", "PUT", "DELETE"].includes(method) || headers.has("Idempotency-Key");
for (let attempt = 0; ; attempt++) {
const res = await fetch(url, { ...init, headers, signal: AbortSignal.timeout(10_000) });
if (res.ok || !canRetry || !RETRYABLE.has(res.status) || attempt === 3) return res;
const retryAfter = Number(res.headers.get("Retry-After"));
const backoff = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: Math.random() * 2 ** attempt * 500; // full jitter
await new Promise((r) => setTimeout(r, backoff));
}
}
const res = await send("/v1/payments", { method: "POST", idempotent: true, body: JSON.stringify(p) });
if (res.headers.get("content-type")?.startsWith("application/problem+json")) {
const problem = await res.json();
if (problem.type === "https://api.example.com/problems/insufficient-stock") showStock(problem.available);
}- The key is generated once, outside the loop, so every retry of the same logical payment carries the same Idempotency-Key.
- Branch on problem.type, never on title or detail text.
- This sketch only handles the delta-seconds form of Retry-After and does not retry thrown network errors or timeouts; those are exactly the ambiguous cases the Idempotency-Key exists for, so catch and retry them the same way.
YAML · OpenAPI 3.2 Client + server
openapi: 3.2.0
info: { title: Orders API, version: "2026-11-01" }
paths:
/v1/orders/{id}:
get:
operationId: getOrderV1
deprecated: true
responses:
"200":
description: The order
headers:
ETag: { schema: { type: string } }
Deprecation: { description: "RFC 9745, e.g. @1767225599", schema: { type: string } }
Sunset: { description: "RFC 8594 HTTP date", schema: { type: string } }
Link: { description: '<https://api.example.com/docs/v2-migration>; rel="deprecation"', schema: { type: string } }
content:
application/json: { schema: { $ref: "#/components/schemas/OrderV1" } }
"404": { $ref: "#/components/responses/Problem" }
components:
responses:
Problem:
description: RFC 9457 problem details
content:
application/problem+json: { schema: { $ref: "#/components/schemas/Problem" } }- OpenAPI 3.2 (current patch 3.2.1) adds a query operation for the HTTP QUERY method, additionalOperations for other methods, and streaming media types such as SSE.
- Make the document the contract: generate clients from it, validate requests and responses against it in tests, and diff it in CI (oasdiff, Optic) to block breaking changes.
- deprecated: true only changes documentation; the Deprecation and Sunset headers are what running clients and gateways can see.
WHEN TO REACH FOR WHAT
REST + OpenAPI
Use when Public, partner or multi-team APIs over resources; you want HTTP caching, CDN support, curl-ability and the widest client ecosystem.
Avoid when One client composes deeply nested data from many sources per screen, or you need high-throughput typed streaming between internal services.
GraphQL
Use when Many first-party screens with different data needs over a shared graph, and you can invest in cost limits, trusted documents and DataLoader.
Avoid when Public APIs that need CDN caching and simple per-request rate limits, or simple CRUD with one consumer.
gRPC / Connect
Use when Internal service-to-service calls, streaming, strict schemas across many languages, low latency.
Avoid when Browser-first public APIs, cache-heavy reads, or consumers who expect curl and JSON.
RPC-style JSON over HTTP
Use when Operations are verbs that do not map to resources (POST /v1/invoices:send), within an otherwise RESTful API.
Avoid when As the whole design: you lose method semantics, caching and safe retries everywhere.
WHERE IT BITES
- Lost updates from read-modify-write without preconditions. Two clients PATCH the same resource and the last one wins silently; require If-Match on writes and return 412 or 428.
- Retrying non-idempotent POSTs from clients, gateways or service meshes. A timeout does not mean failure; without an Idempotency-Key the retry charges twice. Configure gateway retries only for idempotent methods.
- Inconsistent error formats: HTML from the load balancer, {error} from one service, {message} from another, and 200 with an error body. Normalize everything at the edge to application/problem+json with stable type URIs.
- Chattiness and N+1 endpoints: a list endpoint that forces one GET per item for details. Add ?expand= or embed the commonly needed fields, and batch reads (GET /orders?ids=1,2,3) instead of making clients loop.
- Breaking changes shipped as "just a rename" or a newly required field. Mobile apps stay installed for years; make only additive changes within a version and enforce it with an OpenAPI diff in CI.
- Offset pagination over a hot table: page 2000 scans and discards 100 000 rows, and concurrent inserts shift items between pages. Use keyset cursors for feeds and exports.
CLOSE THE AI. EXPLAIN THIS.
Two support agents edit the same refund and one change disappears; a mobile client double-charges after a timeout; a partner breaks when you rename a field. For each, name the HTTP mechanism (header, status code or contract rule) that prevents it and the exact response the client receives.WHEN IT BREAKS IN PRODUCTION
SOURCES
- RFC 9110: HTTP Semantics (opens in new tab)Checked
- RFC 9457: Problem Details for HTTP APIs (opens in new tab)Checked
- IETF draft: The Idempotency-Key HTTP Header Field (opens in new tab)Checked
- RFC 9745: The Deprecation HTTP Response Header Field (opens in new tab)Checked
- RFC 10008: The HTTP QUERY Method (opens in new tab)Checked
- OpenAPI Specification (latest) (opens in new tab)Checked
Explainer reviewed