Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
API design: an API is a promise you keep for years.
Code you can refactor; an API you can only regret. The moment a second team — or a paying customer, or someone's autonomous agent — builds against your interface, every field name becomes a commitment and every sloppy semantic becomes someone's production incident. This handbook is about making promises deliberately: which paradigm, which semantics, and how to evolve without breaking the people who trusted you.
Three paradigms, one honest decision
| Paradigm | Its actual superpower | Reach for it when |
|---|---|---|
| REST | universality — every language, proxy, cache and curl understands it | public APIs, partner integrations, anything a browser touches |
| gRPC | typed contracts + HTTP/2 + streaming + codegen | internal microservices, polyglot teams, latency-sensitive paths |
| GraphQL | client-shaped responses from one endpoint | many frontends with different data needs over one graph |
The pattern that actually ships: REST at the edge, gRPC inside — public simplicity where strangers integrate, typed efficiency where your own services chat thousands of times per second. GraphQL earns its complexity only when the many-frontends problem is real; adopted for fashion, it hands you resolver N+1s, cache invalidation pain and query-cost policing for nothing in return.
REST done properly: resources, not verbs
REST's discipline is modeling: nouns in URLs, verbs from HTTP. GET /orders/17, POST /orders, DELETE /orders/17 — not /getOrder, /createOrder, /cancelOrder2. Sub-resources for containment (/orders/17/items), query params for filtering and sorting, and status codes used to mean what they mean: 201 with a Location on create, 404 vs 403 chosen deliberately, 422 for validation, 429 with Retry-After.
A new consumer can guess your endpoints. If they know /orders exists, they should correctly predict how to list, fetch, create, and filter. Every endpoint that needs explaining is a design debt payment someone makes forever.
Actions that genuinely aren't CRUD (cancel, approve, retry) get modeled honestly as sub-resources or action endpoints (POST /orders/17/cancellation) — the sin isn't the exception, it's pretending everything fits and shipping PUT /orders/17?action=cancel&mode=3.
Methods: safe, idempotent, and PUT vs PATCH
HTTP methods carry promises defined in RFC 9110, and clients, proxies and caches act on them. Safe means the request doesn't change server state, so crawlers and prefetchers may send it freely. Idempotent means sending it twice has the same effect as sending it once, so a client may retry it after a timeout.
| Method | Safe | Idempotent | Use it for |
|---|---|---|---|
| GET, HEAD, OPTIONS | yes | yes | reads; never change state on a GET |
| PUT | no | yes | create or fully replace a resource at a known URL |
| DELETE | no | yes | remove; a repeat may return 404, but the state is the same |
| POST | no | no | create in a collection or run an action; add an idempotency key |
| PATCH | no | not guaranteed | partial updates |
PUT vs PATCH is replace vs modify. A PUT carries the complete new representation: a field you leave out is removed or reset, which is why PUT is idempotent, and why a client that PUTs a stale copy silently erases someone else's change. A PATCH carries a description of a change, usually in one of two standard formats. JSON Merge Patch (RFC 7396) sends just the fields to change, with null deleting one. JSON Patch (RFC 6902) sends a list of operations such as add, remove and test. A merge patch that sets fields behaves idempotently; a JSON Patch that appends to an array does not.
Conditional requests: ETags stop lost updates
Two clients GET the same order, both edit it, both write it back, and the second write silently erases the first: the lost update a database prevents with a version column. HTTP's version column is the ETag:
GET /orders/17
→ 200 OK
ETag: "v7"
PATCH /orders/17
If-Match: "v7"
Content-Type: application/merge-patch+json
{"shipping": {"city": "Lyon"}}
→ 412 Precondition Failed (someone saved v8 first)
Content-Type: application/problem+json
{"type": "https://api.example.com/errors/stale-version",
"title": "The order changed since you read it",
"status": 412,
"detail": "Re-read the order, reapply your change and retry.",
"instance": "/orders/17"}
The server applies the change only if the tag still matches the current version; otherwise it returns 412, and the client re-reads, reapplies its edit and retries. A server can insist on the check by answering unconditional writes with 428 Precondition Required. The same headers do two more jobs: If-None-Match: * on a PUT means "create only if nothing is here", and If-None-Match with a tag on a GET lets the server answer 304 Not Modified with no body. If-Match uses strong comparison, so a weak tag such as W/"v7" never satisfies it. The error body is RFC 9457 Problem Details: a stable type URI a program can branch on, a human-readable title and detail, and room for your own fields, such as a correlation id.
gRPC: the contract-first workhorse
gRPC inverts the workflow: you write the protobuf contract first — messages and services in a .proto file — and generate clients and servers in every language from it. The wire format is compact binary, the transport is HTTP/2 (multiplexed streams, one connection), and you get four call shapes for free: unary, server-streaming, client-streaming, bidirectional. For service-to-service traffic it's simply better physics than JSON-over-HTTP/1.1 — smaller, faster, typed, and impossible to misspell a field against.
Its evolution rules are beautifully mechanical: fields are identified by number, not name — so renames are free, additions are safe, and the only sins are reusing a number or changing its type. Mark removed fields reserved and old clients keep working forever. (The costs: browsers need a proxy like gRPC-Web or a REST gateway, and debugging binary needs tooling, not curl.)
Semantics that survive the real network
Networks time out, clients retry, and your API's behavior under retry IS its correctness. The load-bearing semantics:
Two of these are tiny, exact algorithms. The token bucket allows a burst up to its capacity then meters to the refill rate; the idempotency key replays a stored result so a retried payment charges once:
A burst of 8 against a cap-5 bucket lets 5 through and 429s the rest; three retries of one payment charge $50 once, not $150. The runnable version below is both.
Slow work and push: 202 and webhooks
Some work outlasts any sane HTTP timeout: a transcode, a bulk import, a batch of model calls. Holding the connection open for minutes fails at every proxy along the way. Return an operation resource instead:
POST /imports → 202 Accepted
Location: /operations/op_81
GET /operations/op_81 → 200 {"status": "running", "progress": 0.4}
Retry-After: 5
GET /operations/op_81 → 200 {"status": "succeeded",
"result": "/imports/imp_12"}
The POST validates the request, stores the job and returns 202 Accepted at once, pointing to a resource that reports status, progress, any error and finally a link to the result. Give the POST an idempotency key too, or a retried submit starts the import twice. Google's API design guidance standardizes this shape as AIP-151: an Operation with a done flag and either a response or an error.
For long waits, add webhooks instead of polling: the server POSTs an event to a URL the client registered. Delivery is at least once and in no guaranteed order, so design both ends for that. The sender signs each delivery, typically an HMAC-SHA256 of a timestamp plus the raw body (Stripe's Stripe-Signature header works this way), gives every event an ID, and retries with backoff on anything but a 2xx. The receiver verifies the signature over the raw bytes, rejects old timestamps to block replays, dedupes on the event ID, returns 2xx quickly and does the work from a queue. When order matters, it fetches the resource's current state rather than trusting the payload. Practice the check in the webhook signature challenge.
Who is calling: authentication vs authorization
Authentication asks who is calling; authorization asks whether they may do this, to this object. APIs tend to get the first right and the second wrong. Broken Object Level Authorization, where GET /orders/18 works for a user who owns only order 17, tops the OWASP API Security Top 10. Check ownership on every object access, on the server.
| Credential | Represents | Use it for | Rules |
|---|---|---|---|
| API key | a project or account | a customer's backend calling yours | send in a header, never a URL; store only a hash; give keys a recognizable prefix so leaked ones can be scanned for; allow two live keys for rotation |
| OAuth 2.0 access token | a user or client, plus the scopes granted | apps acting for a user (authorization code with PKCE); service-to-service calls (client credentials) | short-lived and narrowly scoped (orders:read), renewed with refresh tokens |
| JWT or opaque token | how a token is checked | a JWT is verified locally from its signature; an opaque token is checked with the issuer (RFC 7662 introspection) | a JWT can't be revoked before it expires without extra machinery, so keep it short-lived |
Status codes carry the distinction: 401 means missing or invalid credentials and must include a WWW-Authenticate header; 403 means the caller is known but not allowed; 404 is the safer reply when the caller shouldn't learn the object exists. Go deeper in the OAuth & auth deep dive, inspect tokens with the JWT decoder, and run the API security checklist before launch.
Evolution: breaking up without breaking clients
The versioning hierarchy, best to worst: don't need it (additive-only changes — new optional fields, new endpoints — break no one; make additive the team default), explicit versions (/v2 in the path: ugly, honest, cache-friendly), header-based versions (cleaner URLs, invisible in logs and browsers — a mixed blessing). What kills APIs isn't versioning strategy; it's silent breaking changes: a renamed field, a tightened validation, a "harmless" change of default ordering. Contract tests against recorded consumer expectations catch these; changelogs and deprecation windows (with usage metrics on the old path) retire things like an adult.
Header versions have a refined form: dated versions. Stripe pins each account to the API version current at its first request, ships every breaking change behind a new dated version, and lets a request choose another with the Stripe-Version header. GitHub's REST API takes a date in X-GitHub-Api-Version, such as 2022-11-28. Each consumer upgrades on its own schedule; the price is a server that keeps translating between every version it still supports.
The newest consumer: agents
A quiet shift: the fastest-growing consumer of APIs isn't developers reading your docs — it's LLM agents reading your schemas. A tool schema is an API contract whose documentation IS the interface: the model chooses and fills your endpoint based purely on names, descriptions and types. Everything in this handbook compounds there — guessable resource models, precise types, structured errors an agent can recover from, idempotency because agents retry enthusiastically. MCP is this idea standardized. Design your API so a stranger can guess it, and you've designed it so a model can use it.
The checklist
| Decision | Default |
|---|---|
| Edge protocol | REST + JSON, resource-modeled, guessable |
| Internal protocol | gRPC, contract-first, deadlines propagated |
| Writes | idempotency keys on anything non-idempotent |
| Updates | ETag + If-Match wherever two clients can edit one resource; PATCH in a standard format |
| Slow work | 202 + an operation resource; webhooks signed, retried, deduped by event ID |
| Auth | API keys for servers, OAuth scopes for delegated access, ownership checked on every object |
| Lists | cursor pagination, capped page sizes |
| Errors | machine code + human message + correlation id |
| Changes | additive by default; explicit /v2 when not; deprecation with metrics |
| Docs | OpenAPI/proto as source of truth — humans AND agents read it |
A rate limiter and a safe retry, in code
Two reliability primitives every API needs, small enough to run. The token bucket holds up to a capacity of tokens, refilling at a steady rate; a burst of 8 against a cap-5 bucket lets 5 through and 429s the other three, then a 3-second wait refills enough to allow the next. The idempotency key makes retries safe: a client that retries a payment three times with the same key gets charged once — the server replays the stored result instead of re-executing. Change the capacity, refill rate, or keys.
Quick answers
REST vs gRPC vs GraphQL?
REST at the edge (universal), gRPC inside (typed, fast, streaming), GraphQL only when many frontends genuinely need differently-shaped data.
Why idempotency keys?
Networks fail, clients retry. A stored-and-replayed key per logical operation makes retries safe — mandatory where money moves.
Cursor vs offset pagination?
Offsets shift under writes and slow with depth; cursors continue from a stable position in constant time.
How to change an API safely?
Additive by default, explicit /v2 when breaking, deprecation windows with usage metrics, contract tests against consumers.
What is the difference between PUT and PATCH?
PUT replaces the whole resource with the representation you send, so it is idempotent: sending it twice leaves the same state. PATCH applies a partial change described by a patch document, such as JSON Merge Patch or JSON Patch, and is not idempotent in general, because an operation like appending to a list changes the result each time.
How should an API handle a long-running operation?
Return 202 Accepted at once with a Location header pointing to an operation resource, then let the client poll that resource, or receive a webhook, until it reports success or failure. The operation resource carries the status, progress, any error and a link to the result.
Rate limiting explained: the token bucket (and why APIs return 429)
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Handbooks
- The Agent Evaluations HandbookA self-contained handbook on evaluating AI agents — theory, interactive widgets, and practical guidance. Trajectory evals, tool-use scoring, LLM-as-judge, observability, and reliability for PMs, engineers, and founders.Read →
- The System Design Fundamentals HandbookThe load-bearing ideas behind every distributed system — the CAP theorem and consistency models, concurrency and locking, partitioning and replication, and consensus and coordination — each tied to a real worked design you can study interactively.Read →
- CAP Theorem & Consistency ModelsWhat a distributed system can promise when the network splits — CP vs AP, why "CA" is a myth, PACELC, and the full spectrum from linearizable to eventual consistency. Part of System Design Fundamentals.Read →
- Partitioning, Sharding & ReplicationHow one dataset becomes many — range vs hash partitioning, consistent hashing and virtual nodes, hot partitions, replication topologies, replication lag, and quorums. Part of System Design Fundamentals.Read →
- Consensus, Transactions & CoordinationHow nodes that can crash still agree on one truth — majority quorums, Raft and Paxos, leader election, two-phase commit vs the saga pattern, and idempotency for exactly-once effects. Part of System Design Fundamentals.Read →
- The Agent Patterns HandbookThe design patterns behind every LLM agent — the ReAct Thought–Action–Observation loop, tool/function calling, plan-and-execute, reflection, memory, multi-agent orchestration, and the failure modes (loops, hallucinated tools, recovery, human-in-the-loop) that break agents in production.Read →