Skip to content

An API is a contract between programs: one side publishes operations with stable names, inputs, and outputs; the other side calls them without knowing how they are implemented. Almost every concept below answers one of two questions — how do I change my system without breaking someone else’s, and what happens when the network fails mid-call? The network is never an implementation detail: a call can be slow, duplicated, or lost, so an API is designed for failure from the start rather than patched for it later.

PartExampleCarries
Hostapi.shop.comWhich service
Path/orders/42Which resource
Path param42Which instance
Query?limit=20How to shape the result
Fragment#topNothing — never sent

An endpoint is one addressable operation: a URL plus a method. Name paths after resources (nouns, usually plural), not actions — the verb is the method, so /orders/42 with DELETE beats /deleteOrder. Use the query string to filter, sort, and paginate a collection; it should never change what the resource is.

GET /orders/42 HTTP/1.1
Host: api.shop.com
GET /orders?status=open&limit=20 HTTP/1.1
Host: api.shop.com

Gotcha: /orders and /orders/ are different URLs to many routers, caches, and signature schemes. Pick one form and redirect the other permanently.

MethodPurposeSafe / Idempotent
GETRead a resourceYes / Yes
POSTCreate, or run an actionNo / No
PUTReplace the whole resourceNo / Yes
PATCHModify part of itNo / No
DELETERemove itNo / Yes
HEADHeaders only, no bodyYes / Yes

Safe means the call causes no observable change; idempotent means making it twice leaves the server in the same state as making it once. These are promises to everyone in the middle — browsers, proxies, and retry layers prefetch and replay safe methods without asking you.

PATCH /orders/42 HTTP/1.1
Content-Type: application/merge-patch+json
{"status": "cancelled"}

Gotcha: DELETE is idempotent even though the second call usually returns 404. Idempotency constrains the resulting server state, not the status code.

PartIn a requestIn a response
Start linePOST /orders201 Created
HeadersAccept, AuthorizationContent-Type, ETag
BodyWhat you sendThe representation

One request gets one response, and the server remembers nothing between them. That is statelessness: every request carries everything needed to serve it, which is exactly what lets any instance behind a load balancer answer any call.

POST /orders HTTP/1.1
Content-Type: application/json
Accept: application/json
{"sku": "A-17", "qty": 2}
HTTP/1.1 201 Created
Location: /orders/42

Note: Statelessness is about server memory, not about the user having no session. Session state is fine — it just travels in a token on every request instead of living in server RAM.

CodeNameUse when
200OKRead or update succeeded
201CreatedNew resource; add Location
204No ContentSuccess, nothing to return
400Bad RequestMalformed or invalid input
404Not FoundUnknown, or hidden on purpose
409ConflictState clash, e.g. a duplicate

The first digit is the class: 2xx succeeded, 3xx look elsewhere, 4xx the caller must change something, 5xx the server failed and the identical request might work later. That last split is what tells a client whether retrying is pointless or correct.

HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{"type": "https://api.shop.com/errors/dup",
"title": "Order already exists",
"status": 409}

Gotcha: 200 OK with {"error": ...} in the body defeats retries, caches, and monitoring at once — every layer between you and the client reads the status line, never the body.

SchemeSent asFits
Bearer tokenAuthorization: Bearer …Users and apps
API keyX-API-Key: …Server-to-server
BasicAuthorization: Basic …Legacy, internal
mTLSClient certificateHigh-trust B2B

Authentication answers “who is calling?” Because the server keeps no memory between requests, credentials are verified on every single call. Reject unauthenticated requests with 401 and say how to authenticate in WWW-Authenticate.

GET /me HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token"

Warning: Never accept credentials in the query string. URLs are written to access logs, browser history, and Referer headers — a token in a URL is a token in a dozen places you don’t control.

ModelDecision fromExample
RBACThe caller’s roleadmin may delete
ABACAttributesOnly the record’s owner
ScopesGrants in the tokenorders:read
ACLPer-object listA shared document

Authorization answers “may this caller do this, to this object?” It is two checks, not one: does the token grant the operation, and does this subject have rights to that specific instance. Enforce it server-side on every request — hiding a button changes nothing.

GET /orders/42 HTTP/1.1
Authorization: Bearer <token with orders:read>
HTTP/1.1 403 Forbidden

Gotcha: A valid token with the right scope is still not permission for someone else’s row. Skipping the per-object owner check is the most common API vulnerability there is — /orders/43 quietly returns another customer’s order.

TokenLifetimeSent where
AccessMinutesAuthorization, every call
RefreshDays to monthsToken endpoint only
ID tokenMinutesClient only — identity

An access token is a bearer credential: whoever holds it can use it, so it is as sensitive as a password. A JWT is self-contained — the signature is verified locally with no lookup, which is fast but makes revocation hard; an opaque token needs introspection but dies the moment you delete it. Keep access tokens short-lived either way.

{
"sub": "user_991",
"scope": "orders:read orders:write",
"iss": "https://auth.shop.com",
"aud": "https://api.shop.com",
"exp": 1785000000
}

