RESTful API Design: Best Practices for Building Web APIs

Learn REST principles, resource naming, HTTP methods, status codes, and best practices. Design clean, maintainable, and scalable RESTful APIs.

published: reading time: 34 min read author: GeekWorkBench

RESTful API Design: Best Practices for Building Web APIs

REST (Representational State Transfer) is the dominant approach for designing web APIs. It provides a structured way to expose resources over HTTP. Understanding REST principles helps you build APIs that are intuitive, scalable, and maintainable.

The HTTP/HTTPS protocol post covers HTTP methods and status codes. This post focuses on applying those concepts to API design.


Introduction

REST is an architectural style, not a specification. It was defined by Roy Fielding in 2000. The key idea: treat everything as a resource that clients can interact with through standard HTTP operations.

REST APIs expose resources through URLs. The HTTP method indicates the action:

graph LR
    A[Client] -->|GET /users| B[Read users]
    A -->|POST /users| C[Create user]
    A -->|PUT /users/123| D[Update user]
    A -->|DELETE /users/123| E[Delete user]

Resource Naming & URL Design

Resource Naming

Resource names are the foundation of REST API design. Good resource names are:

  • Nouns, not verbs - /users not /getUsers
  • Plural - /users not /user
  • Hierarchical - /users/123/orders for users orders
  • Consistent - Same pattern throughout
# Good resource naming
GET /users              # List users
GET /users/123           # Get user 123
GET /users/123/orders   # Get orders for user 123
POST /users             # Create user
PUT /users/123          # Update user 123
DELETE /users/123       # Delete user 123

# Bad naming (mixing verbs and nouns)
GET /getUsers
GET /getUserById/123
POST /createUser

Nested Resources

Nested resources express relationships:

# A user posts
GET /users/123/posts

# A post comments
GET /posts/456/comments

# Limit nesting depth - typically 2 levels is practical
GET /users/123/posts/789/comments  # Too deep

HTTP Methods & Status Codes

Core HTTP Methods

REST APIs use HTTP methods to indicate actions. The primary methods are:

GET

Retrieves resources. GET requests should be safe (no side effects) and idempotent.

GET /users
GET /users/123

POST

Creates new resources. Each POST request typically creates one resource.

POST /users
Content-Type: application/json

{"name": "Alice", "email": "alice@example.com"}

Response includes the created resource and a 201 status code:

HTTP/1.1 201 Created
Location: /users/124

PATCH

Partially updates a resource. Only the specified fields change.

PATCH /users/123
Content-Type: application/json

{"email": "alice.new@example.com"}

DELETE

Removes a resource.

DELETE /users/123

Idempotency Design Patterns

Idempotency ensures that making the same request multiple times produces the same result. This matters because networks fail — clients may retry requests without knowing if the first attempt succeeded.

Safe Retries with Idempotency Keys

For POST requests (which are not naturally idempotent), clients can include an idempotency key:

POST /payments
Idempotency-Key: a1b2c3d4-e5f6-7890-abcd-ef1234567890
Content-Type: application/json

{"amount": 100, "currency": "USD", "source": "card_123"}

The server stores the idempotency key with the response. If the client retries with the same key, the original response is returned without re-processing:

HTTP/1.1 201 Created
Idempotency-Replayed: true
Location: /payments/999

{"id": 999, "status": "completed", "amount": 100}

Implementing Idempotency Storage

Use a key-value store (Redis, DynamoDB) with TTL:

async function processPayment(payment, idempotencyKey) {
  // Check if already processed
  const cached = await redis.get(`idem:${idempotencyKey}`);
  if (cached) {
    return JSON.parse(cached);
  }

  // Process the payment
  const result = await billing.charge(payment);

  // Cache the result for 24 hours
  await redis.setex(`idem:${idempotencyKey}`, 86400, JSON.stringify(result));

  return result;
}

Idempotency by HTTP Method

MethodNatural IdempotencyNotes
GETYesReading a resource never changes it
PUTYesReplacing with the same data is idempotent
DELETEYesDeleting twice returns 404, but resource is gone
PATCHYes (in practice)Same partial update applied twice has same result
POSTNoEach POST creates a new resource; requires idempotency key

Idempotency Key Best Practices

  • Use UUIDs or similar high-entropy keys — never reuse keys for different requests
  • Store keys with enough TTL to cover client retry windows (at least 24 hours)
  • Return 422 if the request body is invalid (don’t process, but also don’t cache errors)
  • Distinguish between “replayed response” (original result) and “new response” (fresh processing)

Status Codes

REST APIs should use appropriate HTTP status codes:

Success Codes

CodeMeaningWhen to use
200OKSuccessful GET, PUT, PATCH
201CreatedSuccessful POST creating resource
204No ContentSuccessful DELETE

Error Codes

