Good APIs Are Obvious
The best API I ever worked with, I understood within an hour. The worst, I was still discovering edge cases six months later. The difference was not the number of endpoints — it was predictability.
A good API follows conventions so consistently that developers can guess the right endpoint before reading the docs, and be right.
URL Design: Resources, Not Actions
URLs should identify resources, not describe operations. The verb belongs in the HTTP method, not the path.
# Wrong
POST /createUser
GET /getUserById?id=123
POST /deleteUser
# Right
POST /users
GET /users/123
DELETE /users/123
Nested resources for relationships:
GET /users/123/accounts # All accounts for user 123
GET /users/123/accounts/456 # Specific account
POST /users/123/accounts # Create account for user 123
Keep nesting shallow — beyond two levels, it becomes hard to reason about. If you need deeper relationships, consider returning resource references instead of full nesting.
HTTP Methods Mean Something
| Method | Meaning | Body | Idempotent | |--------|---------|------|-----------| | GET | Read | No | Yes | | POST | Create | Yes | No | | PUT | Replace | Yes | Yes | | PATCH | Partial update | Yes | No | | DELETE | Remove | No | Yes |
PUT replaces the entire resource. If a client sends a PUT with a missing field, that field should be set to null/removed. This is why PUT is idempotent: sending the same PUT twice produces the same state.
PATCH updates only the provided fields. A PATCH with { "email": "[email protected]" } only changes the email, leaving everything else unchanged.
Misusing these causes bugs. I've seen APIs where a PATCH replaces the whole resource (behaves like PUT) — this silently deletes data when clients only send the fields they know about.
Consistent Error Responses
Nothing is more frustrating than an API where errors look different on every endpoint. Define one error shape and use it everywhere.
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": [
{
"field": "email",
"message": "Must be a valid email address"
},
{
"field": "phone",
"message": "Must be a Malaysian phone number (+60)"
}
]
}
}
Use HTTP status codes correctly:
400— client made a bad request (validation error, malformed JSON)401— not authenticated403— authenticated but not authorized404— resource not found409— conflict (e.g., duplicate unique key)422— well-formed request but semantically invalid429— rate limited500— server error
Return machine-readable error codes ("code": "VALIDATION_ERROR") alongside human-readable messages. Clients can switch on the code; humans read the message.
Pagination That Scales
Offset pagination (?page=2&limit=20) breaks when the underlying data changes during pagination. If a record is deleted between page 1 and page 2, items shift and the client misses one.
Cursor pagination doesn't have this problem:
{
"data": [...],
"pagination": {
"cursor": "eyJpZCI6IjEyMyIsImNyZWF0ZWRBdCI6IjIwMjQtMDEtMDEifQ",
"hasNext": true,
"limit": 20
}
}
The cursor encodes the position (e.g., the last item's ID and created_at, base64-encoded). The next request sends ?cursor=...&limit=20 and the server returns items after that position.
Cursor pagination is the right default for any list that changes in real time.
Versioning from Day One
The moment your API has an external consumer, it has a contract. Versioning lets you evolve the API without breaking existing clients.
Put the version in the URL:
GET /v1/users/123
GET /v2/users/123
Some teams prefer versioning in the Accept header (Accept: application/vnd.api+json; version=2), but URL versioning is easier to test, log, and route in infrastructure.
Don't remove or rename fields in an existing version. Add fields freely — consumers should ignore fields they don't know about. When you need to change the shape fundamentally, release a new version.
Sensitive Data in Responses
Never return data the caller doesn't need to see. This is both a security concern and a performance one.
// Don't return this from GET /users/123
{
"id": "123",
"email": "[email protected]",
"passwordHash": "...", // Never
"pinSalt": "...", // Never
"internalRiskScore": 0.87, // Only for internal endpoints
"fullKycDocument": {...} // Only for KYC endpoints
}
Define explicit response schemas for each endpoint rather than serializing your database model directly. The database model is your internal concern; the API response is a contract.
The Test That Matters
The test I apply to any API I design: can a developer who has never spoken to me build a working integration from the documentation alone, with no questions?
If the answer is no, something is wrong with the API, not the developer.