Warning: A JWT is signed, not encrypted — anyone holding it can read every claim. Put no secrets in it, and on the server verify iss, aud, exp, and the algorithm, not just the signature.

GrantUse for
Authorization code + PKCEWeb, mobile, SPA
Client credentialsMachine-to-machine
Device codeTVs, CLIs, no browser
Refresh tokenRenewing access

OAuth 2.0 is delegated authorization: a user lets an app call an API on their behalf without giving it their password. Four roles — the resource owner (user), the client (app), the authorization server (issues tokens), and the resource server (your API). Authorization code with PKCE is the default for anything user-facing; the implicit and password grants are deprecated, so treat them as legacy only.

POST /oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQ
&redirect_uri=https://app.example/cb
&code_verifier=dBjftJeZ4CVP

Gotcha: OAuth is authorization, not authentication. An access token says an app may act — it does not tell you who the user is. Use OpenID Connect and an ID token when you need identity.

HeaderMeaning
RateLimit-LimitCeiling for the window
RateLimit-RemainingCalls left in it
RateLimit-ResetSeconds until it resets
Retry-AfterWait this long (with 429)

Rate limiting caps how many calls a client may make per window, protecting shared capacity and enforcing pricing tiers. Pick the key deliberately: per token or per user is fair, per IP alone punishes everyone behind one NAT. Publish the numbers in headers so clients can pace themselves instead of discovering the limit by failing.

HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 42
Retry-After: 42

Tip: Retry with exponential backoff plus jitter. Fixed intervals re-synchronize every blocked client into one spike at the moment the window resets.

StrategyBehaviorClient sees
RejectDrop the excess429
ShapeQueue and delaySlower responses
DegradeCheaper answerPartial data

Rate limiting is the policy — the number in the contract. Throttling is the enforcement: what actually happens to the call that crosses it. Token bucket allows bursts up to the bucket size then refills steadily; leaky bucket smooths output to a fixed rate; a sliding window avoids the double-burst at fixed-window boundaries.

throttle:
key: token
algorithm: token_bucket
rate: 10/s
burst: 50
on_exceed: reject # or: queue

Gotcha: Queueing instead of rejecting converts overload into latency. Clients time out, retry, and add more load — shed traffic early and cheaply rather than holding it.

StyleParamsTrade-off
Offset?offset=40&limit=20Simple; drifts, slow deep
Cursor?cursor=abc&limit=20Stable; no page jumps
Page?page=3&size=20Familiar; offset in disguise

Never return an unbounded collection: set a default limit and a hard maximum. Offset paging re-reads every skipped row and duplicates or drops items when rows are inserted mid-scan; cursor (keyset) paging encodes the last-seen sort key, so it stays correct and fast at any depth. Return the next cursor in the payload — clients should treat it as opaque and never construct one.

{
"data": [{"id": 44}, {"id": 43}],
"page": {
"next": "eyJpZCI6NDN9",
"limit": 20
}
}

Gotcha: Cursor paging needs a total order. Sort by a unique tiebreaker such as (created_at, id), or rows sharing a timestamp fall between pages and are never returned.

HeaderRole
Cache-ControlWho may store it, how long
ETagVersion tag to validate against
If-None-MatchClient’s copy; may get 304
VaryWhich headers change the answer

Caching buys two different things. Freshness (max-age) skips the request entirely; validation (ETag plus If-None-Match) still makes the round trip but skips the body via 304 Not Modified. Mark per-user responses private and shared ones public, and set both deliberately — the default is whatever your framework guessed.

GET /orders/42 HTTP/1.1
If-None-Match: "v7"
HTTP/1.1 304 Not Modified
ETag: "v7"
Cache-Control: private, max-age=60

Gotcha: A shared cache keyed without Vary: Authorization will hand one user’s response to the next user. If a response depends on who asked, say so in Vary or mark it private.

MethodSafe to repeatWhy
GET, HEADYesChanges nothing
PUT, DELETEYesState converges
POSTNoCreates each time

On the wire, a retry is indistinguishable from a new call: a client that times out has no idea whether the server processed the request. Make unsafe operations replay-safe with an idempotency key — the client generates one key per logical operation, and the server stores key → result and replays the stored response for repeats.

POST /payments HTTP/1.1
Idempotency-Key: 7c1f-4b2a-9de3
Content-Type: application/json
{"amount": 4200, "currency": "EUR"}

Gotcha: Generate the key once per operation and reuse it across every retry. Generating it inside the retry loop makes each attempt a new operation — and charges the customer twice.

PatternWho callsLatency
PollingClient → APIOne interval
WebhookAPI → clientNear-instant
StreamingHeld openContinuous

A webhook inverts the direction: your API POSTs an event to a URL the consumer registered. The receiver must verify the signature — anyone on the internet can post to a public URL — return 2xx quickly and process asynchronously, and tolerate duplicates and out-of-order arrival, since delivery is at-least-once.

