
Api Design Patterns
- 371 installs
- 60 repo stars
- Updated May 16, 2026
- asyrafhussin/agent-skills
api-design-patterns is a version 2.0.0 agent skill with 38 REST API rules across seven categories covering resources, errors, security, pagination, versioning, responses, and OpenAPI docs.
About
api-design-patterns is a MIT-licensed version 2.0.0 skill from asyrafhussin/agent-skills encoding RESTful API design principles for consistent, developer-friendly HTTP services. It organizes 38 rules across seven priority categories: resource design (nouns, plural resources, HTTP methods, status codes, idempotency, HATEOAS), error handling (machine-readable codes, validation details, request IDs), security (OAuth2/JWT, RBAC, rate limiting, CORS, HTTPS), pagination and filtering (cursor and offset), versioning (URL and header strategies), response format conventions, and OpenAPI documentation with changelogs. Prefixes like rest-, error-, sec-, page-, ver-, resp-, and doc- tag each rule for quick reference during reviews. Developers reach for api-design-patterns when designing new endpoints, reviewing existing routes, implementing error envelopes, or planning deprecation. Triggers include design API, review API, REST best practices, and API patterns prompts. The skill suits backend engineers and agent-tool authors who need predictable contracts before clients integrate.
- REST and GraphQL layout guidance
- Versioning, pagination, and filtering patterns
- Auth, rate limits, and error response standards
- Naming and resource modeling conventions
- Agent-tool API contract alignment
Api Design Patterns by the numbers
- 371 all-time installs (skills.sh)
- Ranked #1,153 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/asyrafhussin/agent-skills --skill api-design-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 371 |
|---|---|
| repo stars | ★ 60 |
| Last updated | May 16, 2026 |
| Repository | asyrafhussin/agent-skills ↗ |
How do you design consistent REST API endpoints?
Design REST/GraphQL endpoints, versioning, auth, pagination, and error contracts before or while implementing backend services and agent-exposed tools.
Who is it for?
Backend engineers designing or reviewing REST APIs who need checklist-driven rules for resources, errors, security, pagination, and OpenAPI documentation.
Skip if: GraphQL-only schemas or gRPC/protobuf services where REST resource conventions and HTTP status-code rules do not apply.
When should I use this skill?
User asks to design APIs, review endpoints, implement error responses, set up pagination, or apply REST best practices.
What you get
REST endpoint specs with error envelopes, pagination parameters, versioning strategy, security controls, and OpenAPI documentation aligned to 38 rules.
- endpoint specifications
- error response contracts
- OpenAPI documentation outline
By the numbers
- Contains 38 rules across 7 categories in version 2.0.0
- Seven priority categories from critical resource design through documentation
Files
API Design Patterns
RESTful API design principles for building consistent, developer-friendly APIs. Contains 38 rules across 7 categories covering resource design, error handling, security, pagination, versioning, response format, and documentation.
Metadata
- Version: 2.0.0
- Rule Count: 38 rules across 7 categories
- License: MIT
When to Apply
Reference these guidelines when:
- Designing new API endpoints
- Reviewing existing API structure
- Implementing error handling and validation
- Setting up pagination, filtering, and sorting
- Planning API versioning strategy
- Configuring API security (auth, CORS, rate limiting)
- Writing API documentation (OpenAPI/Swagger)
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Resource Design | CRITICAL | rest- |
| 2 | Error Handling | CRITICAL | error- |
| 3 | Security | CRITICAL | sec- |
| 4 | Pagination & Filtering | HIGH | page-, filter-, sort- |
| 5 | Versioning | HIGH | ver- |
| 6 | Response Format | MEDIUM | resp- |
| 7 | Documentation | MEDIUM | doc- |
Quick Reference
1. Resource Design (CRITICAL)
rest-nouns-not-verbs- Use nouns for endpoints, not verbsrest-plural-resources- Use plural resource namesrest-http-methods- Correct HTTP method usage (GET, POST, PUT, PATCH, DELETE)rest-nested-resources- Proper resource nesting (max 2 levels)rest-status-codes- Appropriate HTTP status codesrest-idempotency- Idempotent operations with idempotency keysrest-hateoas- Hypermedia links for discoverabilityrest-resource-actions- Non-CRUD actions as sub-resources
2. Error Handling (CRITICAL)
error-consistent-format- Consistent error response structureerror-meaningful-messages- Helpful, actionable error messageserror-validation-details- Field-level validation errorserror-error-codes- Machine-readable error codeserror-no-stack-traces- Never expose stack traces in productionerror-request-id- Include request IDs for debugging
3. Security (CRITICAL)
sec-authentication- Proper auth implementation (OAuth2/JWT)sec-authorization- Resource-level permissions (RBAC)sec-rate-limiting- Prevent abuse with rate limitingsec-input-validation- Validate and sanitize all inputsec-cors-config- CORS configuration with whitelistssec-https-only- Enforce HTTPS for all trafficsec-sensitive-data- Protect passwords, tokens, PII
4. Pagination & Filtering (HIGH)
page-cursor-based- Cursor pagination for large datasetspage-offset-based- Offset pagination for simple casespage-consistent-params- Consistent parameter namingpage-metadata- Include pagination metadata in responsesfilter-query-params- Filter via query parameterssort-flexible- Flexible sorting with-prefix for descending
5. Versioning (HIGH)
ver-url-path- Version in URL path (/api/v1/)ver-header-based- Version via Accept headerver-backward-compatible- Maintain backward compatibilityver-deprecation- Deprecation strategy with Sunset header
6. Response Format (MEDIUM)
resp-consistent-structure- Consistent response enveloperesp-json-conventions- JSON naming conventionsresp-partial-responses- Field selection (sparse fieldsets)resp-compression- Response compression (gzip/Brotli)
7. Documentation (MEDIUM)
doc-openapi- OpenAPI/Swagger specificationdoc-examples- Request/response examplesdoc-changelog- API changelog
Essential Guidelines
Resource Naming
# ❌ Verbs in URLs
GET /getUsers
POST /createUser
# ✅ Nouns with HTTP methods
GET /users # List users
POST /users # Create user
GET /users/123 # Get user
PUT /users/123 # Update user (full)
PATCH /users/123 # Update user (partial)
DELETE /users/123 # Delete userError Response Format
{
"error": {
"code": "VALIDATION_ERROR",
"message": "The request contains invalid data",
"details": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "Please provide a valid email address"
}
],
"request_id": "req_abc123"
}
}Pagination
{
"data": [...],
"meta": {
"current_page": 2,
"per_page": 20,
"total_pages": 10,
"total_count": 195
},
"links": {
"first": "/users?page=1&per_page=20",
"prev": "/users?page=1&per_page=20",
"next": "/users?page=3&per_page=20",
"last": "/users?page=10&per_page=20"
}
}Rate Limiting Headers
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 998
X-RateLimit-Reset: 1640995200How to Use
Read individual rule files for detailed explanations:
rules/rest-http-methods.md
rules/error-consistent-format.md
rules/page-cursor-based.md
rules/sec-authentication.md
rules/ver-url-path.md
rules/doc-openapi.mdReferences
- RESTful API Guidelines
- Zalando RESTful API Guidelines
- Microsoft API Guidelines
- Google API Design Guide
- OpenAPI Specification
Full Compiled Document
For the complete guide with all rules expanded: AGENTS.md
{
"name": "API Design Patterns",
"version": "2.0.0",
"description": "RESTful API design principles for building consistent, developer-friendly APIs. Contains 38 rules across 7 categories covering resource design, error handling, security, pagination, versioning, response format, and documentation.",
"license": "MIT",
"author": {
"name": "Agent Skills Contributors",
"url": "https://github.com/AsyrafHussin/agent-skills"
},
"categories": [
{
"name": "Resource Design",
"prefix": "rest",
"impact": "CRITICAL",
"ruleCount": 8
},
{
"name": "Error Handling",
"prefix": "error",
"impact": "CRITICAL",
"ruleCount": 6
},
{
"name": "Security",
"prefix": "sec",
"impact": "CRITICAL",
"ruleCount": 7
},
{
"name": "Pagination & Filtering",
"prefix": "page/filter/sort",
"impact": "HIGH",
"ruleCount": 6
},
{
"name": "Versioning",
"prefix": "ver",
"impact": "HIGH",
"ruleCount": 4
},
{
"name": "Response Format",
"prefix": "resp",
"impact": "MEDIUM",
"ruleCount": 4
},
{
"name": "Documentation",
"prefix": "doc",
"impact": "MEDIUM",
"ruleCount": 3
}
],
"references": [
"https://restfulapi.net",
"https://zalando.github.io/restful-api-guidelines",
"https://github.com/microsoft/api-guidelines",
"https://cloud.google.com/apis/design",
"https://swagger.io/specification"
],
"keywords": [
"api",
"rest",
"restful",
"http",
"error-handling",
"pagination",
"versioning",
"security",
"openapi",
"swagger"
],
"rulesTotal": 38,
"lastUpdated": "2026-03-14"
}
API Design Patterns v2.0.0
RESTful API design principles for building consistent, developer-friendly APIs.
Overview
- Resource design with proper HTTP methods and status codes
- Consistent error handling with machine-readable codes
- Security (authentication, authorization, rate limiting, CORS)
- Cursor and offset pagination with filtering and sorting
- API versioning strategies and deprecation
- Response format conventions and compression
- OpenAPI documentation and changelog
- 38 rules across 7 categories
Categories
1. Resource Design (Critical)
Nouns over verbs, plural resources, proper nesting, HTTP methods, status codes, idempotency, HATEOAS.
2. Error Handling (Critical)
Consistent error format, meaningful messages, validation details, error codes, request IDs.
3. Security (Critical)
Authentication (OAuth2/JWT), authorization (RBAC), rate limiting, input validation, CORS, HTTPS.
4. Pagination & Filtering (High)
Cursor-based and offset pagination, consistent parameters, filtering, sorting.
5. Versioning (High)
URL path versioning, header versioning, backward compatibility, deprecation strategy.
6. Response Format (Medium)
Consistent envelope, JSON naming conventions, sparse fieldsets, compression.
7. Documentation (Medium)
OpenAPI/Swagger specification, request/response examples, API changelog.
Usage
Review my API design
Check REST best practices for these endpoints
Design error responses for my API
Set up pagination for this endpointReferences
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Resource Design (rest)
Impact: CRITICAL Description: Foundational REST principles for API endpoint design. Proper resource naming with nouns, plural collections, correct HTTP method semantics, appropriate status codes, idempotency, and HATEOAS links ensure APIs are intuitive, predictable, and follow industry standards.
2. Error Handling (error)
Impact: CRITICAL Description: Consistent error response format across all endpoints. Machine-readable error codes, field-level validation details, meaningful messages, request IDs for debugging, and never exposing stack traces in production enable clients to handle errors programmatically.
3. Security (sec)
Impact: CRITICAL Description: API security fundamentals. Authentication (OAuth2/JWT), authorization (RBAC), rate limiting, input validation and sanitization, CORS configuration with whitelists, HTTPS enforcement, and sensitive data protection prevent unauthorized access and common attack vectors.
4. Pagination & Filtering (page, filter, sort)
Impact: HIGH Description: Efficient data retrieval for collections. Cursor pagination for large datasets, offset pagination for simple cases, consistent parameter naming, pagination metadata in responses, query parameter filtering, and flexible sorting enable clients to efficiently navigate large datasets.
5. Versioning (ver)
Impact: HIGH Description: API versioning strategies for evolving APIs without breaking existing consumers. URL path versioning, header-based versioning, backward compatibility rules, and deprecation strategy with Sunset headers ensure smooth API evolution.
6. Response Format (resp)
Impact: MEDIUM Description: Consistent response structure and conventions. Response envelopes, JSON naming conventions (camelCase vs snake_case), sparse fieldsets for bandwidth optimization, and response compression reduce payload sizes and improve developer experience.
7. Documentation (doc)
Impact: MEDIUM Description: API documentation standards. OpenAPI/Swagger specifications, complete request/response examples, and API changelogs ensure consumers can discover, understand, and track changes to your API.
Rule Title Here
Impact: MEDIUM (optional impact description)
Brief explanation of the rule and why it matters for API design. Focus on the benefits and problems it solves.
Incorrect (description of what's wrong):
// Bad example showing the anti-pattern
{
"example": "bad"
}Correct (description of what's right):
// Good example showing the recommended pattern
{
"example": "good"
}// Additional code examples if needed
app.get('/endpoint', (req, res) => {
// Implementation
});Why
1. Benefit 1: Explanation of why this matters 2. Benefit 2: Another important reason 3. Benefit 3: Additional context
Reference: Link to documentation
API Changelog
Impact: MEDIUM (Prevents production outages from undiscovered breaking changes)
Without a changelog, API consumers discover breaking changes when their production integrations fail. A dated, categorized changelog gives consumers advance notice of changes so they can adapt their code before it breaks.
Incorrect
// ❌ No changelog at all
// Consumers discover changes through:
// - Production errors after a deploy
// - Slack messages: "Did something change with /users?"
// - Trial and error comparing old vs new behavior
// - Reading git commit history (if the repo is even public)
// ❌ Or: a vague changelog with no useful detail
## Updates
- Fixed some bugs
- Improved performance
- Updated user endpointProblems:
- Breaking changes surprise consumers in production
- No way to know when a field was deprecated or removed
- Consumers cannot plan migration timelines for breaking changes
- Support teams fielding questions that a changelog would answer
Correct
Keep a Changelog Format
# API Changelog
All notable changes to the API are documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/).
## [1.5.0] - 2024-03-15
### Added
- `GET /users` now supports `?sort=created_at` and `?sort=last_login_at`
query parameters for sorting results
- New `phone_verified` boolean field on User resource
- `POST /users/bulk` endpoint for creating up to 100 users in one request
### Changed
- `GET /users` default `per_page` changed from 50 to 20 for better
performance. Use `?per_page=50` to restore previous behavior.
### Deprecated
- `GET /users` query parameter `?order` is deprecated in favor of `?sort`.
`?order` will be removed in v2.0.0 (scheduled for 2024-09-01).
- User field `username` is deprecated. Use `email` as the unique identifier.
Field will be removed in v2.0.0.
## [1.4.2] - 2024-02-28
### Fixed
- `PATCH /users/:id` now correctly returns 422 instead of 500 when email
format is invalid
- Pagination `total_pages` calculation was off by one for exact multiples
## [1.4.1] - 2024-02-10
### Security
- Rate limiting on `POST /auth/login` reduced from 60 to 10 requests per
minute to mitigate brute-force attacks
## [1.4.0] - 2024-01-20
### Added
- `GET /users/:id/activity` endpoint returning recent account activity
- Support for `fields` query parameter on all GET endpoints (sparse
fieldsets)
- `X-Request-Id` response header on all endpoints
### Removed
- **BREAKING:** `GET /users/search` endpoint removed. Use
`GET /users?q=search_term` instead. See [migration guide](https://docs.example.com/migration/search).
## [1.3.0] - 2024-01-05
### Added
- `Accept-Encoding: br` (Brotli) compression support
- `suspend` and `reactivate` actions on `POST /users/:id/actions`
### Changed
- Error responses now include `request_id` field in the error envelopeChangelog Entry Categories
Added — New endpoints, fields, query parameters, features
Changed — Non-breaking changes to existing behavior
Deprecated — Features that will be removed in a future version
Removed — BREAKING: features removed in this version
Fixed — Bug fixes
Security — Security-related changesCommunicating Breaking Changes
// ✅ Deprecation header on responses for deprecated features
HTTP/1.1 200 OK
Deprecation: Sun, 01 Sep 2024 00:00:00 GMT
Sunset: Sun, 01 Sep 2024 00:00:00 GMT
Link: <https://docs.example.com/migration/search>; rel="deprecation"// ✅ Deprecation notice in response body
{
"data": { ... },
"meta": {
"warnings": [
{
"code": "DEPRECATED_FIELD",
"message": "Field 'username' is deprecated and will be removed on 2024-09-01. Use 'email' instead.",
"see": "https://docs.example.com/migration/username"
}
]
}
}Best Practices
✅ Date every entry (ISO 8601: YYYY-MM-DD)
✅ Link changelog from your API documentation landing page
✅ Tag breaking changes with "BREAKING:" prefix
✅ Include migration guides for breaking changes
✅ Notify consumers via email/webhook before breaking changes
✅ Give at least 6 months notice before removing deprecated features
✅ Version the changelog alongside the API (same repo, same release)Benefits:
- Consumers can review changes before updating their integrations
- Breaking changes come with advance notice and migration guides
- Deprecation timelines let consumers plan upgrades
- Reduces support burden by answering "what changed?" proactively
Reference: Keep a Changelog
Request/Response Examples
Impact: MEDIUM (Reduces time-to-first-successful-call from hours to minutes)
Schema definitions tell developers what is possible, but examples show them what to actually do. A developer can copy a curl command, run it, and have a working integration in minutes instead of interpreting abstract schemas for hours.
Incorrect
// ❌ Documentation with only schema definitions, no examples
POST /users
Request Body: CreateUserRequest schema
Response: User schema (201) | Error schema (422)
Parameters:
first_name: string, required
last_name: string, required
email: string, required, format: email
password: string, required, minLength: 8
// Developer must guess: What does a real request look like?
// What headers are needed? What does the response actually contain?
// What does a validation error look like?Problems:
- Developers must mentally construct requests from abstract schema descriptions
- No way to quickly copy-paste and test an endpoint
- Error response shapes are a mystery until they happen in production
- Onboarding new API consumers takes hours instead of minutes
Correct
Complete Request Example
# ✅ Create a user — complete curl example
curl -X POST https://api.example.com/v1/users \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"first_name": "Jane",
"last_name": "Doe",
"email": "jane@example.com",
"password": "secureP@ssw0rd"
}'Success Response Example
HTTP/1.1 201 Created
Content-Type: application/json
Location: /v1/users/123// ✅ 201 Created — show exact response body
{
"data": {
"id": 123,
"first_name": "Jane",
"last_name": "Doe",
"email": "jane@example.com",
"status": "active",
"created_at": "2024-03-15T10:30:00Z"
},
"meta": {
"request_id": "req_abc123"
}
}Validation Error Example
# ✅ Show what happens with invalid input
curl -X POST https://api.example.com/v1/users \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." \
-H "Content-Type: application/json" \
-d '{
"first_name": "",
"email": "not-an-email",
"password": "short"
}'// ✅ 422 Unprocessable Entity — show exact error shape
{
"error": {
"code": "VALIDATION_ERROR",
"message": "The request contains invalid data",
"details": [
{
"field": "first_name",
"code": "REQUIRED",
"message": "First name is required"
},
{
"field": "last_name",
"code": "REQUIRED",
"message": "Last name is required"
},
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "Please provide a valid email address"
},
{
"field": "password",
"code": "TOO_SHORT",
"message": "Password must be at least 8 characters"
}
],
"request_id": "req_def456"
}
}Authentication Error Example
# ✅ Show what happens with missing/invalid token
curl -X GET https://api.example.com/v1/users \
-H "Content-Type: application/json"// ✅ 401 Unauthorized
{
"error": {
"code": "UNAUTHORIZED",
"message": "Authentication required. Provide a valid Bearer token.",
"request_id": "req_ghi789"
}
}List Endpoint Example
# ✅ GET with query parameters
curl -X GET "https://api.example.com/v1/users?status=active&page=2&per_page=10" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." \
-H "Accept: application/json"// ✅ 200 OK — show pagination metadata
{
"data": [
{
"id": 123,
"first_name": "Jane",
"last_name": "Doe",
"email": "jane@example.com",
"status": "active"
},
{
"id": 456,
"first_name": "John",
"last_name": "Smith",
"email": "john@example.com",
"status": "active"
}
],
"meta": {
"page": 2,
"per_page": 10,
"total": 142,
"total_pages": 15,
"request_id": "req_jkl012"
}
}Documentation Checklist
Every endpoint should include:
✅ Complete curl command with headers and body
✅ At least one success response (with realistic data)
✅ At least one error response (validation or auth)
✅ Query parameter examples for GET endpoints
✅ Response headers when relevant (Location, Link, etc.)Benefits:
- Developers copy-paste and have a working request in seconds
- Error examples set correct expectations for client-side error handling
- Realistic data in examples clarifies field formats and semantics
- Reduces support tickets from confused API consumers
Reference: Stripe API Documentation — gold standard for API examples
OpenAPI/Swagger Specification
Impact: MEDIUM (Cuts API integration time by 50% with machine-readable docs)
An OpenAPI specification serves as both documentation and a contract. It enables auto-generated client SDKs, interactive docs, and automated testing. Without it, developers rely on outdated wikis or reverse-engineering API behavior from code.
Incorrect
// ❌ No formal API documentation
// Option A: Nothing at all — developers read source code
// Option B: Outdated wiki page last edited 18 months ago
// Option C: Slack messages like "hey how does the /users endpoint work?"
// Option D: README with a few curl examples that no longer match the APIProblems:
- Developers spend hours discovering endpoints and request formats by trial and error
- No single source of truth — information scattered across wikis, Slack, and code comments
- Documentation drifts out of sync with the actual API
- Cannot auto-generate client SDKs, mock servers, or test suites
Correct
Minimal OpenAPI 3.1 Specification
# ✅ openapi.yaml — machine-readable, always up to date
openapi: "3.1.0"
info:
title: User Management API
version: "1.2.0"
description: API for managing user accounts
contact:
name: API Support
email: api-support@example.com
servers:
- url: https://api.example.com/v1
description: Production
- url: https://staging-api.example.com/v1
description: Staging
security:
- bearerAuth: []
paths:
/users:
get:
summary: List users
operationId: listUsers
tags:
- Users
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: [active, inactive, suspended]
responses:
"200":
description: A paginated list of users
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: "#/components/schemas/User"
meta:
$ref: "#/components/schemas/PaginationMeta"
example:
data:
- id: 123
first_name: Jane
last_name: Doe
email: jane@example.com
status: active
created_at: "2024-01-15T10:30:00Z"
meta:
page: 1
per_page: 20
total: 142
total_pages: 8
"401":
$ref: "#/components/responses/Unauthorized"
post:
summary: Create a user
operationId: createUser
tags:
- Users
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateUserRequest"
example:
first_name: Jane
last_name: Doe
email: jane@example.com
password: secureP@ssw0rd
responses:
"201":
description: User created successfully
content:
application/json:
schema:
type: object
properties:
data:
$ref: "#/components/schemas/User"
"422":
$ref: "#/components/responses/ValidationError"
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
User:
type: object
properties:
id:
type: integer
first_name:
type: string
last_name:
type: string
email:
type: string
format: email
status:
type: string
enum: [active, inactive, suspended]
created_at:
type: string
format: date-time
required:
- id
- first_name
- last_name
- email
- status
CreateUserRequest:
type: object
properties:
first_name:
type: string
minLength: 1
maxLength: 100
last_name:
type: string
minLength: 1
maxLength: 100
email:
type: string
format: email
password:
type: string
minLength: 8
required:
- first_name
- last_name
- email
- password
PaginationMeta:
type: object
properties:
page:
type: integer
per_page:
type: integer
total:
type: integer
total_pages:
type: integer
responses:
Unauthorized:
description: Authentication required
content:
application/json:
schema:
type: object
properties:
error:
type: object
properties:
code:
type: string
example: UNAUTHORIZED
message:
type: string
example: Authentication required
ValidationError:
description: Request validation failed
content:
application/json:
schema:
type: object
properties:
error:
type: object
properties:
code:
type: string
example: VALIDATION_ERROR
message:
type: string
details:
type: array
items:
type: object
properties:
field:
type: string
code:
type: string
message:
type: stringRecommended Tools
Swagger UI — Interactive docs with "Try it out" button
Redoc — Clean, responsive three-panel documentation
Stoplight — Visual OpenAPI editor with linting
Spectral — OpenAPI linting and validation CLI
openapi-generator — Auto-generate client SDKs in 40+ languagesBenefits:
- Single source of truth that stays in sync with the codebase
- Auto-generate client SDKs, mock servers, and test stubs
- Interactive documentation lets developers test endpoints in the browser
- CI/CD can validate the spec to catch breaking changes before deploy
Reference: OpenAPI Specification 3.1
Consistent Error Response Format
Impact: CRITICAL (Enables predictable error handling across API)
Inconsistent error formats force API consumers to handle multiple error structures, leading to fragile client code. A consistent error format makes APIs predictable, easier to debug, and simpler to integrate.
Incorrect
// ❌ Different formats across endpoints
// Endpoint A
{ "error": "Not found" }
// Endpoint B
{ "message": "Invalid email", "status": 400 }
// Endpoint C
{ "errors": ["Field required", "Invalid format"] }
// Endpoint D
{
"success": false,
"errorMessage": "Something went wrong"
}
// Endpoint E - just a string
"User not found"Problems:
- Clients can't predict error structure
- Different parsing logic needed for each endpoint
- Hard to build generic error handlers
- Inconsistent developer experience
Correct
Standard Error Envelope
// ✅ Every error follows the same structure
{
"error": {
"code": "ERROR_CODE",
"message": "Human-readable message",
"details": [],
"request_id": "req_abc123"
}
}Validation Errors (422)
{
"error": {
"code": "VALIDATION_ERROR",
"message": "The request contains invalid data",
"details": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "Please provide a valid email address"
},
{
"field": "password",
"code": "TOO_SHORT",
"message": "Password must be at least 8 characters",
"meta": {
"min_length": 8,
"actual_length": 5
}
},
{
"field": "age",
"code": "OUT_OF_RANGE",
"message": "Age must be between 18 and 120",
"meta": {
"min": 18,
"max": 120,
"actual": 15
}
}
],
"request_id": "req_abc123"
}
}Not Found (404)
{
"error": {
"code": "NOT_FOUND",
"message": "User with ID 'usr_123' not found",
"details": [
{
"resource": "user",
"field": "id",
"value": "usr_123"
}
],
"request_id": "req_def456"
}
}Authentication Error (401)
{
"error": {
"code": "UNAUTHORIZED",
"message": "Authentication required",
"details": [
{
"code": "TOKEN_EXPIRED",
"message": "Your access token has expired"
}
],
"request_id": "req_ghi789"
}
}Authorization Error (403)
{
"error": {
"code": "FORBIDDEN",
"message": "You don't have permission to access this resource",
"details": [
{
"resource": "order",
"action": "delete",
"reason": "Only admins can delete orders"
}
],
"request_id": "req_jkl012"
}
}Conflict Error (409)
{
"error": {
"code": "CONFLICT",
"message": "A user with this email already exists",
"details": [
{
"field": "email",
"code": "DUPLICATE",
"value": "john@example.com"
}
],
"request_id": "req_mno345"
}
}Rate Limit Error (429)
{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Please retry after 60 seconds.",
"details": [
{
"limit": 100,
"window": "1 minute",
"retry_after": 60
}
],
"request_id": "req_pqr678"
}
}Server Error (500)
{
"error": {
"code": "INTERNAL_ERROR",
"message": "An unexpected error occurred. Please try again later.",
"request_id": "req_stu901"
}
}Note: Never expose stack traces or internal details in production.
Implementation
TypeScript/Node.js
// ✅ Error classes
abstract class AppError extends Error {
abstract readonly code: string;
abstract readonly statusCode: number;
readonly details: ErrorDetail[];
constructor(message: string, details: ErrorDetail[] = []) {
super(message);
this.details = details;
}
toJSON() {
return {
error: {
code: this.code,
message: this.message,
details: this.details.length > 0 ? this.details : undefined,
}
};
}
}
class ValidationError extends AppError {
readonly code = 'VALIDATION_ERROR';
readonly statusCode = 422;
}
class NotFoundError extends AppError {
readonly code = 'NOT_FOUND';
readonly statusCode = 404;
}
class UnauthorizedError extends AppError {
readonly code = 'UNAUTHORIZED';
readonly statusCode = 401;
}
// Error handler middleware
function errorHandler(err, req, res, next) {
const requestId = req.id || generateRequestId();
if (err instanceof AppError) {
return res.status(err.statusCode).json({
error: {
...err.toJSON().error,
request_id: requestId,
}
});
}
// Log unexpected errors
logger.error('Unexpected error', { error: err, requestId });
// Generic response for unknown errors
res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
request_id: requestId,
}
});
}Laravel/PHP
<?php
// ✅ app/Exceptions/AppException.php
abstract class AppException extends Exception
{
abstract public function getErrorCode(): string;
abstract public function getStatusCode(): int;
protected array $details = [];
public function setDetails(array $details): self
{
$this->details = $details;
return $this;
}
public function render(): JsonResponse
{
return response()->json([
'error' => [
'code' => $this->getErrorCode(),
'message' => $this->getMessage(),
'details' => $this->details ?: null,
'request_id' => request()->id(),
],
], $this->getStatusCode());
}
}
class ValidationException extends AppException
{
public function getErrorCode(): string
{
return 'VALIDATION_ERROR';
}
public function getStatusCode(): int
{
return 422;
}
public static function fromValidator(Validator $validator): self
{
$details = [];
foreach ($validator->errors()->toArray() as $field => $messages) {
$details[] = [
'field' => $field,
'code' => 'INVALID',
'message' => $messages[0],
];
}
return (new self('The request contains invalid data'))
->setDetails($details);
}
}
// Handler
class Handler extends ExceptionHandler
{
public function render($request, Throwable $e)
{
if ($e instanceof AppException) {
return $e->render();
}
if ($e instanceof ModelNotFoundException) {
return response()->json([
'error' => [
'code' => 'NOT_FOUND',
'message' => 'Resource not found',
'request_id' => $request->id(),
],
], 404);
}
// Log and return generic error
Log::error($e->getMessage(), ['exception' => $e]);
return response()->json([
'error' => [
'code' => 'INTERNAL_ERROR',
'message' => 'An unexpected error occurred',
'request_id' => $request->id(),
],
], 500);
}
}Error Code Naming
// ✅ Use SCREAMING_SNAKE_CASE
VALIDATION_ERROR
NOT_FOUND
UNAUTHORIZED
FORBIDDEN
RATE_LIMITED
INTERNAL_ERROR
// ✅ Be specific
INVALID_EMAIL_FORMAT
PASSWORD_TOO_SHORT
DUPLICATE_EMAIL
TOKEN_EXPIRED
INSUFFICIENT_FUNDS
// ❌ Avoid vague codes
ERROR
FAILED
BAD_REQUESTBenefits:
- Predictable API behavior across all endpoints
- Reusable client-side error handling logic
- Easier debugging with request IDs
- Clear error codes for programmatic handling
- Human-readable messages for display
- Detailed validation feedback for field-level errors
Reference: RFC 7807 - Problem Details for HTTP APIs
Use Machine-Readable Error Codes
Impact: CRITICAL (Enables programmatic error handling and client recovery)
Include standardized, machine-readable error codes alongside human-readable messages to enable programmatic error handling.
Incorrect
// ❌ Only human-readable messages
{
"error": "The user was not found"
}
// ❌ HTTP status codes only
{
"status": 404
}
// ❌ Inconsistent or vague codes
{
"error_code": "ERR001"
}
{
"code": 1234
}
{
"error_type": "bad_thing_happened"
}// ❌ No error codes for programmatic handling
app.get('/users/:id', async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) {
// Client can't reliably detect "not found" vs other 404s
res.status(404).json({ message: 'User not found' });
}
});Problems:
- Clients must parse human-readable messages which change with localization
- No stable identifier for programmatic error handling
- Numeric codes are meaningless without a reference table
- Inconsistent code formats across endpoints
- Cannot implement targeted recovery strategies per error type
- Monitoring dashboards cannot categorize errors precisely
Correct
// ✅ Define error codes as constants
const ErrorCodes = {
// Authentication & Authorization
AUTH_TOKEN_MISSING: 'auth_token_missing',
AUTH_TOKEN_INVALID: 'auth_token_invalid',
AUTH_TOKEN_EXPIRED: 'auth_token_expired',
AUTH_INSUFFICIENT_PERMISSIONS: 'auth_insufficient_permissions',
// Validation
VALIDATION_ERROR: 'validation_error',
VALIDATION_REQUIRED_FIELD: 'validation_required_field',
VALIDATION_INVALID_FORMAT: 'validation_invalid_format',
VALIDATION_OUT_OF_RANGE: 'validation_out_of_range',
// Resources
RESOURCE_NOT_FOUND: 'resource_not_found',
RESOURCE_ALREADY_EXISTS: 'resource_already_exists',
RESOURCE_CONFLICT: 'resource_conflict',
RESOURCE_DELETED: 'resource_deleted',
// Rate Limiting
RATE_LIMIT_EXCEEDED: 'rate_limit_exceeded',
// Business Logic
INSUFFICIENT_FUNDS: 'insufficient_funds',
INVENTORY_UNAVAILABLE: 'inventory_unavailable',
ORDER_CANNOT_BE_CANCELLED: 'order_cannot_be_cancelled',
SUBSCRIPTION_EXPIRED: 'subscription_expired',
// Server Errors
INTERNAL_ERROR: 'internal_error',
SERVICE_UNAVAILABLE: 'service_unavailable',
DEPENDENCY_ERROR: 'dependency_error'
};
// Error factory
class APIError extends Error {
constructor(code, message, statusCode = 400, details = null) {
super(message);
this.code = code;
this.statusCode = statusCode;
this.details = details;
}
}
// Usage in routes
app.get('/users/:id', async (req, res, next) => {
try {
const user = await db.findUser(req.params.id);
if (!user) {
throw new APIError(
ErrorCodes.RESOURCE_NOT_FOUND,
'User not found',
404,
{ resourceType: 'user', resourceId: req.params.id }
);
}
res.json(user);
} catch (error) {
next(error);
}
});
app.post('/orders', async (req, res, next) => {
try {
const product = await db.findProduct(req.body.productId);
if (product.stock < req.body.quantity) {
throw new APIError(
ErrorCodes.INVENTORY_UNAVAILABLE,
'Not enough items in stock',
422,
{
requested: req.body.quantity,
available: product.stock,
productId: req.body.productId
}
);
}
// Process order...
} catch (error) {
next(error);
}
});
// Error handler
app.use((err, req, res, next) => {
if (err instanceof APIError) {
return res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
details: err.details
}
});
}
// Unknown error
res.status(500).json({
error: {
code: ErrorCodes.INTERNAL_ERROR,
message: 'An unexpected error occurred'
}
});
});# ✅ Python with error codes
from enum import Enum
from fastapi import FastAPI, HTTPException
from typing import Optional, Any
class ErrorCode(str, Enum):
# Authentication
AUTH_TOKEN_MISSING = "auth_token_missing"
AUTH_TOKEN_INVALID = "auth_token_invalid"
AUTH_TOKEN_EXPIRED = "auth_token_expired"
AUTH_INSUFFICIENT_PERMISSIONS = "auth_insufficient_permissions"
# Validation
VALIDATION_ERROR = "validation_error"
VALIDATION_REQUIRED_FIELD = "validation_required_field"
# Resources
RESOURCE_NOT_FOUND = "resource_not_found"
RESOURCE_ALREADY_EXISTS = "resource_already_exists"
RESOURCE_CONFLICT = "resource_conflict"
# Business Logic
INSUFFICIENT_FUNDS = "insufficient_funds"
INVENTORY_UNAVAILABLE = "inventory_unavailable"
# Server
INTERNAL_ERROR = "internal_error"
SERVICE_UNAVAILABLE = "service_unavailable"
class APIError(Exception):
def __init__(
self,
code: ErrorCode,
message: str,
status_code: int = 400,
details: Optional[dict] = None
):
self.code = code
self.message = message
self.status_code = status_code
self.details = details
app = FastAPI()
@app.exception_handler(APIError)
async def api_error_handler(request, exc: APIError):
return JSONResponse(
status_code=exc.status_code,
content={
"error": {
"code": exc.code.value,
"message": exc.message,
"details": exc.details
}
}
)
@app.get("/users/{user_id}")
async def get_user(user_id: int):
user = await db.get_user(user_id)
if not user:
raise APIError(
code=ErrorCode.RESOURCE_NOT_FOUND,
message=f"User with ID {user_id} not found",
status_code=404,
details={"resource_type": "user", "resource_id": user_id}
)
return user// ✅ Error response with code
{
"error": {
"code": "inventory_unavailable",
"message": "Not enough items in stock to fulfill your order",
"details": {
"productId": "prod_123",
"productName": "Widget Pro",
"requested": 10,
"available": 3
}
}
}// ✅ Client-side error handling
async function createOrder(orderData: OrderData): Promise<Order> {
const response = await fetch('/api/orders', {
method: 'POST',
body: JSON.stringify(orderData)
});
if (!response.ok) {
const error = await response.json();
// Programmatic handling based on error code
switch (error.error.code) {
case 'inventory_unavailable':
showInventoryWarning(error.error.details);
break;
case 'insufficient_funds':
redirectToPaymentUpdate();
break;
case 'auth_token_expired':
await refreshToken();
return createOrder(orderData); // Retry
case 'rate_limit_exceeded':
await delay(error.error.details.retryAfter * 1000);
return createOrder(orderData); // Retry
default:
showGenericError(error.error.message);
}
throw new APIError(error);
}
return response.json();
}Error Code Naming Conventions
| Category | Pattern | Examples |
|---|---|---|
| Auth | auth_* | auth_token_expired, auth_invalid_credentials |
| Validation | validation_* | validation_error, validation_invalid_email |
| Resource | resource_* | resource_not_found, resource_conflict |
| Business | Domain-specific | insufficient_funds, inventory_unavailable |
| Rate Limit | rate_limit_* | rate_limit_exceeded |
| Server | internal_* or service_* | internal_error, service_unavailable |
Benefits:
- Code can switch on error codes to take appropriate recovery action
- Error codes remain stable even when messages change or are localized
- Error codes can be documented and referenced in API docs
- Precise alerting and monitoring dashboards based on error categories
- Tests can assert on specific error codes rather than message strings
- Messages can be translated while codes stay constant across locales
Reference: Google Cloud API Error Model
Provide Meaningful Error Messages
Impact: CRITICAL (Reduces support burden and improves developer experience)
Error messages should be clear, actionable, and help users understand what went wrong and how to fix it.
Incorrect
// ❌ Vague or unhelpful messages
{
"error": "Error"
}
{
"error": "Bad request"
}
{
"error": "Invalid input"
}
{
"error": "Something went wrong"
}
{
"error": "null"
}
{
"error": "Error code: 0x8004005"
}
// ❌ Technical jargon users can't understand
{
"error": "SQLITE_CONSTRAINT_FOREIGNKEY"
}
{
"error": "MongoServerError: E11000 duplicate key error"
}// ❌ Unhelpful error responses
app.post('/users', async (req, res) => {
try {
await db.createUser(req.body);
} catch (error) {
res.status(400).json({ error: 'Bad request' }); // What's bad about it?
}
});Problems:
- Users cannot determine what went wrong or how to fix it
- Developers waste time debugging vague error messages
- Support burden increases with every unclear error
- Internal database errors leak implementation details
- No actionable guidance for the next step
Correct
// ✅ Clear, actionable error messages
const errorMessages = {
email_required: 'Email address is required to create an account',
email_invalid: 'Please provide a valid email address (e.g., user@example.com)',
email_taken: 'An account with this email already exists. Try signing in instead',
password_weak: 'Password must be at least 8 characters with one uppercase, one lowercase, and one number',
rate_limited: 'Too many requests. Please wait 60 seconds before trying again',
resource_not_found: 'The requested user could not be found. It may have been deleted',
permission_denied: 'You do not have permission to access this resource. Contact your administrator',
payment_failed: 'Your payment could not be processed. Please check your card details and try again'
};
app.post('/users', async (req, res, next) => {
try {
const { email, password, name } = req.body;
// Specific validation messages
if (!email) {
return res.status(400).json({
error: {
code: 'validation_error',
message: 'Email address is required to create an account',
field: 'email'
}
});
}
if (!isValidEmail(email)) {
return res.status(400).json({
error: {
code: 'validation_error',
message: 'Please provide a valid email address (e.g., user@example.com)',
field: 'email',
provided: email
}
});
}
const existingUser = await db.findUserByEmail(email);
if (existingUser) {
return res.status(409).json({
error: {
code: 'resource_conflict',
message: 'An account with this email already exists. Try signing in or resetting your password',
field: 'email',
links: {
signin: '/auth/signin',
passwordReset: '/auth/password-reset'
}
}
});
}
const user = await db.createUser({ email, password, name });
res.status(201).json(user);
} catch (error) {
next(error);
}
});# ✅ FastAPI with meaningful errors
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, validator, EmailStr
app = FastAPI()
class UserCreate(BaseModel):
email: EmailStr
password: str
name: str
@validator('password')
def password_strength(cls, v):
if len(v) < 8:
raise ValueError(
'Password must be at least 8 characters long. '
'Strong passwords help protect your account.'
)
if not any(c.isupper() for c in v):
raise ValueError(
'Password must contain at least one uppercase letter (A-Z)'
)
if not any(c.islower() for c in v):
raise ValueError(
'Password must contain at least one lowercase letter (a-z)'
)
if not any(c.isdigit() for c in v):
raise ValueError(
'Password must contain at least one number (0-9)'
)
return v
@validator('name')
def name_not_empty(cls, v):
if not v or not v.strip():
raise ValueError(
'Name is required. Please enter your full name as you would '
'like it to appear on your profile.'
)
if len(v) > 100:
raise ValueError(
f'Name must be 100 characters or less. You entered {len(v)} characters.'
)
return v.strip()
@app.post("/users")
async def create_user(user: UserCreate):
existing = await db.find_user_by_email(user.email)
if existing:
raise HTTPException(
status_code=409,
detail={
"code": "email_already_registered",
"message": (
f"The email '{user.email}' is already registered. "
"If this is your email, try signing in or resetting your password."
),
"suggestions": [
"Sign in with your existing account",
"Reset your password if you forgot it",
"Use a different email address"
]
}
)
return await db.create_user(user)// ✅ Good error response examples
// Validation error with guidance
{
"error": {
"code": "validation_error",
"message": "The email address format is invalid",
"details": [
{
"field": "email",
"message": "Please provide a valid email address (e.g., user@example.com)",
"provided": "not-an-email",
"suggestion": "Check for typos and ensure the email includes an @ symbol"
}
]
}
}
// Resource not found with context
{
"error": {
"code": "resource_not_found",
"message": "Order #12345 could not be found",
"details": [
{
"message": "This order may have been deleted or the ID may be incorrect",
"suggestions": [
"Verify the order ID is correct",
"Check your order history for the correct ID",
"The order may have been archived after 90 days"
]
}
]
}
}
// Permission error with next steps
{
"error": {
"code": "permission_denied",
"message": "You don't have permission to delete this project",
"details": [
{
"message": "Only project owners and administrators can delete projects",
"currentRole": "member",
"requiredRoles": ["owner", "admin"],
"suggestion": "Contact the project owner to request deletion or elevated permissions"
}
]
}
}
// Rate limit with retry info
{
"error": {
"code": "rate_limit_exceeded",
"message": "You've made too many requests. Please slow down.",
"details": [
{
"limit": 100,
"window": "1 minute",
"retryAfter": 45,
"message": "You can make another request in 45 seconds"
}
]
}
}Benefits:
- Clear messages help users fix problems without contacting support
- Self-explanatory errors reduce support ticket volume
- API consumers can debug issues faster with meaningful context
- Actionable guidance tells users what to do next, not just what went wrong
- Professional error messages build confidence in your API
- Clear messages are easier to translate for localization
Reference: Microsoft REST API Guidelines - Errors
Never Expose Stack Traces in Production
Impact: CRITICAL (Prevents security vulnerabilities and information disclosure)
Stack traces and internal error details should never be exposed to API clients in production environments, as they reveal implementation details and potential vulnerabilities.
Incorrect
// ❌ Full stack trace in production response
{
"error": "Cannot read property 'id' of undefined",
"stack": "TypeError: Cannot read property 'id' of undefined\n at getUserOrders (/app/src/controllers/orders.js:45:23)\n at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)\n at next (/app/node_modules/express/lib/router/route.js:137:13)\n at authenticate (/app/src/middleware/auth.js:28:5)\n at /app/node_modules/express/lib/router/index.js:284:15"
}
// ❌ Database error details exposed
{
"error": "SequelizeConnectionError: Connection refused to host 'db.internal.company.com' port 5432",
"sql": "SELECT * FROM users WHERE id = 1 AND deleted_at IS NULL"
}
// ❌ Internal paths and configuration
{
"error": "ENOENT: no such file or directory, open '/var/app/config/secrets.json'"
}// ❌ Exposing all error details
app.use((err, req, res, next) => {
res.status(500).json({
error: err.message,
stack: err.stack, // Never do this in production!
code: err.code
});
});Problems:
- Stack traces reveal file paths, dependencies, and code structure attackers can exploit
- Database error messages may expose schemas, connection strings, or query logic
- Internal file paths reveal server configuration and directory structure
- Framework and version information helps attackers find known vulnerabilities
- Violates security standards like PCI-DSS and SOC 2
Correct
// ✅ Secure error handler
const isProduction = process.env.NODE_ENV === 'production';
app.use((err, req, res, next) => {
// Log full error internally
logger.error('Request error', {
error: err.message,
stack: err.stack,
requestId: req.id,
path: req.path,
method: req.method,
userId: req.user?.id
});
// Determine if error is safe to expose
const isOperationalError = err.isOperational || err.expose;
const statusCode = err.statusCode || 500;
// Build safe response
const errorResponse = {
error: {
code: err.code || 'internal_error',
message: isOperationalError
? err.message
: 'An unexpected error occurred. Please try again later.',
requestId: req.id
}
};
// Only include details in development
if (!isProduction && err.stack) {
errorResponse.error._debug = {
message: err.message,
stack: err.stack.split('\n')
};
}
res.status(statusCode).json(errorResponse);
});
// Custom error class for operational errors
class APIError extends Error {
constructor(message, statusCode = 500, code = 'internal_error') {
super(message);
this.statusCode = statusCode;
this.code = code;
this.isOperational = true; // Safe to expose
}
}
// Database error handling
app.get('/users/:id', async (req, res, next) => {
try {
const user = await db.findUser(req.params.id);
if (!user) {
throw new APIError('User not found', 404, 'resource_not_found');
}
res.json(user);
} catch (error) {
if (error instanceof APIError) {
return next(error);
}
// Log the actual database error
logger.error('Database error', {
error: error.message,
stack: error.stack,
query: 'findUser',
params: { id: req.params.id }
});
// Return generic error to client
next(new APIError(
'Unable to retrieve user information',
500,
'service_error'
));
}
});# ✅ FastAPI with secure error handling
import logging
import traceback
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import os
app = FastAPI()
logger = logging.getLogger(__name__)
IS_PRODUCTION = os.getenv("ENVIRONMENT") == "production"
class APIError(Exception):
def __init__(self, message: str, status_code: int = 500, code: str = "internal_error"):
self.message = message
self.status_code = status_code
self.code = code
self.is_operational = True
@app.exception_handler(APIError)
async def api_error_handler(request: Request, exc: APIError):
return JSONResponse(
status_code=exc.status_code,
content={
"error": {
"code": exc.code,
"message": exc.message,
"request_id": request.state.request_id
}
}
)
@app.exception_handler(Exception)
async def generic_error_handler(request: Request, exc: Exception):
# Log full error internally
logger.error(
"Unhandled exception",
extra={
"error": str(exc),
"traceback": traceback.format_exc(),
"request_id": request.state.request_id,
"path": request.url.path,
"method": request.method
}
)
# Return safe response
content = {
"error": {
"code": "internal_error",
"message": "An unexpected error occurred. Please try again later.",
"request_id": request.state.request_id
}
}
# Include debug info only in development
if not IS_PRODUCTION:
content["error"]["_debug"] = {
"type": type(exc).__name__,
"message": str(exc),
"traceback": traceback.format_exc().split("\n")
}
return JSONResponse(status_code=500, content=content)// ✅ Production error response (safe)
{
"error": {
"code": "internal_error",
"message": "An unexpected error occurred. Please try again later.",
"requestId": "req-abc123"
}
}
// ✅ Development error response (with debug info)
{
"error": {
"code": "internal_error",
"message": "An unexpected error occurred. Please try again later.",
"requestId": "req-abc123",
"_debug": {
"type": "TypeError",
"message": "Cannot read property 'id' of undefined",
"traceback": [
"Traceback (most recent call last):",
" File \"app.py\", line 45, in get_user",
" return user.id",
"TypeError: Cannot read property 'id' of undefined"
]
}
}
}What to Log vs. What to Return
| Information | Log Internally | Return to Client |
|---|---|---|
| Error message | Yes | Generic only |
| Stack trace | Yes | Never in production |
| SQL queries | Yes | Never |
| File paths | Yes | Never |
| Internal IPs | Yes | Never |
| Request ID | Yes | Yes |
| Error code | Yes | Yes |
| User ID | Yes | No |
| Timestamps | Yes | Optional |
Benefits:
- Prevents attackers from exploiting revealed file paths, dependencies, and code structure
- Protects database schemas, API keys, and other secrets from leaking
- Hides framework versions that could expose known vulnerabilities
- Clean error messages present a professional API to consumers
- Meets compliance requirements for PCI-DSS, SOC 2, and similar standards
- Request IDs enable correlation between client reports and internal logs
Reference: OWASP Improper Error Handling
Include Request ID in Error Responses
Impact: CRITICAL (Enables log correlation and efficient debugging)
Every API request should have a unique identifier that appears in both the response and server logs, enabling easy correlation for debugging.
Incorrect
// ❌ No request identifier
{
"error": {
"code": "internal_error",
"message": "An unexpected error occurred"
}
}
// User reports error, but support can't find it in logs// ❌ No request tracking
app.get('/users/:id', async (req, res) => {
try {
const user = await db.findUser(req.params.id);
res.json(user);
} catch (error) {
console.log('Error:', error.message); // No way to correlate
res.status(500).json({ error: 'Something went wrong' });
}
});Problems:
- Support cannot locate specific errors in logs when users report issues
- No way to correlate client-side errors with server-side logs
- Debugging requires time-based log searching which is imprecise
- Distributed systems cannot trace requests across services
- No audit trail for specific request flows
Correct
// ✅ Request ID middleware
const { v4: uuidv4 } = require('uuid');
app.use((req, res, next) => {
// Use client-provided ID or generate new one
req.id = req.headers['x-request-id'] || uuidv4();
// Add to response headers
res.setHeader('X-Request-ID', req.id);
// Add to logger context
req.log = logger.child({ requestId: req.id });
next();
});
// Use in routes
app.get('/users/:id', async (req, res, next) => {
req.log.info('Fetching user', { userId: req.params.id });
try {
const user = await db.findUser(req.params.id);
if (!user) {
return res.status(404).json({
error: {
code: 'resource_not_found',
message: 'User not found',
requestId: req.id
}
});
}
res.json(user);
} catch (error) {
req.log.error('Failed to fetch user', {
error: error.message,
stack: error.stack
});
next(error);
}
});
// Error handler includes request ID
app.use((err, req, res, next) => {
req.log.error('Request failed', {
error: err.message,
stack: err.stack,
statusCode: err.statusCode || 500
});
res.status(err.statusCode || 500).json({
error: {
code: err.code || 'internal_error',
message: err.message || 'An unexpected error occurred',
requestId: req.id
}
});
});# ✅ FastAPI with request ID
import uuid
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import logging
from contextvars import ContextVar
app = FastAPI()
logger = logging.getLogger(__name__)
# Context variable for request ID
request_id_var: ContextVar[str] = ContextVar("request_id", default="")
@app.middleware("http")
async def request_id_middleware(request: Request, call_next):
# Get or generate request ID
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
request.state.request_id = request_id
request_id_var.set(request_id)
# Process request
response = await call_next(request)
# Add request ID to response
response.headers["X-Request-ID"] = request_id
return response
# Custom log filter to include request ID
class RequestIdFilter(logging.Filter):
def filter(self, record):
record.request_id = request_id_var.get("")
return True
# Configure logging
handler = logging.StreamHandler()
handler.addFilter(RequestIdFilter())
handler.setFormatter(logging.Formatter(
'%(asctime)s [%(request_id)s] %(levelname)s: %(message)s'
))
logger.addHandler(handler)
@app.exception_handler(Exception)
async def error_handler(request: Request, exc: Exception):
logger.error(f"Request failed: {exc}", exc_info=True)
return JSONResponse(
status_code=500,
content={
"error": {
"code": "internal_error",
"message": "An unexpected error occurred",
"requestId": request.state.request_id
}
},
headers={"X-Request-ID": request.state.request_id}
)
@app.get("/users/{user_id}")
async def get_user(user_id: int, request: Request):
logger.info(f"Fetching user {user_id}")
user = await db.get_user(user_id)
if not user:
return JSONResponse(
status_code=404,
content={
"error": {
"code": "resource_not_found",
"message": f"User {user_id} not found",
"requestId": request.state.request_id
}
}
)
return user// ✅ Error response with request ID
HTTP/1.1 500 Internal Server Error
X-Request-ID: req-550e8400-e29b-41d4-a716-446655440000
{
"error": {
"code": "internal_error",
"message": "An unexpected error occurred. Please try again.",
"requestId": "req-550e8400-e29b-41d4-a716-446655440000"
}
}# ✅ Server logs with request ID
2024-01-15 10:30:00 [req-550e8400-e29b-41d4-a716-446655440000] INFO: Fetching user 123
2024-01-15 10:30:00 [req-550e8400-e29b-41d4-a716-446655440000] ERROR: Database connection timeout
2024-01-15 10:30:00 [req-550e8400-e29b-41d4-a716-446655440000] ERROR: Request failedDistributed Tracing Integration
// ✅ Integration with OpenTelemetry
const { trace, context } = require('@opentelemetry/api');
app.use((req, res, next) => {
const span = trace.getActiveSpan();
// Use trace ID as request ID for distributed tracing
if (span) {
const traceId = span.spanContext().traceId;
req.id = traceId;
req.spanContext = span.spanContext();
} else {
req.id = uuidv4();
}
res.setHeader('X-Request-ID', req.id);
next();
});# ✅ OpenAPI documentation for request ID
components:
headers:
X-Request-ID:
description: Unique identifier for the request, used for debugging and log correlation
schema:
type: string
format: uuid
example: "550e8400-e29b-41d4-a716-446655440000"
schemas:
Error:
type: object
properties:
error:
type: object
properties:
code:
type: string
message:
type: string
requestId:
type: string
description: Unique request identifier for support correlationBenefits:
- Users can provide the request ID when reporting issues for instant log lookup
- Links all log entries for a single request across multiple services
- Request IDs propagate through microservices for end-to-end distributed tracing
- "Please provide the request ID" is faster than "describe what you did"
- Enables tracking individual request paths through infrastructure
- Audit trails require the ability to trace specific requests
Reference: OpenTelemetry Tracing
Include Validation Error Details
Impact: CRITICAL (Enables field-level error feedback for better UX)
When validation fails, provide specific details about which fields failed and why, enabling clients to display targeted error messages.
Incorrect
// ❌ Single vague validation error
{
"error": "Validation failed"
}
// ❌ List without field association
{
"errors": [
"Invalid email",
"Password too short",
"Name required"
]
}
// ❌ Boolean flags without messages
{
"valid": false,
"emailValid": false,
"passwordValid": false
}// ❌ Unhelpful validation response
app.post('/users', (req, res) => {
const errors = validate(req.body);
if (errors.length > 0) {
res.status(400).json({ error: 'Validation failed' });
}
});Problems:
- Clients cannot highlight specific form fields with their errors
- Users must guess which fields need attention
- Multiple form submissions required to discover all errors
- Frontend validation libraries cannot map errors to form fields
- No constraint context (e.g., min length, allowed range) for UI feedback
Correct
// ✅ Detailed validation errors
const { body, validationResult } = require('express-validator');
const validateUser = [
body('email')
.notEmpty().withMessage('Email is required')
.isEmail().withMessage('Must be a valid email address')
.normalizeEmail(),
body('password')
.notEmpty().withMessage('Password is required')
.isLength({ min: 8 }).withMessage('Password must be at least 8 characters')
.matches(/[A-Z]/).withMessage('Password must contain an uppercase letter')
.matches(/[a-z]/).withMessage('Password must contain a lowercase letter')
.matches(/[0-9]/).withMessage('Password must contain a number'),
body('age')
.optional()
.isInt({ min: 0, max: 150 }).withMessage('Age must be between 0 and 150'),
body('username')
.notEmpty().withMessage('Username is required')
.isLength({ min: 3, max: 30 }).withMessage('Username must be 3-30 characters')
.matches(/^[a-zA-Z0-9_]+$/).withMessage('Username can only contain letters, numbers, and underscores')
];
app.post('/users', validateUser, (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
error: {
code: 'validation_error',
message: 'One or more fields have invalid values',
details: errors.array().map(err => ({
field: err.path,
message: err.msg,
value: err.value,
location: err.location // body, query, params
}))
}
});
}
// Create user...
});// ✅ Detailed validation error response
{
"error": {
"code": "validation_error",
"message": "One or more fields have invalid values",
"details": [
{
"field": "email",
"message": "Must be a valid email address",
"value": "not-an-email",
"location": "body"
},
{
"field": "password",
"message": "Password must be at least 8 characters",
"value": "short",
"location": "body",
"constraints": {
"minLength": 8,
"actualLength": 5
}
},
{
"field": "age",
"message": "Age must be between 0 and 150",
"value": -5,
"location": "body",
"constraints": {
"min": 0,
"max": 150
}
}
]
}
}# ✅ FastAPI with detailed validation
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, EmailStr, validator, Field
from typing import Optional, List
app = FastAPI()
class UserCreate(BaseModel):
email: EmailStr
password: str = Field(..., min_length=8, max_length=100)
username: str = Field(..., min_length=3, max_length=30, regex=r'^[a-zA-Z0-9_]+$')
age: Optional[int] = Field(None, ge=0, le=150)
@validator('password')
def password_complexity(cls, v):
errors = []
if not any(c.isupper() for c in v):
errors.append('must contain an uppercase letter')
if not any(c.islower() for c in v):
errors.append('must contain a lowercase letter')
if not any(c.isdigit() for c in v):
errors.append('must contain a number')
if errors:
raise ValueError(f"Password {', '.join(errors)}")
return v
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
details = []
for error in exc.errors():
field = '.'.join(str(loc) for loc in error['loc'] if loc != 'body')
details.append({
'field': field,
'message': error['msg'],
'type': error['type'],
'context': error.get('ctx', {})
})
return JSONResponse(
status_code=422,
content={
'error': {
'code': 'validation_error',
'message': f'{len(details)} validation error(s) found',
'details': details
}
}
)
@app.post("/users")
async def create_user(user: UserCreate):
return {"id": 1, **user.dict()}// ✅ TypeScript/Zod validation with detailed errors
import { z } from 'zod';
import express from 'express';
const UserSchema = z.object({
email: z.string()
.min(1, 'Email is required')
.email('Must be a valid email address'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
.regex(/[a-z]/, 'Password must contain a lowercase letter')
.regex(/[0-9]/, 'Password must contain a number'),
username: z.string()
.min(3, 'Username must be at least 3 characters')
.max(30, 'Username cannot exceed 30 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),
age: z.number()
.int('Age must be a whole number')
.min(0, 'Age cannot be negative')
.max(150, 'Age cannot exceed 150')
.optional()
});
app.post('/users', (req, res) => {
const result = UserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: {
code: 'validation_error',
message: 'Validation failed',
details: result.error.errors.map(err => ({
field: err.path.join('.'),
message: err.message,
code: err.code
}))
}
});
}
// Create user with result.data
});Nested Object Validation
// ✅ Validation errors for nested objects
{
"error": {
"code": "validation_error",
"message": "Validation failed",
"details": [
{
"field": "address.zipCode",
"message": "ZIP code must be 5 digits",
"value": "123"
},
{
"field": "address.country",
"message": "Country is required"
},
{
"field": "contacts[0].email",
"message": "Invalid email format",
"value": "bad-email"
},
{
"field": "contacts[1].phone",
"message": "Phone number must include country code",
"value": "555-1234"
}
]
}
}Benefits:
- Clients can highlight specific form fields with their errors
- Users see exactly which fields need attention without guessing
- Developers can quickly identify validation issues during development
- Users can fix all issues at once instead of submitting multiple times
- Frontend validation libraries can map errors directly to form fields
- Clear validation responses help document expected input formats
Reference: JSON:API Error Objects
Filter via Query Parameters
Impact: HIGH (Cacheable, bookmarkable filtering that leverages HTTP semantics correctly)
Filtering is a read operation and belongs in GET requests with query parameters. Using POST bodies for filtering breaks HTTP cacheability, makes URLs non-shareable, and violates the semantic contract of HTTP methods. Standard query parameters are combinable, cacheable, and immediately understandable.
Incorrect
POST /api/v1/users/search
Content-Type: application/json
{
"filters": {
"status": "active",
"role": "admin",
"created_after": "2024-01-01"
}
}# Or: custom filter syntax that requires a parser
GET /api/v1/users?filter=status:eq:active|role:eq:admin|created:gt:2024-01-01Problems:
- POST for read operations breaks HTTP caching at every layer (CDN, browser, proxy)
- URLs cannot be bookmarked, shared, or logged meaningfully
- Custom filter syntax requires client-side query builders and server-side parsers
- Violates REST semantics — POST implies resource creation or mutation, not retrieval
Correct
GET /api/v1/users?status=active&role=admin&created_after=2024-01-01{
"data": [
{
"id": 42,
"name": "Jane Smith",
"email": "jane@example.com",
"status": "active",
"role": "admin",
"created_at": "2024-03-15T10:30:00Z"
}
],
"meta": {
"total_count": 12,
"filters_applied": {
"status": "active",
"role": "admin",
"created_after": "2024-01-01"
}
}
}# Multiple values for the same field (OR logic)
GET /api/v1/users?status=active&status=pending
# Range filters with clear suffixes
GET /api/v1/orders?total_min=100&total_max=500&created_after=2024-01-01
# Combine with search
GET /api/v1/users?role=admin&q=smithBenefits:
- Fully cacheable by CDNs, reverse proxies, and browsers
- URLs are bookmarkable and shareable — useful for dashboards and saved views
- Filters are self-documenting and combinable with standard
&syntax - No custom parser needed — standard query string parsing libraries handle it
Reference: Google API Design Guide - Standard Methods
Consistent Pagination Parameter Names
Impact: HIGH (Reduces client integration time by 40-60% through predictable parameter conventions)
When different endpoints use different parameter names for pagination, every client integration becomes a special case. Developers waste time reading docs for each endpoint instead of applying one convention everywhere. Pick one style and enforce it across the entire API.
Incorrect
# Users endpoint uses limit/offset
GET /api/v1/users?limit=20&offset=40
# Orders endpoint uses page/per_page
GET /api/v1/orders?page=3&per_page=20
# Products endpoint uses size/number
GET /api/v1/products?size=20&number=3
# Search endpoint uses count/start
GET /api/v1/search?count=20&start=40Problems:
- Client SDKs need endpoint-specific pagination logic instead of a shared helper
- Developers must consult documentation for every endpoint to find the right parameter names
- Generic pagination UI components cannot be reused across different resource types
- Increased chance of bugs when developers assume one convention but the endpoint uses another
Correct
# Offset-based: use "page" + "per_page" everywhere
GET /api/v1/users?page=3&per_page=20
GET /api/v1/orders?page=3&per_page=20
GET /api/v1/products?page=3&per_page=20
# Cursor-based: use "cursor" + "limit" everywhere
GET /api/v1/events?cursor=eyJpZCI6MTIzfQ&limit=20
GET /api/v1/notifications?cursor=eyJpZCI6NDU2fQ&limit=20
GET /api/v1/logs?cursor=eyJpZCI6Nzg5fQ&limit=20Benefits:
- One pagination helper in the client SDK handles all endpoints
- Developers learn the convention once and apply it everywhere
- Generic UI components (pagers, infinite scroll) work with any resource
- API documentation is simpler — pagination is explained once, not per-endpoint
Reference: Microsoft REST API Guidelines - Pagination
Cursor-Based Pagination for Large Datasets
Impact: HIGH (Constant O(1) pagination performance regardless of dataset depth)
Offset pagination degrades linearly as page depth increases because the database must scan and discard all preceding rows. Cursor-based pagination uses an opaque pointer to the last retrieved item, enabling the database to seek directly to the next batch with constant performance.
Incorrect
GET /api/v1/orders?offset=500000&limit=20{
"data": [
{ "id": 500001, "total": 49.99 },
{ "id": 500002, "total": 129.00 }
]
}-- Behind the scenes: database scans 500,000 rows before returning 20
SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 500000;Problems:
- Query time grows linearly with offset — page 25,000 is dramatically slower than page 1
- Database must scan and discard all rows before the offset
- Inconsistent results if rows are inserted or deleted between page requests
- Memory and CPU waste on large tables (millions of rows)
Correct
GET /api/v1/orders?cursor=eyJpZCI6NTAwMDAwfQ&limit=20{
"data": [
{ "id": 500001, "total": 49.99 },
{ "id": 500002, "total": 129.00 }
],
"meta": {
"has_more": true,
"next_cursor": "eyJpZCI6NTAwMDIwfQ"
},
"links": {
"next": "/api/v1/orders?cursor=eyJpZCI6NTAwMDIwfQ&limit=20"
}
}-- Behind the scenes: index seek, constant performance
SELECT * FROM orders WHERE id > 500000 ORDER BY id LIMIT 20;Benefits:
- Constant query time regardless of how deep into the dataset the client has paginated
- Stable results — no skipped or duplicated items when data changes between requests
- Efficient use of database indexes (seek instead of scan)
- Opaque cursor allows server-side implementation changes without breaking clients
Reference: Slack API - Pagination
Include Pagination Metadata in Responses
Impact: HIGH (Eliminates guesswork pagination and reduces unnecessary API calls by 30-50%)
Without pagination metadata, clients must make an extra request to discover there are no more results, or blindly paginate until they receive an empty response. Including metadata in every paginated response gives clients everything they need to render UI controls and make efficient decisions about fetching more data.
Incorrect
GET /api/v1/products?page=2&per_page=20[
{ "id": 21, "name": "Widget A", "price": 9.99 },
{ "id": 22, "name": "Widget B", "price": 14.99 }
]Problems:
- Client cannot distinguish "page has fewer items than per_page" from "this is the last page"
- No total count means page selector UIs and "X results found" labels are impossible
- Client must request the next page to discover it is empty — wasting a round trip
- No navigation links forces clients to manually construct pagination URLs
Correct
GET /api/v1/products?page=2&per_page=20{
"data": [
{ "id": 21, "name": "Widget A", "price": 9.99 },
{ "id": 22, "name": "Widget B", "price": 14.99 }
],
"meta": {
"current_page": 2,
"per_page": 20,
"total_count": 195,
"total_pages": 10,
"has_more": true
},
"links": {
"first": "/api/v1/products?page=1&per_page=20",
"prev": "/api/v1/products?page=1&per_page=20",
"next": "/api/v1/products?page=3&per_page=20",
"last": "/api/v1/products?page=10&per_page=20"
}
}Benefits:
has_morelets infinite-scroll UIs know when to stop fetching without an extra empty requesttotal_countandtotal_pagesenable "Showing 21-40 of 195 results" display- Navigation links let clients follow links instead of constructing URLs, reducing coupling
- Consistent envelope structure makes client-side deserialization predictable across all endpoints
Reference: GitHub REST API - Pagination
Offset Pagination for Simple Cases
Impact: HIGH (Enables random page access and predictable navigation for small-to-medium datasets)
Offset-based pagination is the most intuitive model for clients that need numbered pages, total counts, and the ability to jump to arbitrary pages. It works well for small-to-medium datasets and should always include metadata and navigation links so clients never have to guess the pagination state.
Incorrect
GET /api/v1/articles?page=2[
{ "id": 21, "title": "Introduction to REST" },
{ "id": 22, "title": "API Versioning" }
]Problems:
- Bare array provides no pagination context — client cannot determine total pages or current position
- No navigation links — client must construct URLs manually and guess when to stop
- No indication of page size — unclear how many items per page the server returned
- Client cannot build a page selector UI without total count information
Correct
GET /api/v1/articles?page=2&per_page=20{
"data": [
{ "id": 21, "title": "Introduction to REST" },
{ "id": 22, "title": "API Versioning" }
],
"meta": {
"current_page": 2,
"per_page": 20,
"total_pages": 10,
"total_count": 195
},
"links": {
"first": "/api/v1/articles?page=1&per_page=20",
"prev": "/api/v1/articles?page=1&per_page=20",
"next": "/api/v1/articles?page=3&per_page=20",
"last": "/api/v1/articles?page=10&per_page=20"
}
}Benefits:
- Full pagination envelope lets clients render page selectors, "showing X of Y" indicators, and navigation controls
- Navigation links follow HATEOAS principles — clients follow links rather than constructing URLs
prevandnextare null-safe (omitted on first and last pages respectively)per_pageparameter gives clients control over batch size within server-enforced limits
Reference: JSON:API - Pagination
Response Compression
Impact: MEDIUM (Reduces JSON payload transfer size by 60-80%)
JSON is highly compressible because of its repetitive structure (keys, braces, quotes). Enabling compression is one of the simplest performance wins for any API, dramatically reducing transfer times with minimal CPU overhead.
Incorrect
// ❌ No compression — client doesn't advertise, server doesn't compress
GET /api/users?per_page=100 HTTP/1.1
Host: api.example.com
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 245760
// 240 KB uncompressed JSON transferred over the wire
{
"data": [
{ "id": 1, "name": "Jane Doe", "email": "jane@example.com", ... },
{ "id": 2, "name": "John Smith", "email": "john@example.com", ... },
// ... 98 more records
]
}Problems:
- 240 KB transferred when 48 KB (gzip) or 38 KB (Brotli) would suffice
- Slower time-to-first-byte, especially on mobile connections
- Higher bandwidth costs for both server and client
- Poor experience for users on metered or slow networks
Correct
Client Requests Compression
// ✅ Client advertises supported encodings
GET /api/users?per_page=100 HTTP/1.1
Host: api.example.com
Accept-Encoding: gzip, brServer Responds with Compressed Content
// ✅ gzip compression — widely supported
HTTP/1.1 200 OK
Content-Type: application/json
Content-Encoding: gzip
Vary: Accept-Encoding
Content-Length: 48200
// Same 240 KB JSON, now 48 KB over the wire (80% reduction)// ✅ Brotli compression — better ratio for modern clients
HTTP/1.1 200 OK
Content-Type: application/json
Content-Encoding: br
Vary: Accept-Encoding
Content-Length: 38400
// Same 240 KB JSON, now 38 KB over the wire (84% reduction)Typical Compression Ratios for JSON
Payload Type | Raw | gzip | Brotli | gzip % | Brotli %
-----------------------+---------+---------+---------+---------+---------
Small object (1 KB) | 1 KB | 0.5 KB | 0.4 KB | 50% | 60%
List of 100 records | 240 KB | 48 KB | 38 KB | 80% | 84%
Large nested response | 1.2 MB | 180 KB | 140 KB | 85% | 88%
Paginated collection | 500 KB | 85 KB | 65 KB | 83% | 87%Important Headers
// ✅ Always include Vary header so caches store compressed
// and uncompressed versions separately
Vary: Accept-Encoding
// ✅ Set minimum size threshold — don't compress tiny responses
// Most servers skip compression for responses under 1 KBNginx Configuration Example
# Enable gzip compression
gzip on;
gzip_types application/json application/javascript text/plain;
gzip_min_length 1024;
gzip_comp_level 6;
gzip_vary on;
# Enable Brotli (if module installed)
brotli on;
brotli_types application/json application/javascript text/plain;
brotli_min_length 1024;
brotli_comp_level 6;curl — Verifying Compression
# Request with compression and see headers
curl -s -H "Accept-Encoding: gzip, br" \
-D - -o /dev/null \
https://api.example.com/users
# Decompress and inspect
curl -s -H "Accept-Encoding: gzip" \
--compressed \
https://api.example.com/users | jq .Benefits:
- 60-80% bandwidth reduction with gzip, 70-88% with Brotli
- Faster response delivery, especially over high-latency connections
- Lower bandwidth costs at scale
- Negligible CPU overhead on modern hardware (1-2% for gzip level 6)
- Transparent to clients —
curl --compressedand all HTTP libraries handle it automatically
Reference: MDN — Content-Encoding
Consistent Response Envelope
Impact: MEDIUM (Reduces client-side parsing complexity by 40-60%)
A consistent response envelope allows API consumers to build reusable parsing logic that works across every endpoint. Without it, clients must special-case each endpoint's response shape, leading to fragile integration code and slower onboarding.
Incorrect
// ❌ Different shapes for different endpoints
// GET /users/123 — bare object
{
"id": 123,
"name": "Jane Doe",
"email": "jane@example.com"
}
// GET /users — bare array
[
{ "id": 123, "name": "Jane Doe" },
{ "id": 456, "name": "John Smith" }
]
// GET /orders — nested differently
{
"orders": [
{ "id": 1, "total": 99.99 }
],
"count": 1
}
// GET /products/42 — yet another shape
{
"product": {
"id": 42,
"title": "Widget"
},
"status": "ok"
}Problems:
- Clients cannot predict the response structure for new endpoints
- Every endpoint requires unique parsing logic
- Impossible to build a generic API client or SDK
- Adding metadata (pagination, rate limits) requires breaking changes
Correct
Simple Envelope Style
// ✅ Single resource — GET /users/123
{
"data": {
"id": 123,
"name": "Jane Doe",
"email": "jane@example.com"
},
"meta": {
"request_id": "req_abc123"
}
}
// ✅ Collection — GET /users?page=1&per_page=20
{
"data": [
{ "id": 123, "name": "Jane Doe" },
{ "id": 456, "name": "John Smith" }
],
"meta": {
"page": 1,
"per_page": 20,
"total": 142,
"total_pages": 8,
"request_id": "req_def456"
}
}
// ✅ Empty collection — GET /users?status=banned
{
"data": [],
"meta": {
"page": 1,
"per_page": 20,
"total": 0,
"total_pages": 0,
"request_id": "req_ghi789"
}
}JSON:API Style
// ✅ Single resource — GET /users/123
{
"data": {
"type": "users",
"id": "123",
"attributes": {
"name": "Jane Doe",
"email": "jane@example.com"
},
"relationships": {
"company": {
"data": { "type": "companies", "id": "7" }
}
}
},
"included": [
{
"type": "companies",
"id": "7",
"attributes": {
"name": "Acme Corp"
}
}
]
}
// ✅ Collection — GET /users
{
"data": [
{
"type": "users",
"id": "123",
"attributes": { "name": "Jane Doe" }
},
{
"type": "users",
"id": "456",
"attributes": { "name": "John Smith" }
}
],
"meta": {
"total": 142,
"page": 1,
"per_page": 20
},
"links": {
"self": "/users?page=1",
"next": "/users?page=2",
"last": "/users?page=8"
}
}Benefits:
- Clients build one parser that works for every endpoint
- Metadata (pagination, request IDs, rate limits) has a predictable location
- New metadata can be added to
metawithout breaking existing clients - SDKs and generic API wrappers become straightforward to implement
Reference: JSON:API Specification
JSON Naming Conventions
Impact: MEDIUM (Eliminates field-name guessing and mapping errors)
Inconsistent naming forces developers to guess field names and write tedious mapping code. Picking one convention and applying it everywhere makes the API predictable and reduces integration bugs.
Incorrect
// ❌ Mixed conventions in the same response
{
"userId": 123,
"first_name": "Jane",
"LastName": "Doe",
"Email": "jane@example.com",
"created_at": "2024-01-15",
"lastLogin": "Jan 20, 2024 3:45 PM",
"isActive": true,
"acct_type": "premium",
"DOB": "1990-05-20",
"addr": {
"str": "123 Main St",
"ZipCode": "90210"
}
}Problems:
- Developers cannot predict whether a field uses camelCase, snake_case, or PascalCase
- Abbreviations like
acct,str,DOBare ambiguous - Date formats vary across fields, requiring per-field parsing
- Mapping between API responses and client models becomes error-prone
Correct
snake_case (common for Ruby, Python, PHP APIs)
// ✅ Consistent snake_case
{
"user_id": 123,
"first_name": "Jane",
"last_name": "Doe",
"email": "jane@example.com",
"created_at": "2024-01-15T10:30:00Z",
"last_login_at": "2024-01-20T15:45:00Z",
"is_active": true,
"account_type": "premium",
"date_of_birth": "1990-05-20",
"address": {
"street": "123 Main St",
"zip_code": "90210"
}
}camelCase (common for JavaScript/TypeScript APIs)
// ✅ Consistent camelCase
{
"userId": 123,
"firstName": "Jane",
"lastName": "Doe",
"email": "jane@example.com",
"createdAt": "2024-01-15T10:30:00Z",
"lastLoginAt": "2024-01-20T15:45:00Z",
"isActive": true,
"accountType": "premium",
"dateOfBirth": "1990-05-20",
"address": {
"street": "123 Main St",
"zipCode": "90210"
}
}Date and Time — Always ISO 8601
// ✅ ISO 8601 with timezone
{
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-03-01T14:22:33+05:30",
"expires_on": "2024-12-31",
"duration_seconds": 3600
}
// ❌ Avoid non-standard date formats
{
"created_at": "Jan 15, 2024",
"updated_at": "03/01/2024",
"expires_on": "1704067200"
}Null vs Omitted Fields
// ✅ Use null for "set but empty" — include the field
{
"first_name": "Jane",
"middle_name": null,
"last_name": "Doe"
}
// ✅ Omit fields that don't apply to this resource
// A "company" user has a company_name; a "personal" user omits it
{
"first_name": "Jane",
"last_name": "Doe",
"account_type": "personal"
}Boolean Naming
// ✅ Use is_, has_, can_, should_ prefixes
{
"is_active": true,
"is_verified": false,
"has_two_factor": true,
"can_edit": false,
"should_notify": true
}
// ❌ Ambiguous boolean names
{
"active": true,
"verified": 1,
"two_factor": "yes",
"edit": false,
"notification": true
}Benefits:
- Developers can predict any field name without checking the docs
- Automated serialization/deserialization works without custom mappings
- ISO 8601 dates are natively parseable in every language
- Boolean prefixes make the type and intent immediately clear
Reference: Google JSON Style Guide
Field Selection (Sparse Fieldsets)
Impact: MEDIUM (Reduces payload size by 50-90% for field-heavy resources)
When an endpoint returns 50+ fields but the client only needs 3, the wasted bandwidth slows mobile apps, increases server serialization time, and drives up data transfer costs. Field selection lets clients request only what they need.
Incorrect
// ❌ Client only needs name and avatar, but gets everything
GET /api/users/123
// Response: 2.4 KB
{
"data": {
"id": 123,
"first_name": "Jane",
"last_name": "Doe",
"email": "jane@example.com",
"phone": "+1-555-0100",
"avatar_url": "https://cdn.example.com/avatars/123.jpg",
"date_of_birth": "1990-05-20",
"bio": "Software engineer with 10 years of experience...",
"address": {
"street": "123 Main St",
"city": "Springfield",
"state": "IL",
"zip_code": "62701",
"country": "US"
},
"preferences": {
"language": "en",
"timezone": "America/Chicago",
"theme": "dark",
"notifications": {
"email": true,
"sms": false,
"push": true
}
},
"social_links": {
"twitter": "https://twitter.com/janedoe",
"linkedin": "https://linkedin.com/in/janedoe",
"github": "https://github.com/janedoe"
},
"created_at": "2023-01-15T10:30:00Z",
"updated_at": "2024-03-01T14:22:33Z",
"last_login_at": "2024-03-10T09:15:00Z"
}
}Problems:
- Wasted bandwidth — client discards 90% of the response
- Slower responses on mobile or low-bandwidth connections
- Server serializes and queries for unused data
- Higher data transfer costs at scale
Correct
Query Parameter Approach
// ✅ Client requests only the fields it needs
GET /api/users/123?fields=id,first_name,last_name,avatar_url
// Response: 180 bytes (93% smaller)
{
"data": {
"id": 123,
"first_name": "Jane",
"last_name": "Doe",
"avatar_url": "https://cdn.example.com/avatars/123.jpg"
}
}Nested Fields
// ✅ Dot notation for nested field selection
GET /api/users/123?fields=id,first_name,address.city,address.country
{
"data": {
"id": 123,
"first_name": "Jane",
"address": {
"city": "Springfield",
"country": "US"
}
}
}Collections with Field Selection
// ✅ Sparse fieldsets on collections — big savings at scale
GET /api/users?fields=id,first_name,avatar_url&per_page=50
// Instead of 50 × 2.4 KB = 120 KB
// Now 50 × 120 bytes = 6 KB
{
"data": [
{ "id": 123, "first_name": "Jane", "avatar_url": "https://cdn.example.com/avatars/123.jpg" },
{ "id": 456, "first_name": "John", "avatar_url": "https://cdn.example.com/avatars/456.jpg" }
],
"meta": {
"total": 142,
"page": 1,
"per_page": 50
}
}JSON:API Sparse Fieldsets
// ✅ JSON:API uses typed field selection
GET /api/users?fields[users]=first_name,avatar_url&fields[companies]=name
{
"data": [
{
"type": "users",
"id": "123",
"attributes": {
"first_name": "Jane",
"avatar_url": "https://cdn.example.com/avatars/123.jpg"
},
"relationships": {
"company": { "data": { "type": "companies", "id": "7" } }
}
}
],
"included": [
{
"type": "companies",
"id": "7",
"attributes": { "name": "Acme Corp" }
}
]
}Benefits:
- Payload size drops 50-90% for typical use cases
- Faster responses, especially on mobile networks
- Server can optimize database queries to fetch only requested columns
- Reduced data transfer costs at high request volumes
Reference: Google API Design Guide — Standard Fields
Include HATEOAS Links for Discoverability
Impact: CRITICAL (Improves API discoverability and reduces client coupling)
HATEOAS (Hypermedia as the Engine of Application State) provides links in responses that guide clients to related resources and available actions.
Incorrect
// ❌ No links, client must construct URLs
{
"id": 123,
"name": "John Doe",
"email": "john@example.com",
"orderId": 456
}
// Client must know to call GET /orders/456 to get order details
// No indication of available actions// ❌ Response without navigation
app.get('/users/:id', async (req, res) => {
const user = await db.findUser(req.params.id);
res.json(user); // Raw data only
});Problems:
- Clients must hardcode URL patterns, creating tight coupling
- No indication of what actions are available on a resource
- API URL changes break all clients
- New features are not automatically discoverable
- Clients cannot adapt behavior based on resource state
Correct
// ✅ Response with HATEOAS links
app.get('/users/:id', async (req, res) => {
const user = await db.findUser(req.params.id);
const baseUrl = `${req.protocol}://${req.get('host')}`;
res.json({
id: user.id,
name: user.name,
email: user.email,
_links: {
self: {
href: `${baseUrl}/users/${user.id}`,
method: 'GET'
},
update: {
href: `${baseUrl}/users/${user.id}`,
method: 'PUT'
},
delete: {
href: `${baseUrl}/users/${user.id}`,
method: 'DELETE'
},
orders: {
href: `${baseUrl}/users/${user.id}/orders`,
method: 'GET'
},
createOrder: {
href: `${baseUrl}/users/${user.id}/orders`,
method: 'POST'
}
}
});
});
// ✅ Collection with pagination links
app.get('/users', async (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const { users, total } = await db.findUsers({ page, limit });
const baseUrl = `${req.protocol}://${req.get('host')}`;
const totalPages = Math.ceil(total / limit);
res.json({
data: users.map(user => ({
...user,
_links: {
self: { href: `${baseUrl}/users/${user.id}` }
}
})),
_links: {
self: { href: `${baseUrl}/users?page=${page}&limit=${limit}` },
first: { href: `${baseUrl}/users?page=1&limit=${limit}` },
last: { href: `${baseUrl}/users?page=${totalPages}&limit=${limit}` },
...(page > 1 && {
prev: { href: `${baseUrl}/users?page=${page - 1}&limit=${limit}` }
}),
...(page < totalPages && {
next: { href: `${baseUrl}/users?page=${page + 1}&limit=${limit}` }
})
},
_meta: {
currentPage: page,
totalPages,
totalItems: total,
itemsPerPage: limit
}
});
});// ✅ Example response with HATEOAS
{
"id": 123,
"name": "John Doe",
"email": "john@example.com",
"status": "active",
"_links": {
"self": {
"href": "https://api.example.com/users/123",
"method": "GET"
},
"update": {
"href": "https://api.example.com/users/123",
"method": "PUT"
},
"deactivate": {
"href": "https://api.example.com/users/123/deactivate",
"method": "POST"
},
"orders": {
"href": "https://api.example.com/users/123/orders",
"method": "GET"
},
"avatar": {
"href": "https://api.example.com/users/123/avatar",
"method": "GET",
"type": "image/png"
}
},
"_embedded": {
"latestOrder": {
"id": 456,
"total": 99.99,
"_links": {
"self": { "href": "https://api.example.com/orders/456" }
}
}
}
}# ✅ FastAPI with HATEOAS helper
from fastapi import FastAPI, Request
from pydantic import BaseModel
from typing import Dict, List, Optional, Any
app = FastAPI()
class Link(BaseModel):
href: str
method: str = "GET"
type: Optional[str] = None
class HATEOASResponse(BaseModel):
data: Any
_links: Dict[str, Link]
_embedded: Optional[Dict[str, Any]] = None
def build_user_links(request: Request, user_id: int) -> Dict[str, Link]:
base_url = str(request.base_url).rstrip('/')
return {
"self": Link(href=f"{base_url}/users/{user_id}"),
"update": Link(href=f"{base_url}/users/{user_id}", method="PUT"),
"delete": Link(href=f"{base_url}/users/{user_id}", method="DELETE"),
"orders": Link(href=f"{base_url}/users/{user_id}/orders"),
}
@app.get("/users/{user_id}")
async def get_user(user_id: int, request: Request):
user = await db.get_user(user_id)
return {
**user.dict(),
"_links": build_user_links(request, user_id)
}
@app.get("/orders/{order_id}")
async def get_order(order_id: int, request: Request):
order = await db.get_order(order_id)
base_url = str(request.base_url).rstrip('/')
cancel_link = (
{"href": f"{base_url}/orders/{order_id}/cancel", "method": "POST"}
if order.status == "pending"
else None
)
return {
**order.dict(),
"_links": {
"self": {"href": f"{base_url}/orders/{order_id}"},
"customer": {"href": f"{base_url}/users/{order.customer_id}"},
"items": {"href": f"{base_url}/orders/{order_id}/items"},
"cancel": cancel_link,
"invoice": {"href": f"{base_url}/orders/{order_id}/invoice", "type": "application/pdf"}
}
}HAL Format (Common Standard)
{
"_links": {
"self": { "href": "/orders/123" },
"customer": { "href": "/customers/456", "title": "John Doe" },
"items": { "href": "/orders/123/items" }
},
"id": 123,
"total": 99.99,
"status": "shipped",
"_embedded": {
"items": [
{
"_links": { "self": { "href": "/products/789" } },
"name": "Widget",
"quantity": 2
}
]
}
}Benefits:
- Responses tell clients exactly what actions are available and how to perform them
- Clients follow links dynamically instead of hardcoding URL patterns
- APIs can change URL structures without breaking clients
- New features are automatically discoverable through new links
- Links can vary based on resource state (e.g., "cancel" only for pending orders)
- Links guide users through multi-step processes naturally
Reference: HAL Specification
Use HTTP Methods Correctly
Impact: CRITICAL (Enables caching, retry logic, and semantic API operations)
HTTP methods have specific semantics and should be used according to their intended purpose. Each method has distinct characteristics for safety and idempotency.
Incorrect
// ❌ Incorrect method usage
POST /users/123 // Should use GET to retrieve
GET /users/create // Should use POST to create
POST /users/123/delete // Should use DELETE method
GET /orders/123/update // Should use PUT/PATCH
POST /search // GET is better for safe operations// ❌ Incorrect method usage
app.post('/users/:id', (req, res) => {
// Fetching user with POST - wrong!
const user = db.findUser(req.params.id);
res.json(user);
});
app.get('/users/delete/:id', (req, res) => {
// Deleting with GET - dangerous!
db.deleteUser(req.params.id);
res.json({ deleted: true });
});
app.post('/users/:id/update', (req, res) => {
// Custom action verb with POST
db.updateUser(req.params.id, req.body);
res.json({ updated: true });
});Problems:
- GET requests that modify data can be triggered by crawlers or link prefetchers
- POST for retrieval prevents browser and CDN caching
- Clients cannot implement safe retry logic without correct method semantics
- Security tools and load balancers cannot apply method-based rules
- API documentation tools generate incorrect specifications
Correct
// ✅ Correct HTTP method usage
const express = require('express');
const router = express.Router();
// GET - Retrieve resource(s), safe and idempotent
router.get('/users', async (req, res) => {
const users = await db.findUsers(req.query);
res.json(users);
});
router.get('/users/:id', async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(user);
});
// POST - Create new resource, not idempotent
router.post('/users', async (req, res) => {
const user = await db.createUser(req.body);
res.status(201).json(user);
});
// PUT - Replace entire resource, idempotent
router.put('/users/:id', async (req, res) => {
const user = await db.replaceUser(req.params.id, req.body);
res.json(user);
});
// PATCH - Partial update, not necessarily idempotent
router.patch('/users/:id', async (req, res) => {
const user = await db.updateUser(req.params.id, req.body);
res.json(user);
});
// DELETE - Remove resource, idempotent
router.delete('/users/:id', async (req, res) => {
await db.deleteUser(req.params.id);
res.status(204).send();
});
// HEAD - Same as GET but no body, for checking existence
router.head('/users/:id', async (req, res) => {
const exists = await db.userExists(req.params.id);
res.status(exists ? 200 : 404).send();
});
// OPTIONS - Return allowed methods
router.options('/users', (req, res) => {
res.set('Allow', 'GET, POST, OPTIONS');
res.status(204).send();
});# ✅ FastAPI with correct HTTP methods
from fastapi import FastAPI, HTTPException, status
app = FastAPI()
# GET - Retrieve
@app.get("/users")
def list_users(skip: int = 0, limit: int = 10):
return db.get_users(skip=skip, limit=limit)
@app.get("/users/{user_id}")
def get_user(user_id: int):
user = db.get_user(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
# POST - Create
@app.post("/users", status_code=status.HTTP_201_CREATED)
def create_user(user: UserCreate):
return db.create_user(user)
# PUT - Full replacement
@app.put("/users/{user_id}")
def replace_user(user_id: int, user: UserUpdate):
return db.replace_user(user_id, user)
# PATCH - Partial update
@app.patch("/users/{user_id}")
def update_user(user_id: int, user: UserPatch):
return db.update_user(user_id, user.dict(exclude_unset=True))
# DELETE - Remove
@app.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(user_id: int):
db.delete_user(user_id)
return NoneHTTP Methods Reference
| Method | Purpose | Safe | Idempotent | Request Body | Response Body |
|---|---|---|---|---|---|
| GET | Retrieve | Yes | Yes | No | Yes |
| POST | Create | No | No | Yes | Yes |
| PUT | Replace | No | Yes | Yes | Yes |
| PATCH | Update | No | Not guaranteed | Yes | Yes |
| DELETE | Remove | No | Yes | Optional | Optional |
| HEAD | Headers | Yes | Yes | No | No |
| OPTIONS | Methods | Yes | Yes | No | No |
Benefits:
- Each method has clear, well-defined purpose all developers understand
- GET requests can be cached by browsers and CDNs
- Browsers handle different methods appropriately (e.g., warn before resubmitting POST)
- Security tools, load balancers, and proxies understand HTTP semantics
- Idempotent methods (GET, PUT, DELETE) can be safely retried on network failures
- Tools like Swagger/OpenAPI rely on correct method usage for accurate documentation
Reference: MDN HTTP Methods
Implement Idempotency for Safe Retries
Impact: CRITICAL (Prevents duplicate operations and enables safe retries)
Idempotent operations produce the same result regardless of how many times they're executed. Implement idempotency keys for non-idempotent operations to enable safe retries.
Incorrect
// ❌ Non-idempotent POST without protection
app.post('/payments', async (req, res) => {
// Each retry creates a duplicate payment!
const payment = await db.createPayment({
amount: req.body.amount,
customerId: req.body.customerId
});
await chargeCard(payment);
res.status(201).json(payment);
});
// ❌ No idempotency key checking
app.post('/orders', async (req, res) => {
// Network timeout after processing = client retries = duplicate order
const order = await db.createOrder(req.body);
await processOrder(order);
res.status(201).json(order);
});// ❌ Client retries without idempotency key
POST /payments
{
"amount": 100,
"customerId": "cust_123"
}
// Timeout... retry... duplicate payment created!Problems:
- Duplicate payments or orders when clients retry after network timeouts
- No way for the server to detect repeated requests
- Financial losses from double-charging customers
- Data inconsistency in distributed systems with message retries
- Clients must implement complex tracking logic to avoid duplicates
Correct
// ✅ Idempotency key middleware
const express = require('express');
const router = express.Router();
const idempotencyStore = new Map(); // Use Redis in production
async function idempotencyMiddleware(req, res, next) {
const idempotencyKey = req.headers['idempotency-key'];
if (!idempotencyKey) {
return res.status(400).json({
error: 'missing_idempotency_key',
message: 'Idempotency-Key header is required for this operation'
});
}
const cacheKey = `${req.path}:${idempotencyKey}`;
const cached = idempotencyStore.get(cacheKey);
if (cached) {
// Return cached response
return res.status(cached.status).json(cached.body);
}
// Store original json function
const originalJson = res.json.bind(res);
// Override to cache response
res.json = function(body) {
idempotencyStore.set(cacheKey, {
status: res.statusCode,
body: body
});
// Set TTL (24 hours typical)
setTimeout(() => idempotencyStore.delete(cacheKey), 24 * 60 * 60 * 1000);
return originalJson(body);
};
next();
}
// ✅ Apply to non-idempotent operations
router.post('/payments', idempotencyMiddleware, async (req, res) => {
const payment = await db.createPayment({
amount: req.body.amount,
customerId: req.body.customerId,
idempotencyKey: req.headers['idempotency-key']
});
await chargeCard(payment);
res.status(201).json(payment);
});
// ✅ Idempotent by design using upsert
router.put('/users/:id/preferences', async (req, res) => {
// PUT is idempotent - same request always produces same result
const preferences = await db.upsertPreferences(
req.params.id,
req.body
);
res.json(preferences);
});
// ✅ Natural idempotency with unique constraints
router.post('/subscriptions', async (req, res) => {
try {
const subscription = await db.createSubscription({
userId: req.body.userId,
planId: req.body.planId
});
res.status(201).json(subscription);
} catch (error) {
if (error.code === 'UNIQUE_VIOLATION') {
// Return existing subscription
const existing = await db.findSubscription(
req.body.userId,
req.body.planId
);
return res.status(200).json(existing);
}
throw error;
}
});# ✅ FastAPI with idempotency
from fastapi import FastAPI, Header, HTTPException
from functools import wraps
import redis
app = FastAPI()
redis_client = redis.Redis()
def idempotent(ttl_seconds: int = 86400):
def decorator(func):
@wraps(func)
async def wrapper(*args, idempotency_key: str = Header(...), **kwargs):
cache_key = f"idempotency:{func.__name__}:{idempotency_key}"
# Check cache
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)
# Execute operation
result = await func(*args, **kwargs)
# Cache result
redis_client.setex(cache_key, ttl_seconds, json.dumps(result))
return result
return wrapper
return decorator
@app.post("/payments")
@idempotent(ttl_seconds=86400)
async def create_payment(payment: PaymentCreate):
result = await process_payment(payment)
return {"id": result.id, "status": result.status}// ✅ Client request with idempotency key
POST /payments HTTP/1.1
Host: api.example.com
Content-Type: application/json
Idempotency-Key: unique-request-id-12345
{
"amount": 100,
"customerId": "cust_123"
}
// Response (same for retries)
HTTP/1.1 201 Created
Idempotency-Key: unique-request-id-12345
{
"id": "pay_789",
"amount": 100,
"customerId": "cust_123",
"status": "completed"
}Idempotency by HTTP Method
| Method | Naturally Idempotent | Notes |
|---|---|---|
| GET | Yes | Always safe to retry |
| HEAD | Yes | Always safe to retry |
| OPTIONS | Yes | Always safe to retry |
| PUT | Yes | Full replacement is idempotent |
| DELETE | Yes | Deleting twice = same result |
| POST | No | Needs idempotency key |
| PATCH | Usually | Depends on implementation |
Benefits:
- Clients can safely retry requests without causing duplicate operations
- Prevents duplicate payments or orders that cause financial and data issues
- Users can safely click "submit" multiple times without fear
- Works well in distributed systems with at-least-once delivery guarantees
- Idempotency keys provide request correlation across systems
- Simplifies client code by removing complex success-tracking logic
Reference: Stripe Idempotency Guide
Design Nested Resources for Hierarchical Relationships
Impact: CRITICAL (Clarifies resource relationships and authorization boundaries)
Use nested URLs to represent parent-child relationships between resources, but avoid deep nesting beyond two levels.
Incorrect
// ❌ Deeply nested resources (3+ levels)
GET /companies/123/departments/456/employees/789/projects/101/tasks/202
POST /organizations/1/teams/2/members/3/assignments/4/subtasks
// ❌ Flat structure losing context
GET /tasks/202 // Which project? Which employee?
GET /comments/999 // Comment on what?
// ❌ Inconsistent nesting
GET /users/123/orders // Nested
GET /order-items?orderId=456 // Query param
GET /products/789/reviews // Nested again// ❌ Overly deep nesting
app.get('/companies/:companyId/departments/:deptId/employees/:empId/reviews/:reviewId',
(req, res) => {
// 4 levels deep - too complex!
const { companyId, deptId, empId, reviewId } = req.params;
// ...
}
);Problems:
- URLs become unwieldy and difficult to construct at 3+ levels deep
- Each nesting level adds required path parameters, complicating client code
- Inconsistent nesting patterns confuse API consumers
- Flat structures lose important relationship context
- Deep nesting makes authorization checks more complex
Correct
// ✅ Maximum 2 levels of nesting
GET /users/123/orders // User's orders
GET /orders/456/items // Order's items
GET /posts/789/comments // Post's comments
// Access deep resources directly when needed
GET /tasks/202 // Direct access with task ID
GET /employees/789 // Direct access with employee ID
// Use query parameters for filtering
GET /tasks?projectId=101 // Filter tasks by project
GET /tasks?employeeId=789&status=active// ✅ Express router with appropriate nesting
const router = express.Router();
// Parent resource
router.get('/users', listUsers);
router.get('/users/:userId', getUser);
router.post('/users', createUser);
// Nested child resource (1 level)
router.get('/users/:userId/orders', getUserOrders);
router.post('/users/:userId/orders', createUserOrder);
router.get('/users/:userId/orders/:orderId', getUserOrder);
// Second-level nested resource (2 levels max)
router.get('/users/:userId/orders/:orderId/items', getOrderItems);
router.post('/users/:userId/orders/:orderId/items', addOrderItem);
// Direct access for deep resources
router.get('/orders/:orderId', getOrder);
router.get('/order-items/:itemId', getOrderItem);
router.patch('/order-items/:itemId', updateOrderItem);# ✅ FastAPI with nested resources
from fastapi import APIRouter
router = APIRouter()
# Users - parent resource
@router.get("/users/{user_id}")
def get_user(user_id: int):
return db.get_user(user_id)
# Posts - nested under users
@router.get("/users/{user_id}/posts")
def get_user_posts(user_id: int, skip: int = 0, limit: int = 10):
return db.get_posts_by_user(user_id, skip, limit)
@router.post("/users/{user_id}/posts")
def create_user_post(user_id: int, post: PostCreate):
return db.create_post(user_id, post)
# Comments - nested under posts (2 levels)
@router.get("/posts/{post_id}/comments")
def get_post_comments(post_id: int):
return db.get_comments_by_post(post_id)
# Direct access for comments when needed
@router.get("/comments/{comment_id}")
def get_comment(comment_id: int):
return db.get_comment(comment_id)
@router.patch("/comments/{comment_id}")
def update_comment(comment_id: int, update: CommentUpdate):
return db.update_comment(comment_id, update)# ✅ OpenAPI spec with nested resources
openapi: 3.0.0
paths:
/users/{userId}/orders:
get:
summary: Get all orders for a user
parameters:
- name: userId
in: path
required: true
schema:
type: integer
/users/{userId}/orders/{orderId}:
get:
summary: Get a specific order for a user
/orders/{orderId}/items:
get:
summary: Get all items in an order
post:
summary: Add item to order
# Direct access endpoint
/orders/{orderId}:
get:
summary: Get order by ID directlyBenefits:
- Nested URLs clearly show ownership and hierarchy (e.g.,
/users/123/orders) - URL structure makes it easy to enforce authorization boundaries
- Limiting to 2 levels keeps URLs manageable and predictable
- Both nested and direct access patterns accommodate different use cases
- Creating under a parent automatically establishes the relationship
- Enables specific error messages like "Order 456 not found for user 123"
Reference: REST API Design - Resource Relationships
Use Nouns, Not Verbs for Resource Names
Impact: CRITICAL (Foundation of REST architecture)
REST API endpoints should represent resources (nouns), not actions (verbs). HTTP methods already convey the action being performed.
Incorrect
// ❌ Verbs in endpoint names
GET /getUsers
POST /createUser
PUT /updateUser/123
DELETE /deleteUser/123
GET /fetchAllOrders
POST /addNewProduct// ❌ Express routes with verb-based endpoints
app.get('/getUsers', getUsers);
app.post('/createUser', createUser);
app.get('/fetchUserById/:id', getUserById);
app.put('/updateUserProfile/:id', updateUser);
app.delete('/removeUser/:id', deleteUser);Problems:
- Redundant action verbs when HTTP methods already describe the operation
- Inconsistent naming across endpoints (get, fetch, create, add)
- More endpoints than necessary for the same resource
- URLs become unpredictable and hard to discover
- Breaks RESTful conventions that developers expect
- Cannot leverage HTTP method semantics for caching and retry logic
Correct
// ✅ Nouns representing resources
GET /users
POST /users
GET /users/123
PUT /users/123
DELETE /users/123
GET /orders
POST /products// ✅ Express routes with noun-based endpoints
app.get('/users', listUsers);
app.post('/users', createUser);
app.get('/users/:id', getUser);
app.put('/users/:id', updateUser);
app.delete('/users/:id', deleteUser);# ✅ FastAPI with noun-based resources
from fastapi import FastAPI
app = FastAPI()
@app.get("/users")
def list_users():
return users
@app.post("/users")
def create_user(user: UserCreate):
return new_user
@app.get("/users/{user_id}")
def get_user(user_id: int):
return user
@app.put("/users/{user_id}")
def update_user(user_id: int, user: UserUpdate):
return updated_user
@app.delete("/users/{user_id}")
def delete_user(user_id: int):
return {"deleted": True}Benefits:
- RESTful convention: URLs are resource identifiers, HTTP methods describe actions
- Consistent and predictable API structure developers can easily understand
- Fewer endpoints needed since one resource path handles multiple operations
- Self-documenting resources that map to domain model entities
- GET requests to noun-based endpoints can be cached effectively
- Leverages built-in HTTP method semantics
Reference: REST Resource Naming Guide
Maintain Backward Compatibility
Impact: HIGH (Prevents breaking existing integrations and avoids costly emergency client fixes)
Breaking changes in a live API force every consumer to update simultaneously or face outages. Maintaining backward compatibility within a version means clients continue working after deployments, and new features are delivered through additive changes only. Reserve breaking changes for new major versions.
Incorrect
# Before: GET /api/v1/users/1{
"id": 1,
"name": "Jane Smith",
"email": "jane@example.com",
"role": "admin"
}# After deploy (same v1): field renamed, field removed, type changed{
"id": 1,
"full_name": "Jane Smith",
"email_address": "jane@example.com",
"roles": ["admin", "editor"]
}Problems:
- Renaming
nametofull_namebreaks every client readingresponse.name - Renaming
emailtoemail_addressbreaks form bindings and display logic - Changing
role(string) toroles(array) causes type errors in client deserialization - Removing fields with no notice gives consumers zero time to adapt
Correct
# Additive changes only within v1: new fields added, old fields preserved
GET /api/v1/users/1{
"id": 1,
"name": "Jane Smith",
"full_name": "Jane Smith",
"email": "jane@example.com",
"email_address": "jane@example.com",
"role": "admin",
"roles": ["admin", "editor"],
"avatar_url": "https://cdn.example.com/avatars/1.jpg"
}# Deprecation communicated via response headers
HTTP/1.1 200 OK
Content-Type: application/json
X-Deprecated-Fields: name, email, role# New endpoints are always safe to add
GET /api/v1/users/1/preferences # new endpoint, no existing contractBenefits:
- Existing clients continue working without any code changes after every deploy
- New clients can adopt new field names immediately while old names remain available
- Deprecation headers give automated tooling a way to detect and flag stale usage
- New endpoints and new fields never conflict with existing client expectations
Reference: Stripe API - Backward Compatibility
Related skills
How it compares
Use api-design-patterns for REST checklist reviews; use OpenAPI generator tools when the schema file itself is the primary deliverable.
FAQ
How many rules does api-design-patterns include?
api-design-patterns version 2.0.0 bundles 38 rules across seven categories: resource design, error handling, security, pagination and filtering, versioning, response format, and documentation with OpenAPI guidance.
What API areas does api-design-patterns prioritize?
api-design-patterns marks resource design, error handling, and security as critical priorities, then pagination, versioning, response format, and documentation. Each rule uses prefixes like rest-, error-, and sec- for targeted reviews.