Skip to content

Split View: API 디자인 완전 가이드 — REST·OpenAPI·Versioning·Pagination·Idempotency·Webhook을 2025년 기준으로 한 번에 정리

|

API 디자인 완전 가이드 — REST·OpenAPI·Versioning·Pagination·Idempotency·Webhook을 2025년 기준으로 한 번에 정리

프롤로그 — "좋은 API는 사랑받는다"

2025년 4월, Stripe API 문서를 열어본다.

  • 한 페이지 안에 요청, 응답, SDK 예시가 다 있다
  • 에러는 일관된 코드 + 친절한 메시지
  • 버전은 명확 (Stripe-Version: 2024-12-18)
  • Rate limit, Idempotency, Webhook signature 모두 표준화

왜 Stripe가 "최고의 API"로 불리는가? 일관성, 예측 가능성, 문서화. 그리고 이건 운이 아니라 설계다.

성능(Ep 18)이 기술적 품질이라면, API 설계는 사용자 경험 — 개발자 사용자. 좋은 API는:

  • 예측 가능하다 — 첫 endpoint 보면 나머지가 짐작됨
  • 안정적이다 — Breaking change 거의 없음
  • 관대하다 — 부분 성공, idempotency, 에러 복구
  • 투명하다 — Rate limit, quotas, deprecation을 미리 알림

이 글은 Season 2 Ep 19 — API 디자인 완전 가이드. REST Maturity부터 OpenAPI, Versioning, Pagination, Idempotency, Rate Limiting, Webhook, Async API까지.


1부 — Richardson Maturity Model — REST의 수준

4단계

Level 0: 단일 엔드포인트 + POST로 모든 것
  POST /api
  { "action": "getUser", "id": 1 }

Level 1: 리소스별 URL
  GET /users
  POST /users/createUser (아직 verb in URL)

Level 2: HTTP 메서드 사용
  GET /users/1
  POST /users
  PUT /users/1
  DELETE /users/1

Level 3: HATEOAS (Hypermedia)
  GET /orders/1
  {
    "id": 1, "status": "paid",
    "_links": {
      "self": { "href": "/orders/1" },
      "cancel": { "href": "/orders/1/cancel" }
    }
  }

현실: 대부분 API는 Level 2. Level 3 HATEOAS는 이론적 우아함 대비 구현 오버헤드 커서 드묾.


2부 — REST API 설계 원칙 10가지

1. Resource-oriented URL

/users/123/orders
/getUserOrders?userId=123

2. 복수형 사용

/users, /orders, /products
/user, /order (단일성/복수성 섞임)

3. HTTP 메서드 의미 지키기

Method의미Idempotent
GET읽기 (안전)
POST생성
PUT전체 교체
PATCH부분 업데이트설계에 따라
DELETE삭제

4. Status Code 올바르게

200 OK — 성공
201 Created생성됨 (Location 헤더)
202 Accepted — 비동기 시작
204 No Content — 성공, 본문 없음

400 Bad Request — 입력 잘못
401 Unauthorized — 인증 안 됨
403 Forbidden — 권한 없음
404 Not Found
409 Conflict — 상태 충돌
422 Unprocessable Entity — 검증 실패
429 Too Many RequestsRate limit

500 Internal Server Error
502 Bad Gateway — 업스트림 에러
503 Service Unavailable — 일시 장애
504 Gateway Timeout

흔한 실수: 모든 에러를 200으로 반환하고 body에 { "error": ... }. 절대 금지.

5. 일관된 에러 포맷 — RFC 7807 / 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): 2023년 RFC 7807 대체. 공식 표준.

6. Response Shape 일관성

✅ 모든 리소스가 같은 envelope
{
  "data": { ... },
  "meta": { "requestId": "...", "timestamp": "..." }
}

✅ 또는 bare object (일관만 되면 OK)
{ "id": 1, "name": "Alice", ... }

중요: 프로젝트 내에서 한 방식. 섞이면 안 됨.

7. 시간은 ISO 8601 + UTC

{ "createdAt": "2026-04-15T12:34:56Z" }

8. 금액은 정수 (minor unit)

// ✅
{ "amount": 10000, "currency": "KRW" }  // 10,000원

// ❌ float은 정확성 문제
{ "amount": 100.50 }

9. Boolean 이름

✅ isActive, hasPermission, canEdit
❌ active, permission (명확하지 않음)

10. ID는 UUID or 문자열

// ✅ (미래 확장 여지)
{ "id": "usr_01HX9G7..." }

// ❌ 정수 노출 (시퀀스 유추 가능, 보안)
{ "id": 12345 }

