ブログ ·
Webhook の再送の設計:間隔・ゆらぎ・冪等性・送信先の自動停止
Webhook の
Webhook の
各社の再送の仕様
まず、
| サービス | 再送の |
応答の |
受け側の |
|---|---|---|---|
| Stripe | 本番は |
記載なし | Stripe-Signature |
| SmartHR | 本番は |
60 秒 | X-SmartHR-Token |
| KOMOJU | 最大 25 回。 |
記載なし | X-Komoju-Signature |
| PAY.JP | 3 分間隔で |
記載なし | X-Payjp-Webhook-Token |
| Shopify | 4 時間で |
5 秒 | X-Shopify-Hmac-SHA256 |
| GitHub | 自動の |
10 秒 | X-Hub-Signature-256 |
出典: Stripe、
再送の
国内で
再送の間隔と打ち切り
指数バックオフ
間隔は、
計算は基準 × 2^(回数) に
const BASE_MS = 5_000; // 最初の間隔
const CAP_MS = 10 * 3600_000; // 1 回の間隔の上限(10 時間)
const MAX_ATTEMPTS = 8; // 最初の送信を含めた回数
/** attempts 回失敗した後の待ち時間。打ち切りなら null */
export function backoffMs(attempts: number): number | null {
if (attempts >= MAX_ATTEMPTS) return null;
const exp = Math.min(CAP_MS, BASE_MS * 2 ** (attempts - 1));
return Math.floor(Math.random() * exp); // full jitter
}ゆらぎ(jitter)
最後のMath.random() がrandom(0, min(cap, base * 2 ** attempt)) を、
ゆらぎを
打ち切りの後
打ち切った
タイムアウトと成功の判定
- 打ち切りの
時間 : 15 秒前後が多い (Svix は 15 秒、 GitHub は 10 秒、 Shopify は 5 秒) 。 長く すると 送信側の ワーカーが 遅い 送信先に 占有されます。 - 成功の
判定 : 2xx だけを成功にします。 3xx は リダイレクトを 追わずに 失敗と して 扱います。 追うと、 登録時に 検査した URL と 違う 宛先へ 送る ことになる ためです。 - 応答の
本文 : 記録するのは先頭の 1KB 程度で 十分です。 受け側の エラーの 手が かりには なり、 記録の 容量は 膨らみません。
export async function sendOnce(url: string, headers: Record<string, string>, body: string) {
const started = Date.now();
try {
const res = await fetch(url, {
method: 'POST',
headers,
body,
redirect: 'manual',
signal: AbortSignal.timeout(15_000),
});
const text = (await res.text()).slice(0, 1024);
return { ok: res.status >= 200 && res.status < 300, status: res.status, head: text, ms: Date.now() - started };
} catch (e) {
return { ok: false, status: null, head: null, ms: Date.now() - started, error: String(e) };
}
}冪等性と webhook-id
再送が
そのwebhook-id ヘッダーにwebhook-timestamp)
受け側は、${webhook-id}.${webhook-timestamp}.${本文} をwhsec_ の
import { createHmac, timingSafeEqual } from 'node:crypto';
import type { IncomingHttpHeaders } from 'node:http';
const SECRET = Buffer.from(process.env.WEBHOOK_SECRET!.replace(/^whsec_/, ''), 'base64');
const TOLERANCE_SEC = 5 * 60;
export function verify(raw: Buffer, h: IncomingHttpHeaders): boolean {
const id = String(h['webhook-id'] ?? '');
const ts = String(h['webhook-timestamp'] ?? '');
const sigs = String(h['webhook-signature'] ?? '').split(' ');
if (!id || !ts || Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE_SEC) return false;
const expected = createHmac('sha256', SECRET).update(`${id}.${ts}.`).update(raw).digest();
// 鍵の切り替え中は署名が空白区切りで複数届く。どれか 1 つ合えばよい
return sigs.some((s) => {
const [ver, b64] = s.split(',');
if (ver !== 'v1' || !b64) return false;
const got = Buffer.from(b64, 'base64');
return got.length === expected.length && timingSafeEqual(got, expected);
});
}署名が
// 受け側(Express)。本文は parse せずに受け取り、署名を検証してから使う
app.post('/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
const id = req.header('webhook-id');
if (!id || !verify(req.body, req.headers)) return res.status(400).end();
const inserted = await db.query(
'INSERT INTO processed_webhooks (id) VALUES ($1) ON CONFLICT DO NOTHING',
[id],
);
if (inserted.rowCount === 0) return res.status(200).end(); // 重複。成功で返す
await queue.add('handle-webhook', { id, body: req.body.toString('utf8') });
res.status(200).end(); // 重い処理はキューの先で
});ポイントは
順番も
失敗が続く送信先の止め方
解約した
- 一時的に
止める : 同じ(サーキットブレーカー) 送信先への 失敗が 続いたら 数十秒送らず、 その 後 1 通だけ試して 様子を 見る。 遅い 送信先が ほかの 顧客の 送信を 待たせないように する ためです。 - 無効に
する : 失敗が数日続いたら 送信先を 無効にし、 送信側の 担当者に 知らせる。 Svix は 5 日で 無効にします (Svix の ドキュメント )。
無効に
Webhook Admin での扱い
Webhook Admin は、
| 回 | 前の |
最初の |
|---|---|---|
| 1 | すぐ | 0 |
| 2 | 5 秒 | 5 秒 |
| 3 | 5 分 | 約 5 分 |
| 4 | 30 分 | 約 35 分 |
| 5 | 2 時間 | 約 2 時間 35 分 |
| 6 | 5 時間 | 約 7 時間 35 分 |
| 7 | 10 時間 | 約 17 時間 35 分 |
| 8 | 10 時間 | 約 27 時間 35 分 |
- 間隔は
Svix と 同じ 固定の 表で、 ゆらぎは 入れていません。 代わりに、 送信先ごとに 同時に 送る 数を 既定 10 本までにし、 同じ 送信先に 5 回続けて 失敗したら 30 秒送らずに 1 通だけ試します。 枠が 空くまでの 送信は キューで 待たせます。 - 応答は
15 秒で 打ち切り、 2xx だけを 成功にします。 リダイレクトは 追いません。 410 も 失敗と して 再送します。 応答の 本文は 先頭 1,024 バイトを 記録します。 - 署名は
Standard Webhooks の webhook-id/webhook-timestamp/webhook-signature(HMAC-SHA256 の v1)です。 webhook-idは再送でも 同じ値で、 時刻と 署名は 送る たびに 作り直します。 鍵を 切り 替えた あと 24 時間は、 新旧の 鍵の 署名を 両方付けます。 - 失敗が
5 日続いた 送信先は 自動で 無効にします。 失敗が 1 時間続いた とき、 再送を 使い 切った とき、 自動で 無効に した ときに、 Slack・Teams・Chatwork・ メール・Webhook で 知らせます (同じ 送信先は 1 時間に 1 回まで) 。 - 打ち切った
送信は、 管理画面か API から 再送できます。
API からIdempotency-Key を
curl -X POST https://api.webhookadmin.com/v1/messages \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: invoice-2026-0927-001" \
-d '{"consumer":"customer_123","event_type":"invoice.paid","payload":{"invoice_id":"inv_001","amount":12000}}'決めることの一覧
- 再送の
間隔 (指数バックオフ+ゆらぎ、 または 送信先ごとの 同時数の 制限) と、 打ち切るまでの 期間 - 成功の
判定 (2xx のみ、 リダイレクトは 追わない) と 打ち切りの 秒数 - 再送でも
変わらない イベントの ID と、 受け側への 「重複は ID で 捨てる」 「2xx を 先に 返す」 の 案内 - 打ち切った
イベントの 記録と、 手での 再送 - 失敗が
続く 送信先の 一時停止・無効化と、 その 知らせ方
再送・記録・