CodeMeaningWhen to use
400Bad RequestMalformed request body
401UnauthorizedMissing or invalid authentication
403ForbiddenAuthenticated but not authorized
404Not FoundResource does not exist
409ConflictResource conflict (duplicate, state conflict)
422Unprocessable EntityValidation errors
500Internal Server ErrorUnexpected server error
// Example: Successful response
{
  "id": 123,
  "name": "Alice",
  "email": "alice@example.com"
}

// Example: Error response
{
  "error": "validation_error",
  "message": "Email is required",
  "details": [
    { "field": "email", "message": "This field is required" }
  ]
}

Request & Response Design

Request Parameters

Content Type

Always specify content type for requests with bodies:

POST /users
Content-Type: application/json

Pagination

For endpoints returning lists, use pagination:

GET /users?page=2&limit=20

Response includes pagination metadata:

{
  "data": [...],
  "pagination": {
    "page": 2,
    "limit": 20,
    "total": 100,
    "totalPages": 5
  }
}

Filtering and Sorting

Support filtering and sorting through query parameters:

# Filter by status
GET /orders?status=pending

# Filter by multiple fields
GET /orders?status=shipped&customer_id=456

# Sort by field
GET /users?sort=created_at&order=desc

Field Selection

Let clients request specific fields:

GET /users?fields=id,name,email

HATEOAS Implementation

HATEOAS (Hypermedia As The Engine Of Application State) is a REST constraint where clients interact through hypermedia links included in responses. Instead of clients constructing URLs, they follow links the server provides.

Why HATEOAS?

Without HATEOAS, clients hardcode URL patterns. When your API changes, clients break. With HATEOAS, clients discover actions dynamically:

// Response with hypermedia links
{
  "id": 123,
  "name": "Alice Smith",
  "email": "alice@example.com",
  "_links": {
    "self": { "href": "/users/123" },
    "orders": { "href": "/users/123/orders" },
    "cancel": { "href": "/users/123", "method": "DELETE" }
  }
}

A client encountering this response knows it can navigate to orders or cancel the user without hardcoding those URLs.

Use a consistent link structure:

"_links": {
  "self": { "href": "/users/123" },
  "collection": { "href": "/users" },
  "related": {
    "orders": { "href": "/users/123/orders" },
    "payment-methods": { "href": "/users/123/payment-methods" }
  },
  "actions": {
    "update": { "href": "/users/123", "method": "PUT" },
    "deactivate": { "href": "/users/123/deactivate", "method": "POST" }
  }
}

Practical Implementation

For collections, include pagination links:

{
  "data": [...],
  "_links": {
    "self": { "href": "/users?page=2&limit=20" },
    "first": { "href": "/users?page=1&limit=20" },
    "prev": { "href": "/users?page=1&limit=20" },
    "next": { "href": "/users?page=3&limit=20" },
    "last": { "href": "/users?page=10&limit=20" }
  },
  "pagination": {
    "page": 2,
    "limit": 20,
    "total": 200
  }
}

Not all APIs need HATEOAS. For simple machine-to-machine integrations, explicit URLs are easier to work with. But for API longevity and self-documenting interfaces, hypermedia is worth the effort.


API Versioning & Error Handling

API Versioning

APIs need to evolve without breaking existing clients. Versioning provides a way to introduce changes.

URL Path Versioning

# Most common approach
GET /v1/users
GET /v2/users

Header Versioning

GET /users
Accept: application/vnd.example.v2+json

Query Parameter Versioning

GET /users?version=2

URL path versioning is the most common because it is explicit and easy to test.

The API Versioning Strategies post covers this topic in depth.

Error Handling

Consistent error responses help clients handle failures:

// Standard error format
{
  "error": {
    "code": "RESOURCE_NOT_FOUND",
    "message": "The requested user does not exist",
    "details": {
      "resource": "user",
      "id": 999
    }
  }
}

Include enough information for debugging but do not leak sensitive details.

Handling Validation Errors

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "details": [
      { "field": "email", "message": "Invalid email format" },
      { "field": "name", "message": "Name must be at least 2 characters" }
    ]
  }
}

Authentication & Security

Authentication

API Keys

Simple but limited. Suitable for server-to-server communication.

GET /users
X-API-Key: your-api-key-here

JWT (JSON Web Tokens)

Stateless tokens with embedded user information. Common for modern applications.

GET /users
Authorization: Bearer eyJhbG...s...

OAuth 2.0

For APIs accessed by third-party applications. More complex but more powerful.

GET /user
Authorization: Bearer access...auth

Documentation & Rate Limiting

API Documentation Deep Dive

OpenAPI (formerly Swagger) is the standard for REST API documentation. A well-structured spec enables client generation, testing tools, and interactive documentation.

OpenAPI Document Structure

openapi: 3.1.0
info:
  title: User Management API
  version: 2.0.0
  description: RESTful API for user lifecycle management

