웹훅
결제, 구독, 월렛 이벤트를 실시간으로 수신하여 앱의 상태를 동기화하세요.
엔드포인트 등록
웹훅을 수신할 HTTPS 엔드포인트를 등록합니다. 수신하고자 하는 이벤트 유형을 지정할 수 있습니다.
await pay.webhooks.create({
url: "https://example.com/api/webhooks/pay",
events: [
"subscription.created",
"subscription.renewed",
"subscription.cancelled",
"payment.completed",
],
});/api/webhooks/<event_type>)를 만들 필요가 없습니다 — 구독한 모든 이벤트가 위에서 등록한 단일 URL로 전송되고, 유형은 본문의 event_type과X-Webhook-Event 헤더로 구분합니다.응답 코드와 재시도
응답 코드에 따라 재시도 여부가 갈립니다. 처리에 실패했고같은 요청을 다시 받고 싶다면 5xx를,이 요청은 몇 번을 보내도 처리할 수 없다면 4xx를 반환하세요.
2xx | 성공. 재시도하지 않습니다. |
5xx · 타임아웃 · 연결 실패 | 일시적 장애로 보고 최대 5회 재시도합니다. |
4xx (408 · 425 · 429 제외) | 재시도하지 않고 즉시 dead-letter 처리합니다. 본문이 바뀌지 않으므로 거절도 바뀌지 않습니다. |
408 · 425 · 429 | 재시도 대상입니다 — 다음 시도에서 결과가 달라질 수 있습니다. |
이벤트 유형
수신 가능한 이벤트 18종입니다. 필요한 이벤트만 선택적으로 구독할 수 있습니다.
@modfolio/contracts의 verifyModfolioEvent는 이들을 모르기 때문에 서명이 맞아도 invalid_payload로 거부합니다. 공유 계약 검증기를 그대로 쓴다면 구독 목록에서 제외하세요.subscription.*
subscription.created | 새 정기결제 생성 — 현재 발생하지 않음 (정기결제 미제공 · 이용권 구매는 payment.completed) | 공유 |
subscription.renewed | 정기결제 갱신 완료 — 현재 발생하지 않음 (정기결제 미제공) | 공유 |
subscription.paused | 정기결제 일시정지 — 현재 발생하지 않음 (이용권은 일시정지하지 않음) | 공유 |
subscription.resumed | 정기결제 재개 — 현재 발생하지 않음 (정기결제 미제공) | 공유 |
subscription.plan_changed | 정기결제 플랜 변경 — 현재 발생하지 않음 (정기결제 미제공) | 공유 |
subscription.cancelled | 구독 · 1개월 이용권 해지 | 공유 |
subscription.renewal_failed | 정기결제 갱신 실패 — 현재 발생하지 않음 (정기결제 미제공) | Pay 고유 |
payment.*
payment.completed | 결제 완료 | 공유 |
payment.failed | 결제 실패 | Pay 고유 |
payment.partial_refunded | 부분 환불 처리 완료 (잔액 남음) | Pay 고유 |
payment.refunded | 전액 환불 처리 완료 (잔액 0) | Pay 고유 |
credit.*
credit.topped_up | 크레딧 충전 — 현재 발생하지 않음 (크레딧 충전·사용 미제공) | 공유 |
credit.consumed | 크레딧 사용 — 현재 발생하지 않음 (크레딧 충전·사용 미제공) | 공유 |
credit.refunded | 크레딧 환급 — 현재 발생하지 않음 (크레딧 충전·사용 미제공) | 공유 |
entitlement.*
entitlement.granted | 이용권 부여 | 공유 |
entitlement.revoked | 이용권 회수 | 공유 |
wallet.* · budget.*
wallet.balance_low | 잔액 부족 알림 — 현재 발생하지 않음 (크레딧 차감 미제공) | Pay 고유 |
budget.alert.triggered | 예산 임계 초과 알림 | Pay 고유 |
서명 검증
모든 배달에는 두 벌의 서명이 함께 실립니다.Standard Webhooks규격(권장)과 기존 연동을 위한 레거시 헤더입니다. 둘은 같은 바디, 같은 타임스탬프 위에서 계산되므로 어느 쪽으로 검증해도 됩니다.신규 연동은 Standard Webhooks 쪽만 쓰세요.
헤더
webhook-signature | v1,<base64> — HMAC-SHA256 over{event_id}.{timestamp}.{body} |
webhook-id | 이벤트 ID. 재시도해도 같은 값 — 멱등 키로 쓰세요 |
webhook-timestamp | Unix seconds. 허용 오차 5분 |
X-Webhook-Signature | 레거시. t=<unix>,v1=<hex> — HMAC-SHA256 over{timestamp}.{body} |
X-Webhook-Event | 이벤트 타입 (정보성) |
X-Webhook-Event-Id | 이벤트 ID (정보성, webhook-id와 동일) |
X-Webhook-Id | 배달 시도 ID — 시도마다 바뀝니다. 로깅용이지 멱등 키가 아닙니다 |
검증 코드
직접 HMAC을 구현하지 마세요. 공유 계약 패키지가 검증기를 제공합니다.
// .npmrc — 익명 read, 토큰 불필요
// @modfolio:registry=https://pkg.modfolio.io/api/packages/modfolio/npm/
// bun add @modfolio/contracts@latest
import {
verifyModfolioEvent,
dispatchModfolioEvent,
} from "@modfolio/contracts/webhook";
export async function POST({ request, env }) {
// raw 문자열이어야 한다 — request.json() 으로 파싱한 뒤
// 재직렬화하면 바이트가 달라져 서명이 깨진다.
const body = await request.text();
const result = await verifyModfolioEvent(
body,
request.headers,
env.PAY_WEBHOOK_SECRET,
);
if (!result.success) {
// reason: missing_headers | timestamp_out_of_tolerance
// | invalid_signature | invalid_payload
// detail 은 어느 필드가 문제인지 한 줄로 알려준다.
return new Response(result.reason, { status: 400 });
}
await dispatchModfolioEvent(result.event, {
"payment.completed": (e) => grantAccess(e),
"subscription.cancelled": (e) => revokeAccess(e),
});
// 모르는 타입도 2xx — 아니면 무한 재시도를 부른다.
return new Response(null, { status: 204 });
}await request.text()로 원문 그대로 읽으세요. 프레임워크가 자동으로 JSON 파싱하는 경로(body parser)를 쓰면로컬에서는 되는데 배포하면 401이 됩니다.재시도 정책
전송이 실패하면 (2xx 이외의 응답, 또는 10초 타임아웃) 최대 5회까지 자동으로 재시도합니다.이벤트는 유실되지 않습니다 — 모든 시도가 배달 원장에 남습니다.
backoff는 2n초로 계산되지만 재시도를 실제로 태우는 것은 30분 주기 배치라, 실효 간격은 최소 30분입니다. 5회를 모두 소진하면 dead-letter로 넘어가고 운영 알림이 발생하며, 관리 화면에서 수동으로 재시도할 수 있습니다. 그 안에 엔드포인트가 복구되면 자동으로 수렴합니다.
멱등성 처리
재시도로 인해 같은 이벤트가 여러 번 전달될 수 있습니다.webhook-id 헤더로 중복을 감지하세요 — 재시도해도 값이 같은 것은 이 헤더뿐입니다. (X-Webhook-Id는 시도마다 바뀌므로 멱등 키로 쓰면 안 됩니다.)
async function handleWebhook(req: Request) {
const eventId = req.headers.get("webhook-id");
// 이미 처리한 이벤트인지 확인
const exists = await db.processedEvents.findUnique({
where: { eventId },
});
if (exists) {
return new Response("OK", { status: 200 });
}
// 이벤트 처리 + ID 기록을 트랜잭션으로
await db.$transaction(async (tx) => {
await processEvent(tx, req);
await tx.processedEvents.create({
data: { eventId, processedAt: new Date() },
});
});
return new Response("OK", { status: 200 });
}