: Prefix (usr_, ord_) — 디버그 시 ID만 보고 리소스 타입 알 수 있음.


3부 — OpenAPI 3.1 실전

기본 구조

openapi: 3.1.0
info:
  title: Example API
  version: 1.0.0
  description: |
    # Overview
    이 API는 ...

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 }

    UserList:
      type: object
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/User' }
        nextCursor: { type: string, nullable: true }

Zod로 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>;

// OpenAPI spec 생성
const openApiSchema = generateSchema(User);

2025 도구

도구용도
Zod + @anatine/zod-openapiTS First, schema 자동 생성
Typebox타입 친화적 스키마
Hono + zod-openapiHono 라우터 + OpenAPI 자동
tRPC + trpc-openapitRPC → OpenAPI 변환
FastAPI (Python)Pydantic + OpenAPI 기본
go-swagger, oapi-codegenGo
Prism, MockoonMock server
SpectralLinting
Stoplight, Readme, ScalarDocs UI

Spec-first vs Code-first

Spec-first: OpenAPI YAML 먼저 → 서버/클라이언트 생성
  장점: 팀 간 계약, 설계 먼저
  단점: 동기화 번거로움, 런타임 검증 분리

Code-first: 코드에서 스키마 선언 → OpenAPI 생성
  장점: 타입 통일, 런타임 검증 자동
  단점: spec 팀과 협의 지연

2025 현실: Code-first (Zod, Pydantic)이 우세. 스펙은 파생물.


4부 — Versioning 전략

5가지 방법

1. URL Path

https://api.example.com/v1/users
https://api.example.com/v2/users

장점: 명확, 캐시 친화적 단점: 모든 endpoint 중복

2. Header

Accept: application/vnd.example.v1+json

장점: URL 깔끔 단점: 디버그 어려움, 헷갈림

3. Custom Header

X-API-Version: 2024-12-18

Stripe 방식. 날짜 기반 버전.

4. Query Parameter

/users?api-version=2

단점: RESTful 아님

5. Content Negotiation

Accept: application/json; version=2

2025 권장

공개 API: URL path (v1, v2) + 날짜 헤더
내부 API: Header 기반, rolling update
모바일: URL path, 오래된 앱도 지원

Stripe의 날짜 버전 (영감 받기)

매일 새 버전 가능 (breaking change 시만 발표)
각 버전은 영원히 유지 (고객 옵트인)
내부적으로 "버전 translator" 운영

함의: 버저닝은 오래 지원할 각오. 짧게 유지 못 하면 고객 신뢰 상실.

Breaking vs Non-breaking Change

Non-breaking (OK):

  • 새 필드 추가
  • 새 endpoint
  • 새 optional parameter
  • 새 enum 값 (클라이언트가 unknown 처리하면)

Breaking (버전 올려야):

  • 필드 제거/이름 변경
  • 필드 타입 변경
  • required field 추가
  • URL 구조 변경
  • 에러 코드 변경

5부 — Pagination — 3가지 방식

1. Offset-based

GET /users?limit=20&offset=40

{ "data": [...], "total": 1000, "offset": 40, "limit": 20 }

장점: 간단, "페이지 5" 같은 UI 친화 단점:

  • Deep offset 느림 (OFFSET 100000은 100000+20 스캔)
  • Concurrent insert 시 중복/누락

2. Cursor-based

GET /users?limit=20&cursor=eyJpZCI6MTAwfQ==

{
  "data": [...],
  "nextCursor": "eyJpZCI6MTIwfQ==",
  "hasMore": true
}

장점: Deep pagination 빠름, 일관된 순서 단점: 랜덤 액세스 불가, UI에서 "페이지 번호" 어려움

Cursor 내용: 보통 마지막 row의 ID + 정렬 값을 base64 인코딩.

3. Keyset-based (Seek Method)

-- offset 방식 (느림)
SELECT * FROM users ORDER BY id LIMIT 20 OFFSET 100000;

-- keyset (빠름)
SELECT * FROM users WHERE id > 100020 ORDER BY id LIMIT 20;

장점: 매우 빠름 (인덱스 활용) 단점: 정렬 기준 변경 시 복잡

선택 가이드

UI"1 2 3 ... 10" 필요 → Offset (큰 데이터엔 비추)
무한 스크롤, APICursor
내부 배치 처리, 큰 테이블 → Keyset

6부 — Idempotency

왜 필요한가

사용자가 "결제" 버튼 클릭 → 네트워크 타임아웃 → 재시도
서버는 같은 요청 2번 받음 → 결제 2번 일어남 → 사고

해결: Idempotency Key