paths:
  /users:
    get:
      summary: List users
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            default: 1
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            maximum: 100
      responses:
        "200":
          description: Paginated user list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserList"
    post:
      summary: Create user
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateUser"
      responses:
        "201":
          description: User created
          headers:
            Location:
              schema:
                type: string
              description: URL of created user
        "422":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidationError"

components:
  schemas:
    UserList:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/User"
        pagination:
          $ref: "#/components/schemas/Pagination"
    User:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        email:
          type: string
          format: email
    Pagination:
      type: object
      properties:
        page:
          type: integer
        limit:
          type: integer
        total:
          type: integer
    CreateUser:
      type: object
      required:
        - name
        - email
      properties:
        name:
          type: string
          minLength: 2
        email:
          type: string
          format: email
    ValidationError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
            message:
              type: string
            details:
              type: array
              items:
                type: object
                properties:
                  field:
                    type: string
                  message:
                    type: string

Documentation-First Workflow

The documentation-first approach designs the API contract before writing code:

  1. Write the OpenAPI spec — define endpoints, request/response shapes, error codes
  2. Review with stakeholders — frontend teams, API consumers, product managers
  3. Generate client SDKs — auto-generate libraries for Python, TypeScript, etc.
  4. Implement to spec — code against the agreed contract
  5. Validate — use automated tests to ensure implementation matches spec

This catches design issues before you write a line of implementation code.

Interactive Documentation with Swagger UI

Add Swagger UI as a route in your Express app:

// Express.js example
import swaggerUi from "swagger-ui-express";
import YAML from "yamljs";

const spec = YAML.load("./openapi.yaml");

app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(spec));

Now at https://api.example.com/api-docs, developers can explore endpoints, try requests with live data, and see actual response shapes.


Rate Limiting

Protect your API from abuse by limiting request rates:

HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 999
X-RateLimit-Reset: 1640995200

When clients exceed the limit:

HTTP/1.1 429 Too Many Requests
Retry-After: 3600

Caching Strategies

API Caching Strategies

Caching reduces load, improves latency, and decreases bandwidth costs. REST APIs can leverage HTTP caching primitives for significant gains.

HTTP Caching Primitives

Cache-Control Header

The Cache-Control header tells clients and intermediaries how to cache responses:

Cache-Control: public, max-age=300, stale-while-revalidate=60

Key directives:

  • public — response can be cached by any cache (not just private)
  • private — response is user-specific, only browser can cache
  • max-age=N — cache for N seconds
  • no-cache — always revalidate before using cached copy
  • no-store — never cache (sensitive data)
  • stale-while-revalidate=N — serve stale content while revalidating in background

ETags for Conditional Requests

ETags provide cache validation without re-downloading content:

# First request - server returns ETag
GET /users/123
HTTP/1.1 200 OK
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Content-Type: application/json

{"id": 123, "name": "Alice"}

# Subsequent request - client sends If-None-Match
GET /users/123
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"
HTTP/1.1 304 Not Modified   # No body - use cached version

ETags should change when the resource changes. Use content hashing or version numbers.

HTTP Caching Hierarchy

LayerScopeTypical DurationUse Case
Browser cacheUser-specificMinutes to daysRepeat visits, offline support
CDNSharedMinutes to hoursStatic assets, API responses
Reverse proxySharedConfigurableOrigin protection, cost reduction
Application cacheCustom logicCustomComplex data, session tokens

Cache Invalidation

Cache Invalidation Patterns

Invalidation is harder than caching. Four patterns work well:

1. Time-based expiry — simplest, use max-age:

Cache-Control: public, max-age=3600  # Refresh after 1 hour

2. Event-driven invalidation — purge on updates:

# When user updates profile, invalidate related caches
DELETE /cache/users/123
DELETE /cache/users/123/profile
DELETE /cache/users/123/orders

3. Tag-based invalidation (surrogate keys) — tag responses, purge by tag:

# Response tagged with multiple keys
X-Surrogate-Key: user-123 order-456 product-789
# Purge all resources tagged with user-123
POST /purge
Surrogate-Key: user-123

4. Versioned URLs — change URL when content changes:

GET /users/123?v=2   # Cache bust by adding version

Caching for Different Request Types

Endpoint TypeCache Strategy
Public list endpointsCache-Control: public, max-age=60
User-specific dataCache-Control: private, max-age=300
Real-time dataCache-Control: no-cache (always fresh)
Search resultsCache-Control: private, max-age=60 (often user-specific)
Health/check endpointsCache-Control: no-store (never cache)

Advanced Topics

When to Use REST

REST is the right choice when:

  • You need a simple, well-understood API pattern
  • HTTP caching benefits your use case
  • You have multiple clients (web, mobile, third-party) consuming the same API
  • Stateless request-response fits your data access patterns
  • You want easy documentation and discoverability
  • Standard HTTP infrastructure (CDNs, proxies, caches) helps
  • You are building CRUD-oriented services

