← Back to Blog

REST API Design Best Practices: The Developer's Handbook

A well-designed REST API is a pleasure to consume and easy to maintain. A poorly designed one haunts every developer who touches it for years. This handbook covers resource naming, HTTP semantics, versioning, pagination, error formats, authentication, rate limiting, and documentation — with concrete examples at every step.

The Core Constraint: Resources, Not Actions

REST (Representational State Transfer) is an architectural style defined by Roy Fielding in his 2000 dissertation. The central constraint is that your API should expose resources (nouns), not actions (verbs). HTTP methods (GET, POST, PUT, PATCH, DELETE) provide the verbs. This is where most APIs go wrong from the start.

# BAD — RPC-style, action in the URL
POST /createUser
GET  /getUser?id=42
POST /deleteUser?id=42

# GOOD — REST-style, resource in the URL + HTTP method is the action
POST   /users           # create a user
GET    /users/42        # read user 42
PUT    /users/42        # replace user 42
PATCH  /users/42        # partially update user 42
DELETE /users/42        # delete user 42

This distinction matters because it allows HTTP caching, content negotiation, and standard tooling (browsers, proxies, API gateways) to work correctly. A GET is idempotent and safe — caches know this. A POST /getUser looks like a side-effecting operation and will never be cached.

Resource Naming Conventions

Consistent naming reduces cognitive load for API consumers. Follow these rules without exception:

  • Use nouns, not verbs: /orders not /getOrders or /fetchOrders.
  • Use lowercase with hyphens: /shipping-addresses not /shippingAddresses or /shipping_addresses. URLs are case-sensitive; lowercase avoids confusion. Hyphens improve readability and are SEO-friendly.
  • Use plural nouns for collections: /users not /user. A collection resource contains zero or more items, so the plural is accurate.
  • Represent hierarchy with path segments: /users/42/orders/7 — order 7 belongs to user 42. Avoid nesting deeper than two levels; use query parameters instead.
  • Never use trailing slashes: /users/42 not /users/42/. Trailing slashes are inconsistent across frameworks and should be treated as separate URLs.
  • Keep URLs lowercase and static: Do not put dynamic data (like dates or usernames) in the resource path unless it is truly an identifier.
# Resource URL examples
GET  /products                      # list all products
GET  /products/sku-12345            # single product by ID/SKU
GET  /products/sku-12345/reviews    # reviews for a product
POST /products/sku-12345/reviews    # add a review

# Filtering belongs in query parameters, not the path
GET  /products?category=electronics&in_stock=true&sort=price_asc
GET  /orders?status=pending&user_id=42&from=2026-01-01

HTTP Methods and Their Semantics

HTTP methods have precisely defined semantics. Using them correctly enables caching, browser behaviour, and integrations with proxies and API gateways to work as expected.

GET — Read (Safe + Idempotent)

Retrieves a resource or collection. Must not modify server state. Responses are cacheable by default. Never put sensitive data in GET query parameters — they appear in server logs and browser history.

GET /users/42
# 200 OK
{
  "id": 42,
  "name": "Alice Smith",
  "email": "alice@example.com",
  "created_at": "2026-01-15T10:30:00Z"
}

POST — Create (Not Idempotent)

Creates a new resource in a collection. Returns 201 Created with a Location header pointing to the new resource. Calling POST twice creates two resources.

POST /users
Content-Type: application/json
{
  "name": "Bob Jones",
  "email": "bob@example.com"
}

# 201 Created
# Location: /users/43
{
  "id": 43,
  "name": "Bob Jones",
  "email": "bob@example.com",
  "created_at": "2026-03-26T08:00:00Z"
}

PUT — Replace (Idempotent)

Replaces an entire resource. The client sends the full representation. Calling PUT multiple times with the same body produces the same result. If the resource does not exist, some APIs create it (upsert); others return 404. Be explicit in your documentation.

PATCH — Partial Update (Not Necessarily Idempotent)

Updates part of a resource. Only send the fields you want to change. Use application/merge-patch+json (RFC 7396) for simple field updates, or application/json-patch+json (RFC 6902) for complex operations.

