| name | rest-api-design |
| description | This skill should be used when the user asks about "REST API", "API design", "endpoint design", "HTTP methods", "status codes", "API versioning", "CRUD endpoints", "RESTful", "API conventions", "pagination", "cursor pagination", "offset pagination", "resource naming", "URL design", "API specification", "OpenAPI", "Swagger", "API contract", "request body", "response schema". Also trigger for "how should I design this API", "what status code should I use", "how to version an API", "is this good API design", "review my endpoint", or "how to structure my routes". |
REST API Design
Production-quality patterns for designing, documenting, and evolving REST APIs.
Core REST Principles
Resource-Oriented Design
Design around resources (nouns), not actions (verbs). Every endpoint operates on a resource.
✓ GET /orders — list orders
✓ POST /orders — create an order
✓ GET /orders/{id} — get a specific order
✓ PATCH /orders/{id} — update an order
✓ DELETE /orders/{id} — cancel/delete an order
✓ POST /orders/{id}/cancel — custom action (not a CRUD operation)
✗ GET /getOrders — verb in URL
✗ POST /cancelOrder — verb + noun, no resource hierarchy
✗ GET /orders/{id}/delete — wrong method for action
URL Design Rules
/v1/{resource} — collection
/v1/{resource}/{id} — single item
/v1/{resource}/{id}/{sub-resource} — nested (max 2 levels)
Naming:
- Plural nouns: /orders, /users, /invoices
- Kebab-case: /order-items, /shipping-addresses
- Lowercase always: /orders (not /Orders)
- No file extensions: /orders (not /orders.json)
- UUIDs for public IDs: /orders/b3d1f2a4-... (not /orders/12345)
HTTP Method Semantics
| Method | Semantics | Idempotent | Safe | Body |
|---|
GET | Read | ✓ | ✓ | No |
POST | Create / non-idempotent action | ✗ | ✗ | Yes |
PUT | Full replace | ✓ | ✗ | Yes |
PATCH | Partial update | ✓* | ✗ | Yes |
DELETE | Remove | ✓ | ✗ | Sometimes |
HEAD | GET without body | ✓ | ✓ | No |
OPTIONS | Discover capabilities | ✓ | ✓ | No |
*PATCH is idempotent when it uses absolute values (not deltas).
Status Code Reference
2xx Success
200 OK — GET, PATCH, PUT success with body
201 Created — POST created a new resource; MUST include Location header
202 Accepted — Async operation started; not yet completed
204 No Content — DELETE success, or action with no meaningful response body
4xx Client Errors
400 Bad Request — Malformed JSON, wrong data type, missing required field
401 Unauthorized — Not authenticated; include WWW-Authenticate header
403 Forbidden — Authenticated but not authorized
404 Not Found — Resource doesn't exist
405 Method Not Allowed — HTTP method not supported; include Allow header
409 Conflict — State conflict (duplicate, optimistic lock failure)
410 Gone — Resource permanently deleted (use 404 if uncertain)
422 Unprocessable — Valid syntax, business rule violation (e.g., order > account limit)
429 Too Many Requests — Rate limited; include Retry-After header
5xx Server Errors
500 Internal Server Error — Never leak internal details in response body
502 Bad Gateway — Upstream dependency failed
503 Service Unavailable — Maintenance or overload; include Retry-After header
504 Gateway Timeout — Upstream timed out
Common Mistakes
✗ 200 + body { success: false } → use 4xx/5xx
✗ 200 for created resource → use 201 + Location header
✗ 400 for auth failure → use 401
✗ 404 for unauthorized → 403 (or 404 if you want to hide existence)
✗ 500 with stack trace in body → never expose internals
Standard Error Response Envelope
Always use the same error structure across your entire API.
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Request validation failed",
"details": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "Must be a valid email address"
},
{
"field": "amount",
"code": "OUT_OF_RANGE",
"message": "Must be between 100 and 1000000 (cents)"
}
],
"request_id": "req_abc123",
"docs_url": "https://docs.example.com/errors#VALIDATION_FAILED"
}
}
Error code conventions:
- All caps with underscores:
NOT_FOUND, RATE_LIMITED, INSUFFICIENT_FUNDS
- Machine-readable (consumers can switch on them, not the message string)
- Stable across versions (don't rename codes)
API Versioning
URL versioning (most common for REST)
https://api.example.com/v1/orders
https://api.example.com/v2/orders
Advantages: obvious, easy to route, cacheable, loggable.
Header versioning
GET /orders HTTP/1.1
Accept: application/vnd.myapi+json;version=2
Advantages: clean URLs, follows HTTP spec. Harder to test in browser.
Version lifecycle
1. Release v2 endpoint
2. Announce v1 deprecation date (minimum 6 months for external APIs)
3. Add Deprecation: "Sat, 01 Jun 2024 00:00:00 GMT" header to v1 responses
4. Add Sunset: "Sat, 01 Dec 2024 00:00:00 GMT" header when sunset is scheduled
5. Retire v1 after sunset date
What constitutes a breaking change
Breaking (requires version bump):
- Removing or renaming a field that consumers use
- Changing a field's type
- Adding a required request parameter
- Removing an endpoint
- Changing status code semantics
- Changing authentication requirements
- Narrowing enum values (removing allowed values)
Non-breaking (backward-compatible):
- Adding optional request parameters
- Adding new fields to responses
- Adding new endpoints
- Adding new enum values (but document this)
- Making previously required fields optional
- Expanding rate limits
Pagination
Cursor-based pagination (prefer for large/real-time data)
GET /orders?limit=20&cursor=eyJpZCI6MTAwfQ
{
"data": [...],
"pagination": {
"next_cursor": "eyJpZCI6MTIwfQ",
"prev_cursor": "eyJpZCI6ODF9",
"has_next": true,
"has_prev": true,
"limit": 20
}
}
function encodeCursor(item: Item): string {
return Buffer.from(JSON.stringify({ id: item.id })).toString('base64url');
}
function decodeCursor(cursor: string): { id: string } {
return JSON.parse(Buffer.from(cursor, 'base64url').toString());
}
const decoded = decodeCursor(cursorParam);
const results = await db.query(
'SELECT * FROM orders WHERE id > $1 ORDER BY id LIMIT $2',
[decoded.id, limit + 1]
);
const hasNext = results.length > limit;
Offset-based pagination (simpler, acceptable for small datasets)
GET /orders?page=3&per_page=20
{
"data": [...],
"pagination": {
"page": 3,
"per_page": 20,
"total_pages": 45,
"total_count": 892
}
}
Offset pagination has consistency issues with live data (items added/deleted between pages cause duplicates/skips). Use cursor for anything > 10k records or frequently updated.
OpenAPI 3.1 Specification Template
openapi: "3.1.0"
info:
title: Orders API
version: "1.0.0"
description: |
Manage customer orders and their lifecycle.
All timestamps are ISO 8601 UTC. All monetary values are in cents (integer).
All endpoints require a Bearer token in the Authorization header.
100 requests per minute per API key. Limits returned in response headers.
servers:
- url: https://api.example.com/v1
description: Production
- url: https://staging.api.example.com/v1
description: Staging
security:
- bearerAuth: []
paths:
/orders:
get:
operationId: listOrders
summary: List orders
tags: [Orders]
parameters:
- $ref: '#/components/parameters/limit'
- $ref: '#/components/parameters/cursor'
- name: status
in: query
schema:
type: string
enum: [pending, processing, completed, cancelled]
responses:
'200':
description: Orders list
content:
application/json:
schema:
type: object
required: [data, pagination]
properties:
data:
type: array
items:
$ref: '#/components/schemas/Order'
pagination:
$ref: '#/components/schemas/Pagination'
'401':
$ref: '#/components/responses/Unauthorized'
'429':
$ref: '#/components/responses/RateLimited'
post:
operationId: createOrder
summary: Create an order
tags: [Orders]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateOrderRequest'
responses:
'201':
description: Order created
headers:
Location:
schema:
type: string
example: /v1/orders/b3d1f2a4-c5d6-4e7f-8g9h-0i1j2k3l4m5n
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
'400':
$ref: '#/components/responses/ValidationError'
'422':
$ref: '#/components/responses/UnprocessableEntity'
/orders/{orderId}:
parameters:
- $ref: '#/components/parameters/orderId'
get:
operationId: getOrder
summary: Get an order
tags: [Orders]
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
'404':
$ref: '#/components/responses/NotFound'
components:
schemas:
Order:
type: object
required: [id, status, totalCents, createdAt, updatedAt]
properties:
id:
type: string
format: uuid
example: "b3d1f2a4-c5d6-4e7f-8g9h-0i1j2k3l4m5n"
status:
type: string
enum: [pending, processing, completed, cancelled]
totalCents:
type: integer
minimum: 0
example: 4999
createdAt:
type: string
format: date-time
example: "2024-01-15T14:30:00Z"
updatedAt:
type: string
format: date-time
CreateOrderRequest:
type: object
required: [items]
properties:
items:
type: array
minItems: 1
items:
type: object
required: [productId, quantity]
properties:
productId:
type: string
format: uuid
quantity:
type: integer
minimum: 1
Pagination:
type: object
properties:
nextCursor:
type: string
nullable: true
hasNext:
type: boolean
limit:
type: integer
Error:
type: object
required: [error]
properties:
error:
type: object
required: [code, message]
properties:
code:
type: string
message:
type: string
requestId:
type: string
details:
type: array
items:
type: object
parameters:
orderId:
name: orderId
in: path
required: true
schema:
type: string
format: uuid
limit:
name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 20
cursor:
name: cursor
in: query
schema:
type: string
responses:
Unauthorized:
description: Not authenticated
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
NotFound:
description: Resource not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
ValidationError:
description: Request validation failed
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
UnprocessableEntity:
description: Business rule violation
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
RateLimited:
description: Rate limit exceeded
headers:
Retry-After:
schema:
type: integer
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
Idempotency for Unsafe Operations
For payment, charge, and state-changing operations that must not run twice:
const idempotencyKey = crypto.randomUUID();
await fetch('/v1/charges', {
method: 'POST',
headers: {
'Idempotency-Key': idempotencyKey,
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ amount: 4999, currency: 'usd' }),
});
app.post('/v1/charges', async (req, res) => {
const key = req.headers['idempotency-key'];
if (!key) return res.status(400).json({ error: { code: 'MISSING_IDEMPOTENCY_KEY' } });
const cached = await redis.get(`idem:${key}`);
if (cached) {
res.setHeader('Idempotent-Replayed', 'true');
return res.status(200).json(JSON.parse(cached));
}
const result = await processCharge(req.body);
await redis.setex(`idem:${key}`, 86400, JSON.stringify(result));
return res.status(201).json(result);
});
HATEOAS / Hypermedia (When to Use)
Full HATEOAS is complex and usually unnecessary. A pragmatic middle ground:
{
"id": "order_abc",
"status": "pending",
"_links": {
"self": { "href": "/v1/orders/order_abc" },
"cancel": { "href": "/v1/orders/order_abc/cancel", "method": "POST" },
"items": { "href": "/v1/orders/order_abc/items" }
}
}
Only include links for actions the current user can take (omit cancel if order is already cancelled). This eliminates client-side "can I do this?" logic.
Deeper Reference
For complete OpenAPI specification templates and canonical error schema definitions, see:
references/openapi-patterns.md — full OpenAPI 3.1 spec templates for CRUD, pagination, bulk operations, and async job endpoints with reusable component schemas
references/error-schemas.md — RFC 7807 Problem Details schema library, HTTP status decision guide, and error response examples for 30+ common API error scenarios