# Webhook の送信を自作するときの全工程：登録・署名・再送・記録・停止

> 自社のサービスから顧客へ Webhook を送る仕組みを自作するときに作る物を、送信先の登録から署名・キューと再送・記録・通知・自動停止・鍵の切り替えまで番号付きで整理し、Node の最小の送信のコードを示します。

公開日: 2026-10-10
Source: https://webhookadmin.com/ja/blog/how-to-send-webhooks/

## 要点

- Webhook の送信そのものは HTTP の POST 1 回です。手間がかかるのは、その周りの再送・署名・記録・停止です。
- 作る物は、送信先の登録と URL の検査、イベントの種類と本文の形、署名、キューと再送、タイムアウト、記録、失敗の通知、自動停止、鍵の切り替え、顧客向けの画面の 11 個です。
- 最初に作るべきなのは、署名・キューを通した再送・記録の 3 つです。

Webhook の送信を自作するとき、送ること自体は HTTP の POST を 1 回行うだけです。手間がかかるのは、届かなかったときの再送、本物であることを示す署名、何が起きたかの記録、壊れた送信先の停止です。この記事では、送る側で作る物を一覧にし、それぞれで決めることと、Node での最小の送信のコードを示します。

## 作る物の一覧

顧客が登録した URL にイベントを送る仕組みで、実際に作ることになる物は次の 11 個です。

1. **送信先の登録と URL の検査**。顧客ごとに URL と受け取るイベントの種類を登録します。社内のアドレスを指す URL を弾く検査（SSRF 対策）もここに入ります。
2. **イベントの種類**。`invoice.paid` のような名前と、その一覧を決めます。
3. **本文の形**。イベントの種類・時刻・中身をどう並べるかを決めます。
4. **署名**。受け側が送り主を確かめられるよう、本文に HMAC などの署名を付けます。
5. **キューと再送**。業務の処理と送信を切り離し、失敗したら間隔を空けて送り直します。
6. **タイムアウトと成功の判定**。何秒待つか、どの応答を成功とするかを決めます。
7. **記録**。送信ごとに、応答のコード・応答の先頭・所要時間・エラーを残します。
8. **失敗の通知**。失敗が続く送信先を、自社の担当者と顧客に知らせます。
9. **自動停止と再開**。失敗が長く続く送信先を止め、直ったら戻せるようにします。
10. **鍵の切り替え**。署名の鍵を、受け側を止めずに新しいものへ替えられるようにします。
11. **顧客向けの画面**。顧客が自分で送信先を登録し、記録を見て、送り直せる画面です。

以下、順に決めることを書きます。

## 送信先の登録と URL の検査

送信先は「顧客」と「URL」の組で持ちます。1 人の顧客が複数の URL を登録でき、URL ごとに受け取るイベントの種類を選べる形が一般的です。

