A well-designed API is invisible — developers use it, things work, they move on. A poorly designed API generates Slack messages like "what does error code 7 mean?" and "why does this endpoint return different formats for empty lists?" We've learned API design through building them and (more painfully) through consuming badly designed ones. Here's what we've distilled.
What We'll Cover
URL Design and Naming
URL design seems trivial but it's the first thing developers see. Get it right and the API feels intuitive. Get it wrong and every endpoint requires documentation lookup.
Rules We Follow
| Rule | Good | Bad | Why |
|---|---|---|---|
| Use nouns, not verbs | GET /users |
GET /getUsers |
HTTP method already conveys the action |
| Plural resource names | /users/123 |
/user/123 |
Consistent: /users returns list, /users/123 returns one |
| Nest related resources | /users/123/orders |
/orders?userId=123 |
Hierarchy is clear. But don't nest more than 2 levels deep |
| Use kebab-case | /payment-methods |
/paymentMethods |
URLs are case-insensitive. Kebab-case is the convention |
| Actions as sub-resources | POST /orders/123/cancel |
POST /cancelOrder |
When an action doesn't map to CRUD, use a verb sub-resource |
HTTP Methods: What They Mean
GET /users → List users (with pagination)
GET /users/123 → Get single user
POST /users → Create user
PUT /users/123 → Replace entire user (all fields)
PATCH /users/123 → Update specific fields
DELETE /users/123 → Delete user
# Less common but useful:
HEAD /users/123 → Check if exists (no body)
OPTIONS /users → Return allowed methods (CORS preflight)
PUT vs PATCH: This confuses everyone. PUT replaces the entire resource — if you PUT without a field, it's set to null. PATCH updates only the provided fields. For most APIs, PATCH is what you actually want. We default to PATCH for updates unless there's a specific reason to use PUT.
Response Design
Consistent Response Envelope
Every API response should follow the same structure. Developers shouldn't have to guess what shape the response will be.
// Success response
{
"success": true,
"data": {
"id": "usr_abc123",
"name": "Priya Sharma",
"email": "priya@example.com",
"created_at": "2025-10-15T10:30:00Z"
}
}
// List response
{
"success": true,
"data": [
{"id": "usr_abc123", "name": "Priya Sharma"},
{"id": "usr_def456", "name": "Rahul Patel"}
],
"pagination": {
"total": 142,
"page": 1,
"per_page": 20,
"has_more": true
}
}
// Error response
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Email address is invalid",
"details": [
{"field": "email", "message": "Must be a valid email address"}
]
}
}
Date Formats
Always use ISO 8601 with timezone: 2025-10-15T10:30:00Z. Never use Unix timestamps in responses (humans can't read them during debugging). Never use locale-specific formats ("15/10/2025" — is that October 15 or March 10?). Include timezone always — Z for UTC or +05:30 for IST.
Null vs Absent Fields
Be consistent. We include all fields in the response even if null — this makes it easier for clients to deserialize without checking for field existence. The alternative (omitting null fields) saves bandwidth but adds complexity on the client side.
Error Handling That Helps Developers
The quality of your error responses determines how much time developers waste integrating with your API. A good error message saves support tickets.
| HTTP Status | When to Use | Error Code Example |
|---|---|---|
| 400 | Client sent invalid data (validation failure) | VALIDATION_ERROR, INVALID_FORMAT |
| 401 | Authentication missing or invalid | UNAUTHORIZED, TOKEN_EXPIRED |
| 403 | Authenticated but not authorized for this action | FORBIDDEN, INSUFFICIENT_PERMISSIONS |
| 404 | Resource doesn't exist | NOT_FOUND |
| 409 | Conflict (duplicate email, concurrent edit) | DUPLICATE_EMAIL, CONFLICT |
| 422 | Valid format but business logic rejection | INSUFFICIENT_BALANCE, ORDER_ALREADY_SHIPPED |
| 429 | Rate limit exceeded | RATE_LIMITED |
| 500 | Server error (your fault, not the client's) | INTERNAL_ERROR |
200 OK with {"error": "not found"} in the body. This breaks HTTP caching, confuses monitoring tools, and makes client error handling a nightmare.
Pagination Done Right
Every list endpoint needs pagination. No exceptions. An endpoint that returns all 50,000 users in one response will work today and crash tomorrow.
Offset vs Cursor Pagination
| Approach | How It Works | Pros | Cons |
|---|---|---|---|
| Offset | ?page=3&per_page=20 |
Simple, jump to any page, total count easy | Slow on large datasets (OFFSET scans rows). Inconsistent with real-time data (items shift between pages) |
| Cursor | ?cursor=eyJpZCI6MTIzfQ&limit=20 |
Fast (index-based), consistent with real-time data | Can't jump to arbitrary page, total count is expensive |
Our default: Cursor pagination for high-volume, real-time data (feeds, logs, events). Offset pagination for admin UIs where page jumping matters and dataset size is manageable.
Versioning: Plan for Change
Your API will change. Plan for it from day one.
URL Path Versioning (Our Recommendation)
https://api.example.com/v1/users
https://api.example.com/v2/users
Pros: obvious, easy to route, easy to document. Cons: "not RESTful" say the purists (the resource hasn't changed, just its representation). We don't care about purity — we care about developer experience, and /v1/ is instantly clear.
When to Bump the Version
- Bump version: Removing a field, changing a field's type, changing error response format, renaming a resource
- Don't bump: Adding a new field (additive), adding a new endpoint, adding a new query parameter
Rule of thumb: if existing clients break, it's a new version. If they don't notice, it's a backward-compatible change.
Rate Limiting
Rate limiting protects your API from abuse and ensures fair usage. Always include rate limit headers so clients can self-regulate.
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000 # Max requests per window
X-RateLimit-Remaining: 847 # Requests left in current window
X-RateLimit-Reset: 1697382400 # Unix timestamp when window resets
Retry-After: 30 # Seconds to wait (only on 429)
Rate Limit Strategies
- Per-API-key: Most common. 1000 requests/minute per key. Simple to implement and explain
- Per-endpoint: Expensive endpoints (search, reports) get lower limits than cheap ones (reads). Prevents one expensive operation from consuming the entire budget
- Sliding window: Smoother than fixed windows. Prevents the "burst at window boundary" problem where a client sends 1000 requests in the last second of one window and 1000 in the first second of the next
Documentation That Developers Read
The best API documentation has three layers:
- Quick start (5 minutes): One
curlcommand that works. Copy, paste, see result. Nothing kills adoption faster than spending 30 minutes before making a single successful request - Reference (complete): Every endpoint, every parameter, every response. OpenAPI/Swagger generated. Machine-readable for SDK generation
- Guides (use-case-driven): "How to implement authentication," "How to handle webhooks," "How to paginate through large datasets." These are the pages developers actually bookmark
We use OpenAPI 3.1 with Swagger UI or Redoc for the reference layer. The guides live as Markdown in the repo alongside the code — this keeps them in sync with changes.
Frequently Asked Questions
Should we use REST or GraphQL?
REST for public APIs and simple CRUD. GraphQL when clients need flexible queries across related data (dashboards, mobile apps with varying data needs). We default to REST unless there's a specific reason for GraphQL — REST is simpler to cache, monitor, and rate-limit.
How do we handle authentication in APIs?
API keys for server-to-server. OAuth 2.0 with JWT for user-facing applications. Never send credentials in URL parameters (they appear in logs). Always use HTTPS. Rotate keys regularly — make key rotation painless by supporting multiple active keys during transition.
What response format should we use?
JSON. It's the universal standard for web APIs. Use camelCase for field names (JavaScript convention) or snake_case (Python/Ruby convention) — pick one and be consistent. We use snake_case because it's more readable and aligns with our PHP/Python backend convention.
How do we handle API deprecation?
Announce deprecation 6 months in advance. Add a Sunset header to responses. Send email notifications to API key owners. Monitor usage of deprecated endpoints — don't remove them until traffic drops to near zero. Provide a migration guide, not just a deprecation notice.