ブログ · · 約 9 分で読めます

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

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

要点

  • 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 対策の記事で扱います。

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

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

署名

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

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

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

キューと再送

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

再送は、最初は短く、失敗が続くほど長い間隔で行います。間隔と再送をやめるまでの期間の決め方は再送の設計の記事で扱います。再送しても webhook-id は同じ値のままにし、時刻と署名は送るたびに作り直します。

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

  • 待つ時間: 10〜15 秒程度にします。GitHub のタイムアウトは 10 秒、Shopify は 5 秒です(2026-09-27 に GitHub・Shopify の公式で確認)。長くすると、遅い送信先にワーカーを占有されます。
  • 成功の判定: 2xx だけを成功とします。
  • リダイレクト: 追わずに失敗として扱います。追うと、登録時に検査した URL と違う宛先に送ることになります。

最小の送信のコード(Node)

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

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)。Node の fetch で redirect: 'manual' を指定すると、3xx の応答がそのまま返るので、res.ok が false になり失敗として扱えます。このコードの呼び出し側で、結果を記録し、失敗なら次の再送の時刻を決めてキューに戻します。

記録

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

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

記録は保存期間を決めて消していきます。本文に個人情報が入るなら、期間は短めにします。残す値の詳しい決め方は送信の記録の記事で扱います。

失敗の通知と自動停止

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

鍵の切り替え

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

顧客向けの画面

顧客が増えると、「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 プランからです。

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

よくある質問

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

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

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

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

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

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

関連記事