Well-designed REST APIs are the backbone of modern web applications. The difference between an API developers enjoy and one they fight is rarely cleverness — it's consistency. This guide covers the conventions, status codes, error formats, and patterns that make an API predictable.
Every design decision in this guide serves one goal: letting a developer who has used one of your endpoints correctly guess how the rest of them work. Consistency is worth more than any individual clever choice.
Resource Naming Conventions
Use Nouns, Not Verbs
Good — RESTful:
GET /users # List all users
POST /users # Create user
GET /users/123 # Get specific user
PUT /users/123 # Update user
PATCH /users/123 # Partial update
DELETE /users/123 # Delete user
Bad — RPC style:
GET /getUsers
POST /createUser
GET /getUserById/123
PUT /updateUser/123
DELETE /deleteUser/123
Why? The HTTP method already defines the action. The path should identify the resource. Encoding the verb in both places means they can disagree.
Use Plural Nouns Consistently
Good:
/users
/products
/orders
/payments
Bad — mixed:
/user
/products
/order
/payment
Which convention you pick matters far less than picking one. Mixed singular and plural forces every consumer to memorise your API endpoint by endpoint instead of learning the pattern once.
Nested Resources for Relationships
Good — expresses the relationship:
GET /users/123/posts # All posts by user 123
POST /users/123/posts # Create post for user 123
GET /users/123/posts/456 # Specific post by user 123
DELETE /users/123/posts/456 # Delete that post
Limit nesting to 2-3 levels:
GET /users/123/posts/456/comments # OK
GET /users/123/posts/456/comments/789/replies # Too deep
Better for deep relationships — flatten and filter:
GET /comments?postId=456&userId=123
GET /replies?commentId=789
HTTP Methods and Status Codes
Standard CRUD Operations
# GET — read (safe and idempotent)
GET /products
GET /products/123
GET /products?category=electronics&sort=-price
# POST — create (not idempotent)
POST /products
Body: { "name": "Laptop", "price": 999.99, "category": "electronics" }
Response: 201 Created
Location: /products/123
# PUT — full replacement (idempotent)
PUT /products/123
Body: { "name": "Updated Laptop", "price": 899.99, "category": "electronics", "stock": 50 }
Response: 200 OK
# PATCH — partial update (idempotent)
PATCH /products/123
Body: { "price": 849.99, "stock": 45 }
Response: 200 OK
# DELETE — remove (idempotent)
DELETE /products/123
Response: 204 No Content
DELETE /products/123 twice returns 204 then 404 — different responses, but the server state is the same either way. That's what idempotency means, and it's why clients can safely retry a DELETE after a network timeout but not a POST.
Custom Actions (When REST Isn't Enough)
Not every operation is CRUD. When an action doesn't map to a resource lifecycle, a verb sub-resource is clearer than contorting the model:
POST /users/123/activate
POST /users/123/deactivate
POST /users/123/send-reset-link
POST /orders/456/cancel
POST /orders/456/refund
POST /payments/789/capture
Status Code Guide
2xx Success
200 OK - Successful GET, PUT, PATCH, or DELETE with a body
201 Created - Successful POST; include a Location header
202 Accepted - Async operation started, not yet complete
204 No Content - Success with nothing to return
4xx Client Errors
400 Bad Request - Malformed request, invalid JSON
401 Unauthorized - Missing or invalid authentication
403 Forbidden - Authenticated but not permitted
404 Not Found - Resource doesn't exist
405 Method Not Allowed - Wrong HTTP method for this path
409 Conflict - Duplicate resource, version mismatch
422 Unprocessable Entity - Well-formed but semantically invalid
429 Too Many Requests - Rate limit exceeded
5xx Server Errors
500 Internal Server Error - Unexpected server fault
502 Bad Gateway - Invalid upstream response
503 Service Unavailable - Overloaded or down; include Retry-After
504 Gateway Timeout - Upstream timed out
401 means "I don't know who you are" — the client should authenticate and retry. 403 means "I know who you are and the answer is still no" — retrying with the same credentials is pointless. Mixing them up sends clients into retry loops that can never succeed.
Request and Response Format
Consistent Response Structure
Wrapping responses in a data envelope costs a few bytes and buys you somewhere to put metadata later without a breaking change:
// Success — list
{
"data": [
{
"id": "usr_123",
"email": "john@example.com",
"name": "John Doe",
"createdAt": "2025-01-15T10:30:00Z"
}
],
"meta": {
"pagination": {
"page": 1,
"perPage": 20,
"total": 157,
"totalPages": 8
},
"links": {
"self": "/users?page=1",
"next": "/users?page=2",
"last": "/users?page=8"
}
}
}
// Success — single resource
{
"data": {
"id": "prd_789",
"name": "Wireless Mouse",
"price": 29.99,
"category": "electronics",
"stock": 150,
"createdAt": "2025-02-10T09:00:00Z",
"updatedAt": "2025-02-12T16:45:00Z"
}
}
Error Responses
Good errors tell a developer which field is wrong, why, and how to correlate the failure with your logs:
// Validation errors
{
"error": {
"type": "validation_error",
"message": "Invalid input data",
"code": "VALIDATION_FAILED",
"details": [
{
"field": "email",
"message": "Invalid email format",
"code": "INVALID_FORMAT"
},
{
"field": "price",
"message": "Price must be greater than 0",
"code": "OUT_OF_RANGE",
"min": 0.01
}
],
"requestId": "req_abc123",
"timestamp": "2025-08-10T10:30:00Z",
"path": "/products"
}
}
// Not found
{
"error": {
"type": "not_found",
"message": "Product not found",
"code": "RESOURCE_NOT_FOUND",
"requestId": "req_def456",
"timestamp": "2025-08-10T10:31:00Z"
}
}
// Rate limited
{
"error": {
"type": "rate_limit_error",
"message": "Rate limit exceeded",
"code": "RATE_LIMIT_EXCEEDED",
"retryAfter": 60,
"limit": 100,
"remaining": 0,
"resetAt": "2025-08-10T11:00:00Z"
}
}
A machine-readable code lets clients branch on the error. A requestId lets your support team find the exact log line when someone pastes an error into a ticket. Both cost almost nothing to add and are painful to retrofit.
Filtering, Sorting, Search, and Pagination
Query Parameter Conventions
# Filtering — exact match
GET /products?category=electronics&status=active
# Filtering — operators
GET /products?price[gte]=100&price[lte]=500
GET /products?createdAt[gt]=2025-01-01
# Sorting — leading minus for descending
GET /products?sort=price
GET /products?sort=-price
GET /products?sort=-price,name
# Pagination — offset based
GET /products?page=2&limit=20
GET /products?offset=40&limit=20
# Pagination — cursor based (for large or live datasets)
GET /products?cursor=eyJpZCI6MTIzfQ&limit=20
# Field selection (sparse fieldsets)
GET /products?fields=id,name,price
# Search
GET /products?q=laptop
# Expanding relationships
GET /orders?include=user,items
GET /orders/123?include=user.profile,items.product
# Combined
GET /products?category=electronics&price[gte]=100&sort=-price&page=1&limit=20&fields=id,name,price
?page=2&limit=20 is implemented as OFFSET 20. If a row is inserted while a client is paging, records shift and get skipped or duplicated across pages. Offset is fine for stable, small datasets; use cursor pagination for feeds, logs, or anything actively being written. Offset also degrades on large tables, since the database must count through every skipped row.
Implementation
// controllers/products.controller.js
import { z } from 'zod';
const productQuerySchema = z.object({
// Filtering
category: z.string().optional(),
status: z.enum(['active', 'inactive', 'archived']).optional(),
'price[gte]': z.coerce.number().positive().optional(),
'price[lte]': z.coerce.number().positive().optional(),
'createdAt[gte]': z.coerce.date().optional(),
// Sorting
sort: z.string().regex(/^-?\w+(,-?\w+)*$/).default('-createdAt'),
// Pagination — note the max, which prevents limit=100000
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
// Field selection
fields: z.string().optional(),
// Search
q: z.string().max(100).optional(),
// Includes
include: z.string().optional(),
});
export async function listProducts(req, res) {
const query = productQuerySchema.parse(req.query);
const filter = {};
if (query.category) filter.category = query.category;
if (query.status) filter.status = query.status;
// Price range
if (query['price[gte]'] || query['price[lte]']) {
filter.price = {};
if (query['price[gte]']) filter.price.$gte = query['price[gte]'];
if (query['price[lte]']) filter.price.$lte = query['price[lte]'];
}
// Date range
if (query['createdAt[gte]']) {
filter.createdAt = { $gte: query['createdAt[gte]'] };
}
// Full-text search
if (query.q) {
filter.$text = { $search: query.q };
}
// Parse sort string into a sort object
const sortObj = {};
query.sort.split(',').forEach(field => {
if (field.startsWith('-')) {
sortObj[field.slice(1)] = -1;
} else {
sortObj[field] = 1;
}
});
// Field selection
const projection = query.fields
? query.fields.split(',').reduce((acc, field) => {
acc[field] = 1;
return acc;
}, {})
: {};
const skip = (query.page - 1) * query.limit;
const [products, total] = await Promise.all([
Product.find(filter)
.select(projection)
.sort(sortObj)
.skip(skip)
.limit(query.limit)
.populate(query.include?.split(',') || [])
.lean(),
Product.countDocuments(filter),
]);
const totalPages = Math.ceil(total / query.limit);
res.json({
data: products,
meta: {
pagination: {
page: query.page,
perPage: query.limit,
total,
totalPages,
},
links: {
self: `/products?page=${query.page}`,
...(query.page < totalPages && {
next: `/products?page=${query.page + 1}`,
}),
...(query.page > 1 && {
prev: `/products?page=${query.page - 1}`,
}),
first: '/products?page=1',
last: `/products?page=${totalPages}`,
},
},
});
}
Validating query parameters with a schema does double duty: it rejects garbage input, and it caps limit so a single request can't ask for your entire table.
Authentication and Authorization
JWT Bearer Token
// middleware/auth.js
import jwt from 'jsonwebtoken';
export async function authenticate(req, res, next) {
try {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({
error: {
type: 'authentication_error',
message: 'Missing or invalid authorization header',
code: 'MISSING_AUTH',
},
});
}
const token = authHeader.slice(7);
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Re-check the user on every request — a token issued before a ban
// is still cryptographically valid
const user = await User.findById(decoded.userId).select('-password');
if (!user || !user.isActive) {
return res.status(401).json({
error: {
type: 'authentication_error',
message: 'Invalid or expired token',
code: 'INVALID_TOKEN',
},
});
}
req.user = user;
req.userId = user.id;
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
error: {
type: 'authentication_error',
message: 'Token has expired',
code: 'TOKEN_EXPIRED',
expiredAt: error.expiredAt,
},
});
}
return res.status(401).json({
error: {
type: 'authentication_error',
message: 'Invalid authentication token',
code: 'INVALID_TOKEN',
},
});
}
}
// Role-based authorization
export function authorize(...roles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({
error: {
type: 'authentication_error',
message: 'Authentication required',
code: 'AUTH_REQUIRED',
},
});
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({
error: {
type: 'authorization_error',
message: 'Insufficient permissions',
code: 'FORBIDDEN',
},
});
}
next();
};
}
// Usage
router.get('/admin/users',
authenticate,
authorize('admin', 'superadmin'),
listUsers
);
It's tempting to return required: ['admin'] and current: 'user' in a 403 body to help debugging. That hands an attacker a map of your role model. Log the detail server-side; return "Insufficient permissions" to the caller.
API Versioning
URL Versioning
URL versioning is the most common approach because it's visible, cacheable, and trivial to route:
// app.js
import v1Routes from './routes/v1/index.js';
import v2Routes from './routes/v2/index.js';
app.use('/api/v1', v1Routes);
app.use('/api/v2', v2Routes);
// Deprecation signalling — tell clients before you break them
app.use('/api/v1', (req, res, next) => {
res.setHeader('X-API-Version', 'v1');
res.setHeader('X-API-Deprecated', 'true');
res.setHeader('X-API-Sunset-Date', '2026-01-01');
res.setHeader('X-API-Migration-Guide', 'https://api.example.com/docs/v1-to-v2');
next();
});
// routes/v1/products.js — old shape
export async function getProducts(req, res) {
const products = await Product.find();
res.json(products); // Bare array
}
// routes/v2/products.js — new shape
export async function getProducts(req, res) {
const products = await Product.find();
res.json({
data: products,
meta: {
version: 'v2',
timestamp: new Date().toISOString(),
},
});
}
The Sunset HTTP header is standardised (RFC 8594) if you'd rather not invent your own X- headers.
Rate Limiting
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import redis from './utils/redis.js';
// General API rate limit — Redis-backed so it works across instances
export const apiLimiter = rateLimit({
store: new RedisStore({ client: redis, prefix: 'rl:' }),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
standardHeaders: true, // RateLimit-* headers
legacyHeaders: false,
handler: (req, res) => {
res.status(429).json({
error: {
type: 'rate_limit_error',
message: 'Too many requests',
code: 'RATE_LIMIT_EXCEEDED',
limit: 100,
windowMs: 900000,
retryAfter: Math.ceil(req.rateLimit.resetTime / 1000),
},
});
},
});
// Stricter limits on auth endpoints to slow credential stuffing
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
skipSuccessfulRequests: true, // Only count failures
});
// Per-user tiered limits (applied after authentication)
export function createUserLimiter(tier) {
const limits = {
free: 1000,
pro: 10000,
enterprise: 100000,
};
return rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: limits[tier] || limits.free,
keyGenerator: (req) => req.user.id,
skip: (req) => !req.user,
});
}
The default express-rate-limit store lives in process memory. Run four instances and an attacker gets four times the limit; restart a pod and the counter resets. Any limit you actually depend on needs a shared store like Redis.
Complete Error Handling
// errors/APIError.js
export class APIError extends Error {
constructor(message, statusCode, code, details = null) {
super(message);
this.name = 'APIError';
this.statusCode = statusCode;
this.code = code;
this.details = details;
Error.captureStackTrace(this, this.constructor);
}
}
export class ValidationError extends APIError {
constructor(details) {
super('Validation failed', 422, 'VALIDATION_ERROR', details);
}
}
export class NotFoundError extends APIError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
export class UnauthorizedError extends APIError {
constructor(message = 'Unauthorized') {
super(message, 401, 'UNAUTHORIZED');
}
}
export class ForbiddenError extends APIError {
constructor(message = 'Forbidden') {
super(message, 403, 'FORBIDDEN');
}
}
export class ConflictError extends APIError {
constructor(message) {
super(message, 409, 'CONFLICT');
}
}
export class RateLimitError extends APIError {
constructor(retryAfter) {
super('Rate limit exceeded', 429, 'RATE_LIMIT_EXCEEDED');
this.retryAfter = retryAfter;
}
}
The handler maps known error types to consistent response bodies and refuses to leak anything else:
// middleware/errorHandler.js
export function errorHandler(err, req, res, next) {
const requestId = req.id || generateId();
logger.error({
err,
req: { method: req.method, url: req.url },
requestId,
}, 'API error');
// Known API errors — safe to surface
if (err instanceof APIError) {
return res.status(err.statusCode).json({
error: {
type: err.name.replace('Error', '').toLowerCase() + '_error',
message: err.message,
code: err.code,
...(err.details && { details: err.details }),
requestId,
timestamp: new Date().toISOString(),
},
});
}
// Zod validation errors
if (err.name === 'ZodError') {
return res.status(422).json({
error: {
type: 'validation_error',
message: 'Validation failed',
code: 'VALIDATION_ERROR',
details: err.errors.map(e => ({
field: e.path.join('.'),
message: e.message,
code: e.code,
})),
requestId,
},
});
}
// JWT errors
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({
error: {
type: 'authentication_error',
message: 'Invalid token',
code: 'INVALID_TOKEN',
requestId,
},
});
}
// Anything else is a bug — never leak internals in production
const message = process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message;
res.status(500).json({
error: {
type: 'internal_error',
message,
code: 'INTERNAL_ERROR',
requestId,
...(process.env.NODE_ENV !== 'production' && { stack: err.stack }),
},
});
}
The snippet above logs only the method and URL. Logging req.body or req.headers wholesale writes passwords, tokens, and card numbers into your log aggregator — where they're retained, indexed, and readable by anyone with dashboard access. Redact before logging.
API Documentation with OpenAPI
An OpenAPI spec is machine-readable, which means it generates clients, mock servers, and interactive docs from one source:
openapi: 3.0.3
info:
title: Products API
version: 2.0.0
description: Complete API for product management
contact:
name: API Support
email: api@example.com
license:
name: MIT
servers:
- url: https://api.example.com/v2
description: Production
- url: https://api-staging.example.com/v2
description: Staging
paths:
/products:
get:
summary: List products
tags: [Products]
parameters:
- name: category
in: query
schema:
type: string
- name: page
in: query
schema:
type: integer
default: 1
- name: limit
in: query
schema:
type: integer
default: 20
maximum: 100
responses:
'200':
description: Successful response
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Product'
meta:
$ref: '#/components/schemas/PaginationMeta'
post:
summary: Create product
tags: [Products]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ProductInput'
responses:
'201':
description: Product created
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
Product:
type: object
properties:
id:
type: string
example: "prd_123"
name:
type: string
price:
type: number
format: double
category:
type: string
createdAt:
type: string
format: date-time
ProductInput:
type: object
required: [name, price, category]
properties:
name:
type: string
minLength: 1
maxLength: 100
price:
type: number
minimum: 0.01
category:
type: string
PaginationMeta:
type: object
properties:
pagination:
type: object
properties:
page:
type: integer
perPage:
type: integer
total:
type: integer
totalPages:
type: integer
Hand-written OpenAPI files drift from the implementation within weeks. Tools like zod-to-openapi derive the spec from the same schemas your routes validate against, so the docs can't disagree with the code.
Testing REST APIs
Test the contract — status codes, response shape, and auth boundaries — not just the happy path:
// __tests__/products.api.test.js
import request from 'supertest';
import app from '../app';
import { setupTestDB, cleanupTestDB } from './helpers/db';
describe('Products API', () => {
let authToken;
beforeAll(async () => {
await setupTestDB();
const res = await request(app)
.post('/api/v2/auth/login')
.send({ email: 'test@example.com', password: 'password123' });
authToken = res.body.data.accessToken;
});
afterAll(async () => {
await cleanupTestDB();
});
describe('GET /api/v2/products', () => {
it('should return paginated products', async () => {
const res = await request(app)
.get('/api/v2/products?page=1&limit=10')
.expect(200);
expect(res.body).toMatchObject({
data: expect.any(Array),
meta: {
pagination: {
page: 1,
perPage: 10,
total: expect.any(Number),
totalPages: expect.any(Number),
},
},
});
});
it('should filter by category', async () => {
const res = await request(app)
.get('/api/v2/products?category=electronics')
.expect(200);
expect(res.body.data.every(p => p.category === 'electronics')).toBe(true);
});
it('should sort by price descending', async () => {
const res = await request(app)
.get('/api/v2/products?sort=-price')
.expect(200);
const prices = res.body.data.map(p => p.price);
expect(prices).toEqual([...prices].sort((a, b) => b - a));
});
it('should return 422 for a limit above the maximum', async () => {
const res = await request(app)
.get('/api/v2/products?limit=101')
.expect(422);
expect(res.body.error.code).toBe('VALIDATION_ERROR');
});
});
describe('POST /api/v2/products', () => {
it('should create product with valid data', async () => {
const product = {
name: 'Test Product',
price: 99.99,
category: 'electronics',
};
const res = await request(app)
.post('/api/v2/products')
.set('Authorization', `Bearer ${authToken}`)
.send(product)
.expect(201);
expect(res.body.data).toMatchObject(product);
expect(res.body.data.id).toBeDefined();
});
it('should return 401 without auth', async () => {
await request(app)
.post('/api/v2/products')
.send({ name: 'Test', price: 10, category: 'test' })
.expect(401);
});
it('should return 422 for invalid price', async () => {
const res = await request(app)
.post('/api/v2/products')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Test', price: -10, category: 'test' })
.expect(422);
expect(res.body.error.details).toContainEqual(
expect.objectContaining({ field: 'price' })
);
});
});
});
Best Practices Checklist
Naming and structure
- [ ] Nouns for resources, not verbs
- [ ] Plural nouns used consistently
- [ ] Resource nesting limited to 2-3 levels
- [ ] kebab-case for multi-word paths
HTTP methods
- [ ] GET for reading (safe and idempotent)
- [ ] POST for creating
- [ ] PUT for full replacement (idempotent)
- [ ] PATCH for partial updates
- [ ] DELETE for removal (idempotent)
Responses
- [ ] Consistent envelope across all endpoints
- [ ] Correct status codes, especially 401 vs 403
- [ ] Pagination metadata included
- [ ] Errors carry a machine-readable code and a requestId
Security
- [ ] Authentication on protected endpoints
- [ ] Input validation on every endpoint
- [ ] Rate limiting backed by a shared store
- [ ] CORS configured deliberately
- [ ] HTTPS enforced
- [ ] Errors don't leak internals or permission structure
Performance
- [ ] Pagination on all list endpoints, with a max limit
- [ ] Field selection supported
- [ ] Response compression enabled
- [ ] Caching headers set
Documentation
- [ ] OpenAPI spec generated from code
- [ ] Example requests and responses
- [ ] Error codes documented
- [ ] Authentication explained
Resources
- REST API Tutorial
- OpenAPI Specification
- MDN HTTP Status Codes
- Microsoft REST API Guidelines
- Google API Design Guide
Our Take
Great API design is mostly restraint. The temptation is to make each endpoint optimal for the use case in front of you; the result is an API where every endpoint is a special case and nothing is guessable.
Pick your conventions early — envelope shape, error format, pagination style, naming — and apply them even where a different choice would be marginally better in isolation. Predictability beats cleverness. A developer who has integrated one endpoint should be able to guess the next one correctly, and good error messages should mean they rarely need to ask you why it didn't work.