Webhook 処理メカニズム
Webhook は、お金を注文とクレジットに変える主経路です。本ページでは、3 プロバイダーのイベントが同一のフルフィルメント経路へ集約される仕組みを説明します。
3 層構造
app/api/{stripe,creem,paypal}/webhook/route.ts
│ やることは 3 つだけ:検証 → switch で振り分け → エラー種別で HTTP ステータスを決定
│ 業務ロジックは持たない
▼
lib/billing/{stripe,creem,paypal}.ts
│ アダプター:ステータスの正規化、主体の解決、ペイロード → コア入力への変換
│ SDK の型はここで止まり、外へ漏れない
▼
lib/billing/fulfillment.ts
プロバイダー非依存のコア。1 トランザクションで注文 + サブスクリプション + クレジットを書くプロバイダーの追加は、ルート 1 つとアダプター 1 つの追加で済みます。コアは変わりません。
署名検証
| プロバイダー | 仕組み | 環境変数 |
|---|---|---|
| Stripe | stripe.webhooks.constructEvent(公式 SDK) | STRIPE_WEBHOOK_SECRET |
| Creem | verifyWebhookSignature(HMAC-SHA256。Standard Webhooks の 3 ヘッダーと旧来の単一ヘッダーの両方に対応) | CREEM_WEBHOOK_SECRET |
| PayPal | verifyPayPalWebhookSignature(PayPal の検証 API を指数バックオフのリトライ付きで呼ぶ) | PAYPAL_WEBHOOK_ID |
ローカル開発時の注意
PayPal は localhost へ検証可能な実イベントを配信できないため、
NODE_ENV=developmentでは署名検証をスキップし、ログに警告を出します。Stripe と Creem はスキップしません。ローカルでは CLI やトンネルで転送してください。
フルフィルメントコアが提供するもの
3 つのアダプターは最終的に lib/billing/fulfillment.ts の次の関数を呼びます。
| 関数 | 役割 |
|---|---|
fulfillOneTimePurchase | クレジットパック購入:注文の書き込みと購入クレジットの付与を 1 トランザクションで |
recordPendingOneTimePurchase | 保留中の決済:クレジット 0 の pending 注文行を書く |
fulfillSubscriptionPayment | サブスクリプションの初回/更新:行の同期 + 冪等な注文書き込み + サブスクリプションクレジットのリセット + 年額ドリップの初期化 |
fulfillSubscriptionUpgrade | 期間途中のプラン変更:差分を増分付与する。サブスクリプション変更を参照 |
syncSubscription | ステータス同期のみ。クレジットとドリップのカウンタには触れない |
handleSubscriptionEnded | サブスクリプション終了:サブスクリプションのクレジットバケットをクリア |
applyRefund | 返金:返金累計額の更新、元注文のステータス書き換え、クレジットの按分回収 |
冪等性の出どころ
コード内の重複判定ではなく、データベースの制約に由来します。
ordersの(provider, provider_order_id)一意インデックスとonConflictDoNothingによる書き込み- 返金は行を追加せず
amount_refundedを累積 credit_transactions.refunded_transaction_idは UNIQUE で、1 件の消費は最大 1 回しか払い戻せない
したがって、Webhook の再送、成功ページの重複トリガー、定期実行の再実行は、いずれも構造的に安全です。
サブスクリプションステータスの正規化
各プロバイダーのネイティブステータスはアダプター内で統一された集合へ変換され、データベースにはこれだけが保存されます。
active | trialing | past_due | unpaid | canceled | incomplete | expired | pausedcanceled と expired は終端状態です。プロバイダーのサブスクリプション ID が一度終端状態に達すると、二度と復活しません(再契約 = 新しいサブスクリプション ID = 新しい行)。syncSubscription はこれを用いて、順序が乱れた Webhook による「復活」の書き戻しを拒否します。paused は終端ではありません。Stripe の paused は active に戻れるためです。
Stripe のイベント
エンドポイント:POST /api/stripe/webhook
| イベント | 処理 |
|---|---|
checkout.session.completed | fulfillCheckoutSession —— クレジットパックのフルフィルメント。サブスクリプションモードでは主に補記 |
invoice.paid | fulfillInvoicePaid —— サブスクリプションの初回・更新、および billing_reason=subscription_update の日割り請求 |
customer.subscription.created / .updated | syncStripeSubscription —— ステータス同期のみ |
customer.subscription.deleted | handleSubscriptionDeleted —— サブスクリプションのクレジットバケットをクリア |
charge.refunded | applyRefundForCharge —— 返金処理 |
invoice.payment_failed | handleInvoicePaymentFailed —— 更新失敗をユーザーへ通知 |
radar.early_fraud_warning.created | handleEarlyFraudWarning —— 後述 |
購読していないイベント種別は静かに ack します。
Radar 早期不正警告
応答動作は STRIPE_RADAR_EARLY_FRAUD_WARNING_TYPE で設定します。
| 値 | 動作 |
|---|---|
refund,email | 自動返金し、管理者へメール通知 |
refund | 自動返金のみ |
email | 管理者へ通知するのみ(返金しない) |
| 空 | 何もしない |
Creem のイベント
エンドポイント:POST /api/creem/webhook
| イベント | 処理 |
|---|---|
checkout.completed | fulfillCreemCheckout —— 買い切り購入のフルフィルメント |
subscription.paid | fulfillCreemSubscriptionPaid —— サブスクリプションの初回/更新 |
subscription.active / .update / .trialing / .paused / .scheduled_cancel / .past_due / .unpaid | syncCreemSubscription —— ステータス同期 |
subscription.canceled / .expired | handleCreemSubscriptionEnded —— サブスクリプションのクレジットバケットをクリア |
refund.created | applyCreemRefund |
dispute.created | handleCreemDispute |
Creem のルートには追加の設計があります。SDK の厳密な型パースに進むのは購読しているイベント種別だけで、購読していない種別は署名検証の直後に ack し、パースしません。これにより、関心のないイベントで Creem がフィールドを追加したりスキーマを変更したりしても、エンドポイント全体が落ちることはありません。
一方、購読している種別での厳密パース失敗は「こちらのコードが古い」という本物のエラーとして扱い、5xx を返して Creem の 24 時間のリトライ窓に入れ、同時に警告を出します。
PayPal のイベント
エンドポイント:POST /api/paypal/webhook
買い切り購入(capture リソース)
| イベント | 処理 |
|---|---|
PAYMENT.CAPTURE.COMPLETED | handlePayPalCaptureCompleted —— フルフィルメント。既存の pending 行はその場で completed へ昇格し、クレジットを付与 |
PAYMENT.CAPTURE.PENDING | recordPendingPayPalCapture —— pending 注文行を書く |
PAYMENT.CAPTURE.DENIED / .DECLINED | handlePayPalCaptureDenied —— failed としてマーク |
PAYMENT.CAPTURE.REFUNDED / .REVERSED | handlePayPalCaptureRefunded |
/api/paypal/capture-order ルートが同期的な情報源で、Webhook は冪等なフォールバックです。そのルートが停止していても、進行中のお金は追跡可能な pending 注文行を残します。
サブスクリプション
| イベント | 処理 |
|---|---|
PAYMENT.SALE.COMPLETED | handlePayPalSaleCompleted —— サブスクリプション課金のフルフィルメント(PayPal のサブスクリプションは v1 sale リソースを使用) |
PAYMENT.SALE.REFUNDED / .REVERSED | handlePayPalSaleRefunded |
BILLING.SUBSCRIPTION.ACTIVATED / .UPDATED | handlePayPalSubscriptionActivated |
BILLING.SUBSCRIPTION.SUSPENDED | handlePayPalSubscriptionSuspended |
BILLING.SUBSCRIPTION.PAYMENT.FAILED | handlePayPalSubscriptionPaymentFailed |
BILLING.SUBSCRIPTION.CANCELLED / .EXPIRED | handlePayPalSubscriptionEnded |
エラー処理の約束
3 つのルートは同じ戦略を共有します。
フルフィルメントが例外を投げる
│
├─ PermanentFulfillmentError か?
│ リトライしても決して成功しない(購入者がアカウントを削除し、組織主体もない等)
│ → 警告(Redis で重複排除)したうえで 200 を返して ack。リトライループには入れない
│
└─ それ以外のエラー
一時障害かもしれないし、こちらのバグかもしれない(修正をデプロイ後のリトライで追いつく)
→ 5xx を返し、プロバイダーに指数バックオフでリトライさせるさらに、お金に関わるイベント(Stripe の checkout.session.completed / invoice.paid、Creem の checkout.completed / subscription.paid、PayPal の対応イベント)が失敗した場合は notifyCreditGrantFailed も発火し、管理者へメールを送ります —— 支払い済みのユーザーが待っているためです。
この警告はオブジェクト ID ごとに Redis で重複排除されます。同一決済のリトライや定期実行の再実行はログに記録されるだけで、受信箱を埋め尽くすことはありません。
lib/billing/notify.ts が提供する 4 つの通知:
| 関数 | 場面 |
|---|---|
notifyCreditGrantFailed | フルフィルメント失敗。管理者へ警告 |
notifyInvoicePaymentFailed | 更新課金の失敗。ユーザーにカード更新を依頼 |
notifyFraudWarningAdmin | 不正警告。管理者へ警告 |
notifyFraudRefundUser | 不正により自動返金。ユーザーへ通知 |
重複サブスクリプションの決着
ユーザーがフロントのガードを回避して並行するサブスクリプションを 2 つ作ってしまった場合(24 時間前の古い session を支払った場合など)、lib/billing/duplicate.ts が後始末をします。
- フルフィルメントコアがトランザクションロック内で「先にフルフィルメントした勝者」を判定する
- 敗者のサブスクリプション行をロック内で canceled にし、勝者の参照をアダプターへ返す
- アダプターがプロバイダー API でのキャンセル、自動返金、警告を担当する(コアからプロバイダー API を呼び返すことはない)
- 返金に失敗した場合は例外を投げ、プロバイダーにリトライさせる
警告は同様に Redis で重複排除されます。
失われた Webhook の補償
lib/billing/reconcile.ts は定期実行タスク /api/cron/credits により毎分駆動されます。
reconcileOverdueRenewals—— 更新が滞っているサブスクリプションについて、各プロバイダーの最新請求を再実行(1 ティックあたり最大 20 件)reconcilePendingPayPalCaptures—— 保留中キャプチャを成功または失敗へ前進(1 ティックあたり最大 20 件)
定期実行が保証するのは到達であって発生ではありません。クレジットはお金が実際に決済されてから付与されます。設定方法は定期実行タスクを参照してください。
ローカルでのテスト
Stripe
stripe listen --forward-to localhost:3000/api/stripe/webhook
# 出力された whsec_xxx を STRIPE_WEBHOOK_SECRET に設定
# 特定のイベントを発火
stripe trigger checkout.session.completedCreem / PayPal
どちらも公開到達可能な URL が必要です。ローカルでは ngrok のようなトンネルで転送し、各ダッシュボードの Webhook アドレスをそこへ向けてください。PayPal は NODE_ENV=development で署名検証をスキップするため、任意の HTTP クライアントから手作りのイベントを POST して試せます。
確認チェックリスト
テスト決済のあと、次の 4 点を確認してください。
ordersに新しい行があり、statusが正しく、credits_grantedが期待通りであること- サブスクリプションの場合、
subscriptionsのステータスが正しく、年額プランではnext_grant_at/remaining_grantsが初期化されていること credit_transactionsに対応する行があり、スナップショットの数値が整合していること- 同じイベントを再送しても、データが二度目の変化を起こさないこと