PATCH /users/42
Content-Type: application/json
{
  "name": "Alice Johnson"
}

# 200 OK — returns full updated resource (or 204 No Content)

DELETE — Remove (Idempotent)

Deletes a resource. Returns 204 No Content on success (no body). Subsequent DELETE requests on the same resource should return 404 Not Found or 204 — both are acceptable; be consistent.

HTTP Status Codes: Use Them Correctly

One of the most common API design mistakes is always returning 200 OK with an error payload in the body. HTTP status codes communicate outcome to intermediaries (proxies, caches, monitoring tools) that do not parse your JSON body.

Success Codes

  • 200 OK — request succeeded, response body contains the result.
  • 201 Created — resource created, include Location header.
  • 202 Accepted — async operation accepted, not yet complete. Return a job/task URL to poll.
  • 204 No Content — success, no body (DELETE, some PATCHes).

Client Error Codes (4xx)

  • 400 Bad Request — malformed JSON, missing required fields, validation failure.
  • 401 Unauthorized — authentication required or credentials invalid. Include a WWW-Authenticate header.
  • 403 Forbidden — authenticated but not authorized for this resource.
  • 404 Not Found — resource does not exist.
  • 409 Conflict — resource state conflict (e.g., duplicate email on user creation).
  • 422 Unprocessable Entity — valid JSON, but business logic validation failed.
  • 429 Too Many Requests — rate limit exceeded. Include Retry-After header.

Server Error Codes (5xx)

  • 500 Internal Server Error — unexpected server error. Never expose stack traces.
  • 502 Bad Gateway — upstream service failure.
  • 503 Service Unavailable — server is down or overloaded. Include Retry-After.

Format and Validate Your API Responses

Debug API responses instantly with our free JSON Formatter. Paste raw JSON to format, validate, and inspect nested structures — with clear error messages for malformed JSON.

Open JSON Formatter

API Versioning Strategies

APIs change. Versioning allows you to evolve your API without breaking existing clients. There are four main strategies, each with trade-offs:

1. URL Path Versioning (Most Common)

GET /v1/users/42
GET /v2/users/42

Highly visible, easy to route at the proxy/gateway level, and simple for clients to implement. The downside: URLs are supposed to be stable identifiers. Changing the version creates a technically different URL for the same resource. Despite this philosophical objection, URL versioning is the pragmatic choice for most public APIs. Used by Stripe, Twilio, GitHub, and the majority of production APIs.

2. Query Parameter Versioning

GET /users/42?version=2
GET /users/42?api_version=2026-03-01

The base URL stays clean. However, it requires every request to include the version parameter, and it is easy for clients to accidentally omit it. Used by some internal APIs but rare in public ones.

3. Header Versioning

GET /users/42
Accept: application/vnd.myapi.v2+json
# or
API-Version: 2

Keeps URLs clean and is semantically correct (the URL identifies the resource; the header specifies representation). Harder to test in browsers and harder for junior developers to discover. Used by GitHub's API (via Accept header).

4. Date-Based Versioning

GET /users/42
Stripe-Version: 2026-03-01

Stripe's approach: the API version is a date. Each date snapshot is fully documented. Clients pin to a specific date and receive exactly that behaviour until they explicitly upgrade. Excellent for long-term stability but operationally complex to maintain.

Recommendation: Use URL path versioning (/v1/) for new public APIs. It is the most widely understood, easiest to route, and simplest to document. Support at least the previous major version for 12 months after deprecation.

Pagination, Filtering, and Sorting

Never return unbounded collections. A GET /users endpoint that returns 10 million records will time out and crash your service. Always paginate.

Cursor-Based Pagination (Preferred for Large Datasets)

GET /users?limit=25&cursor=eyJpZCI6NDJ9

# Response:
{
  "data": [...],
  "pagination": {
    "limit": 25,
    "next_cursor": "eyJpZCI6Njd9",
    "has_more": true
  }
}