POST /hooks/shop HTTP/1.1
X-Signature: sha256=9f86d0818...
X-Event-Id: evt_8891
{"type": "order.paid",
"data": {"id": 42}}

Warning: Deliveries repeat, including for events you already handled. Store the event id and ignore ones you have seen, or a single retried delivery ships the order twice.

WhereExampleNote
URL path/v2/ordersVisible, trivial to route
HeaderAPI-Version: 2Clean URLs, easy to miss
Media type…+json;v=2Most correct, least used

Version only when you must break the contract: removing a field, tightening validation, changing a type, or changing a status code. Additive changes need no version — provided clients ignore fields they don’t recognize, which is a rule you publish on day one. Announce removal in the response, not only in a changelog.

GET /v1/orders/42 HTTP/1.1
HTTP/1.1 200 OK
Deprecation: Sat, 01 Nov 2026 00:00:00 GMT
Sunset: Sun, 01 Feb 2027 00:00:00 GMT
Link: </v2/orders/42>; rel="successor-version"

Gotcha: Every live version is code you must keep, test, and patch for security. Two supported versions is a policy; five is a maintenance backlog you’ll never finish.

ArtifactWhat it is
OpenAPI documentThe contract, in YAML or JSON
Swagger UIRenders that document as docs
GeneratorsClients, servers, mocks, tests

OpenAPI describes an HTTP API in machine-readable form: paths, methods, parameters, schemas, responses, and security schemes. One document then drives documentation, SDKs, request validation, mock servers, and contract tests. Write it by hand or generate it from code — but check it in CI, because a spec that has drifted from the implementation is worse than no spec at all.

paths:
/orders/{id}:
get:
parameters:
- name: id
in: path
required: true
schema: { type: string }
responses:
'200': { description: An order }
'404': { description: Not found }

Note: OpenAPI 3.1 aligns with JSON Schema 2020-12, so its schemas work in ordinary validators. 3.0’s dialect looks the same but is not — nullable and exclusiveMinimum differ.

AspectRESTGraphQL
SurfaceMany URLsOne endpoint
FieldsServer decidesClient asks
CachingHTTP, for freeApp-level, built
LimitsPer requestPer query cost

REST models resources and reuses HTTP itself: methods, status codes, caches, and CDNs all work without extra code. GraphQL exposes one POST endpoint plus a type system, letting a client fetch exactly the fields it needs in one round trip — which is worth a lot for complex, client-driven UIs. The cost is that caching, rate limiting, and error semantics move into your application layer.

REST: GET /orders/42?include=customer
GraphQL: POST /graphql
{ order(id: 42) {
total
customer { name }
} }

Gotcha: GraphQL returns 200 OK with an errors array even when the query failed. Status-code-based dashboards will show a perfectly healthy API while every request is failing.

ConcernHandled at the gateway
IdentityToken check, key lookup
TrafficRate limits, throttling
RoutingPath → service, versions
InsightLogs, metrics, tracing

A gateway is the single front door: one place to terminate TLS, authenticate, throttle, route, and observe, so no service has to reimplement any of it. It also decouples the public contract from internal topology — you can split a service in two without changing a single URL. Keep business logic out of it; rules that live in the gateway become a shared bottleneck nobody dares to edit.

routes:
- path: /v1/orders/*
upstream: http://orders.svc:8080
auth: jwt
rate_limit: 100/min
timeout: 3s

Warning: The gateway is also a single point of failure and of latency. Set its timeouts below the client’s, or one slow upstream stacks connections until the whole front door stops answering.

PropertyMonolithMicroservices
DeployOne unitIndependent
FailureProcess-widePartial
CallFunction callNetwork call
DataShared schemaOwned per service

Microservices split a system into independently deployable services, each owning its own data. The payoff is team and release autonomy; the price is that every former function call is now a network call that can be slow, duplicated, or lost. That is why timeouts, bounded retries, and circuit breakers are baseline requirements — and why sharing one database silently re-couples services you meant to split.

orders_client:
timeout: 2s
retries: 2 # only if idempotent
backoff: exponential+jitter
circuit_breaker:
error_rate: 50%
open_for: 30s

Note: There is no distributed transaction across services. A write spanning two of them needs a saga: local commits plus explicit compensating actions when a later step fails.

SignalLives inRead by
Status codeStart lineClients, proxies, caches
typeBodyClient branching logic
detailBodyA human debugging
Trace idBody or headerYour support and logs

An error response is part of the contract, so make it as predictable as a success. RFC 9457 problem details standardizes the body around type, title, status, detail, and instance — return a stable machine-readable type the client can branch on, plus a trace id so a bug report maps to one log line. Never leak stack traces, SQL, or internal hostnames; they help attackers more than callers.

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{"type": "https://api.shop.com/errors/qty",
"title": "Invalid quantity",
"status": 422,
"detail": "qty must be 1-99",
"instance": "/orders",
"traceId": "b7ad6b71"}

Gotcha: Translating title server-side breaks every client that matched on the old string. Branch on type or a stable code; treat all human-readable text as display-only.