REST API Design Best Practices from 100+ Production APIs
Battle-tested API design principles, from versioning to authentication to error handling.
By Alex Kumar on
Building production APIs that scale, secure well, and are maintainable is an art honed over time. Here are lessons from building 100+ production APIs.
API Versioning
Best Practice: URL-based versioning
# Good
GET /api/v1/users
GET /api/v2/users
# Bad: Breaks existing clients
GET /api/users
GET /api/v2/users
Version lifecycle:
- Support N-1 versions (v2 and v1 when v3 launches)
- 6-month deprecation notice
- Sunset policies documented
Resource Naming
Use nouns, not verbs:
# Good
GET /users/{id}
POST /users
PUT /users/{id}
# Bad: Verbs in URL
GET /getUsers
POST /createUser
Plural vs Singular:
- Use plurals for collections:
/users,/orders - Singular is acceptable only for single-resource operations
Request/Response Design
Consistent response format:
{
"data": { ... },
"meta": {
"page": 1,
"per_page": 50,
"total": 500
},
"errors": null
}
Pagination:
GET /api/v1/users?page=2&per_page=50
# Cursor-based for large datasets
GET /api/v1/transactions?cursor=abc123&limit=100
Authentication & Authorization
JWT Best Practices:
# Set appropriate expiration
exp = datetime.utcnow() + timedelta(hours=1)
# Include minimal claims
payload = {
"user_id": user.id,
"role": user.role,
"exp": exp
}
# Sign with strong secret
token = jwt.encode(payload, SECRET, algorithm="HS256")
API Keys for service-to-service:
- Include in
X-API-Keyheader - Rate limit per key
- Rotate keys regularly
- Scope permissions
Error Handling
HTTP Status Codes:
200 OK- Success201 Created- Resource created204 No Content- Successful deletion400 Bad Request- Validation error401 Unauthorized- Authentication failed403 Forbidden- Authorization failed404 Not Found- Resource doesn’t exist409 Conflict- Resource state conflict422 Unprocessable Entity- Semantic error429 Too Many Requests- Rate limit500 Internal Server Error- Server error
Error Response Format:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Email is required",
"details": {
"field": "email",
"constraint": "required"
}
}
}
Rate Limiting
Implement tiered rate limiting:
rate_limits = {
"free": "100/hour",
"pro": "1000/hour",
"enterprise": "10000/hour"
}
# Use Redis for distributed rate limiting
# Return headers:
# X-RateLimit-Limit: 1000
# X-RateLimit-Remaining: 950
# X-RateLimit-Reset: 1638360000
Security Best Practices
Input Validation:
- Validate all input at API boundaries
- Use schema validation (Pydantic, Joi)
- Sanitize user-generated content
CORS Configuration:
// Only allow trusted origins
const allowedOrigins = ['https://yourdomain.com']
app.use(cors({
origin: function (origin, callback) {
if (allowedOrigins.indexOf(origin) !== -1) {
callback(null, true)
} else {
callback(new Error('CORS not allowed'))
}
}
}))
SQL Injection Prevention:
# Good: Parameterized queries
cursor.execute(
"SELECT * FROM users WHERE id = %s",
(user_id,)
)
# Bad: String concatenation
cursor.execute(
f"SELECT * FROM users WHERE id = {user_id}"
)
Documentation
Use OpenAPI/Swagger:
openapi: 3.0.0
info:
title: User API
version: 1.0.0
paths:
/users:
get:
summary: List all users
parameters:
- name: page
in: query
schema:
type: integer
responses:
'200':
description: Successful response
Include for each endpoint:
- Description
- Authentication required
- Parameters with examples
- Request/response examples
- Error codes
- Rate limits
Testing Strategy
Three levels of testing:
- Unit Tests: Individual endpoints
def test_create_user():
response = client.post('/api/v1/users', json={
'name': 'John Doe',
'email': 'john@example.com'
})
assert response.status_code == 201
- Integration Tests: Full workflows
def test_user_registration_flow():
# Register
response = client.post('/api/v1/register', json={...})
# Login
response = client.post('/api/v1/login', json={...})
# Access protected resource
response = client.get('/api/v1/profile', headers={...})
- Load Tests: Performance under stress
# Use Locust or k6
@task(100)
def get_users(l):
l.client.get('/api/v1/users')
Monitoring & Observability
Key Metrics to Track:
- Request rate (RPS)
- Response time (p50, p95, p99)
- Error rate
- Rate limit hits
- Database query times
Structured Logging:
{
"timestamp": "2024-12-15T10:30:00Z",
"level": "info",
"request_id": "abc-123",
"method": "GET",
"path": "/api/v1/users",
"status": 200,
"duration_ms": 45
}
Performance Optimization
Caching Strategy:
# HTTP caching headers
@app.get("/api/v1/users/{id}")
@cache(ttl=300) # 5 minutes
async def get_user(id: str):
return await db.get_user(id)
Database Optimization:
- Use indexes for filtered columns
- Select only needed fields
- Implement connection pooling
- Use read replicas for reads
Compression:
# Compress responses > 1KB
if len(response.content) > 1024:
response.headers['Content-Encoding'] = 'gzip'
Common Pitfalls to Avoid
❌ N+1 Queries: Fetching related objects one by one ✅ Use eager loading or batch queries
❌ Over-fetching: Returning all fields when only a few needed
✅ Allow field selection with ?fields=id,name
❌ Inconsistent naming: user_id vs userId
✅ Use consistent naming convention (snake_case or camelCase)
❌ No request validation: trusting client input ✅ Validate everything at API boundaries
❌ Tight coupling: Database schema exposed in API ✅ Use DTOs and hide implementation details
Checklist for Production APIs
✅ Authentication and authorization ✅ Rate limiting ✅ Input validation and sanitization ✅ Proper HTTP status codes ✅ Comprehensive error handling ✅ API versioning ✅ CORS configuration ✅ Security headers (helmet, CSP) ✅ Request/response logging ✅ Metrics and monitoring ✅ Load testing completed ✅ Documentation complete ✅ SDK/Client libraries available
Conclusion
Good API design is about consistency, clarity, and developer experience. Follow REST principles, implement proper security, version your APIs, and document thoroughly. Your future self (and your API consumers) will thank you.
We have a newsletter
Subscribe and get the latest news and updates about AI & Backend Development on your inbox every week. No spam, no hassle.