APIs, REST and GraphQL
An API is a contract between programs: a set of operations, their inputs, and their outputs. On the web that contract is expressed over HTTP, and the two dominant styles are REST and GraphQL.
What an API is
Section titled “What an API is”Compare it with a web page. A page returns HTML for a human to look at. An API returns structured data — nearly always JSON — for a program to act on. Same protocol, same methods, same status codes; only the representation and the intended consumer differ.
# HTML for a browsercurl -s https://example.com/products | head -3# <!doctype html><html>...
# JSON for a programcurl -s https://api.example.com/products | head -3# {"data":[{"id":1,"name":"Widget"}],"total":137}Everything in HTTP fundamentals applies unchanged. An API is not a new protocol; it is a discipline about how you use HTTP.
REST is an architectural style. In practice, “a REST API” means: resources identified by URLs, manipulated with HTTP methods, exchanging JSON, statelessly.
Resources and URLs
Section titled “Resources and URLs”A URL names a thing, not an action. Nouns, plural, hierarchical.
GET /users List usersPOST /users Create a userGET /users/42 Fetch user 42PUT /users/42 Replace user 42PATCH /users/42 Partially update user 42DELETE /users/42 Delete user 42
GET /users/42/orders Orders belonging to user 42GET /orders/9/items Items in order 9Common anti-patterns: GET /getUser?id=42 (verb in the path — the method already says the verb), POST /users/42/delete (use DELETE /users/42), and GET /users/42/delete (a safe method performing a destructive action).
Keep nesting to one level. /users/42/orders/9/items/3 is fragile; once you have an order ID, /orders/9/items is enough.
Verb mapping and status codes
Section titled “Verb mapping and status codes”| Operation | Request | Success | Notes |
|---|---|---|---|
| List | GET /users |
200 + array |
Paginate. |
| Read | GET /users/42 |
200 / 404 |
|
| Create | POST /users |
201 + Location: /users/42 |
Body is the new resource. |
| Replace | PUT /users/42 |
200 (body) or 204 |
Full representation required. |
| Update | PATCH /users/42 |
200 or 204 |
Partial body. |
| Delete | DELETE /users/42 |
204 |
404 if it never existed. |
| Validation failure | any | 422 (or 400) |
Return which fields and why. |
| Not authenticated | any | 401 + WWW-Authenticate |
|
| Authenticated, not allowed | any | 403 |
|
| Rate limited | any | 429 + Retry-After |
# Createcurl -i -X POST https://api.example.com/users \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $TOKEN" \ -d '{"name":"Ada Lovelace","email":"ada@example.com"}'# HTTP/1.1 201 Created# Location: /users/42
# Partial updatecurl -i -X PATCH https://api.example.com/users/42 \ -H 'Content-Type: application/json' -d '{"role":"admin"}'
# Deletecurl -i -X DELETE https://api.example.com/users/42# HTTP/1.1 204 No ContentReturn machine-readable errors, not prose — a stable error code the client can branch on, plus per-field detail:
{ "error": "validation_failed", "message": "The request body is invalid.", "details": [ { "field": "email", "code": "invalid_format" }, { "field": "age", "code": "out_of_range", "min": 0, "max": 130 } ]}Statelessness
Section titled “Statelessness”Every request carries everything needed to process it. No server-side conversation state between calls, which is what lets you put any number of identical servers behind a load balancer. Authentication travels per request in an Authorization header — see auth.
Versioning
Section titled “Versioning”Breaking changes need a version. Three approaches:
URL path: GET /v2/users Explicit, cacheable, trivially routable. Most common.Header: Accept: application/vnd.example.v2+json "Purer", harder to test and debug.Query param: GET /users?version=2 Easy but mixes API version with resource params.Path versioning wins on operational grounds: it is visible in logs, easy to route, and you can curl it without thinking. Version only on breaking changes — adding an optional field is not one, and clients must be built to ignore unknown fields.
Pagination
Section titled “Pagination”Never return an unbounded list.
Offset-based — simple, supports jumping to a page, but degrades on large offsets and skips or duplicates rows when data shifts underneath:
curl 'https://api.example.com/users?limit=25&offset=50'# => { "data": [...], "total": 1372, "limit": 25, "offset": 50 }Cursor-based — an opaque pointer to the last row seen. Stable under inserts and fast at any depth; cannot jump to page 7:
curl 'https://api.example.com/users?limit=25&after=eyJpZCI6NzV9'# => { "data": [...], "next_cursor": "eyJpZCI6MTAwfQ", "has_more": true }Cursor pagination is the right default for feeds and large datasets. Some APIs advertise the next page in a Link header instead: Link: <…?after=eyJpZCI6MTAwfQ>; rel="next".
Idempotency
Section titled “Idempotency”PUT and DELETE are idempotent by definition. POST is not — and network retries make that dangerous, since a client that times out cannot tell whether the order was created.
The standard fix is a client-supplied idempotency key:
curl -X POST https://api.example.com/charges \ -H 'Idempotency-Key: 8f14e45f-ceea-167a-5a36-dedd4bea2543' \ -H 'Content-Type: application/json' \ -d '{"amount":5000,"currency":"usd"}'The server stores the key with the response. A repeat of the same key returns the stored response instead of charging again. This is not part of the HTTP spec — it is a widely adopted convention (Stripe popularised it) that you implement yourself.
Rate limiting
Section titled “Rate limiting”Return 429 Too Many Requests with Retry-After: 30, and expose the remaining budget so clients can self-throttle. Budget header names (X-RateLimit-Remaining and friends) are conventional, not standardised — check each API’s docs.
GraphQL
Section titled “GraphQL”GraphQL is a query language for APIs plus a runtime for executing those queries. The core idea: the client specifies exactly which fields it wants, and gets exactly those, in one request.
The problems it solves
Section titled “The problems it solves”Over-fetching. GET /users/42 returns 40 fields; your UI needs 3. You pay for the other 37 in bandwidth and parse time.
Under-fetching (the N+1 round trip). Rendering a user with their orders and each order’s items takes GET /users/42, then GET /users/42/orders, then one GET /orders/{id}/items per order. Each is a round trip; on a mobile connection that is seconds. GraphQL collapses it into one request.
The endpoint
Section titled “The endpoint”One URL, almost always POST /graphql. The method and URL no longer carry meaning — the query body does.
curl -X POST https://api.example.com/graphql \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer $TOKEN' \ -d '{"query":"{ user(id: 42) { name email } }"}'{ "data": { "user": { "name": "Ada Lovelace", "email": "ada@example.com" } } }The schema
Section titled “The schema”Strongly typed and mandatory. It is the contract, and it is introspectable.
type User { id: ID! name: String! email: String orders(first: Int = 10): [Order!]!}
type Order { id: ID! total: Float! items: [Item!]!}
type Item { id: ID!, name: String!, quantity: Int! }
input CreateUserInput { name: String!, email: String! }
type Query { user(id: ID!): User users(first: Int, after: String): [User!]!}
type Mutation { createUser(input: CreateUserInput!): User! deleteUser(id: ID!): Boolean!}
type Subscription { orderCreated(userId: ID!): Order!}! means non-nullable. [Order!]! is a non-null list of non-null orders. Scalars are Int, Float, String, Boolean, ID, plus any custom ones the server defines.
Queries
Section titled “Queries”Read operations. The response mirrors the query’s shape exactly.
query GetUserWithOrders($id: ID!, $orderCount: Int) { user(id: $id) { name email orders(first: $orderCount) { id total items { name quantity } } }}{ "id": "42", "orderCount": 5 }curl -X POST https://api.example.com/graphql \ -H 'Content-Type: application/json' \ -d '{ "query": "query GetUser($id: ID!) { user(id: $id) { name orders(first: 5) { id total } } }", "variables": { "id": "42" } }'One request replaces the four above. Variables (rather than string interpolation) let the server parse and cache the query and prevent injection.
Also available: aliases (active: users(status: ACTIVE) { id }) to fetch the same field twice under different names, fragments to reuse field sets, and directives (@include(if:), @skip(if:)) for conditional fields.
Mutations
Section titled “Mutations”Writes. Syntactically like queries, but the server guarantees top-level fields execute in sequence rather than in parallel.
mutation { createUser(input: { name: "Ada", email: "ada@example.com" }) { id name }}Mutations return data too, so you get the created object back without a second round trip.
Subscriptions
Section titled “Subscriptions”Long-lived pushes, normally over WebSocket.
subscription { orderCreated(userId: "42") { id total }}Introspection
Section titled “Introspection”The schema queries itself. This powers autocomplete, GraphiQL, and code generation.
curl -X POST https://api.example.com/graphql \ -H 'Content-Type: application/json' \ -d '{"query":"{ __schema { types { name } } }"}'{ __type(name: "User") { fields { name type { name kind } } } }Errors
Section titled “Errors”GraphQL returns 200 OK even for failures. Errors live in a top-level errors array, and partial data is normal:
{ "data": { "user": { "name": "Ada", "orders": null } }, "errors": [ { "message": "Not authorized to read orders", "path": ["user", "orders"], "extensions": { "code": "FORBIDDEN" } } ]}Client code must check errors, not just the HTTP status.
REST vs GraphQL
Section titled “REST vs GraphQL”| REST | GraphQL | |
|---|---|---|
| Endpoints | Many, one per resource | One |
| Response shape | Fixed by the server | Chosen by the client |
| Over/under-fetching | Common | Solved by design |
| HTTP caching | Works natively — URLs are cache keys | Effectively unusable; needs a client cache |
| Status codes | Meaningful | Almost always 200 |
| Discoverability | Docs / OpenAPI | Introspection, built in |
| Versioning | Explicit versions | Deprecate fields, add new ones |
| File uploads | Native (multipart/form-data) |
Needs an extension |
| Rate limiting | Per request, easy | Per query cost, hard |
| Server complexity | Low | Resolvers, dataloaders, depth limits |
| Tooling in the wild | Universal — curl, proxies, CDNs | Specialised |
Rough guidance:
- REST for public APIs, CRUD services, anything that benefits from HTTP caching or CDN edge behaviour, and anything a third party must integrate with using ordinary tools.
- GraphQL when many different clients need different slices of a rich, highly connected graph — the case it was built for at Facebook — and you control both ends.
Neither is a default. Most systems are well served by a boring REST API.
Webhooks
Section titled “Webhooks”Reverse APIs: instead of you polling a provider, the provider POSTs to a URL you register when something happens.
POST /webhooks/stripe HTTP/1.1Host: app.example.comContent-Type: application/jsonStripe-Signature: t=1794996400,v1=5257a869e7...
{"type":"payment_intent.succeeded","data":{"object":{"id":"pi_123","amount":5000}}}Rules for receiving them:
- Verify the signature. Providers sign the raw body with a shared secret; recompute the HMAC and compare. An unauthenticated webhook endpoint is a public API for forging events.
- Respond
2xximmediately, process asynchronously. Providers time out fast and retry. Enqueue the event and return. - Expect duplicates. At-least-once delivery is the norm. Deduplicate on the event ID.
- Expect out-of-order delivery. Use timestamps or sequence numbers.
- Include the timestamp in the signed payload and reject old ones, to stop replay.
Real-time: WebSockets and SSE
Section titled “Real-time: WebSockets and SSE”HTTP is request/response — the server cannot speak first. Two standard escapes.
WebSockets
Section titled “WebSockets”A full-duplex, persistent, bidirectional connection. It starts as an HTTP request and upgrades:
GET /socket HTTP/1.1Host: example.comUpgrade: websocketConnection: UpgradeSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==Sec-WebSocket-Version: 13HTTP/1.1 101 Switching ProtocolsUpgrade: websocketConnection: UpgradeSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=After 101, the connection stops being HTTP and speaks the WebSocket frame protocol. Scheme is ws:// or wss:// (TLS).
const socket = new WebSocket('wss://example.com/socket');
socket.addEventListener('open', () => socket.send(JSON.stringify({ type: 'subscribe', room: 'general' })));socket.addEventListener('message', (e) => console.log('received', JSON.parse(e.data)));socket.addEventListener('close', (e) => console.log('closed', e.code, e.reason));socket.addEventListener('error', () => console.error('socket error'));Use for chat, multiplayer, collaborative editing — anything genuinely bidirectional and high-frequency.
Server-Sent Events
Section titled “Server-Sent Events”One-way, server → client, over a plain long-lived HTTP response with Content-Type: text/event-stream.
HTTP/1.1 200 OKContent-Type: text/event-streamCache-Control: no-cache
data: {"price": 42.10}
event: alertdata: {"level":"warn","msg":"threshold crossed"}
id: 1043data: {"price": 42.35}Each event ends with a blank line. Fields are data:, event:, id: and retry:.
const stream = new EventSource('/prices');
stream.onmessage = (e) => console.log('default event', JSON.parse(e.data));stream.addEventListener('alert', (e) => console.warn(JSON.parse(e.data)));stream.onerror = () => console.log('reconnecting…');Watch one from the shell with curl -N https://example.com/prices — -N disables buffering so events print as they arrive.
SSE gives you automatic reconnection and resumption (the browser sends Last-Event-ID on reconnect) for free, and it is ordinary HTTP — proxies, compression and auth all just work.
Choosing
Section titled “Choosing”| Polling | SSE | WebSockets | |
|---|---|---|---|
| Direction | Client → server | Server → client | Both |
| Protocol | HTTP | HTTP | Upgraded, separate |
| Auto-reconnect | N/A | Built in | Implement yourself |
| Binary data | Yes | No (UTF-8 text only) | Yes |
| Proxy/CDN friendliness | Total | Good | Sometimes problematic |
| Complexity | Lowest | Low | Highest |
Default to SSE for one-way streams (notifications, live prices, progress, LLM token streaming). Use WebSockets only when the client also needs to send frequently.
Key points
Section titled “Key points”- REST means resources as URLs, HTTP methods as verbs, correct status codes, and no server-side conversation state.
- Paginate everything, version only on breaking changes, and make
POSTretry-safe with idempotency keys. - GraphQL trades HTTP caching and status codes for exactly-shaped responses in a single round trip.
- GraphQL returns 200 with an
errorsarray — check the body, not just the status. - Webhooks must be signature-verified, answered fast, and treated as at-least-once and unordered.
- SSE for one-way streaming, WebSockets when both sides talk — and validate
Originon the handshake.