API Design Best Practices: Building Robust and Scalable APIs
Well-designed APIs are the backbone of modern software architecture. Whether you are building a public-facing REST API, an internal microservices interface, or a mobile backend, following established best practices ensures your API is intuitive, reliable, and maintainable. This comprehensive guide covers RESTful conventions, versioning strategies, pagination, error handling, rate limiting, idempotency, and security considerations that every API developer should know.
RESTful URL Conventions
Good API URLs are predictable, consistent, and resource-oriented. Following these conventions makes your API self-documenting and easy to use.
Resource Naming
Use nouns (not verbs) to represent resources. Use plural forms consistently. Nest resources to express relationships, but avoid nesting deeper than two levels.
# Good URL patterns
GET /api/users # List all users
GET /api/users/123 # Get a specific user
POST /api/users # Create a new user
PUT /api/users/123 # Replace a user
PATCH /api/users/123 # Partially update a user
DELETE /api/users/123 # Delete a user
# Nested resources (one level)
GET /api/users/123/orders # List user's orders
GET /api/users/123/orders/456 # Get a specific order
POST /api/users/123/orders # Create an order for user
# Bad URL patterns (avoid these)
GET /api/getUsers # Verb in URL
GET /api/user/123 # Inconsistent singular
POST /api/users/123/create-order # Verb in URL
GET /api/users/123/orders/456/items/789/reviews # Too deeply nested
HTTP Methods
| Method | Purpose | Idempotent | Safe | Request Body |
|---|---|---|---|---|
| GET | Retrieve resource(s) | Yes | Yes | No |
| POST | Create a resource | No | No | Yes |
| PUT | Replace a resource entirely | Yes | No | Yes |
| PATCH | Partially update a resource | No | No | Yes |
| DELETE | Remove a resource | Yes | No | Optional |
API Versioning
API versioning is essential for evolving your API without breaking existing clients. There are several strategies, each with trade-offs.
URL Path Versioning
The most common approach. Simple, explicit, and easy to route.
# URL Path Versioning
GET /api/v1/users/123
GET /api/v2/users/123
# Header Versioning
GET /api/users/123
Accept: application/vnd.myapi.v2+json
# Query Parameter Versioning
GET /api/users/123?version=2
| Strategy | Pros | Cons | Used By |
|---|---|---|---|
| URL Path (/v1/) | Simple, clear, cacheable | URL pollution, harder to share code between versions | Stripe, Twitter, Google |
| Header | Clean URLs, flexible | Harder to test, not visible in URL | GitHub, Azure |
| Query Parameter | Easy to add, optional | Can be forgotten, caching issues | AWS, Netflix |
Pagination
Any endpoint that returns a list of resources should support pagination to prevent returning unbounded result sets that could overwhelm both server and client.
Offset-Based Pagination
The simplest approach using limit and offset parameters. Easy to implement but has performance issues with large offsets (the database must skip all preceding rows).
Cursor-Based Pagination
Uses an opaque cursor (typically an encoded identifier) to mark the position. Much more efficient for large datasets because the database can use an index to start from the cursor position.
# Offset-Based Pagination
GET /api/users?limit=20&offset=40
# Response
{
"data": [...],
"pagination": {
"total": 1500,
"limit": 20,
"offset": 40,
"has_more": true
}
}
# Cursor-Based Pagination
GET /api/users?limit=20&after=eyJpZCI6MTIzfQ==
# Response
{
"data": [...],
"pagination": {
"has_next": true,
"next_cursor": "eyJpZCI6MTQzfQ==",
"has_prev": true,
"prev_cursor": "eyJpZCI6MTI0fQ=="
}
}
# Keyset Pagination (variation of cursor)
GET /api/users?limit=20&created_after=2024-01-15T10:30:00Z&id_after=143
| Strategy | Best For | Limitation |
|---|---|---|
| Offset-Based | Small datasets, admin panels | Slow at large offsets, inconsistent with concurrent writes |
| Cursor-Based | Large datasets, feeds, timelines | Cannot jump to arbitrary page |
| Keyset | Time-ordered data, logs | Requires stable sort order |
Error Handling
Consistent, informative error responses are critical for a good developer experience. Use standard HTTP status codes and return structured error bodies.
# Standardized Error Response Format
{
"error": {
"code": "VALIDATION_ERROR",
"message": "The request body contains invalid fields",
"details": [
{
"field": "email",
"message": "Must be a valid email address",
"value": "not-an-email"
},
{
"field": "age",
"message": "Must be a positive integer",
"value": -5
}
],
"request_id": "req_abc123def456",
"documentation_url": "https://docs.swehelper.com/errors/VALIDATION_ERROR"
}
}
# Different error scenarios with appropriate status codes
# 400 Bad Request - Malformed request syntax
{
"error": {
"code": "INVALID_JSON",
"message": "Request body is not valid JSON"
}
}
# 401 Unauthorized - Missing or invalid authentication
{
"error": {
"code": "AUTHENTICATION_REQUIRED",
"message": "A valid API key is required"
}
}
# 403 Forbidden - Authenticated but not authorized
{
"error": {
"code": "INSUFFICIENT_PERMISSIONS",
"message": "You do not have permission to delete this resource"
}
}
# 404 Not Found
{
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "User with ID 999 does not exist"
}
}
# 409 Conflict - Resource state conflict
{
"error": {
"code": "DUPLICATE_EMAIL",
"message": "A user with this email address already exists"
}
}
# 429 Too Many Requests
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "You have exceeded the rate limit of 100 requests per minute",
"retry_after": 45
}
}
Rate Limiting
Rate limiting protects your API from abuse, ensures fair usage, and prevents cascading failures. Implement rate limiting at the API gateway or application level.
Common Rate Limiting Algorithms
| Algorithm | How It Works | Best For |
|---|---|---|
| Fixed Window | Count requests in fixed time windows | Simple rate limiting |
| Sliding Window | Weighted combination of current and previous window | Smoother rate limiting |
| Token Bucket | Tokens added at fixed rate, consumed per request | Allowing burst traffic |
| Leaky Bucket | Requests processed at constant rate | Smooth output rate |
# Rate Limit Response Headers
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 742
X-RateLimit-Reset: 1704067200
Retry-After: 60
# Token Bucket Implementation (Python with Redis)
import redis
import time
class TokenBucketRateLimiter:
def __init__(self, redis_client, max_tokens, refill_rate):
self.redis = redis_client
self.max_tokens = max_tokens
self.refill_rate = refill_rate # tokens per second
def allow_request(self, client_id):
key = f"rate_limit:{client_id}"
now = time.time()
pipe = self.redis.pipeline()
pipe.hgetall(key)
result = pipe.execute()[0]
if not result:
# First request - initialize bucket
self.redis.hset(key, mapping={
'tokens': self.max_tokens - 1,
'last_refill': now
})
self.redis.expire(key, 3600)
return True
tokens = float(result[b'tokens'])
last_refill = float(result[b'last_refill'])
# Add tokens based on elapsed time
elapsed = now - last_refill
tokens = min(self.max_tokens, tokens + elapsed * self.refill_rate)
if tokens >= 1:
self.redis.hset(key, mapping={
'tokens': tokens - 1,
'last_refill': now
})
return True
return False
# Usage
limiter = TokenBucketRateLimiter(
redis_client=redis.Redis(),
max_tokens=100, # burst capacity
refill_rate=10 # 10 requests per second sustained
)
Idempotency
Idempotency ensures that making the same request multiple times produces the same result. This is critical for handling network retries, timeouts, and duplicate requests safely.
GET, PUT, and DELETE are naturally idempotent. POST is not idempotent by default (each call creates a new resource). To make POST idempotent, use idempotency keys.
# Client sends an idempotency key with POST requests
POST /api/payments
Idempotency-Key: idem_a1b2c3d4e5f6
Content-Type: application/json
{
"amount": 9999,
"currency": "usd",
"customer_id": "cus_123"
}
# Server implementation (pseudo-code)
def create_payment(request):
idempotency_key = request.headers.get('Idempotency-Key')
if idempotency_key:
# Check if we already processed this request
cached = redis.get(f"idempotency:{idempotency_key}")
if cached:
return cached # Return the same response
# Process the payment
result = payment_processor.charge(request.body)
if idempotency_key:
# Cache the response for 24 hours
redis.setex(
f"idempotency:{idempotency_key}",
86400,
serialize(result)
)
return result
Filtering, Sorting, and Field Selection
# Filtering
GET /api/users?status=active&role=admin&created_after=2024-01-01
# Sorting (prefix with - for descending)
GET /api/users?sort=created_at # ascending
GET /api/users?sort=-created_at # descending
GET /api/users?sort=-created_at,name # multiple sort fields
# Field Selection (sparse fieldsets)
GET /api/users?fields=id,name,email
GET /api/users/123?fields=id,name,email,profile.avatar
# Search
GET /api/users?q=alice&search_fields=name,email
# Combining everything
GET /api/users?status=active&sort=-created_at&fields=id,name&limit=20&after=cursor123
Security Best Practices
API security is non-negotiable. Follow these practices to protect your API and its consumers:
Authentication: Use OAuth 2.0 or API keys for authentication. Always use HTTPS. Never pass credentials in URL query parameters (they appear in logs). For more on HTTPS and TLS, see our dedicated guide.
Authorization: Implement proper access control. Users should only access their own resources unless they have explicit permissions.
Input Validation: Validate all input on the server side. Never trust client data. Sanitize inputs to prevent injection attacks.
CORS: Configure Cross-Origin Resource Sharing headers carefully. Never use Access-Control-Allow-Origin: * for authenticated endpoints.
Explore our Security and Crypto Tools for JWT debugging, hash generation, and encryption utilities. Use our API and Network Tools for testing API endpoints.
Response Envelope Design
# Successful List Response
{
"data": [
{ "id": "1", "name": "Alice", "email": "alice@example.com" },
{ "id": "2", "name": "Bob", "email": "bob@example.com" }
],
"pagination": {
"has_next": true,
"next_cursor": "eyJpZCI6Mn0=",
"total_count": 1500
},
"meta": {
"request_id": "req_abc123",
"response_time_ms": 45
}
}
# Successful Single Resource Response
{
"data": {
"id": "1",
"name": "Alice",
"email": "alice@example.com",
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-06-20T14:22:00Z"
}
}
Building well-designed APIs requires attention to these conventions and principles. For protocol-level decisions, refer to our comparison of REST vs gRPC vs GraphQL. Consider how your API will work with load balancers, CDNs, and reverse proxies at scale. Visit our tools page for developer utilities.
Frequently Asked Questions
Should I use PUT or PATCH for updates?
Use PUT when the client sends a complete replacement of the resource. Every field must be included, and omitted fields are set to their default values. Use PATCH when the client sends only the fields that should be changed. PATCH is more bandwidth-efficient and reduces the risk of accidentally overwriting fields. In practice, most APIs benefit from supporting PATCH for typical update operations.
How should I handle API versioning when making breaking changes?
First, try to make additive, non-breaking changes (adding new fields, new endpoints) that do not require a version bump. When breaking changes are unavoidable, increment the version and maintain the old version for a deprecation period (typically 6-12 months). Communicate the timeline clearly via deprecation headers, documentation, and email. Monitor usage of the old version and only retire it when adoption of the new version is high.
What is the best pagination strategy for my API?
For most APIs, cursor-based pagination is the best default. It performs consistently regardless of dataset size and handles concurrent data modifications gracefully. Use offset-based pagination only for small, infrequently changing datasets where users need to jump to specific pages (like an admin panel). For time-series data, keyset pagination on timestamp fields is ideal.
How should I implement rate limiting across multiple API servers?
Use a centralized store like Redis to maintain rate limit counters. The token bucket algorithm works well with Redis because it requires only two atomic operations (check and decrement). Implement rate limiting at the API gateway level (like Nginx, Kong, or AWS API Gateway) for simplicity, or at the application level for more granular control per endpoint or user tier.
What is the difference between authentication and authorization in APIs?
Authentication verifies identity (who are you?) while authorization verifies permissions (what are you allowed to do?). Authentication happens first (via API key, JWT, OAuth token) and authorization follows (checking roles, scopes, or policies). A 401 status means authentication failed (unknown identity), while a 403 means authorization failed (known identity, insufficient permissions).