Stripe Webhookの同じイベントが重複処理されるとき、並行受信と再送を冪等に扱う

Stripe Webhookの再送や並行受信を前提に、同じ業務処理を二重実行しないための冪等設計を説明します。

AIものさし編集部

まず結論:イベントIDを永続化し、副作用の確定と重複排除をつなぐ

Stripe Webhookは、受信側が一度処理したイベントでも再送されることがあります。また、同じ業務対象について別々のEvent objectが作られて送られる場合もあります。アプリが「処理済みか確認してから処理する」だけだと、二つのrequestが同時に確認を通り、二重付与・二重請求・二重通知が発生し得ます。

安全な基本形は、署名を検証した後、イベントIDを永続ストレージの一意キーとして確保し、そのイベントの処理状態と業務上の副作用を同じ整合性境界で管理することです。同じevent.idが再送されたら二重に処理せず、並行requestでも一つだけが処理権を取得できるようにします。Stripe公式も、処理済みevent IDを記録して重複を無視する方法を示し、別Event objectの重複を見分ける場合はdata.objectのIDとevent.typeを使うよう案内しています。

この二つのIDは用途が異なります。event.idは同一Event objectの再送を見分ける主キーです。object ID + event.typeは、別Event objectが同じ種類・同じ対象を指す可能性を調べる補助情報です。この組み合わせを全イベントに無条件適用してはいけません。同じobject・同じtypeでも正当な状態更新が複数回起きるイベントがあります。まずイベントの意味と許される業務回数を決めてから、補助的なbusiness keyを選びます。

何が重複しているかを先に分類する

Stripe側のEvent delivery記録とアプリの処理記録を、event.id、event.type、data.object.id、受信時刻、endpoint、処理状態で照合します。payloadやsecretを公開Issueへ貼る必要はありません。

表は横にスクロールできます

ケース受信上の見え方重複判定の主キー
同じEvent objectが再送されたevent.idが同じdeliveryが複数回event.id
同じEventがほぼ同時に届いたevent.idは同じ、到着時刻が近いevent.id + DBの一意制約
同一対象に別Event objectが作られたevent.idは別、object ID/typeが同じことがあるイベントの業務意味に合わせたkey
別の正当な更新object ID/typeが同じでも別状態遷移別イベントとして処理
受信側が2xx前に失敗したStripeがretryする可能性event.idで再処理安全性を確保
queue workerが再実行されたdeliveryは一件でもjobが複数回実行queue job / domain operationのidempotency key

まず「同じevent.idが複数回届いた」のか、「event.idは違うが似た業務内容が二度反映された」のかを分けます。違う問題に同じdedupe条件を使うと、再送は止められないまま正当な更新を捨てることがあります。

Stripe Webhookで守る入口の順序

Webhook handlerは、受信内容を信用して処理を始める前に署名を検証します。Stripe署名検証には、受信したraw request body、Stripe-Signature header、endpoint固有のsigning secretが必要です。Frameworkのmiddlewareがbodyをparse・再serializeしてraw bytesを変えると、検証に失敗することがあります。JSONを自由に整形してから検証しないでください。

入口では複雑な業務処理を同期実行せず、署名検証済みイベントを耐久性のあるqueueまたはDBへ安全に受け入れ、受け入れが確定してから2xxを返します。保存より先に2xxを返すと、queueへの書き込み失敗時にStripe側は成功と判断し、イベントを再送しないため、業務処理が欠落する可能性があります。逆にDBへ保存する前に処理を走らせ、応答がtimeoutすると同イベントが再送され、二重実行の経路ができます。

典型的な流れは次のとおりです。

  1. POSTを受ける。

  2. raw bodyとStripe-Signatureで署名検証する。

  3. event.id、event.type、data.object.id、payload versionなど必要な情報を記録する。

  4. event.idにunique制約を持つinboxレコードをinsertする。

  5. すでにevent.idがあれば重複deliveryとして業務副作用を再実行せず、処理状況に応じて2xxを返す。

  6. 新規レコードをqueue workerへ渡し、状態遷移を記録して処理する。

  7. 重要な業務更新はDB transaction内で一度だけ確定する。

  8. 外部サービスへの副作用にも同じ業務operation keyを渡し、再試行を安全にする。