When Not to Use REST

REST may not be the best fit when:

  • Clients need different data shapes (overfetching/underfetching issues)
  • You need real-time updates (WebSockets, Server-Sent Events make more sense)
  • Your queries are highly complex and deeply nested (GraphQL may fit better)
  • You need batched operations that span multiple resources
  • Mobile apps with limited bandwidth need precise data fetching
  • Your API is tightly coupled to a single client needs

GraphQL vs REST: Trade-off Deep Dive

When choosing between REST and GraphQL, consider these trade-offs:

FactorRESTGraphQLRecommendation
Data fetchingMultiple endpoints; overfetching commonSingle request; exact fields neededGraphQL wins for complex UIs
CachingHTTP caching (CDN, browser) works naturallyPOST requests; requires custom caching layerREST wins for simple caching needs
API evolutionVersioning adds complexityAdditive changes without versioningGraphQL wins for rapidly evolving APIs
Error handlingStandard HTTP status codes200 OK with errors in body; less explicitREST wins for debuggability
ValidationSchema validation at API boundaryType-safe schema; query validation at parseGraphQL wins for developer experience
MonitoringEndpoint-level metrics straightforwardQuery-level metrics require additional toolingREST wins for operational simplicity
DocumentationOpenAPI/Swagger integrates with many toolsSchema is the documentation; Self-documentingGraphQL wins for discoverability
Client flexibilityFixed response shapes per endpointClients specify needed fieldsGraphQL wins for heterogeneous clients
Batch operationsMultiple requests or custom batch endpointsSingle query with multiple operationsGraphQL wins
Real-timeWebSockets or SSE as separate concernsSubscriptions built into schemaGraphQL wins for real-time

Hybrid Patterns

Many teams use both: REST for simple CRUD and authentication, GraphQL for complex data-fetching UI. This is a valid approach — don’t force uniformity where it doesn’t fit.

Webhook and Event Subscription Design

When REST APIs need to notify clients about events (rather than waiting for clients to poll), webhooks provide a push-based alternative. Designing webhook systems requires different considerations than request-response APIs.

Webhook Architecture

sequenceDiagram
    participant C as Client API
    participant S as Your API
    participant Q as Queue
    participant W as Webhook Handler

    S->>S: Resource changes
    S->>Q: Enqueue event
    Q->>W: Process event
    W->>C: POST webhook payload
    Note over C: Client processes event

Payload Design

Deliver structured, predictable payloads:

{
  "event": "user.created",
  "timestamp": "2026-03-22T10:30:00Z",
  "version": "2024-01",
  "data": {
    "id": "usr_abc123",
    "email": "alice@example.com",
    "created_at": "2026-03-22T10:29:58Z"
  },
  "metadata": {
    "correlation_id": "req_xyz789",
    "delivery_attempt": 1
  }
}

Delivery Guarantees

Webhooks are “at-least-once” by nature — network issues can cause duplicate deliveries. Design for that:

// Idempotent webhook handler
async function handleWebhook(payload, signature) {
  const eventId = payload.metadata?.event_id;

  // Check if already processed (idempotency key)
  const processed = await redis.get(`webhook:${eventId}`);
  if (processed) {
    return { status: "duplicate", acknowledged: true };
  }

  // Process the event
  await processEvent(payload);

  // Mark as processed with 7-day TTL
  await redis.setex(`webhook:${eventId}`, 604800, "processed");

  return { status: "processed", acknowledged: true };
}

Signature Verification

Always verify webhook signatures to prevent spoofing:

const crypto = require("crypto");

function verifyWebhookSignature(payload, signature, secret) {
  const expectedSignature = crypto
    .createHmac("sha256", secret)
    .update(payload)
    .digest("hex");

  // Use timing-safe comparison to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(`sha256=${expectedSignature}`),
  );
}

Retry Strategy

When delivery fails, retry with exponential backoff:

AttemptDelay
1Immediate
25 minutes
330 minutes
42 hours
58 hours

After exhausting retries, log the failure and alert operators. Dead letter queues let you replay later.

Webhook Best Practices

  • Send minimal required data — include enough to identify the resource, let clients fetch full data if needed
  • Use stable event IDs — clients use them for deduplication
  • Version your payloads — breaking changes require version bumps
  • Provide test endpoint — let clients configure a test URL that echoes back payloads
  • Include delivery metadata — timestamps, attempt numbers, correlation IDs for debugging

Trade-off Analysis

REST Trade-offs Summary

Designing REST APIs involves navigating several inherent trade-offs. Understanding these helps you make informed decisions for your specific use case.

Statelessness vs. Session State

AspectStatelessStateful (with sessions)
ScalabilityEasy horizontal scaling — any server handles any requestSessions must be shared or sticky; harder to scale
ComplexityEach request contains all contextServer manages session lifecycle
ReliabilitySingle request failure doesn’t corrupt session stateSession loss can corrupt client state
PerformanceHigher latency — repeated auth/token validationLower latency — session context pre-loaded

