본문으로 바로가기
리옵트 핸드북
리옵트 핸드북
한국 사업자의 글로벌 결제

선택·계약

서비스 선택 기준국내 PG와 MoR 비교판매·계약 구조계약·DPA 검토스토어 개설·승인

결제·구독 구현

상품·가격·세금한국 Checkout한국 Checkout QA구독 라이프사이클웹훅·권한 동기화웹훅 구현 부록

정산·세무

정산·월마감정산 원장 템플릿한국 세무·회계개인사업자와 법인세무·법무 검토 자료

고객 운영

약관·소비자·개인정보한국 B2B 청구CS·환불·분쟁

사례·이전·검증

사례·운영 체크리스트Paddle→Lemon Squeezy 이전공식 자료 검증 리포트업데이트 내역
핸드북›한국 사업자의 글로벌 결제›웹훅 구현 부록

웹훅 구현 부록

두 플랫폼 각각의 서명 검증 예제·저장 스키마·worker·재전송 시험을 제공한다.

이 장은 두 서비스를 같은 주제에서 비교한 뒤 두 공급자의 상세 설명을 모두 담습니다. 기능 노출·계약 요율·국가별 결제수단은 계정과 시점에 따라 달라질 수 있으므로 실제 스토어와 각 절의 공식 출처를 확인하세요.

주제별 비교

비교 항목PaddleLemon 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_id unique로 비즈니스 중복 처리를 막으며 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 keybackend 전용, 최소 권한·만료일·소유자 지정, secret manager 저장, 정기 회전
Client-side tokenPaddle.js frontend용. API key와 분리하고 sandbox/live token을 혼용하지 않음
Webhook secretendpoint별 별도 저장, rotation 시 복수 h1 서명 검증
API versionPaddle-Version: 1을 명시적으로 고정하고 changelog 기반 upgrade review
Checkout domainlive checkout에 사용할 domain approval 상태를 배포 전 확인
Rate limit429와 Retry-After를 존중하고 지수 backoff·jitter 적용
Paginationcursor의 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 retry5분마다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 기준으로 최종 권한 결정

공식 출처

  • Webhook requests
  • Signing requests
  • Event types
  • Next.js webhook tutorial
  • API introduction

관련 문서

웹훅·권한 동기화

서로 다른 서명·이벤트·메타데이터를 동일한 멱등 권한 모델에 연결한다. 실제 운영 기준과 검증 항목을 함께 제공한다.

공식 자료 검증 리포트

Paddle와 Lemon Squeezy의 공식 문서 확인 결과, 수수료·결제수단·통화·웹훅·정산에서 재검증할 항목을 공급자별로 기록한다.

보안·코드 서명·컴플라이언스

Expo 엔터프라이즈 프로덕션 · Store signing·OTA code signing·secrets·runtime config 네 경계로 본 Expo 보안. expo-updates codesigning, EAS 변수 visibility, App Attest·Play Integrity, 릴리스 승인 체크리스트.

템플릿 & 도구

SaaS 유료 플랜 설계 · 비용에서 최소 가격을 도출하는 계산 워크시트, Good-Better-Best 티어 캔버스, 경쟁사 분석 매트릭스, 가격 인상 이메일과 런칭 전·분기별 리뷰 체크리스트를 복사해 바로 씁니다.

On this page

주제별 비교Paddle 상세Production credential과 API 기준최소 테이블endpoint 처리 원칙worker 처리 흐름권한 계산 함수replay와 reconcile테스트 시나리오참고 자료Lemon Squeezy 상세운영 자격증명최소 저장 구조Next.js endpoint 예시Worker와 권한검증 시나리오공식 출처