Stripe 방식 (표준)

POST /charges HTTP/1.1
Idempotency-Key: f8a9b2c3-...-xyz
Content-Type: application/json

{ "amount": 10000, "currency": "KRW" }

서버 로직:

1. Idempotency-Key 검사
2. 있으면:
   - 같은 요청 body 이면: 저장된 응답 반환
   - 다른 body 이면: 409 Conflict
3. 없으면: 신규 처리 + 결과 저장 (24시간 TTL)

어떤 메서드에 필요한가

  • GET, PUT, DELETE: 기본적으로 idempotent
  • POST: Idempotency Key 적용 필수
  • PATCH: 설계에 따라

구현 예시 (Node.js + Redis)

async function idempotentHandler(req, res) {
  const key = req.headers['idempotency-key'];
  if (!key) return res.status(400).json({ error: 'Idempotency-Key required' });

  // Lock으로 동시 요청 방지
  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);
    const currentHash = hash(req.body);
    if (bodyHash !== currentHash) {
      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);
}

7부 — Rate Limiting

3가지 알고리즘

1. Token Bucket

버킷에 토큰이 일정 속도로 쌓임 (100/)
요청은 토큰 1개 소비
토큰 없으면 거부
버스트 허용 (버킷 크기 = 200 → 순간 200 요청 OK)

2. Leaky Bucket

요청이 큐에 쌓임
일정 속도로 처리 (100/)
큐 넘치면 거부
버스트 제어 (일정 속도 보장)

3. Sliding Window

지난 1분간 요청 수 카운트
1000 초과 시 거부
Redis sorted set으로 구현

트레이드오프:

알고리즘버스트정확성구현 난이도
Token Bucket허용중간쉬움
Leaky Bucket제한높음중간
Sliding Window제한매우 높음어려움

응답 헤더 표준

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1697050800

또는 RFC 9466 (draft):
RateLimit-Limit: 100, 100;w=60
RateLimit-Remaining: 42
RateLimit-Reset: 58

429 에러 + Retry-After

HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json

{
  "error": "Rate limit exceeded",
  "retryAfter": 60
}

구현 도구

  • Redis + Lua script: 원자적 카운팅
  • Cloudflare Rate Limiting: Edge 레벨
  • Envoy Rate Limit service: Service Mesh
  • Kong, Tyk, Traefik: API Gateway 내장

8부 — Webhook 설계

Webhook이란

Service AHTTP POSTService B
이벤트 발생 시 Service B URL로 알림
: GitHub push → CI, Stripe payment → 내 서버

7가지 필수 요소

1. Signing (서명)

Header: X-Example-Signature: t=1697050800,v1=abc123...
Body: {"event": "payment.succeeded", ...}

서명 계산: HMAC-SHA256(secret, timestamp + "." + body)

목적:

  • Origin 확인 (정말 Stripe가 보냈는지)
  • 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;  // 5분 이내
  const expected = hmacSha256(secret, `${t}.${body}`);
  return timingSafeEqual(expected, v1);
}

2. Timestamp (재전송 공격 방지)

5분 이내만 허용.

3. Retry with Backoff

재시도 스케줄 예시:
1분 → 5분 → 15분 → 1시간 → 6시간 → 24시간
최대 3일까지
실패 시 알림 / 대시보드 표시

4. Idempotency

Webhook ID 포함 → 수신자가 중복 처리 안 하도록.

{ "id": "evt_123...", "event": "payment.succeeded", ... }

5. Ordered vs Unordered

Ordered: 순서 보장 (복잡, 속도 느림)
Unordered: 순서 보장 X (빠름, 수신자가 재정렬 or latest-wins)

Stripe: Unordered. 수신자는 최신 상태를 API로 재확인.

6. Version

Accept-Version: 2024-12-18 (헤더) 또는
{"apiVersion": "2024-12-18", "event": "..."}

7. Testing / Replay

  • 대시보드에서 이벤트 수동 재전송
  • 테스트 모드: Stripe CLI stripe listen
  • Signing secret로컬 dev에서 위조 검증 끄기 X (보안 습관)

수신자 체크리스트

1. 서명 검증 (무조건 먼저)
2. Timestamp 확인 (5분 이내)
3. Idempotency (이벤트 ID로 중복 체크)
4. 200 즉시 반환 (긴 처리는 큐로)
5. 비동기 처리 (Bull, Celery)
6. 실패 시 5xx 반환 → 발신자가 재시도

9부 — Async API와 이벤트 기반

CloudEvents — CNCF 표준

{
  "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 }
}

장점: 표준화된 envelope. Knative, Argo Events, Kafka 등 범용.