Stateless REST APIs scale effortlessly but push complexity to clients. Stateful APIs reduce client complexity but introduce session management overhead.

Uniform Interface vs. Domain-Specific Optimisation

REST’s uniform interface (standard methods, status codes, link formats) enables generality — any client can consume any REST API. But it can feel constraining when your domain has unique access patterns:

  • REST fits: CRUD-heavy resources, standard resource hierarchies, cacheable data
  • REST strains: Complex multi-step workflows, domain-specific batch operations, real-time streams

Visibility vs. Efficiency

REST’s emphasis on explicit headers, standard methods, and cacheable responses makes intercepting proxies and monitoring easy. But this visibility comes at a cost — HTTP headers add overhead, and the request-response cycle can’t be optimised as tightly as binary protocols.

Versioning Strategies Trade-offs

StrategyProsCons
URL path (/v1/users)Simple routing, easy to test, CDN-friendlyURL changes on every version bump
Header (Accept: vnd.api.v1+json)Clean URLs, no URL pollutionHard to test in browser, complex routing
Query param (?version=1)Non-invasive, easy to addCaching issues, often ignored by clients

Pagination Trade-offs

TypeProsCons
Offset (?page=2&limit=20)Simple, user can jump to any pageInconsistent with live data (rows shift), slow on large tables
Cursor (?cursor=abc123)Consistent under concurrent inserts, constant-timeOpaque, harder for users to navigate directly
Keyset (?since_id=123)Very fast, great for “newer than X”Limited to sorted-by-id use cases

Production Failure Scenarios

FailureImpactMitigation
Missing paginationLarge datasets cause timeout or memory issuesImplement cursor or offset pagination; set reasonable limits
No rate limitingAPI can be overwhelmed; DoS vulnerabilityImplement rate limiting per client; return 429 with Retry-After
Inconsistent error responsesClients cannot handle errors gracefullyStandardize error format; document all error codes
Version not implementedBreaking changes affect existing clientsVersion from day one; support multiple versions during transition
N+1 query problemDatabase overwhelmed with queriesUse eager loading; batch related queries; optimize data fetching
Unbounded queriesComplex queries slow or crash databaseSet query complexity limits; implement query timeouts
Missing authenticationUnauthorized access to sensitive dataImplement authentication on all endpoints; verify permissions
Insufficient validationInvalid data corrupts databaseValidate all input; use schema validation; return 400 for bad data

Observability Checklist

Metrics

  • Request rate by endpoint, method, and client
  • Response time distribution (p50, p95, p99) per endpoint
  • Error rate by status code and endpoint
  • Active connections or concurrent requests
  • Authentication failures (attempted unauthorized access)
  • Rate limit hits (429 responses)
  • Payload size (request and response)
  • Cache hit ratio (if using caching)

Logs

  • All requests with request ID, method, path, status, duration
  • Authentication and authorization failures with attempted credentials
  • Validation errors with field-level details
  • Database query times for slow queries (> 100ms)
  • Rate limiting events with client identifier
  • Error stack traces with correlation IDs

Alerts

  • Error rate exceeds 1% for 5 minutes
  • p99 latency exceeds 2 seconds
  • Authentication failures spike (potential attack)
  • Rate limit hits exceed normal threshold
  • Unusual request size or pattern
  • Database query times increasing
  • Memory or CPU usage on API servers

Security Checklist

  • Implement authentication on every protected endpoint
  • Use authorization to verify permissions (not just authentication)
  • Validate and sanitize all input data
  • Use HTTPS for all API endpoints
  • Set appropriate timeouts for all requests
  • Implement rate limiting to prevent abuse
  • Log security events (auth failures, suspicious patterns)
  • Return minimal information in errors (do not leak internal details)
  • Use secure, HttpOnly, SameSite cookies for sessions
  • Implement CORS properly for cross-origin requests
  • Protect against injection attacks (SQL, NoSQL)
  • Validate Content-Type matches expected format
  • Implement HEAD and OPTIONS method security
  • Use API keys or JWT tokens, not passwords in URLs

Common Pitfalls / Anti-Patterns

Using Verbs in URLs

REST uses nouns for resources, not verbs for actions.

# WRONG - verbs in URLs
GET /api/getUsers
POST /api/createUser
POST /api/deleteUser

# CORRECT - nouns and HTTP methods
GET /api/users
POST /api/users
DELETE /api/users/123

Returning 200 for Errors

Always use appropriate status codes for errors.

# WRONG - 200 for error
HTTP/1.1 200 OK
{"error": "Not found"}

# CORRECT - proper status code
HTTP/1.1 404 Not Found
{"error": "User not found"}

Ignoring Pagination

Never return unbounded lists.

