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.
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.
POST /createUser
POST /updateUserEmail
POST /deleteUser
POST /getUserOrders
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).
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.
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.
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
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:
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:
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:
// 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", not12345. 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": 1099for $10.99, not10.99. Floats and money don't mix — you'll see10.989999sooner 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-Signatureheader 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.