安全な請求更新のための冪等な支払いウェブフックチェックリスト
イベントの重複排除、再試行処理、請求書・サブスクリプション・アクセス権の安全な更新のための冪等な支払いウェブフックチェックリスト。

支払いウェブフックが重複更新を生む理由
支払いウェブフックは、課金プロバイダが「支払い成功」「請求書支払い」「サブスクリプションの更新」「返金発生」など重要な出来事が発生したときにバックエンドへ送るメッセージです。要するに「こうなったので記録を更新してね」という通知です。
重複が起きるのは、ウェブフック配信が「確実に届く」ことを目指して設計されているためで、「一度だけ届く」ことを保証するわけではないからです。サーバが遅い、タイムアウトする、エラーを返す、一時的に利用不可になると、プロバイダは通常同じイベントを再送します。また、同じ実世界の操作に紐づく別のイベント(例:請求書イベントと支払いイベント)が届くこともあります。特に返金のような素早い追随がある場合、イベント順が前後することもあります。
ハンドラが冪等でないと、同じイベントが二度適用されて次のような問題になります:
- 請求書が二重に「支払済み」扱いになり重複会計が発生する
- 更新が二度適用されてアクセス期間が過剰に延長される
- 権利が二重に付与される(追加クレジットやシート、機能)
- 返金やチャージバックでアクセスが正しく取り消されない
これは単なる「ベストプラクティス」以上の話です。信頼できる請求処理とサポートチケットだらけの請求処理の差になります。
このチェックリストの目的はシンプルです:受信した各イベントを「最大1回だけ適用する」と扱うこと。全てのイベントに安定した識別子を保存し、再試行を安全に処理し、請求書・サブスクリプション・権利を制御された方法で更新します。AppMaster のようなノーコード生成バックエンドでも同じルールが当てはまります:明確なデータモデルと再現可能なハンドラフローが必要です。
ウェブフックに適用できる冪等性の基本
冪等性とは、同じ入力を何度処理しても最終状態が同じであることを意味します。請求の観点では:同じ請求書は1回だけ支払済みに、同じサブスクリプションは1回だけ更新され、アクセスは1回だけ付与される、ということです。
プロバイダはエンドポイントがタイムアウトしたり 5xx を返したりネットワークが切れたりすると再試行します。再試行は同じイベントを繰り返します。これは数日後に発生する別の実イベント(例えば返金)のような新しいイベントとは違い、新しいイベントは異なるIDを持ちます。
これを機能させるには2つが必要です:安定した識別子と、見たことがあるかを覚えておく小さな“記憶”です。
重要なID(と保存すべきもの)
ほとんどの支払いプラットフォームはウェブフックイベント固有のイベントIDを含みます。ペイロード内にリクエストIDや冪等性キー、課金オブジェクトのID(charge や payment_intent のような)を含むこともあります。
保存すべきは「この正確なイベントを既に適用したか?」に答えられる情報です。
実務的な最小限:
- イベントID(ユニークキー)
- イベントタイプ(デバッグに便利)
- 受信タイムスタンプ
- 処理ステータス(processed/failed 等)
- 該当する顧客・請求書・サブスクリプションへの参照
重要な動きは、イベントID をユニーク制約付きのテーブルに保存することです。ハンドラはまずイベントID を挿入し、既に存在するなら停止して 200 を返す、という流れが安全です。
重複排除レコードはどれくらい保持するか
遅延再試行や調査をカバーする期間は保持してください。一般的な窓は 30~90 日です。チャージバック・異議・長いサブスク周期がある場合は 6~12 か月に延ばし、古い行は削除してテーブルを高速に保ちます。
AppMaster のような生成バックエンドでは、これは WebhookEvents モデルにイベントID のユニークフィールドを持たせ、重複検出時に早期終了するビジネスプロセスへ簡潔にマップされます。
イベントの重複排除のためのシンプルなデータモデル設計
良いウェブフックハンドラはほとんどがデータの問題です。プロバイダのイベントを一度だけ正確に記録できれば、その後の処理は安全になります。
まずはレシートログのように振る舞うテーブルを作ってください。PostgreSQL(AppMaster の Data Designer にモデリングする場合も含む)では、小さく厳格に保ち重複が早く失敗するようにします。
必要な最小限
webhook_events テーブルの実務的なベースラインは:
provider(テキスト、例:"stripe")provider_event_id(テキスト、必須)status(テキスト、例:"received"、"processed"、"failed")processed_at(タイムスタンプ、nullable)raw_payload(jsonb または text)
(provider, provider_event_id) にユニーク制約を追加してください。この単一のルールが主要な重複防止ガードレールになります。
更新対象を特定するためのビジネスIDも欲しいでしょう。これらはウェブフックイベントIDとは別物です。
一般的な例:customer_id、invoice_id、subscription_id。プロバイダは数値でないIDを使うことが多いのでテキストで保存してください。
生のペイロードとパース済みフィールド
デバッグや再処理のために raw payload を保存してください。クエリやレポートを簡単にするためにパース済みフィールドも保存できますが、本当に使うものだけを保存します。
シンプルなアプローチ:
- 常に
raw_payloadを保存する - よくクエリするパース済みID(customer、invoice、subscription)をいくつか保存する
- フィルタリング用に正規化された
event_type(テキスト)を保存する
たとえば invoice.paid が二度届いてもユニーク制約で二回目の挿入はブロックされます。監査用に raw payload が残り、パース済みの invoice ID で最初に更新した請求書をすばやく見つけられます。
手順:安全なウェブフックハンドラのフロー
安全なハンドラはわざと退屈です。プロバイダが同じイベントを再送しても、あるいはイベントが順不同で届いても、常に同じ振る舞いをします。
毎回従うべき 5 ステップのフロー
-
署名を検証してペイロードをパースします。署名チェックに失敗した、想定外のイベントタイプ、またはパースできないリクエストは拒否してください。
-
請求データに触れる前にイベントレコードを書きます。プロバイダイベントID、タイプ、作成時刻、raw payload(またはハッシュ)を保存します。イベントID が既に存在するなら重複扱いにして停止します。
-
イベントを単一の「所有者」レコードにマッピングします。何を更新するのか(請求書、サブスクリプション、顧客)を決め、外部ID をレコードに保存して直接参照できるようにします。
-
安全な状態変更を適用します。状態は常に前進させ、後戻りさせないでください。遅れて来た
invoice.updatedによって支払済みを取り消さないようにします。適用した内容(旧状態、新状態、タイムスタンプ、イベントID)を監査用に記録します。 -
速やかに応答し結果をログに残します。イベントが安全に保存され、処理または無視されたら成功を返してください。処理されたか、重複で無視されたか、拒否されたかとその理由をログに記録します。
AppMaster ではこれが webhook events テーブルと Business Process になり、「既に見たイベントID?」をチェックしてから最小限の更新ステップを実行する形になることが多いです。
再試行、タイムアウト、順不同配信の処理
プロバイダは成功応答が返らないとウェブフックを再試行します。イベントは順不同で送られることもあります。ハンドラは同じ更新が二度届いても、あるいは後続の更新が先に届いても安全である必要があります。
実用的なルール:速やかに応答し、重い処理は後で行う。ウェブフクリクエストを重いロジック実行の場と考えず、受領のレシートとして扱ってください。リクエスト内で外部API呼び出し、PDF 生成、会計計算などを行うとタイムアウトが増え、再試行が誘発されます。
順不同:最新の真実を保持する
順不同は普通に起こります。変更を適用する前に次の2点をチェックしてください:
- タイムスタンプを比較する:そのオブジェクト(請求書・サブスクリプション・権利)に既に保存してあるものより新しい場合のみ適用する
- タイムスタンプが近いか不明確な場合はステータス優先度を使う:paid は open を上回り、canceled は active を上回る
既に請求書を支払済みと記録してあるのに遅れて "open" が来たら無視します。先に "canceled" を受け取っていて後から古い "active" が来た場合は canceled を維持します。
無視するかキューに入れるか
イベントが古いか既に適用済み(同一イベントID、古いタイムスタンプ、低優先度のステータス)なら無視してください。逆に、依存データがまだ無い(例:サブスク更新が顧客レコードより先に来る)場合はキューに入れてください。
実用的なパターン:
- 受信直後にイベントを保存し、処理状態(received, processing, done, failed)を付ける
- 依存関係が欠けているなら waiting にしてバックグラウンドで再試行する
- 再試行上限を設け、繰り返し失敗したらアラートを出す
AppMaster ではイベント保存テーブルと Business Process を組み合わせ、リクエストに速やかに応答してから非同期でキュー処理するのが適切です。
請求書・サブスクリプション・権利の安全な更新
重複排除の次のリスクは分断した請求状態です:請求書は paid なのにサブスクが過去滞納のまま、あるいは権利が二度付与され取り消されていない、など。各ウェブフックを状態遷移として扱い、原子性のある更新で適用してください。
請求書:ステータス変更は単調に
請求書は paid、voided、refunded のように移行します。部分支払いもあり得ます。到着したイベントだけを見て状態をトグルしないでください。現在のステータスと主要な合計(amount_paid、amount_refunded)を保持し、前向きな遷移のみを許可します。
実務ルール:
- 請求書を "paid" にマークするのは最初に paid イベントを見たときだけ
- 返金では
amount_refundedを請求合計まで増やし、減らさない - 請求書が voided になったらフルフィルメントは止めるが監査のためレコードは残す
- 部分支払いは金額を更新するが「全額支払済み」の特典は付けない
サブスクリプションと権利:付与は1回、取り消しは1回
サブスクリプションには更新、キャンセル、猶予期間があります。current_period_start/end のような期間境界とステータスを保持し、そこから権利ウィンドウを導出してください。権利は単一のブール値ではなく明示的なレコードにしてください。
アクセス制御のルール:
- ユーザー×製品×期間ごとに1つの権利付与
- アクセス終了時(キャンセル、返金、チャージバック)には1つの取り消しレコード
- どのウェブフックイベントが変更を引き起こしたかを記録する監査トレイル
分断状態を避けるために1つのトランザクションを使う
請求書・サブスクリプション・権利の更新は一つの DB トランザクションで適用してください。現在の行を読み、イベントが既に適用されていないかを確認し、すべての変更を書きます。何か失敗したらロールバックして、"請求書は支払済みだがアクセスがない" のような状態を防ぎます。
AppMaster では単一の Business Process フローで PostgreSQL をまとめて更新し、ビジネス変更と監査エントリを書き込むのが自然なマッピングです。
ウェブフックエンドポイントのセキュリティとデータ安全性チェック
ウェブフックのセキュリティは正確性の一部です。攻撃者がエンドポイントへ偽のリクエストを送れると、「paid」を偽装して請求データを改ざんされる危険があります。重複排除があっても、イベントが本物であることを証明し顧客データを安全に保つ必要があります。
請求データに触れる前に送信元を検証する
すべてのリクエストで署名を検証してください。Stripe の場合は Stripe-Signature ヘッダと生のリクエストボディ(書き換えない JSON)を使って検証し、古いタイムスタンプのイベントは拒否するのが一般的です。ヘッダがない場合は即座に失敗扱いにしてください。
早い段階で基本を検証してください:HTTP メソッドが正しいか、Content-Type、必須フィールド(event id、type、請求書やサブスクを特定するオブジェクトID)が揃っているか。AppMaster で構築するなら署名シークレットは環境変数や安全な設定に置き、データベースやクライアントコードに置かないでください。
簡単なセキュリティチェックリスト:
- 有効な署名と新しいタイムスタンプがないリクエストは拒否する
- 期待するヘッダとコンテンツタイプを要求する
- ウェブフックハンドラには最小権限の DB アクセスを使う
- シークレットはテーブル外(env/config)で保管し、必要に応じてローテーションする
- イベントを安全に永続化してからのみ 2xx を返す
シークレットを漏らさないログを残す
リトライや異議申立てをデバッグできるだけのログを残しつつ、機微な値は避けてください。保存する PII は安全なサブセットにとどめます:プロバイダの顧客ID、内部ユーザーID、マスクしたメール(例:a***@domain.com)。カード全情報、住所全体、生の認証ヘッダなどは保存しないでください。
再構築に役立つログ項目:
- プロバイダイベントID、タイプ、作成時刻
- 検証結果(署名 OK / 失敗)※署名自体は保存しない
- 重複判定(新規 vs 既処理)
- 更新した内部レコードID(請求書/サブスクリプション/権利)
- エラー理由と再試行回数(キューしている場合)
基本的な悪用対策も追加してください:IP ごとのレート制限、可能なら顧客ID 毎の制限、セットアップが許すならプロバイダの既知 IP 範囲のみ受け付ける等です。
二重請求や二重アクセスを招くよくあるミス
ほとんどの請求バグは計算ミスではなく、ウェブフックを「一度だけ届くもの」と扱ってしまうことに起因します。
重複更新を最も招くミス:
- タイムスタンプや金額で重複排除している。 別のイベントでも同じ金額があり得ますし、再試行は数分後に来ることもあります。プロバイダのユニークイベントID を使ってください。
- 署名検証の前に DB を更新している。 まず検証、次にパース、そして行動。
- イベントをそのまま真実とみなし現在状態を確認していない。 既に paid、refunded、void の請求書に対して無条件に paid とするべきではありません。
- 同じ購入で複数の権利を作ってしまう。 再試行で重複行が生まれます。
subscription_idに対して "権利が存在することを保証する" アップサートを好むべきです。 - 通知サービスのダウンでウェブフックを失敗させる。 メールや SMS、Slack を送れないからといって課金処理を失敗させるべきではありません。通知はキューにして、コアの請求変更を安全に保存したら成功を返してください。
簡単な例:更新イベントが二度来ると、最初の配信で権利行が作られ、再試行で2番目の行が作られ「2つのアクティブ権利」ができてしまい余分なシートやクレジットが付与されます。
AppMaster での対策はフローの見直しです:まず検証、イベントレコードをユニーク制約で挿入、状態チェック付きで請求更新を行い、派生処理(メール等)は非同期ステップにして再試行嵐を防ぎます。
現実的な例:重複更新された継続課金とその後の返金
パターンは怖いように見えますが、ハンドラを安全に再実行できるようにすれば管理可能です。
顧客が月額プランに入っているとします。Stripe が継続課金(例えば invoice.paid)イベントを送ります。あなたのサーバは受け取りデータベースを更新しますが 200 を返すのに時間がかかり(コールドスタート、DB 負荷)、Stripe は失敗と見なして同じイベントを再送します。
最初の配信でアクセスを付与し、再試行では同じイベントであると検出して何もしません。その後、返金イベント(例えば charge.refunded)が届きアクセスを取り消します。
データベースでの状態モデル例(AppMaster Data Designer で作るテーブル):
webhook_events(event_id UNIQUE, type, processed_at, status)invoices(invoice_id UNIQUE, subscription_id, status, paid_at, refunded_at)entitlements(customer_id, product, active, valid_until, source_invoice_id)
各イベント後のデータベース状態の例
イベント A(継続課金、初回配信)後:webhook_events に event_id=evt_123 の新行、status=processed。invoices は paid に、entitlements.active=true、valid_until は次の期間に進む。
イベント A(再試行)後:webhook_events への挿入がユニーク制約で失敗するか、ハンドラが既に処理済みと検出するので請求書や権利に変更はない。
イベント B(返金)後:webhook_events に event_id=evt_456 の新行。invoices.refunded_at がセットされ status=refunded。entitlements.active=false または valid_until を現在に更新し、source_invoice_id を使って一度だけ取り消す。
重要なのはタイミングです:重複チェックは付与や取り消しの前に行われます。
ローンチ前のクイックチェックリスト
本番ウェブフックを有効にする前に、実際のイベントが同じ回数(2回でも10回でも)送られても請求レコードが1回だけ更新されることを証明してください。
エンドツーエンド検証のチェックリスト:
- すべての受信イベントはまず保存される(raw payload、イベントID、タイプ、作成時刻、署名検証結果)、たとえ後続処理が失敗しても
- 重複は早期に検出され(同一プロバイダイベントID)ハンドラは請求/サブスク/権利を変更せず終了する
- ビジネス更新は一度だけ行われることを証明する:請求書ステータス1回、サブスクリプション状態1回、権利付与または取り消しは1回のみ
- 失敗は再実行に十分な情報で記録される(エラーメッセージ、失敗したステップ、再試行状態)
- ハンドラは速やかに応答する:保存後に受領を返し、リクエスト内で長時間処理しない
大きな可観測性セットアップは不要ですが、信号は必要です。ログや簡単なダッシュボードで次を追跡してください:
- 重複配信のスパイク(通常はある程度発生するが急増はタイムアウトやプロバイダ障害のサイン)
- イベントタイプごとの高いエラー率(例:invoice payment failed)
- 再試行に詰まっているイベントのバックログ増加
- ミスマッチ(支払済み請求書だが権利がない、取り消し済みサブスクだがアクセスが残っている)
- 処理時間の急増
AppMaster で構築する場合はイベント保存を専用テーブルにし、「処理済みにする」決定を Business Process の単一の原子ポイントにしてください。
次のステップ:テスト、監視、そしてノーコードでの構築
テストで冪等性は実証されます。ハッピー系だけでなく、同じイベントを何度も再生し、順不同で送り、タイムアウトを発生させてプロバイダに再試行させてください。2回目・3回目・10回目の配信で何も変わらないことが期待されます。
バックフィルの計画もしておきましょう。バグ修正やスキーマ変更、プロバイダ障害の後で過去イベントを再処理したくなることがあります。ハンドラが真に冪等であれば、バックフィルは「同じパイプラインでイベントを再生する」だけで重複を作らずに済みます。
サポートチーム向けの簡単なランブックも用意してください:
- イベントID を見つけて処理済みか確認する
- 請求書やサブスクリプションレコードの状態とタイムスタンプを確認する
- 権利レコード(いつ、なぜ付与されたか)を確認する
- 必要ならその単一のイベントID に対して安全な再処理モードで再実行する
- データ不整合がある場合は是正処置を1つだけ適用して記録する
ボイラープレートを多く書かずに実装したいなら、AppMaster(appmaster.io)はコアテーブルをモデリングし、ビジュアルな Business Process でウェブフックフローを作りつつ実際のバックエンドソースコードを生成できます。
まずはノーコード生成バックエンドでウェブフックハンドラを端から端まで作り、再試行時にも安全に動くことを確認してからトラフィックと収益をスケールさせてください。
よくある質問
重複してウェブフックが届くのは正常な動作です。プロバイダは「少なくとも一度(at least once)」配信を保証する設計で、エンドポイントがタイムアウトしたり 5xx を返したり接続が切れると、同じイベントを成功応答が返るまで再送します。
プロバイダが発行する一意の イベントID を使ってください。金額やタイムスタンプ、顧客メールではなくイベントIDを保管し、ユニーク制約を設ければ再試行は即座に検出して安全に無視できます。
請求書やサブスクリプション、権利を更新する前にイベントをまず挿入してください。挿入がイベントIDの重複で失敗したら処理を中止して成功を返し、再試行で二重更新が起きないようにします。
遅延再試行や調査のカバレッジを考えて十分な期間保管してください。実務では 30~90日 が標準で、異議申立てやチャージバック、長期サイクルがある場合は 6~12か月 に伸ばし、古い行はパージしてテーブル性能を保ちます。
はい。署名検証は必須です。署名を確認してからパースと必須フィールド検証を行ってください。署名が無効なら拒否し、請求変更は書き込まないでください。重複排除だけでは偽造された「paid」イベントから守れません。
イベントを安全に保存したらすぐに受領を返し、重い処理はバックグラウンドで行ってください。リクエスト内で大量処理や外部API呼び出し、PDF生成などを行うとタイムアウトが増え、再試行と重複のリスクが高まります。
状態を前進させる変更のみ適用し、古いイベントは無視してください。タイムスタンプを比較するか、ステータス優先度(たとえば refunded は paid を上書きすべきではない、canceled は active を上書き)を使います。古いイベントは状態を戻さないでください。
毎回新しい権利レコードを作らないでください。『ユーザー×製品×期間(またはサブスクリプション)ごとに1つの権利を保証する』アップサートルールを使い、日付や上限を更新し、どのイベントIDが変更を引き起こしたかを記録します。
請求書・サブスクリプション・権利の変更を1つのデータベーストランザクション内で行ってください。そうすることで『請求書は支払済みになったがアクセスが付与されていない』のような分断状態を防げます。
できます。よく合うパターンは、WebhookEvents モデルに一意なイベントIDを持たせ、Business Process で「既に見たか?」をチェックして早期終了する方法です。請求書/サブスク/権利を Data Designer で明示的にモデリングすれば、再試行やリプレイで重複行が増えるのを防げます。


