6 Things I Wish I Knew Before Building My First Public API
Two years ago, I shipped my first public REST API. It worked fine in development. Three days after launch, a single user accidentally loop-called an unauthenticated endpoint 40,000 times and took down the whole service.
I've since built and maintained several public APIs — some that serve thousands of requests per minute, others that quietly hum along at a few dozen per day. Each one taught me something the hard way. Here are the six lessons that would have saved me the most pain.
1. Version Your API from Day One
I know what you're thinking: "It's just v1. I'll add versioning later when I actually need it."
Don't.
The moment you have even one external consumer, you've locked yourself into a contract. Changing a field name, removing an endpoint, or restructuring a response body becomes a breaking change. Without versioning, your only options are: break everyone's integration, or maintain backward compatibility forever.
Use URL-based versioning — it's the simplest and most visible:
GET /api/v1/users
GET /api/v2/users
Header-based versioning (Accept: application/vnd.myapi.v1+json) is cleaner in theory, but it's a nightmare to debug. When someone sends you a screenshot of a broken response, you can see /api/v1/ in the URL. You can't see their headers.
# FastAPI example — simple and explicit
from fastapi import FastAPI
app = FastAPI()
v1 = FastAPI()
v2 = FastAPI()
app.mount("/api/v1", v1)
app.mount("/api/v2", v2)
@v1.get("/users")
def get_users_v1():
return {"users": [{"name": "Alice"}]}
@v2.get("/users")
def get_users_v2():
return {"data": [{"id": 1, "name": "Alice", "role": "admin"}]}
I once spent a weekend migrating 12 internal services because I renamed userId to user_id and had no versioning escape hatch. Never again.
2. Rate Limiting Isn't Optional — Even for Free Tiers
That 40,000-request incident I mentioned? It wasn't malicious. A junior developer on the other end had a while True loop with no sleep. Oops.
Rate limiting protects you from accidents, not just attacks. Here's what a basic token bucket implementation looks like:
import time
from collections import defaultdict
class RateLimiter:
def __init__(self, requests_per_minute=60):
self.rpm = requests_per_minute
self.buckets = defaultdict(lambda: {"tokens": requests_per_minute, "last": time.time()})
def allow(self, client_id: str) -> bool:
bucket = self.buckets[client_id]
now = time.time()
elapsed = now - bucket["last"]
# Refill tokens
bucket["tokens"] = min(self.rpm, bucket["tokens"] + elapsed * (self.rpm / 60))
bucket["last"] = now
if bucket["tokens"] >= 1:
bucket["tokens"] -= 1
return True
return False
Three things I learned the hard way:
-
Always return rate limit headers (
X-RateLimit-Remaining,X-RateLimit-Reset,Retry-After). Your users can't self-correct if they don't know they're being throttled. -
429 is better than 503. A rate-limited request should get a clear
429 Too Many Requests, not a cryptic server error. Returning 503 when you're overloaded just makes people retry harder. -
Separate auth failures from rate limits. A wrong API key is
401. Too many requests with a valid key is429. Mixing them up makes debugging impossible.
3. Error Messages Are Part of Your API Contract
Here's an error response I actually shipped in v1:
{"error": "Something went wrong"}
The support emails were... not fun.
Your error responses should tell the caller three things: what happened, why it happened, and what to do about it. A good error response looks like this:
{
"error": {
"code": "INSUFFICIENT_CREDITS",
"message": "Your account has 2 credits remaining. This request requires 10.",
"details": {
"required": 10,
"available": 2,
"upgrade_url": "https://example.com/billing"
}
}
}
The code field is machine-readable (clients can switch on it). The message is human-readable (developers can understand it in logs). The details object gives programmatic access to the specifics.
A few rules I now follow:
- Use snake_case error codes, not random integers
- Include a
request_idin every response so users can reference it in support - Never expose stack traces or internal paths in production errors
4. Documentation Should Be Executable, Not Just Readable
I wrote 15 pages of API docs before launch. Beautiful Markdown. Clear examples. A table of contents that would make any technical writer proud.
Within a week, I had 8 GitHub issues pointing out that my examples didn't match the actual API behavior. I'd updated the code but forgotten to update the docs.
Here's what I do now: every code example in my docs is a script that gets run against the live API during CI.
# .github/workflows/api-docs-test.yml
name: Test API Docs
on: [push]
jobs:
test-examples:
runs-on: ubuntu-latest
steps:
- name: Run doc examples against staging
run: |
for script in docs/examples/*.sh; do
echo "Testing $script..."
bash "$script" || exit 1
done
Each example script checks that the response matches the documented format:
#!/bin/bash
# docs/examples/create_user.sh
RESPONSE=$(curl -s -X POST https://api-staging.example.com/v1/users \
-H "Content-Type: application/json" \
-d '{"name": "Test User"}')
echo "$RESPONSE" | python3 -c "
import sys, json
data = json.load(sys.stdin)
assert 'id' in data['data'], 'Missing id field'
assert data['data']['name'] == 'Test User', 'Name mismatch'
print('✅ create_user example passed')
"
Stale docs are worse than no docs — they actively mislead your users. Automating the verification is the only reliable fix.
5. Monitor Before You Scale
When traffic spiked on my second API, my instinct was to add more servers. What I should have done first: look at the metrics.
Turns out 70% of the traffic was coming from one endpoint that was 40x slower than the others. A missing database index was causing full table scans on every request. Adding an index took 5 minutes and dropped response times from 2.3 seconds to 50 milliseconds. Adding servers would have cost hundreds of dollars and masked the real problem.
Set up these three measures before you even think about scaling:
import time
import functools
from collections import Counter
# 1. Per-endpoint latency tracking
def track_latency(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.monotonic()
result = func(*args, **kwargs)
elapsed = time.monotonic() - start
# Ship to your metrics system (Prometheus, Datadog, etc.)
print(f"METRIC endpoint={func.__name__} latency_ms={elapsed*1000:.0f}")
return result
return wrapper
# 2. Error rate by status code
error_counts = Counter()
# 3. Request volume by client (spot the noisy neighbor)
client_requests = Counter()
Start with the simplest thing that works. I use a 50-line Python script that writes to a SQLite database and a cron job that emails me daily summaries. You don't need Grafana on day one — you need to see what's happening.
6. Your API Key Is Not a Secret
This one hurt. I issued API keys that had full account access — create, read, update, delete — and told users to embed them in their frontend JavaScript.
A week later, someone posted a CodePen demo using my API. The API key was visible in the View Source. Within hours, someone had used it to delete every resource in that account.
The fix: separate publishable keys from secret keys.
# Publishable key: safe for client-side use, scoped to read-only
# Format: pk_live_xxxxxxxxxxxx
PUBLISHABLE_SCOPES = ["read"]
# Secret key: server-side only, full access
# Format: sk_live_xxxxxxxxxxxx
SECRET_SCOPES = ["read", "write", "delete"]
def validate_scope(key: str, required_scope: str) -> bool:
if key.startswith("pk_"):
return required_scope in PUBLISHABLE_SCOPES
elif key.startswith("sk_"):
return required_scope in SECRET_SCOPES
return False
Another lesson: never log API keys. Configure your logging framework to redact Authorization headers. It's too easy to accidentally ship a log file with hundreds of valid keys in it.
The Meta Lesson
Every one of these mistakes came from the same root cause: I was optimizing for launch speed instead of long-term maintainability. Building a public API is fundamentally different from building an internal service. External users depend on your decisions for months or years.
The good news: most of these fixes are cheap to implement early and expensive to retrofit. Spend the extra hour now.
What's the biggest mistake you made with your first API? I'd love to hear about it in the comments.
Top comments (0)