API Design
Resource Naming
Resources are nouns, not verbs. URLs identify things; HTTP methods describe what to do with them.
# ❌ WRONG — verbs in paths, inconsistent case
GET /getUsers
POST /createUser
POST /deleteUser?id=123
GET /user_orders/456
# ✅ CORRECT — plural nouns, kebab-case, hierarchical
GET /users
POST /users
GET /users/:id
PATCH /users/:id
DELETE /users/:id
GET /users/:id/orders
GET /users/:id/orders/:orderId
Rules:
- Plural nouns for collections:
/users,/orders,/products - kebab-case for multi-word paths:
/payment-methods,/access-tokens - Nest sparingly — maximum two levels deep.
/users/:id/ordersis fine;/users/:id/orders/:orderId/line-items/:itemId/discountsis not - No file extensions in URLs (no
.json,.xml) - Lowercase everywhere
HTTP Verbs and Idempotency
| Method | Meaning | Idempotent | Safe | Body |
|---|---|---|---|---|
GET |
Read resource(s) | Yes | Yes | No |
HEAD |
Read metadata only | Yes | Yes | No |
POST |
Create resource or non-idempotent action | No | No | Yes |
PUT |
Full replacement of resource | Yes | No | Yes |
PATCH |
Partial update | No* | No | Yes |
DELETE |
Remove resource | Yes | No | No |
* PATCH is idempotent only if designed that way (e.g. { set: { status: "active" } } vs { increment: { count: 1 } }).
// ✅ CORRECT — verb reflects semantics
POST /users // creates a new user (not idempotent — two calls = two users)
PUT /users/:id // replaces the entire user document
PATCH /users/:id // updates specific fields
DELETE /users/:id // removes user — safe to call twice (second returns 404)
// ❌ WRONG — tunnelling everything through POST
POST /users/update
POST /users/delete
POST /api?action=getUser
HTTP Status Codes
Use the most precise code. Never return 200 OK with { success: false } in the body.
2xx — Success
| Code | Name | When to use |
|---|---|---|
200 |
OK | GET/PATCH/PUT succeeded, body contains the resource |
201 |
Created | POST succeeded, resource was created; include Location header |
204 |
No Content | DELETE / action succeeded, no body to return |
202 |
Accepted | Request accepted for async processing; poll a status endpoint |
4xx — Client Error
| Code | Name | When to use |
|---|---|---|
400 |
Bad Request | Malformed JSON, missing required field |
401 |
Unauthorized | Not authenticated (no token / token invalid) |
403 |
Forbidden | Authenticated but not allowed to access this resource |
404 |
Not Found | Resource doesn't exist |
409 |
Conflict | Duplicate resource, state conflict (e.g. already cancelled) |
422 |
Unprocessable Entity | Structurally valid but semantically wrong (validation failure) |
429 |
Too Many Requests | Rate limited; include Retry-After header |
5xx — Server Error
| Code | Name | When to use |
|---|---|---|
500 |
Internal Server Error | Unexpected crash; do not leak details |
502 |
Bad Gateway | Upstream dependency failed |
503 |
Service Unavailable | Intentional downtime / overload; include Retry-After |
Versioning Strategy
URL prefix versioning is the most practical approach for public APIs.
# ✅ RECOMMENDED — explicit, cacheable, easy to route
GET /v1/users
GET /v2/users
# Alternatives (with trade-offs)
Accept: application/vnd.myapi.v2+json # Header versioning — harder to test in browser
?version=2 # Query param — accidentally cached, easy to miss
Rules:
- Start at
/v1/from day one, even for internal APIs - Never remove a version without a documented migration window (minimum 6 months for external consumers)
- Breaking changes = new major version. Additive changes (new fields, new optional params) can ship within the same version
- Document what "breaking" means: removing a field, renaming a field, changing a field type, changing behavior of an existing endpoint
// Route structure example
app.use('/v1', v1Router);
app.use('/v2', v2Router);
// Or with Express Router
const v1 = express.Router();
v1.get('/users', getUsersV1);
const v2 = express.Router();
v2.get('/users', getUsersV2); // e.g. cursor pagination instead of offset
Pagination: Cursor over Offset
Offset pagination breaks when items are inserted or deleted between pages. Cursor pagination is stable.
// ❌ WRONG — offset pagination (breaks on concurrent writes)
GET /users?page=3&per_page=20
// If 5 users are inserted before page 3, the user sees duplicates or misses items
// ✅ CORRECT — cursor pagination (stable, scalable)
GET /users?limit=20
GET /users?limit=20&cursor=eyJ1c2VyX2lkIjoiMTIzIn0
// Response shape for cursor pagination
{
"data": [ /* array of items */ ],
"pagination": {
"nextCursor": "eyJ1c2VyX2lkIjoiMTQzIn0", // null when no more pages
"hasMore": true,
"limit": 20
}
}
// Implementation (TypeScript + PostgreSQL)
interface PaginationParams {
cursor?: string; // opaque base64-encoded JSON
limit: number;
}
interface CursorPayload {
createdAt: string;
id: string;
}
function decodeCursor(cursor: string): CursorPayload {
return JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
}
function encodeCursor(item: { createdAt: Date; id: string }): string {
const payload: CursorPayload = {
createdAt: item.createdAt.toISOString(),
id: item.id,
};
return Buffer.from(JSON.stringify(payload)).toString('base64url');
}
async function getUsers(params: PaginationParams) {
const limit = Math.min(params.limit, 100); // cap to prevent abuse
const cursor = params.cursor ? decodeCursor(params.cursor) : null;
const users = await db.users.findMany({
take: limit + 1, // fetch one extra to determine hasMore
where: cursor
? {
OR: [
{ createdAt: { lt: new Date(cursor.createdAt) } },
{ createdAt: new Date(cursor.createdAt), id: { gt: cursor.id } },
],
}
: undefined,
orderBy: [{ createdAt: 'desc' }, { id: 'asc' }],
});
const hasMore = users.length > limit;
const data = hasMore ? users.slice(0, limit) : users;
const lastItem = data.at(-1);
return {
data,
pagination: {
nextCursor: hasMore && lastItem ? encodeCursor(lastItem) : null,
hasMore,
limit,
},
};
}
Error Response Shape (RFC 7807)
All errors use the same shape. Clients can write one error handler.
// Consistent error envelope
{
"error": {
"code": "VALIDATION_FAILED", // machine-readable, stable string
"message": "Request validation failed", // human-readable, may change
"details": [ // optional — per-field errors
{ "field": "email", "message": "Must be a valid email address" },
{ "field": "age", "message": "Must be 18 or older" }
],
"traceId": "01HX4ZK9R3BVNMJ2QPFW5T8CDE" // correlate with server logs
}
}
// Express error handler
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
const appErr = err instanceof AppError ? err : null;
const statusCode = appErr?.statusCode ?? 500;
res.status(statusCode).json({
error: {
code: appErr?.code ?? 'INTERNAL_ERROR',
message: statusCode < 500
? (appErr?.message ?? 'Bad request')
: 'An unexpected error occurred',
...(appErr instanceof ValidationError && { details: appErr.fields }),
traceId: getTraceContext()?.traceId,
},
});
});
Rules:
codeis a SCREAMING_SNAKE_CASE string that never changes between versionsmessageis human-readable and may change — clients must not parse it- Include
traceIdso users can report a specific request to support - 5xx errors must NOT include stack traces, SQL, or file paths in the response
Idempotency for Mutations
POST requests are not idempotent by default. Allow clients to safely retry.
// Client sends Idempotency-Key header
POST /v1/payments
Idempotency-Key: 7f0c8e2a-5d1f-4b9e-a1c3-2e4f6d8b0a9c
Content-Type: application/json
{ "amount": 9900, "currency": "USD", "customerId": "cus_abc" }
// Server: store result keyed by idempotency key
async function createPayment(
req: Request,
res: Response,
): Promise<void> {
const idempotencyKey = req.headers['idempotency-key'] as string | undefined;
if (idempotencyKey) {
// Check if we already processed this key
const cached = await cache.get(`idem:${idempotencyKey}`);
if (cached) {
// Return exact same response as original — same status code, same body
res.status(cached.status).json(cached.body);
return;
}
}
const payment = await paymentService.create(req.body);
const responseBody = { id: payment.id, status: payment.status };
if (idempotencyKey) {
// Cache for 24h — long enough to cover network retries
await cache.set(`idem:${idempotencyKey}`, {
status: 201,
body: responseBody,
}, { ttl: 86400 });
}
res.status(201).json(responseBody);
}
Rules:
- Idempotency keys should be client-generated UUIDs
- Cache the response (status + body), not just the fact that it happened
- TTL of at least 24 hours for the idempotency store
- Return the exact same response on replay — do not re-execute the mutation
- Required for: payment endpoints, order creation, any operation with real-world side effects
Response Envelope Consistency
Pick one shape and use it everywhere. Mixing patterns forces clients to write multiple parsers.
// ✅ CORRECT — consistent envelope
// Single resource
{ "data": { "id": "usr_123", "email": "..." } }
// Collection
{ "data": [...], "pagination": { "nextCursor": "...", "hasMore": true, "limit": 20 } }
// Created resource
// HTTP 201 + Location: /v1/users/usr_123
{ "data": { "id": "usr_123", ... } }
// Action with no return value
// HTTP 204 — empty body
// Error
{ "error": { "code": "...", "message": "...", "traceId": "..." } }
Checklist
- All paths use plural nouns and kebab-case
- No verbs in paths — HTTP method carries the verb
- Versioning prefix present from day one (
/v1/) - Correct status code for each case (201 for created, 204 for deleted, 422 for validation, etc.)
- All errors use the same
{ error: { code, message, traceId } }shape - Cursor pagination with opaque cursors (not page/offset)
-
Idempotency-Keysupport on POST endpoints with side effects -
Locationheader returned on 201 Created responses -
Retry-Afterheader returned on 429 and 503 responses - API depth capped at two levels of nesting