At HMD Developments, we've built APIs that developers loved and APIs that developers tolerated. The difference isn't in the code - it's in the design decisions made before writing a single line.
After building the HMD Payments API, communication platform backends, and developer tools used by hundreds of integrators, here are the principles that separate pleasant APIs from painful ones.
Principle 1: Predictable URL Structure
A developer should be able to guess your endpoint before reading the docs. If they can't, you've already failed.
// Predictable
GET /v1/users
GET /v1/users/:id
POST /v1/users
PUT /v1/users/:id
DELETE /v1/users/:id
// Unpredictable
GET /v1/getUsers
POST /v1/user/create
PUT /v1/updateUser?id=123
DELETE /v1/remove-user/123
Resources are nouns, plural. Actions are HTTP methods. Versions are in the URL. Once a developer internalizes the pattern, they can navigate the entire API intuitively.
Principle 2: Error Messages That Help
The worst error message: { "error": "Bad Request" }. The best error message tells the developer exactly what went wrong, why, and how to fix it.
{
"error": {
"type": "validation_error",
"message": "The 'amount' field must be a positive integer representing cents.",
"field": "amount",
"received": "-500",
"docs": "https://docs.example.com/api/payments#amount"
}
}This error message contains:
- What went wrong: validation error on the amount field
- Why: the value must be a positive integer in cents
- What was received: the actual invalid value
- Where to learn more: a direct link to the relevant documentation
Every error should be actionable. If a developer reads your error message and still doesn't know what to change, the error message has failed.
Principle 3: Consistent Response Shapes
Every successful response should have the same top-level structure:
{
"data": { ... },
"meta": {
"requestId": "req_abc123",
"timestamp": "2024-09-14T10:30:00Z"
}
}Every list response should add pagination metadata:
{
"data": [ ... ],
"meta": {
"total": 142,
"page": 1,
"perPage": 20,
"hasMore": true
}
}Consistency means developers write one response handler and it works for every endpoint. Inconsistency means they write special cases for every response shape - and hate you for it.
Principle 4: Sensible Defaults, Full Overrides
Every parameter should have a sensible default. No required fields unless genuinely required.
GET /v1/transactions
# Returns: last 20 transactions, most recent first
GET /v1/transactions?limit=50&sort=amount&order=asc
# Returns: 50 transactions sorted by amount ascending
The first request works with zero parameters. The second request fully customizes the response. Both are valid. The developer chooses their level of control.
Principle 5: Versioning From Day One
I made this mistake once and only once. We shipped v1 without a version prefix and then needed breaking changes. Migrating to versioned URLs after launch required coordinating with every integration partner.
Now, every API starts with /v1/. Even if v2 never happens, the placeholder costs nothing and saves everything if it does.
Principle 6: Rate Limits as a Feature, Not a Punishment
Rate limits protect your infrastructure. But they should be transparent:
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 997
X-RateLimit-Reset: 1694689200
Every response includes rate limit headers. When a limit is exceeded, the 429 response includes a Retry-After header with the exact number of seconds to wait. No guessing, no exponential backoff by trial and error.
The HMD Payments API took this further - we provided a rate limit dashboard where developers could see their usage in real time and request limit increases.
Principle 7: Idempotency for Mutations
Every POST, PUT, and DELETE should support an Idempotency-Key header. I wrote about this in detail in Building a Zero-Fee Payment API, but it applies to all APIs, not just financial ones.
Any mutating operation that could be retried needs idempotency protection. Networks are unreliable. Clients retry. Your API should handle that gracefully.
Principle 8: SDKs Are Documentation
The best API documentation isn't a docs site - it's an SDK. When a developer can write:
const payment = await stripe.payments.create({
amount: 5000,
currency: 'usd'
});They don't need to know your URL structure, your authentication headers, or your response format. The SDK handles all of it. Type definitions serve as inline documentation.
For HMD Payments, we generated SDKs in TypeScript, Python, and Go from our OpenAPI specification. One source of truth, multiple languages, always in sync.
The Test: Can a Developer Integrate in Under an Hour?
This is the ultimate metric. If a competent developer can go from "I've never seen this API" to "I've made my first successful API call" in under an hour, your API design is good.
If it takes longer, find out where they got stuck. That's where your design needs work.
Designing an API? Write the SDK usage examples first, then design the API to make those examples possible. Work backwards from the developer experience.