REST API Design: The Complete Guide for 2026
Table of Contents
- 1. What is REST and RESTful API Design
- 2. HTTP Methods
- 3. URL Structure and Naming Conventions
- 4. Request and Response Formats
- 5. HTTP Status Codes
- 6. Authentication and Authorization
- 7. Pagination
- 8. Filtering, Sorting, and Field Selection
- 9. Versioning Strategies
- 10. Error Handling
- 11. Rate Limiting and Throttling
- 12. HATEOAS and Hypermedia
- 13. Caching Strategies
- 14. Security Best Practices
- 15. API Documentation with OpenAPI
- 16. GraphQL vs REST
- 17. Real-World API Design Examples
- 18. Testing REST APIs
- Frequently Asked Questions
- Conclusion
1. What is REST and RESTful API Design
REST stands for Representational State Transfer. It is an architectural style for designing networked applications, defined by Roy Fielding in his 2000 doctoral dissertation at UC Irvine. REST is not a protocol, not a standard, and not a specification — it is a set of constraints that, when followed, produce systems that are scalable, reliable, and easy to evolve.
An API that follows REST constraints is called a RESTful API. In practice, a RESTful API uses HTTP as its transport protocol, represents data as resources identified by URLs, and uses standard HTTP methods to perform operations on those resources.
The Six REST Constraints
- Client-Server Separation — The client and server are independent. The client does not know how data is stored. The server does not know how data is displayed. This separation allows each to evolve independently.
- Statelessness — Every request from client to server must contain all the information needed to understand and process the request. The server does not store client session state between requests. Authentication tokens, pagination cursors, and filters must be sent with every request.
- Cacheability — Responses must define themselves as cacheable or non-cacheable. When a response is cacheable, the client (or intermediary) can reuse that response for identical future requests, reducing load and improving performance.
- Uniform Interface — All resources are accessed through a consistent, standardized interface. This is the most fundamental constraint and includes four sub-constraints: resource identification through URIs, resource manipulation through representations, self-descriptive messages, and hypermedia as the engine of application state (HATEOAS).
- Layered System — The client cannot tell whether it is connected directly to the server or to an intermediary such as a load balancer, cache, or API gateway. Layers can be added or removed without affecting the client-server interaction.
- Code on Demand (Optional) — Servers can extend client functionality by sending executable code (such as JavaScript). This is the only optional constraint and is rarely used in modern REST APIs.
Resources: The Core of REST
In REST, everything is a resource. A resource is any concept that can be named and addressed: a user, a blog post, an order, a shopping cart, a configuration setting. Each resource has a unique identifier (its URL) and one or more representations (JSON, XML, HTML). Clients interact with resources by exchanging representations — they never interact with the resource directly.
# A resource (a specific user) identified by a URL
GET /api/v1/users/42
# The representation returned (JSON)
{
"id": 42,
"name": "Alice Chen",
"email": "alice@example.com",
"role": "admin",
"created_at": "2026-01-15T08:30:00Z"
}
This distinction between a resource and its representation is fundamental. The same resource can have multiple representations: JSON for an API client, HTML for a browser, CSV for a data export. The client specifies which representation it wants using the Accept header.
2. HTTP Methods
HTTP methods (also called HTTP verbs) define the action to perform on a resource. REST APIs use these methods to create a uniform interface that every developer understands without reading documentation.
| Method | Purpose | Idempotent | Safe | Request Body |
|---|---|---|---|---|
GET |
Retrieve a resource or collection | Yes | Yes | No |
POST |
Create a new resource | No | No | Yes |
PUT |
Replace a resource entirely | Yes | No | Yes |
PATCH |
Partially update a resource | Can be | No | Yes |
DELETE |
Remove a resource | Yes | No | Optional |
OPTIONS |
Describe communication options | Yes | Yes | No |
HEAD |
Same as GET but without body | Yes | Yes | No |
GET — Retrieve Resources
GET requests retrieve data without modifying it. They are safe (no side effects) and idempotent (calling them multiple times produces the same result). GET requests must never create, update, or delete data.
# Get a collection of users
GET /api/v1/users
Accept: application/json
# Get a specific user
GET /api/v1/users/42
Accept: application/json
# Get a filtered collection
GET /api/v1/users?role=admin&status=active
Accept: application/json
POST — Create Resources
POST creates a new resource within a collection. The server assigns the resource identifier (ID) and returns it in the response. POST is not idempotent — sending the same POST request twice creates two resources.
# Create a new user
POST /api/v1/users
Content-Type: application/json
{
"name": "Bob Smith",
"email": "bob@example.com",
"role": "editor"
}
# Response: 201 Created
# Location: /api/v1/users/43
{
"id": 43,
"name": "Bob Smith",
"email": "bob@example.com",
"role": "editor",
"created_at": "2026-02-11T10:00:00Z"
}
PUT — Replace a Resource
PUT replaces the entire resource with the data provided in the request body. Every field must be included. If you omit a field, it should be set to its default value or null. PUT is idempotent — sending the same PUT request multiple times always produces the same result.
# Replace user 43 entirely
PUT /api/v1/users/43
Content-Type: application/json
{
"name": "Robert Smith",
"email": "robert@example.com",
"role": "admin"
}
# Response: 200 OK
{
"id": 43,
"name": "Robert Smith",
"email": "robert@example.com",
"role": "admin",
"created_at": "2026-02-11T10:00:00Z"
}
PATCH — Partially Update a Resource
PATCH applies a partial modification to a resource. Only the fields included in the request body are updated; all other fields remain unchanged. This is more bandwidth-efficient than PUT and less error-prone when dealing with resources that have many fields.
# Update only the role
PATCH /api/v1/users/43
Content-Type: application/json
{
"role": "admin"
}
# Response: 200 OK
{
"id": 43,
"name": "Robert Smith",
"email": "robert@example.com",
"role": "admin",
"created_at": "2026-02-11T10:00:00Z"
}
DELETE — Remove a Resource
DELETE removes a resource. It is idempotent — deleting a resource that has already been deleted should return the same result (typically 204 No Content or 404 Not Found, depending on your design choice).
# Delete user 43
DELETE /api/v1/users/43
# Response: 204 No Content
# (empty body)
OPTIONS — Describe Communication Options
OPTIONS returns the HTTP methods and other options available for a resource. It is primarily used in CORS preflight requests, where the browser sends an OPTIONS request before the actual cross-origin request to check if the server allows it.
# Options request
OPTIONS /api/v1/users
# Response: 200 OK
# Allow: GET, POST, OPTIONS
# Access-Control-Allow-Origin: https://example.com
# Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE
HEAD — Metadata Without Body
HEAD is identical to GET except the server must not return a response body. It is used to check if a resource exists, retrieve metadata (content type, content length, last modified date), or verify caching headers without downloading the full response.
# Check if user exists and get metadata
HEAD /api/v1/users/42
# Response: 200 OK
# Content-Type: application/json
# Content-Length: 247
# ETag: "a1b2c3d4"
# Last-Modified: Thu, 11 Feb 2026 08:30:00 GMT
# (no body)
Test HTTP Methods Interactively
Use the HTTP Request Tester to send GET, POST, PUT, PATCH, and DELETE requests to any API endpoint and inspect the response headers, status codes, and body in real time.
3. URL Structure and Naming Conventions
Well-designed URLs are the foundation of a usable API. They should be intuitive, consistent, and predictable. A developer should be able to guess the URL for a resource without reading documentation.
Core Rules
- Use nouns, not verbs — URLs represent resources (things), not actions. Use
/users, not/getUsers. - Use plural nouns — Collections should use plural names:
/users,/posts,/orders. - Use lowercase — URLs are case-sensitive in most servers. Use lowercase only:
/api/v1/users, not/Api/V1/Users. - Use hyphens, not underscores — Hyphens are more readable and better for SEO:
/blog-posts, not/blog_posts. - No file extensions — Use content negotiation (
Acceptheader) instead of.jsonor.xmlin URLs. - No trailing slashes — Be consistent. Most APIs omit trailing slashes:
/users, not/users/.
Resource Hierarchy
Use URL paths to express relationships between resources. Nest resources only when the child cannot exist without the parent.
# Good: clear hierarchy
GET /api/v1/users/42/posts # Posts by user 42
GET /api/v1/users/42/posts/7 # Post 7 by user 42
GET /api/v1/posts/7/comments # Comments on post 7
GET /api/v1/posts/7/comments/15 # Comment 15 on post 7
# Bad: too deeply nested (more than 2 levels)
GET /api/v1/users/42/posts/7/comments/15/likes # Too deep
# Better: flatten when relationship is not strictly hierarchical
GET /api/v1/comments/15/likes # Likes on comment 15
GET /api/v1/likes?comment_id=15 # Or use query parameter
Common URL Patterns
# Collection operations
GET /api/v1/users # List all users
POST /api/v1/users # Create a user
# Single resource operations
GET /api/v1/users/42 # Get user 42
PUT /api/v1/users/42 # Replace user 42
PATCH /api/v1/users/42 # Update user 42
DELETE /api/v1/users/42 # Delete user 42
# Sub-resource collections
GET /api/v1/users/42/posts # List posts by user 42
POST /api/v1/users/42/posts # Create a post for user 42
# Search and filtering
GET /api/v1/users?role=admin # Filter by role
GET /api/v1/users?q=alice # Search
GET /api/v1/posts?author=42&status=published # Multiple filters
# Actions (when CRUD does not fit)
POST /api/v1/users/42/activate # Custom action
POST /api/v1/orders/99/cancel # Cancel an order
POST /api/v1/posts/7/publish # Publish a post
Handling Non-CRUD Actions
Not every operation maps cleanly to CRUD. For actions like "send email", "activate account", or "cancel order", you have several options:
- Use a sub-resource:
POST /users/42/activation - Use a state change on the resource:
PATCH /users/42with{"status": "active"} - Use a verb as last resort:
POST /users/42/send-verification-email
4. Request and Response Formats
JSON is the standard format for REST API request and response bodies. It is human-readable, widely supported across every programming language, and has a smaller payload size than XML.
Content Negotiation
Content negotiation allows clients and servers to agree on the format of data. The client sends an Accept header specifying what formats it can handle, and the server responds with the best match.
# Client requests JSON
GET /api/v1/users/42
Accept: application/json
# Client requests XML (if supported)
GET /api/v1/users/42
Accept: application/xml
# Client sends JSON
POST /api/v1/users
Content-Type: application/json
Accept: application/json
{
"name": "Alice Chen",
"email": "alice@example.com"
}
JSON Conventions
- Use camelCase for field names —
firstName,createdAt,isActive. This is the JavaScript convention and most common in APIs. Some APIs use snake_case (first_name), which is the Python/Ruby convention. Pick one and be consistent. - Use ISO 8601 for dates —
"2026-02-11T10:30:00Z". Always include timezone information. Use UTC (Z suffix) when possible. - Use null for missing values — Do not omit fields; use
nullso clients know the field exists but has no value. - Use strings for IDs — Even if your IDs are numeric, return them as strings (
"id": "42") to avoid integer overflow issues in JavaScript and other languages. - Wrap collections in an object — Never return a bare array. Wrap it in an object so you can add pagination metadata later.
# Good: wrapped collection with metadata
{
"data": [
{"id": "1", "name": "Alice"},
{"id": "2", "name": "Bob"}
],
"meta": {
"total": 150,
"page": 1,
"per_page": 20
}
}
# Bad: bare array (cannot add metadata without breaking change)
[
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"}
]
Response Envelope Pattern
Many APIs use a consistent response envelope that wraps every response in a standard structure:
# Success response
{
"status": "success",
"data": {
"id": "42",
"name": "Alice Chen",
"email": "alice@example.com"
}
}
# Error response
{
"status": "error",
"error": {
"code": "VALIDATION_ERROR",
"message": "The email field is required.",
"details": [
{
"field": "email",
"message": "This field is required."
}
]
}
}
Format and Validate JSON Responses
Use the JSON Formatter to pretty-print, validate, and analyze API response payloads. Paste any JSON and instantly see it formatted with syntax highlighting.
5. HTTP Status Codes
Status codes tell the client what happened with its request. Using the correct status code is essential for a well-designed API — clients rely on status codes to determine how to handle the response.
2xx Success
| Code | Name | When to Use |
|---|---|---|
200 |
OK | Successful GET, PUT, PATCH, or DELETE that returns data |
201 |
Created | Successful POST that creates a resource. Include Location header. |
204 |
No Content | Successful DELETE or PUT/PATCH with no response body |
3xx Redirection
| Code | Name | When to Use |
|---|---|---|
301 |
Moved Permanently | Resource URL has permanently changed. Clients should update their bookmarks. |
304 |
Not Modified | Resource has not changed since last request (used with ETags and conditional requests). |
307 |
Temporary Redirect | Resource temporarily available at different URL. Preserves HTTP method. |
4xx Client Errors
| Code | Name | When to Use |
|---|---|---|
400 |
Bad Request | Invalid syntax, malformed JSON, or missing required fields |
401 |
Unauthorized | Authentication required or credentials are invalid |
403 |
Forbidden | Authenticated but does not have permission to access this resource |
404 |
Not Found | Resource does not exist at this URL |
405 |
Method Not Allowed | HTTP method not supported for this endpoint (e.g., DELETE on a read-only resource) |
409 |
Conflict | Request conflicts with current state (e.g., duplicate email, version mismatch) |
422 |
Unprocessable Entity | Well-formed request but semantic errors (e.g., email format invalid, age is negative) |
429 |
Too Many Requests | Rate limit exceeded. Include Retry-After header. |
5xx Server Errors
| Code | Name | When to Use |
|---|---|---|
500 |
Internal Server Error | Unexpected server error. Never expose stack traces to clients. |
502 |
Bad Gateway | Server received an invalid response from an upstream server |
503 |
Service Unavailable | Server temporarily unavailable (maintenance, overload). Include Retry-After. |
504 |
Gateway Timeout | Upstream server did not respond in time |
Status Code Decision Guide
# Common patterns by HTTP method:
# GET success → 200 OK (with body)
# POST success → 201 Created (with body + Location header)
# PUT success → 200 OK (with body) or 204 No Content
# PATCH success → 200 OK (with body) or 204 No Content
# DELETE success → 204 No Content (no body)
# Validation failed → 422 Unprocessable Entity
# Malformed JSON → 400 Bad Request
# Not logged in → 401 Unauthorized
# Logged in, no access → 403 Forbidden
# Resource does not exist → 404 Not Found
# Duplicate/conflict → 409 Conflict
# Rate limited → 429 Too Many Requests
6. Authentication and Authorization
Authentication verifies who the client is. Authorization determines what the client is allowed to do. Every production API needs both. There are four common authentication mechanisms for REST APIs.
API Keys
An API key is a unique string assigned to each client application. It is the simplest authentication method but provides the least security because the key is a shared secret that can be leaked.
# API key in header (preferred)
GET /api/v1/users
X-API-Key: sk_live_a1b2c3d4e5f6g7h8i9j0
# API key in query parameter (less secure — visible in logs)
GET /api/v1/users?api_key=sk_live_a1b2c3d4e5f6g7h8i9j0
Best for: Server-to-server communication, public APIs with rate limiting, low-sensitivity data.
Not suitable for: User-facing applications where the key would be exposed in client-side code.
OAuth 2.0
OAuth 2.0 is the industry-standard authorization framework. It allows a user to grant a third-party application limited access to their data without sharing their password. OAuth 2.0 defines several grant types (flows):
- Authorization Code — For server-side web apps. Most secure. User is redirected to the authorization server, logs in, and is redirected back with a code that is exchanged for a token.
- Authorization Code with PKCE — For single-page apps (SPAs) and mobile apps. Adds a code verifier/challenge to prevent interception attacks.
- Client Credentials — For machine-to-machine communication. The client authenticates directly with the authorization server using its client ID and secret.
- Device Code — For devices without a browser (smart TVs, CLI tools). The device displays a code, and the user enters it on another device.
# Step 1: Redirect user to authorization server
GET https://auth.example.com/authorize?
response_type=code&
client_id=YOUR_CLIENT_ID&
redirect_uri=https://yourapp.com/callback&
scope=read:users write:posts&
state=random_csrf_token
# Step 2: Exchange authorization code for tokens
POST https://auth.example.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=AUTH_CODE_FROM_CALLBACK&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET&
redirect_uri=https://yourapp.com/callback
# Step 3: Use the access token
GET /api/v1/users/me
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
JWT (JSON Web Tokens)
JWT is a compact, URL-safe token format that contains encoded claims (data) about the user. A JWT has three parts separated by dots: header, payload, and signature.
# JWT structure: header.payload.signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiI0MiIsIm5hbWUiOiJBbGljZSBDaGVuIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzM5MzU4ODAwfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
# Decoded header
{
"alg": "HS256",
"typ": "JWT"
}
# Decoded payload
{
"sub": "42",
"name": "Alice Chen",
"role": "admin",
"exp": 1739358800
}
# The signature is computed as:
# HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
Advantages: Stateless (server does not need a session store), self-contained (contains user data), can be verified without a database lookup.
Disadvantages: Cannot be revoked until expiry (use short-lived tokens + refresh tokens), payload is only encoded (not encrypted) so do not store sensitive data in it.
Bearer Tokens
Bearer tokens are sent in the Authorization header. The token can be a JWT, an opaque token, or any string that the server can validate.
# Using a bearer token
GET /api/v1/users/me
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
# Server validates the token and extracts the user identity
# If invalid or expired → 401 Unauthorized
Authentication Comparison
| Method | Security | Complexity | Best For |
|---|---|---|---|
| API Keys | Low-Medium | Simple | Server-to-server, public APIs |
| OAuth 2.0 | High | Complex | Third-party access, user delegation |
| JWT | Medium-High | Medium | Stateless auth, microservices |
| Bearer Tokens | Medium-High | Low | Session replacement, mobile apps |
Decode and Inspect JWT Tokens
Use the JWT Decoder to decode any JWT token and inspect its header, payload, and expiration time. All processing happens client-side — your tokens never leave your browser.
7. Pagination
Any endpoint that returns a collection must support pagination. Without it, a request for "all users" could return millions of records, overwhelming the client, the server, and the network.
Offset-Based Pagination
The simplest approach. The client specifies a page number and page size. The server skips the appropriate number of rows and returns the requested page.
# Request page 3 with 20 items per page
GET /api/v1/posts?page=3&per_page=20
# Response
{
"data": [
{"id": "41", "title": "Post 41"},
{"id": "42", "title": "Post 42"},
...
],
"meta": {
"current_page": 3,
"per_page": 20,
"total_items": 487,
"total_pages": 25
},
"links": {
"first": "/api/v1/posts?page=1&per_page=20",
"prev": "/api/v1/posts?page=2&per_page=20",
"next": "/api/v1/posts?page=4&per_page=20",
"last": "/api/v1/posts?page=25&per_page=20"
}
}
Advantages: Simple to implement, allows jumping to any page, clients can display page numbers.
Disadvantages: Performance degrades on large datasets (SQL OFFSET scans and discards rows), inconsistent results when items are added or deleted between requests (items can be skipped or duplicated).
Cursor-Based Pagination
The server returns an opaque cursor (typically a base64-encoded value) that points to the last item in the current page. The client sends this cursor to get the next page.
# First request
GET /api/v1/posts?limit=20
# Response
{
"data": [...],
"meta": {
"has_next": true,
"next_cursor": "eyJpZCI6NDAsImNyZWF0ZWRfYXQiOiIyMDI2LTAyLTEwVDA4OjAwOjAwWiJ9"
}
}
# Next page
GET /api/v1/posts?limit=20&cursor=eyJpZCI6NDAsImNyZWF0ZWRfYXQiOiIyMDI2LTAyLTEwVDA4OjAwOjAwWiJ9
Advantages: Consistent performance regardless of dataset size, handles real-time data changes gracefully, no skipped or duplicated items.
Disadvantages: Cannot jump to an arbitrary page, cannot display total page count (without a separate count query).
Keyset Pagination
Similar to cursor-based, but the cursor is a visible field value (such as an ID or timestamp) rather than an opaque token. This makes debugging easier.
# First request
GET /api/v1/posts?limit=20&sort=created_at:desc
# Next page (using the last item's values)
GET /api/v1/posts?limit=20&sort=created_at:desc&after_id=40&after_created_at=2026-02-10T08:00:00Z
Pagination Best Practices
- Always set a default page size (e.g., 20) and a maximum page size (e.g., 100).
- Return pagination metadata in the response (total count, has next page, links).
- Use cursor-based pagination for real-time data feeds and large datasets.
- Use offset-based pagination for admin dashboards and small datasets where users need page numbers.
- Include
Linkheaders withrel="next"andrel="prev"for discoverability.
8. Filtering, Sorting, and Field Selection
Beyond pagination, clients need to filter collections, sort results, and select only the fields they need.
Filtering
Use query parameters to filter collections. Keep parameter names consistent with your resource field names.
# Simple equality filters
GET /api/v1/users?role=admin&status=active
# Range filters
GET /api/v1/products?price_min=10&price_max=100
GET /api/v1/orders?created_after=2026-01-01&created_before=2026-02-01
# Multiple values (OR logic)
GET /api/v1/products?category=electronics,books,toys
# Search
GET /api/v1/users?q=alice # Full-text search
GET /api/v1/users?name_like=ali # Partial match
# Advanced filtering with operators
GET /api/v1/products?filter[price][gte]=10&filter[price][lte]=100
GET /api/v1/users?filter[created_at][gt]=2026-01-01
Sorting
Allow clients to sort by one or more fields. Use a consistent syntax for specifying sort order.
# Sort by a single field (ascending by default)
GET /api/v1/users?sort=name
# Sort descending (prefix with minus)
GET /api/v1/users?sort=-created_at
# Multi-field sort (comma-separated)
GET /api/v1/products?sort=-rating,price
# Sort by rating descending, then by price ascending
# Alternative syntax
GET /api/v1/users?sort_by=created_at&sort_order=desc
Field Selection (Sparse Fieldsets)
Allow clients to request only the fields they need. This reduces response size and improves performance, especially for mobile clients on slow networks.
# Select specific fields
GET /api/v1/users?fields=id,name,email
# Response includes only requested fields
{
"data": [
{"id": "1", "name": "Alice", "email": "alice@example.com"},
{"id": "2", "name": "Bob", "email": "bob@example.com"}
]
}
# Field selection per resource type (JSON:API style)
GET /api/v1/posts?fields[posts]=title,body&fields[author]=name
# Include related resources (expansion)
GET /api/v1/posts?include=author,comments
GET /api/v1/orders/99?expand=customer,line_items
9. Versioning Strategies
APIs evolve over time. Fields are added, removed, or renamed. Response formats change. Versioning allows you to make breaking changes without disrupting existing clients.
URL Path Versioning
The most common and most visible approach. The version number is part of the URL path.
# Version in URL path
GET /api/v1/users/42
GET /api/v2/users/42
# Advantages:
# - Easy to understand and test in a browser
# - Clear which version is being used
# - Can run multiple versions simultaneously
# - Works with API gateways and load balancers
# Disadvantages:
# - Changes the resource URI (purists argue this violates REST)
# - Requires maintaining multiple codepaths or deployments
Custom Header Versioning
The version is specified in a custom HTTP header. The URL remains the same across versions.
# Version in custom header
GET /api/users/42
API-Version: 2
# Or using Accept header with media type versioning
GET /api/users/42
Accept: application/vnd.myapi.v2+json
# Advantages:
# - Clean URLs that do not change across versions
# - Follows REST principles more strictly
# Disadvantages:
# - Cannot test in a browser address bar
# - Harder to discover and debug
# - Requires documentation to know which versions exist
Query Parameter Versioning
The version is specified as a query parameter.
# Version as query parameter
GET /api/users/42?version=2
# Advantages:
# - Easy to add to existing APIs
# - Can default to latest version if omitted
# Disadvantages:
# - Can interfere with caching (different query = different cache key)
# - Clutters the URL with non-resource parameters
Versioning Best Practices
- Pick one strategy and use it consistently across your entire API.
- URL path versioning is recommended for most APIs because of its simplicity and discoverability.
- Only increment the major version for breaking changes. Adding new fields, new endpoints, or new optional parameters are not breaking changes.
- Support at least two versions simultaneously and give clients a migration timeline (typically 6-12 months).
- Document all breaking changes clearly in your changelog.
- Use a deprecation header to warn clients about upcoming version sunset:
Deprecation: true,Sunset: Sat, 01 Aug 2026 00:00:00 GMT.
10. Error Handling
Good error handling is the difference between an API that developers love and one they curse. When something goes wrong, the error response should tell the client exactly what happened, why, and how to fix it.
Error Response Format
Use a consistent error response format across your entire API. Here is a recommended structure based on RFC 7807 (Problem Details for HTTP APIs):
# Standard error response
{
"type": "https://api.example.com/errors/validation-error",
"title": "Validation Error",
"status": 422,
"detail": "One or more fields failed validation.",
"instance": "/api/v1/users",
"errors": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "Must be a valid email address."
},
{
"field": "age",
"code": "OUT_OF_RANGE",
"message": "Must be between 13 and 150."
}
],
"request_id": "req_a1b2c3d4e5",
"timestamp": "2026-02-11T10:30:00Z"
}
Error Response Fields
- type — A URI that identifies the error type. Can link to documentation.
- title — A short, human-readable summary of the problem.
- status — The HTTP status code (duplicated in the body for convenience).
- detail — A human-readable explanation specific to this occurrence.
- instance — The API endpoint that generated the error.
- errors — An array of specific validation errors, each with field, code, and message.
- request_id — A unique identifier for this request, useful for debugging and support tickets.
- timestamp — When the error occurred.
Common Error Patterns
# 400 Bad Request — Malformed JSON
{
"type": "https://api.example.com/errors/bad-request",
"title": "Bad Request",
"status": 400,
"detail": "The request body contains invalid JSON. Expected ',' or '}' at line 3, column 15.",
"request_id": "req_f6g7h8"
}
# 401 Unauthorized — Missing or invalid token
{
"type": "https://api.example.com/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "The access token is expired. Please refresh your token and try again.",
"request_id": "req_i9j0k1"
}
# 403 Forbidden — Insufficient permissions
{
"type": "https://api.example.com/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You do not have permission to delete this resource. Required role: admin.",
"request_id": "req_l2m3n4"
}
# 404 Not Found
{
"type": "https://api.example.com/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "No user found with ID 999.",
"request_id": "req_o5p6q7"
}
# 409 Conflict — Duplicate resource
{
"type": "https://api.example.com/errors/conflict",
"title": "Conflict",
"status": 409,
"detail": "A user with email alice@example.com already exists.",
"request_id": "req_r8s9t0"
}
# 429 Too Many Requests
{
"type": "https://api.example.com/errors/rate-limited",
"title": "Too Many Requests",
"status": 429,
"detail": "Rate limit exceeded. You have made 101 requests in the last 60 seconds. Limit: 100.",
"retry_after": 45,
"request_id": "req_u1v2w3"
}
# 500 Internal Server Error
{
"type": "https://api.example.com/errors/internal-error",
"title": "Internal Server Error",
"status": 500,
"detail": "An unexpected error occurred. Our team has been notified.",
"request_id": "req_x4y5z6"
}
Error Handling Best Practices
- Always return JSON for error responses, not HTML or plain text.
- Use the correct HTTP status code — do not use 200 for errors.
- Include a machine-readable error code (like
INVALID_FORMAT) in addition to human-readable messages. - Include a request ID in every response for debugging.
- Never expose stack traces, SQL queries, or internal paths in production error responses.
- Provide actionable messages that tell the client how to fix the problem.
- Log detailed error information server-side and return only safe information to the client.
11. Rate Limiting and Throttling
Rate limiting protects your API from abuse, prevents denial-of-service attacks, and ensures fair usage across all clients. Every production API should implement rate limiting.
Rate Limit Headers
Include rate limit information in response headers so clients can monitor their usage and avoid hitting limits:
# Response headers
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000 # Maximum requests per window
X-RateLimit-Remaining: 847 # Requests remaining in current window
X-RateLimit-Reset: 1739358800 # Unix timestamp when the window resets
X-RateLimit-Window: 3600 # Window duration in seconds (1 hour)
# When rate limited (429 response)
HTTP/1.1 429 Too Many Requests
Retry-After: 45 # Seconds until the client can retry
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1739358800
Rate Limiting Strategies
- Fixed Window — Count requests in fixed time windows (e.g., 1000 requests per hour). Simple but can allow bursts at window boundaries.
- Sliding Window — Count requests in a rolling window. Smooths out burst traffic. More accurate but requires more state.
- Token Bucket — Tokens accumulate at a fixed rate. Each request consumes a token. Allows controlled bursts up to the bucket capacity.
- Leaky Bucket — Requests are processed at a fixed rate. Excess requests are queued. Produces a smooth output rate.
Tiered Rate Limits
# Different limits by plan
Free tier: 100 requests/hour, 1,000/day
Basic tier: 1,000 requests/hour, 10,000/day
Pro tier: 10,000 requests/hour, 100,000/day
Enterprise: Custom limits
# Different limits by endpoint
GET /api/v1/search: 30 requests/minute (expensive operation)
GET /api/v1/users: 100 requests/minute (standard read)
POST /api/v1/users: 10 requests/minute (write operation)
POST /api/v1/emails: 5 requests/minute (sending emails)
12. HATEOAS and Hypermedia
HATEOAS (Hypermedia As The Engine Of Application State) is one of the REST constraints that is most often overlooked. It means that API responses should include links (hypermedia controls) that tell the client what actions are available next.
In practice, this means that a client should be able to navigate the entire API starting from a single entry point, following links in responses, without hardcoding any URLs.
# A HATEOAS-enabled response
GET /api/v1/orders/99
{
"id": "99",
"status": "pending",
"total": 149.99,
"created_at": "2026-02-11T10:00:00Z",
"customer": {
"id": "42",
"name": "Alice Chen"
},
"_links": {
"self": {"href": "/api/v1/orders/99"},
"customer": {"href": "/api/v1/customers/42"},
"items": {"href": "/api/v1/orders/99/items"},
"cancel": {"href": "/api/v1/orders/99/cancel", "method": "POST"},
"pay": {"href": "/api/v1/orders/99/pay", "method": "POST"}
}
}
# After the order is paid, the response changes:
GET /api/v1/orders/99
{
"id": "99",
"status": "paid",
"total": 149.99,
"_links": {
"self": {"href": "/api/v1/orders/99"},
"customer": {"href": "/api/v1/customers/42"},
"items": {"href": "/api/v1/orders/99/items"},
"refund": {"href": "/api/v1/orders/99/refund", "method": "POST"},
"receipt": {"href": "/api/v1/orders/99/receipt"}
}
// Note: "cancel" and "pay" links are gone because they are no longer valid
}
Benefits of HATEOAS
- Discoverability — Clients can explore the API by following links, like browsing a website.
- Evolvability — Server can change URLs without breaking clients that follow links rather than hardcoding them.
- Self-documentation — Available actions are visible in the response.
- State-driven UI — The client can show or hide buttons based on which links are present.
HATEOAS in Practice
Full HATEOAS is rarely implemented because it adds complexity and most API clients hardcode URLs anyway. However, including basic links (self, next, prev, related resources) is a widely adopted best practice that improves API usability.
13. Caching Strategies
Caching is one of the REST constraints and one of the most impactful performance optimizations for any API. Proper caching reduces server load, decreases latency, and saves bandwidth.
Cache-Control Headers
The Cache-Control header tells clients and intermediaries (CDNs, proxies) how to cache a response.
# Cache for 1 hour (public — CDNs can cache)
Cache-Control: public, max-age=3600
# Cache for 5 minutes (private — only the client can cache)
Cache-Control: private, max-age=300
# No caching at all (sensitive data, user-specific responses)
Cache-Control: no-store
# Must revalidate with server before using cached version
Cache-Control: no-cache
# Cache but revalidate after max-age expires
Cache-Control: public, max-age=3600, must-revalidate
# Stale-while-revalidate: serve stale content while fetching fresh
Cache-Control: public, max-age=60, stale-while-revalidate=300
ETags (Entity Tags)
An ETag is a unique identifier for a specific version of a resource. The server generates it (typically a hash of the response body) and sends it with the response. The client can use it for conditional requests.
# Server sends ETag with response
GET /api/v1/users/42
HTTP/1.1 200 OK
ETag: "a1b2c3d4e5f6"
{
"id": "42",
"name": "Alice Chen",
"email": "alice@example.com"
}
# Client sends conditional request with If-None-Match
GET /api/v1/users/42
If-None-Match: "a1b2c3d4e5f6"
# If the resource has NOT changed:
HTTP/1.1 304 Not Modified
# (no body — client uses its cached version)
# If the resource HAS changed:
HTTP/1.1 200 OK
ETag: "f7g8h9i0j1k2"
{
"id": "42",
"name": "Alice Chen-Smith",
"email": "alice@example.com"
}
Conditional Requests
Conditional requests allow the client to avoid downloading data that has not changed. There are two mechanisms:
- ETag + If-None-Match — Used for GET requests. The server returns 304 Not Modified if the ETag matches.
- Last-Modified + If-Modified-Since — The server sends a
Last-Modifiedtimestamp. The client includes it in subsequent requests asIf-Modified-Since. Less precise than ETags but simpler. - If-Match for safe updates — Used with PUT/PATCH to prevent overwriting changes made by another client (optimistic concurrency control).
# Optimistic concurrency control with ETags
# Step 1: Client fetches resource and receives ETag
GET /api/v1/users/42
HTTP/1.1 200 OK
ETag: "a1b2c3d4"
# Step 2: Client updates with If-Match
PUT /api/v1/users/42
If-Match: "a1b2c3d4"
Content-Type: application/json
{"name": "Alice Chen-Smith", "email": "alice@example.com", "role": "admin"}
# If no one else modified it → 200 OK
# If someone else modified it → 412 Precondition Failed
Caching Best Practices
- Use
Cache-Control: no-storefor user-specific or sensitive data. - Use
Cache-Control: public, max-age=3600for static reference data. - Always include ETags for resources that change over time.
- Use
Varyheader to indicate which request headers affect caching (e.g.,Vary: Accept, Authorization). - Set appropriate
max-agevalues based on how frequently data changes. - Use
stale-while-revalidatefor improved perceived performance.
14. Security Best Practices
API security is not optional. A vulnerable API exposes your data, your users, and your infrastructure to attackers. Follow these practices to build secure APIs from the ground up.
Always Use HTTPS
Every API call must use HTTPS. HTTP transmits data in plain text, meaning anyone on the network can read authentication tokens, request bodies, and response data. There are no exceptions to this rule.
# Redirect HTTP to HTTPS
server {
listen 80;
server_name api.example.com;
return 301 https://$server_name$request_uri;
}
# HSTS header (tell browsers to always use HTTPS)
Strict-Transport-Security: max-age=31536000; includeSubDomains
CORS (Cross-Origin Resource Sharing)
CORS controls which web domains can call your API from a browser. Without proper CORS configuration, any website could make requests to your API using your users' cookies.
# CORS response headers
Access-Control-Allow-Origin: https://app.example.com # Specific origin (not *)
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true # Allow cookies
Access-Control-Max-Age: 86400 # Cache preflight for 24h
# NEVER use these in production:
# Access-Control-Allow-Origin: * # Allows any origin
# Access-Control-Allow-Headers: * # Allows any header
Input Validation
Never trust client input. Validate every field in every request.
- Validate data types — Ensure strings are strings, numbers are numbers, emails are valid.
- Validate field lengths — Set minimum and maximum lengths for strings.
- Validate ranges — Ensure numbers are within acceptable ranges.
- Sanitize strings — Strip or escape HTML, SQL, and shell metacharacters.
- Use allowlists — For enum fields, reject any value not in the allowed list.
- Validate content type — Reject requests that do not have the expected
Content-Typeheader. - Limit request body size — Prevent denial-of-service attacks from oversized payloads.
# Example: Express.js input validation with express-validator
const { body, validationResult } = require('express-validator');
app.post('/api/v1/users', [
body('name').isString().trim().isLength({ min: 1, max: 100 }),
body('email').isEmail().normalizeEmail(),
body('age').isInt({ min: 13, max: 150 }),
body('role').isIn(['user', 'editor', 'admin']),
], (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({
type: 'https://api.example.com/errors/validation-error',
title: 'Validation Error',
status: 422,
errors: errors.array().map(e => ({
field: e.path,
message: e.msg
}))
});
}
// Process valid request...
});
Security Headers
# Essential security headers for API responses
X-Content-Type-Options: nosniff # Prevent MIME-type sniffing
X-Frame-Options: DENY # Prevent clickjacking
Content-Security-Policy: default-src 'none' # No loading of external resources
Referrer-Policy: no-referrer # Do not send referrer header
Permissions-Policy: camera=(), microphone=(), geolocation=()
X-Request-Id: req_a1b2c3d4 # For tracking and debugging
Additional Security Measures
- Rate limiting — Prevent brute-force attacks on authentication endpoints.
- Request logging — Log all API requests for auditing (but never log request bodies containing passwords or tokens).
- Dependency updates — Keep all dependencies up to date to patch known vulnerabilities.
- Secrets management — Never hardcode API keys, database passwords, or JWT secrets in source code. Use environment variables or a secrets manager.
- Minimize data exposure — Only return the fields the client needs. Never return password hashes, internal IDs, or debug information in production.
- Use parameterized queries — Prevent SQL injection by never concatenating user input into queries.
15. API Documentation with OpenAPI
API documentation is the user interface of your API. If developers cannot understand how to use your API from the documentation alone, the API has failed regardless of how well it is designed technically.
OpenAPI Specification (formerly Swagger)
The OpenAPI Specification (OAS) is the industry standard for describing REST APIs. It is a machine-readable format (YAML or JSON) that defines endpoints, request/response schemas, authentication, and examples.
# openapi.yaml — Example OpenAPI 3.1 specification
openapi: 3.1.0
info:
title: Blog API
version: 1.0.0
description: A RESTful API for managing blog posts and comments.
contact:
email: api@example.com
servers:
- url: https://api.example.com/v1
description: Production
paths:
/posts:
get:
summary: List all posts
operationId: listPosts
tags: [Posts]
parameters:
- name: page
in: query
schema:
type: integer
default: 1
- name: per_page
in: query
schema:
type: integer
default: 20
maximum: 100
- name: status
in: query
schema:
type: string
enum: [draft, published, archived]
responses:
'200':
description: A paginated list of posts
content:
application/json:
schema:
$ref: '#/components/schemas/PostList'
post:
summary: Create a new post
operationId: createPost
tags: [Posts]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreatePost'
example:
title: "REST API Design Guide"
body: "This is a comprehensive guide..."
status: draft
responses:
'201':
description: Post created successfully
'422':
description: Validation error
/posts/{id}:
get:
summary: Get a specific post
operationId: getPost
tags: [Posts]
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: The requested post
'404':
description: Post not found
components:
schemas:
Post:
type: object
properties:
id:
type: string
title:
type: string
body:
type: string
status:
type: string
enum: [draft, published, archived]
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
CreatePost:
type: object
required: [title, body]
properties:
title:
type: string
minLength: 1
maxLength: 200
body:
type: string
minLength: 1
status:
type: string
enum: [draft, published]
default: draft
PostList:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Post'
meta:
type: object
properties:
total:
type: integer
page:
type: integer
per_page:
type: integer
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
Documentation Tools
- Swagger UI — Interactive documentation that lets developers try API calls from the browser. Generates from OpenAPI spec.
- Redoc — Clean, responsive API documentation with a three-panel layout. Popular for public APIs.
- Stoplight — Visual API design tool with built-in documentation and mocking.
- Postman — Can generate documentation from collections and import/export OpenAPI specs.
Documentation Best Practices
- Include examples for every endpoint — Show complete request and response examples, not just schemas.
- Document error responses — Show what error codes are possible and what the error body looks like.
- Provide a quickstart guide — Help new users make their first successful API call in under 5 minutes.
- Document authentication clearly — Explain how to obtain and use API keys or tokens.
- Keep documentation in sync — Use code-first or spec-first approaches to prevent documentation drift.
- Include rate limit information — Document the limits per plan and per endpoint.
- Provide SDKs and code examples in popular languages (JavaScript, Python, Go, Java, Ruby).
16. GraphQL vs REST
GraphQL is an alternative API architecture developed by Facebook (now Meta) in 2012 and released publicly in 2015. It is not a replacement for REST but a different approach with different tradeoffs.
Key Differences
| Aspect | REST | GraphQL |
|---|---|---|
| Endpoints | Multiple endpoints (one per resource) | Single endpoint (/graphql) |
| Data fetching | Server decides what to return | Client specifies exactly what fields it needs |
| Over-fetching | Common (returns all fields) | Eliminated (client picks fields) |
| Under-fetching | Common (requires multiple requests) | Eliminated (nested queries in one request) |
| Caching | HTTP caching works natively | Requires custom caching (e.g., Apollo cache) |
| Versioning | URL or header versioning | No versioning needed (evolve schema) |
| Error handling | HTTP status codes | Always returns 200; errors in response body |
| Learning curve | Lower (uses familiar HTTP concepts) | Higher (new query language, schema, resolvers) |
| Tooling | curl, Postman, any HTTP client | Requires GraphQL-specific clients and tools |
| File uploads | Native multipart support | Requires workarounds (multipart request spec) |
When to Use REST
- Public APIs consumed by many different clients
- Simple CRUD operations on resources
- APIs that benefit from HTTP caching
- Microservice-to-microservice communication
- APIs where simplicity and broad compatibility matter
- When your team is familiar with REST but not GraphQL
When to Use GraphQL
- Mobile applications that need to minimize data transfer
- Complex frontends that fetch data from many related resources
- Rapidly evolving APIs where schema changes are frequent
- When clients have very different data needs (web vs mobile vs watch)
- APIs that aggregate data from multiple backend services
Can You Use Both?
Yes. Many organizations use REST for their public API and GraphQL for their frontend applications. Some use a GraphQL gateway that sits in front of multiple REST microservices, aggregating them into a single graph. There is no rule that says you must choose one or the other.
17. Real-World API Design Examples
Theory is essential, but seeing complete examples is what solidifies understanding. Let us design two real-world APIs from scratch: a blog API and an e-commerce API.
Example 1: Blog API
# Blog API — Complete Endpoint Design
# Authentication
POST /api/v1/auth/register # Register a new user
POST /api/v1/auth/login # Login, returns JWT
POST /api/v1/auth/refresh # Refresh access token
POST /api/v1/auth/logout # Invalidate refresh token
# Users
GET /api/v1/users # List users (admin only)
GET /api/v1/users/:id # Get user profile
PATCH /api/v1/users/:id # Update user profile
DELETE /api/v1/users/:id # Delete user (admin only)
GET /api/v1/users/:id/posts # Get posts by user
# Posts
GET /api/v1/posts # List published posts
POST /api/v1/posts # Create a draft post
GET /api/v1/posts/:id # Get a specific post
PUT /api/v1/posts/:id # Replace a post
PATCH /api/v1/posts/:id # Update specific fields
DELETE /api/v1/posts/:id # Delete a post
POST /api/v1/posts/:id/publish # Publish a draft
POST /api/v1/posts/:id/unpublish # Revert to draft
# Comments
GET /api/v1/posts/:id/comments # List comments on a post
POST /api/v1/posts/:id/comments # Add a comment to a post
PATCH /api/v1/comments/:id # Edit a comment
DELETE /api/v1/comments/:id # Delete a comment
# Tags
GET /api/v1/tags # List all tags
GET /api/v1/tags/:slug/posts # Get posts with a specific tag
# Search
GET /api/v1/search?q=rest+api&type=posts # Search posts
GET /api/v1/search?q=alice&type=users # Search users
Blog API: Create Post Request/Response
# Request
POST /api/v1/posts
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
Content-Type: application/json
{
"title": "Getting Started with REST APIs",
"body": "REST APIs are the backbone of modern web development...",
"tags": ["rest", "api", "tutorial"],
"status": "draft"
}
# Response: 201 Created
# Location: /api/v1/posts/128
{
"data": {
"id": "128",
"title": "Getting Started with REST APIs",
"slug": "getting-started-with-rest-apis",
"body": "REST APIs are the backbone of modern web development...",
"excerpt": "REST APIs are the backbone of modern web...",
"status": "draft",
"author": {
"id": "42",
"name": "Alice Chen",
"avatar_url": "https://cdn.example.com/avatars/42.jpg"
},
"tags": ["rest", "api", "tutorial"],
"comment_count": 0,
"read_time_minutes": 5,
"created_at": "2026-02-11T10:00:00Z",
"updated_at": "2026-02-11T10:00:00Z",
"published_at": null
},
"_links": {
"self": {"href": "/api/v1/posts/128"},
"author": {"href": "/api/v1/users/42"},
"comments": {"href": "/api/v1/posts/128/comments"},
"publish": {"href": "/api/v1/posts/128/publish", "method": "POST"}
}
}
Example 2: E-Commerce API
# E-Commerce API — Complete Endpoint Design
# Products
GET /api/v1/products # List products (with filters)
GET /api/v1/products/:id # Get product details
POST /api/v1/products # Create product (admin)
PATCH /api/v1/products/:id # Update product (admin)
DELETE /api/v1/products/:id # Delete product (admin)
GET /api/v1/products/:id/reviews # Get product reviews
POST /api/v1/products/:id/reviews # Add a review
# Categories
GET /api/v1/categories # List categories
GET /api/v1/categories/:slug/products # Products in category
# Shopping Cart
GET /api/v1/cart # Get current cart
POST /api/v1/cart/items # Add item to cart
PATCH /api/v1/cart/items/:id # Update quantity
DELETE /api/v1/cart/items/:id # Remove item from cart
DELETE /api/v1/cart # Clear entire cart
# Orders
POST /api/v1/orders # Create order (checkout)
GET /api/v1/orders # List user's orders
GET /api/v1/orders/:id # Get order details
POST /api/v1/orders/:id/cancel # Cancel an order
GET /api/v1/orders/:id/tracking # Get shipping tracking
# Payments
POST /api/v1/payments # Process payment
GET /api/v1/payments/:id # Get payment status
POST /api/v1/payments/:id/refund # Request refund
# Addresses
GET /api/v1/addresses # List saved addresses
POST /api/v1/addresses # Add address
PATCH /api/v1/addresses/:id # Update address
DELETE /api/v1/addresses/:id # Delete address
E-Commerce API: Product Listing with Filters
# Request: List products with filters, sorting, and pagination
GET /api/v1/products?category=electronics&price_min=50&price_max=500&sort=-rating&page=1&per_page=12&fields=id,name,price,rating,thumbnail_url
# Response: 200 OK
{
"data": [
{
"id": "prod_001",
"name": "Wireless Noise-Cancelling Headphones",
"price": 299.99,
"rating": 4.7,
"thumbnail_url": "https://cdn.example.com/products/001-thumb.jpg"
},
{
"id": "prod_002",
"name": "Mechanical Keyboard",
"price": 149.99,
"rating": 4.5,
"thumbnail_url": "https://cdn.example.com/products/002-thumb.jpg"
}
],
"meta": {
"total": 87,
"page": 1,
"per_page": 12,
"total_pages": 8
},
"links": {
"self": "/api/v1/products?category=electronics&page=1&per_page=12",
"next": "/api/v1/products?category=electronics&page=2&per_page=12",
"last": "/api/v1/products?category=electronics&page=8&per_page=12"
},
"filters_applied": {
"category": "electronics",
"price_min": 50,
"price_max": 500,
"sort": "-rating"
}
}
18. Testing REST APIs
A well-designed API is meaningless if it does not work correctly. API testing verifies that your endpoints return the correct data, status codes, and headers under all conditions.
Types of API Tests
- Unit tests — Test individual functions (validation logic, data transformations) in isolation.
- Integration tests — Test endpoints with a real database and dependencies.
- Contract tests — Verify that the API schema has not changed unexpectedly (prevents breaking changes).
- Load tests — Measure performance under heavy traffic.
- Security tests — Check for vulnerabilities (injection, broken auth, data exposure).
Testing with curl
# GET request
curl -s https://api.example.com/v1/users | jq .
# POST request with JSON body
curl -s -X POST https://api.example.com/v1/users \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"name": "Alice", "email": "alice@example.com"}' | jq .
# PUT request
curl -s -X PUT https://api.example.com/v1/users/42 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"name": "Alice Chen", "email": "alice@example.com", "role": "admin"}' | jq .
# PATCH request
curl -s -X PATCH https://api.example.com/v1/users/42 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"role": "admin"}' | jq .
# DELETE request
curl -s -X DELETE https://api.example.com/v1/users/42 \
-H "Authorization: Bearer YOUR_TOKEN" -v
# Show response headers
curl -s -D - https://api.example.com/v1/users/42 -o /dev/null
Integration Testing with JavaScript (Jest + Supertest)
const request = require('supertest');
const app = require('../app');
describe('POST /api/v1/users', () => {
it('should create a user and return 201', async () => {
const res = await request(app)
.post('/api/v1/users')
.set('Authorization', `Bearer ${token}`)
.send({
name: 'Alice Chen',
email: 'alice@example.com',
role: 'editor'
});
expect(res.status).toBe(201);
expect(res.body.data).toHaveProperty('id');
expect(res.body.data.name).toBe('Alice Chen');
expect(res.body.data.email).toBe('alice@example.com');
expect(res.headers.location).toMatch(/\/api\/v1\/users\/\w+/);
});
it('should return 422 for invalid email', async () => {
const res = await request(app)
.post('/api/v1/users')
.set('Authorization', `Bearer ${token}`)
.send({
name: 'Bob',
email: 'not-an-email',
role: 'editor'
});
expect(res.status).toBe(422);
expect(res.body.errors[0].field).toBe('email');
});
it('should return 401 without authentication', async () => {
const res = await request(app)
.post('/api/v1/users')
.send({ name: 'Eve', email: 'eve@example.com' });
expect(res.status).toBe(401);
});
it('should return 409 for duplicate email', async () => {
// First create a user
await request(app)
.post('/api/v1/users')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'Alice', email: 'alice@example.com', role: 'user' });
// Try to create another with same email
const res = await request(app)
.post('/api/v1/users')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'Alice 2', email: 'alice@example.com', role: 'user' });
expect(res.status).toBe(409);
});
});
Integration Testing with Python (pytest + requests)
import pytest
import requests
BASE_URL = "https://api.example.com/v1"
class TestUsersAPI:
def test_list_users_returns_200(self, auth_headers):
response = requests.get(f"{BASE_URL}/users", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert "data" in data
assert "meta" in data
assert isinstance(data["data"], list)
def test_create_user_returns_201(self, auth_headers):
payload = {
"name": "Alice Chen",
"email": "alice@example.com",
"role": "editor"
}
response = requests.post(
f"{BASE_URL}/users",
json=payload,
headers=auth_headers
)
assert response.status_code == 201
assert "Location" in response.headers
user = response.json()["data"]
assert user["name"] == "Alice Chen"
assert user["email"] == "alice@example.com"
def test_get_nonexistent_user_returns_404(self, auth_headers):
response = requests.get(
f"{BASE_URL}/users/nonexistent",
headers=auth_headers
)
assert response.status_code == 404
error = response.json()
assert error["status"] == 404
assert "request_id" in error
def test_invalid_json_returns_400(self, auth_headers):
response = requests.post(
f"{BASE_URL}/users",
data="this is not json",
headers={**auth_headers, "Content-Type": "application/json"}
)
assert response.status_code == 400
API Testing Checklist
- Every endpoint returns the correct status code for success and each type of failure.
- Response body matches the documented schema.
- Required fields that are missing produce 422 errors with field-level details.
- Authentication failures return 401, authorization failures return 403.
- Pagination metadata is correct (total count, page numbers, links).
- Filters, sorting, and field selection work correctly and can be combined.
- Rate limiting returns 429 with a
Retry-Afterheader. - ETags and conditional requests work (304 Not Modified).
- CORS headers are present and correct.
- Error responses never expose stack traces or internal paths.
- Large payloads are handled gracefully (413 Payload Too Large).
- Concurrent updates are handled (409 Conflict or ETag-based concurrency).
Test Your APIs Right Now
Use the HTTP Request Tester to send requests to any endpoint, inspect response headers and status codes, and debug API issues. Use the JSON Formatter to validate and pretty-print response bodies, and the JWT Decoder to inspect authentication tokens.
Frequently Asked Questions
What is the difference between REST and RESTful APIs?
REST (Representational State Transfer) is an architectural style defined by Roy Fielding in his 2000 doctoral dissertation. It describes six constraints: client-server separation, statelessness, cacheability, uniform interface, layered system, and optional code-on-demand. A RESTful API is an API that adheres to these REST constraints. In practice, most APIs described as "RESTful" follow the conventions of using HTTP methods, resource-based URLs, and JSON responses, even if they do not strictly satisfy all six constraints.
Should I use PUT or PATCH to update a resource?
Use PUT when the client sends a complete replacement of the resource — every field must be included in the request body. Use PATCH when the client sends only the fields that need to change (a partial update). PUT is always idempotent: sending the same PUT request twice produces the same result. PATCH can be idempotent depending on the implementation. Most modern APIs prefer PATCH for updates because it reduces bandwidth and avoids accidentally overwriting fields the client did not intend to change.
How should I version my REST API?
The three main API versioning strategies are URL path versioning (e.g., /api/v1/users), custom header versioning (e.g., API-Version: 2), and query parameter versioning (e.g., /api/users?version=1). URL path versioning is the most common and easiest for developers to understand and test in a browser. Header versioning keeps URLs clean but is harder to test. Query parameter versioning is simple but can interfere with caching. Choose one strategy and apply it consistently across your entire API.
What is the best pagination strategy for REST APIs?
The best pagination strategy depends on your use case. Offset-based pagination (?page=2&limit=20) is simplest and allows jumping to any page, but performs poorly on large datasets. Cursor-based pagination uses an opaque token pointing to the last item, offering consistent performance regardless of dataset size. Keyset pagination uses a visible field value (like an ID). For most APIs, cursor-based pagination is recommended because it handles real-time data changes gracefully and scales well.
How do I secure a REST API?
To secure a REST API: always use HTTPS to encrypt data in transit; implement authentication using OAuth 2.0, JWT tokens, or API keys; use authorization to control access to specific resources; validate and sanitize all input to prevent injection attacks; implement rate limiting to prevent abuse; configure CORS headers to restrict which origins can call your API; use security headers like X-Content-Type-Options and Strict-Transport-Security; never expose sensitive data in URLs or error messages; log all access for auditing; and keep dependencies updated to patch known vulnerabilities.
Conclusion
REST API design is a skill that combines technical knowledge with practical experience. The principles in this guide — clear URL structures, correct HTTP methods and status codes, consistent error handling, proper authentication, sensible pagination, and thorough documentation — form the foundation of APIs that developers enjoy using.
The key takeaways from this guide:
- Use nouns in URLs, verbs in HTTP methods —
GET /users, notGET /getUsers. - Use the correct HTTP status code for every response — 201 for creation, 204 for deletion, 422 for validation errors.
- Design consistent error responses with machine-readable error codes and human-readable messages.
- Implement pagination, filtering, and sorting on every collection endpoint from day one.
- Version your API (URL path versioning is the simplest) and maintain at least two versions.
- Secure your API with HTTPS, proper authentication, input validation, rate limiting, and CORS.
- Document everything with OpenAPI and provide real examples for every endpoint.
- Test thoroughly — success cases, error cases, edge cases, and performance.
Good API design is not about following every rule perfectly. It is about making consistent, well-documented choices that your consumers can rely on. When in doubt, look at how established APIs (Stripe, GitHub, Twilio) handle the same problem — they have invested thousands of engineering hours into getting the details right.
Build and Test Your APIs
Put this knowledge into practice with DevToolbox: test endpoints with the HTTP Request Tester, format responses with the JSON Formatter, and debug authentication with the JWT Decoder. All free, all client-side, no installation required.