目次
Webhook は「何かが起きたら、別のシステムに通知する」仕組みだ。 polling(定期的な問い合わせ)に比べてサーバー負荷が低く、リアルタイム性が高い。しかし、適切に設計しないと「通知の重複」「受信失敗」「セキュリティ侵害」といった問題に直面する。
本稿では、Webhook の送信側・受信側の実装パターン、セキュリティ対策、エラーハンドリングを網羅的に解説する。
1. Webhook の基本アーキテクチャ
Webhook はイベント駆動アーキテクチャの一種だ。発信元(プロバイダー)と受信先(コンシューマー)の関係で構成される。
[イベント発生] → [プロバイダー] → [HTTP POST] → [コンシューマー]
↓
データベース更新
注文確定
ユーザー作成
polling との比較:
| 項目 | polling | Webhook |
|---|---|---|
| リアルタイム性 | 低い(間隔依存) | 高い(即時通知) |
| サーバー負荷 | 高い(常に問い合わせ) | 低い(イベント時のみ) |
| 実装コスト | 低 | 中 |
| 信頼性 | 高(失敗時に再試行容易) | 中(受信側がダウンすると消失) |
Webhook を選ぶべきケース:
- イベント発生から数秒以内に処理したい
- polling 間隔を短くするとサーバー負荷が高くなる
- 外部サービスの変化を検知したい(GitHub、Stripe、Shopify など)
2. Webhook 送信側(プロバイダー)の実装
2.1 イベント検知とキューイング
イベント発生時に同期で Webhook を送信してはいけない。送信処理が失敗した場合、全体がロールバックする可能性があるためだ。
適切なパターン:
[イベント発生] → [DB 更新] → [キューにジョブ投入] → [即座にレスポンス]
↓
[ワーカーが非同期で Webhook 送信]
Node.js + Bull(Redis キュー)の例:
// イベント発生時の処理
await db.orders.create(orderData);
await webhookQueue.add('order.created', {
orderId: order.id,
userId: order.userId,
payload: orderData
});
return res.status(201).json({ status: 'created' });
// ワーカー処理
webhookQueue.process(async (job) => {
const { userId, payload } = job.data;
const endpoints = await getWebhookEndpoints(userId);
for (const endpoint of endpoints) {
await sendWebhook(endpoint.url, payload, {
secret: endpoint.secret,
event: 'order.created'
});
}
});
2.2 再送制御
Webhook 送信が失敗した場合、再送ロジックが必須だ。
指数バックオフ(Exponential Backoff):
| 試行 | 待機時間 |
|---|---|
| 1 回目 | 即時 |
| 2 回目 | 1 分 |
| 3 回目 | 4 分 |
| 4 回目 | 15 分 |
| 5 回目 | 1 時間 |
最大試行回数(例:5 回)を超えた場合、その Webhook は「配信失敗」としてマークし、管理者に通知する。
2.3 Webhook ペイロードの設計
一貫したペイロード構造を提供する:
{
"id": "evt_123456789",
"type": "order.created",
"created_at": "2025-11-08T10:30:00Z",
"data": {
"order_id": "ord_987654321",
"total": 5000,
"items": [...]
},
"metadata": {
"attempt": 1,
"api_version": "2025-11"
}
}
含めるべきフィールド:
id: イベントの一意識別子(重複検出に使用)type: イベントタイプ(order.created,user.deletedなど)created_at: イベント発生時刻(ISO 8601)data: ペイロード本体metadata: 再送試行回数、API バージョンなど
3. Webhook 受信側(コンシューマー)の実装
3.1 エンドポイントの設計
Webhook 受信エンドポイントは以下の要件を満たす必要がある:
- 幂等性(Idempotency): 同じイベントを複数回受信しても副作用がない
- 高速レスポンス: 200 OK を 2-3 秒以内に返す
- 検証ロジック: 送信元を検証する
app.post('/webhooks/payment', async (req, res) => {
// 1. タイムスタンプ検証(リプレイ攻撃防止)
const timestamp = req.headers['x-webhook-timestamp'];
if (Math.abs(Date.now() - new Date(timestamp).getTime()) > 300000) {
return res.status(400).json({ error: 'timestamp too old' });
}
// 2. 署名検証
const signature = req.headers['x-webhook-signature'];
const isValid = verifySignature(req.body, signature, process.env.WEBHOOK_SECRET);
if (!isValid) {
return res.status(401).json({ error: 'invalid signature' });
}
// 3. 重複チェック(イベント ID で管理)
const eventId = req.body.id;
const exists = await db.webhookEvents.findOne({ eventId });
if (exists) {
return res.status(200).json({ status: 'already processed' });
}
// 4. キューに投入(即座にレスポンス)
await webhookProcessQueue.add(req.body);
// 5. イベント ID を保存(重複防止)
await db.webhookEvents.create({ eventId, receivedAt: new Date() });
return res.status(200).json({ status: 'received' });
});
3.2 幂等性の確保
Webhook は「最低一度」配信される前提で設計する。同じイベントを複数回受信しても問題ないよう、以下の対策を講じる。
イベント ID で管理:
CREATE TABLE webhook_events (
event_id VARCHAR(255) PRIMARY KEY,
event_type VARCHAR(100),
received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMP,
status VARCHAR(20)
);
// トランザクション内で処理
const client = await pool.getClient();
try {
await client.query('BEGIN');
// 重複チェック
const check = await client.query(
'SELECT 1 FROM webhook_events WHERE event_id = $1 FOR UPDATE',
[eventId]
);
if (check.rows.length === 0) {
// 新規イベントとして処理
await processEvent(event);
await client.query(
'INSERT INTO webhook_events (event_id, status) VALUES ($1, $2)',
[eventId, 'processed']
);
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
}
4. Webhook セキュリティ
4.1 署名検証(HMAC)
Webhook の信頼性を確保するため、HMAC(Hash-based Message Authentication Code)署名を使う。
送信側の署名生成:
import crypto from 'crypto';
function generateSignature(payload, secret) {
const hmac = crypto.createHmac('sha256', secret);
hmac.update(JSON.stringify(payload));
return `sha256=${hmac.digest('hex')}`;
}
// ヘッダーに付与
headers['X-Webhook-Signature'] = generateSignature(payload, secret);
受信側の署名検証:
import crypto from 'crypto';
function verifySignature(payload, signature, secret) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(JSON.stringify(payload))
.digest('hex');
const providedSignature = signature.replace('sha256=', '');
// タイミング攻撃防止のため timingSafeEqual を使用
return crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(providedSignature)
);
}
4.2 リプレイ攻撃防止
署名が正しくても、同じペイロードを再送信する「リプレイ攻撃」のリスクがある。対策:
- タイムスタンプ検証: 5 分以上経過した webhook は拒否
- イベント ID の一意性チェック: 既に処理した ID は拒否
- nonce の使用: 使い捨ての乱数値を含める
// タイムスタンプ検証
const timestamp = parseInt(req.headers['x-webhook-timestamp']);
const now = Date.now();
const maxAge = 5 * 60 * 1000; // 5 分
if (Math.abs(now - timestamp) > maxAge) {
return res.status(400).json({ error: 'timestamp too old' });
}
4.3 IP ホワイトリスト
主要な Webhook プロバイダーは送信元 IP アドレスを公開している。可能であれば、IP ホワイトリストで制限する。
例(GitHub):
140.82.112.0/20140.82.113.0/24140.82.114.0/24
nginx の場合:
location /webhooks/github {
allow 140.82.112.0/20;
allow 140.82.113.0/24;
allow 140.82.114.0/24;
deny all;
proxy_pass http://backend;
}
5. エラーハンドリングとモニタリング
5.1 エラーレスポンスの設計
| ステータスコード | 意味 | 再送要否 |
|---|---|---|
| 200-299 | 成功 | 不要 |
| 400-499 | クライアントエラー(署名不正、ペイロード不正) | 不要(修正が必要) |
| 500-599 | サーバーエラー | 必要(一時的障害) |
| タイムアウト | 応答なし | 必要 |
5.2 デッドレターキュー
最大再送回数を超えた Webhook はデッドレターキュー(DLQ)に送る。
webhookQueue.process(async (job, done) => {
try {
await sendWebhook(job.data);
done();
} catch (error) {
if (job.attempts >= job.opts.attempts) {
// 最大試行回数を超えたので DLQ に送る
await deadLetterQueue.add({
...job.data,
error: error.message,
failedAt: new Date()
});
// 管理者に通知
await notifyAdmin({
type: 'webhook_failed',
eventId: job.data.id,
endpoint: job.data.endpoint
});
}
done(error);
}
});
5.3 モニタリング指標
監視すべき指標:
- 配信成功率: 目標 99% 以上
- 平均レイテンシ: 送信から 200 OK までの時間
- 再送率: 全体の何%が再送か(5% 以下が目標)
- DLQ 蓄積数: 0 が理想、増加トレンドならアラート
6. Webhook テスト戦略
6.1 ローカルテスト
ngrok や Cloudflare Tunnel を使って、ローカル環境に Webhook を受信する。
# ngrok でトンネル作成
ngrok http 3000
# 生成された URL を Webhook エンドポイントに登録
# https://xxxx.ngrok.io/webhooks/test
6.2 検証ツールの活用
- Stripe CLI:
stripe listen --forward-to localhost:3000/webhooks - GitHub Webhook Delivery Log: 過去の配信ログを再送信可能
- Postman/Insomnia: 手動で Webhook ペイロードを構築・送信
6.3 自動化テスト
describe('Webhook Handler', () => {
it('should process valid webhook', async () => {
const payload = createTestPayload('order.created');
const signature = generateSignature(payload, SECRET);
const response = await request(app)
.post('/webhooks/order')
.set('X-Webhook-Signature', signature)
.send(payload);
expect(response.status).toBe(200);
expect(response.body.status).toBe('received');
});
it('should reject invalid signature', async () => {
const payload = createTestPayload('order.created');
const response = await request(app)
.post('/webhooks/order')
.set('X-Webhook-Signature', 'invalid')
.send(payload);
expect(response.status).toBe(401);
});
it('should handle duplicate events idempotently', async () => {
const payload = createTestPayload('order.created');
const signature = generateSignature(payload, SECRET);
// 1 回目
await request(app)
.post('/webhooks/order')
.set('X-Webhook-Signature', signature)
.send(payload);
// 2 回目(同じイベント ID)
const response2 = await request(app)
.post('/webhooks/order')
.set('X-Webhook-Signature', signature)
.send(payload);
expect(response2.status).toBe(200);
expect(response2.body.status).toBe('already processed');
});
});
まとめ
- Webhook はイベント駆動アーキテクチャの要。polling より効率的でリアルタイム性が高い
- 送信側: キューイングで非同期化、指数バックオフで再送制御
- 受信側: 幂等性を確保、署名検証、重複検出
- セキュリティ: HMAC 署名、タイムスタンプ検証、IP ホワイトリスト
- エラーハンドリング: ステータスコードで再送要否を分ける、DLQ で失敗を管理
- テスト: ngrok でローカル検証、自動化テストで回帰防止
Webhook の適切な設計は、システム間の信頼性を高める。本稿のパターンを実装に活用してほしい。
免責事項 — 掲載情報は執筆時点のものです。料金・機能は変更される場合があります。最新情報は各公式サイトをご確認ください。