How I Design APIs As Senior Developer For Better Results
Bad developers do not write most bad APIs. Rushed developers do.
I know because I have built a few of them. And one of them still runs in production and requires constant rewriting.
Last quarter I reviewed the Orders API behind our new mobile app. At a glance, it looked fine. But once I read it properly, I found serious issues in it.
I want to walk you through how I redesigned it. The first few decisions are the basic ones most of us already know.
But the others are often ignored. That is exactly where bad API design starts to hurt.
Non-medium members click here to read for free.
The Problem With the Current API
Here is what the API looked like when it reached me:
And the response from /getOrderById:
Take a minute. Count the problems.
I found eight on the surface. The ones underneath were worse.
Here’s what I learnt while fixing each of those issues.
Step 1: Start With the Callers, Then Fix the Obvious
Look at that response once more. It is a mirror of the Orders table, and that is the root of everything else.
An API exists for its callers. So before you think about URLs, list who calls it and what they need.
- The mobile app places orders and shows an order history screen
- The admin dashboard searches orders and edits shipping details
- The warehouse service needs paid orders and marks them shipped
Three callers with three different read patterns and two different write patterns. The response does not tell that story. It is just a copy of the table.
Every decision below comes back to this list. The surface problems take five minutes once you have it.
GET /v1/orders
POST /v1/orders
GET /v1/orders/{id}
PATCH /v1/orders/{id}
{
"id": "42",
"customerId": "7",
"status": "paid",
"total": { "amount": 149950, "currency": "USD" },
"items": [
{ "productId": "88", "quantity": 2, "unitPrice": { "amount": 74975, "currency": "USD" } }
],
"createdAt": "2026-03-12T14:05:00Z",
"version": 3
}
GET /v1/orders
POST /v1/orders
GET /v1/orders/{id}
PATCH /v1/orders/{id}
{
"id": "42",
"customerId": "7",
"status": "paid",
"total": { "amount": 149950, "currency": "USD" },
"items": [
{ "productId": "88", "quantity": 2, "unitPrice": { "amount": 74975, "currency": "USD" } }
],
"createdAt": "2026-03-12T14:05:00Z",
"version": 3
}
Nouns in the path, one casing style, money as minor units, ISO dates with a zone, IDs as strings. You know these. I will not spend more words on them.
Two things are worth a second look. is_deleted and internal_notes are gone. Because every field you expose becomes part of the contract you have to maintain. And there is a new version field.
Step 2: Page Lists Before They Grow
GET /getOrders returned every order in the database. That is fine with two hundred orders but not when orders grow to 2 million.
So every list gets paging on day one, and I use cursors rather than page numbers.
GET /v1/orders?status=paid&sort=-createdAt&limit=20&cursor=eyJpZCI6IjQyIn0
{
"items": [ ... ],
"nextCursor": "eyJpZCI6IjIyIn0"
}
GET /v1/orders?status=paid&sort=-createdAt&limit=20&cursor=eyJpZCI6IjQyIn0
{
"items": [ ... ],
"nextCursor": "eyJpZCI6IjIyIn0"
}
Page numbers look simpler until orders keep arriving. A new order pushes every row down, so page 2 repeats an item the caller already saw on page 1. A cursor points at a position in the data, so new rows do not shift it.
The wrapper around items matters as much as the cursor. If you return a bare array, you can never add a field beside it without breaking every caller.
Step 3: Use the Error Format the Industry Already Agreed On
The current API returned HTTP 200 with { "success": false }. Every caller had to parse the body to learn the call failed.
You do not need to invent an error shape. RFC 9457 (Problem Details) already defines one, and most web frameworks can emit it out of the box.
With this, the response should be 409 here.
HTTP 409 Conflict
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/invalid-state-transition",
"title": "Invalid state transition",
"status": 409,
"detail": "Order 42 is cancelled and cannot be shipped.",
"instance": "/v1/orders/42",
"traceId": "0af7651916cd43dd8448eb211c80319c"
}
HTTP 409 Conflict
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/invalid-state-transition",
"title": "Invalid state transition",
"status": 409,
"detail": "Order 42 is cancelled and cannot be shipped.",
"instance": "/v1/orders/42",
"traceId": "0af7651916cd43dd8448eb211c80319c"
}
The type is a stable URL the client can switch on. The detail is for humans. The traceId is for you, on call, at midnight.
That last field is the one most engineers skip. When a customer sends a screenshot of an error, the trace ID in it saves you an hour of log searching.
For that ID to be worth anything, it has to survive the whole call. We accept a traceparent header, pass it to every downstream service, and log it on every line. One ID then covers the API, the order service, and the payment call.
The first time this paid off, a customer complaint was addressed by searching the logs with the ID. Instead of wasting time figuring out the details.
We pasted the ID into the log tool and saw exactly which service returned the error.
Step 4: Design for Concurrency from Day Zero
Here is a bug the current API would have shipped. Two support engineers open order 42. One changes the address. The other changes the delivery note.
With a plain PATCH, whoever saves second wins. The first change is gone, and nobody knows.
We learned this from a parcel that went to an old address. Two engineers, one order, thirty seconds apart, and no error anywhere in the logs.
The fix is optimistic concurrency. Return an ETag with every GET. The client sends it back with the write.
PATCH /v1/orders/42
If-Match: "3"
Content-Type: application/merge-patch+json
{ "shippingAddress":
{
"line1": "350 Fifth Avenue",
"city": "New York"
}
}
PATCH /v1/orders/42
If-Match: "3"
Content-Type: application/merge-patch+json
{ "shippingAddress":
{
"line1": "350 Fifth Avenue",
"city": "New York"
}
}
If the order has moved to version 4 since the client read it, return 412 Precondition Failed. The client reloads, and the support engineer sees the other change before overwriting it.
Two more details matter here. The Content-Type says this is a JSON Merge Patch, so null means remove a field. And make If-Match required on PATCH, not optional. Optional safety gets skipped.
Step 5: Make Retries Boring
Mobile networks drop responses all the time. The user taps Place Order, the request lands, the response never returns, the app retries.
We had this happen. The payment provider succeeded, our request timed out, and the client retried. One customer, one purchase, two orders and two charges.
The fix is an idempotency key.
POST /v1/orders
Idempotency-Key: 7b0c2e6a-1d4f-4c1a-9c3e-2f8e5b7a9d10
POST /v1/orders
Idempotency-Key: 7b0c2e6a-1d4f-4c1a-9c3e-2f8e5b7a9d10
The basic version stores the key and replays the response. The better version handles three more cases.
Same key with a different body returns 422. That is a client bug, and you should say so.
Same key while the first request is still running returns 409, so the client backs off. Keys expire after 24 hours so the store does not grow forever.
Status changes need the same care. POST /updateOrderStatus let anyone jump from paid to delivered. Replace it with actions that enforce the state machine.
POST /v1/orders/{id}/cancel
POST /v1/orders/{id}/ship
POST /v1/orders/{id}/cancel
POST /v1/orders/{id}/ship
Each action checks the current state and returns the 409 problem from Step 3 if the move is illegal. The transition rules live in one place, on the server.
Step 6: Never Let the API Confirm What the Caller Cannot See
The current API checked authentication and stopped there. Any logged-in customer could call /getOrderById?id=41 and read someone else’s order.
This is one of the most common API authorization bugs, and it hides in code that looks fine. The route is protected. The handler forgets to check ownership.
We found ours during a penetration test, not in review. The tester changed one digit in an order id and read a stranger’s shipping address.
So run every read of a single resource through a scoped query. For a customer, that means WHERE id = @id AND customer_id = @currentCustomer.
And when the order belongs to someone else, return 404, not 403. A 403 confirms the order exists. Attackers enumerate IDs with that.
The admin dashboard uses the same endpoint with a different scope. One route, one handler, two scopes. Do not build a parallel admin API that drifts away from the customer one.
Step 7: Don’t Make Callers Wait on Slow Work
Placing an order captures payment. Our gateway takes anywhere from two seconds to forty. The current API held the HTTP connection open the whole time.
Mobile clients time out at thirty seconds. The payment succeeds, the app shows an error, the user orders again. Step 5 saves you from the duplicate, but the user experience is still broken.
When a request starts a process rather than completing one, return 202 Accepted with an operation resource.
HTTP 202 Accepted
Location: /v1/operations/op_9f2c
{
"id": "op_9f2c",
"status": "processing",
"resourceType": "order"
}
HTTP 202 Accepted
Location: /v1/operations/op_9f2c
{
"id": "op_9f2c",
"status": "processing",
"resourceType": "order"
}
The client polls /v1/operations/op_9f2c until it sees succeeded with a link to the order, or failed with a problem body. The app shows a spinner instead of a false error.
The trap is deciding this by stopwatch. A five-second write that finishes in one step is still synchronous. A fast request that only starts a workflow is asynchronous, however quickly it returns. Decide by what the request means, not by how long it takes.
Step 8: Push Events Instead of Letting Services Poll
The warehouse service called /getOrders every ten seconds to find new paid orders. That is 8,640 requests a day, and almost all of them return nothing new.
Send the news instead of making them ask for it. When an order reachespaid, publish an order.paid event to a queue or a webhook.
Two things then matter.
- Sign the payload so the receiver knows it came from you.
- Send IDs, not the whole order, so the consumer reads the current state from the API.
And expect the same event twice. Receivers go down, you retry, and a duplicate slips through. An event ID lets consumers skip what they have already handled.
Step 9: Treat Compatibility as a Test, Not a Promise
Everyone says they will not break clients. I have watched teams break them in the same sprint they said it. Including one where I renamed a field and took down an older app version for a day.
The rule is simple to state. Adding a field or an optional parameter is usually safe, provided clients ignore what they do not recognise.
Renaming, removing, or changing a type means a new major version in the path.
One edge case bites people. A new enum value looks additive, but a client with a switch on the old values will fall through. Treat new enum values as breaking unless you have agreed otherwise.
The rule is worthless without enforcement, so do two things.
First, give every response room to grow, the way the list wrapper in Step 2 does.
Second, ask the mobile team to write consumer contract tests. They record which fields their app reads from each endpoint. That contract runs in your CI pipeline. If you rename createdAt, your build fails before their app does.
When a version does need to retire, announce it in the response itself with a Deprecation header and a Sunset date.
Step 10: Build in Limits Before Launch
A single misbehaving client can take an API down. Retry loops without backoff are the usual cause, and I have been that cause once myself.
So ship every API with rate limits per client key. When a client crosses the line, it gets a 429 with a Retry-After header. Well-behaved clients honour it, and the storm dies down on its own.
Set a server-side timeout on every handler and every downstream call too. An API without timeouts turns one slow database query into a full thread pool exhaustion.
None of this shows up in the endpoint list. All of it decides whether your API survives its first busy week.
The Final Design
Here is the whole thing on one page, with the decisions from each step placed where they act in the flow.
GET /v1/orders filters, sort, cursor paging, scoped by caller
POST /v1/orders Idempotency-Key required, 202 + operation
GET /v1/orders/{id} ETag, 404 for other customers' orders
PATCH /v1/orders/{id} If-Match required, JSON Merge Patch
POST /v1/orders/{id}/cancel state machine enforced, 409 on illegal move
POST /v1/orders/{id}/ship same
GET /v1/operations/{id} status of slow work
event order.paid versioned, signed, ids only
GET /v1/orders filters, sort, cursor paging, scoped by caller
POST /v1/orders Idempotency-Key required, 202 + operation
GET /v1/orders/{id} ETag, 404 for other customers' orders
PATCH /v1/orders/{id} If-Match required, JSON Merge Patch
POST /v1/orders/{id}/cancel state machine enforced, 409 on illegal move
POST /v1/orders/{id}/ship same
GET /v1/operations/{id} status of slow work
event order.paid versioned, signed, ids only
Six endpoints, one operation resource, one event. The same domain as the API we started with, designed with a very different set of questions.
API Design Best Practices I Follow
The Orders API was one case. These are the rules I carry into every API, whatever the domain.
- Design from the callers, not the tables. List who calls the API and what each one needs. Every other decision follows from that list.
- Put nouns in the path and verbs in the method.
/ordersand/orders/{id}. Model real actions like cancel or ship as sub-resources. - Expose only what callers asked for. Every field becomes part of the contract you maintain. Internal flags and notes stay on the server.
- Page every list from day one. Cursors over page numbers. Wrap items in an object so you can add fields later.
- Use one error shape everywhere. Problem Details, real status codes, and a trace id in every body. A 200 never means failure.
- Propagate the trace id across services. Accept it, pass it down, log it on every line. One id should cover the whole call.
- Require ETag and If-Match on updates. Return 412 when versions differ. Optional safety gets skipped.
- Make every create idempotent. Store the key and replay the response. Reject the same key with a different body.
- Scope every query to the caller. Return 404 for what they cannot see. A 403 confirms the resource exists.
- Make compatibility a test, not a promise. Consumer contract tests in CI. Treat new enum values as breaking.
- Ship rate limits and timeouts before launch. 429 with Retry-After. A timeout on every handler and every downstream call.
The engineer who wrote the original API now reviews API designs for the team. His first comment on every PR is the question I asked him: Who is calling this, and what happens when they call it twice?


