コラム /
Webhookが2回届く前提で設計する ─ 冪等性キーと再送耐性の実装パターン
📖 約5分 / 公開日: 2026/05/18
Webhookは再送される。1回しか送られないという前提でコードを書くと、決済が二重に走り、在庫が2回引かれ、サンクスメールが3通届きます。Stripeは公式ドキュメントで明示的にat-least-once deliveryを保証していますし、Shopifyも同様です。GitHubのWebhookも、5xxを返した場合や30秒以内に応答が返らなかった場合は再送されます。
それでも冪等性を実装していないシステムが大半です。理由は単純で、テスト環境では1度しか届かないから。本番に投入してから、月初の請求バッチや決済ゲートウェイ側のリトライ嵐で初めて顕在化します。
## 最小構成は「event_idのユニークキー1つ」から始まる
冪等性の実装で最も単純なパターンは、受信したevent_idをunique index付きのテーブルにINSERTし、重複エラーが返ったら処理済みとしてスキップする方式です。Stripeならevt_...形式のIDが必ず付与されます。Shopifyは`X-Shopify-Webhook-Id`ヘッダ、GitHubは`X-GitHub-Delivery`ヘッダで一意識別できます。
```sql
CREATE TABLE webhook_processed (
event_id VARCHAR(255) PRIMARY KEY,
source VARCHAR(50) NOT NULL,
received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(20) NOT NULL DEFAULT 'received'
) ENGINE=InnoDB;
```
トランザクション開始直後にINSERTを試み、duplicate keyで200 OKを返す。これだけで「届いてしまった2回目」を握りつぶせます。
ただし落とし穴が2つあります。1つ目、INSERTした直後にアプリケーションが落ちると、event_idは記録されたのに業務処理が走っていない状態が残る。2つ目、長時間ロックを取る処理を含むと、再送が来たときにロック待ちになる。
このため、event_idの記録と業務処理は同一トランザクション内で完結させるのが基本設計です。記録だけ別トランザクションで先にcommitする設計は、上記1つ目の障害で復旧が極端に困難になります。トランザクションの粒度を切り過ぎてはいけない、というのが現場の経験則。
## 業務処理が長い場合は「受信記録」と「完了記録」を分離する
決済処理や外部API呼び出しを含む業務処理は、1トランザクション内で完結できないことがあります。Stripe Webhookを受け取って、自社の決済ステータスを更新し、Slack通知を送り、Salesforceにレコードを作る、といったケース。
ここでは「受信記録」と「処理完了記録」を分けます。受信した瞬間にevent_idを`recording_started`として記録、業務処理が全て成功した時点で`recording_completed`に更新。再送が来たときは`recording_completed`のみスキップし、`recording_started`のまま残っているものはタイムアウト後に再処理対象とする。
実装上のポイントは、`recording_started`のままで30分以上経過したレコードを再処理キューに戻すバッチを別途走らせること。これを忘れると「処理途中で落ちた決済」が永遠に放置されます。年に数件、こうした「迷子の決済」が顧客から「お金引き落とされたのに商品来ない」という問い合わせで初めて発覚する。これだけは本気で防いだ方がいい。
## 署名検証は冪等性とセットで考える
Stripe WebhookはHMAC-SHA256で署名されています。検証していないと、攻撃者が任意のevent_idでリクエストを打ち込んで、自社の冪等性テーブルを汚染できます。「evt_attacker_001は処理済み」と記録された後で、本物の同じIDのWebhookが来たら何もせずに200を返してしまう。
これは冪等性とセキュリティが交差するポイントで、ユニットテストでも見落とされがちです。署名検証に失敗したリクエストはevent_idを記録せずに400を返す。記録する前に検証する。順序は譲れません。
## タイムアウト設計でハマる典型
StripeのWebhookタイムアウトは30秒、Shopifyは5秒です。Shopifyの5秒は意外と短く、本番でAPI呼び出しを含む処理を同期実行すると簡単に超える。
正解は「受信したらキューに積んで即座に200を返す」。SQS、Cloud Pub/Sub、RedisのList、Sidekiq、なんでも構いません。本処理は別ワーカーで非同期実行する。これでShopify側のリトライは止まりますが、ワーカーの処理失敗時にShopify側で再送できないというトレードオフが発生します。
そのため、キュー投入失敗時のみ5xxを返す設計にする。キュー投入後はワーカー側で独自のリトライポリシーを持つ。デッドレターキューに溜まったものはSlackやPagerDutyに通知する。リトライ最大3回、指数バックオフ、最終的にはDLQで人間が見る。この三層構造が現場で機能する最低ラインです。
## 順序保証は最初から諦める
StripeもShopifyもWebhookの到着順序を保証しません。注文作成と注文キャンセルが順番を逆転して届くことが、年に何回か発生します。Stripeの公式ドキュメントにも「順序は保証しない」と明記されている。
タイムスタンプベースで状態遷移を判定する設計が必要です。受信したWebhookのcreated_atと、データベース上の現在のレコードのupdated_atを比較し、過去のイベントなら無視する。Shopifyはupdated_atをボディに含めて送るので、それと自社レコードのupdated_atを比較すれば判定できる。
「最新のイベントだけが状態を変える」というルールにすれば、順序が崩れても結果整合性は保たれます。状態遷移ではなく状態スナップショットとして扱う発想が要ります。これは設計時に明確に決めておかないと、後から直すのは骨が折れる部分。
## ローカル試験はngrokとstripe triggerで
ngrokでローカル開発機をインターネット公開し、Stripe CLIの`stripe listen`と`stripe trigger checkout.session.completed`で擬似的に再送試験ができます。同じevent_idで2回triggerする仕組みは標準では用意されていませんが、ngrokのリプレイ機能で全く同じリクエストを再送できる。これで冪等性の動作確認が初めて意味を持ちます。
テストコードでも、`POST /webhook/stripe` を同じペイロードで2回連続で叩いて、片方は200・もう片方は200かつ業務処理が走っていないことを assert する、というのを必ず1本書いておく。これがないと冪等性は実装したつもりで動いていない、ということが普通に起きる。
冪等性のないコードを本番に出した瞬間に、年に1〜2回は必ずインシデントが起きます。SaaSの月額10万円のサービスで二重課金が3件発生した場合、返金処理、顧客への謝罪、信用毀損、社内事後分析で、ざっくり30万円分の工数が消える。Webhook初実装の段階で2日かけて冪等性を入れる方が、桁違いに安い投資です。
それでも冪等性を実装していないシステムが大半です。理由は単純で、テスト環境では1度しか届かないから。本番に投入してから、月初の請求バッチや決済ゲートウェイ側のリトライ嵐で初めて顕在化します。
## 最小構成は「event_idのユニークキー1つ」から始まる
冪等性の実装で最も単純なパターンは、受信したevent_idをunique index付きのテーブルにINSERTし、重複エラーが返ったら処理済みとしてスキップする方式です。Stripeならevt_...形式のIDが必ず付与されます。Shopifyは`X-Shopify-Webhook-Id`ヘッダ、GitHubは`X-GitHub-Delivery`ヘッダで一意識別できます。
```sql
CREATE TABLE webhook_processed (
event_id VARCHAR(255) PRIMARY KEY,
source VARCHAR(50) NOT NULL,
received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(20) NOT NULL DEFAULT 'received'
) ENGINE=InnoDB;
```
トランザクション開始直後にINSERTを試み、duplicate keyで200 OKを返す。これだけで「届いてしまった2回目」を握りつぶせます。
ただし落とし穴が2つあります。1つ目、INSERTした直後にアプリケーションが落ちると、event_idは記録されたのに業務処理が走っていない状態が残る。2つ目、長時間ロックを取る処理を含むと、再送が来たときにロック待ちになる。
このため、event_idの記録と業務処理は同一トランザクション内で完結させるのが基本設計です。記録だけ別トランザクションで先にcommitする設計は、上記1つ目の障害で復旧が極端に困難になります。トランザクションの粒度を切り過ぎてはいけない、というのが現場の経験則。
## 業務処理が長い場合は「受信記録」と「完了記録」を分離する
決済処理や外部API呼び出しを含む業務処理は、1トランザクション内で完結できないことがあります。Stripe Webhookを受け取って、自社の決済ステータスを更新し、Slack通知を送り、Salesforceにレコードを作る、といったケース。
ここでは「受信記録」と「処理完了記録」を分けます。受信した瞬間にevent_idを`recording_started`として記録、業務処理が全て成功した時点で`recording_completed`に更新。再送が来たときは`recording_completed`のみスキップし、`recording_started`のまま残っているものはタイムアウト後に再処理対象とする。
実装上のポイントは、`recording_started`のままで30分以上経過したレコードを再処理キューに戻すバッチを別途走らせること。これを忘れると「処理途中で落ちた決済」が永遠に放置されます。年に数件、こうした「迷子の決済」が顧客から「お金引き落とされたのに商品来ない」という問い合わせで初めて発覚する。これだけは本気で防いだ方がいい。
## 署名検証は冪等性とセットで考える
Stripe WebhookはHMAC-SHA256で署名されています。検証していないと、攻撃者が任意のevent_idでリクエストを打ち込んで、自社の冪等性テーブルを汚染できます。「evt_attacker_001は処理済み」と記録された後で、本物の同じIDのWebhookが来たら何もせずに200を返してしまう。
これは冪等性とセキュリティが交差するポイントで、ユニットテストでも見落とされがちです。署名検証に失敗したリクエストはevent_idを記録せずに400を返す。記録する前に検証する。順序は譲れません。
## タイムアウト設計でハマる典型
StripeのWebhookタイムアウトは30秒、Shopifyは5秒です。Shopifyの5秒は意外と短く、本番でAPI呼び出しを含む処理を同期実行すると簡単に超える。
正解は「受信したらキューに積んで即座に200を返す」。SQS、Cloud Pub/Sub、RedisのList、Sidekiq、なんでも構いません。本処理は別ワーカーで非同期実行する。これでShopify側のリトライは止まりますが、ワーカーの処理失敗時にShopify側で再送できないというトレードオフが発生します。
そのため、キュー投入失敗時のみ5xxを返す設計にする。キュー投入後はワーカー側で独自のリトライポリシーを持つ。デッドレターキューに溜まったものはSlackやPagerDutyに通知する。リトライ最大3回、指数バックオフ、最終的にはDLQで人間が見る。この三層構造が現場で機能する最低ラインです。
## 順序保証は最初から諦める
StripeもShopifyもWebhookの到着順序を保証しません。注文作成と注文キャンセルが順番を逆転して届くことが、年に何回か発生します。Stripeの公式ドキュメントにも「順序は保証しない」と明記されている。
タイムスタンプベースで状態遷移を判定する設計が必要です。受信したWebhookのcreated_atと、データベース上の現在のレコードのupdated_atを比較し、過去のイベントなら無視する。Shopifyはupdated_atをボディに含めて送るので、それと自社レコードのupdated_atを比較すれば判定できる。
「最新のイベントだけが状態を変える」というルールにすれば、順序が崩れても結果整合性は保たれます。状態遷移ではなく状態スナップショットとして扱う発想が要ります。これは設計時に明確に決めておかないと、後から直すのは骨が折れる部分。
## ローカル試験はngrokとstripe triggerで
ngrokでローカル開発機をインターネット公開し、Stripe CLIの`stripe listen`と`stripe trigger checkout.session.completed`で擬似的に再送試験ができます。同じevent_idで2回triggerする仕組みは標準では用意されていませんが、ngrokのリプレイ機能で全く同じリクエストを再送できる。これで冪等性の動作確認が初めて意味を持ちます。
テストコードでも、`POST /webhook/stripe` を同じペイロードで2回連続で叩いて、片方は200・もう片方は200かつ業務処理が走っていないことを assert する、というのを必ず1本書いておく。これがないと冪等性は実装したつもりで動いていない、ということが普通に起きる。
冪等性のないコードを本番に出した瞬間に、年に1〜2回は必ずインシデントが起きます。SaaSの月額10万円のサービスで二重課金が3件発生した場合、返金処理、顧客への謝罪、信用毀損、社内事後分析で、ざっくり30万円分の工数が消える。Webhook初実装の段階で2日かけて冪等性を入れる方が、桁違いに安い投資です。