A well-designed REST API is predictable, consistent, and easy to consume. This guide covers the conventions and patterns that separate production-grade APIs from ad-hoc endpoints — including URI design, versioning, pagination, error handling, idempotency, rate limiting, caching, and authentication. Practices are drawn from real-world API standards at Stripe, GitHub, and Google Cloud, and from RFC 9110 (HTTP semantics, 2022). If you are weighing the alternative, see REST vs GraphQL.
How Should You Structure Resource URIs?
URIs should identify resources (nouns), not actions (verbs). Use plural nouns for collections and nest resources only when there is a true parent-child relationship. Keep nesting shallow — two levels maximum.
| Pattern | Example | Notes |
|---|---|---|
| Collection | /users | Plural noun, lowercase |
| Single resource | /users/42 | Identifier in the path |
| Nested resource | /users/42/orders | Max 2 levels of nesting |
| Filtering | /orders?status=pending | Query params for filters |
| Avoid | /getUser, /createOrder | Verbs belong in HTTP methods |
GET /articles → List articles (collection)
GET /articles/7 → Get article 7 (single resource)
POST /articles → Create a new article
PUT /articles/7 → Replace article 7 entirely
PATCH /articles/7 → Partially update article 7
DELETE /articles/7 → Delete article 7What Are the Best API Versioning Strategies?
Version your API from day one. The three common approaches are URI path versioning, header versioning, and query parameter versioning. URI path versioning is the most widely used because it is visible and easy to route.
# URI path (most common — used by Stripe, GitHub)
GET /v1/users/42
GET /v2/users/42
# Custom header (used by GitHub as alternative)
GET /users/42
Accept: application/vnd.myapi.v2+json
# Query parameter
GET /users/42?version=2Tip: Only increment the major version when you introduce breaking changes (removed fields, renamed endpoints, changed response shapes). Additive changes (new fields, new endpoints) are backward-compatible and do not require a new version.
Cursor vs offset pagination — which should you use?
Offset-based pagination is simpler but breaks when data changes between requests. Cursor-based pagination is stable and performs better on large datasets because the database can use an index seek instead of counting rows. If your cursors encode JSON, decode them with the JSONPath Tester to inspect the embedded keys.
// Request
// GET /articles?page=3&per_page=20
// Response
{
"data": [ /* 20 articles */ ],
"meta": {
"page": 3,
"per_page": 20,
"total": 542,
"total_pages": 28
}
}
// Problem: if a new article is inserted while the client
// is paginating, items shift and duplicates appear.// Request
// GET /articles?limit=20&after=eyJpZCI6MTAwfQ
// Response
{
"data": [ /* 20 articles */ ],
"meta": {
"has_next": true,
"next_cursor": "eyJpZCI6MTIwfQ",
"has_previous": true,
"previous_cursor": "eyJpZCI6MTAxfQ"
}
}
// Cursor is an opaque, base64-encoded token (e.g. the last seen ID).
// Stable even when new rows are inserted or deleted.How Should You Handle Errors Consistently?
Return a consistent error envelope with a machine-readable code, human-readable message, and optional field-level details. Always use appropriate HTTP status codes — do not return 200 for errors. RFC 7807 (Problem Details for HTTP APIs) standardises an envelope using application/problem+json if you prefer a spec-backed shape.
// 422 Unprocessable Entity
{
"error": {
"code": "validation_error",
"message": "Request body failed validation",
"details": [
{ "field": "email", "message": "must be a valid email address" },
{ "field": "age", "message": "must be at least 18" }
]
}
}
// 404 Not Found
{
"error": {
"code": "not_found",
"message": "User with ID 999 does not exist"
}
}
// 429 Too Many Requests
{
"error": {
"code": "rate_limited",
"message": "Rate limit exceeded. Retry after 30 seconds.",
"retry_after": 30
}
}How do you make POST requests idempotent?
Per RFC 9110, GET, PUT, and DELETE are idempotent by definition — repeating the request yields the same server state. POST is not. The fix, popularised by Stripe, is an Idempotency-Key request header: the server caches the response keyed by that header (Stripe stores it for 24 hours) and returns the cached result on retry, so a client that times out and retries a charge does not double-bill the customer.
# Client generates a UUID per logical operation and reuses it on retry
POST /v1/charges
Authorization: Bearer sk_live_...
Idempotency-Key: 8e0e4d4e-2c4b-4c9a-9b1a-7f3e9d2a4b6c
Content-Type: application/json
{ "amount": 1999, "currency": "usd", "customer": "cus_abc" }
# First request → 200 OK, charge created, response cached for 24h.
# Retry with the same key → 200 OK, original response replayed.
# Retry with a different body but the same key → 409 Conflict.// Pseudocode for an idempotency middleware
async function handle(req) {
const key = req.headers['idempotency-key'];
if (!key) return next(req);
const cached = await store.get(`idem:${key}`);
if (cached) {
if (cached.bodyHash !== hash(req.body)) {
return reply(409, { error: { code: 'idempotency_conflict' } });
}
return reply(cached.status, cached.body);
}
const res = await next(req);
await store.setex(`idem:${key}`, 86_400, {
status: res.status,
body: res.body,
bodyHash: hash(req.body),
});
return res;
}Which HTTP Status Codes Matter Most?
Pick the narrowest code that describes the outcome — generic 400 or 500 hides real failure modes from clients. For a deeper reference, see the HTTP status codes cheat sheet.
| Code | Meaning | When to Use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST that creates a resource |
| 204 | No Content | Successful DELETE with no response body |
| 400 | Bad Request | Malformed JSON, missing required params |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Authenticated but lacks permission |
| 404 | Not Found | Resource does not exist |
| 422 | Unprocessable Entity | Valid JSON but failed business validation |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Unhandled server exception |
How Should You Handle Authentication?
Use Bearer tokens (JWT or opaque) for stateless auth and API keys for server-to-server communication. Always transmit credentials over HTTPS. Include rate limiting and token expiration. For browser-based clients, also configure CORS correctly, and follow JWT best practices if you ship JWTs.
# Bearer token (JWT)
GET /v1/users/me
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
# API key (server-to-server)
GET /v1/data/export
X-API-Key: sk_live_abc123def456
# OAuth 2.0 token exchange
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=AUTH_CODE&redirect_uri=...What rate-limiting and caching headers should you return?
Two header families separate a polished API from a noisy one. Rate-limit headers tell clients how much budget they have left and when to retry; cache validators let clients skip work entirely with a 304 Not Modified. Stripe, GitHub, and Cloudflare all expose these and they are cheap to add.
HTTP/1.1 200 OK
X-RateLimit-Limit: 5000 # ceiling for the window
X-RateLimit-Remaining: 4982 # requests left in the window
X-RateLimit-Reset: 1714312800 # Unix epoch when the window resets
# Once exhausted:
HTTP/1.1 429 Too Many Requests
Retry-After: 30 # seconds (or HTTP-date) before retrying# First request — server returns the validator
HTTP/1.1 200 OK
Cache-Control: private, max-age=60
ETag: "v3-7f3e9d2a4b6c"
# Subsequent request — client revalidates
GET /v1/users/42
If-None-Match: "v3-7f3e9d2a4b6c"
HTTP/1.1 304 Not Modified # no body, no DB hit
ETag: "v3-7f3e9d2a4b6c"What Is HATEOAS and Should You Use It?
HATEOAS (Hypermedia As The Engine Of Application State) embeds navigational links in responses so clients can discover related actions without hardcoding URLs. It is part of the REST maturity model (Level 3) but adds complexity. In practice, most teams skip HATEOAS and ship an OpenAPI spec instead — clients generate type-safe SDKs from it, which delivers the "discoverability" promise without per-response link bookkeeping.
// GET /v1/orders/42
{
"id": 42,
"status": "shipped",
"total": 99.99,
"_links": {
"self": { "href": "/v1/orders/42" },
"cancel": { "href": "/v1/orders/42/cancel", "method": "POST" },
"customer": { "href": "/v1/users/7" },
"items": { "href": "/v1/orders/42/items" },
"invoice": { "href": "/v1/orders/42/invoice", "type": "application/pdf" }
}
}References
- RFC 9110 — HTTP Semantics — the 2022 specification that defines methods, status codes, and idempotency.
- RFC 7807 — Problem Details for HTTP APIs — standard machine-readable error envelope.
- Stripe API reference — canonical example of versioning, pagination, and idempotency keys.
- GitHub REST API documentation — battle-tested rate-limit and conditional-request patterns.
- Google Cloud API Design Guide — Google's internal-then-public guidance on resource-oriented APIs.
- OpenAPI Initiative — the spec most teams adopt instead of HATEOAS for discoverability.
Key Takeaways
- • Use plural nouns for URIs and let HTTP methods express the action
- • Version your API from day one — URI path versioning is the most common approach
- • Prefer cursor-based pagination for large, frequently-changing datasets
- • Return consistent error envelopes with machine-readable codes and field-level details
- • Use appropriate HTTP status codes — never return 200 for errors
- • Make POST safe to retry with an Idempotency-Key header (24h server-side cache)
- • Expose X-RateLimit-* and ETag/Cache-Control so clients can self-throttle and revalidate
- • Authenticate with Bearer tokens or API keys over HTTPS
- • Skip HATEOAS in favour of an OpenAPI spec unless workflows are deeply hypermedia-driven
Inspect the rate-limit, cache, and auth headers your own API returns with the HTTP Header Analyzer tool.