Notes on good API design
A create request times out, the client retries, and one checkout turns into two orders. Or a field moves during a cleanup release, and integrations that ran quietly for months start failing. I keep hitting that when I ship APIs and when I write scripts against other people’s.
Sean Goedecke wrote a good post on API design that matches most of my experience. I took the ideas I use and ran them through a small bookstore API, the same “purchase a book” setup from comment-driven development and failure-mode driven development.
Make the paths guessable
The people calling your API have a job, and that job is buying a book, listing orders, or refunding a bad purchase. Learning your private vocabulary is overhead on the way there. The best surface is one they can mostly guess before opening the docs:
GET /v1/books/bk_19
GET /v1/orders/ord_01HZX
POST /v1/orders
{
"id": "ord_01HZX",
"status": "paid",
"amount_cents": 150000,
"currency": "IDR",
"book_id": "bk_19",
"customer_id": "cus_99"
}
Plain nouns, flat JSON, ids you can copy straight into the next call. Sean’s word for this is boring. Time spent decoding a clever envelope comes out of the feature the caller meant to build.
A published API freezes fast. Once a few consumers ship against it, every path and field name above is a promise. Most of the design work from here is finding room to grow inside that promise.
Do not break people who already shipped
Linus Torvalds has a rule for the kernel: we do not break userspace. API maintainers inherit the same duty, and neatness is not a strong enough reason to break software that already works.
Additive changes are usually fine. A new field on the order object hurts nobody, and clients should ignore keys they do not recognize. If a typed SDK explodes on an extra field, the SDK is the bug.
Removals and reshapes are different. On /v1/orders/ord_01HZX I will not casually:
- drop
amount_cents - change it from a number to the string
"150000" - move
customer_idundercustomer.id - reuse
status: "paid"for a new meaning
Any of those breaks every consumer that reads the field, including teams three dependencies downstream who have never heard of me. The HTTP spec still spells Referer wrong because fixing the typo would break browsers and proxies that shipped decades ago.
Version only when you have to
Sometimes a breaking change is worth it anyway. The responsible way to do it is to serve both shapes at once:
POST /v1/orders
POST /v2/orders
Stripe pins the version in a header instead and lets accounts pick a default in the dashboard. Either way, old clients keep working until they decide to move.
I treat a new version as a last resort. The docs grow a version selector that half the readers set wrong. Support opens every ticket with “which version are you on?” for the next two years. A translation layer sounds like it will contain the mess, and then version-specific branches leak into the core code anyway. Before minting /v2 I will try an optional field, a long deprecation window, and loud warnings in the changelog.
If /v2 still has to happen, plan for months of overlap. A migration announced on Friday for Monday goes badly for everyone involved.
The product carries the API
An API sits between the caller and the thing they want, which here is the book. If people need the product badly enough, they will suffer a bad API to get it. Jira’s API is famously unpleasant, and companies integrate with it every day because they need Jira. Quality becomes a tie-breaker only when two products solve the same problem equally well.
Having no API at all is a different problem. Technical buyers walk away from products they cannot automate, however nice the UI is.
You feel the difference the day a competitor gets close on features and their version is pleasant to script against.
Awkward resources leak into the API
An API usually mirrors the product’s basic resources: books, orders, customers. When those are modeled badly, the API inherits the damage.
Say comments on a book are stored as a linked list, each node knowing only the next id. The naive REST layer on top looks like this:
GET /comments/1
{
"id": 1,
"body": "first",
"next_comment_id": 2
}
Or worse, unbounded nesting:
{
"body": "first",
"next_comment": {
"body": "second",
"next_comment": { "...": "..." }
}
}
A UI can hide that behind infinite scroll. An API consumer will ask for comment 40,000 and expect a normal page. If the honest answer is “start a background job and poll for the result”, they now own your storage mistake.
Fix the model when you can. When you cannot, put a normal list endpoint in front and pay the translation cost yourself.
Give people a key they can paste
OAuth has its place and you should probably support it. You should also hand out a long-lived API key, because most integrations start life as a one-off script:
API_KEY=demo_key_for_docs
curl -sS https://api.example.com/v1/me \
-H "X-Api-Key: ${API_KEY}"
Salespeople, students, and ops folks write these scripts too, and many of them have never read an OAuth spec. If the first successful call requires a browser redirect, PKCE, and somewhere to store a refresh token, a lot of them give up before their first 200.
Keys still need scopes, rotation, and a revoke button. All of that is simpler than forcing every caller through the enterprise path on day one.
Writes should survive a retry
This is the same problem I wrote about in failure-mode driven development. You send POST /v1/orders, the connection dies, and now you do not know whether the server committed. A blind retry can charge the customer twice. The same goes for refunds and SMS sends.
Idempotency keys cover the common case. The client picks a unique key, the server stores the key with the result, and any repeat of the same key returns the first result:
POST /v1/orders
Idempotency-Key: 8f3c2a1e-9b44-4c0d-9f1a-2e6d7c8b0a11
Content-Type: application/json
{
"book_id": "bk_19",
"customer_id": "cus_99"
}
The first request creates the order. A retry with the same key returns the same body, even if the original response never reached the client.
Redis with a TTL of a few hours handles most apps, since retries tend to happen within seconds. Money movement deserves something atomic with the ledger write itself. Deletes are already mostly safe: three DELETE /v1/orders/ord_01HZX calls do not delete three orders, because the id acts as the key.
Keep the header optional on low-risk endpoints so beginners can ignore it. Document it loudly on anything that moves money.
Limit how hard callers can hit you
A person in your UI is limited by how fast they can click. A script loops as fast as the network allows, and scripts in the wild do strange things: create and delete the same order hundreds of times a minute to “stay in sync”, or poll GET /v1/orders forever with zero backoff. One chatty integration can take down a worker pool that every other customer shares.
Put rate limits on everything, tighter ones on expensive fan-out, and keep a switch that pauses a single account without a full outage. Return headers that well-behaved clients can honor:
HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1723780800
If you expose an endpoint like “notify every customer who bought this book”, assume someone will build a chat app on top of it, and you will find out at 3am.
Paginate like the table can grow
SELECT * into one JSON array works until memory runs out, in the database or in the serializer. A popular book collects a lot of orders.
Offset pagination is the simple version:
GET /v1/orders?page=2&per_page=50
ORDER BY id
LIMIT 50 OFFSET 50
It gets slow at depth, because the database still walks every skipped row to reach page 400.
Cursor pagination stays cheap when the sort key is indexed:
GET /v1/orders?limit=50
{
"data": [ { "id": "ord_10" }, { "id": "ord_11" } ],
"next_cursor": "ord_11"
}
GET /v1/orders?limit=50&cursor=ord_11
WHERE id > 'ord_11'
ORDER BY id
LIMIT 50
The query costs the same on page 1 and page 10,000. Use cursors for any collection that can grow; offsets are fine for admin tables that stay small. Either way, return a next_cursor or next_page field so consumers do not rebuild the paging math wrong on their side.
Keep expensive fields off by default
If a field costs an extra service hop to compute, keep it out of the default response. Subscription status is the classic case: do not fetch it on every GET /v1/customers/cus_99 when most callers only wanted the email.
GET /v1/customers/cus_99?include=subscription,orders
{
"id": "cus_99",
"email": "a@example.com",
"subscription": { "plan": "pro", "status": "active" },
"orders": [ { "id": "ord_01HZX", "status": "paid" } ]
}
Optional includes cover a lot of what people reach for GraphQL to solve, without asking every consumer to learn a query language. GraphQL earns its complexity when many clients need different slices of a big graph and you are prepared to pay for arbitrary queries on the server: harder caching, fiddlier resolvers, more edge cases. For the product APIs I build, a few focused endpoints plus includes stay easier to cache, document, and support.
Internal APIs can move faster
Inside one company the rules loosen. If you own every caller, you can ship a breaking change and its fix in the same pull request. You can demand heavier auth, because your consumers are engineers with time to set it up.
Money writes still need idempotency, expensive paths still need limits, and a misbehaving internal consumer pages you at 3am just as loudly as an external one.
Closing
I default to JSON over HTTP because every language already ships a client for it. OpenAPI starts paying for itself once the surface grows. For a small API, markdown that opens with a working curl command is enough.
Most of these habits are cheap at design time and expensive later. Cursor pagination costs the same as offsets on day one and saves a migration. The idempotency store is an afternoon before launch, or a cleanup project after.