ブログ ·

Webhook の再送の設計:間隔・ゆらぎ・冪等性・送信先の自動停止

Webhook の送信側で再送を作るときに決めることを、国内外の SaaS の実際の仕様と Node のコードで整理します。再送の間隔と打ち切り、指数バックオフとゆらぎ、webhook-id による重複の除去、タイムアウト、失敗が続く送信先の止め方まで。

Webhook の送信側で再送を作るときに決めることは、「いつ再送するか」「いつ諦めるか」「同じイベントが 2 回届いたときに受け側がどう見分けるか」「壊れた送信先をいつ止めるか」の 4 つです。この記事では、実際の SaaS の仕様を並べたうえで、自作するときの実装の要点と、Webhook Admin での扱いを書きます。

各社の再送の仕様

まず、よく使われるサービスがどう再送しているかを並べます。いずれも 2026-09-27 に公式のドキュメントで確かめたものです。

サービス 再送の回数と期間 応答の打ち切り 受け側の検証
Stripe 本番は指数バックオフで最長 3 日間 記載なし Stripe-Signature(HMAC-SHA256)
SmartHR 本番は約 3 日間で最大 17 回 60 秒 X-SmartHR-Token(固定のトークン)
KOMOJU 最大 25 回。最後は約 100 時間の間隔で、最初の送信から 25 日後 記載なし X-Komoju-Signature(HMAC)
PAY.JP 3 分間隔で最大 3 回 記載なし X-Payjp-Webhook-Token(固定のトークン)
Shopify 4 時間で最大 8 回 5 秒 X-Shopify-Hmac-SHA256(HMAC-SHA256)
GitHub 自動の再送なし(手動か API で再送) 10 秒 X-Hub-Signature-256(HMAC-SHA256)

出典: Stripe、SmartHR、KOMOJU、PAY.JP、Shopify、Shopify の署名、GitHub、GitHub の署名

再送の期間は、PAY.JP の約 9 分から KOMOJU の 25 日まで開きがあります。受け側の障害は数分で直るものもあれば、週末をまたぐものもあります。PAY.JP のように 3 分間隔で 3 回だと、受け側の 10 分のメンテナンスでイベントが落ちます。一方で 25 日も再送を続けると、受け側が直ったときに古いイベントがまとめて届きます。Stripe と SmartHR は約 3 日、配信サービスの Svix は約 28 時間です。

国内で送信側を自作した記事を読むと、再送は SQS の可視性タイムアウト(ハコベル)、Cloud Tasks(CastingONE、SkyWay)、Sidekiq(ITANDI)と、手元のキューに任せる形がほとんどです。キューの再試行は「処理が落ちたら同じ仕事をやり直す」ためのもので、送信先ごとの失敗の記録や自動停止までは面倒を見てくれません。そこは自分で書くことになります。

再送の間隔と打ち切り

指数バックオフ

間隔は、最初は短く、失敗が続くほど長くするのが基本です。一時的な接続の失敗はすぐ直ることが多く、長く落ちている送信先に短い間隔で送り続けても負荷をかけるだけだからです。

計算は 基準 × 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() がゆらぎです。受け側が落ちている間に 1,000 通が失敗すると、ゆらぎがなければ 1,000 通が同じ時刻に再送され、復旧した直後の受け側にまとめて届きます。待ち時間を 0 から上限の間でばらけさせると、再送が時間方向に散ります。AWS のアーキテクチャブログの記事「Exponential Backoff And Jitter」(2015)で「Full Jitter」と呼ばれている random(0, min(cap, base * 2 ** attempt)) を、そのまま書いたものです。

ゆらぎを入れる代わりに、送信先ごとに同時に送る数を絞る方法もあります。こちらは後述します。

打ち切りの後

打ち切ったイベントは消さずに「失敗」として残し、あとから手で再送できるようにします。Stripe もダッシュボードと CLI から再送できるようにしています。受け側が直ったあとに「この時刻以降に失敗したものを全部送り直す」操作があると、問い合わせの対応が楽になります。

タイムアウトと成功の判定

  • 打ち切りの時間: 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

再送がある以上、受け側には同じイベントが 2 回以上届きます。受け側が処理を終えたのに応答が途中で切れた場合、送信側から見ると失敗なので再送されます。配信の約束は「少なくとも 1 回」で、重複を除くのは受け側の仕事です。

そのために、送信側はイベントごとに一意の ID を付け、再送しても同じ ID を送ります。Standard Webhooks ではこれを webhook-id ヘッダーに入れます。署名の時刻(webhook-timestamp)と署名は送るたびに作り直し、ID だけは変えません。

受け側は、まず署名を検証します。Standard Webhooks の署名は ${webhook-id}.${webhook-timestamp}.${本文} を HMAC-SHA256 にかけたもので、鍵は whsec_ の後ろを base64 で戻したものです。本文はフレームワークが JSON に parse する前の、届いたままのバイト列を使います。parse してから文字列に戻すと空白や数値の書き方が変わり、署名が合わなくなります。

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);
  });
}

署名が合ったら、処理済みの ID を一意制約つきの表に入れ、入らなければ重複として捨てます。

// 受け側(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(); // 重い処理はキューの先で
});

ポイントは 2 つあります。重複のときも 2xx を返すこと(4xx を返すと送信側はまた再送します)。そして、重い処理の前に 2xx を返すことです。Stripe のドキュメントも、複雑な処理の前に 2xx を返すよう書いています。同期で外部 API を呼んでから応答すると、打ち切りの時間を超えて失敗扱いになり、処理は終わっているのに再送が来ます。

順番も保証されません。CastingONE の記事にある「登録 → 削除 → 更新の順番でリクエストが届く」ケースは、再送があれば普通に起こります。受け側は、イベントの本文に対象の ID を入れてもらい、最新の状態を API で取り直す形にしておくと順番に依存しません。

失敗が続く送信先の止め方

解約した顧客の URL や、ドメインが切れた URL には、いくら再送しても届きません。放っておくと、再送のたびにワーカーが打ち切りの時間まで待たされます。止め方は 2 段階に分けると扱いやすくなります。

  1. 一時的に止める(サーキットブレーカー): 同じ送信先への失敗が続いたら数十秒送らず、その後 1 通だけ試して様子を見る。遅い送信先がほかの顧客の送信を待たせないようにするためです。
  2. 無効にする: 失敗が数日続いたら送信先を無効にし、送信側の担当者に知らせる。Svix は 5 日で無効にします(Svix のドキュメント)。

無効にしたことは、送信する SaaS の担当者と、送信先の持ち主(顧客)の両方が分かる形にしておきます。知らせがないと、顧客から「最近届かない」と言われて初めて気づくことになります。

Webhook Admin での扱い

Webhook Admin は、ここまでの内容を次の値で実装しています(2026-09-27 時点のコードの値)。

回 前の送信からの間隔 最初の送信からの時間
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 を付けます。同じキーは 24 時間、同じメッセージの ID を返します。

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 を先に返す」の案内
  • 打ち切ったイベントの記録と、手での再送
  • 失敗が続く送信先の一時停止・無効化と、その知らせ方

再送・記録・自動停止・失敗の通知を自分で作らずに済ませたい場合は、Webhook Admin を無料で試せます(月 5 万通まで無料)。https://app.webhookadmin.com/signup