Original by Jeff Sternal (Oct 30, 2023) Updated with modern security, engineering, and best practices
- Update 2025-07-26: Added security practices, OpenAPI documentation, versioning strategies, rate limiting, CORS, authentication/authorization patterns, and modern engineering practices
In my career, I have consumed hundreds of REST APIs and produced dozens. Since I often see the same mistakes repeated in API design, I thought it might be nice to write down a set of best practices. This updated version incorporates modern security requirements, engineering practices, and lessons learned from the evolution of API design.
Most developers today understand "REST" as some sort of HTTP-based API with noun-based URLs. The term when coined meant something slightly different, but language changes. Don't worry about what is or isn't REST; focus on building pragmatic, useful APIs.
It's an arbitrary convention, but it's well-established and I have found violations tend to be a leading indicator of "this API will have rough edges".
# GOOD
GET /products # get all the products
GET /products/{product_id} # get one product
# BAD
GET /product/{product_id}
A common mistake seems to be trying to build your relational model into your URL structure.
# GOOD
GET /v3/application/listings/{listing_id}
# BAD
PATCH /v3/application/shops/{shop_id}/listings/{listing_id}
GET /v3/application/shops/{shop_id}/listings/{listing_id}/properties
The {listing_id} is globally unique; there's no reason for {shop_id} to be part of the URL.
- URLs are resource identifiers, not representations
- HTTP already offers headers (
Accept,Accept-Charset,Accept-Encoding,Accept-Language) to negotiate representations - JSON should be the default anyway
The top level response from an endpoint should always be an object, never an array.
# GOOD
GET /things returns:
{
"data": [{ ...thing1...}, { ...thing2...}],
"meta": {
"totalCount": 42,
"page": 1,
"pageSize": 20
}
}
# BAD
GET /things returns:
[{ ...thing1...}, { ...thing2...}]
Return arrays of objects instead of map structures.
# BAD
GET /things returns:
{
"KEY1": { "id": "KEY1", "foo": "bar" },
"KEY2": { "id": "KEY2", "foo": "baz" }
}
# GOOD
GET /things returns:
{
"data": [
{ "id": "KEY1", "foo": "bar" },
{ "id": "KEY2", "foo": "baz" }
]
}
Always use strings for object identifiers, even if your internal representation is numeric.
# BAD
{ "id": 123 }
# GOOD
{ "id": "123" }
Make different types of IDs self-describing:
- Stripe's identifiers:
cus_1MVpWEJVZPfyS2HyRgVDkwiZ - Consider UUIDs with prefixes:
prod_550e8400-e29b-41d4-a716-446655440000 - Or use URN format:
urn:app:product:550e8400-e29b-41d4-a716-446655440000
Use a different 400-level error code that clients can interpret as "I understand what you're asking for, but I don't have it". Options:
- 410 GONE
- 422 Unprocessable Entity (with clear error message)
Keep field names, formats, and structures consistent across your entire API.
{
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "Product with ID 'prod_123' not found",
"type": "NotFoundError",
"timestamp": "2025-07-26T10:15:30Z",
"traceId": "550e8400-e29b-41d4-a716-446655440000",
"details": {
"resourceType": "product",
"resourceId": "prod_123"
},
"help": "https://api.example.com/docs/errors/RESOURCE_NOT_FOUND"
}
}Support idempotency for non-GET requests:
POST /orders
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
{
"product": "frisbee",
"quantity": 2
}
Use ISO8601 format with UTC timezone:
- Good:
"2025-07-26T10:15:30Z" - Bad:
1722001230000
Authentication Options:
-
API Keys (for server-to-server):
X-API-Key: sk_live_4eC39HqLyjWDarjtT1zdp7dc -
Bearer Tokens (JWT or OAuth 2.0):
Authorization: Bearer eyJhbGciOiJIUzI1NiIs... -
OAuth 2.0 with PKCE (for public clients):
# Authorization request GET /oauth/authorize? client_id=CLIENT_ID& redirect_uri=REDIRECT_URI& response_type=code& scope=read:products& state=RANDOM_STATE& code_challenge=CODE_CHALLENGE& code_challenge_method=S256
Include rate limit headers in responses:
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 999
X-RateLimit-Reset: 1722004800
X-RateLimit-Retry-After: 3600
Consider different limits for:
- Authenticated vs anonymous requests
- Different subscription tiers
- Different endpoints (writes vs reads)
{
"email": "user@example.com", // Validate email format
"age": 25, // Validate range: 0-150
"role": "admin", // Validate enum: ["user", "admin", "moderator"]
"url": "https://example.com" // Validate URL format and protocol
}Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With
Access-Control-Max-Age: 86400
Access-Control-Allow-Credentials: true
Never use:
Access-Control-Allow-Origin: * // With credentials
- Enforce TLS 1.3 minimum
- Implement HSTS headers
- Use certificate pinning for mobile apps
- Redirect all HTTP to HTTPS
URL Path Versioning (recommended for clarity):
https://api.example.com/v1/products
https://api.example.com/v2/products
Header Versioning (alternative):
GET /products
API-Version: 2025-07-26
Sunset Headers for deprecation:
Sunset: Sat, 31 Dec 2025 23:59:59 GMT
Deprecation: true
Link: <https://api.example.com/v2/products>; rel="successor-version"
openapi: 3.1.0
info:
title: Example API
version: 1.0.0
description: |
Production API for Example Service.
## Authentication
Use Bearer token in Authorization header.
## Rate Limiting
1000 requests per hour for authenticated users.
servers:
- url: https://api.example.com/v1
description: Production server
paths:
/products:
get:
summary: List all products
operationId: listProducts
parameters:
- name: page
in: query
schema:
type: integer
minimum: 1
default: 1
- name: pageSize
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 20
responses:
'200':
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/ProductList'Include examples in multiple languages:
// TypeScript/JavaScript
import { ExampleAPI } from '@example/api-client';
const api = new ExampleAPI({
apiKey: process.env.EXAMPLE_API_KEY,
timeout: 30000,
retryConfig: {
retries: 3,
retryDelay: 1000
}
});
try {
const products = await api.products.list({
page: 1,
pageSize: 20
});
console.log(products.data);
} catch (error) {
console.error('API Error:', error);
}// PHP
use Example\ApiClient;
use Example\ApiException;
$client = new ApiClient([
'apiKey' => $_ENV['EXAMPLE_API_KEY'],
'timeout' => 30,
'retryAttempts' => 3
]);
try {
$products = $client->products->list([
'page' => 1,
'pageSize' => 20
]);
foreach ($products->data as $product) {
echo $product->name . PHP_EOL;
}
} catch (ApiException $e) {
error_log('API Error: ' . $e->getMessage());
}Cursor-based pagination (recommended for large datasets):
{
"data": [...],
"pagination": {
"hasMore": true,
"nextCursor": "eyJpZCI6MTAwfQ==",
"prevCursor": "eyJpZCI6ODB9"
}
}Offset-based pagination (simpler, but has limitations):
{
"data": [...],
"pagination": {
"page": 2,
"pageSize": 20,
"totalPages": 50,
"totalCount": 1000
}
}Allow clients to request only needed fields:
GET /products?fields=id,name,price
GET /products?fields=id,name,category(id,name)
Cache-Control: max-age=3600, private
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Last-Modified: Wed, 21 Oct 2025 07:28:00 GMT
Support conditional requests:
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"
If-Modified-Since: Wed, 21 Oct 2025 07:28:00 GMT
POST /products/bulk
{
"operations": [
{
"method": "create",
"data": { "name": "Product 1", "price": 29.99 }
},
{
"method": "update",
"id": "prod_123",
"data": { "price": 34.99 }
},
{
"method": "delete",
"id": "prod_456"
}
]
}
// Response
{
"results": [
{ "status": "success", "id": "prod_789", "data": {...} },
{ "status": "success", "id": "prod_123", "data": {...} },
{ "status": "error", "id": "prod_456", "error": {...} }
]
}POST https://customer.example.com/webhooks
{
"id": "evt_1MqRtBEcg9tqpw",
"type": "order.completed",
"created": "2025-07-26T10:15:30Z",
"data": {
"object": {
"id": "ord_123",
"status": "completed"
}
}
}
// Include webhook signature for security
X-Webhook-Signature: sha256=d57c5c6f4b6e5a106b7b12d95a0c1c3f08f9e245c36596e2c49e0c5584e31234GET /health
{
"status": "healthy",
"version": "1.2.3",
"timestamp": "2025-07-26T10:15:30Z",
"checks": {
"database": "healthy",
"cache": "healthy",
"queue": "healthy"
}
}Include correlation IDs in all requests:
X-Request-ID: 550e8400-e29b-41d4-a716-446655440000
X-Correlation-ID: 7e0a7c0b-f3f4-4d04-a4d4-3e5c6f6e7d8e
Accept-Encoding: gzip, deflate, br
Content-Encoding: gzip
For HTTP/2 and HTTP/3:
- Use multiplexing
- Implement proper connection reuse
- Set appropriate timeouts
- Minimize payload sizes
- Support partial responses
- Implement aggressive caching
- Consider GraphQL for flexible queries
Production: https://api.example.com
Sandbox: https://sandbox-api.example.com
With test data and safe operations.
Use tools like Pact or Spring Cloud Contract to ensure API compatibility.
Track:
- Response times (p50, p95, p99)
- Error rates by endpoint
- Rate limit violations
- Authentication failures
Building great APIs requires attention to detail, consistency, and a focus on developer experience. By following these rules and staying current with security and engineering best practices, you'll create APIs that are secure, performant, and a joy to use.
Remember: The best API is one that developers can understand quickly, integrate easily, and rely on completely.