Webhook 구현 부록
Paddle webhook 원본 저장, signature 검증, 멱등성 테이블, replay queue, snapshot reconcile 구현 가이드
핵심 요약
- 성공 redirect나 client-side callback이 아니라 서버에서 검증한 webhook과 Paddle API snapshot을 기준으로 권한을 재계산한다.
- SDK 또는 동등한 구현으로 raw body, timestamp, 모든
h1서명을 검증하고,event_idunique로 비즈니스 중복 처리를 막으며 5초 내 200을 응답한다. occurred_at을 비교해 늦게 도착한 과거 이벤트가 최신 상태를 덮어쓰지 않게 한다.- 권한은 이벤트 타입별 if문이 아니라 subscription snapshot에서 파생(
resolveEntitlement)한다. - failed retry·manual replay·일일 subscription reconcile·월마감 payout reconcile로 누락과 불일치를 보정한다.
Paddle webhook 구현은 결제 기능을 떠받치는 인프라입니다. 성공 redirect나 client-side callback, checkout close event를 권한 부여의 근거로 삼지 말고, 서버에서 검증한 webhook과 Paddle API snapshot을 기준으로 권한을 다시 계산합니다.
Production credential과 API 기준
| 항목 | 운영 기준 |
|---|---|
| API key | backend 전용, 최소 권한·만료일·소유자 지정, secret manager 저장, 정기 회전 |
| Client-side token | Paddle.js frontend용. API key와 분리하고 sandbox/live token을 혼용하지 않음 |
| Webhook secret | endpoint별 별도 저장, rotation 시 복수 h1 서명 검증 |
| API version | Paddle-Version: 1을 명시적으로 고정하고 changelog 기반 upgrade review |
| Checkout domain | live checkout에 사용할 domain approval 상태를 배포 전 확인 |
| Rate limit | 429와 Retry-After를 존중하고 지수 backoff·jitter 적용 |
| Pagination | cursor의 has_more와 next를 끝까지 순회하고 전체 개수를 선행 가정하지 않음 |
대부분의 Paddle API는 IP 기준 분당 240회, preview endpoint는 분당 1,000회 제한으로 안내됩니다. 일부 subscription 즉시 청구 변경은 구독별 시간·일 단위 제한이 별도로 있으므로 대량 migration에서 일반 API 제한만 보면 안 됩니다. report API도 생성 한도가 있으므로 월마감 job은 중복 생성을 피하고 기존 report를 재사용합니다.
최소 테이블
create table paddle_webhook_events (
id bigserial primary key,
notification_id text not null unique,
event_id text not null unique,
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')
const endpointSecret = process.env.PADDLE_WEBHOOK_SECRET
if (!signature || !endpointSecret) return new Response('Invalid webhook configuration', { status: 400 })
// Paddle SDK verifier 또는 아래 검증 요건을 충족한 함수만 사용
verifyPaddleSignature({ rawBody, signature, endpointSecret, toleranceSeconds: 5 })
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 이후 문자열이 바뀌면 서명 검증이 깨질 수 있음 |
| timestamp tolerance 검증 | 오래된 정상 요청의 replay 차단. SDK 기본 tolerance는 5초 |
모든 h1 후보 비교 | secret rotation 중 복수 서명이 올 수 있음 |
| constant-time HMAC 비교 | 비교 과정의 timing leak 방지 |
event_id unique | 같은 Paddle event의 비즈니스 처리 중복 방지 |
notification_id 저장 | destination별 전달과 수동 replay 추적 |
5초 내 200 응답 | Paddle delivery retry와 timeout 방지 |
| 후속 작업은 queue 처리 | 이메일, CRM, 회계 연동 실패가 webhook 응답을 막지 않게 함 |
occurred_at 비교 | 늦게 도착한 과거 이벤트가 최신 상태를 덮어쓰지 않게 함 |
Paddle-Signature는 ts와 하나 이상의 h1 값으로 구성됩니다. 서명 대상은 raw body를 사용한
ts:rawBody이고 HMAC-SHA256으로 계산합니다. endpoint secret을 API key나 client-side token과 혼동하지
말고 secret manager에 보관하며, 서버 시각이 tolerance 안에 들도록 NTP를 유지합니다. 가능하면 직접
파서를 작성하지 말고 Paddle 공식 SDK verifier를 사용합니다.
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'
}환불·chargeback·reversal은 subscription status와 별도인 adjustment입니다. worker가
adjustment.created/updated를 받으면 최신 adjustment를 조회하고 action, status, partial/full 금액을
내부 결제 원장에 기록한 뒤 상품 정책에 따라 credit 또는 entitlement를 조정합니다. 부분 환불만으로 전체
구독 권한을 제거하지 않습니다.
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 두 번 수신 | 두 번째 이벤트는 저장/처리 중복 없음 |
같은 event_id가 다른 delivery로 수신 | 비즈니스 side effect는 한 번만 실행 |
| timestamp tolerance를 벗어난 서명 | 400 응답, payload 저장·처리하지 않음 |
secret rotation 중 복수 h1 | 유효한 후보 하나가 일치하면 승인 |
subscription.updated가 subscription.created보다 먼저 도착 | snapshot 조회로 최종 상태 정상 반영 |
| worker 중간 실패 | event는 failed, retry 후 처리 |
| Paddle API 일시 장애 | 권한을 마지막 정상 snapshot 기준으로 유지 |
| unknown payment method type | 원본 저장, 분석 테이블에는 unknown으로 표시 |
| 성공 redirect 누락 | webhook만으로 권한 부여 |
| 부분 환불 승인 | 환불액·credit만 조정하고 전체 권한은 정책에 따라 유지 |
참고 자료
- Paddle Developer - Webhooks overview
- Paddle Developer - Signature verification
- Paddle Developer - Handle webhook delivery
- Paddle Developer - Handle provisioning and fulfillment
- Paddle Developer - adjustment.created
- Paddle Developer - adjustment.updated
- Paddle Developer - API authentication
- Paddle Developer - Client-side tokens
- Paddle Developer - API versioning
- Paddle Developer - Rate limiting
- Paddle Developer - Pagination
- Paddle Developer - Checkout domains API
- Paddle Developer - Report creation limit