Cursor pagination is stable: if items are inserted or deleted between pages, clients do not miss records or see duplicates. The cursor is typically a base64-encoded pointer (e.g., the last item's ID or a sort key). This is the correct choice for real-time data streams, social feeds, and any dataset that changes frequently.

Offset-Based Pagination (Simple, Good for Small Datasets)

GET /products?page=3&per_page=20

# Response:
{
  "data": [...],
  "pagination": {
    "page": 3,
    "per_page": 20,
    "total": 1847,
    "total_pages": 93
  }
}

Offset pagination is intuitive and allows jumping to arbitrary pages. The downside: page N is computed as OFFSET (N-1)*per_page LIMIT per_page, which becomes slow on large tables and unstable if rows are inserted/deleted between requests.

Filtering and Sorting

# Filtering via query parameters
GET /orders?status=shipped&customer_id=42&created_after=2026-01-01

# Sorting: use sort= with a field name, prefix - for descending
GET /products?sort=-price            # price descending
GET /products?sort=category,-price   # category ascending, then price descending

# Field selection (sparse fieldsets) — reduce payload size
GET /users/42?fields=id,name,email

Consistent Error Response Format

Every error response should follow the same structure so clients can handle errors programmatically without parsing free-form strings. Adopt RFC 7807 (Problem Details for HTTP APIs) — it is a standard, well-supported by frameworks, and familiar to developers:

# RFC 7807 Problem Details
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type": "https://api.example.com/errors/validation-error",
  "title": "Validation Error",
  "status": 422,
  "detail": "The request body contains invalid data.",
  "instance": "/v1/users",
  "errors": [
    {
      "field": "email",
      "code": "invalid_format",
      "message": "Must be a valid email address."
    },
    {
      "field": "age",
      "code": "out_of_range",
      "message": "Must be between 18 and 120."
    }
  ]
}

Key fields: type (a URI uniquely identifying the error type — can point to documentation), title (human-readable summary, stable across instances), status (HTTP status code, repeated for clients that only parse the body), detail (specific description of this instance), and instance (URI of the specific request that caused the error).

Authentication and Authorization

Use Bearer Tokens (JWT or Opaque)

GET /users/42
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiJ9.abc123

The Authorization header with the Bearer scheme is the standard for REST APIs. Never put tokens in URLs (they end up in server logs). For machine-to-machine (M2M) authentication, use OAuth 2.0 Client Credentials flow. For user-facing APIs, use Authorization Code flow with PKCE.

JWT vs Opaque Tokens

  • JWT (JSON Web Tokens): Self-contained — the server can validate without a database lookup. Ideal for stateless microservices. Downside: cannot be invalidated before expiry without a denylist. Use short expiry (15 min) + refresh tokens. Use our JWT Generator to inspect and test JWTs.
  • Opaque tokens: Random strings that map to a session in the database. Can be instantly revoked. Better for long-lived sessions. Require a database lookup on every request.

API Keys for Service-to-Service

# Preferred: header
X-API-Key: sk_live_abc123...

# Alternative: Bearer
Authorization: Bearer sk_live_abc123...

Scope API keys to the minimum required permissions. Allow clients to create multiple keys (for different services) and rotate them without downtime. Log key usage and alert on anomalous patterns.

Rate Limiting

Rate limiting protects your API from abuse, ensures fair usage, and prevents a single client from degrading service for others. Always implement rate limiting and always communicate limits to clients via response headers.

# Standard rate limit headers (IETF draft standard)
X-RateLimit-Limit: 1000          # requests allowed per window
X-RateLimit-Remaining: 847       # requests remaining in current window
X-RateLimit-Reset: 1743000000    # Unix timestamp when window resets

# When limit is exceeded:
HTTP/1.1 429 Too Many Requests
Retry-After: 47                  # seconds until client can retry
{
  "type": "https://api.example.com/errors/rate-limit-exceeded",
  "title": "Rate Limit Exceeded",
  "status": 429,
  "detail": "You have exceeded the 1000 requests/hour limit.",
  "retry_after": 47
}

