Skip to content

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 requestId in every response for debugging
  • Use consistent error codes across all endpoints
  • Return null for 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-Type and Cache-Control headers

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

  1. Validate all input with a schema library like Zod — never trust client data
  2. Use an envelope pattern for consistent response structure across all endpoints
  3. Idempotency keys prevent duplicate operations on retries
  4. Standardize error responses with machine-readable codes and human-readable messages
  5. Content negotiation lets the same endpoint serve JSON, CSV, or XML based on the Accept header
  6. Include request IDs in every response to make debugging possible