Webhook解説:署名付きペイロードとリトライ
決済ゲートウェイを統合したばかりだとします。ドキュメントには「支払いが成功したら、エンドポイントにWebhookを送信します」と書かれています。シンプルなルートを設定し、ローカルでテストしてデプロイします。すると本番環境で、重複イベント、通知の欠落、謎の500エラーに気づきます。Webhookは単純に見えますが、そうではありません。
このガイドでは、Webhookが実際にどのように機能するか、署名付きペイロードがなぜ重要か、そして問題が発生してもシステムを同期させ続けるリトライロジックの構築方法を説明します。
Webhookとは何か?
WebhookはHTTPコールバックです。特定のイベントが発生したときに、あるシステムがあなたが提供したURLにHTTP POSTリクエストを送信します。「何か起こりましたか?」と繰り返し尋ねるポーリングとは異なり、Webhookはほぼリアルタイムでイベントをプッシュします。
一般的な用途は次のとおりです:
- 決済プロセッサが成功した請求を通知する。
- Gitホスティングサービスがプッシュ時にCI/CDパイプラインをトリガーする。
- コミュニケーションプラットフォームが新着メッセージを通知する。
送信者(例:Stripe、GitHub)はWebhookプロバイダーであり、あなたのサーバーはWebhookコンシューマーです。
署名付きペイロードが不可欠な理由
Webhookエンドポイントは公開されています。URLを知っている人は誰でも偽のイベントを送信できます。検証がなければ、攻撃者は「支払い成功」イベントを偽装し、あなたが無料で商品を発送する可能性があります。
署名付きペイロードは、あなたとプロバイダーだけが知る共有シークレットで生成された暗号署名をリクエストヘッダーに含めることでこれを解決します。Webhookを受信したら、署名を再計算して比較します。一致すれば、ペイロードは本物です。
HMAC署名の仕組み
ほとんどのプロバイダーはHMAC-SHA256を使用します。プロバイダーは次を計算します:
signature = HMAC-SHA256(secret, payload)そしてそれをX-Signatureのようなヘッダーで送信します。あなたのサーバーも同じ計算を行い、等しいか確認します。シークレットは秘密であるため、有効な署名を生成できるのはプロバイダーだけです。
実際に署名を検証する
Expressを使った最小限のNode.jsの例です:
const crypto = require('crypto');
const express = require('express');
const app = express();
app.post('/webhook', express.raw({type: 'application/json'}), (req, res) => {
const signature = req.headers['x-signature'];
const secret = process.env.WEBHOOK_SECRET;
const payload = req.body; // raw Buffer
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
if (signature !== expected) {
return res.status(401).send('Invalid signature');
}
// Process the event
const event = JSON.parse(payload);
console.log('Received event:', event.type);
res.status(200).send('OK');
});
app.listen(3000);重要:署名検証には常に生のリクエストボディを使用してください。先にJSONをパースすると、シリアライズされた文字列が異なる可能性があり(空白、キーの順序)、署名チェックが失敗します。
よくある落とし穴
- 間違ったシークレットの使用:プロバイダーによってはテストモードとライブモードで別々のシークレットがあります。
- タイミング攻撃:情報漏洩を避けるため、定数時間比較関数(例:
crypto.timingSafeEqual)を使用してください。 - タイムスタンプヘッダーの無視:多くのプロバイダーはリプレイ攻撃を防ぐためにタイムスタンプを含めます。数分以上古いリクエストは拒否してください。
リトライ:障害を適切に処理する
ネットワークは失敗し、サーバーは再起動し、デプロイは発生します。Webhookプロバイダーは、あなたのエンドポイントが一時的に利用できない可能性を想定して、失敗した配信をリトライします。しかしリトライは新たな課題をもたらします:重複イベント、順序外の配信、注意しないと無限ループです。
プロバイダーのリトライ方法
ほとんどのプロバイダーは指数バックオフを使用します。最初の失敗後は短時間待ち、その後徐々に間隔を長くします。たとえば、Stripeは最大3日間、遅延を増やしながらリトライします。GitHubは最大8回リトライします。正確なスケジュールは異なりますが、パターンは一貫しています。
エンドポイントは受信を確認するために迅速に2xxステータスコードを返すべきです。それ以外のステータス(4xx、5xx)は失敗を示し、リトライをトリガーします。
冪等なハンドラーの構築
リトライは同じイベントを複数回配信する可能性があるため、ハンドラーは冪等でなければなりません。同じイベントを2回処理しても、1回処理した場合と同じ効果になる必要があります。
一般的な戦略:
- イベントIDの追跡:ペイロードまたはヘッダーで提供される一意のイベントIDをデータベースに保存します。処理前に、すでに処理済みか確認します。
- アップサート:自然に冪等なデータベース操作(例:
INSERT ... ON CONFLICT DO NOTHING)を使用します。 - 状態機械:ビジネスロジックを繰り返しの遷移に耐えられるように設計します(例:「注文を支払い済みにする」はまだ支払い済みでない場合のみ)。
シンプルなインメモリセットを使った例(説明用。本番では永続ストアを使用してください):
const processedEvents = new Set();
app.post('/webhook', (req, res) => {
const event = req.body;
if (processedEvents.has(event.id)) {
return res.status(200).send('Already processed');
}
// Process event...
processedEvents.add(event.id);
res.status(200).send('OK');
});迅速に応答する
プロバイダーはしばしばタイムアウト(例:5秒)を設定しています。ハンドラーが重い処理を行う場合は、すぐに確認応答し、非同期で処理します。たとえば、イベントをキューにプッシュして200を返します。
信頼性の高いWebhookのベストプラクティス
- 常に署名を検証する:生のボディと定数時間比較を使用します。
- HTTPSを使用する:盗聴や改ざんを防ぐため、エンドポイントにHTTPSを使用します。
- 冪等性を実装する:重複配信を処理します。
- すべてをログに記録する:受信ペイロード、署名、応答をデバッグ用に記録します。
- 失敗を監視する:繰り返しエラーに対するアラートを設定します。
- 実際のイベントでテストする:プロバイダーのダッシュボードやCLIツールを使用します。
- 順序外のイベントを処理する:ロジックにタイムスタンプやシーケンス番号を含めます。
比較:ポーリング vs. Webhook
| 側面 | ポーリング | Webhook |
|---|---|---|
| レイテンシ | 間隔に依存 | ほぼリアルタイム |
| サーバー負荷 | 高い(定常的なリクエスト) | 低い(イベント時のみ) |
| 複雑さ | 実装が簡単 | エンドポイント、セキュリティ、リトライが必要 |
| 信頼性 | リトライを自分で制御 | プロバイダーがリトライを制御 |
FAQ
Webhook署名とは何ですか?
Webhook署名は、共有シークレットで生成されたペイロードの暗号ハッシュ(通常はHMAC-SHA256)です。受信者は、リクエストが期待される送信者から来て改ざんされていないことを検証できます。
重複するWebhookイベントをどのように処理しますか?
イベントIDを追跡するか、自然に冪等なデータベース操作を使用して、ハンドラーを冪等にします。変更を適用する前に、イベントがすでに処理されているか確認します。
Webhookエンドポイントは何を返すべきですか?
イベントを受信して検証したら、すぐに2xxステータスコード(例:200 OK)を返します。重い処理が必要な場合は、すぐに確認応答し、非同期で作業を処理します。
Webhookペイロードをテストする準備はできましたか?JSON Formatterを使用して、受信イベントのJSON構造を検査および検証してください。