API Rules
HTTP API rules keep Node backends predictable for clients, BFFs, and partner integrations across services.
Search across all documentation pages
HTTP API rules keep Node backends predictable for clients, BFFs, and partner integrations across services.
Quick-reference recipe card - copy-paste ready.
app.get("/invoices", async (req, res) => {
const limit = Math.min(Number(req.query.limit ?? 20), 100);
const cursor = req.query.cursor as string | undefined;
const page = await listInvoices({ limit, cursor });
res.json({ data: page.items, nextCursor: page.nextCursor });
});
app.post("/invoices", async (req, res) => {
const key = req.headers["idempotency-key"];
if (!key) return res.status(400).json({ error: { code: "missing_idempotency_key", message: "required" } });
// ...
});When to reach for this:
// src/errors/http-error.ts
export class HttpError extends Error {
constructor(
public status: number,
public code: string,
message: string,
) {
super(message);
}
}
export function errorHandler(err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) {
if (err instanceof HttpError) {
return res.status(err.status).json({ error: { code: err.code, message: err.message } });
}
res.status(500).json({ error: { code: "internal_error", message: "unexpected error" } });
}// src/routes/invoices.ts
app.get("/v1/invoices", async (req, res, next) => {
try {
const limit = Math.min(Math.max(Number(req.query.limit ?? 20), 1), 100);
const result = await repo.list({ limit, cursor: req.query.cursor as string | undefined });
res.json({ data: result.rows, nextCursor: result.nextCursor });
} catch (e) {
next(e);
}
});
app.post("/v1/invoices", async (req, res, next) => {
try {
const idempotencyKey = req.headers["idempotency-key"];
if (typeof idempotencyKey !== "string") {
throw new HttpError(400, "missing_idempotency_key", "Idempotency-Key header required");
}
const existing = await repo.findByIdempotencyKey(idempotencyKey);
if (existing) return res.status(200).json({ data: existing });
const created = await repo.create({ ...req.body, idempotencyKey });
res.status(201).json({ data: created });
} catch (e) {
next(e);
}
});What this demonstrates:
{ error: { code, message } } and { data: ... } success envelope.limit and nextCursor./v1/ allows breaking changes in /v2/ without silent client breaks.code is machine-readable; message is human-safe (no stack traces).409 Conflict for state conflicts (already cancelled invoice).| Code | Use |
|---|---|
| 400 | Validation failed |
| 401 | Missing/invalid auth |
| 403 | Auth ok, not allowed |
| 404 | Resource not found |
| 409 | Conflict / duplicate |
| 422 | Semantic validation (optional) |
| 429 | Rate limited |
| 500 | Unexpected server error |
packages/contracts for monorepo consumers.code: "validation_error".limit=999999 - DB OOM. Fix: hard cap 100 server-side always.postgres uuid constraint in message. Fix: generic message, details in logs only.| Alternative | Use When | Don't Use When |
|---|---|---|
| GraphQL | Flexible client queries | Simple CRUD partner APIs |
| gRPC internal | Service-to-service perf | Browser-facing public API |
| Problem Details RFC 7807 | Standards-heavy orgs | Existing { error: { code } } clients |
Prefer cursor for large tables (stable under inserts). Offset ok for admin low-volume lists.
Required on POST creates; PUT often naturally idempotent by resource id; document per endpoint.
Pick one envelope org-wide; { data } common for JSON:API-like consistency.
Map Fastify validation to same { error: { code, message } } in setErrorHandler.
Custom filter maps HttpException to shared error JSON shape.
429 with Retry-After header and error.code: "rate_limited".
Deleting twice returns 204 or 404 consistently - document which.
Yes strings in UTC 2026-07-09T12:00:00.000Z; never ambiguous local without offset.
Recommended for public APIs; generate from Zod/schemas where possible.
Path /v1 is clearer for caching and routing; header optional supplement.
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.
Reviewed by Chris St. John·Last updated Jul 18, 2026