Backend · APIs & Auth
API design without the cargo cult
REST vs RPC vs GraphQL is the wrong question. Here's a checklist that keeps you out of the worst API design mistakes.
After years of building APIs and a few of redesigning them, I've stopped having opinions about REST vs. RPC vs. GraphQL. They're all fine. They all suck for different reasons. The choice that matters is much earlier in the design.
Here's the checklist I run.
1. Pick a noun-style or action-style — and stick with it#
REST says "resources first": GET /users/42/orders. RPC says "actions first": POST /list_orders_for_user. Either works. Mixing them in the same API hurts.
The mistake: starting RESTful, hitting some action that doesn't fit ("re-send the email"), and adding POST /users/42/resend-email as a one-off. Now consumers can't tell which style your API uses.
Pick one upfront. If REST: actions become side-effects on resources. POST /emails/{id}/sends creates a send. POST /orders/{id}/cancellations creates a cancellation. The verb is hidden in the resource collection.
If RPC: name it like a function. Live with POST /listOrders and POST /cancelOrder. The whole API is a list of operations. Document them.
2. Idempotency for state-mutating ops#
Every POST/PUT/DELETE that mutates state should accept an idempotency key:
POST /payments
Idempotency-Key: 3f4d-9b2c-1a8e-7c5f
{ "amount": 5000, "currency": "USD" }Replays with the same key return the original response. New keys process normally.
Why this is non-negotiable: networks fail, clients retry, you'll double-charge a customer. Stripe, Square, every serious payment API does this. Your API should too.
Implementation: store (idempotency_key, response) in Redis or DB with a TTL. On request, check first. If match: return stored response.
3. Pagination, always#
Every collection endpoint paginates. Even ones that are "small today." Especially ones that are "small today" — they grow.
Two patterns work:
Cursor-based (preferred for real-time feeds):
GET /orders?limit=50&after=eyJpZCI6MTIzfQReturns { items: [...], next_cursor: "..." }. Stable across inserts.
Offset-based (acceptable for static-ish data):
GET /orders?limit=50&offset=200Simpler. Breaks when items shift positions. Don't use for >10K-item collections.
Bad: returning { items: [...] } with no pagination wrapper. The first time someone fetches your "small" collection at 100K rows, you'll regret it.
4. Errors as a contract#
Every error has a stable shape. Stripe's is the gold standard:
{
"error": {
"type": "validation_error",
"code": "missing_field",
"message": "The 'amount' field is required.",
"param": "amount",
"request_id": "req_abc123"
}
}type: machine-readable category (validation_error,not_found,rate_limited,internal_error)code: machine-readable specific (missing_field,invalid_currency)message: human-readable, safe to show usersparam: which field caused it, when applicablerequest_id: for correlating with your logs
The win: clients can switch on type and code, log the request_id, and never have to grep error messages.
Anti-pattern: returning { "error": "Something went wrong" }. Equivalent to a black box.
5. Versioning: header or path, but explicit#
Don't pretend you'll never break compat. You will.
Path versioning: /v1/users, /v2/users. Hardest to mess up. URLs are obvious.
Header versioning: Accept: application/vnd.acme.v2+json. Cleaner, harder to debug.
Stripe does dated versioning: Stripe-Version: 2025-01-15. Customers pin to a date; old versions keep working. Costs more to maintain but lets the API evolve gracefully.
Whatever you choose, document the deprecation policy. "v1 is supported for 12 months after v2 launches" is good. "We'll figure it out" is bad.
6. Field naming: consistent everywhere#
Pick one: snake_case or camelCase. Use it across every field of every endpoint.
Mixed naming in the same API is a tell that the API was built piece-by-piece without coordination. It's also a sign that consumer SDKs will be a nightmare.
Other conventions to nail down once:
- Booleans:
is_*prefix or just*ed? (is_activevs.active) - Timestamps: ISO 8601 strings or Unix epoch?
- IDs:
id(numeric) orid(string opaque tokens)? - Money: integer cents or string decimals?
Document each. Consistency beats your favorite convention.
7. Rate limit responses, with a path forward#
When you 429 someone, tell them how to recover:
HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1730000000
{
"error": {
"type": "rate_limited",
"code": "too_many_requests",
"message": "Rate limit exceeded. Retry in 30 seconds.",
"retry_after_seconds": 30
}
}Retry-After is the contract. SDKs can implement automatic retry-with-backoff if you set it.
8. Search/filter syntax: pick one, simply#
The temptation: copy GraphQL or build a query DSL.
The wisdom: don't. Most APIs need filtering, sorting, pagination. That's it. Two patterns:
GET /orders?status=paid&sort=-created_at&limit=50Simple. Works for 90% of needs.
GET /orders?filter[status]=paid&filter[amount][gte]=1000&sort=-created_atJSONAPI-style. More expressive, supports operators (gte, in, like).
If you find yourself reaching for "full-text search across nested fields," accept that you have a search problem and build a search endpoint for it. Don't bolt OData onto your CRUD API.
9. Webhook design#
When your API fires webhooks:
- Sign every request with HMAC. The receiver verifies signature against a shared secret.
- Include a timestamp and reject events older than ~5 minutes (replay protection).
- Retry with backoff on 5xx responses. Stop retrying on 4xx.
- Idempotency key in the payload so the receiver can dedupe.
- Versioned event payload — same versioning story as the API.
Stripe's webhook docs are the canonical reference for "how to do this right."
10. Document with examples, not specs#
OpenAPI / Swagger schemas are useful as machine-readable contracts. They are terrible as primary docs.
Real docs lead with examples:
# Create a payment
curl https://api.acme.com/v1/payments \
-u sk_live_xxx: \
-d amount=5000 \
-d currency=USD
# Response
{ "id": "pay_abc", "status": "succeeded", "amount": 5000 }Show the request, show the response. Show the error. Show three real use cases. Then link to the schema for completeness.
Stripe, Twilio, GitHub: all great example-led docs. None lead with the schema.
What this looks like at scale#
After a few years of disciplined application, an API that follows these:
- Has a single style (REST or RPC).
- Has uniform error shape, pagination, idempotency.
- Versions with a deprecation contract.
- Documents with examples first.
- Webhooks signed, retried, idempotent.
…ends up being one of the durable assets of the company. Customers integrate. SDKs work. Onboarding new team members onto the API is straightforward.
The opposite — half-REST half-RPC, ad-hoc errors, no idempotency — is a maintenance pit that drains engineer time forever.
Further reading#
- Microsoft's REST API guidelines — heavy but thorough.
- Stripe's API docs — model for both API design and documentation.
- JSONAPI spec — opinionated REST conventions.
- "Designing APIs for Humans" by Brandur Leach — the tone of how to think about it.