| name | api-design |
| description | REST API design: resource naming, versioning, pagination, error responses, rate limiting headers, idempotency, OpenAPI documentation |
API Design Skill
When to activate
- Designing a new REST API (resource structure, HTTP methods, status codes)
- Adding pagination to an existing list endpoint
- Standardising error response format across a codebase
- Setting up API versioning strategy
- Writing OpenAPI/Swagger documentation
- Implementing idempotency for POST endpoints
When NOT to use
- GraphQL APIs — different design constraints
- Internal microservice communication where gRPC is more appropriate
- WebSocket/realtime APIs — different protocol
Instructions
Resource naming
# Pattern: /resources/{id}/sub-resources/{sub-id}
# Nouns, not verbs. Plural. Lowercase with hyphens.
GET /users → list users
POST /users → create user
GET /users/{id} → get user
PATCH /users/{id} → partial update user
PUT /users/{id} → full replace user
DELETE /users/{id} → delete user
GET /users/{id}/posts → list user's posts
POST /users/{id}/posts → create post for user
# Actions that don't fit CRUD — use a verb sub-resource
POST /users/{id}/activate
POST /users/{id}/password-reset
POST /orders/{id}/cancel
POST /invoices/{id}/send
# Never:
POST /getUser ❌
GET /deleteUser/{id} ❌
POST /user_management ❌
HTTP status codes — use the right one
2xx Success
200 OK — GET, PATCH, PUT success with body
201 Created — POST success, include Location header
204 No Content — DELETE success, or PUT/PATCH with no response body
202 Accepted — async operation started (not yet complete)
4xx Client errors (their fault)
400 Bad Request — malformed JSON, missing required field
401 Unauthorized — not authenticated (no/invalid token)
403 Forbidden — authenticated but not authorised
404 Not Found — resource doesn't exist
405 Method Not Allowed — GET on a write-only endpoint
409 Conflict — unique constraint (email taken)
410 Gone — resource permanently deleted
422 Unprocessable — valid JSON but failed validation
429 Too Many Requests — rate limit exceeded
5xx Server errors (your fault)
500 Internal Server Error — unexpected error
502 Bad Gateway — upstream service down
503 Service Unavailable — planned maintenance or overload
504 Gateway Timeout — upstream timeout
Error response format
Consistent error format across every endpoint:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Request validation failed",
"details": [
{ "field": "email", "message": "Must be a valid email address" },
{ "field": "password", "message": "Must be at least 8 characters" }
],
"requestId": "req_01JKM3X2...",
"docsUrl": "https://docs.example.com/errors/VALIDATION_FAILED"
}
}
Error code conventions:
SNAKE_UPPER_CASE string codes (not numeric)
- Specific enough to act on:
EMAIL_ALREADY_REGISTERED not CONFLICT
- Stable across API versions (don't change them)
from fastapi import Request
from fastapi.responses import JSONResponse
class AppError(Exception):
def __init__(self, code: str, message: str, status: int, details=None):
self.code = code
self.message = message
self.status = status
self.details = details or []
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError):
return JSONResponse(
status_code=exc.status,
content={
"error": {
"code": exc.code,
"message": exc.message,
"details": exc.details,
"requestId": request.state.request_id,
}
}
)
raise AppError(
code="EMAIL_ALREADY_REGISTERED",
message="This email address is already in use",
status=409,
)
Pagination
Cursor-based pagination (recommended for large datasets):
GET /posts?limit=20&cursor=eyJpZCI6IjEwMCJ9
{
"data": [...],
"pagination": {
"limit": 20,
"nextCursor": "eyJpZCI6IjEyMCJ9",
"hasMore": true
}
}
import base64, json
def encode_cursor(id: str) -> str:
return base64.b64encode(json.dumps({"id": id}).encode()).decode()
def decode_cursor(cursor: str) -> dict:
return json.loads(base64.b64decode(cursor).decode())
@router.get("/posts")
async def list_posts(limit: int = 20, cursor: str | None = None):
query = Post.query.order_by(Post.id.desc())
if cursor:
last_id = decode_cursor(cursor)["id"]
query = query.filter(Post.id < last_id)
posts = query.limit(limit + 1).all()
has_more = len(posts) > limit
posts = posts[:limit]
return {
"data": posts,
"pagination": {
"limit": limit,
"nextCursor": encode_cursor(str(posts[-1].id)) if has_more else None,
"hasMore": has_more,
}
}
Offset pagination (simpler, fine for small datasets):
GET /posts?page=2&perPage=20
{
"data": [...],
"pagination": {
"page": 2,
"perPage": 20,
"total": 347,
"totalPages": 18
}
}
Versioning strategy
# URL versioning (most common, most explicit)
/v1/users
/v2/users
# Header versioning (cleaner URLs, harder to test in browser)
GET /users
Accept: application/vnd.myapi.v2+json
# Query param (avoid — pollutes query space)
GET /users?version=2 ❌
Rules for versioning:
- Increment the version for breaking changes only
- Non-breaking additions (new fields, new optional params) don't need a version bump
- Keep old versions alive for at least 6 months after deprecation notice
- Communicate deprecation via
Deprecation and Sunset headers:
Deprecation: Sun, 01 Jan 2027 00:00:00 GMT
Sunset: Sun, 01 Jul 2027 00:00:00 GMT
Link: <https://docs.example.com/migration/v1-to-v2>; rel="deprecation"
Standard response headers
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 450
X-RateLimit-Reset: 1716835200
Idempotency-Key: client-generated-uuid
X-Request-ID: req_01JKM3X2...
Link: <https://api.example.com/posts?cursor=next>; rel="next"
Idempotency for POST endpoints
import hashlib
@router.post("/payments")
async def create_payment(
payload: PaymentRequest,
idempotency_key: str = Header(alias="Idempotency-Key"),
redis: Redis = Depends(get_redis),
):
cache_key = f"idempotency:{idempotency_key}"
cached = await redis.get(cache_key)
if cached:
return JSONResponse(content=json.loads(cached))
result = await charge_card(payload)
await redis.setex(cache_key, 86400, json.dumps(result))
return result
OpenAPI documentation (FastAPI auto-generates this)
@router.get(
"/users/{user_id}",
response_model=UserResponse,
responses={
404: {"model": ErrorResponse, "description": "User not found"},
401: {"model": ErrorResponse, "description": "Not authenticated"},
},
summary="Get a user by ID",
description="Returns the user's public profile. Requires authentication.",
tags=["Users"],
)
async def get_user(
user_id: str = Path(description="The user's UUID"),
include_private: bool = Query(default=False, description="Include private fields (self only)"),
):
...
Example
User: Design the API for a task management app with projects, tasks, and comments — including pagination, error format, and how to handle task assignment.
Expected output:
GET /v1/projects → paginated list
POST /v1/projects → create, returns 201 + Location
GET /v1/projects/{id}/tasks → paginated, filterable (?status=open)
POST /v1/projects/{id}/tasks → create task
PATCH /v1/tasks/{id} → update (title, description, status)
POST /v1/tasks/{id}/assign → assign to user (action endpoint)
POST /v1/tasks/{id}/comments → add comment
DELETE /v1/tasks/{id} → soft delete, 204
Error format: { error: { code, message, details[], requestId } }
Pagination: cursor-based on all list endpoints