REST API Design Best Practices: Building APIs That Stand the Test of Time
TL;DR
Great REST APIs aren’t built by accident. They’re designed with intention, following proven principles that make them intuitive, scalable, and maintainable. This guide covers the essential best practices: resource-oriented design, proper HTTP method usage, thoughtful status codes, versioning strategies, pagination, filtering, error handling, security, and documentation. Whether you’re building your first API or refining an existing one, these patterns will help you create APIs that developers love to use.
Key Principles:
- Design around resources, not actions
- Use HTTP methods correctly (GET, POST, PUT, PATCH, DELETE)
- Return appropriate status codes
- Version your API from day one
- Implement pagination and filtering
- Provide clear, consistent error messages
- Secure with authentication and rate limiting
- Document thoroughly with examples
Why API Design Matters
I’ve integrated with hundreds of APIs over my career. Some were a joy to work with. Others made me want to throw my laptop out the window.
The difference? Thoughtful design.
A well-designed API feels intuitive. You can predict how endpoints work. Errors are clear. Documentation matches reality. You’re productive in minutes, not days.
A poorly-designed API? You’re constantly confused. Endpoints are inconsistent. Errors are cryptic. Documentation is outdated or missing. Every integration becomes a battle.
The stakes are high:
- Your API is your product’s interface to the world
- Poor design costs developers time and frustration
- Good design enables adoption and growth
- Redesigning APIs after launch is painful and expensive
Let’s build APIs that get it right the first time.
Principle 1: Think in Resources, Not Actions
REST is about resources, not procedures. This is the most fundamental principle.
Bad: Action-Oriented Design
POST /api/getUserById
POST /api/createNewUser
POST /api/updateUserEmail
POST /api/deleteUserAccount
This isn’t REST. This is RPC over HTTP.
Good: Resource-Oriented Design
GET /api/users/123 # Get user
POST /api/users # Create user
PATCH /api/users/123 # Update user
DELETE /api/users/123 # Delete user
Why this matters:
- Resources are nouns (users, orders, products)
- Operations are HTTP methods (GET, POST, PUT, DELETE)
- URLs identify resources, methods specify actions
- Consistent patterns across your entire API
Resource Naming Conventions
Use plural nouns:
✅ /api/users
✅ /api/orders
✅ /api/products
❌ /api/user
❌ /api/getUsers
❌ /api/user-list
Nested resources for relationships:
✅ /api/users/123/orders # Orders for user 123
✅ /api/orders/456/items # Items in order 456
✅ /api/teams/789/members # Members of team 789
❌ /api/getUserOrders?userId=123
❌ /api/orderItems?orderId=456
Keep nesting shallow (2-3 levels max):
✅ /api/users/123/orders
✅ /api/teams/456/projects/789/tasks
❌ /api/companies/1/divisions/2/departments/3/teams/4/members/5/tasks
For deeply nested resources, provide direct access:
GET /api/tasks/999 # Direct access
GET /api/teams/4/tasks # Team's tasks
Principle 2: Use HTTP Methods Correctly
HTTP methods aren’t suggestions. They have semantic meaning.
GET - Retrieve Resources
GET /api/users # List all users
GET /api/users/123 # Get specific user
GET /api/users/123/orders # Get user's orders
Characteristics:
- Safe: No side effects, doesn’t modify server state
- Idempotent: Multiple identical requests = same result
- Cacheable: Responses can be cached
- No request body: Parameters in query string
Query parameters for filtering:
GET /api/users?role=admin&status=active
GET /api/products?category=electronics&price_max=1000
GET /api/orders?created_after=2025-01-01&sort=created_at:desc
POST - Create Resources
POST /api/users
Content-Type: application/json
{
"email": "user@example.com",
"name": "John Doe",
"role": "developer"
}
Response:
HTTP/1.1 201 Created
Location: /api/users/124
Content-Type: application/json
{
"id": 124,
"email": "user@example.com",
"name": "John Doe",
"role": "developer",
"created_at": "2025-09-15T10:30:00Z"
}
Characteristics:
- Not safe: Creates resources, modifies state
- Not idempotent: Multiple POSTs create multiple resources
- Return 201 Created with Location header
- Include created resource in response body
PUT - Replace Resource
PUT /api/users/123
Content-Type: application/json
{
"email": "newemail@example.com",
"name": "John Doe",
"role": "senior-developer"
}
Characteristics:
- Not safe: Modifies state
- Idempotent: Multiple identical PUTs = same result
- Replaces entire resource: Must include all fields
- Return 200 OK or 204 No Content
PATCH - Partial Update
PATCH /api/users/123
Content-Type: application/json
{
"role": "senior-developer"
}
Characteristics:
- Not safe: Modifies state
- Not idempotent: Depends on implementation
- Updates specific fields: Only send what changes
- Return 200 OK with updated resource
PUT vs PATCH:
- PUT: Replace the entire resource (send all fields)
- PATCH: Update specific fields (send only changes)
Use PATCH for most updates. It’s more flexible and efficient.
DELETE - Remove Resource
DELETE /api/users/123
Response:
HTTP/1.1 204 No Content
Or if returning confirmation:
HTTP/1.1 200 OK
Content-Type: application/json
{
"message": "User deleted successfully",
"deleted_at": "2025-09-15T10:45:00Z"
}
Characteristics:
- Not safe: Removes resources
- Idempotent: Deleting same resource multiple times = same result
- Return 204 No Content or 200 OK
Principle 3: Return Meaningful Status Codes
HTTP status codes communicate what happened. Use them correctly.
Success Codes (2xx)
200 OK - Request succeeded, returning data
GET /api/users/123 → 200 OK
PATCH /api/users/123 → 200 OK
201 Created - Resource created successfully
POST /api/users → 201 Created
Location: /api/users/124
204 No Content - Success, no response body
DELETE /api/users/123 → 204 No Content
PUT /api/users/123 → 204 No Content
Client Error Codes (4xx)
400 Bad Request - Invalid request format or validation error
{
"error": "validation_error",
"message": "Invalid request data",
"details": {
"email": "Invalid email format",
"age": "Must be a positive integer"
}
}
401 Unauthorized - Missing or invalid authentication
{
"error": "unauthorized",
"message": "Authentication required",
"hint": "Include a valid access token in the Authorization header"
}
403 Forbidden - Authenticated but not authorized
{
"error": "forbidden",
"message": "You don't have permission to delete this user",
"required_role": "admin"
}
404 Not Found - Resource doesn’t exist
{
"error": "not_found",
"message": "User with ID 123 not found"
}
409 Conflict - Request conflicts with current state
{
"error": "conflict",
"message": "A user with email 'user@example.com' already exists",
"conflicting_field": "email"
}
422 Unprocessable Entity - Validation failed on semantically correct request
{
"error": "validation_error",
"message": "Cannot create order with negative quantity",
"details": {
"quantity": "Must be greater than 0"
}
}
429 Too Many Requests - Rate limit exceeded
{
"error": "rate_limit_exceeded",
"message": "Too many requests",
"retry_after": 60,
"limit": 100,
"remaining": 0,
"reset_at": "2025-09-15T11:00:00Z"
}
Server Error Codes (5xx)
500 Internal Server Error - Unexpected server error
{
"error": "internal_error",
"message": "An unexpected error occurred",
"request_id": "req_abc123"
}
503 Service Unavailable - Server temporarily unavailable
{
"error": "service_unavailable",
"message": "Service temporarily unavailable for maintenance",
"retry_after": 300
}
Key principle: Status codes are for machines (HTTP clients). Error messages are for humans (developers).
Principle 4: Version Your API from Day One
APIs evolve. Breaking changes happen. Versioning prevents breaking existing clients.
URL Path Versioning (Recommended)
https://api.example.com/v1/users
https://api.example.com/v2/users
Pros:
- Clear and explicit
- Easy to route
- Browser-friendly
- Works with all clients
Cons:
- URLs change between versions
This is the most common and recommended approach.
Header Versioning
GET /api/users
Accept: application/vnd.example.v2+json
Pros:
- URLs don’t change
- RESTful purists prefer it
Cons:
- Less visible
- Harder to test in browser
- More complex routing
When to Create a New Version
Create a new major version when making breaking changes:
Breaking changes:
- Removing endpoints
- Removing fields from responses
- Changing field types
- Renaming fields or endpoints
- Changing authentication mechanism
- Changing behavior significantly
Non-breaking changes (don’t require new version):
- Adding new endpoints
- Adding new optional fields to requests
- Adding new fields to responses
- Adding new query parameters
- Making required fields optional
Version support strategy:
v1: Deprecated, supported for 6 months
v2: Current stable version
v3: Beta, available for early adopters
Always provide a migration guide when releasing new versions.
Principle 5: Implement Pagination, Filtering, and Sorting
Large datasets need efficient querying.
Pagination
Offset-based pagination:
GET /api/users?limit=20&offset=40
Response:
{
"data": [...],
"pagination": {
"total": 150,
"limit": 20,
"offset": 40,
"has_more": true
}
}
Cursor-based pagination (better for large datasets):
GET /api/users?limit=20&cursor=eyJ1c2VyX2lkIjoxMjN9
Response:
{
"data": [...],
"pagination": {
"next_cursor": "eyJ1c2VyX2lkIjoxNDN9",
"has_more": true
}
}
Why cursor-based is better:
- Consistent results even with data changes
- Better performance on large datasets
- Prevents duplicate or missing items
Filtering
GET /api/users?role=admin&status=active
GET /api/products?category=electronics&price_min=100&price_max=500
GET /api/orders?created_after=2025-01-01&status=completed
Advanced filtering with operators:
GET /api/products?price[gte]=100&price[lte]=500
GET /api/users?created_at[gt]=2025-01-01
GET /api/orders?status[in]=pending,processing,shipped
Sorting
GET /api/users?sort=created_at:desc
GET /api/products?sort=price:asc,name:asc
GET /api/orders?sort=-created_at # Minus = descending
Field Selection
Allow clients to request only needed fields:
GET /api/users?fields=id,name,email
GET /api/users/123?fields=id,name,orders(id,total)
Reduces payload size and improves performance.
Principle 6: Design Clear Error Responses
Errors are inevitable. Make them helpful.
Consistent Error Format
{
"error": "validation_error",
"message": "Request validation failed",
"details": {
"email": "Invalid email format",
"password": "Must be at least 8 characters"
},
"request_id": "req_abc123",
"timestamp": "2025-09-15T10:30:00Z"
}
Key elements:
- error: Machine-readable error code
- message: Human-readable description
- details: Specific field-level errors
- request_id: For support and debugging
- timestamp: When error occurred
Validation Errors
POST /api/users
{
"email": "invalid-email",
"age": -5
}
Response:
HTTP/1.1 422 Unprocessable Entity
{
"error": "validation_error",
"message": "Validation failed",
"details": {
"email": "Must be a valid email address",
"age": "Must be a positive integer"
}
}
Business Logic Errors
POST /api/orders
{
"product_id": 123,
"quantity": 10
}
Response:
HTTP/1.1 422 Unprocessable Entity
{
"error": "insufficient_inventory",
"message": "Cannot create order: insufficient inventory",
"details": {
"product_id": 123,
"requested_quantity": 10,
"available_quantity": 3
}
}
Authentication Errors
GET /api/users/123
Authorization: Bearer invalid_token
Response:
HTTP/1.1 401 Unauthorized
{
"error": "invalid_token",
"message": "Access token is invalid or expired",
"hint": "Obtain a new token using the /auth/token endpoint"
}
Principle 7: Secure Your API
Security isn’t optional. Build it in from the start.
Authentication
Bearer tokens (recommended for APIs):
GET /api/users
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
API keys (simple but less secure):
GET /api/users
X-API-Key: sk_live_abc123def456
OAuth 2.0 (for third-party access):
GET /api/users
Authorization: Bearer ya29.a0AfH6SMBx...
Rate Limiting
Prevent abuse and ensure fair usage:
Response Headers:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1694779200
When limit exceeded:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
{
"error": "rate_limit_exceeded",
"message": "Rate limit exceeded",
"limit": 100,
"remaining": 0,
"reset_at": "2025-09-15T11:00:00Z"
}
HTTPS Only
Always use HTTPS in production. No exceptions.
- Encrypts data in transit
- Prevents man-in-the-middle attacks
- Required for OAuth and modern security standards
Input Validation
Validate and sanitize all inputs:
- Check data types
- Validate formats (email, URL, date)
- Enforce length limits
- Sanitize against injection attacks
- Use allow-lists, not deny-lists
CORS Configuration
Configure Cross-Origin Resource Sharing correctly:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
Principle 8: Document Thoroughly
Great APIs have great documentation.
What to Document
1. Authentication
- How to obtain credentials
- How to include credentials in requests
- Token expiration and refresh
2. Endpoints
- Resource URLs
- Supported methods
- Request parameters (path, query, body)
- Response format and status codes
- Examples (curl, JavaScript, Python)
3. Error Codes
- All possible error codes
- What they mean
- How to resolve them
4. Rate Limits
- Limits per endpoint
- How to check remaining quota
- What happens when exceeded
5. Versioning
- Current version
- Deprecated versions and sunset dates
- Migration guides
OpenAPI/Swagger
Use OpenAPI specification for machine-readable docs:
openapi: 3.0.0
info:
title: Example API
version: 1.0.0
paths:
/users:
get:
summary: List users
parameters:
- name: limit
in: query
schema:
type: integer
default: 20
responses:
'200':
description: Success
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
Benefits:
- Auto-generated documentation
- Client SDK generation
- API testing tools
- Contract testing
Real-World Example: Well-Designed API
Here’s what a complete, well-designed API interaction looks like:
Create User
POST /api/v1/users
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
{
"email": "john@example.com",
"name": "John Doe",
"role": "developer"
}
Response:
HTTP/1.1 201 Created
Location: /api/v1/users/124
Content-Type: application/json
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
{
"id": 124,
"email": "john@example.com",
"name": "John Doe",
"role": "developer",
"created_at": "2025-09-15T10:30:00Z",
"updated_at": "2025-09-15T10:30:00Z"
}
List Users with Filtering and Pagination
GET /api/v1/users?role=developer&status=active&limit=20&cursor=eyJ1c2VyX2lkIjoxMDB9
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Response:
HTTP/1.1 200 OK
Content-Type: application/json
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 86
{
"data": [
{
"id": 101,
"email": "dev1@example.com",
"name": "Developer One",
"role": "developer",
"status": "active",
"created_at": "2025-09-10T08:00:00Z"
},
{
"id": 102,
"email": "dev2@example.com",
"name": "Developer Two",
"role": "developer",
"status": "active",
"created_at": "2025-09-11T09:00:00Z"
}
],
"pagination": {
"next_cursor": "eyJ1c2VyX2lkIjoxMjB9",
"has_more": true
}
}
Update User
PATCH /api/v1/users/124
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
{
"role": "senior-developer"
}
Response:
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": 124,
"email": "john@example.com",
"name": "John Doe",
"role": "senior-developer",
"created_at": "2025-09-15T10:30:00Z",
"updated_at": "2025-09-15T10:45:00Z"
}
Error Handling
POST /api/v1/users
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
{
"email": "invalid-email",
"name": ""
}
Response:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
"error": "validation_error",
"message": "Request validation failed",
"details": {
"email": "Must be a valid email address",
"name": "Name is required and cannot be empty"
},
"request_id": "req_abc123",
"timestamp": "2025-09-15T10:50:00Z"
}
Common API Design Mistakes
Learn from these common pitfalls:
1. Using GET for State-Changing Operations
❌ GET /api/users/123/delete
✅ DELETE /api/users/123
GET requests should be safe (no side effects).
2. Including Verbs in URLs
❌ POST /api/createUser
❌ GET /api/getUsers
✅ POST /api/users
✅ GET /api/users
The HTTP method is the verb. URLs should be nouns.
3. Inconsistent Naming
❌ /api/users
❌ /api/user-order
❌ /api/ProductCategories
✅ /api/users
✅ /api/user-orders
✅ /api/product-categories
Pick a convention (plural nouns, kebab-case) and stick to it.
4. Returning 200 for Errors
❌ HTTP 200 OK
{
"success": false,
"error": "User not found"
}
✅ HTTP 404 Not Found
{
"error": "not_found",
"message": "User not found"
}
Status codes exist for a reason. Use them.
5. Exposing Database Structure
❌ GET /api/users?where=status='active'&orderBy=created_at
✅ GET /api/users?status=active&sort=created_at:desc
Abstract your database. APIs should be stable even if your database schema changes.
Checklist: Is Your API Well-Designed?
Use this checklist to evaluate your API:
Resource Design:
- URLs use nouns, not verbs
- Resources are plural (/users, not /user)
- Nested resources show relationships clearly
- Naming is consistent across all endpoints
HTTP Methods:
- GET for retrieval (safe, idempotent, cacheable)
- POST for creation
- PUT for full replacement
- PATCH for partial updates
- DELETE for removal
- Methods match their semantic meaning
Status Codes:
- 2xx for success
- 4xx for client errors
- 5xx for server errors
- Specific codes used correctly (404, 422, etc.)
Versioning:
- API is versioned (v1, v2)
- Breaking changes require new version
- Deprecation policy is clear
Query Features:
- Pagination implemented (offset or cursor)
- Filtering supported
- Sorting supported
- Field selection available
Error Handling:
- Consistent error format
- Machine-readable error codes
- Human-readable messages
- Field-level validation details
- Request IDs for debugging
Security:
- HTTPS only in production
- Authentication required
- Rate limiting implemented
- Input validation on all endpoints
- CORS configured correctly
Documentation:
- All endpoints documented
- Request/response examples provided
- Error codes explained
- Authentication instructions clear
- OpenAPI/Swagger spec available
Conclusion
Great API design isn’t about following rules blindly. It’s about understanding principles and applying them thoughtfully.
The core principles:
- Think in resources - URLs are nouns, methods are verbs
- Use HTTP correctly - Methods, status codes, and headers have meaning
- Version early - Plan for evolution from day one
- Make it queryable - Pagination, filtering, sorting
- Handle errors well - Clear, consistent, actionable
- Secure by default - Authentication, rate limiting, HTTPS
- Document thoroughly - Your API is only as good as its docs
A well-designed API is:
- Intuitive: Developers can predict how it works
- Consistent: Patterns repeat across all endpoints
- Robust: Handles errors gracefully
- Scalable: Supports growth without breaking
- Maintainable: Easy to extend and evolve
Build APIs that developers love.
Design with intention. Document with care. Test thoroughly. Listen to feedback.
Your API is your product’s handshake with the world. Make it a good one.
Resources:
- REST API Tutorial
- HTTP Status Codes
- OpenAPI Specification
- API Security Best Practices
- JSON API Specification
“Good API design is invisible. Great API design is inevitable.”