Request & Response Design
Design clean request and response structures with proper headers, content negotiation, error formats, and envelope patterns.
Why Structure Matters
A well-designed request/response structure makes your API predictable. When every endpoint follows the same patterns, developers can guess how a new endpoint works without reading the docs.
Real-World Analogy
Like filling out a form at a government office — the form has required fields (headers), a body (your information), and you get back a receipt with a status stamp.
Request Design
Request Headers
Headers carry metadata about the request. These are the essential ones:
// Common request headers
const headers = {
// Content type of the request body
"Content-Type": "application/json",
// Authentication
"Authorization": "Bearer eyJhbGciOi...",
// Client identification
"User-Agent": "MyApp/2.1.0 (iOS 17.2)",
// Request tracing
"X-Request-Id": "req_abc123def456",
// Idempotency (prevent duplicate operations)
"Idempotency-Key": "idem_789xyz",
// API version
"Accept": "application/vnd.myapi.v2+json",
}; Idempotency Keys
Idempotency keys prevent duplicate operations when a client retries a request (network timeout, etc.).
import { randomUUID } from "crypto";
// Client side — generate a unique key per operation
async function createPayment(amount: number) {
const idempotencyKey = randomUUID();
const response = await fetch("/api/payments", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({ amount, currency: "BDT" }),
});
return response.json();
}
// Server side — check for duplicate requests
app.post("/api/payments", async (req, res) => {
const idempotencyKey = req.headers["idempotency-key"];
if (idempotencyKey) {
const existing = await redis.get(`idempotency:${idempotencyKey}`);
if (existing) {
return res.status(200).json(JSON.parse(existing));
}
}
const payment = await processPayment(req.body);
if (idempotencyKey) {
await redis.set(
`idempotency:${idempotencyKey}`,
JSON.stringify(payment),
"EX",
86400 // 24 hours
);
}
res.status(201).json(payment);
}); Request Body Validation
Always validate incoming data. Never trust the client.
import { z } from "zod";
// Define the schema
const CreateUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
role: z.enum(["user", "admin", "moderator"]).default("user"),
age: z.number().int().min(13).max(150).optional(),
tags: z.array(z.string()).max(10).default([]),
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
// Validation middleware
function validate<T>(schema: z.ZodSchema<T>) {
return (req: express.Request, res: express.Response, next: express.NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({
error: "Validation Error",
details: result.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
code: issue.code,
})),
});
}
req.body = result.data;
next();
};
}
app.post("/api/users", validate(CreateUserSchema), async (req, res) => {
const user = await db.users.create(req.body);
res.status(201).json({ data: user });
}); Never Trust Client Input
- Always validate on the server, even if you validate on the client
- Sanitize strings to prevent XSS and SQL injection
- Set maximum sizes for arrays and strings
- Use allowlists (enum of valid values) instead of blocklists
Response Design
The Envelope Pattern
Wrap your responses in a consistent structure so clients always know what to expect:
// Single resource
{
"data": {
"id": "user_42",
"name": "Fatima Khan",
"email": "fatima@example.com"
},
"meta": {
"requestId": "req_abc123"
}
}
// Collection
{
"data": [
{ "id": "user_1", "name": "Rahim" },
{ "id": "user_2", "name": "Karim" }
],
"meta": {
"page": 1,
"limit": 20,
"total": 156,
"hasMore": true
}
}
// Error
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request body",
"details": [
{ "field": "email", "message": "Must be a valid email address" }
]
},
"meta": {
"requestId": "req_def456"
}
} Implementing the Envelope
// Response helper functions
interface ApiResponse<T> {
data?: T;
error?: {
code: string;
message: string;
details?: Array<{ field: string; message: string }>;
};
meta?: Record<string, unknown>;
}
function success<T>(data: T, meta?: Record<string, unknown>): ApiResponse<T> {
return { data, meta };
}
function paginated<T>(
data: T[],
page: number,
limit: number,
total: number
): ApiResponse<T[]> {
return {
data,
meta: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasMore: page * limit < total,
},
};
}
function error(code: string, message: string, details?: Array<{ field: string; message: string }>): ApiResponse<never> {
return {
error: { code, message, details },
};
}
// Usage
app.get("/api/users", async (req, res) => {
const page = Number(req.query.page) || 1;
const limit = Number(req.query.limit) || 20;
const [users, total] = await Promise.all([
db.users.find().skip((page - 1) * limit).limit(limit),
db.users.count(),
]);
res.json(paginated(users, page, limit, total));
});
app.get("/api/users/:id", async (req, res) => {
const user = await db.users.findById(req.params.id);
if (!user) {
return res.status(404).json(error("NOT_FOUND", `User ${req.params.id} not found`));
}
res.json(success(user));
}); Error Response Standards
Consistent Error Format
// Define error codes as constants
const ErrorCodes = {
VALIDATION_ERROR: "VALIDATION_ERROR",
NOT_FOUND: "NOT_FOUND",
UNAUTHORIZED: "UNAUTHORIZED",
FORBIDDEN: "FORBIDDEN",
CONFLICT: "CONFLICT",
RATE_LIMITED: "RATE_LIMITED",
INTERNAL_ERROR: "INTERNAL_ERROR",
} as const;
// Custom error class
class ApiError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
public details?: Array<{ field: string; message: string }>
) {
super(message);
}
}
// Global error handler middleware
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
if (err instanceof ApiError) {
return res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
details: err.details,
},
meta: { requestId: req.headers["x-request-id"] },
});
}
// Unexpected errors — never leak internals
console.error("Unhandled error:", err);
res.status(500).json({
error: {
code: "INTERNAL_ERROR",
message: "An unexpected error occurred",
},
meta: { requestId: req.headers["x-request-id"] },
});
}); Content Negotiation
Content negotiation lets clients specify what format they want:
app.get("/api/reports/:id", async (req, res) => {
const report = await db.reports.findById(req.params.id);
if (!report) {
return res.status(404).json(error("NOT_FOUND", "Report not found"));
}
const accept = req.accepts(["json", "csv", "xml"]);
switch (accept) {
case "json":
res.type("json").json({ data: report });
break;
case "csv":
res.type("csv").send(convertToCSV(report));
break;
case "xml":
res.type("xml").send(convertToXML(report));
break;
default:
res.status(406).json(error("NOT_ACCEPTABLE", "Supported formats: JSON, CSV, XML"));
}
}); Response Design Checklist
- Always wrap responses in an envelope:
{ data, error, meta } - Include a
requestIdin every response for debugging - Use consistent error codes across all endpoints
- Return
nullfor missing optional fields, omit them, or use a default — pick one strategy and stick with it - Include pagination metadata for all list endpoints
- Set proper
Content-TypeandCache-Controlheaders
Response Headers
// Common response headers
app.use((req, res, next) => {
// Request tracing
const requestId = req.headers["x-request-id"] || randomUUID();
res.set("X-Request-Id", requestId);
// Rate limiting info
res.set("X-RateLimit-Limit", "1000");
res.set("X-RateLimit-Remaining", "999");
res.set("X-RateLimit-Reset", "1700003600");
// Security headers
res.set("X-Content-Type-Options", "nosniff");
res.set("X-Frame-Options", "DENY");
// Caching
res.set("Cache-Control", "no-store"); // default, override per route
next();
});
// Cache static data
app.get("/api/countries", (req, res) => {
res.set("Cache-Control", "public, max-age=86400"); // Cache 24h
res.json({ data: countries });
}); Key Takeaways
- Validate all input with a schema library like Zod — never trust client data
- Use an envelope pattern for consistent response structure across all endpoints
- Idempotency keys prevent duplicate operations on retries
- Standardize error responses with machine-readable codes and human-readable messages
- Content negotiation lets the same endpoint serve JSON, CSV, or XML based on the Accept header
- Include request IDs in every response to make debugging possible