api-design
API Design
Apply these conventions when designing, reviewing, or implementing RESTful APIs. Consistency matters more than cleverness — follow the patterns below.
URL Structure
Use nouns for resources, not verbs. Resources are things, not actions.
| Rule | Correct | Incorrect |
|---|---|---|
| Plural nouns | /users, /orders |
/user, /getOrders |
| Nouns, not verbs | POST /users |
POST /createUser |
| Kebab-case for multi-word | /line-items |
/lineItems, /line_items |
| Nest for relationships (max 2 levels) | /users/:id/orders |
/users/:id/orders/:orderId/items/:itemId |
| Flat when parent is obvious | /orders/:orderId/items |
/users/:userId/orders/:orderId/items |
Query Parameters
Use query parameters for filtering, sorting, searching, and pagination. Never encode these in the URL path.
GET /users?status=active&sort=-created_at&limit=20&cursor=abc123
GET /orders?user_id=42&min_total=100&fields=id,status,total
| Parameter | Convention | Example |
|---|---|---|
| Filter | Field name as key | ?status=active&role=admin |
| Sort | Field name, prefix - for descending |
?sort=-created_at,name |
| Search | q parameter |
?q=john |
| Field selection | fields comma-separated |
?fields=id,name,email |
| Pagination | cursor or offset/limit |
?cursor=abc&limit=20 |
HTTP Methods
| Method | Purpose | Idempotent | Request Body | Success Code | Response Body |
|---|---|---|---|---|---|
GET |
Retrieve resource(s) | Yes | No | 200 |
Resource or collection |
POST |
Create a new resource | No | Yes | 201 |
Created resource |
PUT |
Replace a resource entirely | Yes | Yes | 200 |
Updated resource |
PATCH |
Partially update a resource | No* | Yes | 200 |
Updated resource |
DELETE |
Remove a resource | Yes | No | 204 |
No body |
*PATCH is not inherently idempotent but can be implemented as such. Treat it as non-idempotent by default.
Key rules:
- GET must never mutate state. Ever.
- PUT replaces the entire resource — omitted fields are removed or reset to defaults.
- PATCH updates only the fields included in the body.
- POST to a collection creates a new resource. Return the created resource with its
id. - DELETE should succeed silently if the resource is already gone (idempotent).
Status Codes
Use the most specific appropriate code. Don't return 200 for everything.
2xx — Success
| Code | Meaning | Use When |
|---|---|---|
200 OK |
Request succeeded | GET, PUT, PATCH success |
201 Created |
Resource created | POST success — include Location header |
204 No Content |
Success, no body | DELETE success, or PUT/PATCH when no body is needed |
4xx — Client Error
| Code | Meaning | Use When |
|---|---|---|
400 Bad Request |
Malformed request | Invalid JSON, missing required fields, validation failures |
401 Unauthorized |
Not authenticated | Missing or invalid authentication token |
403 Forbidden |
Authenticated but not allowed | User lacks permission for this action |
404 Not Found |
Resource doesn't exist | ID not found, or route doesn't exist |
405 Method Not Allowed |
Wrong HTTP method | POST to a read-only resource |
409 Conflict |
State conflict | Duplicate creation, version mismatch |
422 Unprocessable Entity |
Semantic validation failure | Valid JSON, but business rules violated |
429 Too Many Requests |
Rate limit exceeded | Include Retry-After header |
5xx — Server Error
| Code | Meaning | Use When |
|---|---|---|
500 Internal Server Error |
Unexpected failure | Unhandled exception — log it, don't leak details |
502 Bad Gateway |
Upstream failure | A dependency returned an invalid response |
503 Service Unavailable |
Temporarily down | Maintenance or overload — include Retry-After |
Error Format
Return errors in a consistent shape. Every error response uses this structure:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request body failed validation.",
"details": [
{ "field": "email", "message": "Must be a valid email address." },
{ "field": "age", "message": "Must be at least 18." }
]
}
}
| Field | Type | Required | Description |
|---|---|---|---|
error.code |
string |
Yes | Machine-readable error code (UPPER_SNAKE_CASE) |
error.message |
string |
Yes | Human-readable summary |
error.details |
array | object |
No | Field-level errors, additional context |
Rules:
- Always return the same top-level
{ error: { ... } }shape for all error responses. - Use
codefor programmatic handling (clients switch on it),messagefor display. - Never expose stack traces, internal paths, or database errors in production.
- Include a
request_idin error responses (or top-level meta) for debugging.
Pagination
Cursor-Based (Preferred)
Use cursor-based pagination by default. It handles inserts/deletes gracefully and scales to large datasets.
Request:
GET /users?limit=20&cursor=eyJpZCI6MTAwfQ
Response:
{
"data": [ { "id": 101, "name": "Ada" }, { "id": 102, "name": "Bob" } ],
"pagination": {
"next_cursor": "eyJpZCI6MTAyfQ",
"has_more": true
}
}
| Field | Description |
|---|---|
next_cursor |
Opaque string — encode whatever your DB needs (usually last ID) |
has_more |
Boolean — tells client whether to fetch again |
Offset-Based (When Needed)
Use offset pagination only when users need to jump to arbitrary pages (admin tables, search results).
Request:
GET /users?offset=40&limit=20
Response:
{
"data": [ { "id": 41, "name": "Ada" } ],
"pagination": {
"offset": 40,
"limit": 20,
"total": 523
}
}
Warning: Offset pagination degrades on large datasets (DB must skip offset rows). Never use for infinite scroll.
Authentication
Bearer Tokens (Preferred for User Auth)
Pass tokens in the Authorization header:
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
- Use short-lived access tokens (15 min–1 hr) with refresh tokens.
- Validate tokens on every request. Don't cache validation results.
- Return
401for missing/expired tokens,403for insufficient permissions.
API Keys (For Server-to-Server)
Pass API keys in a custom header or as a query parameter (header preferred):
X-API-Key: sk_live_abc123def456
| Aspect | Recommendation |
|---|---|
| Where to send | X-API-Key header (preferred) or Authorization: ApiKey <key> |
| Key format | Prefix with environment: sk_live_, sk_test_ |
| Storage | Hash keys server-side, never store plaintext |
| Rotation | Support multiple active keys for zero-downtime rotation |
When to Use Each
| Scenario | Auth Method |
|---|---|
| Browser/mobile app user sessions | Bearer token (OAuth 2.0 / JWT) |
| Server-to-server integration | API key |
| Third-party developer access | OAuth 2.0 with scopes |
| Internal microservices | mTLS or signed JWTs |
Versioning
URL Versioning (Preferred)
Prefix major versions in the URL path:
GET /v1/users
GET /v2/users
- Version only when you introduce breaking changes.
- Run old versions in parallel during migration. Set a sunset date and communicate it.
- Non-breaking additions (new optional fields, new endpoints) do not require a version bump.
Header Versioning (Alternative)
Accept: application/vnd.myapi.v2+json
Use header versioning when URL versioning creates routing complexity or you need fine-grained version control. URL versioning is simpler for most cases.
What Counts as a Breaking Change
| Breaking | Not Breaking |
|---|---|
| Removing a field from a response | Adding a new optional field to a response |
| Renaming a field | Adding a new endpoint |
| Changing a field's type | Adding a new optional query parameter |
| Removing an endpoint | Adding a new enum value (if clients handle unknown) |
| Making an optional field required | Improving error messages |
Rate Limiting
Include rate-limit headers in every response so clients can self-regulate:
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 742
X-RateLimit-Reset: 1702483200
| Header | Description |
|---|---|
X-RateLimit-Limit |
Max requests allowed in the window |
X-RateLimit-Remaining |
Requests remaining in current window |
X-RateLimit-Reset |
Unix timestamp when the window resets |
When the limit is exceeded, return:
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Too many requests. Retry after 30 seconds.",
"details": {
"retry_after": 30
}
}
}
Return 429 Too Many Requests with a Retry-After header (in seconds).
Anti-Patterns
Avoid these. When you see them in a codebase, flag or refactor.
| Anti-Pattern | Problem | Do Instead |
|---|---|---|
| Verbs in URLs | /getUsers, /deleteOrder |
Use HTTP methods: GET /users, DELETE /orders/:id |
| Deep nesting (3+ levels) | /users/:id/orders/:oid/items/:iid/reviews |
Flatten: /order-items/:iid/reviews |
| 200 for errors | Client can't distinguish success from failure by status | Use appropriate 4xx/5xx codes |
| GET for mutations | Breaks caching, prefetching, crawlers | Use POST, PUT, PATCH, DELETE |
| Plural/singular inconsistency | /user/1 but /orders |
Always use plural: /users/1, /orders |
| Returning arrays at root | Vulnerable to JSON hijacking, can't add metadata | Wrap in { "data": [...] } |
| Leaking internal errors | Stack traces, SQL errors in responses | Return generic message, log details server-side |
Ignoring Accept headers |
Client can't negotiate content type | Respect Accept or return 406 |
| Custom status codes | Clients won't understand them | Stick to standard HTTP status codes |
| Inconsistent error shapes | Some errors return { message }, others { error } |
Use one error shape everywhere |