verbs · idempotency · status codes · stateless · content negotiation · ETag · pagination
Overview — what "REST" actually means in 2026
REST is not a library, not a protocol, not a framework. It's a set of architectural constraints Roy Fielding described in his 2000 dissertation — and most production "REST APIs" violate at least three of them. That's usually fine; what matters is understanding the constraints you're keeping and the ones you're breaking, so you can reason about caching, scaling, and security correctly.
We'll go through the handful of fundamentals every backend engineer and API pentester needs in their bones: HTTP verb semantics, idempotency, status codes, statelessness, content negotiation, caching and pagination. Each one has a visual so you can see the gap between an API that merely "feels RESTful" and one that actually behaves correctly under retries, scale and concurrent updates.
What this article is — and is NOT
| This IS | A foundations article. Designed to make sense of HTTP verbs, status codes, and the request/response contract such that you can spot bugs in any REST API you encounter — yours or someone else's. |
| This is NOT | A REST-versus-GraphQL-versus-gRPC debate, an OpenAPI specification tutorial, or a framework-specific guide. The patterns apply across Express, Django, Rails, Spring, FastAPI, Echo, Fiber, Axum — all of them. |
| Why it matters for security | Most API vulnerabilities (BOLA, mass assignment, replay attacks, cache poisoning) exploit gaps between how the developer THINKS REST works and how the spec / browser / proxy chain ACTUALLY works. This article fills the gap. |
HTTP verbs — semantics, safety, idempotency
Five core verbs cover ~99% of REST APIs. Each verb has a contract — what it does, whether it can be safely retried, whether it has side-effects. Picking the wrong verb usually doesn't cause functional bugs immediately, but it breaks caching, breaks retries, and signals to anyone reading your API that the underlying model is fuzzy.
The five verbs
| Verb | Semantics |
|---|---|
| GET | Read a resource (or collection). Body returned, no side effects. Safe + idempotent. |
| POST | Create a new resource (server assigns ID), or kick off a process. NOT idempotent — replaying creates duplicates. Returns 201 Created with Location header. |
| PUT | Replace the entire resource at this URL. Idempotent — same body = same final state. Use when client knows the full resource. |
| PATCH | Partial update. Modify only the fields in the body. Idempotency depends on the operation (replace=yes, increment=no). |
| DELETE | Remove the resource. Idempotent — first call deletes, subsequent calls return 404. State after N calls is the same. |
Safety + idempotency cheat sheet
Verb Safe Idempotent Body request Body response Cacheable ───────────────────────────────────────────────────────────────────────────── GET yes yes no yes yes HEAD yes yes no no yes OPTIONS yes yes no yes no POST no no yes yes sometimes PUT no yes yes optional no PATCH no maybe yes optional no DELETE no yes no optional no
"Safe" means the operation has no side effects — the server state after a GET is identical to the state before. "Idempotent" means N identical calls have the same effect as 1 call. GET is both. POST is neither. PUT/DELETE are idempotent but not safe (they DO change state, but the final state stabilises).
POST vs PUT — the most common confusion
| Pattern | What it means |
|---|---|
| POST /users | Create a new user. Server assigns id=43. Body is the new user data. Response 201 Created, Location: /users/43. |
| PUT /users/43 | Replace user 43. Body MUST contain all fields you want the resource to have — missing fields = null. Response 200 OK. |
| POST /users/43 | Almost never seen — POST on an existing resource means "run an action on this resource" (e.g., POST /users/43/email-verify). Confuses readers. |
| PUT /users | Almost never seen — would mean "replace the entire users collection". Usually a mistake. |
PUT /products/sku-A1B ✓ — client knows the SKU. POST /products ✓ — server assigns the auto-id.Idempotency — surviving retries cleanly
Idempotency is the most under-appreciated REST property. The internet is not reliable — TCP timeouts, mobile hops, proxy hiccups, retry middleware, load-balancer failovers, browser refreshes. Any request can be retried by something somewhere in the chain. Idempotent operations survive retries cleanly; non-idempotent ones cause double-charges, duplicate orders, exploding queues.
Why this matters in production
| Source of retries | Concrete impact |
|---|---|
| Payment processors | Stripe, Adyen, Razorpay — all require Idempotency-Key on POST /charges. Without it, a single retry can double-charge a customer. With it, the second request returns the first request's result without re-running the side effect. |
| Webhook deliveries | Senders retry on 5xx or timeout. If your webhook handler isn't idempotent, you process the same event twice (sending a notification, debiting an account, etc.). |
| Background jobs | At-least-once message queues (SQS, RabbitMQ, Kafka) WILL deliver messages twice under failure. Your job processor must tolerate this. |
| Browser back button + refresh | After a POST → 200, hitting refresh prompts "Resubmit form?". User clicks yes. Duplicate order. The classic. |
Implementing idempotency for POST
The standard pattern (popularised by Stripe) is the Idempotency-Key header. Client generates a UUID per logical operation. Server stores key + result. On replay, server returns the stored result instead of executing the action again.
// client const idempotencyKey = crypto.randomUUID(); fetch("/api/charges", { method: "POST", headers: { "Content-Type": "application/json", "Idempotency-Key": idempotencyKey }, body: JSON.stringify({ amount: 1000, currency: "usd" }) }); // server (express + redis) app.post("/api/charges", async (req, res) => { const key = req.header("Idempotency-Key"); if (!key) return res.status(400).json({ error: "missing Idempotency-Key" }); const existing = await redis.get(`idem:${key}`); if (existing) { // already processed — return stored response return res.status(200).json(JSON.parse(existing)); } const result = await chargeCustomer(req.body); // store for 24h — long enough to cover any reasonable retry window await redis.set(`idem:${key}`, JSON.stringify(result), "EX", 86400); res.json(result); });
Idempotency-Key for non-POST verbs
PUT, DELETE, GET are already idempotent by spec — you don't normally need a key. But for distributed write-ahead patterns (multi-region writes, eventual-consistency stores), even PUTs can benefit from an explicit key so you can reason about which write "wins". Most APIs only use the key on POST.
Status codes — the response contract
A 200 carrying {"error": "not found"} is the most common API design mistake on the internet. The status code is part of your response contract — proxies cache on it, monitoring alerts on it, retry libraries make decisions on it. Return 200 for an error and you've quietly broken every layer of the stack that was supposed to react to the failure.
The five families
| Family | Meaning + common codes |
|---|---|
| 1xx Informational | 100 Continue, 101 Switching Protocols. Used for streaming + WebSocket upgrade. Rarely customised. |
| 2xx Success | Request received, understood, processed. 200 OK, 201 Created, 202 Accepted (async), 204 No Content (success, no body). |
| 3xx Redirection | Client must take additional action. 301 Permanent, 302 Found, 304 Not Modified (caching!), 307/308 (keep method on redirect). |
| 4xx Client errors | Client got something wrong. 400 bad input, 401 missing/bad auth, 403 forbidden, 404 not found, 409 conflict, 422 unprocessable, 429 too many requests. |
| 5xx Server errors | Server failed at something it should have done. 500 generic, 502 bad gateway, 503 unavailable, 504 timeout. |
The "which 4xx do I pick" question
| Code | When to use it |
|---|---|
| 400 Bad Request | Could not parse / validate the request. Malformed JSON, wrong Content-Type, missing required field. |
| 401 Unauthorized | Authentication missing or invalid. Re-authenticate to retry. (Confusingly named — should be "Unauthenticated".) |
| 403 Forbidden | Authenticated but not authorized. Re-authenticating won't help — this user can never access this resource. |
| 404 Not Found | Resource doesn't exist. Server is also allowed to lie (return 404 for resources the user shouldn't even know exist). |
| 405 Method Not Allowed | The URL exists but doesn't support this verb. Include Allow: header listing what IS supported. |
| 409 Conflict | Request collides with current state. Common in optimistic-concurrency: "your update is based on stale version". |
| 410 Gone | Resource used to exist, now permanently removed. Helps search engines update their index. |
| 422 Unprocessable Entity | Syntactically valid (parses) but semantically wrong (e.g., business rule violation). Many APIs prefer 400 + error details over 422. |
| 429 Too Many Requests | Rate limit exceeded. MUST include Retry-After: header. |
5xx — never leak internals
// BAD — leaks stack trace, framework, version, library names HTTP/1.1 500 Internal Server Error { "error": "TypeError: Cannot read property 'name' of undefined", "stack": "at /app/src/users.ts:42:18\n at /app/node_modules/express/...", "node_version": "20.10.0" } // GOOD — generic message + correlation ID to find it in logs HTTP/1.1 500 Internal Server Error { "error": "Internal server error", "correlationId": "8f3a2b1c-…" } // the actual error + stack trace goes to your server logs, indexed by correlationId
Statelessness — why each request stands alone
Of Fielding's six REST constraints, statelessness is the one most directly tied to scaling. Every request must carry everything the server needs to understand it — no "remember that I was on step 3 of the wizard" sitting in one server's memory. The payoff is that any server in your fleet can handle any request. The cost is that every request is a little bigger, revocation gets harder, and the client carries more of the weight.
Stateless vs server-side session
| Model | Trade-off |
|---|---|
| Server-side session | Server stores session object in RAM (or Redis). Client only sends a SessionId. Pros: easy revocation, small request size. Cons: needs sticky sessions OR shared cache; hard to scale horizontally. |
| Stateless (token-based) | Server stores nothing. Token (JWT or signed cookie) carries the user identity + claims. Server validates signature on each request. Pros: trivially horizontally scalable, no shared state. Cons: harder to revoke, bigger requests, must protect signing key. |
Common statelessness violations
| Pattern | Why it breaks |
|---|---|
| Storing per-user state in server memory | Wizards, shopping carts, "in-progress" forms held in RAM. Falls over with multiple servers behind LB. |
| Anonymous server-side rate limit by IP | Counter held in single server's memory. Other servers don't see it. Attacker round-robins. Fix: Redis or a centralised rate-limit service. |
| Sticky-session affinity required | LB has to pin user to one server. If that server dies, user loses their session. Fix: move state to shared cache. |
| WebSocket-only mid-flow | Initial WebSocket established session context that subsequent REST calls assume. New deploy disconnects WS → confused REST calls. |
Stateless does NOT mean "no state anywhere"
Servers obviously have state — the database, the cache, the queue. Statelessness in REST is specifically about not stashing per-conversation state in a single server's memory. Database state is fine. A user session in Redis is fine too — technically stateful, but it's a centralised cache any server can read. What isn't fine is "server A remembers something about this client that server B doesn't."
Content negotiation — one resource, many representations
Same resource, multiple representations. The Accept header lets a client say "I want this as JSON" while a legacy integration says "give me XML" and a spreadsheet export says "give me CSV". The server picks the best match it can produce. Powerful, often under-used in modern APIs.
The negotiation headers
| Header | Purpose |
|---|---|
| Accept | What the client wants. e.g., Accept: application/json, application/xml;q=0.5 — JSON preferred, XML acceptable. |
| Accept-Language | Preferred languages. Accept-Language: en-US, hi-IN;q=0.7. Server returns the localised resource. |
| Accept-Encoding | Compression formats. Accept-Encoding: gzip, br, deflate. Server picks one. |
| Accept-Charset | Character encoding. Mostly irrelevant now — assume utf-8. |
| Content-Type | What the SENT body is. Symmetric to Accept but for request payloads. |
q-values — the quality / preference scale
Accept: application/json;q=1, application/xml;q=0.7, text/csv;q=0.3, */*;q=0.1 // ^^^^^^^^^^^^^^^^^^ prefer JSON (default q=1) // ^^^^^^^^^^^^^^^^^^ XML OK, 0.7 preference // ^^^^^^^^^ CSV grudgingly accepted // ^^^^^^^^^ anything is better than nothing // server returns the highest-q match it can produce // if it can do JSON: JSON. if not JSON but yes XML: XML. and so on.
Versioning via content negotiation
A popular pattern is to version your API by mime-type rather than by URL. Accept: application/vnd.example.user+json; version=2. The URL stays the same; the response format reflects the requested version. Avoids the /v1/, /v2/ URL proliferation. GitHub's API used this for years.
// versioning by content negotiation GET /users/42 Accept: application/vnd.example.user+json; version=2 // vs URL-based versioning GET /v2/users/42 Accept: application/json
Caching — ETag, Cache-Control, and the 304 win
REST over HTTP hands you a powerful caching layer for free — if you actually use it. Most APIs ignore Cache-Control and ETag completely and leave 90%+ of their bandwidth on the table. A properly cached API is faster, cheaper and more scalable than any in-process LRU you could hand-roll.
The caching headers
| Header | Effect |
|---|---|
| Cache-Control: max-age=3600 | Response is fresh for 3600 seconds. Client/proxy can reuse without re-asking. |
| Cache-Control: no-cache | Must revalidate with the server before each use — using ETag or Last-Modified. |
| Cache-Control: no-store | Do not cache at all. For sensitive data — e.g., one-time auth flows. |
| Cache-Control: private | Only the end-user's browser may cache. Shared proxies (CDN, corporate cache) must not. |
| Cache-Control: public | Anything is allowed to cache, including shared caches. |
| ETag | Opaque version identifier. Client sends back via If-None-Match on revalidation. |
| Last-Modified | Timestamp version identifier. Client sends back via If-Modified-Since. Less precise than ETag. |
| Vary | Tells caches "this response varies by these request headers". Without Vary: Accept, a CDN might serve the JSON response to an XML client. |
ETag flow — the 304 win
// 1) first request, no cache GET /products HTTP/1.1 Accept: application/json HTTP/1.1 200 OK Content-Type: application/json ETag: "v1-abc123" Cache-Control: no-cache Vary: Accept [... 50 KB body ...] // client stores: body + ETag // 2) later, client wants the same resource GET /products HTTP/1.1 Accept: application/json If-None-Match: "v1-abc123" HTTP/1.1 304 Not Modified ETag: "v1-abc123" Cache-Control: no-cache // empty body. client uses its cached copy. ~200 bytes vs 50 KB. // 3) data changes server-side, ETag becomes "v2-def456" GET /products HTTP/1.1 If-None-Match: "v1-abc123" HTTP/1.1 200 OK ETag: "v2-def456" [... 52 KB new body ...] // client refreshes its cache.
Strong vs weak ETags
ETag: "abc123" # strong — bytes must be identical ETag: W/"abc123" # weak — semantically equivalent OK
Strong ETags require byte-for-byte match — used when range requests / byte caching matter. Weak ETags allow representations that are semantically equivalent (e.g., same JSON with different key order). For most REST APIs, weak is fine.
Cache poisoning — the security angle
Vary: Cookie on user-specific data, Cache-Control: private, or just don't cache anything user-specific at the CDN layer.Pagination — offset vs cursor
Returning unbounded collections is a denial-of-service speedrun. Every list endpoint needs pagination. The choice between OFFSET and CURSOR pagination determines whether your API stays correct as the underlying data changes mid-fetch — which it always does in real systems.
Offset vs cursor — the comparison
| Style | How it works |
|---|---|
| Offset | ?offset=20&limit=10 or ?page=3&limit=10. Server SKIPs N rows, returns the next 10. Easy to implement; intuitive for users (page numbers). FAILS when items are added/removed between page fetches → duplicates or skips. |
| Cursor | ?after=item_20&limit=10. Server reads 10 rows WHERE id > item_20. Stable under inserts/deletes. Slightly less intuitive (no "page 5" concept). Standard for activity feeds, infinite-scroll lists. |
| Keyset | Sorts by an indexed column (created_at, id) + uses WHERE clause instead of OFFSET. Same as cursor but more general — cursor IS the indexed column value. |
| Seek | Same family as cursor/keyset. Different name in some communities (Java/Spring). |
Why offset gets slow at scale
-- SELECT * FROM items LIMIT 10 OFFSET 100000 -- DB must read AND DISCARD 100,000 rows before returning the 10 you want -- O(N) per page, terrible for deep pagination -- vs cursor (using indexed id): -- SELECT * FROM items WHERE id > 99999 LIMIT 10 -- DB does an index seek to id=99999 then reads 10 rows. O(log N) regardless of depth.
The Link header — RFC 5988
// the standard way for a paginated response to point to next/prev pages HTTP/1.1 200 OK Link: <https://api.example.com/items?after=item_30&limit=10>; rel="next", <https://api.example.com/items?before=item_21&limit=10>; rel="prev", <https://api.example.com/items?limit=10>; rel="first" X-Total-Count: 1543 [items 21..30 here] // client follows Link rel="next" without having to construct the URL itself // (this is the closest most APIs come to HATEOAS — see s9)
Pagination + filtering + sorting
GET /items? filter[status]=active& sort=-created_at& # leading "-" = descending fields=id,name,price& # sparse fieldset after=item_30& limit=20 // the JSON:API spec standardises this shape. follow it if you don't have a strong reason otherwise.
limit=10000 someone will eventually send it. Default to 25, cap at 100, and reject limit>100 with a 400. (True story: an internal report kept hitting limit=999999 and took the database down at 9am every Monday.)URL design + a brief word on HATEOAS
URL design isn't about following a manifesto — it's about being predictable. Conventions reduce cognitive load for every developer who reads your API.
URL design conventions
| Convention | Example |
|---|---|
| Nouns, not verbs | /users/42 ✓ /getUser?id=42 ✗ /createOrder ✗ (use POST /orders) |
| Plural collections | /users/42 ✓ /user/42 ✗ |
| Hierarchical relationships | /users/42/orders/100 ✓ — orders that belong to user 42, order id 100 |
| Use HTTP verbs for actions | DELETE /users/42 ✓ POST /users/42/delete ✗ /deleteUser?id=42 ✗ |
| Query string for filtering/sorting | /users?role=admin&sort=-created_at ✓ /users-by-admin ✗ |
| Kebab-case OR snake_case OR camelCase | Pick ONE for path segments and stick with it. Mixing them in the same API is jarring. |
HATEOAS — the constraint nobody actually follows
Hypermedia As The Engine Of Application State — Fielding's strictest REST constraint. Responses should include links to related resources, so clients can navigate the API without hard-coding URLs.
// strict HATEOAS response { "id": 42, "name": "alice", "email": "[email protected]", "_links": { "self": { "href": "/users/42" }, "orders": { "href": "/users/42/orders" }, "manager": { "href": "/users/13" }, "deactivate": { "href": "/users/42", "method": "DELETE" } } } // client follows _links rather than computing URLs from a template. // the server can change URLs without breaking clients.
In practice, almost no production API follows HATEOAS strictly because clients usually have hard-coded URL templates anyway, and the verbosity isn't worth it. The lightest version — including self link and next/prev for paginated responses (cf. §8) — captures most of the benefit.
Errors — Problem+JSON, validation, enumeration leaks
Status codes communicate the high-level outcome. The response body should communicate the detail. Inconsistent error shapes are the single biggest cause of "I can't figure out what your API is telling me" in client integrations.
RFC 7807 — Problem Details for HTTP APIs
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://example.com/probs/validation",
"title": "Validation failed",
"status": 422,
"detail": "The submitted form has 2 errors.",
"instance": "/orders/draft/abc123",
"errors": [
{ "field": "items[0].quantity", "message": "must be positive" },
{ "field": "shipping.postalCode", "message": "invalid format" }
]
}RFC 7807 (problem+json) is the closest thing to an industry standard for API error responses. It gives clients a stable shape — type, title, status, detail, instance, plus your own fields. Even if you don't adopt it formally, copy the shape.
Validation patterns
| Aspect | Recommendation |
|---|---|
| 400 vs 422 | 400 = couldn't even parse the body (malformed JSON). 422 = parsed fine but violates business rules. Some APIs use 400 for both — fine, just be consistent. |
| Field-level errors | List each invalid field by path. items[0].quantity beats "Invalid items". |
| Error codes | Add a stable machine-readable code per error type. QUANTITY_NEGATIVE. Clients can branch on this without parsing English. |
| i18n | If you serve a multilingual audience, the human-readable title + detail should be localised based on Accept-Language. The machine-readable code stays fixed. |
Don't over-leak in 4xx either
Security fundamentals — the baseline checklist
REST API security is a deep topic in its own right — the API Top 10 articles cover it properly. The fundamentals below are just the floor:
Foundational security checklist
| # | Control |
|---|---|
| 1. | TLS everywhere. HSTS with includeSubDomains + preload. HTTP/1.1 + HTTP/2 both behind TLS. |
| 2. | Authentication on every endpoint. Public endpoints are explicitly tagged "public" in your route config. |
| 3. | Authorization on every endpoint. NEVER trust client-claimed roles or IDs — derive from the auth token server-side. |
| 4. | Rate limiting per user + per IP. 429 with Retry-After header. Centralised counter (Redis), not per-server. |
| 5. | Input validation at the boundary. Strict schemas (Zod, Joi, Pydantic, Hibernate Validator). Reject extra fields rather than silently dropping. |
| 6. | Output encoding — JSON.stringify, NOT string concatenation. Set Content-Type charset=utf-8. |
| 7. | CSRF protection if you accept cookies — SameSite=Lax/Strict + CSRF tokens for state-changing requests from browsers. |
| 8. | CORS — explicitly list allowed origins. Wildcard * + credentials=true is silently rejected by browsers but other clients can exploit a misconfig. |
| 9. | Idempotency-Key for POST creating side-effects (cf. §3). |
| 10. | Pagination cap (cf. §8) — refuse limit>N at the boundary. |
| 11. | Generic 4xx/5xx error bodies — no stack traces, no internal hostnames, no library versions. |
| 12. | Audit logging — who did what when. Correlation IDs in every response and in every log line. |
Pentester quick-look
# 1. enumerate endpoints gobuster dir -u https://api.example.com -w api-wordlist.txt ffuf -u https://api.example.com/FUZZ -w api-wordlist.txt # 2. fetch OpenAPI / swagger curl https://api.example.com/openapi.json curl https://api.example.com/swagger.json curl https://api.example.com/v2/api-docs # 3. check verb support per endpoint for v in GET POST PUT PATCH DELETE OPTIONS; do echo -n "$v "; curl -s -o /dev/null -w "%{http_code}\n" -X $v https://api.example.com/users/42 done # 4. flip every id (BOLA) — see "BOLA / IDOR" article curl https://api.example.com/users/1 -H "Authorization: Bearer $T_USER1" curl https://api.example.com/users/2 -H "Authorization: Bearer $T_USER1" # should 403 or 404 # 5. abuse pagination curl "https://api.example.com/items?limit=999999" # capped? curl "https://api.example.com/items?offset=-1" # validated? curl "https://api.example.com/items?sort=password" # sortable on private field? # 6. test idempotency for i in 1 2 3; do curl -X POST https://api.example.com/orders -d '{"item":"x"}' -H "Idempotency-Key: same-key-here" done # 7. test rate limiting seq 1 1000 | xargs -P 50 -I _ curl -s https://api.example.com/endpoint -o /dev/null -w "%{http_code}\n" | sort | uniq -c
Cheat sheet — verbs, codes, headers, tools
A quick reference for the people designing and reviewing these APIs:
REST cheat sheet
| Verb | Notes |
|---|---|
| GET | read; safe; idempotent; cacheable |
| POST | create / action; not idempotent (use Idempotency-Key); body in, body out |
| PUT | replace whole resource; idempotent; client knows URL |
| PATCH | partial update; sometimes idempotent; JSON Merge Patch (RFC 7396) or JSON Patch (RFC 6902) |
| DELETE | remove; idempotent; 204 first call, 404 subsequent |
| HEAD | GET headers only; safe; idempotent |
| OPTIONS | capabilities discovery; preflight CORS |
Status code cheat sheet
| Code | Meaning |
|---|---|
| 200 | OK (read or update success with body) |
| 201 | Created (POST success — include Location: header) |
| 202 | Accepted (async — work queued, will finish later) |
| 204 | No Content (success, no body — DELETE, PUT without body) |
| 301/308 | Permanent redirect (308 preserves method) |
| 302/307 | Temporary redirect (307 preserves method) |
| 304 | Not Modified (ETag matched — use cached) |
| 400 | Bad Request (malformed) |
| 401 | Unauthorized (auth missing/invalid) |
| 403 | Forbidden (auth OK, not allowed) |
| 404 | Not Found (or hidden) |
| 405 | Method Not Allowed (Allow: header required) |
| 409 | Conflict (stale version, duplicate) |
| 410 | Gone (resource permanently removed) |
| 422 | Unprocessable Entity (semantic validation failed) |
| 429 | Too Many Requests (Retry-After: required) |
| 500 | Internal Server Error (generic, do not leak) |
| 502 | Bad Gateway (upstream failed) |
| 503 | Service Unavailable (planned/temporary outage) |
| 504 | Gateway Timeout (upstream slow) |
Headers worth knowing
| Header | Use |
|---|---|
| Authorization | Bearer / Basic / API-Key auth |
| Idempotency-Key | POST replay protection (cf. §3) |
| ETag / If-None-Match | Caching revalidation (cf. §7) |
| Cache-Control | Caching policy |
| Vary | Caches: response varies by these request headers |
| Accept / Content-Type | Content negotiation (cf. §6) |
| Link | Pagination + HATEOAS-ish navigation |
| Retry-After | 429 / 503 — when to retry |
| Location | 201 / 3xx — URL of new or moved resource |
| X-Request-ID / X-Correlation-ID | Logging correlation across services |
| X-RateLimit-Limit / Remaining / Reset | Rate-limit telemetry |
Tools
| Tool | Use |
|---|---|
| Postman / Insomnia / Bruno | Interactive API exploration. Save collections, scripts, env vars. |
| HTTPie | CLI alternative to curl with friendly defaults. http POST api.example.com/users name=alice. |
| curl + jq | The duct tape of API work. Learn both deeply. |
| OpenAPI / Swagger UI | API documentation + interactive try-it. Generate clients from the spec. |
| Burp Suite | For security testing — intercept, modify, replay any HTTP request. |
| k6 / Locust / Vegeta | Load testing. |
| RFC 7231 + 7232 + 7234 + 7807 | The actual HTTP standards. Read them once; refer back when in doubt. |
Closing thoughts
Three things to take away:
HTTP isn't just the transport — it's the contract. Verbs, status codes, headers, ETags all carry real meaning that proxies, caches, monitoring and clients depend on. Returning 200 { error } isn't creative design, it's breaking the contract every layer of your stack assumes. Lean into HTTP semantics and you get caching, retries, observability and easy integrations for free.
Idempotency isn't optional for write APIs. The network will fail and clients will retry — both are certainties. Every POST without an Idempotency-Key is a duplicate charge waiting to page someone at 3am. The implementation is about twenty lines of Redis-backed middleware; skipping it gets paid for in support tickets and refunds instead.
Most "REST" APIs aren't really REST, and that's fine. Pure HATEOAS, strict resource modelling, hypermedia state machines — interesting ideas that mostly don't survive contact with production. What does survive is predictable URLs, correct verbs, honest status codes, idempotent writes, stable pagination and sensible caching. Get those right and your API is a pleasure to integrate with, REST orthodoxy or not.
Next on the API Security track: API Authentication — Bearer tokens, mTLS, HMAC signing — then Postman/Insomnia for pentesting, then the API Top 10. Everything there builds on the fundamentals here.