- Published on
API Design Complete Guide — REST, OpenAPI, Versioning, Pagination, Idempotency, Webhooks (2025)
- Authors

- Name
- Youngju Kim
- @fjvbn20031
Prologue — "Great APIs are loved"
Open the Stripe API docs in April 2026. Requests, responses, and SDK examples live on a single page. Errors come with consistent codes and helpful messages. Versions are explicit (Stripe-Version: 2024-12-18). Rate limits, Idempotency, and Webhook signatures are standardized.
Why is Stripe called "the best API"? Consistency, predictability, documentation. That's design, not luck.
If performance is technical quality, API design is user experience — for your developer users. Good APIs are predictable, stable, forgiving, and transparent about limits and deprecation.
Part 1 — Richardson Maturity Model
Level 0: Single endpoint, POST everything
Level 1: Resource URLs (but verbs still leak in)
Level 2: Proper HTTP methods (GET/POST/PUT/DELETE)
Level 3: HATEOAS (hypermedia links in responses)
Reality: most APIs sit at Level 2. Level 3 HATEOAS is elegant in theory but rarely worth the overhead.
Part 2 — 10 REST Design Principles
1. Resource-oriented URLs
Good: /users/123/orders
Bad: /getUserOrders?userId=123
2. Use plural nouns
Good: /users, /orders, /products
Bad: /user, /order (mixed)
3. Respect HTTP method semantics
| Method | Meaning | Idempotent |
|---|---|---|
| GET | Read (safe) | Yes |
| POST | Create | No |
| PUT | Full replace | Yes |
| PATCH | Partial update | Depends |
| DELETE | Delete | Yes |
4. Use status codes correctly
200 OK / 201 Created / 202 Accepted / 204 No Content
400 Bad Request / 401 Unauthorized / 403 Forbidden
404 Not Found / 409 Conflict / 422 Unprocessable / 429 Too Many Requests
500 / 502 / 503 / 504
Common mistake: always returning 200 with { "error": ... } in body. Never do this.
5. Consistent error format — RFC 9457
{
"type": "https://example.com/errors/insufficient-funds",
"title": "Insufficient funds",
"status": 422,
"detail": "Account balance $50 is less than required $100",
"instance": "/accounts/12345/transactions/abc",
"errors": [
{ "field": "amount", "message": "must be <= balance" }
],
"requestId": "req_abc123"
}
RFC 9457 (Problem Details) replaced RFC 7807 in 2023.
6. Consistent response envelope
Pick one shape — envelope or bare object — and stick with it project-wide.
7. Times in ISO 8601 + UTC
{ "createdAt": "2026-04-15T12:34:56Z" }
8. Money as integer minor units
{ "amount": 10000, "currency": "KRW" }
Floats break precision.
9. Boolean names
Use isActive, hasPermission, canEdit — not ambiguous nouns.
10. IDs as strings with prefixes
{ "id": "usr_01HX9G7..." }
Prefixes (usr_, ord_) help debugging; sequential integers invite enumeration attacks.
Part 3 — OpenAPI 3.1 in Practice
Basic shape
openapi: 3.1.0
info:
title: Example API
version: 1.0.0
servers:
- url: https://api.example.com/v1
paths:
/users:
get:
summary: List users
operationId: listUsers
parameters:
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
- name: cursor
in: query
schema: { type: string }
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/UserList'
components:
schemas:
User:
type: object
required: [id, email]
properties:
id: { type: string, pattern: '^usr_' }
email: { type: string, format: email }
createdAt: { type: string, format: date-time }
Zod as single source of truth
import { z } from 'zod';
import { generateSchema } from '@anatine/zod-openapi';
export const User = z.object({
id: z.string().startsWith('usr_'),
email: z.string().email(),
createdAt: z.string().datetime(),
});
export type User = z.infer<typeof User>;
const openApiSchema = generateSchema(User);
2025 tooling
| Tool | Purpose |
|---|---|
| Zod + @anatine/zod-openapi | TS-first, auto schema |
| Typebox | Type-friendly schema |
| Hono + zod-openapi | Router + OpenAPI auto |
| tRPC + trpc-openapi | tRPC to OpenAPI |
| FastAPI (Python) | Pydantic + OpenAPI |
| Spectral | Linting |
| Scalar, Mintlify, Stoplight | Docs UI |
Spec-first vs Code-first: 2025 consensus is code-first. The spec is a derived artifact.
Part 4 — Versioning
Five approaches
- URL path —
/v1/users,/v2/users. Clear, cache-friendly. - Accept header —
Accept: application/vnd.example.v1+json. Clean URLs, harder to debug. - Custom header —
X-API-Version: 2024-12-18. Stripe's date-based approach. - Query parameter —
?api-version=2. Not REST-y. - Content negotiation —
Accept: application/json; version=2.
2025 recommendation
Public API: URL path (v1, v2) + date header
Internal: Header-based, rolling update
Mobile: URL path, long-tail support
Stripe's date versioning
Every version is kept forever (customer opt-in); internally a version translator bridges them. The lesson: versioning is a long-term commitment.
Breaking vs non-breaking
Non-breaking: adding fields, endpoints, optional params, enum values (if clients handle unknowns).
Breaking: removing/renaming fields, changing types, adding required params, changing URLs or error codes.
Part 5 — Pagination
1. Offset-based
GET /users?limit=20&offset=40
Simple and UI-friendly, but deep offsets are slow and concurrent inserts cause duplicates/gaps.
2. Cursor-based
GET /users?limit=20&cursor=eyJpZCI6MTAwfQ==
{ "data": [...], "nextCursor": "eyJpZCI6MTIwfQ==", "hasMore": true }
Fast for deep pagination and stable order; no random access.
3. Keyset (seek method)
-- slow offset
SELECT * FROM users ORDER BY id LIMIT 20 OFFSET 100000;
-- fast keyset
SELECT * FROM users WHERE id > 100020 ORDER BY id LIMIT 20;
Very fast, index-driven; changing the sort column gets complicated.
Choose
Paged UI ("1 2 3 ... 10") → Offset
Infinite scroll / API → Cursor
Internal batch, huge tables → Keyset
Part 6 — Idempotency
User taps "Pay", network times out, client retries. The server sees two requests, charges twice. The fix is an Idempotency Key.
Stripe pattern
POST /charges HTTP/1.1
Idempotency-Key: f8a9b2c3-...-xyz
Content-Type: application/json
{ "amount": 10000, "currency": "KRW" }
Server logic:
1. Check Idempotency-Key
2. If present:
- same body -> return stored response
- different -> 409 Conflict
3. If absent: process and cache result (24h TTL)
When required
- GET/PUT/DELETE — naturally idempotent
- POST — must accept Idempotency-Key
- PATCH — design dependent
Node + Redis sketch
async function idempotentHandler(req, res) {
const key = req.headers['idempotency-key'];
if (!key) return res.status(400).json({ error: 'Idempotency-Key required' });
const lock = await redis.set(`lock:${key}`, '1', 'NX', 'EX', 30);
if (!lock) await sleep(500);
const cached = await redis.get(`idempotency:${key}`);
if (cached) {
const { bodyHash, response } = JSON.parse(cached);
if (bodyHash !== hash(req.body)) {
return res.status(409).json({ error: 'Conflicting request' });
}
return res.status(response.status).json(response.body);
}
const result = await processPayment(req.body);
await redis.set(
`idempotency:${key}`,
JSON.stringify({ bodyHash: hash(req.body), response: { status: 200, body: result } }),
'EX', 86400
);
return res.json(result);
}
Part 7 — Rate Limiting
Three algorithms
- Token Bucket — tokens refill at a steady rate; bursts allowed up to bucket size. Easy.
- Leaky Bucket — queue drains at a fixed rate; bursts smoothed. Medium.
- Sliding Window — precise count over last N seconds via Redis sorted sets. Hard but accurate.
Response headers
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1697050800
# Or RFC 9466 (draft):
RateLimit-Limit: 100, 100;w=60
RateLimit-Remaining: 42
RateLimit-Reset: 58
429 with Retry-After
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json
{ "error": "Rate limit exceeded", "retryAfter": 60 }
Implement via Redis + Lua, Cloudflare, Envoy Rate Limit, or API gateways (Kong, Tyk, Traefik).
Part 8 — Webhook Design
Seven essentials
- Signing — HMAC-SHA256 over
timestamp + "." + body, sent asX-Example-Signature: t=...,v1=.... Verifies origin and prevents tampering.
function verifyWebhook(body: string, signature: string, secret: string): boolean {
const [t, v1] = signature.split(',').map(s => s.split('=')[1]);
const timestamp = parseInt(t);
if (Math.abs(Date.now()/1000 - timestamp) > 300) return false;
const expected = hmacSha256(secret, `${t}.${body}`);
return timingSafeEqual(expected, v1);
}
- Timestamp — reject messages older than 5 minutes to block replay.
- Retry with backoff — e.g., 1m, 5m, 15m, 1h, 6h, 24h for up to 3 days.
- Idempotency — include event ID so receivers dedupe.
- Ordered vs unordered — Stripe uses unordered; receivers re-fetch latest state.
- Version —
Accept-Versionheader orapiVersionin payload. - Testing/replay — dashboard re-send, Stripe CLI
stripe listen, never disable signature checks in dev.
Receiver checklist
1. Verify signature first
2. Check timestamp (<= 5 minutes)
3. Dedupe by event ID
4. Return 200 immediately; queue heavy work
5. Async processing (Bull, Celery, etc.)
6. Return 5xx on failure so sender retries
Part 9 — AsyncAPI and Events
CloudEvents (CNCF standard)
{
"specversion": "1.0",
"type": "com.example.order.created",
"source": "/orders",
"id": "evt_abc",
"time": "2026-04-15T12:00:00Z",
"datacontenttype": "application/json",
"data": { "orderId": 123, "amount": 10000 }
}
AsyncAPI 3.0
asyncapi: 3.0.0
info:
title: Order Service Events
version: 1.0.0
channels:
orderCreated:
address: orders.created
messages:
orderCreatedMessage:
payload:
type: object
properties:
orderId: { type: string }
amount: { type: integer }
Webhook vs Queue vs Stream
| Pattern | Shape | Examples |
|---|---|---|
| Webhook | HTTP push | Stripe, GitHub |
| Queue | Pull, 1:1 | SQS, RabbitMQ |
| Stream | Pull, 1:N, ordered | Kafka, Kinesis |
External integration → Webhook. Internal jobs → Queue. Audit/analytics → Stream.
Part 10 — REST vs GraphQL vs gRPC vs tRPC
REST + OpenAPI: public APIs, caching, diverse clients
GraphQL: complex graphs, client-driven field selection
gRPC: internal microservices, perf, polyglot
tRPC: TS monorepo full-stack, fast dev
TypeSpec (Microsoft) is rising — a concise IDL that compiles to OpenAPI.
Part 11 — Security and Auth
| Method | Notes |
|---|---|
| API Key | Simple, rotation hard |
| OAuth 2.0 | User delegation, complex |
| JWT | Stateless, revocation hard |
| Session Cookie | Easy revocation, mind CSRF |
| mTLS | Service-to-service, strong |
API key best practices
- Prefix (
sk_live_,pk_test_) for type clarity - At least 32 random characters
- Store hashed; compare hash to hash
- Scope permissions (read-only, admin)
- Rotate cleanly (old+new overlap)
- Enroll in secret scanners (GitHub)
CORS
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization, Idempotency-Key
Access-Control-Max-Age: 86400
Browsers reject Allow-Origin: * combined with credentials — always name the origin.
Part 12 — Deprecation and Sunset
Sunset header (RFC 8594)
Sunset: Wed, 01 Jan 2027 00:00:00 GMT
Deprecation: true
Link: <https://docs.example.com/v2>; rel="successor-version"
Policy: 6 months ahead start deprecation header, 3 months email, 1 month final notice, after sunset return 410 Gone. Provide migration guides, usage dashboards, and SDK warnings.
Part 13 — 6-Month Roadmap
- Month 1: REST basics, methods, status codes, errors
- Month 2: OpenAPI 3.1 with Zod + zod-openapi
- Month 3: Pagination (all three styles), cursor encoding
- Month 4: Idempotency + Rate Limiting on Redis
- Month 5: Webhook system — signing, retries, dashboard
- Month 6: Docs automation (Scalar/Mintlify), SDK generation
Part 14 — 12-Item Checklist
- OpenAPI 3.1 auto-generated (code-first)
- HTTP methods / status codes consistent
- Errors follow RFC 9457
- Idempotency-Key required on POST
- Rate-limit headers
- Cursor pagination for new endpoints
- Webhook signing (HMAC-SHA256)
- Webhook retries + dashboard
- Versioning policy documented
- Sunset/Deprecation headers
- Explicit CORS origins
- Auto-generated SDKs
Part 15 — 10 Anti-Patterns
- 200 for every error
- Verbs in URLs (
/getUserById) - Lists without pagination
- Payment APIs without idempotency
- Inconsistent error shapes
- Breaking changes without new version
- Skipping webhook signature verification
- No rate limiting on public endpoints
- Exposing sequential integer IDs
- Deleting endpoints without deprecation notice
Closing — "An API is a contract"
Once published, an API is hard to change. Customers depend on it. Breaking changes cost real money. Design carefully, document thoroughly, support for the long haul.
The 2025 ten commandments:
- Resource-oriented, plural URLs
- Correct HTTP methods and status codes
- RFC 9457 errors
- OpenAPI 3.1 as single source of truth
- Clear versioning policy
- Cursor pagination
- Idempotency-Key on POST
- Rate limits with standard headers
- Webhook signing + retries + dashboard
- Deprecation and sunset headers
Next up — SaaS Architecture Complete Guide: multi-tenancy, billing, feature flags, audit logs, RBAC/ABAC, usage metering. You built the API. Now sell it.