Study interactive :: Progress tools open in the Study Hub reader.

09. API Design

Designing an API is mostly about predictability. A good API feels like the next thing the developer was going to try works. A bad one feels like a maze.

This chapter is opinionated. There's no single right way to design an API, but there is a sane defaults list.

Resource naming

Use plural nouns for collections, singular for items:

GET  /users          # list
GET  /users/42       # one user
POST /users          # create

Nested resources are fine, but stay shallow. Two levels max:

GET /users/42/posts        # ok
GET /users/42/posts/9      # ok
GET /users/42/posts/9/comments/3/likes   # too deep

For the last case, give comments their own top-level endpoint and link by ID:

GET /comments/3
GET /comments?post_id=9

Use verbs only when the action doesn't fit a resource. POST /payments/42/refund is fine.

Status codes that pull their weight

Pick the right one. Don't 200 OK every response with {"error": "..."}. You're making future you grep server logs by hand.

Code Use when
200 Got the thing
201 Created the thing
204 Did the thing, nothing to return
400 Request was malformed
401 Not logged in
403 Logged in, but not allowed
404 Doesn't exist
409 Conflict (duplicate email, version mismatch)
422 Validation failed
429 Rate limited
500 Server crashed
503 Server overloaded

A common point of confusion: 401 vs 403. 401 is "you haven't authenticated". 403 is "I know who you are, but no".

Pagination

Never return unbounded lists. Two main styles:

Offset / limit:

GET /posts?limit=20&offset=40

Easy. Breaks under heavy writes (page 3 might skip or duplicate items if rows are added/removed).

Cursor-based:

GET /posts?limit=20&after=cursor_abc

Better for infinite scroll. Stable under writes. Slightly more work on the client.

GitHub, Twitter, Stripe all use cursor pagination for anything important. Offset works fine for an admin panel.

Filtering and sorting

A consistent convention saves a lot of pain:

GET /posts?status=published&author=42&sort=-created_at&limit=20

Errors should be a contract

When something goes wrong, return a structured error, not a wall of text:

{
  "error": {
    "code": "validation_failed",
    "message": "Email is required.",
    "field": "email",
    "request_id": "req_abc123"
  }
}

Include a request_id. When a user opens a support ticket, you'll find this request in the logs in seconds instead of hours.

Stripe's error format is the gold standard. Copy it.

Idempotency

We touched on this in the HTTP chapter. Briefly: any non-idempotent operation (POST, especially payments) should accept an idempotency key:

POST /payments
Idempotency-Key: 6f8b3e6e-...

{
  "amount": 1000,
  "currency": "usd"
}

Server stores the key with the result for, say, 24 hours. If the client retries, the server returns the cached result instead of charging twice.

Rate limiting

Two reasons to do it: protect yourself from abuse, and protect noisy clients from breaking everyone else.

Common headers:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 73
X-RateLimit-Reset: 1716808800

When the limit hits, return 429 with Retry-After: 60 so the client knows when to come back. Don't just drop the connection.

Algorithms in order of simplicity:

Redis is the workhorse for distributed rate limiting because it has fast atomic counters.

A naive token bucket in pseudocode:

def allowed(user_id, max_tokens=10, refill_per_sec=1):
    tokens, last = redis.hget(user_id, "tokens", "last") or (max_tokens, now())
    elapsed = now() - last
    tokens = min(max_tokens, tokens + elapsed * refill_per_sec)
    if tokens >= 1:
        tokens -= 1
        redis.hset(user_id, tokens=tokens, last=now())
        return True
    return False

In production you'd write this as a Lua script for atomicity.

Authentication

Three flavors you'll see:

A typical mobile app: user logs in with email + password, server returns a short-lived JWT + a longer-lived refresh token. The app uses the JWT until it expires, then exchanges the refresh token for a new one.

Versioning recap

From the previous chapter:

URL:    /v1/users/42       (most common)
Header: Accept: application/vnd.app.v2+json
Date:   Stripe style, header like X-API-Version: 2026-05-01

Pick one, document it, stick to it. The worst is invisible versioning where the same endpoint changes behavior over time.

Documentation

If you publish an API, you publish docs. Two big options:

For internal services, even a simple Markdown file with example curls beats nothing.

A worked example

You're designing the API for a habit tracker (look familiar?). What does it look like?

GET    /habits                       # list user's habits
POST   /habits                       # create new habit
GET    /habits/42                    # one habit
PATCH  /habits/42                    # rename
DELETE /habits/42                    # delete

POST   /habits/42/check-ins          # mark done today
GET    /habits/42/check-ins?from=2026-05-01&to=2026-05-26

GET    /me/stats                     # summary: total habits, done today, best streak

A check-in needs an idempotency key (don't double-count if the network flakes):

POST /habits/42/check-ins
Idempotency-Key: 5a3e... 

Pagination on check-ins because they grow forever:

GET /habits/42/check-ins?limit=50&after=cursor_abc

All errors structured. All responses JSON. Auth via JWT in the Authorization: Bearer ... header.

That's a fine API. Predictable, paginated, idempotent. Future-you can extend it without breaking past-you.

Things to remember

Going deeper