웹훅 구현 부록
두 플랫폼 각각의 서명 검증 예제·저장 스키마·worker·재전송 시험을 제공한다.
이 장은 두 서비스를 같은 주제에서 비교한 뒤 두 공급자의 상세 설명을 모두 담습니다. 기능 노출·계약 요율·국가별 결제수단은 계정과 시점에 따라 달라질 수 있으므로 실제 스토어와 각 절의 공식 출처를 확인하세요.
주제별 비교
| 비교 항목 | Paddle | Lemon Squeezy |
|---|---|---|
| 인증 헤더 | Paddle-Signature와 timestamp를 검증한다. | X-Signature와 raw body digest를 비교한다. |
| 중복 처리 | event_id·notification_id와 업무 멱등 키를 사용한다. | 리소스 ID·이벤트명·payload digest와 업무 멱등 키를 사용한다. |
| 환경 | sandbox/live secret·ID를 분리한다. | test/live secret·ID를 분리한다. |
Paddle 상세
핵심 요약
- 성공 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
Lemon Squeezy 상세
핵심 요약
- Lemon Squeezy는
X-Signature에 raw body의 HMAC-SHA256 hex digest를 보낸다. - 서명 검증 전 JSON을 재직렬화하지 않는다. 길이가 다른 서명은 안전하게 거부한다.
- 이벤트 저장과
200응답 뒤 worker에서 최신 리소스를 조회한다. - 외부 이벤트 ID가 없는 payload는 digest·리소스 ID를 사용해 비즈니스 처리를 멱등화한다.
운영 자격증명
API key는 서버의 secret manager에 보관하고 test/live를 분리합니다. webhook signing secret은 API key와 다르며 endpoint별로 관리합니다. Lemon Squeezy API는 JSON:API 요청·응답을 사용하므로 Accept: application/vnd.api+json, Content-Type: application/vnd.api+json을 API 호출에 적용합니다. API 문서는 분당 300회 제한을 안내하므로 대량 reconcile에는 rate limit과 pagination을 고려합니다.
최소 저장 구조
create table lemon_webhook_inbox (
id bigserial primary key,
dedupe_key text not null unique,
event_name text not null,
resource_type text not null,
resource_id text not null,
test_mode boolean not null,
payload jsonb not null,
status text not null default 'pending',
received_at timestamptz not null default now(),
processed_at timestamptz,
last_error text
);
create table billing_subscriptions (
id bigserial primary key,
workspace_id text not null,
lemon_subscription_id text not null unique,
lemon_customer_id text,
variant_id text not null,
status text not null,
ends_at timestamptz,
renews_at timestamptz,
entitlement_status text not null,
snapshot_updated_at timestamptz,
reconciled_at timestamptz
);dedupe_key는 예를 들어 이벤트명 + 리소스 ID + raw payload SHA-256으로 만듭니다. 동일한 변경이 다시 도착해도 worker의 upsert와 외부 side effect 키가 중복 실행되지 않아야 합니다. digest만으로 리소스의 모든 업무 규칙을 대신하지 않습니다.
Next.js endpoint 예시
import { createHmac, createHash, timingSafeEqual } from 'node:crypto'
export async function POST(request: Request) {
const secret = process.env.LEMON_WEBHOOK_SECRET
const signature = request.headers.get('X-Signature')
const eventName = request.headers.get('X-Event-Name')
const rawBody = await request.text()
if (!secret || !signature || !eventName) return new Response('Invalid request', { status: 400 })
const expected = createHmac('sha256', secret).update(rawBody).digest('hex')
const actualBytes = Buffer.from(signature, 'utf8')
const expectedBytes = Buffer.from(expected, 'utf8')
if (actualBytes.length !== expectedBytes.length || !timingSafeEqual(actualBytes, expectedBytes)) {
return new Response('Invalid signature', { status: 401 })
}
const event = JSON.parse(rawBody)
if (event.meta?.event_name !== eventName || !event.data?.id || !event.data?.type) {
return new Response('Invalid payload', { status: 400 })
}
const digest = createHash('sha256').update(rawBody).digest('hex')
const dedupeKey = `${eventName}:${event.data.type}:${event.data.id}:${digest}`
// 앱에서 구현: inbox와 작업 outbox를 한 DB 트랜잭션으로 기록한다.
await persistInboxAndOutbox({ dedupeKey, eventName, event })
return new Response('ok', { status: 200 })
}위 코드는 핵심 흐름 예시이며 persistInboxAndOutbox는 앱에서 구현해야 합니다. 외부 queue에 바로 보낸 뒤 DB 저장에 실패하는 틈을 막기 위해 inbox와 outbox를 같은 DB 트랜잭션에 기록하고 별도 worker가 outbox를 전송합니다. 실제 서비스에는 요청 크기 제한, JSON parse 예외 처리, 환경별 secret, 안전한 로그 마스킹을 추가합니다. 공식 signing requests는 raw body 해시를 설명하며 타 공급자의 timestamp 서명 포맷을 적용하지 않습니다.
Worker와 권한
inbox pending
→ eventName과 resource ID 확인
→ Lemon Squeezy API에서 최신 order/subscription 조회
→ 내부 주문·구독 snapshot upsert
→ variant 매핑과 status/ends_at로 권한 재계산
→ 이메일·분석·회계 outbox 기록
→ inbox processed외부 조회 실패는 재시도하며 권한 상태를 임의로 전진시키지 않습니다. 고객에게 받은 meta.custom_data.workspace_id는 checkout 생성 시 서버가 저장한 intent와 대조합니다. subscription_updated가 subscription_created보다 먼저 도착해도 최신 API 조회로 동일 결과가 나와야 합니다.
검증 시나리오
| 입력 | 기대 결과 |
|---|---|
| 같은 payload 재전송 | inbox 중복 저장·외부 side effect 없음 |
| 서명 길이·값 오류 | 401, 권한 변경 없음 |
X-Event-Name과 meta.event_name 불일치 | 400, 운영 경보 |
| test 이벤트가 live endpoint에 도착 | live 원장·권한에 반영하지 않음 |
| worker 실패 후 재시도 | 같은 snapshot으로 멱등 처리 |
| 취소 후 만료 이벤트 역순 수신 | API 현재 상태와 ends_at 기준으로 최종 권한 결정 |