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.
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 -
/usersnot/getUsers - Plural -
/usersnot/user - Hierarchical -
/users/123/ordersfor 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
| Method | Natural Idempotency | Notes |
|---|---|---|
| GET | Yes | Reading a resource never changes it |
| PUT | Yes | Replacing with the same data is idempotent |
| DELETE | Yes | Deleting twice returns 404, but resource is gone |
| PATCH | Yes (in practice) | Same partial update applied twice has same result |
| POST | No | Each 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
| Code | Meaning | When to use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST creating resource |
| 204 | No Content | Successful DELETE |
Error Codes
| Code | Meaning | When to use |
|---|---|---|
| 400 | Bad Request | Malformed request body |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Authenticated but not authorized |
| 404 | Not Found | Resource does not exist |
| 409 | Conflict | Resource conflict (duplicate, state conflict) |
| 422 | Unprocessable Entity | Validation errors |
| 500 | Internal Server Error | Unexpected 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.
Designing Hypermedia Links
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:
- Write the OpenAPI spec — define endpoints, request/response shapes, error codes
- Review with stakeholders — frontend teams, API consumers, product managers
- Generate client SDKs — auto-generate libraries for Python, TypeScript, etc.
- Implement to spec — code against the agreed contract
- 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 cachemax-age=N— cache for N secondsno-cache— always revalidate before using cached copyno-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
| Layer | Scope | Typical Duration | Use Case |
|---|---|---|---|
| Browser cache | User-specific | Minutes to days | Repeat visits, offline support |
| CDN | Shared | Minutes to hours | Static assets, API responses |
| Reverse proxy | Shared | Configurable | Origin protection, cost reduction |
| Application cache | Custom logic | Custom | Complex 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 Type | Cache Strategy |
|---|---|
| Public list endpoints | Cache-Control: public, max-age=60 |
| User-specific data | Cache-Control: private, max-age=300 |
| Real-time data | Cache-Control: no-cache (always fresh) |
| Search results | Cache-Control: private, max-age=60 (often user-specific) |
| Health/check endpoints | Cache-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:
| Factor | REST | GraphQL | Recommendation |
|---|---|---|---|
| Data fetching | Multiple endpoints; overfetching common | Single request; exact fields needed | GraphQL wins for complex UIs |
| Caching | HTTP caching (CDN, browser) works naturally | POST requests; requires custom caching layer | REST wins for simple caching needs |
| API evolution | Versioning adds complexity | Additive changes without versioning | GraphQL wins for rapidly evolving APIs |
| Error handling | Standard HTTP status codes | 200 OK with errors in body; less explicit | REST wins for debuggability |
| Validation | Schema validation at API boundary | Type-safe schema; query validation at parse | GraphQL wins for developer experience |
| Monitoring | Endpoint-level metrics straightforward | Query-level metrics require additional tooling | REST wins for operational simplicity |
| Documentation | OpenAPI/Swagger integrates with many tools | Schema is the documentation; Self-documenting | GraphQL wins for discoverability |
| Client flexibility | Fixed response shapes per endpoint | Clients specify needed fields | GraphQL wins for heterogeneous clients |
| Batch operations | Multiple requests or custom batch endpoints | Single query with multiple operations | GraphQL wins |
| Real-time | WebSockets or SSE as separate concerns | Subscriptions built into schema | GraphQL 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:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 8 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
| Aspect | Stateless | Stateful (with sessions) |
|---|---|---|
| Scalability | Easy horizontal scaling — any server handles any request | Sessions must be shared or sticky; harder to scale |
| Complexity | Each request contains all context | Server manages session lifecycle |
| Reliability | Single request failure doesn’t corrupt session state | Session loss can corrupt client state |
| Performance | Higher latency — repeated auth/token validation | Lower 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
| Strategy | Pros | Cons |
|---|---|---|
URL path (/v1/users) | Simple routing, easy to test, CDN-friendly | URL changes on every version bump |
Header (Accept: vnd.api.v1+json) | Clean URLs, no URL pollution | Hard to test in browser, complex routing |
Query param (?version=1) | Non-invasive, easy to add | Caching issues, often ignored by clients |
Pagination Trade-offs
| Type | Pros | Cons |
|---|---|---|
Offset (?page=2&limit=20) | Simple, user can jump to any page | Inconsistent with live data (rows shift), slow on large tables |
Cursor (?cursor=abc123) | Consistent under concurrent inserts, constant-time | Opaque, 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
| Failure | Impact | Mitigation |
|---|---|---|
| Missing pagination | Large datasets cause timeout or memory issues | Implement cursor or offset pagination; set reasonable limits |
| No rate limiting | API can be overwhelmed; DoS vulnerability | Implement rate limiting per client; return 429 with Retry-After |
| Inconsistent error responses | Clients cannot handle errors gracefully | Standardize error format; document all error codes |
| Version not implemented | Breaking changes affect existing clients | Version from day one; support multiple versions during transition |
| N+1 query problem | Database overwhelmed with queries | Use eager loading; batch related queries; optimize data fetching |
| Unbounded queries | Complex queries slow or crash database | Set query complexity limits; implement query timeouts |
| Missing authentication | Unauthorized access to sensitive data | Implement authentication on all endpoints; verify permissions |
| Insufficient validation | Invalid data corrupts database | Validate 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:
/usersnot/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 (
/usersnot/getUsers) - Resource names are plural consistently (
/usersnot/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
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.
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.
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.
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
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 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.
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.