AsyncAPI — API 명세 for Events

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 }

도구: AsyncAPI Generator → 문서/클라이언트 생성.

Webhook vs Queue vs Stream

방식특징예시
WebhookHTTP POST 푸시Stripe, GitHub
QueuePull, 1:1 (Worker)SQS, RabbitMQ
StreamPull, 1:N, 순서Kafka, Kinesis

선택: 외부 통합 → Webhook, 내부 작업 → Queue, 감사/분석 → Stream.


10부 — GraphQL vs gRPC vs tRPC vs REST 심화

언제 무엇?

REST (+ OpenAPI):
- 외부 공개 API
- 캐시 중요
- 다양한 클라이언트 (, 모바일, 서버)

GraphQL:
- 복잡한 데이터 그래프
- 프론트가 필드 선택 원함
- 모바일 (under-fetching 중요)

gRPC:
- 내부 마이크로서비스
- 고성능 바이너리
- 다양한 언어 간

tRPC:
- 모노레포 TS 풀스택
- 빠른 개발, 타입 자동
- 외부 클라이언트 없음

REST + TypeSpec — 2025 부상

// typespec
model User {
  id: string;
  email: string;
}

@route("/users")
interface Users {
  @get list(): User[];
  @get read(@path id: string): User;
}

Microsoft가 만든 IDL. OpenAPI 컴파일. 간결함.


11부 — 보안과 인증

인증 방식

방식특징
API Key간단, 로테이션 어려움
OAuth 2.0사용자 위임, 복잡
JWT무상태, 취소 어려움
Session Cookie취소 쉬움, CSRF 주의
mTLS서비스 간, 강력

API Key 베스트 프랙티스

1. Prefix (sk_live_, pk_test_)로 구분
2. 최소 32자 무작위
3. 해시 저장 (비교는 hash == hash)
4. 권한 분리 (read-only, admin 등)
5. 로테이션 쉽게 (old + new 동시 유효 기간)
6. Secrets Scanner 제휴 (GitHub secret scanning)

Rate Limit + Auth

API Key별 rate limit 다르게
- Free tier: 100/min
- Paid: 1000/min
- Enterprise: 무제한 (, soft limit 알림)

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

주의: Access-Control-Allow-Origin: * + Credentials: true브라우저가 거부. 명확한 origin 지정.


12부 — Deprecation과 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"

정책:

  • 6개월 전: Deprecation 헤더 시작
  • 3개월 전: 경고 메일/대시보드
  • 1개월 전: 마지막 알림
  • Sunset 후: 410 Gone 반환

마이그레이션 지원

  • Migration guide 문서
  • Side-by-side 버전 둘 다 지원 기간
  • Usage dashboard — 고객이 자신의 API 사용량 확인 가능
  • CLI 경고: Stripe의 stripe-node 버전별 알림

13부 — 6개월 로드맵

1개월차: REST 원칙 10가지. HTTP 메서드/상태 코드/에러 포맷 정리 2개월차: OpenAPI 3.1 스펙 작성. Zod + zod-openapi 연동 3개월차: Pagination 3방식 구현. Cursor 인코딩/디코딩 4개월차: Idempotency + Rate Limiting Redis로 구현 5개월차: Webhook 시스템 설계. 서명, 재시도, 대시보드 6개월차: API 문서 자동화 (Scalar, Mintlify). SDK 생성 (oapi-codegen)


14부 — 체크리스트 12개

  • OpenAPI 3.1 스펙 자동 생성 (code-first)
  • HTTP 메서드/상태 코드 일관성
  • 에러 포맷 RFC 9457 준수
  • Idempotency-Key POST에 필수화
  • Rate Limit + RateLimit 헤더
  • Cursor-based Pagination (신규 엔드포인트)
  • Webhook 서명 (HMAC-SHA256)
  • Webhook 재시도 + 대시보드
  • API Versioning 정책 문서
  • Sunset/Deprecation 헤더
  • CORS 명시적 origin
  • SDK 또는 클라이언트 자동 생성

15부 — 안티패턴 10가지

  1. 모든 에러 200 반환 → 클라이언트 디버깅 지옥
  2. URL에 verb (/getUserById) → RESTful 위반
  3. Pagination 없이 list 반환 → 10만 개 리소스 한 번에
  4. Idempotency 없는 결제 API → 중복 차지 사고
  5. 에러 메시지 일관성 없음 → 클라이언트 분기 폭발
  6. Breaking change without version → 신뢰 상실
  7. Webhook 서명 검증 스킵 → 위조 공격
  8. Rate Limit 없이 공개 → DDoS / 비용 폭탄
  9. ID에 순차 정수 노출 → 열거(enumeration) 공격
  10. Deprecation 통보 없이 삭제 → 고객 장애

