A good API is one that developers can use correctly without reading the documentation. A great API is one where developers read the documentation anyway because it is that well-written. Most APIs are neither — they are collections of endpoints that grew organically, where every endpoint returns errors differently, naming conventions change halfway through, and the pagination format is a surprise every time.
This guide covers how to design APIs that are consistent, predictable, and pleasant to work with — because your API is your product’s developer experience.
REST API Naming Conventions
Resources are nouns (plural), actions are HTTP methods:
GET /users → List users
POST /users → Create a user
GET /users/123 → Get user 123
PUT /users/123 → Replace user 123
PATCH /users/123 → Update user 123 (partial)
DELETE /users/123 → Delete user 123
Nested resources:
GET /users/123/orders → List orders for user 123
POST /users/123/orders → Create order for user 123
Avoid:
❌ GET /getUser/123 (verb in URL)
❌ POST /users/create (action in URL)
❌ GET /user/123 (singular resource name)
❌ GET /Users/123 (capitalized)
❌ GET /user_orders/123 (mixed naming with underscores)
URL Design Rules
| Rule | Example | Why |
|---|
| Plural nouns for collections | /orders not /order | Consistency — collection vs item distinguished by presence/absence of ID |
| Kebab-case for multi-word | /order-items not /orderItems | URLs are case-insensitive; hyphens are standard |
| No verbs in URLs | /users/123/activate → PATCH /users/123 {status: "active"} | HTTP method IS the verb |
| Max 2 levels of nesting | /users/123/orders not /users/123/orders/456/items/789 | Deep nesting is hard to use; flatten with top-level resources |
Error Handling: Be Specific and Helpful
// ❌ Bad: tells you nothing
{
"error": "Bad Request"
}
// ❌ Also bad: internal details leaked
{
"error": "NullPointerException at OrderService.java:142"
}
// ✅ Good: specific, actionable, documented
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{
"field": "email",
"message": "Must be a valid email address",
"value": "not-an-email"
},
{
"field": "quantity",
"message": "Must be between 1 and 100",
"value": 0
}
],
"request_id": "req-abc-123"
}
}
HTTP Status Codes: Use the Right One
| Status | When to Use | Common Mistakes |
|---|
| 200 | Success | — |
| 201 | Resource created (POST) | Using 200 for creation |
| 204 | Success, no content (DELETE) | Returning 200 with empty body |
| 400 | Client sent invalid data | Using for server errors |
| 401 | Authentication required (no valid credentials) | Confused with 403 |
| 403 | Authenticated but not authorized | Confused with 401 |
| 404 | Resource not found | Using for authorization failures (leaks resource existence) |
| 409 | Conflict (duplicate, state conflict) | Using 400 for conflicts |
| 422 | Semantically invalid (valid JSON, invalid business logic) | Using 400 for everything |
| 429 | Rate limit exceeded | Not implementing rate limiting |
| 500 | Server error (unexpected) | Returning for client errors |
| Pattern | Best For | Example |
|---|
| Offset | Simple, small datasets, UI “page 3 of 40” | ?offset=40&limit=20 |
| Cursor | Large datasets, real-time data, infinite scroll | ?cursor=eyJpZCI6MTIzfQ&limit=20 |
| Keyset | Sorted results, database-friendly | ?after_id=123&limit=20 |
{
"data": [
{"id": "order_124", "total": 49.99},
{"id": "order_125", "total": 29.99}
],
"pagination": {
"has_next": true,
"next_cursor": "eyJpZCI6MTI1fQ",
"has_prev": true,
"prev_cursor": "eyJpZCI6MTI0fQ"
},
"meta": {
"total_count": 1847,
"per_page": 20
}
}
Versioning
| Strategy | URL Example | Header Example | Tradeoff |
|---|
| URL path | /v2/users | — | Simple, visible, but pollutes URL |
| Header | /users | API-Version: 2 | Clean URLs, harder to discover |
| Query param | /users?version=2 | — | Easy to use, easy to forget |
Recommendation: Use URL path versioning (/v1/, /v2/). It is the most visible and hardest to forget. Only increment the major version for breaking changes (removed fields, renamed endpoints, changed semantics).
What Counts as a Breaking Change
| Breaking ❌ | Non-Breaking ✅ |
|---|
| Removing a field from response | Adding a new field to response |
| Changing a field type (string → number) | Adding a new optional query parameter |
| Renaming an endpoint | Adding a new endpoint |
| Changing error code format | Adding new error codes |
| Making an optional field required | Making a required field optional |
Rate Limiting
Rate Limit Response Headers:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100 # Max requests per window
X-RateLimit-Remaining: 0 # Requests remaining
X-RateLimit-Reset: 1625766000 # Unix timestamp when limit resets
Retry-After: 30 # Seconds to wait before retrying
| Strategy | How It Works | Best For |
|---|
| Fixed window | 100 requests per minute, reset at :00 | Simple, easy to understand |
| Sliding window | 100 requests in any 60-second period | Smoother, prevents burst at window boundary |
| Token bucket | Refill tokens at steady rate, burst up to bucket size | Allows bursts, rate-limited over time |
Documentation
| Documentation Element | Why It Matters |
|---|
| OpenAPI/Swagger spec | Machine-readable, generates client libraries |
| Authentication guide | First thing developers need |
| Quick start (< 5 minutes) | Proves the API works before deep dive |
| Error catalog | Every error code with cause and resolution |
| Rate limit documentation | Prevents surprises |
| Changelog | Developers know what changed and when |
| Code examples (curl, Python, JS) | Copy-paste-friendly |
Implementation Checklist