要約
信頼性の高いWebhookを設計するには、指数関数的バックオフによるリトライ(5~10回)、べき等性キー、HMAC署名検証、5秒のタイムアウトを実装しましょう。2xx応答はすぐに返し、処理は非同期で行います。Modern PetstoreAPIは、注文更新、ペットの引き取り、支払い通知のためのWebhookを、完全なリトライとセキュリティ機能とともに実装しています。
はじめに
クライアントにペットが引き取られたことを通知するためにWebhookを送信しました。しかし、クライアントのサーバーがダウンしています。Webhookは失敗します。リトライしますか?何回?もしクライアントがWebhookを2回受け取ってしまい、顧客に2回請求してしまったらどうなりますか?
Webhookは、イベントが発生した際にクライアントのURLにイベントをプッシュするHTTPコールバックです。理論的には単純ですが、実際には複雑です。ネットワークの障害、サーバーのクラッシュ、クライアント側のバグなどが起こり得ます。本番環境のWebhookには、リトライロジック、べき等性、セキュリティ、監視が必要です。
Modern PetstoreAPIは、注文更新、ペットの引き取り、支払い通知のための本番環境対応のWebhookを実装しています。すべてのWebhookには、リトライロジック、署名検証、べき等性が含まれています。
このガイドでは、Modern PetstoreAPIのパターンを使用して信頼性の高いWebhookを設計する方法を学びます。
Webhookの基本
Webhookは、イベントが発生したときにクライアントが提供するURLに送信されるHTTP POSTリクエストです。
Webhookの動作原理
1. クライアントがWebhook URLを登録します:
POST /webhooks
{
"url": "https://client.com/webhooks/petstore",
"events": ["pet.adopted", "order.completed"]
}
2. イベントが発生(ペットが引き取られる)
3. サーバーがWebhookを送信します:
POST https://client.com/webhooks/petstore
Content-Type: application/json
X-Webhook-Signature: sha256=abc123...
{
"event": "pet.adopted",
"data": {
"petId": "019b4132",
"userId": "user-456",
"timestamp": "2026-03-13T10:30:00Z"
}
}
4. クライアントが応答します:
200 OK
信頼性の問題
Webhookは多くの理由で失敗する可能性があります。
- クライアントのサーバーがダウンしている
- ネットワークタイムアウト
- クライアントが500エラーを返す
- クライアントの処理が遅い(30秒かかる)
- クライアントがWebhookを受信したが、処理する前にクラッシュした
リトライロジックがなければイベントは失われ、べき等性がなければ重複したWebhookが重複したアクションを引き起こします。
指数関数的バックオフによるリトライロジック
失敗したWebhookを、遅延を増やしながらリトライします。
指数関数的バックオフ戦略
1回目: 即時
2回目: 1秒後
3回目: 2秒後
4回目: 4秒後
5回目: 8秒後
6回目: 16秒後
なぜ指数関数的なのか? クライアントがダウンしている場合、リトライを連打しても意味がありません。指数関数的バックオフは、復旧のための時間を与えます。
実装
async function sendWebhook(url, payload, attempt = 1, maxAttempts = 6) {
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': generateSignature(payload)
},
body: JSON.stringify(payload),
timeout: 5000 // 5秒のタイムアウト
});
if (response.ok) {
return { success: true, attempt };
}
// 5xxエラーでリトライ
if (response.status >= 500 && attempt < maxAttempts) {
const delay = Math.pow(2, attempt - 1) * 1000;
await sleep(delay);
return sendWebhook(url, payload, attempt + 1, maxAttempts);
}
// 4xxエラーではリトライしない(クライアントエラー)
return { success: false, status: response.status };
} catch (error) {
// ネットワークエラーまたはタイムアウト - リトライ
if (attempt < maxAttempts) {
const delay = Math.pow(2, attempt - 1) * 1000;
await sleep(delay);
return sendWebhook(url, payload, attempt + 1, maxAttempts);
}
return { success: false, error: error.message };
}
}
いつリトライするか
以下の場合にリトライ:
- 5xxサーバーエラー(500, 502, 503, 504)
- ネットワークタイムアウト
- 接続拒否
- DNS障害
以下の場合にはリトライしない:
- 4xxクライアントエラー(400, 401, 404)- クライアント側では解決できない
- 2xx成功 - すでに成功しているため
デッドレターキュー
最大リトライ回数を超えた後、失敗したWebhookは手動レビューのためにデッドレターキューに移動します。
if (!result.success) {
await deadLetterQueue.add({
url,
payload,
attempts: maxAttempts,
lastError: result.error,
timestamp: new Date()
});
}
重複防止のためのべき等性
クライアントは同じWebhookを複数回受け取る可能性があります。べき等性は重複処理を防ぎます。
べき等性キー
各Webhookに一意のIDを含めます。
{
"id": "webhook_019b4132",
"event": "pet.adopted",
"data": {...}
}
クライアントは処理済みIDを保存します:
app.post('/webhooks/petstore', async (req, res) => {
const webhookId = req.body.id;
// すでに処理済みか確認
const processed = await db.webhooks.findOne({ id: webhookId });
if (processed) {
return res.status(200).json({ message: 'Already processed' });
}
// Webhookを処理
await processPetAdoption(req.body.data);
// 処理済みとしてマーク
await db.webhooks.insert({ id: webhookId, processedAt: new Date() });
res.status(200).json({ message: 'Processed' });
});
べき等な操作
べき等な操作を設計します。
悪い例(べき等ではない):
// 2回請求すると二重請求になる
await chargeCustomer(userId, amount);
良い例(べき等である):
// べき等性キーを使用して請求すると、二重請求を防ぐことができる
await chargeCustomer(userId, amount, { idempotencyKey: webhookId });
セキュリティのための署名検証
Webhookが攻撃者からではなく、あなたのAPIから送信されたものであることを検証します。
HMAC署名
共有シークレットを使用して署名を生成します。
// サーバーが署名を生成
const crypto = require('crypto');
function generateSignature(payload, secret) {
const hmac = crypto.createHmac('sha256', secret);
hmac.update(JSON.stringify(payload));
return hmac.digest('hex');
}
// ヘッダーに含める
headers['X-Webhook-Signature'] = `sha256=${generateSignature(payload, webhookSecret)}`;
クライアントが署名を検証します:
function verifySignature(payload, signature, secret) {
const expected = generateSignature(payload, secret);
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(`sha256=${expected}`)
);
}
app.post('/webhooks/petstore', (req, res) => {
const signature = req.headers['x-webhook-signature'];
if (!verifySignature(req.body, signature, WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Webhookを処理
...
});
タイムスタンプ検証
リプレイ攻撃を防ぐためにタイムスタンプを含めます。
{
"id": "webhook_019b4132",
"timestamp": "2026-03-13T10:30:00Z",
"event": "pet.adopted",
"data": {...}
}
古いWebhookを拒否します:
const webhookAge = Date.now() - new Date(req.body.timestamp);
if (webhookAge > 5 * 60 * 1000) { // 5分
return res.status(400).json({ error: 'Webhook too old' });
}
タイムアウト処理
遅いクライアントがシステムをブロックするのを防ぐために、積極的なタイムアウトを設定します。
5秒のタイムアウト
const response = await fetch(url, {
method: 'POST',
body: JSON.stringify(payload),
timeout: 5000 // 5秒
});
なぜ5秒なのか? Webhookはすぐに応答を返す必要があります。クライアントがそれ以上かかる場合、同期処理を行っていることになります(誤ったパターンです)。
非同期処理パターン
悪い例(同期処理):
app.post('/webhooks/petstore', async (req, res) => {
// これには30秒かかり、Webhookはタイムアウトします
await processOrder(req.body.data);
await sendEmail(req.body.data);
await updateInventory(req.body.data);
res.status(200).json({ message: 'Processed' });
});
良い例(非同期処理):
app.post('/webhooks/petstore', async (req, res) => {
// すぐに返す
res.status(200).json({ message: 'Received' });
// 非同期で処理
queue.add('process-webhook', req.body);
});
Modern PetstoreAPIでのWebhookの実装方法
Modern PetstoreAPIは、本番環境対応のWebhookを実装しています。
Webhookイベント
pet.adopted - ペットが引き取られた
pet.status_changed - ペットのステータスが変更された
order.created - 注文が作成された
order.completed - 注文が完了した
payment.succeeded - 支払いが成功した
payment.failed - 支払いが失敗した
Webhookペイロード
{
"id": "webhook_019b4132-70aa-764f-b315-e2803d882a24",
"event": "pet.adopted",
"timestamp": "2026-03-13T10:30:00Z",
"data": {
"petId": "019b4132-70aa-764f-b315-e2803d882a24",
"userId": "user-456",
"orderId": "order-789",
"adoptionDate": "2026-03-13"
},
"apiVersion": "v1"
}
リトライ設定
- 最大試行回数: 10回
- バックオフ: 指数関数的(1秒, 2秒, 4秒, 8秒, 16秒, 32秒, 64秒, 128秒, 256秒, 512秒)
- 合計リトライ期間: 約17分
- 最大リトライ後のデッドレターキュー
セキュリティ
X-Webhook-Signatureヘッダー内のHMAC-SHA256署名- タイムスタンプ検証(5分以上経過したものを拒否)
- Webhook URLにはHTTPSが必須
ApidogでWebhookをテストする
ApidogはWebhookテストをサポートしています。
Webhook配信のテスト
- ApidogでモックWebhookエンドポイントを作成
- PetstoreAPIにエンドポイントを登録
- イベントをトリガー(ペットの引き取り)
- Webhookが受信されたことを確認
- ペイロードの形式を確認
署名検証のテスト
// Apidogテストスクリプト
const signature = pm.request.headers.get('X-Webhook-Signature');
const payload = pm.request.body.raw;
const secret = pm.environment.get('WEBHOOK_SECRET');
const expected = generateSignature(payload, secret);
pm.test('署名が有効', () => {
pm.expect(signature).to.equal(`sha256=${expected}`);
});
リトライロジックのテスト
- モックエンドポイントから500エラーを返す
- 指数関数的バックオフによるリトライ試行を確認
- 最大リトライ後のデッドレターキューを確認
べき等性のテスト
- Webhookを受信
- 200を返す
- 同じWebhookを再度受信(リトライをシミュレート)
- 重複処理がないことを確認
結論
信頼性の高いWebhookには以下が必要です:
- 指数関数的バックオフによるリトライ(5~10回)
- 重複を防ぐためのべき等性キー
- セキュリティのためのHMAC署名検証
- 5秒のタイムアウト
- クライアント側での非同期処理
- 失敗したWebhookのためのデッドレターキュー
Modern PetstoreAPIはこれらのすべてのパターンを実装しています。完全な例についてはWebhookドキュメントを参照してください。
本番環境に移行する前に、ApidogでWebhookをテストし、リトライロジック、署名、べき等性を検証してください。
よくある質問
Webhookは何回リトライすべきですか?
指数関数的バックオフで5〜10回試行します。これにより、クライアントに過負荷をかけることなく、一時的な障害(5〜17分)に対応できます。
Webhookは4xxエラーでリトライすべきですか?
いいえ。4xxエラーはクライアント側の問題(URLの間違い、認証失敗など)を示します。リトライしてもこれらは解決しません。5xxエラーとネットワーク障害の場合のみリトライしてください。
Webhookのタイムアウトはどれくらいにすべきですか?
最大5秒です。クライアントはすぐに200を返し、非同期で処理すべきです。タイムアウトが長い場合は、クライアントが同期処理を行っていることを示します。
クライアントがWebhookにまったく応答しない場合はどうすればよいですか?
最大リトライ後、デッドレターキューに移動します。クライアントにメールで通知し、度重なる失敗の後にはそのクライアントへのWebhookを無効にすることを検討してください。
Webhook URLはHTTPSであるべきですか?
はい、常にHTTPSを必須にしてください。HTTPのWebhookは傍受・改ざんされる可能性があります。Modern PetstoreAPIはHTTPのWebhook URLを拒否します。
リプレイ攻撃をどのように防ぎますか?
ペイロードにタイムスタンプを含め、5分以上経過したWebhookは拒否します。署名検証と組み合わせます。
クライアントはWebhookの再配信を要求できますか?
はい。Modern PetstoreAPIは、特定のWebhookを再配信するためのエンドポイントを提供しています: POST /webhooks/{id}/redeliver
ローカルでWebhookをテストするにはどうすればよいですか?
ngrokのようなツールを使用してlocalhostをインターネットに公開するか、開発中にApidogのモックサーバーを使用してWebhookエンドポイントをシミュレートします。
