Six API Design Mistakes That Will Cost You Six Months
API mistakes are expensive because they are public
Almost every internal mistake is cheap. You rename a function, you fix the callers, you move on. An API is different: the moment a mobile app, a partner integration or a client's accounting system depends on your JSON, your mistake has become someone else's contract. You cannot fix it alone anymore. You have to negotiate it, version it, and maintain the wrong thing for a year while people migrate.
That is why the six mistakes below cost months rather than days. None of them are exotic. I have made four of them personally, on systems that were handling real money in Kenya, and I have watched teams pay for them long after the person who wrote the code left.
Mistake 1: HTTP 200 with an error in the body
This is the most common one, and it is usually justified with "our client library handles it". It does not survive contact with reality.
HTTP/1.1 200 OK
{ "success": false, "message": "Insufficient balance" }
Everything between your server and the caller now believes this request succeeded. Your load balancer's error-rate metric shows zero. Your uptime monitor is green. The client's retry middleware does not retry. The partner's ETL job writes the failure into their database as a successful row. You will find out weeks later during a reconciliation.
Use the status code. It is not decoration, it is the part of the response that infrastructure can read. And when you need structured detail, there is now a standard for it: RFC 9457, Problem Details for HTTP APIs, which replaced RFC 7807 in 2023.
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/insufficient-balance",
"title": "Insufficient balance",
"status": 422,
"detail": "Wallet 8812 has KES 240.00 available, transfer requires KES 500.00",
"instance": "/v1/transfers/9f2c11",
"balance_minor": 24000,
"required_minor": 50000,
"currency": "KES"
}
The type field is the stable machine-readable identifier. Clients switch on it. The title and detail are for humans and may be reworded freely. Extension members (balance_minor, required_minor) let the client render a useful message without string-parsing your prose. That last part matters more than it sounds: if the only machine-readable signal is the message text, somebody will write if "Insufficient" in msg and your next copy edit becomes a production incident.
Mistake 2: Offset pagination on data that changes
Everyone starts here because it maps to SQL in the obvious way:
SELECT * FROM transactions
ORDER BY created_at DESC
LIMIT 50 OFFSET 200;
Two things go wrong. First, correctness: if three rows are inserted while a client is paging, the window shifts and the client silently skips records. For a transactions endpoint that a partner uses to reconcile, silently skipped records is the worst possible bug, because nothing errors. Second, performance: OFFSET 200000 forces the database to generate and discard 200,000 rows on every page. Page 1 is instant, page 4,000 times out.
Use keyset (cursor) pagination. Order by something stable and unique, and ask for rows after the last one you saw:
-- First page
SELECT id, created_at, amount_minor
FROM transactions
WHERE account_id = $1
ORDER BY created_at DESC, id DESC
LIMIT 50;
-- Next page: pass the last row's (created_at, id) as the cursor
SELECT id, created_at, amount_minor
FROM transactions
WHERE account_id = $1
AND (created_at, id) < ($2, $3)
ORDER BY created_at DESC, id DESC
LIMIT 50;
The tuple comparison handles ties on created_at correctly, which a naive created_at < $2 does not. Encode the cursor opaquely so clients cannot construct one and depend on its shape:
{
"data": [ "..." ],
"next_cursor": "eyJjIjoiMjAyNi0wNy0yM1QxMToxMjowMFoiLCJpIjo5MTIyfQ",
"has_more": true
}
Note there is no total count. Counting a large filtered table on every page is expensive, and clients rarely need it. If they truly do, make it a separate endpoint they call once.
Mistake 3: Booleans that should have been states
This one is subtle and it compounds. You ship this:
{ "id": 55, "is_paid": false }
Then payments can fail, so you add is_failed. Then they can be pending at the provider, so is_pending. Then refunds arrive, so is_refunded. You now have four booleans, sixteen representable combinations, and roughly five of them are legal. Every client writes its own interpretation of what is_paid=false, is_pending=false, is_failed=false means, and each one gets it slightly differently wrong.
{
"id": 55,
"status": "pending",
"status_changed_at": "2026-07-23T11:12:00Z"
}
One field, an explicit set of values, and an illegal state is unrepresentable. Document the permitted transitions in your API reference, treat adding a new status value as a breaking-ish change (clients must handle unknown values gracefully - say so in the docs from day one), and never remove a value that has been emitted.
The general rule: if two fields can contradict each other, they should have been one field.
Mistake 4: Your API is your database schema
Auto-generating endpoints straight from your ORM models feels like leverage. It is a loan. Every column name, every foreign key, every enum value becomes a public contract, and now you cannot rename a column without a client migration.
I once had a table with a column called ussd_flag, a leftover from an experiment, that ended up in a partner's integration. It stayed in the API for two years after the feature died, because removing it required a partner to ship a release, and they had no engineering capacity that quarter.
Concrete consequences worth naming:
- Sequential integer IDs leak volume. Anyone can watch your growth by creating two records a week apart. Worse, they invite enumeration attacks. Use UUIDs or prefixed opaque IDs (txn_9f2c11c8) in the API even if the primary key stays an integer internally.
- Internal enums leak internal thinking. A status of PENDING_MANUAL_KYC_TIER2 tells the world how your compliance process works.
- Nullable columns become optional fields with no documented meaning, and clients each guess differently.
Write a serializer layer. It is thirty lines per resource and it buys you the freedom to change your schema without asking permission from every integrator you have.
Mistake 5: No versioning plan until you need one
The mistake is not choosing the wrong versioning scheme. It is choosing none, shipping, and then discovering you need one under deadline pressure.
Put /v1/ in the path on day one. It costs nothing and it is the most widely understood option. Then adopt one discipline that matters more than the version number itself:
Additive changes are free. Everything else needs a new version. Adding a field, adding an endpoint, adding an optional parameter, adding an enum value that clients were told to handle - free. Renaming, removing, changing a type, tightening validation, changing a default - not free.
Tightening validation is the one people miss. If your API accepted a 20-character phone number for a year and you "fix" it to enforce 12, you have broken every client sitting on messy data. That is a breaking change even though you only made the code stricter.
When you do ship v2, run both. Log usage per version per client so you know exactly who is left, and email those specific people rather than posting a deprecation notice into the void. Set a sunset date and put it in the response header so it shows up in someone's logs:
curl -sD - https://api.example.com/v1/transfers -o /dev/null
HTTP/2 200
sunset: Sat, 31 Jan 2027 23:59:59 GMT
deprecation: true
link: <https://api.example.com/docs/v2-migration>; rel="deprecation"
Mistake 6: Ambiguous time and money
These two ride together because both are cheap to get right and brutal to fix later.
Time. Always RFC 3339, always UTC, always with the offset. "2026-07-23 14:12:00" is not a timestamp, it is a riddle. In a market spanning EAT, WAT and CAT, a client will guess wrong and the bug will look like an off-by-three-hours reporting error nobody can reproduce. And when a user picks "3pm on the 5th" for a future scheduled event, store the local time and the IANA timezone name (Africa/Nairobi) alongside the instant, because political timezone changes do happen.
Money. Never floats. Ever.
>>> 0.1 + 0.2
0.30000000000000004
>>> sum([1150.10] * 3)
3450.2999999999997
Send integer minor units plus an explicit currency:
{
"amount": { "value": 115010, "currency": "KES", "exponent": 2 }
}
The exponent is there because not every currency has two decimal places, and hardcoding 100 as the divisor breaks the day you touch a currency that does not. Include the currency on every monetary field even when your product is single-currency today, because "today" has a way of ending.
The cheap window
All six of these are trivial to fix before the first external integration and structurally hard afterwards. So the practical advice is procedural rather than technical: get someone to build a real client against your API before you call it stable. Not a Postman collection you wrote - an actual application written by someone who did not design the endpoints. Every wrong assumption they make is a design flaw you can still fix for free.
After that, you are in maintenance mode forever, and the only question left is how much of your future you spend supporting decisions you made in an afternoon.