Skip to content

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.

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.

Terminal window
# HTML for a browser
curl -s https://example.com/products | head -3
# <!doctype html><html>...
# JSON for a program
curl -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.

A URL names a thing, not an action. Nouns, plural, hierarchical.

GET /users List users
POST /users Create a user
GET /users/42 Fetch user 42
PUT /users/42 Replace user 42
PATCH /users/42 Partially update user 42
DELETE /users/42 Delete user 42
GET /users/42/orders Orders belonging to user 42
GET /orders/9/items Items in order 9

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

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
Terminal window
# Create
curl -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 update
curl -i -X PATCH https://api.example.com/users/42 \
-H 'Content-Type: application/json' -d '{"role":"admin"}'
# Delete
curl -i -X DELETE https://api.example.com/users/42
# HTTP/1.1 204 No Content

Return 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 }
]
}

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.

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.

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:

Terminal window
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:

Terminal window
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".

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:

Terminal window
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.

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

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.

One URL, almost always POST /graphql. The method and URL no longer carry meaning — the query body does.

Terminal window
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" } } }

Strongly typed and mandatory. It is the contract, and it is introspectable.

schema.graphql
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.

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
}
}
}
}
variables
{ "id": "42", "orderCount": 5 }
Terminal window
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.

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.

Long-lived pushes, normally over WebSocket.

subscription {
orderCreated(userId: "42") {
id
total
}
}

The schema queries itself. This powers autocomplete, GraphiQL, and code generation.

Terminal window
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 } } } }

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

Reverse APIs: instead of you polling a provider, the provider POSTs to a URL you register when something happens.

POST /webhooks/stripe HTTP/1.1
Host: app.example.com
Content-Type: application/json
Stripe-Signature: t=1794996400,v1=5257a869e7...
{"type":"payment_intent.succeeded","data":{"object":{"id":"pi_123","amount":5000}}}

Rules for receiving them:

  1. 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.
  2. Respond 2xx immediately, process asynchronously. Providers time out fast and retry. Enqueue the event and return.
  3. Expect duplicates. At-least-once delivery is the norm. Deduplicate on the event ID.
  4. Expect out-of-order delivery. Use timestamps or sequence numbers.
  5. Include the timestamp in the signed payload and reject old ones, to stop replay.

HTTP is request/response — the server cannot speak first. Two standard escapes.

A full-duplex, persistent, bidirectional connection. It starts as an HTTP request and upgrades:

GET /socket HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-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.

One-way, server → client, over a plain long-lived HTTP response with Content-Type: text/event-stream.

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
data: {"price": 42.10}
event: alert
data: {"level":"warn","msg":"threshold crossed"}
id: 1043
data: {"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.

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.

  • 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 POST retry-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 errors array — 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 Origin on the handshake.