DEV Community

Sir Max
Sir Max

Posted on

6 Things I Wish I Knew Before Building My First Public API

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
Enter fullscreen mode Exit fullscreen mode

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"}]}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 is 429. 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"}
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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_id in 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
Enter fullscreen mode Exit fullscreen mode

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')
"
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)