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.
REST API design starts with resources and HTTP semantics, then carries through errors, versioning, authentication, caching, and webhooks. This guide compares REST with GraphQL, explains choices such as cursor pagination and idempotency keys, and shows how OpenAPI contracts, rate limits, and observability affect production APIs. Use its examples and checklists to review an API before clients rely on it.
RESTful API Design: Best Practices for Building Web APIs
Introduction
A REST API gives clients a predictable way to work with resources over HTTP. Its contract depends on more than routes: method semantics, status codes, request and response shapes, authentication, and compatibility rules all affect how safely clients can use it.
This guide covers resource design, errors, versioning, pagination, caching, security, and event subscriptions. It also walks through common production failures and trade-offs so you can choose patterns that fit your clients and operational needs.
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 | Depends | Setting values can be idempotent; increments may not be |
| 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
REST itself does not prescribe an authentication mechanism. The protocol is method-agnostic about who is calling and whether they are allowed to. This choice sits with the API designer, and it is one of the highest-stakes decisions in the system: get it wrong and you retrofit pain for years.
Authentication answers the question “who are you?” Authorization answers “what are you allowed to do?” These are different questions, but many APIs conflate them. An authenticated user can still be forbidden from specific actions. A missing token returns 401. A present but insufficient token returns 403.
Three patterns cover most real-world cases: API keys, JWTs, and OAuth 2.0. They are not mutually exclusive — a single API often uses API keys for partner integrations, JWTs for first-party mobile clients, and OAuth 2.0 for delegated access on behalf of end users. Pick the simplest scheme that meets the trust boundary. Authenticate protected endpoints and enforce authorization at resource and action boundaries. Public read routes can remain unauthenticated when their data and abuse controls permit it; define that policy explicitly rather than treating authentication as a performance toggle.
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 de facto standard for REST API documentation. A well-structured spec enables client generation, testing tools, and interactive documentation.
A good OpenAPI document does more than describe endpoints. It is the single source of truth from which you generate typed client SDKs in multiple languages, contract tests that catch breaking changes before release, mocks for frontend developers, and reference docs that stay in lockstep with the running service. Treat the spec as a build artifact, not a hand-maintained doc — if a route exists in code but not in the spec, the spec is the bug. The sub-sections below cover the structure of an OpenAPI document, the documentation-first workflow, and how to wire up Swagger UI for interactive exploration.
The key shift: treat the OpenAPI spec as a contract you define before writing code, not documentation you write after the fact. That contract then drives implementation, tests, and client generation. When teams treat it as optional documentation tacked on at the end, they end up with a spec that describes what the API does rather than what it should do — by then, fixing mismatches is already expensive.
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 cuts bandwidth costs. REST APIs sit on top of HTTP, which has a complete cache model built in — status codes, headers, validators, and freshness directives. A read-heavy API that skips these primitives leaves significant performance gains on the table for a few lines of header configuration.
Three layers are worth thinking about separately. HTTP caching is what the spec calls for: cache-control directives, ETags, conditional requests, and the Vary header. CDN caching sits in front of the origin and serves cached responses from edge locations close to users. Application caching lives inside the service, typically in Redis or Memcached, and handles data that the HTTP layer cannot represent cleanly. The sub-sections below cover HTTP caching primitives in depth, the harder problem of cache invalidation, and where to apply which layer.
Choosing where to cache depends on who needs the data. User-specific reads like profiles and orders stay in the browser or a private CDN edge. Public shared data like product catalogs belongs at the CDN and in browsers. Complex computed data that changes often (search results, aggregated dashboards) belongs in application cache where you control invalidation. These layers are not mutually exclusive — the same response might flow through all three at once, with each layer handling a different scope.
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) |
Choosing an API Style
When to Use REST
REST is the right choice when you need a simple, well-understood API pattern, HTTP caching helps, or you have multiple clients consuming the same API. It works well when stateless request-response fits your data access patterns, you want easy documentation and discoverability, standard HTTP infrastructure would help, or you’re building CRUD-oriented services.
When Not to Use REST
REST may not be the best fit when clients need different data shapes, you need real-time updates (WebSockets, Server-Sent Events make more sense), your queries are highly complex and deeply nested, you need batched operations that span multiple resources, mobile apps with limited bandwidth need precise data fetching, or your API is tightly coupled to a single client’s 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
Observability in REST APIs is the ability to answer questions about system behavior from the outside, using only the signals your service emits. Unlike a monolith where you can attach a debugger to a single process, a REST API sits in a chain of clients, gateways, upstream services, and databases. When something goes wrong, you need to know which request failed, why it failed, and which downstream system was responsible, and you need to know it from logs that already exist.
For REST APIs specifically, observability rests on three pillars: metrics (numeric time-series that show aggregate behavior), logs (discrete event records for individual requests), and traces (causality chains that follow a request across services). Each pillar answers a different kind of question. Metrics answer “is something wrong right now?”: error rates, p99 latency, request volume. Logs answer “what happened to this specific request?”: the actual request, the user, the stack trace. Traces answer “where did the time go across the call chain?”: which downstream service introduced the latency.
The checklist below is the minimum bar for a production REST API, split into metrics, logs, and alerts. Build the signal collection into the request pipeline from day one, with a middleware that emits these signals on every request before the route handler runs. APIs that bolt this on after launch tend to leave the hardest incidents underdocumented, because the requests that fail in interesting ways are the ones no one remembered to instrument.
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 safe client or subject IDs and reason codes; never log credential values
- Validation errors with field names and codes, without raw submitted values
- Database query times for slow queries (> 100ms)
- Rate limiting events with client identifier
- Error stack traces with correlation IDs
Traces
- Propagate trace context through the gateway, route handler, database client, and outbound service calls.
- Add spans for authentication, validation, database work, and external requests; include the route template, status, duration, and retry count.
- Keep credentials, raw request bodies, and personal data out of span attributes.
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
Compliance considerations
- Classify the data each endpoint returns, collect only what clients need, and define retention and deletion behavior for API logs and stored records.
- Keep an auditable record of access to sensitive data, with access controls and retention periods that match the applicable policy.
- Document where data is processed and stored, and apply the relevant regional or contractual restrictions to requests and backups.
- Map controls to the regulations and agreements that apply to the service; a generic security checklist does not establish compliance on its own.
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"
}
}
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
?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
/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.
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.
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
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.
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.
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.
{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.
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.
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.
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.
Deprecation header, maybe add a warning in the response), then remove in a future version.
POST /reports returns 202 with Location: /reports/123/status), status endpoint, and optional webhook callback.
Further Reading
- Resource Names, Relationships, and Collections — model resource paths and their relationships.
- API Contracts and Consumer Expectations — make interface behavior explicit for consumers.
- GraphQL vs REST — compare REST with a query-based API style.
- API Versioning Strategies — plan compatible changes and migration windows.
- RFC 9110: HTTP Semantics — authoritative definitions for HTTP methods, status codes, and fields.
- Roy Fielding’s REST dissertation — the architectural constraints behind REST.
Conclusion
REST remains the foundation of modern web API design. The principles Fielding defined — resources, uniform interface, statelessness, and hypermedia — provide a 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 a versioning strategy you commit to from day one.
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 by comparing URL paths, headers, and query parameters, then plan compatible changes, deprecation, and consumer migrations.
Rate Limiting: Algorithms for Distributed Systems
Rate limiting protects APIs from abuse. Learn token bucket, sliding window, fixed window algorithms and distributed rate limiting at scale.