2026年10月8日に、GitHubのWebhook配信検証ガイドを再読しました。GitHubは到着した元のbodyを使い、X-Hub-Signature-256をHMAC-SHA256で検証する例を示しています。ヘッダーがない、または署名が一致しない場合は拒否し、Octokitの検証例ではbodyを読み取ってから検証します。プロキシやロードバランサーがbodyやヘッダーを変更すると検証に影響するため、これはGitHubの契約に沿った確認であり、別サービスの署名形式や業務処理の完了を保証するものではありません。
Webhookは、あるシステムで起きた出来事を、登録した受信先へ通知する仕組みです。注文受付を在庫処理へつなぐ、といった用途があります。ただし通知を受け取ることと、業務処理が一度だけ正しく完了することは別です。署名、重複、再送、記録を分けて設計します。
30秒で要点
- Webhookの受信口もAPIになり得る。「APIかWebhookか」ではなく、必要時の要求かイベント通知かを比較する。
- 元のbody bytesで送信元固有の署名を確認してから、JSONや業務データを扱う。
- 受入、処理完了、再送を区別し、重複や順序の変化を想定する。教材は外部通信なしで試せる。
ポーリングと通知の違い
定期的に相手へ問い合わせるポーリングは、何をいつ取得するかを受信側が決めます。Webhookは、送信側がイベントを契機に通知します。どちらもHTTP APIを使う場合があります。
表が収まらない場合は、横にスクロールできます
| 判断項目 | 定期的な問い合わせ | Webhook通知 |
|---|---|---|
| 起点 | 受信側の予定や要求 | 送信側のイベント |
| 更新を知るまで | 問い合わせ間隔に左右される | 配送遅延や失敗・再送に左右される |
| 欠落の確認 | 差分や対象期間を取り直せる設計か | 再送・照会・照合手段を別に用意する |
| 受信口 | 公開の通知受信口が不要な構成もある | 送信元が到達できる受信口が必要な場合がある |
「リアルタイムだから欠落しない」「Webhookなら必ず低負荷」とは言えません。送信量、照会方法、再送条件、運用上の復旧方法で選びます。通知を受けた後にAPIで最新状態を照会する、両方を使う構成もあります。
架空の注文発生
→ 配送する通知(イベントIDを付ける)
→ 署名・形式を確認
→ 重複を確認して受入
→ 業務処理とその結果を別に記録
応答が失われた場合 → 同じイベントが再び届く可能性
イベントの形と算数を明示する
以下は著者作成の注文通知です。顧客の実データではありません。税込・税抜や送料などは扱わず、2種類の商品の単価×数量だけを合計しています。
{
"id": "evt_demo_1",
"type": "order.created",
"order": {
"currency": "JPY",
"lines": [
{ "unitPrice": 1000, "quantity": 2 },
{ "unitPrice": 1500, "quantity": 1 }
],
"total": 3500
}
}
1000円×2個+1500円×1個=3500円です。金額が合っていても正しい送信元とは限らず、署名が正しくても業務上の値が妥当とは限りません。本番では通貨、金額の最小単位、型、範囲、イベントの契約版などを別に検査します。
受信処理を小さく分ける
以降の5つのJavaScript枠を、掲載順で一つのwebhook-lesson.mjsへ保存すると、最後の枠が外部通信なしの確認を実行します。Node.jsの標準機能だけを使います。関数宣言は後ろの枠を参照しますが、すべて定義した後に実行します。
最初は受信部分です。64KiBは教材用の上限で、送信元が許す最大サイズではありません。未知の種類を400にする方針もこの教材の契約です。
function receive(raw, signature, secret, seen, queue) {
if (!Buffer.isBuffer(raw) || raw.length > 64 * 1024) {
return { status: 413, outcome: "too-large-or-not-bytes" };
}
if (!verify(raw, signature, secret)) {
return { status: 401, outcome: "invalid-signature" };
}
let event;
try { event = JSON.parse(raw.toString("utf8")); }
catch { return { status: 400, outcome: "invalid-json" }; }
if (!event || typeof event.id !== "string" ||
!/^[A-Za-z0-9_-]{1,80}$/.test(event.id) ||
event.type !== "order.created") {
return { status: 400, outcome: "unsupported-shape" };
}
if (seen.has(event.id)) return { status: 200, outcome: "duplicate" };
queue.push(event); // 教材のメモリー内受入。永続化ではない
seen.add(event.id);
return { status: 202, outcome: "accepted" };
}
このSetと配列は、再起動すれば失われます。同時実行やクラッシュに耐える重複排除ではありません。本番ではイベントIDの一意制約、業務更新との原子性、永続キューへの受入、失敗後の回復を設計します。注文内訳の全検査もこの小さな受信関数の対象外です。
送信と署名を同じbytesにつなぐ
送信役には通信関数を注入します。本文の確認では、HTTP送信の代わりに先ほどの受信関数を呼びます。Promiseを待ち、非成功の応答を見落とさない形です。
async function send(event, secret, transport) {
const raw = Buffer.from(JSON.stringify(event), "utf8");
const signature = "sha256=" +
createHmac("sha256", secret).update(raw).digest("hex");
const response = await transport({ raw, signature });
if (response.status < 200 || response.status >= 300) {
const error = new Error("delivery rejected");
error.retryable = response.status === 429 || response.status >= 500;
throw error;
}
return response;
}
署名検証では、JSONを解析して文字列にし直したものではなく、到着した元のbytesを使います。下の形式はGitHubのHMAC-SHA256の規約を参考にした教材であり、Stripeなど別の送信元へそのまま適用する形式ではありません。
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(raw, signature, secret) {
if (typeof signature !== "string" ||
!/^sha256=[0-9a-f]{64}$/.test(signature)) return false;
const supplied = Buffer.from(signature.slice(7), "hex");
const expected = createHmac("sha256", secret).update(raw).digest();
return supplied.length === expected.length &&
timingSafeEqual(supplied, expected);
}
timingSafeEqualは長さの異なる入力で例外を投げるため、形式と長さを確認してから呼びます。この関数を使うだけで周辺処理全体がタイミング攻撃へ安全になるわけではありません。Node.jsのAPI説明
実運用の秘密値は安全な保管先から読み、ログやリポジトリへ残しません。TLS、許可する受信経路、サイズ・時間制限、必要に応じた時刻や再送防止の条件も、送信元の契約に合わせて確認します。
再送回数と待ち時間を数える
下のmaxAttemptsは「初回を含む最大試行回数」です。5回すべて一時失敗なら、待機は1秒、2秒、4秒、8秒の4回、合計15秒です。通信自体にかかる時間は別です。待機関数も注入するので、教材では実際に15秒待ちません。
async function sendWithRetry(deliver, wait, maxAttempts = 5) {
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
throw new Error("maxAttempts must be a positive integer");
}
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try { return await deliver(); }
catch (error) {
if (attempt === maxAttempts || error.retryable !== true) throw error;
await wait(1000 * 2 ** (attempt - 1));
}
}
}
この教材では429または5xxを再送候補にします。本番では相手の規約、Retry-After、総期限、ジッター、再送しないエラー、監視や手動回復を追加判断します。ネットワーク例外を無条件にすべて再送可能とせず、失敗の意味を分類します。
ローカル確認とログを分ける
元の開発用トンネルは、手元の受信口を外部から到達可能にする役割です。そのため、以下の最初の確認にはトンネルを使いません。公開エンドポイントを試す段階では、許可されたテスト環境、架空データ、秘密値の管理、終了後の公開停止を別に確認します。
node --check webhook-lesson.mjs
node webhook-lesson.mjs
最後の枠は確認用です。本文全体やヘッダーをログにせず、イベントID、結果、HTTP状態、所要時間という許可した項目だけを返します。実運用ではID自体の機密性やログの閲覧権限・保存期間も検討します。
import assert from "node:assert/strict";
function safeLog(eventId, outcome, status, durationMs) {
return { eventId, outcome, status, durationMs };
}
const secret = "fictional-fixture-key-not-for-production";
const event = { id: "evt_demo_1", type: "order.created",
order: { currency: "JPY", lines: [
{ unitPrice: 1000, quantity: 2 }, { unitPrice: 1500, quantity: 1 }
], total: 3500 } };
const seen = new Set(), queue = [];
const transport = async ({ raw, signature }) =>
receive(raw, signature, secret, seen, queue);
assert.equal((await send(event, secret, transport)).status, 202);
assert.equal((await send(event, secret, transport)).outcome, "duplicate");
assert.equal(queue.length, 1);
assert.equal(verify(Buffer.from("{}"), "bad", secret), false);
const bytes = Buffer.from(JSON.stringify(event));
const sig = "sha256=" + createHmac("sha256", secret).update(bytes).digest("hex");
assert.equal(verify(Buffer.concat([bytes, Buffer.from(" ")]), sig, secret), false);
let attempts = 0;
const waits = [];
await assert.rejects(sendWithRetry(async () => {
attempts++;
const error = new Error("fixture temporary failure");
error.retryable = true;
throw error;
}, async ms => { waits.push(ms); }));
assert.equal(attempts, 5);
assert.deepEqual(waits, [1000, 2000, 4000, 8000]);
const log = safeLog(event.id, "accepted", 202, 4);
assert.deepEqual(Object.keys(log), ["eventId", "outcome", "status", "durationMs"]);
console.log("offline checks passed");
確認できるのは教材の署名、改変拒否、同じIDの重複、最大試行数、待ち時間、ログ項目などです。永続化、並列処理、公開HTTP到達、実サービスとの互換性を検証したことにはなりません。
運用前に決めること
表が収まらない場合は、横にスクロールできます
| 問い | 記録する内容 |
|---|---|
| 何を成功と呼ぶか | 受信、永続受入、業務完了を別の状態にする |
| 応答が失われたら | 同じIDの再送と処理済み判定をどう扱うか |
| 順序が変わったら | 受信順だけに依存せず、状態の照会や競合処理を設計する |
| 復旧するとき | 失敗イベントの範囲、再処理する権限、二重処理の防止 |
| 調査するとき | 秘密や個人情報を過剰保存せず、IDで追跡できるか |
通知をチャットへ流す、CRMへ登録する、注文を連携する、といった用途では、送信する側と受信する側のどちらをサービスが提供するかも確認します。連携ツールの名前が同じでも、受信口の契約まで同じとは限りません。