Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

Handbooks  /  API Design
Engineering~13 min readIntermediate
Deep Dive

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.

01

Three paradigms, one honest decision

ParadigmIts actual superpowerReach for it when
RESTuniversality — every language, proxy, cache and curl understands itpublic APIs, partner integrations, anything a browser touches
gRPCtyped contracts + HTTP/2 + streaming + codegeninternal microservices, polyglot teams, latency-sensitive paths
GraphQLclient-shaped responses from one endpointmany 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.

02

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.

→ The test of a good resource model

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.

03

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.

MethodSafeIdempotentUse it for
GET, HEAD, OPTIONSyesyesreads; never change state on a GET
PUTnoyescreate or fully replace a resource at a known URL
DELETEnoyesremove; a repeat may return 404, but the state is the same
POSTnonocreate in a collection or run an action; add an idempotency key
PATCHnonot guaranteedpartial 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.

04

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.

05

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

06

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:

The reliability contract
Idempotency keysclient sends a unique key per logical operation; server stores + replays the result on retry. Mandatory wherever money moves or resources spawn.
Cursor paginationopaque continue-from tokens, not offsets — stable under writes, constant-time at any depth.
Rate-limit headers429 + Retry-After + X-RateLimit-Remaining — teach clients to back off instead of guessing.
Structured errorsmachine-readable code, human message, correlation id (Problem+JSON shape) — an error a program can branch on and a human can debug.
Timeouts + deadlinesaccept a client deadline and propagate it downstream (gRPC does this natively) — no zombie work for callers who already gave up.

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:

tokens ← min(cap, tokens + Δt·rate); allow ⟺ tokens ≥ 1  ·  retry(key) → replay stored result, never re-execute

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.

07

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.

08

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.

CredentialRepresentsUse it forRules
API keya project or accounta customer's backend calling yourssend 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 tokena user or client, plus the scopes grantedapps 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 tokenhow a token is checkeda 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.

09

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.

10

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.

11

The checklist

DecisionDefault
Edge protocolREST + JSON, resource-modeled, guessable
Internal protocolgRPC, contract-first, deadlines propagated
Writesidempotency keys on anything non-idempotent
UpdatesETag + If-Match wherever two clients can edit one resource; PATCH in a standard format
Slow work202 + an operation resource; webhooks signed, retried, deduped by event ID
AuthAPI keys for servers, OAuth scopes for delegated access, ownership checked on every object
Listscursor pagination, capped page sizes
Errorsmachine code + human message + correlation id
Changesadditive by default; explicit /v2 when not; deprecation with metrics
DocsOpenAPI/proto as source of truth — humans AND agents read it
RUN IT YOURSELF

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.

CPython · WebAssembly
Frequently asked

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.

▶  Watch it explained

Rate limiting explained: the token bucket (and why APIs return 429)

API Design · Engineering · Vibe Engines · 2026
Finished this one? 0 / 208 Handbooks done
NextgRPC vs REST Next on the “Networks, APIs and auth” ladder · rung 4 of 9

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More Handbooks