4xx/5xxやタイムアウト、Stripe側retryの条件はendpointとイベントの状況で異なるため、常に同じresponseを返すとは限りません。応答を返すタイミング、再送、処理失敗後の再開ポリシーを一組として設計します。受信しただけで業務処理が成功したと見なさないよう、delivery状態とbusiness completionを別々に記録します。

「確認してから処理」だけでは並行実行に負ける

次のような処理は競合に弱い設計です。


if event.id が処理済みでない:

    決済・付与・通知を実行

    event.idを処理済みとして保存

二つのworkerが同時に「未処理」と読んでから副作用を実行すると、両方が成功できます。DB上での確認だけでなく、処理権の取得を一つの原子的操作にする必要があります。

一つの方法はevent_inboxテーブルにevent.idのunique constraintを設定し、insertに成功したworkerだけを処理者とすることです。既存event.idをinsertしようとして一意制約違反になったworkerは、同じeventを並行処理しないようにします。別方式として、状態列とcompare-and-swap相当の更新、DB advisory lock、queueのdeduplication機能を使う方法もあります。どの方式でも、ロック失効後の再取得やworker crash時に、二つが同時に処理権を得ないことをテストします。


-- 概念例。実際のDDLやSQL方言は利用DBへ合わせる。

CREATE TABLE stripe_event_inbox (

  event_id TEXT PRIMARY KEY,

  event_type TEXT NOT NULL,

  object_id TEXT,

  state TEXT NOT NULL,

  received_at TIMESTAMP NOT NULL,

  processed_at TIMESTAMP

);

この例はデータベース製品に依存しない概念説明です。transaction隔離レベル、upsert構文、unique制約の競合時の扱いはDBごとに確認します。ここで重要なのは、アプリ側のif文だけで排他制御しないことです。

副作用とDB記録の間で失敗した場合

event.idを先に「処理済み」にしてから外部副作用を呼び、その呼び出しが失敗した場合、再送を重複として捨てると処理が永久に抜け落ちます。反対に副作用を先に呼び、完了記録の保存が失敗すると、retryで同じ副作用が再実行されます。

状態を少なくとも「received」「processing」「processed」「failed/retryable」などに分け、どの失敗なら再試行できるかを決めます。inboxと業務DBを同じDB transactionで更新できるなら、そのtransaction内で副作用に相当するDB変更とevent状態を確定します。外部API、メール、決済、メッセージ配信などtransaction外の副作用は、outbox patternやproviderが提供するidempotency keyを検討し、業務operationの一意キーを渡します。Stripe APIのidempotency keyとWebhook受信event IDは別物なので、混同しません。

Workerが一定時間後に失敗状態を再処理する場合、同じ行を二つのworkerが同時に取得しない仕組みが必要です。たとえば短いtransaction内で行をclaimし、lease期限、attempt count、last error classを記録します。長い業務処理中にDB transactionを開き続けるのではなく、claimと副作用を分離し、再実行時もbusiness operation keyで一度だけの結果に収束させます。

別Event objectの「意味上の重複」を慎重に扱う

Stripeは、同じevent objectの再送とは別に、同一対象について二つの異なるEvent objectを生成・送信する場合があると案内しています。その候補を探すためにdata.object.idとevent.typeを組み合わせられます。ただし、同じ対象に同じ種類のeventが複数回発生するのが正当なリソースもあります。そこでこのペアを一意制約にし、後続の正当な変更をすべて捨てるのは危険です。

