A well-designed API is a competitive advantage. A poorly designed one becomes technical debt that every client has to work around forever.
URL Structure
# Resources are nouns, not verbs
GET /users # List users
POST /users # Create user
GET /users/{id} # Get one user
PUT /users/{id} # Replace user
PATCH /users/{id} # Partial update
DELETE /users/{id} # Delete user
# Nested resources for clear ownership
GET /users/{id}/orders
GET /users/{id}/orders/{orderId}
# Actions that don't map to CRUD: use verbs as sub-resources
POST /users/{id}/password-reset
POST /orders/{id}/cancel
POST /invoices/{id}/send
Response Format
// Success: 200 OK
{
"data": {
"id": "usr_01HX...",
"email": "user@example.com",
"createdAt": "2026-04-15T14:32:00Z"
}
}
// List: 200 OK with pagination
{
"data": [...],
"pagination": {
"page": 1,
"perPage": 20,
"total": 254,
"hasMore": true,
"nextCursor": "eyJpZCI6NTB9"
}
}
// Error: 4xx/5xx
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Email address is invalid",
"details": [
{ "field": "email", "message": "Must be a valid email address" }
]
}
}
Be consistent. Every response has the same shape.
HTTP Status Codes
200 OK -- successful GET, PUT, PATCH
201 Created -- successful POST that creates a resource
204 No Content -- successful DELETE
400 Bad Request -- invalid input (wrong format, missing fields)
401 Unauthorized -- no auth credentials
403 Forbidden -- valid credentials, but not permitted
404 Not Found -- resource doesn't exist
409 Conflict -- resource already exists, or state conflict
422 Unprocessable Entity -- valid format but semantic errors
429 Too Many Requests -- rate limited
500 Internal Server Error -- unexpected server error
Error Codes
Use a consistent machine-readable error code system:
enum ErrorCode {
VALIDATION_ERROR = 'VALIDATION_ERROR',
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
DUPLICATE_RESOURCE = 'DUPLICATE_RESOURCE',
UNAUTHORIZED = 'UNAUTHORIZED',
FORBIDDEN = 'FORBIDDEN',
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
}
function createError(code: ErrorCode, message: string, details?: object) {
return { error: { code, message, details } }
}
app.get('/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id)
if (!user) {
return res.status(404).json(
createError(ErrorCode.RESOURCE_NOT_FOUND, `User ${req.params.id} not found`)
)
}
res.json({ data: user })
})
Pagination
Cursor-based pagination scales better than offset-based:
// Offset pagination (simple but slow on large datasets)
GET /users?page=5&limit=20
// Cursor pagination (fast at any scale)
GET /users?limit=20&cursor=eyJpZCI6NTB9
async function listUsers(limit: number, cursor?: string) {
let query = db.users.orderBy('id', 'asc').limit(limit + 1) // +1 to check hasMore
if (cursor) {
const { id } = JSON.parse(Buffer.from(cursor, 'base64').toString())
query = query.where('id', '>', id)
}
const users = await query
const hasMore = users.length > limit
return {
data: users.slice(0, limit),
pagination: {
hasMore,
nextCursor: hasMore
? Buffer.from(JSON.stringify({ id: users[limit - 1].id })).toString('base64')
: null
}
}
}
Versioning
# URL versioning (most common, easy to understand)
/api/v1/users
/api/v2/users
# Header versioning (cleaner URLs, harder to test in browser)
Accept: application/vnd.myapi.v2+json
Version at the resource level, not the entire API. You can have v2 of /users while /products stays at v1.
Filtering, Sorting, Field Selection
# Filtering
GET /orders?status=active&userId=usr_01HX
# Sorting
GET /users?sort=createdAt:desc,name:asc
# Field selection (reduces response size)
GET /users?fields=id,email,name
# Full-text search
GET /products?q=wireless+headphones
Authentication
# API keys (for server-to-server)
Authorization: Bearer sk_live_xxx...
# JWT (for user sessions)
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...
Always use HTTPS. Always put auth in headers, never in URLs (logs capture URLs).
Rate Limiting Response Headers
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 987
X-RateLimit-Reset: 1715000000
Retry-After: 60 # (when 429'd)
Rate limit by API key, not IP. IP-based limits break shared offices and proxies.
API Versioning Lifecycle
Document your deprecation policy upfront: announce at least 6 months before removing an endpoint. Add Deprecation and Sunset headers to deprecated endpoints:
Deprecation: Sun, 01 Jan 2027 00:00:00 GMT
Sunset: Sun, 01 Jul 2027 00:00:00 GMT
Link: <https://docs.myapi.com/migration/v1-to-v2>; rel="successor-version"
A great API is one your users want to call, not one they're forced to call.