API Rules
HTTP API rules keep Node backends predictable for clients, BFFs, and partner integrations across services.
Recipe
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:
- Public REST or JSON HTTP APIs consumed by web/mobile/partners.
- Multiple teams implement services that clients treat uniformly.
- Payment, order, or billing mutations need safe retries.
Working Example
// 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:
- Uniform
{ error: { code, message } }and{ data: ... }success envelope. - Pagination with capped
limitandnextCursor. - Idempotent POST returns 200 on replay, 201 on first create.
Deep Dive
How It Works
- Version prefix
/v1/allows breaking changes in/v2/without silent client breaks. - Error
codeis machine-readable;messageis human-safe (no stack traces). - Idempotency keys stored with unique index prevent duplicate charges.
409 Conflictfor state conflicts (already cancelled invoice).
Status Code Guide
| 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 |
TypeScript Notes
- Share response types in
packages/contractsfor monorepo consumers. - Zod parse errors map to 400 with
code: "validation_error".
Gotchas
- Different error shapes per route - Clients cannot parse reliably. Fix: global error middleware only.
- Unbounded
limit=999999- DB OOM. Fix: hard cap 100 server-side always. - Idempotency only in docs - Retries double-charge. Fix: DB unique constraint on key.
- Returning 200 with error body - Breaks HTTP semantics. Fix: proper status codes.
- Leaking internal ids in errors -
postgres uuid constraintin message. Fix: generic message, details in logs only.
Alternatives
| 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 |
FAQs
Cursor vs offset pagination?
Prefer cursor for large tables (stable under inserts). Offset ok for admin low-volume lists.
Idempotency-Key on PUT/PATCH?
Required on POST creates; PUT often naturally idempotent by resource id; document per endpoint.
Should success wrap always use data?
Pick one envelope org-wide; { data } common for JSON:API-like consistency.
Fastify schema validation errors?
Map Fastify validation to same { error: { code, message } } in setErrorHandler.
NestJS exception filters?
Custom filter maps HttpException to shared error JSON shape.
Rate limit response body?
429 with Retry-After header and error.code: "rate_limited".
DELETE idempotency?
Deleting twice returns 204 or 404 consistently - document which.
ISO8601 dates in JSON?
Yes strings in UTC 2026-07-09T12:00:00.000Z; never ambiguous local without offset.
OpenAPI required?
Recommended for public APIs; generate from Zod/schemas where possible.
Version in header vs path?
Path /v1 is clearer for caching and routing; header optional supplement.
Related
- Node Project Rules Checklist - rules 11-13
- Security Rules - validation at boundary
- Logging & Observability Rules - log requestId with errors
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.