たとえば「ユーザーへ初回特典を一度だけ付与する」業務なら、対象ユーザーと特典種別、対象subscription/periodなど、業務上の一回性を表すkeyを設計します。「すべてのsubscription.updatedを一度だけ処理する」ではなく、処理すべき状態遷移を判別する条件も合わせて定義します。StripeのEvent IDはtransport-levelのdedupeに使い、business idempotency keyは業務上の一回性へ使う、という二段に分けます。

Event objectのdataには作成時のAPI versionによる構造が記録され、あとからaccountのversionを変えても過去Eventの内容は遡って変わりません。受信イベントのpayload形式を読むときは、固定されたAPI versionやsnapshot/thin eventの形式も確認してください。古いpayloadを最新schemaと同じだと仮定してはいけません。

障害を再現するテストケース

本番データに副作用を発生させず、テストDB・sandbox・fake providerで次のケースを確認します。Stripe CLIやDashboardのtest eventを使う場合でも、どのdeliveryが生成されたか記録し、運用環境の実ユーザーへ誤通知しない設定にします。

表は横にスクロールできます

テスト入力合格条件
同じevent.idを順番に2回同じ署名済みfixtureを2回投入業務副作用は一度だけ
同じevent.idを同時に2回並行HTTP requestまたはworker片方だけが処理権を取得
event保存後にworker停止inbox登録後にworkerを停止queue再開後に一度だけ完了
副作用成功後に完了記録失敗providerは成功、DB更新を故意に失敗再実行で二重副作用が起きない
署名不正bodyを変えるか署名を不一致にする処理されず、適切な失敗応答
別event.id、同じobject/type業務上の類似eventを投入正当な更新を捨てず、business keyで判定
異なるevent type、同じobject ID連続する状態遷移を投入順序前提を置かず、必要なら最新状態を再取得
queue jobの再実行同じjob keyでworker再実行完了状態を一度だけ保持

テストで単にHTTP 200を確認するだけでは不十分です。受信レコード、workerのclaim、DB更新、外部副作用のカウントを別々に検証します。

運用で記録する最小情報

重複が発生した際は、調査用にevent ID、event type、object ID、endpoint ID、受信日時、Stripe delivery status、inbox state、worker attemptを記録します。顧客PII、全payload、signature、signing secret、API secretを一般ログへ記録しないでください。payload保存が業務上必要な場合は、アクセス権、保持期限、暗号化、削除手順を明確にします。

Stripe公式資料では、live modeの自動delivery retryは最大3日、sandboxは数時間の間に3回と説明されています。またDashboardでのResendは作成後15日まで、Stripe CLIによる再送は30日までとされています。手動再送しても自動retryが必ず止まるわけではありません。これらは現在の公式説明に基づく範囲なので、将来の運用では対象modeと最新ドキュメントを再確認します。

実装前のチェックリスト

  • raw bodyとStripe-Signature headerで署名検証する

  • 署名検証後にevent.idを永続的な一意キーとして確保する

  • 「未処理か確認→副作用」の二段処理だけに依存していない

  • 同じevent.idを並行受信しても一つの処理者に限定される

  • 2xxを返す前にイベントを耐久的に保存している

  • delivery stateと業務処理の完了を区別している

  • queue worker再試行でも副作用が冪等

  • object ID + event.typeを全イベントに無条件適用していない

  • 外部副作用のoperation keyを決めた

  • Sandboxで再送・並行・worker停止後の再開を試した

  • logにsecret、signature、不要な個人情報を出していない

次に行うこと

最初に同じイベントのdelivery記録をevent.idでグループ化し、別Event objectの類似処理はobject ID/typeと業務状態遷移で別に調べてください。次にinboxのunique制約、workerのclaim、外部副作用のidempotency keyを図にし、同時requestと途中失敗のテストをsandboxで実行します。実DB、queue、endpointのコードを見ていないため、ここに示したSQLや状態名は設計例です。利用中のDBと業務要件に合わせて実装し、Productionへ適用する前にrollback可能なmigrationと重複防止テストを確認してください。

Sources