웹훅

결제, 구독, 월렛 이벤트를 실시간으로 수신하여 앱의 상태를 동기화하세요.

엔드포인트 등록

웹훅을 수신할 HTTPS 엔드포인트를 등록합니다. 수신하고자 하는 이벤트 유형을 지정할 수 있습니다.

TypeScript
await pay.webhooks.create({
  url: "https://example.com/api/webhooks/pay",
  events: [
    "subscription.created",
    "subscription.renewed",
    "subscription.cancelled",
    "payment.completed",
  ],
});
엔드포인트는 반드시 HTTPS여야 합니다. HTTP 엔드포인트는 보안상 등록이 거부됩니다.
배달 주소는 등록한 URL 하나입니다.이벤트 유형별로 서로 다른 경로(/api/webhooks/<event_type>)를 만들 필요가 없습니다 — 구독한 모든 이벤트가 위에서 등록한 단일 URL로 전송되고, 유형은 본문의 event_type과X-Webhook-Event 헤더로 구분합니다.

응답 코드와 재시도

응답 코드에 따라 재시도 여부가 갈립니다. 처리에 실패했고같은 요청을 다시 받고 싶다면 5xx를,이 요청은 몇 번을 보내도 처리할 수 없다면 4xx를 반환하세요.

2xx성공. 재시도하지 않습니다.
5xx · 타임아웃 · 연결 실패일시적 장애로 보고 최대 5회 재시도합니다.
4xx (408 · 425 · 429 제외)재시도하지 않고 즉시 dead-letter 처리합니다. 본문이 바뀌지 않으므로 거절도 바뀌지 않습니다.
408 · 425 · 429재시도 대상입니다 — 다음 시도에서 결과가 달라질 수 있습니다.
엔드포인트가 계속 실패하면 발신을 일시 중단합니다.연속 10회 실패 시 회로가 열려 5분간 요청을 보내지 않고, 실패가 이어지면 대기 시간이 두 배씩 늘어 최대 6시간까지 갑니다. 그동안의 배달은버려지지 않고 대기 시간 이후로 재예약되며, 한 번 성공하면 즉시 정상으로 돌아옵니다.

이벤트 유형

수신 가능한 이벤트 18종입니다. 필요한 이벤트만 선택적으로 구독할 수 있습니다.

공유 표시가 없는 이벤트는 Modfolio Pay 고유 이벤트입니다.@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-signaturev1,<base64> — HMAC-SHA256 over{event_id}.{timestamp}.{body}
webhook-id이벤트 ID. 재시도해도 같은 값 — 멱등 키로 쓰세요
webhook-timestampUnix 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을 구현하지 마세요. 공유 계약 패키지가 검증기를 제공합니다.

TypeScript
// .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회까지 자동으로 재시도합니다.이벤트는 유실되지 않습니다 — 모든 시도가 배달 원장에 남습니다.

1차즉시
2~5차약 30분 간격
소진약 2.5시간 후

backoff는 2n초로 계산되지만 재시도를 실제로 태우는 것은 30분 주기 배치라, 실효 간격은 최소 30분입니다. 5회를 모두 소진하면 dead-letter로 넘어가고 운영 알림이 발생하며, 관리 화면에서 수동으로 재시도할 수 있습니다. 그 안에 엔드포인트가 복구되면 자동으로 수렴합니다.

엔드포인트를 비활성화하면 대기 중이던 배달은 재시도 없이 즉시 dead-letter로 넘어갑니다.

멱등성 처리

재시도로 인해 같은 이벤트가 여러 번 전달될 수 있습니다.webhook-id 헤더로 중복을 감지하세요 — 재시도해도 값이 같은 것은 이 헤더뿐입니다. (X-Webhook-Id는 시도마다 바뀌므로 멱등 키로 쓰면 안 됩니다.)

TypeScript
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 });
}
웹훅 핸들러는 5초 이내에 200 OK를 반환해야 합니다. 오래 걸리는 작업은 큐에 넣고 비동기로 처리하세요.