ブログ · · 約 9 分で
Webhook の送信を自作するときの全工程:登録・署名・再送・記録・停止
自社の
要点
- Webhook の
送信 その ものは HTTP の POST 1 回です。 手間が かかるのは、 その 周りの 再送・署名・記録・停止です。 - 作る
物は、 送信先の 登録と URL の 検査、 イベントの 種類と 本文の 形、 署名、 キューと 再送、 タイムアウト、 記録、 失敗の 通知、 自動停止、 鍵の 切り 替え、 顧客向けの 画面の 11 個です。 - 最初に
作るべきなのは、 署名・キューを 通した 再送・記録の 3 つです。
Webhook の
作る物の一覧
顧客が
- 送信先の
登録と 。URL の 検査 顧客ごとに URL と 受け取る イベントの 種類を 登録します。 社内の アドレスを 指す URL を 弾く 検査 (SSRF 対策) も ここに 入ります。 - イベントの
種類 。invoice.paidのような名前と、 その 一覧を 決めます。 - 本文の
形 。イベントの 種類・時刻・中身を どう 並べるかを 決めます。 - 署名。
受け側が 送り主を 確かめられるよう、 本文に HMAC などの 署名を 付けます。 - キューと
再送 。業務の 処理と 送信を 切り離し、 失敗したら 間隔を 空けて 送り直します。 - タイムアウトと
成功の 。判定 何秒待つか、 どの 応答を 成功と するかを 決めます。 - 記録。
送信ごとに、 応答の コード・応答の 先頭・所要時間・エラーを 残します。 - 失敗の
通知 。失敗が 続く 送信先を、 自社の 担当者と 顧客に 知らせます。 - 自動停止と
再開 。失敗が 長く続く 送信先を 止め、 直ったら 戻せるようにします。 - 鍵の
切り 。替え 署名の 鍵を、 受け側を 止めずに 新しい もの へ 替えられるようにします。 - 顧客向けの
画面 。顧客が 自分で 送信先を 登録し、 記録を 見て、 送り直せる 画面です。
以下、
送信先の登録と URL の検査
送信先は
登録時のuser:pass@)localhost や.internal のような
イベントの種類と本文の形
イベントのリソース.動作invoice.paid、customer.deleted)type・timestamp・data を
署名
受け側は、
- ヘッダーは
webhook-id・webhook-timestamp・webhook-signatureの3 つ - 署名する
中身は ID.時刻.本文、方式は HMAC-SHA256、 符号化は base64 - 鍵は
whsec_の後ろに base64 の ランダムな 値 (仕様の 推奨は 24〜64 バイト)
ID を
キューと再送
業務の
再送は、webhook-id は
タイムアウトと成功の判定
- 待つ
時間 : 10〜15 秒程度にします。GitHub の タイムアウトは 10 秒、 Shopify は 5 秒です (2026-09-27 に GitHub・Shopify の 公式で 確認) 。 長く すると、 遅い 送信先に ワーカーを 占有されます。 - 成功の
判定 : 2xx だけを成功とします。 - リダイレクト: 追わずに
失敗と して 扱います。 追うと、 登録時に 検査した URL と 違う 宛先に 送ることになります。
最小の送信のコード(Node)
署名を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 でfetch でredirect: 'manual' をres.ok が
記録
送信ごとに
| 残す値 | 使い道 |
|---|---|
| イベントの |
どの |
| 何回目の |
再送の |
| 応答の |
受け側の |
| 所要時間・接続の |
タイムアウトや |
| 送った |
送り直し |
記録は
失敗の通知と自動停止
失敗が
鍵の切り替え
鍵がwebhook-signature に
顧客向けの画面
顧客が
作る順番
次の
- 署名・キューを
通した 再送・記録 - タイムアウトと
リダイレクトの 扱い、 URL の 検査 - 失敗の
通知、 自動停止 - 鍵の
切り 替え、 顧客向けの 画面
Webhook Admin で置き換えられる部分
Webhook Admin は、POST /v1/messages に
- 送信先の
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 プランからです。
手順は始め方の
よくある質問
キューは何を使えばよいですか。
すでに
業務の処理の中で同期で送ってはいけないのですか。
送信先が
最初に作るべき部品はどれですか。
署名、