The moment a client ships code against your API, every decision you made becomes permanent. Change a field name? Their app breaks. Remove an endpoint? Their deploy fails at 2 AM. Rename a status code? Their error handling silently stops working.
Good API design isn’t about elegance — it’s about making the right thing obvious and the wrong thing hard. This guide covers everything: resource naming, HTTP methods, status codes, error formats, versioning, pagination, authentication, idempotency, rate limiting, and documentation.
If you’re new to REST, start with What is a REST API? first. If you’re evaluating whether REST is even the right choice, compare it with GraphQL and gRPC.
Resource-oriented URLs
URLs identify resources. They’re nouns, not verbs.
✅ Good
GET /users
GET /users/42
GET /users/42/orders
POST /users/42/orders
❌ Bad
GET /getUsers
POST /createUser
GET /getUserOrders?userId=42
POST /deleteUser
Rules that save you from future regret:
- Plural nouns for collections:
/users, not/user - Nested resources for relationships:
/users/42/orders(orders belonging to user 42) - No verbs in URLs — the HTTP method is the verb
- Lowercase, hyphen-separated:
/order-items, not/orderItemsor/OrderItems - No trailing slashes:
/users, not/users/ - Keep nesting shallow — two levels max. Instead of
/users/42/orders/7/items/3, use/order-items/3
For actions that don’t map cleanly to CRUD, use a sub-resource:
POST /users/42/activate → better than POST /activateUser
POST /orders/7/cancel → better than POST /cancelOrder
POST /reports/generate → for one-off operations
HTTP methods — use them correctly
Each method has a specific meaning. Don’t use POST for everything.
GET /users → List users (safe, cacheable)
GET /users/42 → Get one user (safe, cacheable)
POST /users → Create a user (not idempotent)
PUT /users/42 → Replace user 42 entirely (idempotent)
PATCH /users/42 → Update specific fields (idempotent)
DELETE /users/42 → Delete user 42 (idempotent)
Key distinctions people get wrong:
- PUT replaces the entire resource. If you send a PUT without the
emailfield, the email gets removed. PATCH only updates the fields you send. - GET must never change state. No
GET /users/42/delete. Crawlers will follow that link and delete your data. - POST is the only non-idempotent method. Sending the same POST twice creates two resources. This matters — see idempotency.
- DELETE should be idempotent. Deleting something that’s already deleted returns
204, not404.
Status codes
Don’t return 200 OK for everything. Status codes tell clients what happened without parsing the body.
| Code | Name | When to use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST — include Location header |
| 204 | No Content | Successful DELETE — no response body |
| 301 | Moved Permanently | Resource URL changed permanently |
| 304 | Not Modified | Cached response is still valid |
| 400 | Bad Request | Malformed JSON, missing required fields |
| 401 | Unauthorized | No credentials or invalid token |
| 403 | Forbidden | Authenticated but not authorized |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Duplicate entry, version conflict |
| 422 | Unprocessable Entity | Valid JSON but failed validation |
| 429 | Too Many Requests | Rate limit exceeded — include Retry-After header |
| 500 | Internal Server Error | Unhandled exception — never expose stack traces |
| 502 | Bad Gateway | Upstream service failed |
| 503 | Service Unavailable | Maintenance or overloaded — include Retry-After |
The difference between 400 and 422 trips people up. Use 400 when the request is syntactically broken (malformed JSON). Use 422 when the JSON is valid but the data fails business rules (email already taken, amount is negative).
For the full list with examples, see the HTTP Status Codes cheat sheet.
Consistent error format
Every error response should use the same JSON envelope. Clients shouldn’t have to guess the shape.
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Request validation failed",
"details": [
{
"field": "email",
"message": "Must be a valid email address"
},
{
"field": "age",
"message": "Must be at least 18"
}
],
"requestId": "req_abc123"
}
}
Rules for error responses:
- Machine-readable
code— clients switch on this, not the message string - Human-readable
message— for developers reading logs - Field-level
detailsfor validation errors — so forms can highlight the right fields requestIdin every response — makes debugging support tickets possible- Never expose internal details — no stack traces, no SQL queries, no file paths in production
For a deep dive on error handling patterns, error codes, and retry logic, see How to Handle API Errors.
Versioning
APIs change. Versioning lets you evolve without breaking existing clients.
The three main approaches:
URL path: GET /v1/users ← most common, most visible
Header: Accept: application/vnd.api+json;version=2
Query param: GET /users?version=1 ← least common
URL path versioning wins in practice because it’s visible, cacheable, and easy to route. But there are real tradeoffs — header versioning keeps URLs clean, and some teams use content negotiation for fine-grained control.
The full breakdown with migration strategies: REST API Versioning Strategies.
Pagination
Never return unbounded lists. A GET /users that returns 2 million rows will kill your server and the client.
Two common patterns:
Offset-based (simple, but skips items when data changes):
GET /users?page=2&limit=20
{
"data": [...],
"pagination": {
"page": 2,
"limit": 20,
"total": 1847
}
}
Cursor-based (stable, better for real-time data):
GET /users?cursor=eyJpZCI6NDJ9&limit=20
{
"data": [...],
"pagination": {
"nextCursor": "eyJpZCI6NjJ9",
"hasMore": true
}
}
Cursor-based pagination is more reliable for large datasets because inserting or deleting rows doesn’t cause items to be skipped or duplicated. Offset-based is simpler to implement and lets users jump to arbitrary pages.
For keyset pagination, infinite scroll patterns, and performance comparisons: Pagination Patterns Explained.
Authentication
Your API needs to know who’s calling. The main options:
- API keys — simple, good for server-to-server. Send in a header, never in the URL.
- OAuth 2.0 / OIDC — the standard for user-facing apps. Supports scopes, refresh tokens, third-party access.
- JWT bearer tokens — stateless, but you can’t revoke them without extra infrastructure.
- Session cookies — fine for same-origin web apps, but don’t work for mobile or third-party clients.
Always use HTTPS. Always. An API key sent over HTTP is a password on a postcard.
For a detailed comparison with implementation examples: API Authentication Compared.
Idempotency
Network failures happen. Clients retry. If a POST /payments gets retried, does the customer get charged twice?
Idempotent operations produce the same result regardless of how many times they’re called. GET, PUT, and DELETE are idempotent by definition. POST is not — which is why payment APIs use idempotency keys:
POST /payments
Idempotency-Key: pay_req_abc123
Content-Type: application/json
{
"amount": 5000,
"currency": "usd",
"customer": "cus_42"
}
The server stores the result keyed by pay_req_abc123. If the same key comes in again, it returns the stored result instead of processing a new payment.
This is critical for any operation involving money, inventory, or state changes that can’t be undone. Full implementation guide: Idempotency in APIs.
Rate limiting
Without rate limiting, one misbehaving client can take down your entire API. At minimum:
- Set per-client limits (e.g., 1000 requests/minute)
- Return
429 Too Many Requestswith aRetry-Afterheader - Include rate limit headers so clients can self-throttle:
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 247
X-RateLimit-Reset: 1716134400
The algorithms behind this (token bucket, sliding window, leaky bucket) matter more than you’d think. A fixed-window counter has a burst problem at window boundaries that can double your effective limit. See How Rate Limiting Actually Works for the full breakdown.
Filtering, sorting, and field selection
Let clients ask for exactly what they need:
GET /users?status=active&role=admin → filtering
GET /users?sort=-created_at,name → sorting (- prefix = descending)
GET /users?fields=id,name,email → sparse fieldsets
Conventions that work:
- Use query parameters for filtering — don’t create separate endpoints for each filter
- Prefix with
-for descending sort - Support comma-separated values for multi-select:
?status=active,pending - Document which fields are filterable — not every field should be
HATEOAS
HATEOAS (Hypermedia as the Engine of Application State) means including links in responses so clients can discover available actions:
{
"id": 42,
"name": "Alice",
"status": "active",
"_links": {
"self": { "href": "/users/42" },
"orders": { "href": "/users/42/orders" },
"deactivate": { "href": "/users/42/deactivate", "method": "POST" }
}
}
In theory, this makes APIs self-documenting and evolvable. In practice, most APIs skip it. Clients hardcode URLs anyway, and the added payload size isn’t worth it for most use cases. GitHub’s API is a notable exception that does it well.
If you’re building a public API with many consumers, consider it. For internal APIs, good documentation is usually enough.
Webhooks
APIs aren’t always request-response. When something happens asynchronously — a payment completes, a build finishes, a user signs up — webhooks push the event to the client instead of making them poll.
POST https://your-app.com/webhooks/payments
Content-Type: application/json
X-Webhook-Signature: sha256=abc123...
{
"event": "payment.completed",
"data": {
"id": "pay_42",
"amount": 5000,
"currency": "usd"
},
"timestamp": "2026-05-19T14:30:00Z"
}
Always sign webhooks so receivers can verify they came from you. Always implement retry with exponential backoff for failed deliveries. For architecture patterns, security, and delivery guarantees: Webhook Architecture Patterns.
Documentation
An undocumented API doesn’t exist. Developers won’t use what they can’t understand.
What good API docs include:
- Authentication — how to get and use credentials
- Every endpoint — method, URL, parameters, request/response examples
- Error codes — what each error code means and how to fix it
- Rate limits — what the limits are and how to handle 429s
- Changelog — what changed and when
OpenAPI (Swagger) specs are the standard. They generate interactive docs, client SDKs, and can validate requests at runtime.
The difference between docs developers actually read and docs they ignore: API Documentation Developers Read.
API gateways
As your API grows, you’ll need a layer in front of your services that handles cross-cutting concerns: authentication, rate limiting, request routing, logging, and SSL termination.
That’s an API gateway. You can use managed solutions (AWS API Gateway, Kong, Apigee) or build your own for full control. If you want to understand the internals: Build an API Gateway From Scratch.
Design checklist
Before shipping an API endpoint, run through this:
- URLs use plural nouns, no verbs
- HTTP methods match the operation (GET reads, POST creates, etc.)
- Status codes are specific (not just 200 and 500)
- Error responses use a consistent JSON envelope with machine-readable codes
- Pagination is implemented for all list endpoints
- Authentication is required and uses HTTPS
- Rate limiting is in place with proper headers
- Idempotency keys are supported for non-idempotent operations
- API is versioned
- Every endpoint is documented with examples
Where to go from here
This guide covers the principles. The cluster articles go deep on each topic:
And the foundational references: