WebHook設計のベストプラクティス|署名・再試行・べき等性で信頼性を担保する
WebHook設計の三本柱は署名で本物を確認、2xxを速く返して非同期処理、イベントIDでべき等性を担保する。送信側は指数バックオフ+ジッターで再試行し、上限超過分はDLQに隔離する。この骨格があれば、Stripe連携も自社マイクロサービス間連携も同じ原則で信頼性を確保できる。
WebHookとは何か:イベント駆動連携の基本
WebHook(Webhookとも表記)は、あるシステムでイベントが発生したとき、事前に登録されたHTTPエンドポイントへPOSTで通知する仕組みです。クライアントが定期的にAPIを叩くポーリングと違い、サーバー側からプッシュされるため、リアルタイム性が高く、API呼び出し回数も抑えられます。
典型的な流れは次のとおりです。
- 受信側がWebhook URLを登録する(例:
https://api.example.com/webhooks/stripe) - 送信側(Stripe、GitHub、Shopify、自社サービスなど)がイベント発生時にPOSTする
- 受信側が署名検証・べき等チェックを行い、HTTPステータスで結果を返す
- 送信側は2xx以外の場合、指数バックオフで再試行する
WebHookは「届いたかどうか」をHTTPレスポンスだけで完結させられません。ネットワーク断、デプロイ中の503、DB接続プール枯渇——一時的な失敗は日常です。設計段階から「同じイベントが複数回届く」「相手が一時的に落ちている」を前提に組み立てる必要があります。
| 方式 | 特徴 |
|---|---|
| ポーリング | 実装は単純だが、リアルタイム性が低く、無駄なAPI呼び出しが増える |
| WebHook | イベント発生直後に通知。送信側の再試行設計が必須 |
| WebSocket / SSE | 双方向・長寿命接続向き。ファイアウォール越えや再接続設計が別課題 |
| メッセージキュー(SQS等) | 同一組織内の非同期連携に最適。外部SaaS連携にはWebHookが定番 |
HMAC署名でリクエストの真正性を検証する
WebHookエンドポイントはインターネットに公開されます。URLが漏洩したり推測されたりすると、攻撃者が偽の決済完了イベントを送り込めます。共有シークレットとHMACで「本当に送信元から来たペイロードか」を検証するのが業界標準です。
検証の流れは次のとおりです。
- 送信側がリクエストボディとシークレットでHMAC-SHA256を計算し、ヘッダーに載せる
- 受信側が同じシークレットで同じ計算を行い、結果を比較する
- 一致すれば真正、不一致なら401で拒否する
比較には crypto.timingSafeEqual を使います。通常の === 比較は、不一致位置によって応答時間が微妙に変わり、タイミング攻撃で署名を推測される余地があります。timingSafeEqual は常に一定時間で比較を完了します。
JSONをパースする前に署名を検証してください。JSON.stringify() で再シリアライズすると空白やキー順序の差で署名が一致しなくなります。Express では express.raw({ type: 'application/json' }) で生ボディを保持します。また、タイムスタンプが5分以上古いリクエストは拒否し、リプレイ攻撃を防ぎます。
Stripe形式の署名ヘッダーとNode.js実装
Stripeは Stripe-Signature ヘッダーに t=<timestamp>,v1=<signature> 形式でHMACを載せます。タイムスタンプとボディを連結してから署名する方式は、多くのSaaSが参考にしているデファクトスタンダードです。
POST /webhooks/stripe HTTP/1.1
Host: api.example.com
Content-Type: application/json
Stripe-Signature: t=1718870400,v1=3b4f8c9e2a1d7f6e5c4b3a2918070605040302010f0e0d0c0b0a0908070605040302010
{"id":"evt_1abc","type":"payment_intent.succeeded","data":{...}}
受信側の完全な検証コードは次のとおりです。
// webhook-verify.js
const crypto = require('crypto');
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; // whsec_xxx 形式
const TOLERANCE_SECONDS = 300; // 5分
/**
* Stripe形式の署名を検証する
* @param {Buffer} rawBody - 生のリクエストボディ
* @param {string} signatureHeader - Stripe-Signature ヘッダー値
* @returns {boolean}
*/
function verifyStripeSignature(rawBody, signatureHeader) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((part) => {
const [key, value] = part.split('=');
return [key, value];
})
);
const timestamp = parseInt(parts.t, 10);
if (Number.isNaN(timestamp)) {
return false;
}
// リプレイ攻撃対策: タイムスタンプの許容幅
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) {
return false;
}
// Stripeは "timestamp.rawBody" を署名対象にする
const signedPayload = `${timestamp}.${rawBody.toString('utf8')}`;
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload, 'utf8')
.digest('hex');
const receivedSignature = parts.v1;
if (!receivedSignature || receivedSignature.length !== expectedSignature.length) {
return false;
}
// タイミング攻撃対策: 定数時間比較
return crypto.timingSafeEqual(
Buffer.from(receivedSignature, 'hex'),
Buffer.from(expectedSignature, 'hex')
);
}
module.exports = { verifyStripeSignature };
Expressでのエンドポイント統合例です。
// routes/webhook.js
const express = require('express');
const { verifyStripeSignature } = require('../webhook-verify');
const { checkIdempotency, enqueueWebhookEvent } = require('../webhook-service');
const router = express.Router();
// 重要: JSONパーサーの前に raw ミドルウェアを適用
router.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.headers['stripe-signature'];
if (!signature) {
return res.status(401).json({ error: 'missing_signature' });
}
if (!verifyStripeSignature(req.body, signature)) {
return res.status(401).json({ error: 'invalid_signature' });
}
let event;
try {
event = JSON.parse(req.body.toString('utf8'));
} catch {
return res.status(400).json({ error: 'invalid_json' }); // 4xx → 再試行しない
}
// べき等チェック(後述)
const idempotencyResult = await checkIdempotency(event.id);
if (idempotencyResult.alreadyProcessed) {
return res.status(200).json(idempotencyResult.cachedResponse);
}
// SQSにエンキューして即座に202を返す
await enqueueWebhookEvent(event);
const response = { received: true, event_id: event.id };
await idempotencyResult.markPending(response);
return res.status(202).json(response);
}
);
module.exports = router;
レスポンス設計:速く2xxを返し、重い処理は後回しにする
WebHook送信側は通常 5〜30秒でタイムアウト します。DB更新、外部API呼び出し、メール送信、PDF生成——これらを同期的に実行すると、処理が長引いた瞬間にタイムアウト → 再送 → 二重処理、という悪循環に陥ります。
受信側の鉄則は次の2点です。
- 署名検証とべき等チェックまでを同期で終わらせ、すぐ
200または202を返す - ビジネスロジックはキュー(SQS、Redis Streams、DBジョブテーブルなど)に積んで非同期実行する
| ステータス | 意味 | 送信側の動作 |
|---|---|---|
200 OK | 処理完了(または重複イベントの再送) | 再試行停止 |
202 Accepted | 受付完了、非同期処理中 | 再試行停止 |
401 Unauthorized | 署名不正 | 再試行停止(設定ミス) |
400 Bad Request | ペイロード形式エラー | 再試行停止 |
409 Conflict | べき等キー競合(処理中) | 送信側ポリシーによる |
500/503 | 一時障害 | 再試行する |
恒久的なエラー(存在しないユーザーID、必須フィールド欠落)は 4xx で返し、何度送っても直らないイベントの再試行を止めさせます。これは送信側にとっても、無駄なリトライで自社インフラを圧迫しないための親切な設計です。
SQSキューパターン:受信と処理の分離
Amazon SQSを使った非同期処理パターンは、WebHook受信の定番アーキテクチャです。API GatewayやALBの背後で署名検証まで行い、検証済みイベントをSQSに投入、別ワーカーがポーリングして処理します。
// webhook-service.js
const { SQSClient, SendMessageCommand } = require('@aws-sdk/client-sqs');
const sqs = new SQSClient({ region: 'ap-northeast-1' });
const QUEUE_URL = process.env.WEBHOOK_QUEUE_URL;
async function enqueueWebhookEvent(event) {
const command = new SendMessageCommand({
QueueUrl: QUEUE_URL,
MessageBody: JSON.stringify({
event_id: event.id,
event_type: event.type,
payload: event,
received_at: new Date().toISOString(),
}),
// FIFOキューの場合: 同一イベントの順序保証
MessageGroupId: event.type,
MessageDeduplicationId: event.id,
});
await sqs.send(command);
}
module.exports = { enqueueWebhookEvent };
ワーカー側の処理ループです。
// webhook-worker.js
const { SQSClient, ReceiveMessageCommand, DeleteMessageCommand } = require('@aws-sdk/client-sqs');
const { processPaymentEvent } = require('./handlers/payment');
const sqs = new SQSClient({ region: 'ap-northeast-1' });
const QUEUE_URL = process.env.WEBHOOK_QUEUE_URL;
async function pollAndProcess() {
const { Messages } = await sqs.send(new ReceiveMessageCommand({
QueueUrl: QUEUE_URL,
MaxNumberOfMessages: 10,
WaitTimeSeconds: 20, // ロングポーリング
VisibilityTimeout: 60, // 処理中は他ワーカーから見えない
}));
if (!Messages) return;
for (const message of Messages) {
try {
const { event_id, event_type, payload } = JSON.parse(message.Body);
// べき等: 処理開始前にもう一度チェック
const status = await getIdempotencyStatus(event_id);
if (status === 'processed') {
await deleteMessage(message);
continue;
}
await markIdempotencyProcessing(event_id);
switch (event_type) {
case 'payment_intent.succeeded':
await processPaymentEvent(payload);
break;
default:
console.warn(`Unknown event type: ${event_type}`);
}
await markIdempotencyProcessed(event_id);
await deleteMessage(message);
} catch (err) {
console.error('Processing failed:', err);
// メッセージを削除しない → VisibilityTimeout後に再配信
// 受信回数が maxReceiveCount を超えると DLQ へ
}
}
}
async function deleteMessage(message) {
await sqs.send(new DeleteMessageCommand({
QueueUrl: QUEUE_URL,
ReceiptHandle: message.ReceiptHandle,
}));
}
setInterval(pollAndProcess, 1000);
SQSの VisibilityTimeout は処理時間より長く設定します。処理中にメッセージが再び見えると二重処理の原因になります。maxReceiveCount を3〜5に設定し、超過分はDLQに流すのが定石です。
べき等性テーブル:SQLスキーマと実装パターン
再試行により同じイベントが複数回届くのは正常動作です。べき等性がなければ、決済の二重引き落とし、在庫の二重減算、通知の二重送信が起きます。
PostgreSQLでのべき等テーブル定義例です。
-- べき等性ストア: イベントID単位で処理状態を記録
CREATE TABLE webhook_idempotency (
id BIGSERIAL PRIMARY KEY,
event_id VARCHAR(255) NOT NULL,
source VARCHAR(64) NOT NULL DEFAULT 'stripe', -- 送信元識別
status VARCHAR(16) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'processing', 'processed', 'failed')),
response_body JSONB, -- 再送時に同じレスポンスを返す
response_status SMALLINT, -- 再送時に同じHTTPステータスを返す
payload_hash VARCHAR(64), -- SHA-256(hex) でペイロード改変検知
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '30 days',
CONSTRAINT uq_webhook_event UNIQUE (source, event_id)
);
CREATE INDEX idx_webhook_idempotency_expires
ON webhook_idempotency (expires_at)
WHERE status = 'processed';
Node.jsからの操作例です。
// idempotency.js
const crypto = require('crypto');
const db = require('./db');
async function checkIdempotency(eventId, source = 'stripe') {
const row = await db.query(
`SELECT status, response_body, response_status
FROM webhook_idempotency
WHERE source = $1 AND event_id = $2`,
[source, eventId]
);
if (row.rows.length === 0) {
return {
alreadyProcessed: false,
markPending: async (response, statusCode = 202) => {
await db.query(
`INSERT INTO webhook_idempotency (event_id, source, status, response_body, response_status)
VALUES ($1, $2, 'pending', $3, $4)
ON CONFLICT (source, event_id) DO NOTHING`,
[eventId, source, JSON.stringify(response), statusCode]
);
},
};
}
const { status, response_body, response_status } = row.rows[0];
if (status === 'processed') {
return {
alreadyProcessed: true,
cachedResponse: response_body,
cachedStatus: response_status,
};
}
// pending / processing 中 → 409 または最初のレスポンスを返す
return {
alreadyProcessed: true,
cachedResponse: response_body ?? { status: 'processing' },
cachedStatus: response_status ?? 409,
};
}
function hashPayload(rawBody) {
return crypto.createHash('sha256').update(rawBody).digest('hex');
}
module.exports = { checkIdempotency, hashPayload };
同じイベントIDで再送が来たら、最初と同じHTTPレスポンスを返すのが理想です。送信側は2xxを受け取って再試行を止めるため、毎回異なるレスポンスを返すと無限ループの温床になります。
指数バックオフ:送信側の再試行実装
WebHookの送信側(自社がイベントを通知する側)も、受信側が503やタイムアウトを返したときに再試行する必要があります。固定間隔のリトライは、障害直後に全クライアントが同時再送し、Thundering Herd(再試行ストーム) で相手をさらに落とす原因になります。
指数バックオフは試行ごとに待機時間を倍増させる方式です。1秒 → 2秒 → 4秒 → 8秒…と広げ、負荷を分散します。フルジッター(0〜計算値の間でランダム)を加えると、同時再送のピークをさらに平らにできます。
// retry.js — WebHook送信側の再試行ロジック
const crypto = require('crypto');
const RETRY_CONFIG = {
maxAttempts: 8,
initialDelayMs: 1000,
multiplier: 2,
maxDelayMs: 3_600_000, // 1時間上限
retriableStatus: new Set([408, 429, 500, 502, 503, 504]),
nonRetriableStatus: new Set([400, 401, 403, 404, 409, 422]),
};
/**
* 指数バックオフ + フルジッターで待機時間を計算
* @param {number} attempt - 0始まりの試行回数
* @returns {number} 待機ミリ秒
*/
function calculateBackoff(attempt) {
const exponential = RETRY_CONFIG.initialDelayMs *
Math.pow(RETRY_CONFIG.multiplier, attempt);
const capped = Math.min(exponential, RETRY_CONFIG.maxDelayMs);
// Full jitter: 0 〜 capped の間でランダム
return Math.floor(crypto.randomInt(0, capped + 1));
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* WebHookを送信し、失敗時は指数バックオフで再試行
* @param {string} url - 送信先URL
* @param {object} payload - イベントペイロード
* @param {string} secret - HMACシークレット
*/
async function deliverWebhook(url, payload, secret) {
const rawBody = JSON.stringify(payload);
const timestamp = Math.floor(Date.now() / 1000);
const signature = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
for (let attempt = 0; attempt < RETRY_CONFIG.maxAttempts; attempt++) {
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Timestamp': String(timestamp),
'X-Webhook-Signature': `sha256=${signature}`,
'X-Webhook-Event-Id': payload.id,
},
body: rawBody,
signal: AbortSignal.timeout(10_000), // 10秒タイムアウト
});
if (response.ok) {
return { success: true, status: response.status, attempt };
}
if (RETRY_CONFIG.nonRetriableStatus.has(response.status)) {
return {
success: false,
status: response.status,
reason: 'non_retriable',
attempt,
};
}
if (!RETRY_CONFIG.retriableStatus.has(response.status)) {
console.warn(`Unexpected status ${response.status}, treating as retriable`);
}
} catch (err) {
if (attempt === RETRY_CONFIG.maxAttempts - 1) {
return { success: false, reason: 'network_error', error: err.message, attempt };
}
}
const delay = calculateBackoff(attempt);
console.log(`Retry attempt ${attempt + 1}/${RETRY_CONFIG.maxAttempts} after ${delay}ms`);
await sleep(delay);
}
return { success: false, reason: 'max_retries_exceeded' };
}
module.exports = { deliverWebhook, calculateBackoff };
| 再試行方式 | 特徴 |
|---|---|
| 固定間隔 | 実装は簡単だが、障害時に負荷スパイクを起こしやすい |
| 指数バックオフ | 待機が伸びるほど相手の復旧時間を与えられる。AWS・Google推奨 |
| フルジッター | 0〜上限のランダム待機。多数テナントの同時再送を分散 |
| 等差 + ジッター | Stripe等が採用。上限到達後も一定間隔で再試行を継続 |
Dead Letter Queue(DLQ):失敗イベントの隔離と運用
再試行上限を超えたイベントは、サイレントに捨てるのではなく Dead Letter Queue(DLQ) に隔離します。DLQは「届かなかったイベントの墓場」ではなく、手動調査・再送・根本原因分析の入口です。
受信側(SQS)のDLQ設定
// CloudFormation / CDK のイメージ
const webhookQueue = new sqs.Queue(this, 'WebhookQueue', {
visibilityTimeout: Duration.seconds(60),
deadLetterQueue: {
queue: dlq,
maxReceiveCount: 5, // 5回失敗でDLQへ
},
});
const dlq = new sqs.Queue(this, 'WebhookDLQ', {
retentionPeriod: Duration.days(14), // 14日保持
});
DLQ監視用のLambda例です。
// dlq-handler.js
const { SQSClient, ReceiveMessageCommand } = require('@aws-sdk/client-sqs');
const { WebClient } = require('@slack/web-api');
const slack = new WebClient(process.env.SLACK_TOKEN);
const DLQ_URL = process.env.WEBHOOK_DLQ_URL;
async function alertOnDLQMessages() {
const { Messages } = await sqs.send(new ReceiveMessageCommand({
QueueUrl: DLQ_URL,
MaxNumberOfMessages: 10,
}));
if (!Messages || Messages.length === 0) return;
for (const msg of Messages) {
const body = JSON.parse(msg.Body);
await slack.chat.postMessage({
channel: '#webhook-alerts',
text: `:rotating_light: WebHook DLQにイベントが到着\n` +
`Event ID: ${body.event_id}\n` +
`Type: ${body.event_type}\n` +
`Received: ${body.received_at}`,
});
}
}
送信側のDLQ(DBテーブル)
自社がWebHookを送信する側も、上限超過イベントをDBに記録します。
CREATE TABLE webhook_delivery_failures (
id BIGSERIAL PRIMARY KEY,
event_id VARCHAR(255) NOT NULL,
target_url TEXT NOT NULL,
payload JSONB NOT NULL,
last_status SMALLINT,
attempts SMALLINT NOT NULL,
last_error TEXT,
failed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMPTZ,
resolved_by VARCHAR(64)
);
CREATE INDEX idx_delivery_failures_unresolved
ON webhook_delivery_failures (failed_at)
WHERE resolved_at IS NULL;
運用フローは次のとおりです。
- DLQ / 失敗テーブルにイベントが蓄積 → Slackアラート
- オンコール担当がペイロードとエラー原因を確認
- 一時障害なら 手動再送、恒久エラーなら送信先URL修正やペイロード修正
- 解決後
resolved_atを更新し、同イベントの再アラートを防ぐ
DLQは「見えないうちに溜まる」ものです。週次で未解決件数をダッシュボード化し、1件でも決済系イベントが入っていたら即調査するルールをチームに共有してください。
監視・ログ・セキュリティの実務ポイント
本番でWebHookを運用するとき、コード以上に重要なのが可観測性です。
| メトリクス | 用途 |
|---|---|
| 署名検証失敗率 | シークレット不一致・なりすまし試行の検知 |
| 2xx応答レイテンシ(p99) | タイムアウト再試行の予兆。100ms以下が目安 |
| SQSキュー深度 | ワーカー不足・処理遅延の検知 |
| DLQ到着件数 | 即アラート対象 |
べき等テーブルの pending 滞留 | 処理途中で落ちたイベントの検知 |
ログには イベントID を必ず含め、ペイロード全体(PII含む)はマスクまたはサンプリングします。シークレット・署名値そのものは絶対にログ出力しないでください。
セキュリティ面では、Webhook URLに推測困難なパス(/webhooks/stripe/a8f3k2m9)を使い、IP許可リストは補助的程度に留めます。SaaS側のIPは変わるため、署名検証が主防御線です。
本番導入前のチェックリスト
共有シークレットを環境変数・Secrets Managerで管理し、ログに出力しない
express.raw()で生ボディを保持し、timingSafeEqualでHMAC署名を検証する
タイムスタンプの許容幅(300秒程度)を設定し、リプレイ攻撃を防ぐ
署名検証後すぐ202を返し、重い処理はSQS等のキューに逃がす
webhook_idempotencyテーブルでイベントIDの二重処理を防ぎ、再送時は同じレスポンスを返す
送信側に指数バックオフ+フルジッター付き再試行を実装する
SQS maxReceiveCountとDLQを設定し、Slackアラートを配線する
ステージングで意図的に503を返し、再送・べき等・DLQの動作を検証する
関連記事
この記事は API・バックエンド テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。
| トピック | 記事 |
|---|---|
| APIキャッシュ設計ガイド | APIキャッシュ設計ガイド|ETag・Cache-Control・304・CDNキャッシュキーの実務 |
| Web APIのエラーハンドリング設計 | Web APIのエラーハンドリング設計|RFC 9457・error_code・400/422/409の使い分け |
| Web APIのべき等性(Idempotency)設計 | Web APIのべき等性(Idempotency)設計|二重決済を防ぐ実装ガイド |
| API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較 | API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較 |
| APIのレート制限(Rate Limiting)実装ガイド | APIのレート制限(Rate Limiting)実装ガイド|アルゴリズム選定と429エラーの適切なハンドリング |
| APIレート制限と429対応 | APIレート制限と429対応|X-RateLimit・Retry-Afterを使ったクライアント実装 |
| ZodでAPI境界のランタイム検証を設計する | ZodでAPI境界のランタイム検証を設計する|z.infer・Expressミドルウェア・zod-to-openapi(2026) |
| OpenAPI 3.1仕様のCIバリデーション完全ガイド | OpenAPI 3.1仕様のCIバリデーション完全ガイド|Spectral Lint・Breaking Change検出・GitHub Actions |
| GraphQL N+1 問題の解決 | GraphQL N+1 問題の解決|DataLoader バッチング実装と Apollo Server 完全ガイド |
| Circuit Breaker + Retry パターン実装ガイド | Circuit Breaker + Retry パターン実装ガイド|Node.js opossum/cockatiel・指数バックオフ・Jitter |
| Sagaパターン完全ガイド | Sagaパターン完全ガイド|Orchestration vs Choreography と補償トランザクション(Node.js) |
| Event SourcingとCQRSの基礎 | Event SourcingとCQRSの基礎|Node.js + PostgreSQL 実装ガイド |
まとめ
WebHook設計の核心は「届くこと」「本物であること」「一度だけ処理されること」の3点です。
- HMAC署名 + timingSafeEqual で改ざんとなりすましを防ぐ
- 生ボディで検証し、タイムスタンプでリプレイ攻撃を拒否する
- 202 Accepted + SQS で速く応答し、タイムアウト再送を防ぐ
- べき等テーブル(UNIQUE制約) で再試行と両立させる
- 指数バックオフ + フルジッター で送信側の再試行ストームを抑える
- DLQ で上限超過イベントを隔離し、人間が追えるようにする
StripeやGitHubクラスの外部連携でも、自社マイクロサービス間のイベント通知でも、この骨格は同じです。最初から完璧を目指さず、ステージングで503を意図的に返して再送・べき等・DLQの動作を確認してから本番に載せる——それが最も確実な導入方法です。