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

> Webhook の送信側で再送を作るときに決める、再送の間隔と期間、指数バックオフとゆらぎ、webhook-id、タイムアウト、失敗が続く送信先の止め方を、各社の仕様と Node のコードで説明します。

公開日: 2026-09-27
Source: https://webhookadmin.com/ja/blog/webhook-retry-design/

## 要点

- 再送で決めることは、再送の間隔、諦めるまでの期間、重複を見分ける ID、失敗が続く送信先の止め方の 4 つです。
- 再送の期間は各社で約 9 分から 25 日まで開きがあり、Stripe と SmartHR は約 3 日、Svix は約 28 時間です。
- 間隔は指数バックオフにゆらぎを加えるか送信先ごとの同時送信数を絞り、再送でも同じ webhook-id を送ります。失敗が続く送信先は一時停止と自動の無効化の 2 段階で止めます。

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](https://docs.stripe.com/webhooks)、[SmartHR](https://developer.smarthr.jp/api/about_webhook)、[KOMOJU](https://ja.doc.komoju.com/docs/webhooks)、[PAY.JP](https://docs.pay.jp/v1/webhook)、[Shopify](https://shopify.dev/docs/apps/build/webhooks/troubleshooting-webhooks)、[Shopify の署名](https://shopify.dev/docs/apps/build/webhooks/verify-deliveries)、[GitHub](https://docs.github.com/en/webhooks/using-webhooks/handling-failed-webhook-deliveries)、[GitHub の署名](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries)、[Svix](https://docs.svix.com/retries)

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

国内で送信側を自作した記事を読むと、再送は SQS の可視性タイムアウト（[ハコベル](https://zenn.dev/hacobell_dev/articles/hacobell-webhooks)）、Cloud Tasks（[CastingONE](https://zenn.dev/castingone_dev/articles/5bd1fdfc5697df)、[SkyWay](https://engineers.ntt.com/entry/202512-skyway-webhook/entry)）、Sidekiq（[ITANDI](https://tech.itandi.co.jp/entry/2025/06/18/122656)）と、手元のキューに任せる形がほとんどです。キューの再試行は「処理が落ちたら同じ仕事をやり直す」ためのもので、送信先ごとの失敗の記録や自動停止は含まれないため、その部分は自分で書きます。

## 再送の間隔と期間

### 指数バックオフ

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

計算は `基準 × 2^(回数)` に上限をかけるのが素直です。

```ts
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](https://aws.amazon.com/blogs/architecture/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 程度で十分です。受ける側のエラーの手がかりにはなり、記録の容量は膨らみません。

```ts
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 回以上届きます。受ける側が処理を終えたのに応答が途中で切れた場合も、送信側から見ると失敗なので再送されます。

そのため送信側は、イベントごとに一意の ID を付け、**再送しても同じ ID を送ります**。[Standard Webhooks](https://www.standardwebhooks.com/) ではこれを `webhook-id` ヘッダーに入れ、署名の時刻（`webhook-timestamp`）と署名だけを送るたびに作り直します。受ける側はこの ID を一意制約つきで記録し、重複には処理をせずに 2xx を返します。受ける側の冪等性の作り方とコードは[Webhook の重複と冪等性](https://webhookadmin.com/ja/blog/webhook-idempotency-duplicates/)に、署名の検証は[Standard Webhooks の解説](https://webhookadmin.com/ja/blog/standard-webhooks-explained/)にまとめています。

受ける側に案内しておくことは 2 つです。重複のときも 2xx を返すこと（4xx を返すと送信側はまた再送します）と、重い処理の前に 2xx を返すことです。同期で外部 API を呼んでから応答するとタイムアウトになり、処理は終わっているのに再送が来ます（[タイムアウトの記事](https://webhookadmin.com/ja/blog/webhook-timeouts/)）。

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

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

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

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

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

## Webhook Admin での扱い

Webhook Admin は、ここまでの内容を次の値で実装しています（2026-09-27 時点のコードの値）。再送の回数と間隔は下の表が既定で、送信先ごとに選べます（最初の送信から最後の再送までは 3 日まで）。

| 回 | 前の送信からの間隔 | 最初の送信からの時間 |
|---|---|---|
| 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 を返します。

```bash
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](https://app.webhookadmin.com/signup)

## よくある質問

### 再送の期間はどれくらいにすればよいですか。

受ける側の障害が週末をまたいでも届く長さが目安で、Stripe と SmartHR は約 3 日、Svix は約 28 時間です。短すぎると受ける側のメンテナンスでイベントが落ち、長すぎると復旧後に古いイベントがまとめて届きます。

### 指数バックオフにゆらぎ（jitter）は必要ですか。

多くの送信先への送信がまとめて失敗しうるなら入れます。ゆらぎがないと、受ける側が復旧した直後に再送が同じ時刻に集中します。送信先ごとに同時に送る数を絞る方法でも、集中を防げます。

### 再送のたびに webhook-id を変えてもよいですか。

変えません。受ける側は webhook-id で重複を見分けるので、再送で ID が変わると同じイベントを 2 回処理します。送るたびに作り直すのは webhook-timestamp と署名だけです。