# WRONG - unbounded response
GET /api/users
# Could return millions of users

# CORRECT - paginated response
GET /api/users?page=1&limit=20
# Returns: {"data": [...], "pagination": {"page": 1, "limit": 20, "total": 1000}}

Not Implementing Versioning

APIs need to evolve without breaking clients.

# Version in URL path (most explicit)
GET /v1/users
GET /v2/users

# Version in header (cleaner URLs, harder to test)
GET /users
Accept: application/vnd.example.v2+json

Exposing Internal Details in Errors

Keep error responses informative but not leaky.

// WRONG - exposing internal details
{
  "error": "Database connection failed: mysql://user:pass@host:3306/db"
}

// CORRECT - safe error response
{
  "error": {
    "code": "INTERNAL_ERROR",
    "message": "An unexpected error occurred"
  }
}

Key Points

RESTful API design comes down to a few core principles. Use nouns for resource names, not verbs. Use plural names consistently. Apply HTTP methods correctly and use the right status codes. Paginate list endpoints. Format errors consistently. Version your API from the start. Secure endpoints with appropriate authentication. Implement rate limiting. Document everything with OpenAPI. Make incremental changes carefully.


Key Bullets

  • REST uses nouns for resources (urls), not verbs (actions are HTTP methods)
  • Use plural names consistently: /users not /user
  • Use appropriate HTTP methods: GET (read), POST (create), PUT (replace), PATCH (modify), DELETE (remove)
  • Return proper status codes: 2xx for success, 4xx for client errors, 5xx for server errors
  • Implement pagination for list endpoints
  • Standardize error response format across the API
  • Version your API from the start to allow evolution
  • Secure endpoints with authentication and authorization
  • Implement rate limiting to protect against abuse
  • Validate all input and return 400 for invalid data

Copy/Paste Checklist

# Test REST endpoint with curl
curl -X GET "https://api.example.com/users?page=1&limit=10" \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." \
  -H "Accept: application/json"

# Create resource with POST
curl -X POST "https://api.example.com/users" \
  -H "Content-Type: application/json" \
  -d '{"name":"Alice","email":"alice@example.com"}'

# Update resource with PATCH
curl -X PATCH "https://api.example.com/users/123" \
  -H "Content-Type: application/json" \
  -d '{"email":"alice.new@example.com"}'

# Delete resource
curl -X DELETE "https://api.example.com/users/123" \
  -H "Authorization: Bearer ..."

# Check API version (if using header versioning)
curl -X GET "https://api.example.com/users" \
  -H "Accept: application/vnd.example.v2+json"

Quick Recap Checklist

Use this checklist when designing or reviewing a REST API:

Resource Design

  • Resources use nouns, not verbs (/users not /getUsers)
  • Resource names are plural consistently (/users not /user)
  • Nested resources express relationships (/users/123/orders)
  • Nesting depth is limited (max 2 levels typically)

HTTP Semantics

  • GET is safe and idempotent (no side effects)
  • POST creates new resources and returns 201 with Location header
  • PUT replaces entire resources (idempotent)
  • PATCH updates only specified fields (partial update)
  • DELETE removes resources (idempotent)
  • DELETE returns 204 on success, 404 if already gone

Response Status Codes

  • 200 for successful GET, PUT, PATCH
  • 201 for successful POST creating a resource
  • 204 for successful DELETE (no body)
  • 400 for malformed requests
  • 401 for missing authentication
  • 403 for authenticated but unauthorized
  • 404 for resources that don’t exist
  • 422 for validation errors
  • 429 when rate limited

Request/Response

  • Pagination on all list endpoints (?page=N&limit=N)
  • Filtering and sorting via query parameters
  • Consistent error response format with machine-readable codes
  • Content-Type header specified for request bodies
  • Location header on 201 responses

Security & Performance

  • HTTPS on all endpoints
  • Authentication on all protected endpoints
  • Rate limiting implemented (X-RateLimit headers)
  • Input validation on all endpoints
  • Cache-Control appropriate for resource type
  • ETag on mutable resources for conditional requests

Documentation

  • OpenAPI spec defined before implementation
  • All error codes documented
  • Versioning strategy defined from day one

Interview Questions

Fundamentals

1. What is the difference between PUT and PATCH HTTP methods in REST API design?

PUT replaces an entire resource — send all fields, even the ones you don't want to change. PATCH only updates the fields you actually send. The practical difference: PUT is idempotent (send it twice, same result), PATCH technically isn't but in practice you get the same final state either way.

2. When would you return HTTP 201 instead of HTTP 200?

Use 201 when a POST created something new. The distinction matters: 200 is for requests that succeeded without creating a resource (GET, PUT, DELETE, successful PATCH). 201 tells the client "we made something" and should include a Location header pointing to the new resource.

3. What is HATEOAS and why is it considered a REST constraint?

