Patterns — Designing REST APIs
Contents
- URL and method matrix
- Pagination envelope (cursor)
- problem+json error schemas
- Idempotency-Key handling
- OpenAPI 3.1 skeleton
- Gotchas
URL and method matrix
text
GET /v1/orders list (paginated) 200
POST /v1/orders create 201 + Location
GET /v1/orders/{id} read 200 / 404
PUT /v1/orders/{id} full replace (idempotent) 200 / 404
PATCH /v1/orders/{id} partial update 200 / 404 / 409
DELETE /v1/orders/{id} delete (idempotent) 204 / 404
POST /v1/orders/{id}/refund non-CRUD action 201 / 409
GET /v1/customers/{id}/orders nested read-only view 200Nest at most one level; deeper relations get top-level resources with filters
(/v1/orders?customer_id=7), not /customers/7/orders/3/items/9.
Pagination envelope (cursor)
json
{
"data": [{ "id": "ord_01J8", "status": "active" }],
"pagination": {
"next_cursor": "eyJpZCI6Im9yZF8wMUo4In0",
"has_more": true,
"limit": 20
}
}Request: GET /v1/orders?limit=20&cursor=eyJpZCI6.... The cursor is opaque
(base64 of the last sort key) — clients must not parse it. limit above the
max of 100 → clamp to 100, do not error.
problem+json error schemas
Header: Content-Type: application/problem+json
json
{
"type": "https://api.example.com/errors/validation",
"title": "Validation failed",
"status": 422,
"detail": "2 fields are invalid.",
"instance": "/v1/orders",
"errors": [
{ "field": "email", "message": "must be a valid email address" },
{ "field": "quantity", "message": "must be between 1 and 99" }
]
}Conflict example:
json
{
"type": "https://api.example.com/errors/state-conflict",
"title": "Order already shipped",
"status": 409,
"detail": "Order ord_01J8 cannot be cancelled after shipment.",
"instance": "/v1/orders/ord_01J8/cancellation"
}Define the schema once under components/schemas/Problem and $ref it from
every 4xx/5xx response.
Idempotency-Key handling
python
# Pseudocode for POST endpoints that create resources.
# Keys expire after 24h — long enough for client retry storms, short enough
# to bound storage.
def create_order(request):
key = request.headers.get("Idempotency-Key")
if key:
cached = idempotency_store.get(key)
if cached:
if cached.request_hash != hash(request.body):
return problem(422, "Idempotency-Key reused with different body")
return cached.response # replay, no side effect
response = do_create(request.body) # single side effect
if key:
idempotency_store.put(key, hash(request.body), response, ttl_hours=24)
return responseOpenAPI 3.1 skeleton
yaml
openapi: 3.1.0
info: { title: Orders API, version: 1.0.0 }
servers: [{ url: https://api.example.com/v1 }]
paths:
/orders:
get:
parameters:
- { name: limit, in: query, schema: { type: integer, maximum: 100, default: 20 } }
- { name: cursor, in: query, schema: { type: string } }
responses:
"200": { $ref: "#/components/responses/OrderList" }
"429": { $ref: "#/components/responses/Problem" }
post:
parameters:
- { name: Idempotency-Key, in: header, schema: { type: string } }
responses:
"201": { $ref: "#/components/responses/Order" }
"422": { $ref: "#/components/responses/Problem" }
components:
schemas:
Problem:
type: object
properties:
type: { type: string }
title: { type: string }
status: { type: integer }
detail: { type: string }
instance: { type: string }Gotchas
- 404 vs 403 leaks existence. If callers must not learn a resource exists, return 404 for both missing and forbidden.
- PUT with partial body silently nulls fields — that is correct replace semantics; if clients expect merge, they need PATCH. Document which you offer.
- Offset pagination drifts when rows are inserted mid-scan; deep offsets also get slow. Cursors fix both — default to them.
- Trailing slashes:
/ordersand/orders/must not be two resources. Redirect or normalize one to the other. - Enum widening is breaking for clients that switch exhaustively. Adding an enum value is only additive if the spec documents "unknown values may appear" from v1.
- Date-times: always RFC 3339 UTC (
2026-08-05T14:30:00Z); epoch integers and local times cause silent client bugs.