Menu

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 つの追加で済みます。コアは変わりません。

署名検証

プロバイダー仕組み環境変数
Stripestripe.webhooks.constructEvent(公式 SDK)STRIPE_WEBHOOK_SECRET
CreemverifyWebhookSignature(HMAC-SHA256。Standard Webhooks の 3 ヘッダーと旧来の単一ヘッダーの両方に対応)CREEM_WEBHOOK_SECRET
PayPalverifyPayPalWebhookSignature(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 | paused

canceled と expired は終端状態です。プロバイダーのサブスクリプション ID が一度終端状態に達すると、二度と復活しません(再契約 = 新しいサブスクリプション ID = 新しい行)。syncSubscription はこれを用いて、順序が乱れた Webhook による「復活」の書き戻しを拒否します。paused は終端ではありません。Stripe の paused は active に戻れるためです。

Stripe のイベント

エンドポイント:POST /api/stripe/webhook

イベント処理
checkout.session.completedfulfillCheckoutSession —— クレジットパックのフルフィルメント。サブスクリプションモードでは主に補記
invoice.paidfulfillInvoicePaid —— サブスクリプションの初回・更新、および billing_reason=subscription_update の日割り請求
customer.subscription.created / .updatedsyncStripeSubscription —— ステータス同期のみ
customer.subscription.deletedhandleSubscriptionDeleted —— サブスクリプションのクレジットバケットをクリア
charge.refundedapplyRefundForCharge —— 返金処理
invoice.payment_failedhandleInvoicePaymentFailed —— 更新失敗をユーザーへ通知
radar.early_fraud_warning.createdhandleEarlyFraudWarning —— 後述

購読していないイベント種別は静かに ack します。

Radar 早期不正警告

応答動作は STRIPE_RADAR_EARLY_FRAUD_WARNING_TYPE で設定します。

値動作
refund,email自動返金し、管理者へメール通知
refund自動返金のみ
email管理者へ通知するのみ(返金しない)
空何もしない

Creem のイベント

エンドポイント:POST /api/creem/webhook

イベント処理
checkout.completedfulfillCreemCheckout —— 買い切り購入のフルフィルメント
subscription.paidfulfillCreemSubscriptionPaid —— サブスクリプションの初回/更新
subscription.active / .update / .trialing / .paused / .scheduled_cancel / .past_due / .unpaidsyncCreemSubscription —— ステータス同期
subscription.canceled / .expiredhandleCreemSubscriptionEnded —— サブスクリプションのクレジットバケットをクリア
refund.createdapplyCreemRefund
dispute.createdhandleCreemDispute

Creem のルートには追加の設計があります。SDK の厳密な型パースに進むのは購読しているイベント種別だけで、購読していない種別は署名検証の直後に ack し、パースしません。これにより、関心のないイベントで Creem がフィールドを追加したりスキーマを変更したりしても、エンドポイント全体が落ちることはありません。

一方、購読している種別での厳密パース失敗は「こちらのコードが古い」という本物のエラーとして扱い、5xx を返して Creem の 24 時間のリトライ窓に入れ、同時に警告を出します。

PayPal のイベント

エンドポイント:POST /api/paypal/webhook

買い切り購入(capture リソース)

イベント処理
PAYMENT.CAPTURE.COMPLETEDhandlePayPalCaptureCompleted —— フルフィルメント。既存の pending 行はその場で completed へ昇格し、クレジットを付与
PAYMENT.CAPTURE.PENDINGrecordPendingPayPalCapture —— pending 注文行を書く
PAYMENT.CAPTURE.DENIED / .DECLINEDhandlePayPalCaptureDenied —— failed としてマーク
PAYMENT.CAPTURE.REFUNDED / .REVERSEDhandlePayPalCaptureRefunded

/api/paypal/capture-order ルートが同期的な情報源で、Webhook は冪等なフォールバックです。そのルートが停止していても、進行中のお金は追跡可能な pending 注文行を残します。

サブスクリプション

イベント処理
PAYMENT.SALE.COMPLETEDhandlePayPalSaleCompleted —— サブスクリプション課金のフルフィルメント(PayPal のサブスクリプションは v1 sale リソースを使用)
PAYMENT.SALE.REFUNDED / .REVERSEDhandlePayPalSaleRefunded
BILLING.SUBSCRIPTION.ACTIVATED / .UPDATEDhandlePayPalSubscriptionActivated
BILLING.SUBSCRIPTION.SUSPENDEDhandlePayPalSubscriptionSuspended
BILLING.SUBSCRIPTION.PAYMENT.FAILEDhandlePayPalSubscriptionPaymentFailed
BILLING.SUBSCRIPTION.CANCELLED / .EXPIREDhandlePayPalSubscriptionEnded

エラー処理の約束

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 が後始末をします。

  1. フルフィルメントコアがトランザクションロック内で「先にフルフィルメントした勝者」を判定する
  2. 敗者のサブスクリプション行をロック内で canceled にし、勝者の参照をアダプターへ返す
  3. アダプターがプロバイダー API でのキャンセル、自動返金、警告を担当する(コアからプロバイダー API を呼び返すことはない)
  4. 返金に失敗した場合は例外を投げ、プロバイダーにリトライさせる

警告は同様に 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.completed

Creem / PayPal

どちらも公開到達可能な URL が必要です。ローカルでは ngrok のようなトンネルで転送し、各ダッシュボードの Webhook アドレスをそこへ向けてください。PayPal は NODE_ENV=development で署名検証をスキップするため、任意の HTTP クライアントから手作りのイベントを POST して試せます。

確認チェックリスト

テスト決済のあと、次の 4 点を確認してください。

  1. orders に新しい行があり、status が正しく、credits_granted が期待通りであること
  2. サブスクリプションの場合、subscriptions のステータスが正しく、年額プランでは next_grant_at / remaining_grants が初期化されていること
  3. credit_transactions に対応する行があり、スナップショットの数値が整合していること
  4. 同じイベントを再送しても、データが二度目の変化を起こさないこと