env.dev

REST API Best Practices: Design Guide

REST API design conventions used by Stripe, GitHub, and Google: URI naming, versioning, cursor pagination, error envelopes, status codes, auth, and HATEOAS.

Last updated:

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.

PatternExampleNotes
Collection/usersPlural noun, lowercase
Single resource/users/42Identifier in the path
Nested resource/users/42/ordersMax 2 levels of nesting
Filtering/orders?status=pendingQuery params for filters
Avoid/getUser, /createOrderVerbs belong in HTTP methods
HTTP methods mapped to CRUD
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 7

What 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.

Versioning approaches
# 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=2

Tip: 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.

Offset-based pagination
// 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.
Cursor-based pagination (recommended)
// 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.

Consistent error response format
// 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.

Idempotency-Key header (Stripe pattern)
# 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.
Server-side dedup pattern
// 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.

CodeMeaningWhen to Use
200OKSuccessful GET, PUT, PATCH
201CreatedSuccessful POST that creates a resource
204No ContentSuccessful DELETE with no response body
400Bad RequestMalformed JSON, missing required params
401UnauthorizedMissing or invalid authentication
403ForbiddenAuthenticated but lacks permission
404Not FoundResource does not exist
422Unprocessable EntityValid JSON but failed business validation
429Too Many RequestsRate limit exceeded
500Internal Server ErrorUnhandled 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.

Authentication patterns
# 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.

Rate-limit headers (GitHub / Stripe convention)
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
Cache validators (ETag + Cache-Control)
# 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.

HATEOAS response example
// 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

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.

Was this helpful?

Read next

GraphQL: Developer Guide to Queries, Schemas & APIs

Master GraphQL from scratch: schema design with SDL, queries, mutations, subscriptions, resolvers, the N+1 problem with DataLoader, cursor-based pagination, error handling, security hardening, and the Apollo/Relay ecosystem.

Continue →

Frequently Asked Questions

How should I name REST API endpoints?

Use nouns (not verbs), plural form, kebab-case: /api/v1/blog-posts. Use HTTP methods for actions: GET (read), POST (create), PUT/PATCH (update), DELETE (remove). Nest related resources: /users/123/orders.

How should I version my API?

URL path versioning (/api/v1/) is the most common and explicit approach. Header versioning (Accept: application/vnd.api+json;version=1) is cleaner but harder to test. Pick one and be consistent.

How should I handle API errors?

Return appropriate HTTP status codes (400 for bad input, 404 for not found, 500 for server errors). Include a JSON body with error code, message, and details. Be consistent across all endpoints.

What is API idempotency and how do I implement it?

GET, PUT, DELETE are idempotent by spec. POST is not — clients should send an Idempotency-Key header (per Stripe pattern) and the server caches the response keyed by that header for ~24h to safely deduplicate retries.

Stay up to date

Get notified about new guides, tools, and cheatsheets.