베타 운영 중·유료 거래는 2026년 12월 17일 정식 오픈 후 시작됩니다. 지금은 상품을 미리 등록하고 둘러보실 수 있습니다.

웹훅 가이드

권한 발급·회수 등 이벤트가 발생하면 상품에 등록한 웹훅 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_idnull입니다.

이벤트카테고리발생 시점
order.paidPurchase Event주문 결제 완료
order.refundedPurchase Event환불 승인 처리
order.cancelledPurchase Event · Business 이상주문 취소 (구매자 취소·미결제 자동 취소)
entitlement.activatedLicense Event권한 발급·재발급 (라이선스 키 등)
entitlement.revokedLicense Event권한 회수 (환불·판매자 회수)
order.deliveredDownload Event상품 전달 완료
checkout.completedIn-App Event인앱 결제 완료 (아이템 코드·수량·앱 사용자 id 포함)
subscription.createdSubscription Event상품 구독 시작 (첫 결제 성공)
subscription.renewedSubscription Event상품 구독 갱신 결제 성공
subscription.cancelledSubscription Event구독 해지 예약 (주기말 종료 예정)
subscription.expiredSubscription Event상품 구독 종료 (만료·해지 확정)
user.updatedUser Event · Business 이상활성 권한 보유 구매자의 프로필(이름) 변경
user.withdrawnUser Event활성 권한 보유 구매자의 회원 탈퇴 (판매자 측 개인정보 파기 판단용 — 전 플랜 발송)
product.approvedProduct Event · Business 이상상품 심사 승인 (게시 시작)
product.rejectedProduct Event · Business 이상상품 심사 반려
product.updatedProduct Event · Business 이상상품 정보 수정
review.createdReview Event · Business 이상상품 리뷰 등록
review.updatedReview Event · Business 이상상품 리뷰 수정 (작성 후 30일 이내)
review.deletedReview Event · Business 이상상품 리뷰 삭제
product.featuredMarketplace Event · Business 이상추천 상품 선정
product.unfeaturedMarketplace Event · Business 이상추천 상품 해제
product.favoritedMarketplace Event · Business 이상상품 찜 추가
api_key.issuedAPI Event · Business 이상API Key 발급 (재발급 포함)
api_key.revokedAPI Event · Business 이상API Key 회수
usage.threshold_exceededUsage Event사용량 임계치 초과
tenant.provision_requestedTenant Event화이트라벨 테넌트 생성 요청
team.member_invitedTeam Event · Business 이상 · 판매자 계정팀원 초대 발송
team.member_joinedTeam Event · Business 이상 · 판매자 계정팀원 합류 (초대 수락)
team.role_changedTeam Event · Business 이상 · 판매자 계정팀원 역할 변경
team.member_removedTeam Event · Business 이상 · 판매자 계정팀원 제외
payout.completedPayout Event · Business 이상 · 판매자 계정정산 지급 완료
payout.heldPayout Event · Business 이상 · 판매자 계정정산 보류
analytics.daily_summaryAnalytics Event · Business 이상 · 판매자 계정일별 통계 요약 (매일)
analytics.weekly_summaryAnalytics Event · Business 이상 · 판매자 계정주간 통계 요약 (매주 월요일)
analytics.monthly_summaryAnalytics 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 이벤트가 중복 도착할 수 있습니다.