Common rate limiting strategies: fixed window (simplest, susceptible to burst at window boundary), sliding window (smoother, prevents bursting), token bucket (allows short bursts, then throttles — used by Stripe, GitHub). For most APIs, a sliding window per API key with different tiers for free vs paid plans is the right choice.

Request and Response Design

Always Return JSON (with Correct Content-Type)

Content-Type: application/json
# or for problem details:
Content-Type: application/problem+json

Use ISO 8601 for Dates and Times

# Always UTC, always ISO 8601
"created_at": "2026-03-26T08:30:00Z"
"expires_at": "2026-04-26T08:30:00Z"

# Never:
"created_at": 1743400000          # Unix timestamp (not human-readable)
"created_at": "03/26/2026"        # ambiguous locale-specific format

Use Consistent Field Naming

Pick one convention and use it everywhere: snake_case (Python, Ruby, most REST APIs), camelCase (JavaScript, Java), or kebab-case (rare in JSON). snake_case is the most common in REST APIs and aligns with JSON:API, GitHub, and Stripe's conventions.

Return the Full Resource After Mutations

After a POST or PATCH, return the full updated resource in the response body. This eliminates the need for an immediate follow-up GET request to see the updated state — cutting round trips in half.

HATEOAS: Hypermedia Controls

HATEOAS (Hypermedia as the Engine of Application State) is the highest level of REST maturity (Richardson Maturity Model Level 3). Responses include links to related actions and resources, making the API self-discoverable.

GET /orders/123
{
  "id": 123,
  "status": "processing",
  "total": 99.99,
  "_links": {
    "self":   { "href": "/v1/orders/123" },
    "cancel": { "href": "/v1/orders/123/cancel", "method": "POST" },
    "items":  { "href": "/v1/orders/123/items" },
    "customer": { "href": "/v1/users/42" }
  }
}

In practice, full HATEOAS is rare in production APIs because it significantly increases response size and implementation complexity. The pragmatic takeaway: include a _links or links object with at minimum a self link, and related resource links where they reduce client-side URL construction.

Documentation: OpenAPI (Swagger)

A REST API without documentation is unusable. OpenAPI 3.x is the industry standard for REST API documentation. Write your OpenAPI spec first (design-first approach), generate server stubs from it, and serve interactive docs with Swagger UI or Redoc.

# Minimal OpenAPI 3.1 example
openapi: 3.1.0
info:
  title: Orders API
  version: 1.0.0
paths:
  /v1/orders/{id}:
    get:
      summary: Get an order
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Order found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '404':
          description: Order not found

Commit the OpenAPI spec to your repository and run contract tests against it in CI. Tools like Spectral can lint your spec against custom style rules. Prism can mock your API from the spec before you write a single line of server code.

API Design Checklist

  • URLs use plural nouns, lowercase, hyphens (no verbs, no trailing slashes)
  • HTTP methods used correctly (GET is safe/idempotent, POST is not idempotent)
  • Correct HTTP status codes (not always 200)
  • Consistent versioning strategy (/v1/ recommended)
  • All collections are paginated (default limit enforced)
  • Filtering and sorting via query parameters
  • RFC 7807 error format with field-level validation details
  • Bearer token authentication via Authorization header
  • Rate limiting with X-RateLimit-* headers and Retry-After
  • ISO 8601 dates, snake_case field names, JSON Content-Type
  • Full resource returned after POST/PATCH
  • OpenAPI 3.x spec committed to the repository
  • HTTPS enforced everywhere, HTTP redirects to HTTPS
  • CORS configured for browser clients

The Bottom Line

Good REST API design is not about following rules for their own sake — every convention exists because it solves a real problem: caching, client compatibility, error handling, or developer experience. Use resources and HTTP methods correctly, version from day one, paginate everything, return consistent error shapes, and document with OpenAPI. Your future self and every developer who integrates with your API will thank you.

Related tools: JSON Formatter (validate API responses), JWT Generator (test authentication tokens), JSON Schema Validator (validate request/response shapes), HTTP Status Codes reference, and more developer guides.