HATEOAS means responses include links to related actions — a user response tells you about their orders, a payment tells you about the transaction. Clients discover actions by following links rather than hardcoding URLs. This is what Fielding meant by "hypermedia as the engine of application state." It decouples clients from your URL structure so you can change URLs without breaking existing integrations.

4. How do you handle pagination in a REST API?

Query parameters for page and size (e.g., `?page=2&limit=20`), with metadata returned alongside the data — current page, total count, total pages. Offset pagination works fine at small scale. Once you're dealing with millions of rows, cursor-based pagination (opaque cursor instead of page number) is faster and handles real-time data better — inserted rows don't create duplicates or gaps.

Design & Architecture

5. What are the trade-offs between URL path versioning and header versioning for APIs?

URL path versioning (`/v1/users`) is straightforward — visible in the address bar, easy to route at the CDN or API gateway level, simple to test. Downside is URLs that change when versions change. Header versioning keeps URLs clean but you lose visibility (can't test in a browser), routing gets more complex, and caching becomes awkward. In practice, URL path versioning wins because it trades a bit of URL ugliness for operational simplicity.

6. How would you design an idempotent payment API endpoint?

Accept an `Idempotency-Key` header on POST requests — clients generate a UUID per payment attempt. Store the key with the response in Redis or DynamoDB with a TTL (at least 24 hours). When a request comes in with a known key, return the cached response without re-processing. This means a client can safely retry without charging twice — the network fails, the client retries, you don't double-charge.

7. When would you choose GraphQL over REST for an API?

GraphQL earns its place when you have diverse clients that need different data shapes — a mobile app wanting minimal payloads, a web dashboard wanting everything, a public API with many consumers. The query language is expressive enough that clients can ask for exactly what they need in a single request. REST makes sense when you want straightforward HTTP caching, have simple CRUD operations, or need operational simplicity with off-the-shelf monitoring.

8. How do you prevent N+1 query problems in a REST API?

N+1 happens when fetching a list of resources triggers one query per item for related data. Solutions: eager load with JOINs or batch queries, use a dataloader to batch and cache lookups within a single request, or denormalize related data into the resource. If you're fetching users with orders, don't query users then loop through querying orders — load orders for all users in one query upfront.

9. What information should a REST API error response include?

Three things: a machine-readable error code (VALIDATION_ERROR, NOT_FOUND), a human-readable message, and field-level detail for validation errors. Include a request ID for correlation in logs. Do not leak internal details — no stack traces, database connection strings, or internal URLs. Consistency matters more than completeness: clients need a predictable structure so they can handle errors uniformly.

10. How would you design a REST API endpoint for searching across multiple resource types?

Three common approaches: dedicated search endpoint (`GET /search?q=...`), query parameter on a proxy resource (`GET /resources?type=user&q=...`), or a POST-based search for complex queries. The search endpoint should return standardized result objects with type indicators so clients can render appropriately. Include pagination since search can return large result sets. Consider using a query parameter to filter by resource type if combining results.

Advanced & Production

11. How would you implement webhook signature verification?

Compute an HMAC-SHA256 of the raw request body using a shared secret, then compare with the signature in the `X-Webhook-Signature` header using a timing-safe comparison to prevent timing attacks. If they match, the payload is authentic. Any missing or mismatched signature gets rejected.

12. How do ETags work and why are they useful?

An ETag is a hash or version identifier for a resource's current state. Server sends it with responses. Client stores it and sends it back on subsequent requests via `If-None-Match`. If the resource hasn't changed, server responds with 304 Not Modified — no body, client uses its cached copy. This saves bandwidth and cuts latency on unchanged resources.

13. Describe a cache invalidation strategy for a REST API serving user-specific data.

User-specific data gets `Cache-Control: private, max-age=N` — browsers can cache it, CDNs cannot. On updates, you have options: tag-based invalidation (tag responses with user ID, purge by tag), explicit invalidation (delete cache entries when user data changes), or short TTL with stale-while-revalidate (serve stale content while fetching fresh in the background). Time-based expiry is simplest; event-driven invalidation requires more infrastructure.

14. How do you handle partial success in batch operations in a REST API?

Batch operations are tricky because some items may succeed while others fail. Options: return 200 with a result array showing each item's status (`{results: [{id: 1, status: "created"}, {id: 2, error: "validation_failed"}]}`); or use 207 Multi-Status when different items have different status codes. Include enough detail per item so clients can retry failures. Consider whether batch is the right design — sometimes multiple individual calls with proper error handling is cleaner than trying to handle partial success.

15. How would you design an API for file uploads in a RESTful manner?

Use POST to a resource endpoint with `Content-Type: multipart/form-data`. The response should include an identifier to retrieve the file later (`GET /files/{id}`). For large files, consider: multipart upload with chunking for resumability, or pre-signed URLs to bypass your server entirely (client uploads directly to S3/GCS). Set reasonable size limits (enforced with 413 Payload Too Large), validate content types, and scan for malware. Store metadata separately from the file itself so you can query efficiently.

16. What are the advantages and disadvantages of using JSON Patch (RFC 6902) vs simple PATCH with JSON?

JSON Patch (`Content-Type: application/json-patch+json`) uses a structured format with operations: `[{"op": "replace", "path": "/email", "value": "new@example.com"}]`. Simple PATCH sends partial JSON directly. JSON Patch is explicit, ordered, and supports add/remove/test operations — better for complex partial updates, concurrent modification testing (test op), and when you need to distinguish between setting null vs omitting a field. Simple PATCH is more intuitive and human-readable for basic partial updates. Both are valid PATCH approaches — pick based on complexity needs.

17. What is the Richardson Maturity Model and how does it relate to REST API design?

The Richardson Maturity Model (RMM) is a way to classify how RESTful an API is across four levels. Level 0: one endpoint, SOAP-like (all operations through POST to a single URL). Level 1: multiple endpoints but only POST. Level 2: multiple endpoints with proper HTTP methods (GET, POST, PUT, DELETE). Level 3: full HATEOAS with hypermedia links in responses. Most "REST APIs" people use today sit at Level 2 — they use HTTP verbs correctly but skip hypermedia. True REST (Fielding's definition) requires Level 3. The RMM is a useful framework for evaluating how closely you're following REST principles.

18. What are the trade-offs between using HTTP caching headers (Cache-Control, ETag) vs application-level caching in REST APIs?

HTTP caching leverages existing infrastructure — CDNs, proxies, browsers all understand Cache-Control and ETags. It's zero-setup at the application level and works across all clients automatically. Application-level caching gives you more control — you can cache complex query results, user-specific data that shouldn't go through HTTP layers, or response transformations. HTTP caching fails when responses are personalized (use `private`), when you need cache invalidation tied to business events (not just time), or when you're behind a CDN that ignores cache headers. Best practice: use HTTP caching for public, static, or shareable resources; application caching for everything else.

19. How do you handle API backwards compatibility when adding new fields to response payloads?

Adding new fields to responses is backwards-compatible — clients that don't expect the field simply ignore it. This is why REST versioning focuses on breaking changes, not additive ones. Rules: never remove fields (breaking), never rename fields (breaking), never change field types (breaking), never change semantics (breaking). Additive changes are safe: new optional fields, new endpoints, new response properties. When you need to make breaking changes, introduce a new API version. If you need to remove a field, deprecate it first (add a `Deprecation` header, maybe add a warning in the response), then remove in a future version.

20. What is the difference between synchronous and asynchronous REST API patterns, and when would you use each?

Synchronous REST: client sends request, waits for response, connection held open. This is the default pattern and works for fast operations where you need the result immediately. Asynchronous REST: server accepts the request with 202 Accepted, returns a job/resource ID, client polls or uses webhook for the result. Use async when operations take longer than a few seconds (file processing, report generation, external API calls), when you need to decouple client from server processing time, or when the operation might fail after the client disconnects. Implement async with a job endpoint (`POST /reports` returns 202 with `Location: /reports/123/status`), status endpoint, and optional webhook callback.


Further Reading

RESTful API design comes down to applying HTTP conventions consistently. Resources are nouns, actions are HTTP methods, and responses use standard status codes. Good naming, proper error handling, and thoughtful versioning create APIs that developers actually want to use.

For comparing REST with GraphQL, see the GraphQL vs REST post. For API versioning details, see the API Versioning Strategies post.


Conclusion

REST remains the foundation of modern web API design. The principles established by Fielding — resources, uniform interface, statelessness, and hypermedia — provide a durable framework that scales from simple CRUD services to complex distributed systems. Success with REST comes from disciplined application of HTTP conventions: meaningful status codes, consistent resource naming, proper error formatting, and thoughtful versioning strategy from the start.

Design decisions made at the API boundary ripple through every client and service that depends on it. Investing in clean resource models, idempotent operations, and comprehensive error handling pays dividends across the lifetime of your API. Use the checklists and trade-off analysis in this guide as a starting point, then adapt to your specific domain constraints.

Category

Related Posts

GraphQL vs REST: Choosing the Right API Paradigm

Compare GraphQL and REST APIs, understand when to use each approach, schema design, queries, mutations, and trade-offs between the two paradigms.

#api #graphql #rest

API Versioning: Managing Change Without Breaking Clients

Learn API versioning strategies: URL path, header, and query parameter approaches. Understand backward compatibility, deprecation practices, and migration patterns.

#api #versioning #rest

Rate Limiting: Token Bucket, Sliding Window, and Distributed Systems

Rate limiting protects APIs from abuse. Learn token bucket, sliding window, fixed window algorithms and distributed rate limiting at scale.

#api #rate-limiting #architecture