Webhook解説:署名付きペイロードと信頼性の高いリトライ
決済連携をリリースし、プロバイダーがWebhookを送信し、エンドポイントが200を返した——しかし3時間後、サポートから注文が見つからないと報告が来る。さらに悪いケースでは、同じイベントが4回届いて顧客に二重請求が発生する。Webhookは単純に見えます(URLへのHTTP POST)が、難しいのは信頼と配信保証です。
このガイドでは両方を扱います。署名付きペイロードを使ってペイロードが本当に送信者から来たことを検証する方法と、ダウンタイム、タイムアウト、重複配信を乗り越えるリトライモデルを構築する方法です。
Webhookとは実際何か
Webhookは逆方向のAPI呼び出しです。サーバーが毎分GET /eventsをポーリングする代わりに、何かが起きたときにプロバイダーが登録したURLへHTTPリクエストをプッシュします。ペイロードは通常JSONで、メソッドはほぼ常にPOST、返すレスポンスはシグナルです。2xxは「受信済み」、それ以外は「後で再試行して」を意味します。
この一文が契約のすべてです。署名、リトライ、順序保証など、その他すべては、信頼できないネットワーク上でこの契約を安全にするために存在します。
署名なしWebhookが危険な理由
Webhookエンドポイントは公開URLです。それを見つけた誰もが、注文が支払われたと主張する偽のペイロードをPOSTできます。検証せずにボディを信頼すると、請求ロジックへの開かれた扉を築いたことになります。
IP許可リストは少しは役立ちますが、CDN、サーバーレスの送信元、プロバイダーのIP変更の前では破綻します。堅牢な答えは、共有シークレットと生のリクエストボディに対するHMAC署名です。
署名付きペイロードの仕組み
プロバイダーとあなたは秘密鍵を共有します。各配信時に、プロバイダーは生のボディ(場合によってはタイムスタンプとボディ)に対してHMAC(通常SHA-256)を計算し、その16進ダイジェストをX-Signatureなどのヘッダーで送信します。あなたは秘密鍵のコピーで同じダイジェストを再計算し、比較します。
予想以上に重要な2つの詳細があります:
- 生のバイトに署名する。 フレームワークがJSONをパースして再シリアライズすると、キーの順序や空白が変わり、ダイジェストが一致しなくなります。まず生のボディを読み取ってください。
- 定数時間で比較する。 素朴な
==はタイミング情報を漏らします。言語の暗号ライブラリにある定数時間比較関数を使用してください。
最小限の検証例(Node.js)
const crypto = require('crypto');
function verifySignature(rawBody, headerSig, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody) // raw Buffer or string, not parsed JSON
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(headerSig || '', 'utf8');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
同じパターンは、Pythonではhmac.compare_digest、Goではhmac.Equal、JavaではMessageDigest.isEqualで機能します。原則はどこでも同じです。生のバイトに対するHMACと定数時間比較です。
タイムスタンプによるリプレイ保護
有効な署名は、ペイロードが秘密鍵を持つ誰かから来たことを証明しますが、傍受されたリクエストは永遠にリプレイできます。これを気にするプロバイダーは、署名文字列にタイムスタンプを含めます。例えばt=1712345678,v1=<hex>です。あなたの側では:
- ヘッダーからタイムスタンプをパースします。
- 許容ウィンドウ(多くの場合5分)より古い場合、リクエストを拒否します。
timestamp + "." + rawBodyに対してHMACを再計算します。
これにより、漏洩したリクエストの被害は永遠ではなく数分に限定されます。
実際に配信されるリトライの設計
ネットワークは失敗し、サービスは再起動し、デプロイでポッドが入れ替わります。真面目なプロバイダーは失敗した配信をスケジュールでリトライします。真面目なレシーバーは、すべてのイベントが複数回届く可能性があると想定しなければなりません。
送信側のリトライルール
- 非2xxレスポンス、接続エラー、タイムアウトはすべて失敗として扱います。
- 指数バックオフとジッターでリトライします。例えば1秒、5秒、30秒、5分、30分、2時間など。プロバイダーの障害が復旧時にサンダリングハードを引き起こさないようにします。
- 試行回数に上限を設け(一般に5〜10回)、最終的な失敗を記録してユーザーや運用者が確認できるようにします。
- 短いクライアントタイムアウト(5〜10秒)を設定します。Webhookハンドラーは素早く確認応答し、処理は非同期で行うべきです。
受信側のルール
- まず検証、次にパース。 データベースに触れる前に、不正な署名を401で拒否します。
- イベントを永続化してから200を返す。 生のペイロードとイベントIDをキューまたはテーブルに書き込み、すぐに応答し、ワーカーで処理します。処理が失敗した場合は内部的にリトライします。
- ハンドラーを冪等にする。 プロバイダーのイベントIDを一意キーとして使用します。既に存在する場合は、副作用を再実行せずに200を返します。
- エラーを正直に返す。 データベースがダウンしている場合は500を返してプロバイダーにリトライさせます。200を返してイベントを黙って破棄してはいけません。
| レスポンス | 送信者への意味 | 次に起こること |
|---|---|---|
| 200 / 204 | 配信済み | リトライなし |
| 400 | 不正または拒否 | 通常リトライなし。デバッグ用にログ記録 |
| 401 | 署名が無効 | リトライなし。秘密鍵を調査 |
| 429 | レート制限 | バックオフしてリトライ |
| 5xx / タイムアウト | 一時的な失敗 | バックオフしてリトライ |
実践的な冪等性
冪等性はここではバズワードではありません。リトライと二重請求の間に立つ唯一のものです。パターン:
INSERT INTO webhook_events (event_id, payload, received_at)
VALUES ($1, $2, now())
ON CONFLICT (event_id) DO NOTHING;
挿入が0行に影響した場合、このイベントは既に確認済みです。確認応答して先に進みます。これは同時配信でも機能します。一意制約がデータベースでそれらを直列化するからです。
正確に1回だけ発生する必要がある副作用(メール送信、カード請求)については、独自の冪等キーを下流に渡します。ほとんどの決済APIとメールAPIはそれを受け入れます。
順序、デバッグ、可観測性
イベントが順番に届くと仮定してはいけません。リトライは配信を並べ替える可能性があり、並列ワーカーも同様です。順序が重要な場合は、ペイロードにシーケンス番号またはタイムスタンプを含め、デルタを盲目的に適用するのではなく状態を調整します。
すべての配信試行を、イベントID、署名結果、レスポンスコード、処理時間とともにログに記録します。顧客が「注文が届かなかった」と言ったとき、数秒で答えたいはずです。Webhookは受信されたか、署名は有効だったか、処理は成功したか? イベントごとの構造化ログ行で十分です。
大量のトラフィックを受信するエンドポイントでは、デプロイ後のリトライストームに注意してください。それを見つける簡単な方法は、アクセスログのリクエストパターンを見ることです。1つのIPから同じパスへの5xx応答の急増は、典型的なリトライループです。Nginxログアナライザーのようなツールを使えば、クエリを手書きせずにそのパターンを明らかにできます。
避けるべき一般的な間違い
- 署名を検証する前にJSONをパースする。
- 環境間で同じ秘密鍵を使用する、または一度もローテーションしない。
- リクエストハンドラー内で重い処理(PDF生成、サードパーティ呼び出し)を行う。
- 処理エラー時に200を返し、プロバイダーがリトライしないようにする。
- 重要な場合にプロバイダーのAPIからリソースを再取得せず、ペイロードの金額やステータスを信頼する。
FAQ
ボディがJSONの場合、Webhook署名を検証するにはどうすればよいですか?
JSONをパースする前に生のリクエストボディをバイトとして読み取り、それらのバイトに対してHMACを計算し、定数時間関数を使ってヘッダーと比較します。再シリアライズされたJSONは異なるダイジェストを生成します。
Webhookは何回リトライすべきですか?
普遍的な数はありませんが、指数バックオフとジッターを伴う数時間にわたる5〜10回の試行が一般的です。重要なのは、レシーバーが冪等であることです。そうすれば、リトライが頻繁でも安全です。
Webhookエンドポイントはどのステータスコードを返すべきですか?
イベントを永続的に保存した後でのみ2xxを返します。無効な署名には401、不正なペイロードには400、送信者にリトライしてほしい場合は5xxまたは429を返します。
Webhookペイロードをテストしたり、受信したJSONをログに記録したりする場合、JSONフォーマッターは、パースロジックを構築する前にキャプチャしたイベントを整形して検証する簡単な方法です。