登録時の検査は最低でも次の 4 つを行います。https だけを受け付ける、URL に認証情報（`user:pass@`）を入れさせない、IP アドレスを直接書いた URL を弾く、`localhost` や `.internal` のような社内向けの名前を弾く。加えて、名前解決した結果が社内のアドレスでないかを、送る直前にもう一度確かめます。詳しくは [SSRF 対策の記事](https://webhookadmin.com/ja/blog/webhook-ssrf-protection/)で扱います。

## イベントの種類と本文の形

イベントの種類は `リソース.動作`（`invoice.paid`、`customer.deleted`）の形にしておくと、受け側が前方一致で振り分けられます。本文は、種類・時刻・中身の 3 つを持つ形が扱いやすく、[Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) の仕様も `type`・`timestamp`・`data` を推奨しています。同じ仕様は、本文を小さく（通常は 20KB より小さく）保つことを勧めています。

## 署名

受け側は、届いた要求が本当に自社から来たものかを署名で確かめます。独自の形を作るより、Standard Webhooks の形にそろえておくと、受け側は公式のライブラリで検証できます。仕様の各項目の理由は [Standard Webhooks の記事](https://webhookadmin.com/ja/blog/standard-webhooks-explained/)で扱います。

- ヘッダーは `webhook-id`・`webhook-timestamp`・`webhook-signature` の 3 つ
- 署名する中身は `ID.時刻.本文`、方式は HMAC-SHA256、符号化は base64
- 鍵は `whsec_` の後ろに base64 のランダムな値（仕様の推奨は 24〜64 バイト）

ID を署名に含めるのは、受け側が重複を見分けるための ID を書き換えられないようにするためで、時刻を含めるのは、古い要求をそのまま送り直す再生攻撃を防ぐためです。

## キューと再送

業務の処理の中で同期で送ると、送信先が遅いときに自社の処理が待たされ、送信先が落ちているとイベントが失われます。業務の処理ではイベントを記録してキューに入れるだけにし、送信はワーカーで行います。業務のデータの保存とイベントの記録を 1 つのトランザクションで行う（outbox パターン）と、「保存したのにイベントが出ない」ことも防げます（[outbox パターンの記事](https://webhookadmin.com/ja/blog/webhook-outbox-pattern/)）。

再送は、最初は短く、失敗が続くほど長い間隔で行います。間隔と再送をやめるまでの期間の決め方は[再送の設計の記事](https://webhookadmin.com/ja/blog/webhook-retry-design/)で扱います。再送しても `webhook-id` は同じ値のままにし、時刻と署名は送るたびに作り直します。

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

- **待つ時間**: 10〜15 秒程度にします。GitHub のタイムアウトは 10 秒、Shopify は 5 秒です（2026-09-27 に [GitHub](https://docs.github.com/en/webhooks/using-webhooks/handling-failed-webhook-deliveries)・[Shopify](https://shopify.dev/docs/apps/build/webhooks/troubleshoot) の公式で確認）。長くすると、遅い送信先にワーカーを占有されます。
- **成功の判定**: 2xx だけを成功とします。
- **リダイレクト**: 追わずに失敗として扱います。追うと、登録時に検査した URL と違う宛先に送ることになります。

## 最小の送信のコード（Node）

署名を付け、タイムアウトを 15 秒にし、リダイレクトを追わない 1 回分の送信です。Node 18 以降の `fetch` と `AbortSignal.timeout` を使います。

```ts
import { createHmac, randomUUID } from 'node:crypto';

const SECRET = Buffer.from(process.env.ENDPOINT_SECRET!.replace(/^whsec_/, ''), 'base64');

export async function sendOnce(url: string, msgId: string, type: string, data: unknown) {
  const body = JSON.stringify({ type, timestamp: new Date().toISOString(), data });
  const ts = Math.floor(Date.now() / 1000).toString();
  const sig = createHmac('sha256', SECRET).update(`${msgId}.${ts}.${body}`).digest('base64');
  const started = Date.now();
  try {
    const res = await fetch(url, {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        'webhook-id': msgId,
        'webhook-timestamp': ts,
        'webhook-signature': `v1,${sig}`,
      },
      body,
      redirect: 'manual', // 3xx は追わずにそのまま受け取る
      signal: AbortSignal.timeout(15_000), // 15 秒でタイムアウト（TimeoutError）
    });
    const head = (await res.text()).slice(0, 1024);
    return { ok: res.ok, status: res.status, head, ms: Date.now() - started };
  } catch (e) {
    return { ok: false, status: null, head: null, ms: Date.now() - started, error: String(e) };
  }
}

// 1 つのイベントには 1 つの ID を割り当て、再送でも同じ ID を使う
const msgId = `msg_${randomUUID()}`;
```

`AbortSignal.timeout()` は、指定した時間が過ぎると `TimeoutError` で中断する信号を返します（[MDN](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static)）。Node の `fetch` で `redirect: 'manual'` を指定すると、3xx の応答がそのまま返るので、`res.ok` が false になり失敗として扱えます。このコードの呼び出し側で、結果を記録し、失敗なら次の再送の時刻を決めてキューに戻します。

## 記録

送信ごとに次の値を残します。問い合わせに答えるときも、受け側の不具合を指摘するときも、この記録が根拠になります。

| 残す値 | 使い道 |
|---|---|
| イベントの ID・種類・送信先 | どのイベントがどこへ行ったか |
| 何回目の送信か・時刻 | 再送の経過 |
| 応答のコードと応答の本文の先頭（1KB 程度） | 受け側のエラーの手がかり |
| 所要時間・接続のエラー | タイムアウトや名前解決の失敗の切り分け |
| 送った本文 | 送り直し |

記録は保存期間を決めて消していきます。本文に個人情報が入るなら、期間は短めにします。残す値の詳しい決め方は[送信の記録の記事](https://webhookadmin.com/ja/blog/webhook-delivery-logs/)で扱います。

## 失敗の通知と自動停止

失敗が続く送信先は、放っておくと再送のたびにワーカーの時間を使います。止め方は 2 段に分けます。短い時間の失敗の連続で一時的に送るのを止める段と、数日続いたら送信先を無効にする段です。無効にしたことは、自社の担当者と送信先の持ち主（顧客）の両方に知らせます。Stripe も、Webhook の失敗が続くとメールで知らせています（[Stripe](https://docs.stripe.com/billing/subscriptions/webhooks)）。

## 鍵の切り替え

鍵が漏れたときや定期的な交換のために、送信先ごとに鍵を替えられるようにします。新しい鍵に替えた直後は受け側がまだ古い鍵で検証しているので、一定の期間は新旧 2 つの鍵で署名し、`webhook-signature` に空白区切りで並べます。Stripe は古い鍵を最長 24 時間残せます（[Stripe](https://docs.stripe.com/webhooks)）。

## 顧客向けの画面

顧客が増えると、「URL を変えたい」「届いていないようだ」という問い合わせが増えます。送信先の登録・変更、記録の閲覧、送り直しを顧客自身ができる画面があると、この問い合わせの多くがなくなります。自社の管理画面に埋め込む場合は、顧客ごとに期限付きのリンクを発行し、他の顧客のデータが見えないようにします。

## 作る順番

次の順で作ると、早い段階から顧客に使ってもらえます。

1. 署名・キューを通した再送・記録
2. タイムアウトとリダイレクトの扱い、URL の検査
3. 失敗の通知、自動停止
4. 鍵の切り替え、顧客向けの画面

## Webhook Admin で置き換えられる部分

Webhook Admin は、上の一覧のうち 1・4〜11 を API と管理画面で持っています。自社で決めるのはイベントの種類と本文の中身だけで、送るときは `POST /v1/messages` に顧客の ID・イベントの種類・本文を渡します。

- 送信先の URL は https（ポート 443 か 8443）だけを受け付け、IP の直書きと社内向けの名前・社内のアドレスを指す名前を弾きます。リダイレクトは追いません。
- 署名は Standard Webhooks の形式で、鍵を切り替えた後の 24 時間は新旧 2 つの署名を付けます。
- 再送は、既定では、すぐ・5 秒・5 分・30 分・2 時間・5 時間・10 時間・10 時間の 8 回（約 28 時間）です。送信先ごとに回数と間隔を選べます。タイムアウトは 15 秒です。
- 送信ごとに、応答のコード・応答の先頭 1,024 バイト・所要時間を記録し、管理画面と API から送り直せます。
- 失敗が 5 日続いた送信先は自動で止め、Slack・Teams・Chatwork・メール・Webhook で知らせます。
- 顧客向けの画面は Starter プランからです。

手順は[始め方のドキュメント](https://webhookadmin.com/ja/docs/)に、料金は[料金表](https://webhookadmin.com/ja/#pricing)にあります。再送・記録・通知を自作する前に試してみたい場合は、無料で登録できます（月 5 万通まで）。[https://app.webhookadmin.com/signup](https://app.webhookadmin.com/signup)

## よくある質問

### キューは何を使えばよいですか。

すでに使っているものがあれば、それで足ります。SQS、Cloud Tasks、Sidekiq、BullMQ など、遅らせて実行できて、失敗したら再実行できるものなら組めます。キューの再試行とは別に、送信先ごとの失敗の記録と、再送を使い切った送信を後から送り直す仕組みは自分で作ります。

### 業務の処理の中で同期で送ってはいけないのですか。

送信先が遅いと自社の処理が送信先の応答を待つことになり、送信先が落ちているとイベントが失われます。業務の処理ではイベントを記録するだけにして、送信はキューの先のワーカーで行うのが基本です。

### 最初に作るべき部品はどれですか。

署名、キューを通した再送、送信ごとの記録の 3 つです。署名がないと受け側は本物か確かめられず、再送がないと受け側の一時的な障害でイベントが落ち、記録がないと問い合わせに答えられません。
