Building a REST API seems straightforward on the surface: create a few HTTP endpoints (GET /users, POST /orders), serialize JSON responses, and return HTTP status codes.
However, designing an API that withstands years of production growth, client updates, and security auditing requires adhering to established architectural patterns.
In this article, we will examine REST architectural constraints, resource naming standards, standardized error handling with RFC 7807, API versioning strategies, and implementing Idempotency Keys for critical write operations.
1. The Core Architectural Constraints of REST
REST (Representational State Transfer) was introduced by Roy Fielding in his 2000 doctoral dissertation. To be considered truly "RESTful," an API must adhere to six fundamental constraints:
- Client-Server Architecture: Separation of UI concerns from data storage concerns.
- Statelessness: Every client request to the server must contain all necessary context (e.g. JWT tokens). The server stores zero session state between requests.
- Cacheability: Responses must define themselves as cacheable or non-cacheable (
Cache-Controlheaders) to prevent stale data. - Layered System: The client cannot tell whether it is connected directly to an end server or an intermediate proxy/load balancer.
- Uniform Interface: Standardized URIs, HTTP verbs (
GET,POST,PUT,PATCH,DELETE), and representations. - HATEOAS (Hypermedia As The Engine Of Application State): Responses provide dynamic hypermedia links pointing to related actions.
// Example of HATEOAS hypermedia links in an Order response
{
"id": "ord_99812",
"status": "pending_payment",
"amount": 149.99,
"_links": {
"self": { "href": "/api/v1/orders/ord_99812", "method": "GET" },
"pay": { "href": "/api/v1/orders/ord_99812/payments", "method": "POST" },
"cancel": { "href": "/api/v1/orders/ord_99812", "method": "DELETE" }
}
}
2. Resource Naming & URI Hierarchy Standards
A well-designed REST API uses nouns for URIs, never verbs. HTTP methods dictate the action, while the URI path identifies the resource.
Bad vs. Good Endpoint Examples
| Bad Endpoint (Verb-Based / RPC) | Good Endpoint (RESTful Resource) | HTTP Method |
|---|---|---|
/api/getUsers | /api/v1/users | GET |
/api/createNewUser | /api/v1/users | POST |
/api/updateUser?id=42 | /api/v1/users/42 | PATCH |
/api/deleteUser | /api/v1/users/42 | DELETE |
/api/getUserOrders | /api/v1/users/42/orders | GET |
Query Parameters for Filtering, Sorting, and Pagination
Keep URI paths clean by putting filtering, sorting, and pagination options in query strings:
GET /api/v1/products?category=electronics&min_price=100&sort=-created_at&page=2&limit=20
Recommended pagination envelope response format:
{
"data": [ /* Array of products */ ],
"pagination": {
"total_items": 142,
"page": 2,
"limit": 20,
"total_pages": 8,
"has_next": true
}
}
3. Standardized Error Handling: RFC 7807 (Problem Details)
Many APIs return inconsistent error structures across different endpoints. RFC 7807 defines application/problem+json as the standard HTTP error response format.
Instead of returning custom unstructured objects, return RFC 7807 compliant payloads:
{
"type": "https://api.example.com/errors/insufficient-funds",
"title": "Insufficient Funds",
"status": 422,
"detail": "Your account balance of $12.50 is insufficient for order value $149.99.",
"instance": "/api/v1/orders/ord_99812/payments",
"invalid_params": [
{
"name": "amount",
"reason": "Exceeds remaining daily spending limit of $50.00"
}
]
}
Implementing RFC 7807 Error Response Helper in TypeScript
export interface ProblemDetails {
type: string;
title: string;
status: number;
detail: string;
instance?: string;
invalid_params?: Array<{ name: string; reason: string }>;
}
export class APIError extends Error {
constructor(public problem: ProblemDetails) {
super(problem.detail);
Object.setPrototypeOf(this, APIError.prototype);
}
}
// Example instantiation
export function createUnprocessableEntityError(
detail: string,
invalidParams?: Array<{ name: string; reason: string }>
): APIError {
return new APIError({
type: 'https://api.example.com/errors/unprocessable-entity',
title: 'Unprocessable Entity',
status: 422,
detail,
invalid_params: invalidParams,
});
}
4. API Versioning Battleground: Which Strategy to Choose?
When making breaking schema changes, versioning is required. There are three primary versioning approaches:
1. URI Path Versioning (Recommended for Public APIs)
GET /api/v1/users vs GET /api/v2/users
- Pros: Highly visible, simple to cache with proxies/CDNs, straightforward documentation.
- Cons: Can pollute routing namespaces.
2. Custom Header Versioning
X-API-Version: 2
- Pros: Keeps URIs clean.
- Cons: Harder to test in browser search bars and requires custom CORS configuration.
3. Accept Header / Media Type Content Negotiation
Accept: application/vnd.myapi.v2+json
- Pros: Strict REST compliance according to hypermedia standards.
- Cons: High complexity for client developers and third-party integrations.
Industry Verdict: Stripe, GitHub, and Twilio recommend URI Path Versioning (
/v1/) for simplicity and reliability across client platforms.
5. Idempotent Requests & Implementing Idempotency-Key
HTTP methods have predefined idempotency guarantees:
- Idempotent:
GET,PUT,DELETE,HEAD,OPTIONS. Calling them multiple times with identical parameters produces the same side-effects. - Non-Idempotent:
POST(e.g. creating a credit card payment charge).
If a network timeout occurs during a POST /payments request, the client cannot know whether the server executed the payment or failed before reaching the handler. Retrying the request blindly can cause double-charging.
Implementing Idempotency-Key Headers
By requiring an Idempotency-Key: uuid-v4 header on state-changing endpoints, the server caches response payloads in Redis:
import { Request, Response, NextFunction } from 'express';
import Redis from 'ioredis';
const redis = new Redis();
export async function idempotencyMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
const key = req.headers['idempotency-key'] as string;
if (!key) return next(); // Skip if header not present
const cacheKey = `idempotency:${key}`;
const cachedResponse = await redis.get(cacheKey);
if (cachedResponse) {
const { status, body } = JSON.parse(cachedResponse);
return res.status(status).json(body);
}
// Intercept res.json to cache response before sending to client
const originalJson = res.json.bind(res);
res.json = (body: any) => {
if (res.statusCode >= 200 && res.statusCode < 300) {
redis.set(cacheKey, JSON.stringify({ status: res.statusCode, body }), 'EX', 86400); // 24h TTL
}
return originalJson(body);
};
next();
}
Summary Checklist for Modern REST APIs
- Use plural nouns for endpoints (
/users,/orders). - Use query parameters for filtering, pagination, and sorting.
- Adopt RFC 7807 (
application/problem+json) for structured error responses. - Version your public endpoints with URI paths (
/v1/). - Protect critical financial/write operations with
Idempotency-Keyheaders.