Two years ago I lost a day to an endpoint called POST /api/v2/getOrderList. It took a JSON body, silently ignored the limit field, returned every order the account had ever placed in one 40 MB array, and answered 200 OK when the token was expired — with {"success": false, "msg": "auth"} buried in the payload.
My client did what clients do. It checked response.ok, saw true, parsed the body, found no orders, and handed an empty list to the billing job. We under-invoiced for nine days before anyone noticed.
That endpoint is the reason I write a REST API example the way I do now: full request, full response, real status codes, and the error case shown next to the happy path. What follows is one small resource built end to end. Every command below is meant to be pasted and adapted, not admired.
Quick answer: what does a REST API request and response look like?
A REST API request is an HTTP method plus a URL that names a thing (GET /v1/notes/note_01HZ8QK7X2), usually with an Authorization header and, for writes, a JSON body. The response is an HTTP status code that carries the outcome (200, 201, 404, 422), response headers, and a JSON body holding either the resource or a structured error. The status code is the contract — the body explains it, it never contradicts it.
curl -i "https://api.example.com/v1/notes/note_01HZ8QK7X2" \
-H "Authorization: Bearer $API_KEY" \
-H "Accept: application/json"
HTTP/2 200
content-type: application/json; charset=utf-8
x-request-id: req_01HZ8QK7X2M4NPRS
etag: "3"
The resource for this REST API example
I'll use notes, because it's boring enough to stay out of the way. A note has an id, a title, a body, tags, and timestamps. The base URL is https://api.example.com/v1, and every request carries a bearer token:
export API_KEY="sk_live_9f2c...";
export BASE="https://api.example.com/v1"
Two rules I hold to before writing a single route:
-
URLs name nouns, methods supply the verb.
/notes,/notes/{id},/notes/{id}/attachments. Never/getNotesor/notes/delete. -
Collections are always bounded. There is no version of this API where
GET /notesreturns everything.
GET the collection, with pagination
curl -sS "$BASE/notes?limit=2" \
-H "Authorization: Bearer $API_KEY" \
-H "Accept: application/json"
200 OK:
{
"data": [
{
"id": "note_01HZ8QK7X2M4NPRS",
"title": "Rate limit runbook",
"body": "429 responses carry Retry-After in seconds.",
"tags": ["api", "ops"],
"created_at": "2026-08-03T09:12:44Z",
"updated_at": "2026-08-05T17:40:02Z"
},
{
"id": "note_01HZ8QM1B7C9DEFG",
"title": "Cursor pagination",
"body": "Offsets drift when rows are inserted mid-scan.",
"tags": ["api"],
"created_at": "2026-08-02T11:03:19Z",
"updated_at": "2026-08-02T11:03:19Z"
}
],
"pagination": {
"limit": 2,
"has_more": true,
"next_cursor": "eyJpZCI6Im5vdGVfMDFIWjhRTTFCN0M5REVGRyJ9"
}
}
The list lives under data, not at the top level. That one decision means I can add pagination, warnings, or total_count later without breaking a single client. A bare top-level array is a design you can never extend.
The next page is a pure copy-paste of the cursor:
curl -sS "$BASE/notes?limit=2&cursor=eyJpZCI6Im5vdGVfMDFIWjhRTTFCN0M5REVGRyJ9" \
-H "Authorization: Bearer $API_KEY"
GET one
curl -sS "$BASE/notes/note_01HZ8QK7X2M4NPRS" \
-H "Authorization: Bearer $API_KEY"
200 OK returns the note object directly — no data wrapper needed for a single resource, though wrapping it consistently is also defensible. Pick one and never mix them.
Ask for something that isn't there and you get 404 Not Found with a body that is still JSON:
{
"error": {
"type": "not_found",
"code": "note_not_found",
"message": "No note with id note_01HZ8QK7X2M4NPRQ",
"request_id": "req_01HZ8R5V0P2K3XYZ"
}
}
POST to create
curl -sS -X POST "$BASE/notes" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"title": "Retry budgets",
"body": "Retry 429 and 5xx only. Cap total attempts at 3.",
"tags": ["api", "reliability"]
}'
201 Created, with a Location header pointing at the new resource:
{
"id": "note_01HZ8S9TQ4W7YZAB",
"title": "Retry budgets",
"body": "Retry 429 and 5xx only. Cap total attempts at 3.",
"tags": ["api", "reliability"],
"created_at": "2026-08-09T14:22:07Z",
"updated_at": "2026-08-09T14:22:07Z"
}
POST is not idempotent, which is why the Idempotency-Key header exists: replay the same key within the server's retention window and you get the original 201 response back instead of a duplicate note. If you are building the server, this is the single highest-value header you can support. Clients retry. They always retry.
Send garbage and you get 422 Unprocessable Content — not 200, not 500:
{
"error": {
"type": "validation_error",
"code": "invalid_field",
"message": "title must be between 1 and 200 characters",
"field": "title",
"request_id": "req_01HZ8SB3N6M8QRST"
}
}
PATCH to change part of it
curl -sS -X PATCH "$BASE/notes/note_01HZ8S9TQ4W7YZAB" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "If-Match: \"1\"" \
-d '{"tags": ["api", "reliability", "runbook"]}'
200 OK with the full updated object. PATCH sends only the fields you're changing; everything omitted stays untouched.
The If-Match header carries the ETag you got from the last read. If someone else edited the note in between, the server answers 412 Precondition Failed and your write is rejected instead of quietly clobbering theirs. It's optional, it's two lines of server code, and it eliminates an entire class of "the dashboard reverted my change" bug reports.
DELETE
curl -i -X DELETE "$BASE/notes/note_01HZ8S9TQ4W7YZAB" \
-H "Authorization: Bearer $API_KEY"
HTTP/2 204
x-request-id: req_01HZ8SD9F1G2H3JK
204 No Content means an empty body. Not {}, not null, not {"deleted": true} — literally zero bytes. Your client must handle that before it calls .json(), which is exactly the bug I've fixed most often in other people's SDKs.
HTTP methods, semantics, and status codes at a glance
| Method | Semantics | Idempotent | Typical success | Typical failure |
|---|---|---|---|---|
GET /notes |
Read a bounded page of the collection | Yes (and safe) | 200 OK |
401, 403, 422 (bad query params) |
GET /notes/{id} |
Read one resource | Yes (and safe) | 200 OK |
404 Not Found |
POST /notes |
Create a new member | No |
201 Created + Location
|
409 Conflict, 422
|
PUT /notes/{id} |
Replace the whole representation | Yes |
200 OK or 204 No Content
|
404, 412, 422
|
PATCH /notes/{id} |
Apply a partial change | Not guaranteed | 200 OK |
404, 412, 422
|
DELETE /notes/{id} |
Remove the resource | Yes | 204 No Content |
404, 409
|
Idempotent means repeating it lands you in the same state, not it returns the same response. Deleting a note twice leaves you with no note both times; the second call may legitimately answer 404. That distinction is what tells a client library which calls are safe to retry automatically.
A JavaScript client for this REST API example
Here's the whole client. It runs on Node 18+ and in any modern browser, no dependencies.
const BASE_URL = "https://api.example.com/v1";
class ApiError extends Error {
constructor(status, payload, requestId) {
super(payload?.error?.message ?? `HTTP ${status}`);
this.name = "ApiError";
this.status = status;
this.type = payload?.error?.type;
this.code = payload?.error?.code;
this.field = payload?.error?.field;
this.requestId = requestId;
}
}
async function request(path, { method = "GET", body, apiKey, headers = {}, signal } = {}) {
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
...(body === undefined ? {} : { "Content-Type": "application/json" }),
...headers,
},
body: body === undefined ? undefined : JSON.stringify(body),
signal,
});
const requestId = res.headers.get("x-request-id");
// 204 and 304 are defined to have no body. Never call .json() on them.
if (res.status === 204 || res.status === 304) {
if (!res.ok) throw new ApiError(res.status, null, requestId);
return null;
}
const text = await res.text();
let payload = null;
if (text) {
try {
payload = JSON.parse(text);
} catch {
// A proxy or WAF returned HTML. Surface it instead of throwing SyntaxError.
throw new ApiError(res.status, { error: { message: text.slice(0, 200) } }, requestId);
}
}
if (!res.ok) throw new ApiError(res.status, payload, requestId);
return payload;
}
Three things that snippet does which most hand-rolled clients don't: it reads the body as text before parsing, it attaches x-request-id to the error so support tickets are one-line, and it never trusts a 2xx to mean the body is well formed.
Pagination becomes an async generator, so callers write a normal for loop and never see a cursor:
async function* listNotes({ apiKey, limit = 50, signal } = {}) {
let cursor = null;
do {
const qs = new URLSearchParams({ limit: String(limit) });
if (cursor) qs.set("cursor", cursor);
const page = await request(`/notes?${qs}`, { apiKey, signal });
yield* page.data;
cursor = page.pagination.has_more ? page.pagination.next_cursor : null;
} while (cursor);
}
Retries belong in one place, and only for calls that can survive being repeated:
const RETRIABLE = new Set(["GET", "PUT", "DELETE", "HEAD"]);
async function requestWithRetry(path, options = {}, attempts = 3) {
const method = options.method ?? "GET";
const canRetry = RETRIABLE.has(method) || Boolean(options.headers?.["Idempotency-Key"]);
for (let i = 0; ; i++) {
try {
return await request(path, options);
} catch (err) {
const transient = err instanceof ApiError && (err.status === 429 || err.status >= 500);
if (!canRetry || !transient || i >= attempts - 1) throw err;
const backoff = 2 ** i * 500 + Math.random() * 250;
await new Promise((resolve) => setTimeout(resolve, backoff));
}
}
}
And the code you actually write against it:
const apiKey = process.env.API_KEY;
const note = await request("/notes", {
method: "POST",
apiKey,
headers: { "Idempotency-Key": crypto.randomUUID() },
body: {
title: "Retry budgets",
body: "Retry 429 and 5xx only.",
tags: ["api", "reliability"],
},
});
await request(`/notes/${note.id}`, {
method: "PATCH",
apiKey,
body: { tags: [...note.tags, "runbook"] },
});
for await (const n of listNotes({ apiKey })) {
console.log(n.id, n.title);
}
await request(`/notes/${note.id}`, { method: "DELETE", apiKey }); // → null
Mistakes I keep finding in real integrations
These aren't hypotheticals. Every one of them cost me or a teammate real hours.
-
200 OKwith an error in the body. The status code is the only signal generic middleware, load balancers, dashboards, andres.okcan read. Hiding failure inside a200breaks all of them at once, including your own monitoring. If the request failed, return a4xxor5xx. -
A different error shape per endpoint. One route returns
{"error": "..."}, another{"errors": [...]}, a third{"message": "..."}. Now every call site needs bespoke parsing. Define the error envelope once, apply it to every non-2xx response, and include arequest_id. -
Unbounded collections.
GET /noteswith nolimitand no cursor works beautifully with 12 rows in staging and takes down a pod at 400,000 in production. Caplimitserver-side, apply a default, and return the cap in the response so clients can see it. -
Verbs in the URL.
/notes/create,/notes/{id}/delete,/getNoteList. The moment the path carries the action, caching, method-based routing, and read-only proxies all stop working, and nobody can guess your next endpoint. -
Breaking changes without a version. Renaming
bodytocontent, or narrowing a nullable field, ships instantly to every client that never asked for it. Additive changes are free; anything else needs a new version and an overlap window. -
Timestamps without a timezone.
"2026-08-09 14:22:07"is not a moment in time. Use RFC 3339 with an offset —2026-08-09T14:22:07Z— and make every field in the API agree.
What good API documentation looks like
A REST API example is only as good as the reference behind it. When I evaluate an API before integrating, I look for four things: every endpoint's full request and response shown as real payloads, the complete list of error codes with their status codes, the auth flow written out with an actual header, and pagination documented as a runnable loop rather than a sentence.
If you want a small public reference to inspect against that checklist, the Misar.Blog API reference documents a live REST API — resource-shaped paths, bearer auth, and per-endpoint request and response bodies — and you can read the whole surface in a few minutes.
The test I apply: can a developer make their first successful call using nothing but copy-paste, without opening a support ticket? If the docs pass that, the API design is usually fine too. The two tend to fail together.
FAQ
What's the actual difference between PUT and PATCH?
PUT replaces the entire resource with the representation you send — omit a field and you're asking for it to be cleared or reset to its default. PATCH applies a partial change, so omitted fields are left alone. Practically, PUT is idempotent and safe to retry; PATCH only is if your patch is absolute ({"tags": [...]}) rather than relative ({"increment_views": 1}). If you offer PATCH, document which of the two your semantics are.
When does REST lose to GraphQL?
REST loses when a single screen needs data from six resources and you're either making six round trips or bolting on ?expand= parameters that grow forever. That's the classic over-fetching/under-fetching squeeze, and GraphQL solves it directly. REST wins on HTTP caching, on debuggability with plain curl, on rate limiting per endpoint, and on the fact that any developer can read a URL and guess what it does. For a public API with a few dozen resources and diverse consumers, I still reach for REST first.
How should I version a REST API?
Put the major version in the path — /v1/notes — because it's visible in logs, trivially routable, and impossible to forget. Additive changes (new optional fields, new endpoints, new enum values clients may ignore) don't need a bump; removals, renames, type changes, and stricter validation do. Header-based or date-based versioning is more elegant and genuinely harder to operate, so only take it on if you already have the tooling.
What's the best way to paginate?
Cursor-based, in almost every case. ?limit=50&cursor=... reads a stable position in the result set, so rows inserted or deleted mid-scan don't cause you to skip or duplicate records the way ?page=3&per_page=50 does — and it stays fast on large tables where OFFSET 100000 does not. Return has_more alongside next_cursor so clients have an unambiguous stop condition, and treat the cursor as an opaque string that clients pass back untouched.
Top comments (0)