cd ../writing
// api · design patterns

REST API design — the patterns that don't bite you later.

Every API ships with the version 1 mistakes baked in. The decisions you make on day one — URL structure, pagination, error format, versioning strategy — are the ones you'll be working around five years later. This guide is the working reference for the decisions that compound: which patterns hold up, which ones look clever but rot, and how the best APIs (Stripe, GitHub, Twilio) make these choices.

10 design rules real examples v1 mistakes avoided © use freely

01Resources, not actions

URLs name things. HTTP verbs name actions on those things. Together they form the API surface. Get this backwards and the API becomes a mess of verb-laden endpoints.

✗ verb in URL — RPC-style
POST /createUser
POST /updateUserEmail
POST /deleteUser
POST /getUserOrders
✓ resources + verbs
POST   /users                    // create
GET    /users/{id}               // read
PATCH  /users/{id}               // partial update
DELETE /users/{id}               // delete
GET    /users/{id}/orders        // nested resource

Use plural nouns for collections (/users, not /user). Use the resource ID inside the path, not a query param. Nest related resources up to one level deep — beyond that, flatten with query params (/orders?user_id=123) to avoid awkward URLs like /users/123/orders/456/items/789.

02Versioning — pick the strategy day one

You will need to break the API. The only question is how you'll handle it. Three approaches in production use:

  • URL path versioning: /v1/users, /v2/users. Most common, easiest to understand. GitHub, Twilio, GitLab all use this.
  • Header versioning: Accept: application/vnd.api+json; version=2. Purer REST, but harder to test in a browser. GitHub also supports this.
  • Date-based versioning: Stripe-Version: 2024-03-12. Each version is a specific date. Stripe's approach — clients pin to a date and only upgrade when ready.

For most teams, URL versioning is the right call. It's discoverable, easy to debug, and works with any HTTP tool without configuration. Stripe's date-based approach is brilliant but requires more infrastructure (per-version request transformers).

The rule: additive changes (new fields, new endpoints) don't need a version bump. Breaking changes (renamed fields, removed endpoints, changed status codes) do. Most APIs over-version because teams confuse "we added something" with "we broke something."

03Error responses — be specific and consistent

Bad error responses are the #1 reason APIs are painful to integrate with. 500 Internal Server Error with no body tells the client nothing.

✓ structured error response
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{
  "error": {
    "type": "validation_error",
    "message": "Email address is invalid",
    "code": "invalid_email",
    "field": "email",
    "request_id": "req_abc123",
    "docs_url": "https://docs.example.com/errors/invalid_email"
  }
}

Five fields make this useful:

  • type — high-level category for client-side branching (validation_error, authentication_error, rate_limit_error)
  • message — human-readable, safe to display to users
  • code — machine-readable identifier the client can switch on
  • request_id — what the user includes in support tickets
  • docs_url — direct link to error documentation

Be consistent. Every error from every endpoint uses the same shape. Clients can then write one error handler instead of one per endpoint.

04HTTP status codes — the ones that matter

Don't use every status code. Use these consistently:

  • 200 OK — successful GET, PATCH, PUT, DELETE with response body
  • 201 Created — successful POST that created a resource (include the resource in the body)
  • 204 No Content — successful action with no response body (e.g., DELETE)
  • 400 Bad Request — malformed request (bad JSON, missing required fields)
  • 401 Unauthorized — missing or invalid authentication
  • 403 Forbidden — authenticated but not allowed
  • 404 Not Found — resource doesn't exist
  • 409 Conflict — resource state conflict (duplicate email, version mismatch)
  • 422 Unprocessable Entity — request was well-formed but validation failed
  • 429 Too Many Requests — rate limited
  • 500 Internal Server Error — your bug
  • 503 Service Unavailable — downstream is down, retry possible

05Pagination — cursor, not offset

Two pagination styles. One ages well, one doesn't.

✗ offset pagination — slow at scale, unstable
GET /users?limit=20&offset=10000

