API Design
REST API conventions: URL structure, HTTP methods, status codes, pagination, filtering, error responses, and versioning.
URL Structure
- Use nouns, not verbs — the HTTP method provides the verb
- Use plural resource names —
/users,/orders,/products - Use kebab-case for multi-word resources —
/order-items, not/orderItems - Nest resources to show relationships — max 2 levels deep
- Use query parameters for filtering, not path segments
# Good
GET /api/v1/users
GET /api/v1/users/123
GET /api/v1/users/123/orders
POST /api/v1/users
PATCH /api/v1/users/123
DELETE /api/v1/users/123
# Bad
GET /api/v1/getUsers
GET /api/v1/user/123
POST /api/v1/users/123/orders/456/items/789/notes # too deeply nested
HTTP Methods
| Method | Purpose | Idempotent | Request Body | Success Code |
|---|---|---|---|---|
| GET | Read resource(s) | Yes | No | 200 |
| POST | Create resource | No | Yes | 201 |
| PUT | Replace resource entirely | Yes | Yes | 200 |
| PATCH | Partial update | No* | Yes | 200 |
| DELETE | Remove resource | Yes | No | 204 |
*PATCH is not guaranteed idempotent, but should be designed to be when possible.
Rules
- GET requests must be safe — no side effects, no state changes
- POST for creation — return the created resource with
Locationheader - Use PATCH over PUT — partial updates are more practical than full replacement
- DELETE should be idempotent — deleting a non-existent resource returns 204, not 404
Status Codes
Use the correct status code. When in doubt, refer to this table:
Success (2xx)
| Code | Meaning | When to Use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST that creates a resource |
| 204 | No Content | Successful DELETE, or PUT/PATCH with no response body |
Client Errors (4xx)
| Code | Meaning | When to Use |
|---|---|---|
| 400 | Bad Request | Malformed JSON, invalid field values, validation errors |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Authenticated but lacks permission |
| 404 | Not Found | Resource does not exist |
| 409 | Conflict | Duplicate resource, state conflict |
| 422 | Unprocessable Entity | Valid JSON but fails business rules |
| 429 | Too Many Requests | Rate limit exceeded |
Server Errors (5xx)
| Code | Meaning | When to Use |
|---|---|---|
| 500 | Internal Server Error | Unexpected server failure |
| 502 | Bad Gateway | Upstream service failure |
| 503 | Service Unavailable | Server overloaded or in maintenance |
Error Responses
Use a consistent error format across all endpoints:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed.",
"details": [
{
"field": "email",
"message": "Must be a valid email address.",
"code": "INVALID_FORMAT"
},
{
"field": "age",
"message": "Must be at least 18.",
"code": "MIN_VALUE"
}
]
}
}
Rules
- Always include a machine-readable error code —
VALIDATION_ERROR,NOT_FOUND,RATE_LIMITED - Include a human-readable message — suitable for developer debugging
- Never expose internal errors — no stack traces, SQL queries, or file paths in production
- Field-level errors in
detailsarray — for validation errors, specify which field failed
Pagination
Use cursor-based pagination for large datasets, offset-based for simple cases.
Offset-based (simple)
GET /api/v1/users?page=2&per_page=25
Response:
{
"data": [ ... ],
"pagination": {
"page": 2,
"per_page": 25,
"total": 150,
"total_pages": 6
}
}
Cursor-based (scalable)
GET /api/v1/users?limit=25&cursor=eyJpZCI6MTAwfQ
Response:
{
"data": [ ... ],
"pagination": {
"limit": 25,
"has_more": true,
"next_cursor": "eyJpZCI6MTI1fQ"
}
}
Rules
- Default page size: 25, max: 100 — prevent clients from requesting unlimited data
- Always return pagination metadata — clients need to know if there are more pages
- Use cursor-based for real-time data or large tables — offset-based breaks with concurrent writes
Filtering and Sorting
# Filter by field values
GET /api/v1/users?status=active&role=admin
# Date ranges
GET /api/v1/orders?created_after=2025-01-01&created_before=2025-12-31
# Search
GET /api/v1/products?q=keyboard
# Sort (prefix with - for descending)
GET /api/v1/users?sort=created_at
GET /api/v1/users?sort=-updated_at
# Combine everything
GET /api/v1/orders?status=shipped&sort=-created_at&page=1&per_page=25
Rules
- Use
snake_casefor query parameter names - Support multiple sort fields —
?sort=-created_at,name - Validate all filter parameters — return 400 for unknown fields
- Document allowed filter fields per endpoint
Request and Response Conventions
- Use
snake_casefor all JSON keys —created_at,first_name,order_id - Use ISO 8601 for dates —
2025-06-15T14:30:00Z - Use UUIDs or opaque strings for IDs — avoid exposing auto-increment integers
- Wrap collections in a
datakey —{ "data": [...] }, not a bare array - Include
created_atandupdated_atin all resources - Use
nullfor absent optional fields — don't omit them entirely
{
"data": {
"id": "usr_a1b2c3d4",
"email": "[email protected]",
"first_name": "Jane",
"last_name": "Doe",
"role": "admin",
"avatar_url": null,
"created_at": "2025-06-15T14:30:00Z",
"updated_at": "2025-06-15T14:30:00Z"
}
}
Versioning
- Use URL path versioning —
/api/v1/,/api/v2/ - Increment the major version only for breaking changes
- Support the previous version for at least 6 months after deprecation
- Return a
Deprecationheader on deprecated endpoints - Document migration guides between versions
Authentication
- Use Bearer tokens in the
Authorizationheader —Authorization: Bearer <token> - Never pass tokens in query parameters — they end up in server logs
- Return 401 for missing/invalid tokens, 403 for insufficient permissions
- Include rate limit headers —
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset
Anti-patterns
- Verbs in URLs — use HTTP methods instead
- Returning 200 with error body — use proper status codes
- Nested resources deeper than 2 levels — flatten with query parameters
- Inconsistent naming — pick
snake_caseorcamelCaseand stick with it - Missing pagination on list endpoints — always paginate collections
- Exposing internal IDs — use prefixed opaque IDs like
usr_,ord_,prod_