I spent six months building what I thought was a clean, well-designed API. Good REST conventions, consistent naming, sensible status codes. By the time I had real users, I'd already broken their integrations twice, undercharged everyone, and written auth code I'm still embarrassed about.
This is the stuff I wish someone had told me upfront.
Every tutorial shows you /api/v1/ and calls it a day. That's not a versioning strategy, it's a placeholder.
The real question is: what counts as a breaking change? Adding a required field? Renaming a key? Changing a status code? I had no clear answer to any of those when I shipped, and I paid for it when I silently broke an integration by renaming a response field that "nobody was using."
Stripe's engineering post on API versioning is worth reading carefully. They pin each API key to the version it was created under, so existing users never see breaking changes unless they explicitly upgrade. You don't have to do exactly that, but you need some answer before you have users, not after.
At minimum: document what constitutes a breaking change, give users a deprecation window (90 days is standard), and send emails before you break anything.
I shipped with API keys stored as plaintext in the database. I know.
The correct baseline: hash your API keys at rest (treat them like passwords), only show the full key once at creation time, and prefix them so users can identify your keys in their own codebases (e.g. sk_live_...). Stripe, GitHub, and Twilio all do this for good reason.
Also think about scopes before you think you need them. "Read-only keys" and "write keys" sounds like over-engineering on day one. It becomes a very reasonable request from enterprise users on day 60.
I didn't add rate limiting early because I didn't want to seem restrictive. Within two weeks I had a user accidentally hammering my endpoint in a loop and taking down the service for everyone else.
Rate limiting protects your other users. It also protects that user. They were horrified when I told them what had happened. A clear 429 Too Many Requests with a Retry-After header is friendlier than a mysterious 503. Treat it as a guard rail, not a penalty.
If you charge based on API usage, you need to answer these questions before launch:
Do you charge for failed requests? (Most don't, but you should decide.)
What happens when a user hits their limit: hard cutoff or overage?
How do you handle usage spikes for good customers?
What's your billing cycle vs. your usage reset cycle?
I charged for failed requests for the first two weeks because I hadn't thought about it. Users noticed. It's a trust issue more than a money issue.
Every vague 500 Internal Server Error you return is a support ticket waiting to happen. Every cryptic "error": "invalid input" is a developer spending 30 minutes debugging something you could have explained in one sentence.
This is more consequential than most people assume. A qualitative study of REST API design practices from Tufts University found that error message design is one of the most consistently underspecified parts of API development, even among experienced teams.
Good API errors have three things: a machine-readable error code (invalid_api_key, not just 401), a human-readable message explaining what went wrong, and ideally a link to relevant docs. This breakdown of what makes error messages actually useful has concrete before/after examples worth bookmarking.
I launched with a README and a Postman collection. That was fine for week one. By week four, users were asking questions I'd already answered in Slack three times, and I had no canonical place to send them.
The minimum viable docs at launch: an authentication guide, a quick-start with working code examples, a full endpoint reference, and a changelog. The ROI is disproportionate. Every hour you spend writing docs saves you several hours of support.
Networks fail. Clients retry. If your POST /orders endpoint isn't idempotent, a user with a flaky connection might create the same order five times and not know it until they check their account.
The fix is straightforward: accept an Idempotency-Key header, store it with a short TTL, and return the same response for duplicate requests. It takes maybe half a day to implement and it will save at least one user from a genuinely bad experience.
The meta-lesson
All of these mistakes share the same root cause: I designed the API for the happy path and for users who would use it exactly as intended. Real developers are in a hurry, their networks drop, their loops have bugs, and they'll hit your endpoints in ways you never imagined.
Build for that developer, not the one in your head.
What's the thing you got wrong first when building an API? Curious whether others hit the same walls or found completely different problems.
Resources: https://www.cs.tufts.edu/~jfoster/papers/vlhcc23.pdf