I Spent 3 Years Building APIs — Here Are 6 Things Nobody Tells You
I've been building and maintaining REST APIs professionally for the past few years. Not the kind you build in a weekend hackathon — the kind that serve real users, at real scale, with real consequences when they break.
Here are six lessons I learned the hard way. Some of them cost me sleep at 3 AM. Others cost me users. All of them made me a better engineer.
1. Your API Is a Product, Not a Backend
The biggest mindset shift I had wasn't technical — it was realizing that an API is a product with its own users, its own UX, and its own support burden.
When a frontend developer integrates your API, they're your customer. They don't care about your clean architecture or your elegant ORM queries. They care about three things:
- Does it work as documented?
- Is it fast enough?
- When it breaks, do I get a useful error message?
I once spent two weeks optimizing our database queries, only to discover that the real bottleneck was a missing index on a JSONB column — something I would have caught in five minutes if I'd looked at the slow query log first. The frontend team didn't notice my query optimization. They did notice the 500 errors when that index was missing.
What I do now: Treat every endpoint like a feature. Before shipping, ask: "Would I be happy if I had to integrate this?"
2. Documentation Is Your First Line of Defense
Early in my career, I thought documentation was something you wrote after the code was done. I was wrong. Documentation is something you write before the code — it's a design tool.
Here's why: writing the docs first forces you to think about the user's perspective. What parameters do they actually need? What should the response look like? What errors should they expect?
# Bad: "We'll figure out the contract later"
@app.post("/orders")
async def create_order(request: Request):
data = await request.json()
# ... 200 lines of logic ...
return {"status": "ok"}
# Better: The contract is clear from the start
@app.post("/orders")
async def create_order(
items: list[OrderItem],
shipping_address: Address,
payment_method_id: str,
) -> OrderResponse:
"""
Create a new order. Returns 201 on success, 422 on validation error.
Rate limit: 10 requests per minute per customer.
"""
...
I now write the OpenAPI spec before I write a single line of implementation code. It catches design problems early — if I can't explain the endpoint clearly in the spec, I don't understand it well enough to build it.
3. Rate Limiting Isn't Optional — It's Infrastructure
In my second year, someone wrote a script that accidentally hammered our search endpoint at 200 requests per second. The database didn't crash, but every other API consumer experienced 5-second timeouts for the next ten minutes.
Here's what I learned: rate limiting isn't about protecting against malicious actors. It's about protecting your well-intentioned users from each other.
A simple in-memory rate limiter in Python:
import time
from collections import defaultdict
class RateLimiter:
def __init__(self, max_requests: int = 60, window_seconds: int = 60):
self.max_requests = max_requests
self.window = window_seconds
self.requests: dict[str, list[float]] = defaultdict(list)
def is_allowed(self, key: str) -> bool:
now = time.time()
cutoff = now - self.window
self.requests[key] = [t for t in self.requests[key] if t > cutoff]
if len(self.requests[key]) >= self.max_requests:
return False
self.requests[key].append(now)
return True
This is fine for a single process, but in production you'll want Redis with sorted sets for atomicity. The key principle is the same: always return a clear Retry-After header so the client knows when to come back.
# Good error response
{
"error": "rate_limit_exceeded",
"message": "Too many requests. Try again in 12 seconds.",
"retry_after": 12
}
A rate-limited client that keeps retrying is a friendly client. A rate-limited client that has no idea when to retry is an angry one.
4. Error Messages Are User Experience
Nothing frustrates an API consumer more than this:
{"error": "Internal Server Error"}
That's not an error message. That's a shrug in JSON format.
Every error response should answer three questions:
-
What went wrong? — Be specific:
"Field 'email' must be a valid email address", not"Validation error" - Whose fault is it? — Is it the client's input (4xx) or the server's state (5xx)?
- What should they do next? — Should they fix their request, retry later, or contact support?
Here's a pattern I use now:
class APIError(Exception):
def __init__(self, status_code: int, code: str, message: str, details: dict | None = None):
self.status_code = status_code
self.body = {
"error": code,
"message": message,
"details": details or {}
}
# Usage
raise APIError(
400,
"invalid_payment_method",
"Payment method 'card_abc123' has expired.",
{"expired_at": "2026-01-15", "suggested_action": "update_payment_method"}
)
The code field is machine-readable (great for SDKs). The message is human-readable. The details give actionable context. This pattern has saved me more support tickets than any other single change.
5. Versioning Is a Social Contract
I've seen teams argue for weeks about API versioning strategies — URL-based (/v1/, /v2/), header-based (Accept: application/vnd.api+json;version=2), query parameter-based. The strategy matters less than the commitment.
When you publish a /v1/users endpoint, you're making a promise: this will keep working the same way until we give you a clear migration path to v2.
Breaking that promise — even for a "minor" change — erodes trust. I once changed a field name from created_at to created because "it's cleaner." A production integration broke because someone was deserializing by field name. It took two hours to roll back. I never made that mistake again.
My rule: If a change might break any existing consumer, it goes in a new version. Period. Even if it's "just" renaming a field or changing a default value.
And document your deprecation policy:
v1 endpoints: supported until 2026-12-31
v2 endpoints: current stable, guaranteed through 2027-06-30
6. Observability Before Optimization
I've lost count of how many times I've seen engineers (including myself) optimize something without measuring it first.
Before you add a caching layer, ask: what's the p95 latency of this endpoint right now? Before you denormalize a database table, ask: which query is actually slow and by how much?
Here's the minimum observability stack I'd recommend for any production API:
import time
import logging
from functools import wraps
logger = logging.getLogger("api.metrics")
def instrument(endpoint: str):
"""Decorator that logs request duration and status."""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
start = time.monotonic()
try:
result = await func(*args, **kwargs)
duration_ms = (time.monotonic() - start) * 1000
logger.info(f"endpoint={endpoint} status=ok duration_ms={duration_ms:.1f}")
return result
except Exception as e:
duration_ms = (time.monotonic() - start) * 1000
logger.error(f"endpoint={endpoint} status=error duration_ms={duration_ms:.1f} error={type(e).__name__}")
raise
return wrapper
return decorator
This is dead simple, but with it you can answer questions like:
- Which endpoints are slowest at p95?
- Is there a spike in errors at a specific time?
- Did that last deploy make things better or worse?
Optimize what you measure. Measure what matters.
The Thread That Runs Through All Six
Looking back, every one of these lessons traces back to the same root: respect the person on the other side of the API.
They're trying to build something. They're on a deadline. They're debugging at midnight just like you. Every design decision you make either helps them or hurts them.
Build APIs like you're the one who has to integrate them. Because someday, you will be.
What's the hardest API lesson you've learned? I'd love to hear about it in the comments.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.