// Problems:
// 1. Database has to skip 10000 rows — O(n) cost
// 2. If rows are inserted/deleted, items shift between pages
// 3. Can't reliably bookmark page N
✓ cursor pagination — fast, stable
GET /users?limit=20&cursor=eyJpZCI6MTIzNDV9

Response:
{
  "data": [...],
  "has_more": true,
  "next_cursor": "eyJpZCI6MTIzNjV9"
}

The cursor is an opaque token (typically base64-encoded JSON like {"id": 12345}) representing "give me items after this point." The database query becomes WHERE id > 12345 ORDER BY id LIMIT 20 — index lookup, constant time regardless of dataset size.

Cursors also handle the "real-time data" problem. If new users are being created while paginating, offset pagination shows duplicates or skips items. Cursor pagination doesn't.

06Idempotency — handle the network

Networks fail. The client sends a request, the request reaches your server, the server processes it, the response gets lost. The client retries. Now you've charged the card twice.

Solve this with idempotency keys. The client generates a unique key per request:

✓ idempotency key
POST /charges
Idempotency-Key: 7f8a9b2c-3d4e-5f6g-7h8i-9j0k1l2m3n4o

{
  "amount": 5000,
  "currency": "usd",
  "customer": "cus_abc"
}

Server-side: when you receive a request, look up the idempotency key. If you've seen it before, return the previously-stored response. If not, process the request and store the response keyed by the idempotency key (typically with a 24-hour TTL).

This makes retries safe. The client can retry as many times as it wants — only the first one actually executes. This is how Stripe, AWS, and every serious payment API work.

07Rate limiting — be transparent

Tell clients what their limits are and where they stand. Three response headers, on every response:

✓ rate limit headers
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 743
X-RateLimit-Reset: 1716643200

When the client exceeds the limit, return 429 Too Many Requests with a Retry-After header indicating seconds until they can retry. Don't silently drop requests — clients can't fix what they can't see.

08Filtering, sorting, fields — query params

For collection endpoints, support common operations via query params:

✓ standard collection params
// Filtering
GET /users?status=active&created_after=2026-01-01

// Sorting (- prefix for descending)
GET /users?sort=-created_at,name

// Field selection — return only what you need
GET /users?fields=id,email,name

// Expansion — include related resources
GET /orders?expand=customer,items.product

Field selection and expansion are particularly valuable. They let clients tune payload size to their needs. Mobile clients can fetch minimal fields; admin dashboards can expand everything.

09Dates, IDs, and other small choices

Three small details that compound:

  • Dates as ISO 8601 strings with timezone: "2026-05-25T12:34:56Z". Never Unix timestamps in JSON — they're ambiguous about resolution (seconds vs ms) and inhuman to read.
  • IDs as strings, not integers: "user_abc123", not 12345. Prefixed IDs make logs self-documenting; you can tell what type of resource an ID belongs to at a glance. Stripe does this religiously.
  • Money as integers in the smallest unit: "amount": 1099 for $10.99, not 10.99. Floats and money don't mix — you'll see 10.989999 sooner than you think.

10Webhooks — when you need to push

Polling is wasteful and slow. For events that matter (payment succeeded, file processed, build complete), expose webhooks. The patterns:

  • Sign every webhook. Include an X-Signature header that's an HMAC of the payload + a shared secret. Without this, anyone can spoof events to your customer's servers.
  • Retry with exponential backoff. Customer's webhook handler will fail sometimes. Retry with increasing delays for ~24 hours before giving up.
  • Provide a webhook log. Let customers see what you sent, when, and the response. This is the #1 webhook debugging tool.
  • Deliver at-least-once, not exactly-once. Customers must make their handlers idempotent. Document this clearly.

The discipline

API design is about making decisions you can live with. Consistency matters more than cleverness — a slightly suboptimal pattern applied consistently is better than three optimal patterns applied inconsistently. Pick a style guide, document it, and follow it on every endpoint.

The best APIs feel inevitable. Every endpoint follows the same rules, every error has the same shape, every list paginates the same way. That feeling is what lets your customers build on you confidently — and it's worth the upfront discipline to get there.