/users # Collection - supports GET (list), POST (create)
/users/{id} # Singleton - supports GET, PUT, PATCH, DELETE
/users/{id}/profile # Singleton sub-resource (1:1 relationship)
/users/{id}/orders # Sub-collection (1:many relationship)
Approach 1: Sub-resources (strong ownership)
GET /users/{id}/orders # Orders belong to user
POST /users/{id}/orders # Create order for user
Approach 2: Top-level with filters (independent entities)
GET /orders?user_id={id} # Orders exist independently
GET /orders/{order_id} # Direct access without user context
Approach 3: Relationship endpoints (many-to-many)
GET /users/{id}/roles # List user's roles
PUT /users/{id}/roles/{rid} # Assign role (no body needed)
DELETE /users/{id}/roles/{rid} # Remove role
| Use sub-resource | Use top-level |
|---|---|
| Child can't exist without parent | Entity is independently meaningful |
| Always accessed in parent context | Frequently queried across parents |
| Moderate cardinality (< 1000) | High cardinality |
| Lifecycle tied to parent | Independent lifecycle |
# Actions that don't map to CRUD - use sub-resources
POST /orders/{id}/cancel # State transition
POST /users/{id}/verify-email # Trigger action
POST /reports/{id}/export # Async operation
# Avoid: verbs as top-level resources
POST /cancelOrder # Bad
POST /send-notification # Bad
# Search as a resource (when GET query string is too complex)
POST /users/search
{ "filters": { "age_range": [18, 30], "location": { "within": "10km", "of": [lat, lng] } } }
JSON Merge Patch (RFC 7396) - Simple, intuitive:
PATCH /users/123
Content-Type: application/merge-patch+json
{ "name": "New Name", "address": null }
name to "New Name"address (null = delete)JSON Patch (RFC 6902) - Precise operations:
PATCH /users/123
Content-Type: application/json-patch+json
[
{ "op": "replace", "path": "/name", "value": "New Name" },
{ "op": "remove", "path": "/address" },
{ "op": "add", "path": "/tags/-", "value": "premium" },
{ "op": "test", "path": "/version", "value": 5 }
]
test enables optimistic concurrency (apply only if value matches)Recommendation: Use JSON Merge Patch for most APIs (simpler). Use JSON Patch when you need array manipulation or atomic test-and-set.
# HEAD - metadata without body (same headers as GET)
HEAD /files/report.pdf
# Returns: Content-Length, Content-Type, Last-Modified, ETag
# Use: check existence, get size before download
# OPTIONS - discover allowed methods (CORS preflight uses this)
OPTIONS /users
# Returns: Allow: GET, POST, HEAD, OPTIONS
# Client requests specific format
GET /users/123
Accept: application/json # JSON (default)
Accept: application/xml # XML
Accept: text/csv # CSV export
Accept: application/pdf # PDF report
# Versioning via media type
Accept: application/vnd.myapi.v2+json # Version in media type
# Server must validate Content-Type on mutations
POST /users
Content-Type: application/json # Standard
Content-Type: multipart/form-data # File uploads
Content-Type: application/x-www-form-urlencoded # Form data
func handleGetUser(w http.ResponseWriter, r *http.Request) {
accept := r.Header.Get("Accept")
user := fetchUser(r)
switch {
case strings.Contains(accept, "application/xml"):
w.Header().Set("Content-Type", "application/xml")
xml.NewEncoder(w).Encode(user)
case strings.Contains(accept, "text/csv"):
w.Header().Set("Content-Type", "text/csv")
writeCSV(w, user)
default:
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
}
Encode the cursor as base64 for opacity:
// Encode cursor
type Cursor struct {
ID int64 `json:"id"`
CreatedAt time.Time `json:"created_at"`
}
func encodeCursor(c Cursor) string {
b, _ := json.Marshal(c)
return base64.URLEncoding.EncodeToString(b)
}
func decodeCursor(s string) (Cursor, error) {
b, err := base64.URLEncoding.DecodeString(s)
if err != nil {
return Cursor{}, err
}
var c Cursor
return c, json.Unmarshal(b, &c)
}
Request/Response:
GET /users?limit=20&after=eyJpZCI6MTIzLCJjcmVhdGVkX2F0IjoiMjAyNC0wMS0xNVQxMDozMDowMFoifQ==
{
"data": [...],
"pagination": {
"has_more": true,
"next_cursor": "eyJpZCI6MTQzLCJjcmVhdGVkX2F0IjoiMjAyNC0wMS0xNlQwODoxNTowMFoifQ==",
"prev_cursor": "eyJpZCI6MTI0LCJjcmVhdGVkX2F0IjoiMjAyNC0wMS0xNVQxMTowMDowMFoifQ=="
}
}
SQL (keyset pagination under the hood):
SELECT * FROM users
WHERE (created_at, id) > ('2024-01-15T10:30:00Z', 123)
ORDER BY created_at ASC, id ASC
LIMIT 21; -- fetch limit+1 to determine has_more
Link: <https://api.example.com/users?after=abc123&limit=20>; rel="next",
<https://api.example.com/users?before=xyz789&limit=20>; rel="prev",
<https://api.example.com/users?limit=20>; rel="first"
total count requires a separate COUNT(*) query - expensive on large tablesGET /users?limit=20&include_total=trueSELECT reltuples FROM pg_class WHERE relname = 'users'# Simple equality
GET /users?status=active&role=admin
# Operators (LHS brackets style - used by Stripe, Supabase)
GET /users?created_at[gte]=2024-01-01&created_at[lt]=2024-02-01
GET /products?price[lte]=100&category[in]=electronics,books
# Operators (filter syntax)
GET /users?filter=status eq "active" and age gt 18
# Simple (comma-separated, prefix - for descending)
GET /users?sort=-created_at,name
# Multiple fields
GET /products?sort=category,-price # category ASC, then price DESC
# Return only specific fields (reduces payload)
GET /users?fields=id,name,email
GET /users/123?fields=id,name,email,profile.avatar
# Related resource fields
GET /orders?fields=id,total&fields[customer]=id,name
POST /users/batch
Content-Type: application/json
{
"items": [
{ "name": "Alice", "email": "alice@example.com" },
{ "name": "Bob", "email": "bob@example.com" }
]
}
# Response: 207 Multi-Status
{
"results": [
{ "status": 201, "data": { "id": "u1", "name": "Alice" } },
{ "status": 409, "error": { "type": "conflict", "detail": "Email already exists" } }
],
"summary": { "succeeded": 1, "failed": 1 }
}
POST /users/batch-action
{
"action": "deactivate",
"ids": ["u1", "u2", "u3"],
"reason": "Account cleanup"
}
# Start operation
POST /reports/generate
{ "type": "annual", "year": 2024 }
# Response: 202 Accepted
{
"operation_id": "op-abc-123",
"status": "pending",
"status_url": "/operations/op-abc-123",
"estimated_completion": "2024-01-15T10:35:00Z"
}
# Poll for status
GET /operations/op-abc-123
{
"operation_id": "op-abc-123",
"status": "completed", # pending | running | completed | failed
"progress": 100,
"result_url": "/reports/rpt-xyz-789",
"completed_at": "2024-01-15T10:34:12Z"
}
POST /reports/generate
{
"type": "annual",
"year": 2024,
"callback_url": "https://myapp.com/webhooks/report-ready"
}
# Server POSTs to callback_url when done:
{
"event": "report.completed",
"operation_id": "op-abc-123",
"result_url": "/reports/rpt-xyz-789"
}
GET /operations/op-abc-123/stream
Accept: text/event-stream
event: progress
data: {"percent": 25, "stage": "fetching data"}
event: progress
data: {"percent": 75, "stage": "generating charts"}
event: complete
data: {"result_url": "/reports/rpt-xyz-789"}
| Worth it | Not worth it |
|---|---|
| Public API with many consumers | Internal microservice |
| API that evolves frequently | Stable, versioned API |
| Workflow-driven (state machines) | Simple CRUD |
| Discoverability is a feature | Clients are tightly coupled |
{
"id": "order-123",
"status": "pending_payment",
"total": 5999,
"_links": {
"self": { "href": "/orders/order-123" },
"pay": { "href": "/orders/order-123/pay", "method": "POST" },
"cancel": { "href": "/orders/order-123", "method": "DELETE" }
},
"_embedded": {
"items": [
{
"product_id": "prod-456",
"quantity": 2,
"_links": {
"product": { "href": "/products/prod-456" }
}
}
]
}
}
openapi: 3.1.0
info:
title: My API
version: 2.0.0
description: |
## Authentication
All endpoints require Bearer token authentication.
contact:
email: api-support@example.com
servers:
- url: https://api.example.com/v2
description: Production
- url: https://sandbox.example.com/v2
description: Sandbox
paths:
/users:
get:
summary: List users
operationId: listUsers
tags: [Users]
parameters:
- name: limit
in: query
schema:
type: integer
default: 20
maximum: 100
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/UserList'
| Tool | Strength |
|---|---|
| Redoc | Beautiful single-page docs from OpenAPI |
| Swagger UI | Interactive "try it" playground |
| Stoplight | Design-first with mock servers |
| Mintlify | Modern docs with guides + API reference |
{
"id": "evt_abc123",
"type": "order.completed",
"created_at": "2024-01-15T10:30:00Z",
"api_version": "2024-01-15",
"data": {
"id": "order-456",
"status": "completed",
"total": 5999
}
}
# Header
X-Webhook-Signature: sha256=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
# Compute: HMAC-SHA256(webhook_secret, raw_body)
func verifyWebhookSignature(secret, signature string, body []byte) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
| Practice | Detail |
|---|---|
| Retry with backoff | 1s, 5s, 30s, 5m, 30m, 2h, 24h |
| Idempotency | Include event ID, consumers must deduplicate |
| Timeout | 30 second max wait for 2xx response |
| Disable after failures | Disable after N consecutive failures, notify owner |
| Event log | Provide UI/API to replay failed webhooks |
| Thin payloads | Send IDs + event type, let consumer fetch full data |
# First request
GET /users/123
ETag: "a1b2c3d4"
# Subsequent request
GET /users/123
If-None-Match: "a1b2c3d4"
# Response if unchanged: 304 Not Modified (no body)
# Response if changed: 200 with new ETag
GET /users/123
Last-Modified: Thu, 15 Jan 2024 10:30:00 GMT
# Subsequent request
GET /users/123
If-Modified-Since: Thu, 15 Jan 2024 10:30:00 GMT
# Public, cacheable for 1 hour
Cache-Control: public, max-age=3600
# Private (user-specific), cacheable for 5 minutes
Cache-Control: private, max-age=300
# No caching (real-time data)
Cache-Control: no-store
# Revalidate before using cache
Cache-Control: no-cache
# Stale-while-revalidate (serve stale, refresh in background)
Cache-Control: public, max-age=60, stale-while-revalidate=300
| Resource Type | Strategy | Cache-Control |
|---|---|---|
| Static assets | Immutable with hash | public, max-age=31536000, immutable |
| User profile | Short-lived, private | private, max-age=60 |
| Product catalog | Medium, public | public, max-age=300, stale-while-revalidate=600 |
| Search results | No cache or very short | no-store or max-age=10 |
| Real-time data | No cache | no-store |