웹훅 가이드
권한 발급·회수 등 이벤트가 발생하면 상품에 등록한 웹훅 URL로 POST가 발송됩니다. 서명을 반드시 검증한 뒤 처리하세요.
수신 형식
POST {등록한 웹훅 URL}
Content-Type: application/json
X-Saemol-Event: entitlement.activated
X-Saemol-Timestamp: 1783219000
X-Saemol-Signature: hex(HMAC-SHA256(웹훅 서명 키, "{timestamp}.{body}"))
{
"event": "entitlement.activated",
"product_id": "…",
"data": {
"order_id": "…", "order_number": "MF-…", "entitlement_id": "…",
"kind": "subscription", "plan_name": "Pro",
"license_key": "MF-…", "api_key": "mfk_…", "buyer_id": "…"
},
"sent_at": "2026-07-07T…"
}웹훅 URL과 서명 키는 상품 수정 페이지의 연동 정보 카드에서 설정·확인합니다.
이벤트 목록
Business 이상으로 표시된 이벤트는 판매자 플랜의 SDK 티어가 Advanced(Business 이상)일 때만 발송됩니다. 판매자 계정 범위 이벤트는 상품 웹훅이 아닌 연동 관리의 판매자 계정 웹훅 URL로 발송되며, 이때 본문의 product_id는 null입니다.
| 이벤트 | 카테고리 | 발생 시점 |
|---|---|---|
order.paid | Purchase Event | 주문 결제 완료 |
order.refunded | Purchase Event | 환불 승인 처리 |
order.cancelled | Purchase Event · Business 이상 | 주문 취소 (구매자 취소·미결제 자동 취소) |
entitlement.activated | License Event | 권한 발급·재발급 (라이선스 키 등) |
entitlement.revoked | License Event | 권한 회수 (환불·판매자 회수) |
order.delivered | Download Event | 상품 전달 완료 |
checkout.completed | In-App Event | 인앱 결제 완료 (아이템 코드·수량·앱 사용자 id 포함) |
subscription.created | Subscription Event | 상품 구독 시작 (첫 결제 성공) |
subscription.renewed | Subscription Event | 상품 구독 갱신 결제 성공 |
subscription.cancelled | Subscription Event | 구독 해지 예약 (주기말 종료 예정) |
subscription.expired | Subscription Event | 상품 구독 종료 (만료·해지 확정) |
user.updated | User Event · Business 이상 | 활성 권한 보유 구매자의 프로필(이름) 변경 |
user.withdrawn | User Event | 활성 권한 보유 구매자의 회원 탈퇴 (판매자 측 개인정보 파기 판단용 — 전 플랜 발송) |
product.approved | Product Event · Business 이상 | 상품 심사 승인 (게시 시작) |
product.rejected | Product Event · Business 이상 | 상품 심사 반려 |
product.updated | Product Event · Business 이상 | 상품 정보 수정 |
review.created | Review Event · Business 이상 | 상품 리뷰 등록 |
review.updated | Review Event · Business 이상 | 상품 리뷰 수정 (작성 후 30일 이내) |
review.deleted | Review Event · Business 이상 | 상품 리뷰 삭제 |
product.featured | Marketplace Event · Business 이상 | 추천 상품 선정 |
product.unfeatured | Marketplace Event · Business 이상 | 추천 상품 해제 |
product.favorited | Marketplace Event · Business 이상 | 상품 찜 추가 |
api_key.issued | API Event · Business 이상 | API Key 발급 (재발급 포함) |
api_key.revoked | API Event · Business 이상 | API Key 회수 |
usage.threshold_exceeded | Usage Event | 사용량 임계치 초과 |
tenant.provision_requested | Tenant Event | 화이트라벨 테넌트 생성 요청 |
team.member_invited | Team Event · Business 이상 · 판매자 계정 | 팀원 초대 발송 |
team.member_joined | Team Event · Business 이상 · 판매자 계정 | 팀원 합류 (초대 수락) |
team.role_changed | Team Event · Business 이상 · 판매자 계정 | 팀원 역할 변경 |
team.member_removed | Team Event · Business 이상 · 판매자 계정 | 팀원 제외 |
payout.completed | Payout Event · Business 이상 · 판매자 계정 | 정산 지급 완료 |
payout.held | Payout Event · Business 이상 · 판매자 계정 | 정산 보류 |
analytics.daily_summary | Analytics Event · Business 이상 · 판매자 계정 | 일별 통계 요약 (매일) |
analytics.weekly_summary | Analytics Event · Business 이상 · 판매자 계정 | 주간 통계 요약 (매주 월요일) |
analytics.monthly_summary | Analytics Event · Business 이상 · 판매자 계정 | 월간 통계 요약 (매월 1일) |
재발급 시에는 구키
entitlement.revoked(reason: reissued) 직후 새 키 entitlement.activated가 연속 발송됩니다 — payload의 새 키로 교체하면 됩니다. ‘회원 생성’에 해당하는 통지는 구매자가 내 상품의 권한을 처음 얻는 시점의 entitlement.activated입니다.서명 검증 — verifyWebhook
SDK의 verifyWebhook이 서명과 타임스탬프(리플레이 윈도 5분)를 검증하고 타입된 이벤트를 반환합니다. 반드시 파싱 전 원문(raw body)으로 검증해야 서명이 일치합니다.
Next.js (App Router)
import { verifyWebhook, SaemolWebhookError } from '@saemol/sdk'
export async function POST(req: Request) {
const rawBody = await req.text()
let event
try {
event = verifyWebhook({
headers: req.headers,
rawBody,
secret: process.env.SAEMOL_WEBHOOK_SECRET!,
})
} catch (err) {
if (err instanceof SaemolWebhookError) return new Response(null, { status: 400 })
throw err
}
switch (event.event) {
case 'entitlement.activated':
await provision(event.data.api_key ?? event.data.license_key)
break
case 'entitlement.revoked':
await blockAccess(event.data) // event.data.reason으로 구분
break
}
return Response.json({ received: true })
}Express — express.json() 이전에 raw로 받는다
import express from 'express'
import { verifyWebhook } from '@saemol/sdk'
app.post('/webhooks/saemol',
express.raw({ type: 'application/json' }),
(req, res) => {
try {
const event = verifyWebhook({
headers: req.headers,
rawBody: req.body, // Buffer 그대로
secret: process.env.SAEMOL_WEBHOOK_SECRET!,
})
handle(event)
res.json({ received: true })
} catch {
res.status(400).end()
}
})SDK 없이 직접 검증
import { createHmac, timingSafeEqual } from 'node:crypto'
const expected = createHmac('sha256', webhookSecret)
.update(`${headers['x-saemol-timestamp']}.${rawBody}`)
.digest('hex')
const valid =
expected.length === signature.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(signature)) &&
Math.abs(Date.now() / 1000 - Number(headers['x-saemol-timestamp'])) <= 300실패와 재발송
발송 실패(URL 다운·비 2xx 응답)는 플랫폼에 기록되며, 판매자 대시보드 → 연동 관리에서 실패 내역 조회와 수동 재발송을 할 수 있습니다. 수신 측은 멱등하게 처리하세요 — 같은 entitlement_id 이벤트가 중복 도착할 수 있습니다.