마무리 — "API는 계약이다"

좋은 API의 본질은 계약. 한 번 공개하면:

  • 수정 어렵다
  • 고객 코드가 의존한다
  • Breaking change는 비용이다

그래서 신중하게 설계하고, 철저하게 문서화하고, 오래 유지해야 한다.

2025년 API 디자인의 10계명:

  1. 리소스 중심 URL, 복수형
  2. HTTP 메서드와 상태 코드 정확히
  3. 에러 RFC 9457 형식
  4. OpenAPI 3.1 Single Source of Truth
  5. 버저닝 정책 명확
  6. Cursor-based Pagination
  7. Idempotency Key POST
  8. Rate Limit + 표준 헤더
  9. Webhook 서명 + 재시도 + 대시보드
  10. Deprecation 통보 + Sunset 헤더

다음 글은 Season 2 Ep 20 — SaaS 아키텍처 완전 가이드. Multi-tenancy, Billing, Feature Flag, Audit Log, RBAC/ABAC, Usage Metering까지. API를 만들었다면, 그걸 SaaS로 파는 법을 알아보자.


다음 글 예고 — "SaaS 아키텍처 완전 가이드: Multi-tenancy·Billing·Feature Flag·Audit"

Season 2 Ep 20은:

  • Multi-tenant 전략 (Silo/Pool/Bridge)
  • Billing + Subscription (Stripe Billing, Lago)
  • Feature Flag (LaunchDarkly, Unleash)
  • Audit Log 설계
  • RBAC vs ABAC
  • Usage Metering + Pricing
  • SSO (SAML, OIDC)
  • Data export / Import

좋은 API를 SaaS로. 다음 글에서.

API Design Complete Guide — REST, OpenAPI, Versioning, Pagination, Idempotency, Webhooks (2025)

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

MethodMeaningIdempotent
GETRead (safe)Yes
POSTCreateNo
PUTFull replaceYes
PATCHPartial updateDepends
DELETEDeleteYes

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

ToolPurpose
Zod + @anatine/zod-openapiTS-first, auto schema
TypeboxType-friendly schema
Hono + zod-openapiRouter + OpenAPI auto
tRPC + trpc-openapitRPC to OpenAPI
FastAPI (Python)Pydantic + OpenAPI
SpectralLinting
Scalar, Mintlify, StoplightDocs UI

Spec-first vs Code-first: 2025 consensus is code-first. The spec is a derived artifact.


Part 4 — Versioning

Five approaches

  1. URL path/v1/users, /v2/users. Clear, cache-friendly.
  2. Accept headerAccept: application/vnd.example.v1+json. Clean URLs, harder to debug.
  3. Custom headerX-API-Version: 2024-12-18. Stripe's date-based approach.
  4. Query parameter?api-version=2. Not REST-y.
  5. Content negotiationAccept: 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 / APICursor
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

  1. Signing — HMAC-SHA256 over timestamp + "." + body, sent as X-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);
}
  1. Timestamp — reject messages older than 5 minutes to block replay.
  2. Retry with backoff — e.g., 1m, 5m, 15m, 1h, 6h, 24h for up to 3 days.
  3. Idempotency — include event ID so receivers dedupe.
  4. Ordered vs unordered — Stripe uses unordered; receivers re-fetch latest state.
  5. VersionAccept-Version header or apiVersion in payload.
  6. 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

PatternShapeExamples
WebhookHTTP pushStripe, GitHub
QueuePull, 1:1SQS, RabbitMQ
StreamPull, 1:N, orderedKafka, 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

MethodNotes
API KeySimple, rotation hard
OAuth 2.0User delegation, complex
JWTStateless, revocation hard
Session CookieEasy revocation, mind CSRF
mTLSService-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

  1. 200 for every error
  2. Verbs in URLs (/getUserById)
  3. Lists without pagination
  4. Payment APIs without idempotency
  5. Inconsistent error shapes
  6. Breaking changes without new version
  7. Skipping webhook signature verification
  8. No rate limiting on public endpoints
  9. Exposing sequential integer IDs
  10. 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:

  1. Resource-oriented, plural URLs
  2. Correct HTTP methods and status codes
  3. RFC 9457 errors
  4. OpenAPI 3.1 as single source of truth
  5. Clear versioning policy
  6. Cursor pagination
  7. Idempotency-Key on POST
  8. Rate limits with standard headers
  9. Webhook signing + retries + dashboard
  10. 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.