api-design
RESTful API design — resource naming, error envelopes, versioning, pagination strategies, rate limiting, idempotency keys, request/response validation with Pydantic/Zod, HATEOAS, OpenAPI generation, API documentation.
API Design
Design RESTful APIs that are consistent, versioned, validated, and documented. Covers resource modeling, error contracts, pagination, rate limiting, idempotency, schema validation, HATEOAS, and OpenAPI/Swagger generation.
When to Use
- Designing a new REST API or refactoring an existing one for consistency.
- Choosing pagination strategy (offset vs cursor vs keyset) for a dataset.
- Implementing API versioning (URL path vs header-based).
- Adding rate limiting, idempotency keys, or request validation.
- Generating OpenAPI specs and interactive documentation.
- Designing error response envelopes for client error handling.
Core Concepts
1. RESTful Resource Naming
Rules:
- Use nouns for resources, HTTP methods for actions
- Use plural nouns for collections
- Use kebab-case for multi-word resource names
- Nest resources to express ownership, not to exceed 3 levels deep
# Good
GET /api/v1/users # List users
POST /api/v1/users # Create user
GET /api/v1/users/{id} # Get user
PUT /api/v1/users/{id} # Replace user
PATCH /api/v1/users/{id} # Update user fields
DELETE /api/v1/users/{id} # Delete user
GET /api/v1/users/{id}/orders # List user's orders
POST /api/v1/orders/{id}/cancel # Action on a resource (exception)
# Bad
GET /api/v1/getUser # Verb in URL
POST /api/v1/createUser # Verb in URL
GET /api/v1/user # Singular collection name
GET /api/v1/users/{id}/orders/{oid}/items/{iid}/details # Too deep
2. Error Envelope
Consistent error responses across all endpoints:
{
"success": false,
"data": null,
"error": {
"code": 422,
"message": "Validation failed",
"details": [
{ "field": "email", "reason": "Invalid email format" },
{ "field": "password", "reason": "Must be at least 8 characters" }
]
}
}
Success envelope with pagination:
{
"success": true,
"data": [
{ "id": "usr_123", "email": "[email protected]", "name": "Jane" }
],
"error": null,
"pagination": {
"total": 150,
"page": 1,
"page_size": 20,
"next_cursor": "eyJpZCI6MjB9"
}
}
Pydantic envelope models:
from typing import Generic, TypeVar, Optional, Any
from pydantic import BaseModel
T = TypeVar("T")
class ErrorDetail(BaseModel):
field: str
reason: str
class ErrorEnvelope(BaseModel):
code: int
message: str
details: Optional[list[ErrorDetail]] = None
class PaginationMeta(BaseModel):
total: Optional[int] = None
page: Optional[int] = None
page_size: Optional[int] = None
next_cursor: Optional[str] = None
prev_cursor: Optional[str] = None
class APIResponse(BaseModel, Generic[T]):
success: bool
data: Optional[T] = None
error: Optional[ErrorEnvelope] = None
pagination: Optional[PaginationMeta] = None
3. API Versioning
URL Path Versioning (recommended):
GET /api/v1/users
GET /api/v2/users
- Clear, cacheable, and easy to route
- Clients explicitly opt into a version
- Deprecate v1 by returning
Sunsetheader
Header-Based Versioning:
GET /api/users
Header: API-Version: 2025-01-15
- Cleaner URLs, but harder to debug and cache
- Use when versioning by date is preferred (Stripe-style)
Deprecation headers:
Sunset: Sat, 01 Jan 2027 00:00:00 GMT
Deprecation: true
Link: </api/v2/users>; rel="successor-version"
4. Pagination Strategies
| Strategy | Best For | Example |
|---|---|---|
| Offset/Limit | Small datasets, admin dashboards | ?page=2&limit=20 |
| Cursor-based | Large datasets, real-time feeds | ?cursor=eyJpZCI6MjB9&limit=20 |
| Keyset | Ordered data with stable keys | ?after_id=42&limit=20 |
Cursor-based pagination implementation:
import base64
import json
from typing import Optional
def encode_cursor(values: dict) -> str:
return base64.urlsafe_b64encode(
json.dumps(values).encode()
).decode()
def decode_cursor(cursor: str) -> dict:
return json.loads(
base64.urlsafe_b64decode(cursor.encode()).decode()
)
async def list_users(
cursor: Optional[str] = None,
limit: int = 20,
) -> dict:
decoded = decode_cursor(cursor) if cursor else {}
last_id = decoded.get("id", 0)
users = await db.query(
"SELECT * FROM users WHERE id > $1 ORDER BY id ASC LIMIT $2",
last_id, limit + 1,
)
has_next = len(users) > limit
if has_next:
users = users[:limit]
next_cursor = encode_cursor({"id": users[-1]["id"]}) if has_next else None
return {
"data": users,
"pagination": {
"page_size": limit,
"next_cursor": next_cursor,
"has_more": has_next,
},
}
5. Rate Limiting
Response headers:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1649000000
Retry-After: 60 (only on 429)
Implementation (token bucket):
import time
from collections import defaultdict
class RateLimiter:
def __init__(self, max_requests: int, window_seconds: int):
self.max_requests = max_requests
self.window = window_seconds
self.buckets: dict[str, list[float]] = defaultdict(list)
def is_allowed(self, client_id: str) -> tuple[bool, dict]:
now = time.time()
window_start = now - self.window
# Prune expired entries
self.buckets[client_id] = [
t for t in self.buckets[client_id] if t > window_start
]
remaining = self.max_requests - len(self.buckets[client_id])
reset_at = now + self.window
if remaining <= 0:
return False, {
"X-RateLimit-Limit": str(self.max_requests),
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": str(int(reset_at)),
"Retry-After": str(self.window),
}
self.buckets[client_id].append(now)
return True, {
"X-RateLimit-Limit": str(self.max_requests),
"X-RateLimit-Remaining": str(remaining - 1),
"X-RateLimit-Reset": str(int(reset_at)),
}
6. Idempotency Keys
For POST/PUT/PATCH operations that must be safe to retry:
import hashlib
from typing import Optional
idempotency_store: dict[str, tuple[int, dict]] = {}
def get_idempotency_key(key: Optional[str], request_body: bytes) -> str:
if key:
return key
return hashlib.sha256(request_body).hexdigest()[:16]
async def handle_idempotent_request(
key: str,
handler,
*args,
**kwargs,
) -> tuple[int, dict]:
if key in idempotency_store:
status, response = idempotency_store[key]
return status, response
status, response = await handler(*args, **kwargs)
idempotency_store[key] = (status, response)
return status, response
Client usage:
POST /api/v1/payments
Idempotency-Key: req_abc123
{"amount": 100, "currency": "USD"}
# Retry with same key → returns original response, no duplicate charge
7. Request/Response Validation
Python with Pydantic:
from pydantic import BaseModel, Field, field_validator, EmailStr
class CreateUserRequest(BaseModel):
email: EmailStr
name: str = Field(..., min_length=1, max_length=150)
role: str = Field(default="user", pattern="^(user|admin|editor)$")
model_config = {
"str_strip_whitespace": True,
"json_schema_extra": {
"examples": [{
"email": "[email protected]",
"name": "Jane Doe",
"role": "user",
}]
},
}
@field_validator("name")
@classmethod
def name_not_blank(cls, v: str) -> str:
if not v.strip():
raise ValueError("Name cannot be blank")
return v
class CreateUserResponse(BaseModel):
id: str
email: EmailStr
name: str
role: str
created_at: str
model_config = {"from_attributes": True}
TypeScript with Zod:
import { z } from "zod";
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(150).trim(),
role: z.enum(["user", "admin", "editor"]).default("user"),
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
function validateCreateUser(body: unknown): CreateUserInput {
return CreateUserSchema.parse(body);
}
8. HATEOAS (Hypermedia as the Engine of Application State)
{
"success": true,
"data": {
"id": "usr_123",
"email": "[email protected]",
"name": "Jane Doe",
"_links": {
"self": { "href": "/api/v1/users/usr_123" },
"orders": { "href": "/api/v1/users/usr_123/orders" },
"update": { "href": "/api/v1/users/usr_123", "method": "PATCH" },
"delete": { "href": "/api/v1/users/usr_123", "method": "DELETE" }
}
}
}
9. OpenAPI/Swagger Generation
FastAPI auto-generates from type hints:
from fastapi import FastAPI, APIRouter
from app.schemas.user import CreateUserRequest, CreateUserResponse, APIResponse
router = APIRouter(tags=["users"])
@router.post(
"/users",
response_model=APIResponse[CreateUserResponse],
status_code=201,
summary="Create a new user",
responses={
201: {"description": "User created successfully"},
409: {"description": "User with this email already exists"},
422: {"description": "Validation error"},
},
)
async def create_user(payload: CreateUserRequest):
...
Generate static OpenAPI spec:
# Export OpenAPI 3.1 JSON
python -c "from app.main import create_app; import json; print(json.dumps(create_app().openapi()))" > openapi.json
# Generate TypeScript client from OpenAPI
npx openapi-typescript openapi.json -o src/api/types.ts
# Generate Python client
openapi-python-client generate --path openapi.json --meta setup
Anti-Patterns
- Verbs in URLs —
GET /getUserinstead ofGET /users/{id} - Inconsistent error formats — Different endpoints return different error shapes
- No versioning — Breaking changes force all clients to update simultaneously
- Offset pagination on huge tables — Slow queries; use cursor-based pagination
- Missing idempotency on payments — Retries cause duplicate charges
- No rate limiting — API vulnerable to abuse and overload
- Validation only on the client — Always validate server-side
- Leaking stack traces in 500 errors — Exposes internals; return generic messages
- Ignoring HTTP status codes — Returning 200 for errors with
error: truein body
Best Practices
- Use noun-based plural URLs —
/users,/users/{id}/orders - Always wrap responses in a consistent envelope —
{success, data, error, pagination} - Version in the URL path —
/api/v1/...,/api/v2/... - Validate every input with Pydantic/Zod — reject early, fail fast
- Use cursor-based pagination for any collection that grows beyond 10K rows
- Implement idempotency keys for all mutating operations that clients may retry
- Return proper HTTP status codes — 200/201 for success, 4xx for client errors, 5xx for server errors
- Add rate limit headers so clients can self-regulate
- Generate OpenAPI specs from code — never maintain Swagger by hand
- Document with examples — every endpoint should show request and response samples
Related Skills
backend-patterns— FastAPI implementation patterns with DI, middleware, and testingapi-integrations— Consuming third-party APIs with retries, auth, and circuit breakersskill-authoring— Documenting API conventions as reusable skills