Webhook 구현 부록
Paddle webhook 원본 저장, signature 검증, 멱등성 테이블, replay queue, snapshot reconcile 구현 가이드
Paddle webhook 구현은 결제 기능의 핵심 인프라입니다. 성공 redirect, client-side callback, checkout close event를 권한 부여의 근거로 쓰지 말고, 서버에서 검증한 webhook과 Paddle API snapshot을 기준으로 권한을 재계산합니다.
최소 테이블
create table paddle_webhook_events (
id bigserial primary key,
notification_id text not null unique,
event_id text not null,
event_type text not null,
occurred_at timestamptz not null,
payload jsonb not null,
signature_header text not null,
processing_status text not null default 'pending',
retry_count integer not null default 0,
last_error text,
received_at timestamptz not null default now(),
processed_at timestamptz
);
create index paddle_webhook_events_status_idx
on paddle_webhook_events (processing_status, received_at);
create index paddle_webhook_events_event_id_idx
on paddle_webhook_events (event_id);
create table billing_subscriptions (
id bigserial primary key,
workspace_id text not null,
paddle_customer_id text,
paddle_subscription_id text unique,
paddle_status text not null,
current_price_id text,
next_billed_at timestamptz,
entitlement_status text not null,
last_event_occurred_at timestamptz,
updated_at timestamptz not null default now()
);endpoint 처리 원칙
export async function POST(request: Request) {
const rawBody = await request.text()
const signature = request.headers.get('Paddle-Signature')
verifyPaddleSignature(rawBody, signature)
const event = JSON.parse(rawBody)
await saveEventIfNew({
notificationId: event.notification_id,
eventId: event.event_id,
eventType: event.event_type,
occurredAt: event.occurred_at,
payload: event,
signatureHeader: signature,
})
await enqueueBillingJob(event.notification_id)
return new Response('ok', { status: 200 })
}| 원칙 | 이유 |
|---|---|
| raw body로 signature 검증 | JSON parse 이후 문자열이 바뀌면 서명 검증이 깨질 수 있음 |
notification_id unique | 같은 webhook notification의 중복 재전송 방지 |
event_id 저장 | 같은 Paddle event를 trace하고 API event와 대조 |
5초 내 200 응답 | Paddle delivery retry와 timeout 방지 |
| 후속 작업은 queue 처리 | 이메일, CRM, 회계 연동 실패가 webhook 응답을 막지 않게 함 |
occurred_at 비교 | 늦게 도착한 과거 이벤트가 최신 상태를 덮어쓰지 않게 함 |
worker 처리 흐름
권한 계산 함수
권한은 이벤트 타입별 if문으로 직접 열고 닫지 말고, subscription snapshot에서 파생합니다.
type EntitlementStatus = 'full_access' | 'grace_access' | 'no_paid_access'
function resolveEntitlement(snapshot: {
status: string
currentPriceIds: string[]
nextBilledAt: string | null
scheduledChange?: { action: string; effectiveAt: string } | null
}): EntitlementStatus {
if (snapshot.status === 'active' || snapshot.status === 'trialing') {
return 'full_access'
}
if (snapshot.status === 'past_due') {
return 'grace_access'
}
return 'no_paid_access'
}replay와 reconcile
| 작업 | 주기 | 구현 |
|---|---|---|
| failed event retry | 5분마다 | processing_status = 'failed' 재시도 |
| manual replay | 운영자 실행 | notification_id 기준 worker 재실행 |
| subscription reconcile | 매일 | Paddle subscription list와 내부 상태 비교 |
| payout reconcile | 월마감 | transaction/payout reconciliation report와 내부 주문 매칭 |
| stale checkout cleanup | 매일 | 결제 생성 후 완료되지 않은 transaction 정리 |
테스트 시나리오
| 시나리오 | 기대 결과 |
|---|---|
같은 notification_id 두 번 수신 | 두 번째 이벤트는 저장/처리 중복 없음 |
subscription.updated가 subscription.created보다 먼저 도착 | snapshot 조회로 최종 상태 정상 반영 |
| worker 중간 실패 | event는 failed, retry 후 처리 |
| Paddle API 일시 장애 | 권한을 마지막 정상 snapshot 기준으로 유지 |
| unknown payment method type | 원본 저장, 분석 테이블에는 unknown으로 표시 |
| 성공 redirect 누락 | webhook만으로 권한 부여 |