The APIs I remember are the ones that made me guess: a 200 OK with an error in the body, a list endpoint that times out once the table grows, a retried POST that charged a customer twice, a renamed field that broke every mobile app in the wild. None of these are hard problems. They're decisions nobody made on purpose, and they get expensive once clients depend on them.
This post covers the conventions I use for APIs other developers have to live with, from URLs and status codes to errors, pagination, retries, concurrency, versioning, rate limits, and documentation. The examples use TypeScript with Express 5, zod 4, and PostgreSQL via node-postgres, built around a small orders API.
Design URLs around resources
REST models your domain as resources: nouns identified by URLs, with HTTP methods supplying the verbs.
GET /orders list orders
POST /orders create an order
GET /orders/:id read one order
PATCH /orders/:id update some fields
DELETE /orders/:id delete an order
GET /customers/:id/orders list one customer's ordersPlural nouns and shallow nesting
I use plural nouns so a collection and its members share a prefix: /orders and /orders/:id. Paths are lowercase and hyphenated (/shipping-addresses), and JSON fields use one casing everywhere. Mine is camelCase.
Nesting shows ownership, and I stop at one level. /customers/:id/orders reads well, but order IDs are already unique, so an order's items live at /orders/:id/items, not /customers/:id/orders/:orderId/items. Verbs stay out of paths: POST /createOrder repeats what the method already says.
When an action endpoint is fine
Some operations aren't CRUD. Cancelling an order refunds a payment, releases stock, and emails the customer. Hiding that behind PATCH /orders/:id with { "status": "cancelled" } makes it hard to validate or authorize separately, so I use POST /orders/:id/cancel: explicit, separately permissioned, and still scoped to a resource. One exception: if the action creates something with its own lifecycle, model that thing. A refund has an ID and a status, so it's POST /payments/:id/refunds.
Use methods and status codes the way HTTP intends
Safety and idempotency
RFC 9110 defines two method properties that clients and proxies rely on. A safe method doesn't change state, so crawlers and link previewers can call it freely. An idempotent method has the same effect whether it's sent once or five times, so it's safe to retry after a timeout.
| Method | Use it for | Safe | Idempotent |
|---|---|---|---|
| GET | Read a resource or collection | Yes | Yes |
| PUT | Replace a resource with the full representation | No | Yes |
| PATCH | Change some fields of a resource | No | Not guaranteed |
| DELETE | Remove a resource | No | Yes |
| POST | Create a resource or run an action | No | No |
GET must never change state: a GET /orders/:id/cancel link will eventually be fetched by a chat app building a preview. Idempotency is about the effect, not the response, so a second DELETE can return 404. PATCH is idempotent only if you design it that way; setting a field is, appending to a list isn't. POST can't be retried blindly; idempotency keys fix that later.
Status codes that matter
Clients branch on the status code before reading the body:
| Status | When to use it |
|---|---|
| 200 OK | A successful GET, or an update that returns the resource |
| 201 Created | A POST created a resource; include a Location header |
| 202 Accepted | Work was queued; return a URL to poll for progress |
| 204 No Content | Success with no body, typically a DELETE |
| 400 Bad Request | Unparseable request: malformed JSON, a corrupt cursor, a missing required header |
| 401 Unauthorized | Missing or invalid credentials; send WWW-Authenticate |
| 403 Forbidden | Authenticated, but not allowed |
| 404 Not Found | It doesn't exist, or the caller shouldn't learn that it does |
| 409 Conflict | Clashes with current state: a duplicate email, cancelling a shipped order |
| 412 Precondition Failed | An If-Match precondition failed |
| 422 Unprocessable Content | The request parses, but its values fail validation |
| 429 Too Many Requests | Rate limit exceeded; send Retry-After |
| 500 Internal Server Error | A bug on your side; reveal nothing internal |
| 503 Service Unavailable | Overloaded or in maintenance; send Retry-After if you can |
Never return 200 with an error in the body; retries, monitoring, and client libraries all key off the status. Prefer 404 over 403 when admitting a resource exists would leak information. The 400 versus 422 split is a judgment call: I use 400 when I can't parse the request and 422 when the values are wrong. Many good APIs use 400 for both, so pick a rule and document it.
Return errors clients can act on
The Problem Details format
Every error should share one shape, so clients write one error path instead of twenty. RFC 9457, which obsoletes RFC 7807, defines that shape: Problem Details, served as application/problem+json.
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/validation-error",
"title": "Your request is not valid",
"status": 422,
"errors": [
{ "pointer": "#/customerId", "detail": "Invalid UUID" },
{ "pointer": "#/items/0/quantity", "detail": "Too big: expected number to be <=100" }
],
"instance": "urn:uuid:6f1b0c2e-8a47-4d5e-b3f9-1c2d3e4f5a6b"
}Clients branch on type, a URI that should resolve to documentation for that error. title is a stable summary, detail explains this occurrence, and instance identifies it. Anything else, like errors (borrowed from the RFC's own validation example), is an extension member, and clients must ignore extensions they don't recognize.
One error handler for every route
Express 5 forwards errors thrown in async handlers to the error middleware, so one function can translate every failure: HttpProblem for expected failures, ZodError for invalid input, and a generic 500 for everything else.
import { randomUUID } from "node:crypto";
import { STATUS_CODES } from "node:http";
import type { ErrorRequestHandler } from "express";
import { ZodError } from "zod";
const TYPE_BASE = "https://api.example.com/problems/";
export class HttpProblem extends Error {
readonly status: number;
readonly type: string;
readonly title: string;
readonly detail?: string;
constructor(status: number, slug: string, title: string, detail?: string) {
super(detail ?? title);
this.status = status;
this.type = TYPE_BASE + slug;
this.title = title;
this.detail = detail;
}
}
interface Problem {
type: string;
title: string;
status: number;
[member: string]: unknown;
}
export const problemHandler: ErrorRequestHandler = (err, _req, res, next) => {
if (res.headersSent) return next(err);
let problem: Problem;
if (err instanceof HttpProblem) {
const { type, title, status, detail } = err;
problem = { type, title, status, detail };
} else if (err instanceof ZodError) {
problem = {
type: TYPE_BASE + "validation-error",
title: "Your request is not valid",
status: 422,
errors: err.issues.map((issue) => ({
pointer: "#" + issue.path.map((key) => `/${String(key)}`).join(""),
detail: issue.message,
})),
};
} else if (err.expose === true && typeof err.status === "number") {
// express.json() errors: malformed JSON (400), body too large (413)
const { status, message } = err;
const title = STATUS_CODES[status] ?? "Bad Request";
problem = { type: "about:blank", title, status, detail: message };
} else {
const title = "Internal Server Error";
problem = { type: "about:blank", title, status: 500 };
}
// Clients get an ID; the details stay in the logs
problem.instance = `urn:uuid:${randomUUID()}`;
if (problem.status >= 500) console.error(problem.instance, err);
res.status(problem.status).type("application/problem+json").json(problem);
};Register it after all routes with app.use(problemHandler). Without the express.json() branch, a client that sends malformed JSON would get a 500 for its own mistake.
Validate at the boundary with zod
The body, query string, path parameters, and headers are all untrusted. I parse each with zod at the top of the handler and pass only the parsed result inward.
import { z } from "zod";
export const OrderParams = z.object({ id: z.uuid() });
export const CreateOrderBody = z.object({
customerId: z.uuid(),
items: z
.array(
z.object({
sku: z.string().min(1).max(64),
quantity: z.number().int().min(1).max(100),
}),
)
.min(1)
.max(50),
note: z.string().max(500).optional(),
});
export const UpdateOrderBody = z.object({
note: z.string().max(500),
});
export type CreateOrderBody = z.infer<typeof CreateOrderBody>;A handler starts with const input = CreateOrderBody.parse(req.body), and bad input becomes a 422. Parsing also strips unknown keys, so a client that sends "role": "admin" can't smuggle it into your SQL. Bounds on every string and array cap what one request can do, and z.uuid() on path parameters turns a garbage ID into a clean 422 instead of a PostgreSQL cast error and a 500.
Paginate, filter, and sort predictably
Paginate every collection from day one. Adding it later breaks clients that assume they got everything.
Offset versus keyset pagination
Offset pagination (?page=3&limit=20) is simple and lets clients jump to any page. But OFFSET 100000 makes PostgreSQL read and discard 100,000 rows, and rows inserted or deleted mid-scroll shift everything, so clients see duplicates or miss items.
Keyset pagination, exposed to clients as a cursor, asks for the rows after the last one the client saw. With the right index every page costs about the same, and inserts don't shift anything. You give up jumping to page 37 and cheap total counts, and the sort must be deterministic. I use offset for small admin lists and keyset for anything that grows.
Keyset pagination in PostgreSQL
Here's "newest first", with id as the tie-breaker:
-- One composite index serves the tenant filter and the sort order
CREATE INDEX orders_account_created_id_idx
ON orders (account_id, created_at, id);
-- Next page: the rows that sort after the cursor
SELECT id, status, total_cents, created_at
FROM orders
WHERE account_id = $1
AND (created_at, id) < ($2, $3)
ORDER BY created_at DESC, id DESC
LIMIT $4;The row comparison means "created earlier, or at the same instant with a smaller id", which matches the ORDER BY exactly. Without the tie-breaker, rows that share a timestamp can straddle a page boundary and disappear. The index lets PostgreSQL seek straight to the cursor position, and the first page simply omits the row comparison.
The cursor is base64url-encoded JSON of the last row's sort key, and findOrdersPage runs the query above.
import type { RequestHandler } from "express";
import { z } from "zod";
import { HttpProblem } from "../http/problem.js";
import { findOrdersPage, toOrderResponse } from "./repository.js";
const ListOrdersQuery = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
cursor: z.string().max(512).optional(),
});
const Cursor = z.object({ createdAt: z.iso.datetime(), id: z.uuid() });
type Cursor = z.infer<typeof Cursor>;
const encodeCursor = (cursor: Cursor) =>
Buffer.from(JSON.stringify(cursor)).toString("base64url");
function decodeCursor(raw: string): Cursor {
try {
const json = Buffer.from(raw, "base64url").toString("utf8");
return Cursor.parse(JSON.parse(json));
} catch {
throw new HttpProblem(400, "invalid-cursor", "Invalid cursor");
}
}
export const listOrders: RequestHandler = async (req, res) => {
const { limit, cursor } = ListOrdersQuery.parse(req.query);
const after = cursor ? decodeCursor(cursor) : undefined;
// Ask for one extra row: if it comes back, there is another page
const rows = await findOrdersPage(res.locals.accountId, after, limit + 1);
const page = rows.slice(0, limit);
const last = page.at(-1);
const nextCursor =
rows.length > limit && last
? encodeCursor({ createdAt: last.created_at.toISOString(), id: last.id })
: null;
res.json({ data: page.map(toOrderResponse), nextCursor });
};Fetching limit + 1 rows reveals whether another page exists without a count query. The cursor is validated like any other input. Opaque means clients mustn't parse it, so you can change the format; it doesn't mean secret, since anyone can decode base64.
A page looks like this; on the last one, nextCursor is null:
{
"data": [
{
"id": "7c2e9a41-3b8d-4f6a-9e15-8d4b2c7f0a63",
"status": "shipped",
"totalCents": 12900,
"createdAt": "2026-09-14T11:02:47.905Z"
},
{
"id": "0f8e2b6c-5d1a-4c3e-9b7f-2a6d8e4c1b90",
"status": "paid",
"totalCents": 4200,
"createdAt": "2026-09-14T10:12:03.418Z"
}
],
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA5LTE0VDEwOjEyOjAzLjQxOFoiLCJpZCI6IjBmOGUyYjZjLTVkMWEtNGMzZS05YjdmLTJhNmQ4ZTRjMWI5MCJ9"
}Filtering and sorting conventions
Filters are plain query parameters named after response fields. Sorting uses sort, with a leading minus for descending:
GET /orders?status=paid&createdAfter=2026-09-01T00:00:00Z
GET /orders?sort=-total&limit=50Each filter is a typed field in the zod query schema. I avoid generic filter languages, because every operator you ship is one you support forever.
Sorting needs an allowlist. Column names and directions can't be bind parameters, so they end up in the SQL string itself:
import { z } from "zod";
const OrderSort = z.enum(["createdAt", "-createdAt", "total", "-total"]);
// The only strings that can ever reach ORDER BY
export const ORDER_BY: Record<z.infer<typeof OrderSort>, string> = {
createdAt: "created_at ASC, id ASC",
"-createdAt": "created_at DESC, id DESC",
total: "total_cents ASC, id ASC",
"-total": "total_cents DESC, id DESC",
};
export const OrderFilters = z.object({
status: z.enum(["pending", "paid", "shipped", "cancelled"]).optional(),
createdAfter: z.iso.datetime().optional(),
sort: OrderSort.default("-createdAt"),
});Anything outside the enum is rejected with a 422, and ORDER BY ${ORDER_BY[filters.sort]} only ever interpolates a constant. Clients sort by API names like createdAt, so columns can change underneath them. Back every sort option with an index. With keyset pagination, the cursor must also carry the sort and the last row's value for that field.
Handle retries, conflicts, and rate limits
Idempotency keys for POST
If the connection drops after the server commits an order but before the response arrives, the client retries, and a naive POST /orders creates a duplicate.
The fix, popularized by Stripe and described in an IETF Internet-Draft, is an Idempotency-Key header. The client sends a unique key, such as a UUID, per logical operation and reuses it on every retry. The server stores the key, a hash of the request, and the response. Same key and body: replay the stored response. Same key with a different body: 422.
CREATE TABLE idempotency_keys (
account_id uuid NOT NULL,
idempotency_key text NOT NULL,
request_hash text NOT NULL,
response_status integer,
response_body json, -- json, not jsonb: keeps key order for replays
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, idempotency_key)
);import { createHash } from "node:crypto";
import type { PoolClient } from "pg";
import { transaction } from "../db.js";
import { HttpProblem } from "./problem.js";
type Stored<T> = { status: number; body: T };
export async function idempotent<T>(
accountId: string,
key: string,
input: unknown,
work: (db: PoolClient) => Promise<Stored<T>>,
): Promise<Stored<T>> {
const hash = createHash("sha256").update(JSON.stringify(input)).digest("hex");
return transaction(async (db) => {
// Claim the key. If another request holds it, this waits for that
// transaction to finish, then inserts nothing.
const claim = await db.query(
`INSERT INTO idempotency_keys (account_id, idempotency_key, request_hash)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING`,
[accountId, key, hash],
);
if (claim.rowCount === 0) {
const { rows } = await db.query(
`SELECT request_hash, response_status, response_body
FROM idempotency_keys
WHERE account_id = $1 AND idempotency_key = $2`,
[accountId, key],
);
if (rows[0].request_hash !== hash) {
throw new HttpProblem(
422,
"idempotency-key-reused",
"Idempotency-Key reused",
"This key was already used with a different request body.",
);
}
return { status: rows[0].response_status, body: rows[0].response_body };
}
const result = await work(db);
await db.query(
`UPDATE idempotency_keys
SET response_status = $3, response_body = $4
WHERE account_id = $1 AND idempotency_key = $2`,
[accountId, key, result.status, JSON.stringify(result.body)],
);
return result;
});
}The route validates first, then wraps the real work:
import type { RequestHandler } from "express";
import { idempotent } from "../http/idempotency.js";
import { HttpProblem } from "../http/problem.js";
import { insertOrder, toOrderResponse } from "./repository.js";
import { CreateOrderBody } from "./schemas.js";
export const createOrder: RequestHandler = async (req, res) => {
const key = req.get("Idempotency-Key");
if (!key || key.length > 255) {
throw new HttpProblem(
400,
"idempotency-key-required",
"Idempotency-Key required",
"Send a unique key of at most 255 characters, such as a UUID.",
);
}
const input = CreateOrderBody.parse(req.body);
const accountId: string = res.locals.accountId; // set by the auth middleware
const result = await idempotent(accountId, key, input, async (db) => {
const order = await insertOrder(db, accountId, input);
return { status: 201, body: toOrderResponse(order) };
});
const { status, body } = result;
res.status(status).location(`/orders/${body.id}`).json(body);
};transaction wraps the callback in BEGIN and COMMIT, rolling back on error. The primary key handles races: a concurrent duplicate blocks on its INSERT until the first transaction commits, then replays the stored response. A failure rolls back and releases the key, so a retry starts fresh. Scope keys to the authenticated account, or one client could replay another's response, and expire them after a documented window like 24 hours.
Conditional requests with ETags
An ETag identifies one version of a resource; clients echo it in conditional headers:
If-None-Matchon a GET: if nothing changed, answer 304 Not Modified with no body, saving bandwidth for polling clients.If-Matchon a write: if the resource changed since the client read it, answer 412 Precondition Failed instead of silently overwriting someone's work. That's optimistic concurrency: no locks, no lost updates.
GET /orders/0f8e2b6c-5d1a-4c3e-9b7f-2a6d8e4c1b90 HTTP/1.1
If-None-Match: "7"
HTTP/1.1 304 Not Modified
ETag: "7"I derive the ETag from a version column that every update increments:
import { Router } from "express";
import { pool } from "../db.js";
import { HttpProblem } from "../http/problem.js";
import { findOrder, toOrderResponse } from "./repository.js";
import { OrderParams, UpdateOrderBody } from "./schemas.js";
export const ordersRouter = Router();
ordersRouter.get("/orders/:id", async (req, res) => {
const { id } = OrderParams.parse(req.params);
const order = await findOrder(id);
if (!order) throw new HttpProblem(404, "order-not-found", "Order not found");
const etag = `"${order.version}"`; // strong ETag from a version column
res.set("ETag", etag);
// If-None-Match uses weak comparison, so a W/ prefix doesn't matter
const cached = req
.get("If-None-Match")
?.split(",")
.map((tag) => tag.trim().replace(/^W\//, ""));
if (cached?.includes(etag)) {
res.status(304).end();
return;
}
res.json(toOrderResponse(order));
});
ordersRouter.patch("/orders/:id", async (req, res) => {
const { id } = OrderParams.parse(req.params);
const ifMatch = req.get("If-Match");
if (!ifMatch) {
throw new HttpProblem(
428,
"precondition-required",
"If-Match required",
"Send the ETag from your last GET in an If-Match header.",
);
}
const { note } = UpdateOrderBody.parse(req.body);
// Our ETags look like "7". Anything else, even W/"7", never matches.
const version = /^"(\d{1,9})"$/.exec(ifMatch)?.[1] ?? null;
// Check and write in one atomic statement
const { rows } = await pool.query(
`UPDATE orders SET note = $3, version = version + 1
WHERE id = $1 AND version = $2
RETURNING *`,
[id, version, note],
);
const updated = rows[0];
if (!updated) {
if (!(await findOrder(id))) {
throw new HttpProblem(404, "order-not-found", "Order not found");
}
throw new HttpProblem(
412,
"order-modified",
"Order was modified",
"Fetch the latest version and reapply your change.",
);
}
res.set("ETag", `"${updated.version}"`).json(toOrderResponse(updated));
});The version check lives in the WHERE clause on purpose. If you read the row, compare in application code, and then write, two requests can both pass the check; a single statement makes it atomic. A request with no If-Match at all gets 428 Precondition Required (RFC 6585).
Rate limiting with 429 and Retry-After
When a client exceeds its limit, return 429 Too Many Requests with a problem body and a Retry-After header in seconds:
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 30
{
"type": "https://api.example.com/problems/rate-limited",
"title": "Rate limit exceeded",
"status": 429,
"detail": "This API key allows 100 requests per minute. Retry in 30 seconds."
}Limit per API key or account, not per IP; many clients share an address behind NAT. When the whole service is overloaded, use 503 with Retry-After instead.
To warn clients before they hit the limit, many APIs send non-standard X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers. The IETF HTTPAPI working group has a draft standardizing RateLimit and RateLimit-Policy header fields, but it's still an Internet-Draft and its syntax has changed between revisions. If you adopt it, pin and document the revision you implement.
Evolve the API without breaking clients
URL or header versioning
Clients need a way to opt in to breaking changes:
| Approach | Example | Strengths | Weaknesses |
|---|---|---|---|
| URL path | /v1/orders | Visible in logs, links, and curl; easy to route | Versions everything at once; URLs change |
| Header | Api-Version: 2026-09-01 | Stable URLs; small, dated versions | Invisible in links; caches need Vary: Api-Version |
GitHub and Stripe both use date-based version headers. I default to /v1 in the URL because it's boring and every tool understands it. The real goal is to rarely need /v2.
Additive changes and deprecation
Additive changes are safe: new endpoints, new optional fields and parameters, new response fields. Removing or renaming fields, changing types, making optional fields required, tightening validation, and changing defaults all break clients. Additions are only safe if clients ignore fields and enum values they don't recognize, so document that from day one; otherwise adding "refunded" to status breaks every exhaustive switch.
To retire something, announce it in the responses:
HTTP/1.1 200 OK
Content-Type: application/json
Deprecation: @1790812800
Sunset: Thu, 01 Apr 2027 00:00:00 GMT
Link: <https://api.example.com/docs/migrate-orders-v2>; rel="deprecation"; type="text/html"Deprecation (RFC 9745) holds a Unix timestamp prefixed with @, here October 1, 2026. Sunset (RFC 8594) is the HTTP date when the endpoint stops working, and it must not be earlier than the deprecation. The deprecation link points to the migration guide. Nobody reads headers until something breaks, though, so I also log which API keys still call deprecated endpoints and contact their owners well before the sunset.
Document the contract with OpenAPI
Without accurate docs, clients reverse-engineer your API, and whatever they infer becomes the contract. OpenAPI gives you one machine-readable description that drives reference docs, client generation, mock servers, and contract tests.
paths:
/orders:
post:
operationId: createOrder
summary: Create an order
parameters:
- name: Idempotency-Key
in: header
required: true
schema: { type: string, maxLength: 255 }
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/CreateOrder" }
responses:
"201":
description: Order created
headers:
Location: { schema: { type: string } }
content:
application/json:
schema: { $ref: "#/components/schemas/Order" }
"422": { $ref: "#/components/responses/ValidationProblem" }
"429": { $ref: "#/components/responses/RateLimited" }Document what teams usually skip: error responses and their problem types, pagination limits, required headers like Idempotency-Key and If-Match, rate limits, and the deprecation policy. Generate the spec from your validation schemas (zod 4 has z.toJSONSchema(), and libraries such as zod-openapi build full documents), lint it in CI, and diff it against the last release with a tool like oasdiff to catch breaking changes.
A pre-launch checklist
Before an API goes public, I check:
- Plural-noun URLs, shallow nesting, and few action endpoints
- GET is safe; PUT and DELETE are idempotent
- Precise status codes, never an error with a 200
- Every error is
application/problem+jsonwith a documentedtype - All input validated at the boundary, with explicit bounds
- Every collection paginated, with a maximum page size
- Sort and filter fields allowlisted and indexed
-
Idempotency-Keyon creating POSTs,If-Matchon updates - 429 responses include
Retry-After - A written versioning and deprecation policy
- An OpenAPI spec that is complete, linted, and diffed in CI
Key takeaways
- Model resources as plural nouns and let HTTP methods be the verbs.
- Precise status codes and one RFC 9457 error format are what clients code against first.
- Validate every input at the boundary, and allowlist anything that reaches your SQL text.
- Use keyset pagination with a unique tie-breaker and opaque, validated cursors.
- Make retries and concurrent writes safe with idempotency keys,
If-Match, andRetry-After. - Evolve additively, announce deprecations loudly, and let CI catch breaking changes.
None of this is clever. Each practice is a promise: this URL means this thing, this status means this outcome, retries are safe, and nothing changes without warning. Keep those promises and developers will trust your API enough to build on it.