Webhookの作り方は、他社サービスからの通知を受け取る側を作るのか、自社サービスから通知を送る側を作るのかで中身が変わります。受け取る側で作るのは、「生のリクエストボディで署名を検証し、すぐ2xxを返し、イベントIDで重複を捨てる」エンドポイント1本です。送る側は「署名を付けて送り、失敗したら間隔を空けて再送し、登録されたURLが社内ネットワークを向いていないか確かめる」配信処理を作ります。
この記事では、両方をNode.jsで実装し、Node.js 26.5.0・Express 5.3.0・PostgreSQL 18.6で実行した結果を載せています。同じ受信処理をPython(FastAPI)で書く場合の要点と、Google Apps Script(GAS)で受けようとしたときに突き当たる制約も加えました。GitHub・Stripe・Slack・Shopifyの署名ヘッダーと応答期限の違いは表にまとめています。Webhookそのものの仕組みやAPIとの違いはWebhookとは?読み方・仕組み・APIとの違いを実際のHTTPリクエストで解説で扱っています。
まとめ|受信側3手順と送信側の署名・再送・SSRF対策
- 受信側の作り方は3手順:JSONに変換する前の生ボディを受け取る → HMACで計算した署名とヘッダーの署名を定数時間で比べる → イベントを永続化して2xxを返し、重い処理はワーカーへ回す。Node.jsでもPythonでも骨格は変わりません。
- 応答期限はサービスごとに違う:Slack Events APIは3秒、Shopifyは全体で5秒、GitHubは10秒。いちばん短い3秒に合わせ、応答前は「検証と保存」だけにします。
- 重複は必ず届く前提で作る:Stripeは同じイベントを複数回届けることがあり、到着順も保証しません。イベントIDを主キーにした表へ
INSERT … ON CONFLICT DO NOTHINGで入れ、0行なら処理しません。 - GASは署名付きWebhookの受け口に向かない:
doPost(e)のイベントにはリクエストヘッダーが入らず、ヘッダーで届く署名を検証できません。社内通知の中継など署名が要らない用途に限ります。 - 送る側は署名・再送・SSRF対策の3点:署名形式はStandard Webhooks(
webhook-id・webhook-timestamp・webhook-signature)に合わせると受信側が既製ライブラリで検証できます。再送は数日にわたる指数バックオフ、登録URLはプライベートアドレス宛てを拒否します。
受信側と送信側で作るものの違い|立場別に必要なエンドポイントと処理
「Webhookを作る」と言っても、作業の大半は次のどちらかです。自分がどちらの立場かを先に決めると、必要な実装が絞れます。
| 立場 | 典型例 | 作るもの | 外せない処理 |
|---|---|---|---|
| 受信側 | Stripeの決済完了で注文を確定 | HTTPSのPOSTエンドポイント | 署名検証・即時応答・重複排除 |
| 送信側 | 自社SaaSの更新を顧客へ通知 | 配信キューと配信ワーカー | 署名付与・再送・SSRF対策 |
| 送るだけ | 監視アラートをSlackへ投稿 | 発行済みURLへのPOST | URLの秘匿・429の扱い |
3行目の「送るだけ」は、SlackやDiscordが発行したURLにJSONをPOSTすれば済むので、配信の仕組みを自作する必要はありません。手順はIncoming Webhookとは?仕組みとSlack・Discordでの使い方を解説とSlack Incoming Webhookの実装手順:URL発行・Block Kit整形・SDK送信と429対策にまとめています。この記事では1行目と2行目を扱います。
受信側の作業を順番に並べると、次の5段階です。この記事の章立てもこの順に沿っています。
- 受信エンドポイントを書く(生ボディの取得・署名検証・保存と2xx応答)
- 送信元ごとの署名ヘッダー・応答期限・再送仕様に合わせる
- 重複と順序の入れ替わりに耐えるよう、イベントIDで冪等にする
- curlで署名付きリクエストを再現し、トンネルやCLIで本物の通知を受けて試す
- HTTPSの公開URLを送信元の管理画面かAPIに登録し、失敗した配信を監視する
送る側を作る場合は、これに加えて署名の付与・再送スケジュール・宛先URLの検証が必要になります。後半の章でまとめて扱います。
Node.js・Express 5で受信エンドポイントを作る3つの手順と動くコード
GitHubのWebhookを受け取るエンドポイントを例にします。GitHub側のContent typeは、このコードに合わせて application/json を選択してください。GitHubは X-Hub-Signature-256 ヘッダーに、リクエストボディをSecretでHMAC-SHA256した値を sha256= 付きの16進数で入れて送ってきます(GitHub Docs「Validating webhook deliveries」)。署名の方式が違うサービスでも、手順の骨格は同じです。
import express from "express";
import crypto from "node:crypto";
const app = express();
const SECRET = process.env.WEBHOOK_SECRET; // GitHubのWebhook設定に入れたSecret
const seen = new Set(); // 本番はDBの一意制約で代替する
// 署名は「受け取ったバイト列そのもの」に対して計算されるため、JSONにする前の生ボディを受け取る
app.post("/webhooks/github", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-Hub-Signature-256") ?? "";
const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
// 長さが違うと timingSafeEqual は例外を投げるので先に比べる
const a = Buffer.from(header);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send("invalid signature");
}
const deliveryId = req.get("X-GitHub-Delivery");
if (!deliveryId) return res.status(400).send("missing delivery id");
let payload;
try {
payload = JSON.parse(req.body.toString("utf8"));
} catch {
return res.status(400).send("invalid JSON");
}
// 同じ配信IDが再送されたら、処理せずに成功を返す
if (seen.has(deliveryId)) return res.status(200).send("duplicate");
seen.add(deliveryId); // 本番はここでDBかキューに保存し、成功してから応答する
// 保存できたら2xxを返し、業務処理は応答の外で行う(本番は別のワーカー)
res.status(202).send("accepted");
const event = req.get("X-GitHub-Event");
setImmediate(() => console.log("process", event, deliveryId, payload.action ?? ""));
});
const port = process.env.PORT ?? 3000;
app.listen(port, () => console.log(`listening on :${port}`));
npm i express のあと WEBHOOK_SECRET=… node receiver.mjs で起動します。以下では、生ボディの取得、署名検証、保存と応答の順に説明します。
手順1 署名検証のためにJSON変換前の生ボディをexpress.rawで受信
署名は送信側が送ったバイト列そのものに対して計算されています。express.json() で先にオブジェクトへ変換し、JSON.stringify で文字列に戻してから検証すると、空白や改行、キーの並びが変わって一致しません。そのためWebhookのルートだけ express.raw() でBufferとして受け取ります。この例の本文サイズ上限は既定の100kbなので、購読するイベントの大きさに合わせて limit を決め、超えたときの413応答もテストしておきます。Stripeも公式ドキュメントで「未加工のリクエスト本文に何らかの変更が行われた場合、検証は失敗します」と明記しています(Stripe「Webhook エンドポイントで Stripe イベントを受信する」)。
実際に、正しい署名を付けたボディ {"action":"opened","number":1} は202、カンマの後ろに空白を1つ足しただけのボディは401になりました。アプリ全体で app.use(express.json()) を先に登録している場合は、Webhookのルートをその前に定義してください。RailsやDjangoのようにPOSTへCSRFトークンを自動で要求するフレームワークでは、Webhookのルートを除外する設定も必要です。
手順2 HMAC-SHA256の署名をtimingSafeEqualで定数時間比較
署名の比較に === を使うと、先頭から何文字一致したかで処理時間が変わり、タイミング攻撃の手がかりになります。crypto.timingSafeEqual を使い、長さが違うと例外になるので長さは先に比べておくのが定石です。GitHubは検証の答え合わせ用に、Secretが It's a Secret to Everybody、ペイロードが Hello, World! のとき署名が 757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17 になるという値を公開しています。自分の実装でこの値が出るかを最初に確かめると、署名の作り方の誤りをすぐ切り分けられます(上のコードと同じ計算で一致しました)。
Stripe・Slack・Standard Webhooksのように署名対象にタイムスタンプを含む方式では、タイムスタンプが古すぎるリクエストも拒否してリプレイ攻撃を防ぎます。Stripeの公式ライブラリは既定で5分の許容差を設けており、許容差に0を指定すると鮮度チェックそのものが無効になると注意書きがあります。
手順3 イベントを保存してから2xxを返し業務処理はワーカーへ回す設計
検証に通ったら、イベントをDBまたは永続キューに保存してから202か200を返します。業務データの更新や外部API呼び出しは、別のワーカーの担当です。応答が遅れると送信側はタイムアウトと見なして再送し、受信側の負荷がさらに上がります。上のコードは説明のため setImmediate で後回しにしていますが、本番ではイベントを保存してキューに積み、別のワーカーで処理する形が前提です。キューの選び方はAmazon SQSとは?標準キューとFIFOの違い・可視性タイムアウトとDLQ設計を解説が参考になります。
なお、届いたイベントのうち処理しない種類にも2xxを返します。4xxや5xxは通常、配信失敗として扱われます。自動再送の有無や打ち切り条件は送信元によって異なり、GitHubは失敗した配信を自動再送しません。そもそも不要なイベントは、送信側の設定で購読から外すのが先です。
Python・FastAPIでの受信実装とhmac.compare_digestの使い方
Pythonで書く場合も手順は同じで、違いは「生ボディの取り方」と「比較関数」の2点だけです。FastAPIでは引数にPydanticモデルを置くとJSONに変換された後の値しか手に入らないため、Request を直接受け取り await request.body() でバイト列を読みます(FastAPI「Using the Request Directly」)。FastAPIの始め方そのものはFastAPIとは|最短で動かす手順とasync/defの使い分けを参照してください。
import hashlib
import hmac
import os
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
SECRET = os.environ["WEBHOOK_SECRET"].encode()
@app.post("/webhooks/github", status_code=202)
async def github_webhook(request: Request):
body = await request.body() # JSONに変換する前のバイト列
header = request.headers.get("x-hub-signature-256", "")
expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
# bytes同士で比べる(str同士だとASCII以外の文字でTypeErrorになる)
if not hmac.compare_digest(expected.encode(), header.encode()):
raise HTTPException(status_code=401, detail="invalid signature")
delivery_id = request.headers.get("x-github-delivery")
if not delivery_id:
raise HTTPException(status_code=400, detail="missing delivery id")
# ここでイベントをDBに保存し、業務処理はワーカーへ回す
return {"accepted": delivery_id}
比較には hmac.compare_digest を使います(Python公式「hmac」)。Node.jsの timingSafeEqual と違い、長さが違っても例外にならず False を返すので、長さの事前比較は要りません。一方で、str同士を渡すとASCII以外の文字を含んだ時点で TypeError になります。攻撃者が送る署名ヘッダーに非ASCII文字を混ぜると500を返してしまうため、上のコードのように両方を encode() してbytesで比べます。
この署名検証の部分は、Python 3.14.6でGitHubの公開テスト値(前の手順2で示した値)と一致すること、ボディの末尾に空白を1つ足すと False になること、str同士の比較で非ASCII文字を渡すと TypeError: comparing strings with non-ASCII characters is not supported になることを確かめています。Djangoで書く場合は request.body がバイト列なので同じ関数をそのまま使えますが、CSRF検証の対象から外す設定を忘れないでください。
GitHub・Stripe・Slack・Shopifyの署名ヘッダーと応答期限・再送仕様
受信側の作り方は共通でも、ヘッダー名・署名の対象・応答期限・再送の有無はサービスごとに違います。2026年10月時点の各社公式ドキュメントで確認した値です。なお、署名を付けずに通知を送ってくるSaaSもあり、その場合に受信側で何を検査するかはStudio不正アクセスとWebhook URLの入れ替え手順で扱いました。
| サービス | 署名ヘッダー | 値の形式 | 署名の対象 | 応答期限 | 自動再送 |
|---|---|---|---|---|---|
| GitHub | X-Hub-Signature-256 | sha256=16進 | ボディ | 10秒 | なし(3日以内は手動) |
| Stripe | Stripe-Signature | t=…,v1=… | t.ボディ | 明示なし | 本番は最長3日 |
| Slack Events API | X-Slack-Signature | v0=16進 | v0:時刻:ボディ | 3秒 | 既定3回(直後・1分・5分) |
| Shopify | X-Shopify-Hmac-Sha256 | Base64 | ボディ | 接続1秒・全体5秒 | 4時間で8回 |
| Standard Webhooks | webhook-signature | v1,Base64 | ID.時刻.ボディ | 送信側の推奨15〜30秒 | 数日の指数バックオフ |
GitHubは失敗した配信を自動では再送せず、過去3日以内の配信をWeb画面かREST APIで手動再配信する仕組みです(GitHub Docs「Redelivering webhooks」)。受信サーバーが止まっていた間の取りこぼしは自分で拾い直す必要があり、配信一覧APIで失敗分を定期的に再配信するスクリプトを用意しておくと安全です。
Stripeは本番環境で指数バックオフを使い最長3日間再試行し、サンドボックスでは数時間のうちに3回再試行します。再試行のたびに新しいタイムスタンプと署名を作り直すので、署名やタイムスタンプを重複判定のキーには使えません。
Slack Events APIは3秒以内に2xxを返さないと失敗と見なし、再送には試行番号の x-slack-retry-num と理由の x-slack-retry-reason(3秒超過なら http_timeout)が付きます。60分間の配信試行の95%超が失敗するとイベント購読が一時的に無効化されます。ただし、1時間の受信イベント数が1,000件未満のアプリは自動無効化の対象外です(Slack「Events API」)。署名は v0・X-Slack-Request-Timestamp・生ボディをコロンでつないだ文字列から計算します。アプリ設定でDelayed Eventsを有効にすると、既定の3回に続けて24時間にわたり1時間ごとの再送が追加されます。
Shopifyは接続確立1秒・リクエスト全体5秒でタイムアウトし、失敗すると4時間で8回再送します。Admin APIで作成した購読は8回連続で失敗すると自動で削除されます(Shopify「Verify webhook deliveries」)。署名は16進ではなくBase64なので、GitHub用の比較コードをそのまま流用すると必ず不一致になります。
この表から導ける設計上の結論は1つです。応答前にやってよいのは「署名検証」と「イベントの保存」だけで、それ以外はすべて応答の後に回します。応答処理はSlackの3秒より十分短く収めます。ただし、Shopifyの接続確立期限は1秒であり、アプリの処理を速くしても接続遅延やネットワーク障害によるタイムアウトまでは防げません。
重複配信と順序の入れ替わりに耐える冪等な受信処理とPostgreSQLの一意制約
再送がある以上、同じイベントは複数回届きます。Stripeは「Webhook エンドポイントは、同じイベントを複数回受信する可能性があります」と明記し、イベントが生成された順序で届くことも保証していません。created の時刻で順序や処理済みかどうかを判断せず、イベントIDで重複を見分けるよう求めています。GitHubの手動再配信では X-GitHub-Delivery が元の配信と同じ値で届くので、これをキーにできます。
受信したイベントを保存する表に、送信元とイベントIDの組で主キーを張るのがいちばん確実です。PostgreSQL 18.6で同じイベントを2回入れた結果を載せます。
CREATE TABLE webhook_events (
provider text NOT NULL,
event_id text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz,
payload jsonb NOT NULL,
PRIMARY KEY (provider, event_id)
);
-- 受信時:既に同じIDがあれば何も挿入せず、0行が返る
INSERT INTO webhook_events (provider, event_id, payload)
VALUES ('stripe', 'evt_1Nxyz', '{"type":"invoice.paid"}')
ON CONFLICT (provider, event_id) DO NOTHING
RETURNING event_id;
INSERT INTO webhook_events (provider, event_id, payload)
VALUES ('stripe', 'evt_1Nxyz', '{"type":"invoice.paid"}')
ON CONFLICT (provider, event_id) DO NOTHING
RETURNING event_id;
CREATE TABLE
event_id
-----------
evt_1Nxyz
(1 row)
INSERT 0 1
event_id
----------
(0 rows)
INSERT 0 0
1回目は RETURNING でIDが返り、2回目は0行です。アプリ側は「1行返ったときだけキューに積む」と書けば、メモリ上の Set と違ってサーバーを複数台に増やしても重複を弾けます。処理が終わったら processed_at を埋め、埋まっていない行を定期的に拾い直す処理が、キュー投入前の障害への備えです。ただし、実行中の行や業務処理の直後に停止した行も再投入され得ます。ワーカーでは行ロック(SELECT … FOR UPDATE SKIP LOCKED など)で同時処理を防ぎ、同じDB内の業務更新と processed_at の更新を同一トランザクションで確定します。外部APIを呼ぶ処理には、相手側の冪等キーも必要です。冪等性の考え方そのものは冪等性とは?読み方・意味からAPI・IaCでの担保方法まで実装者向けに解説、キーの保存期間や再生の設計は冪等キーとは?Idempotency-Keyの発行・保存・再生の実装と二重課金の止め方を解説で詳しく扱っています。
順序の入れ替わりには、イベントの中身を「最新状態」として信じない設計で対処します。たとえば invoice.paid が invoice.created より先に届いても、イベントに含まれるIDでAPIから請求書の現在の状態を取り直せば、到着順に左右されません。
ローカル環境で受信をテストする手順|curlの署名再現とトンネル転送
openssl・curlで署名付きリクエストを再現し202・401・400を確かめる手順
外部サービスに登録する前に、署名の計算と重複排除だけなら手元で確かめられます。テスト用のターミナルでも受信サーバーと同じシークレットを export WEBHOOK_SECRET=… で設定してから、openssl で署名を作り、GitHubと同じヘッダーを付けて送ります。
BODY='{"action":"opened","number":1}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | sed 's/^.*= //')
curl -i -X POST http://localhost:3000/webhooks/github \
-H "Content-Type: application/json" \
-H "X-GitHub-Event: pull_request" \
-H "X-GitHub-Delivery: 11111111-2222" \
-H "X-Hub-Signature-256: sha256=$SIG" \
--data "$BODY"
1回目は HTTP/1.1 202 Accepted、同じ X-GitHub-Delivery で2回目を送ると HTTP/1.1 200 OK(本文は duplicate)が返ります。BODY だけを書き換えて署名を作り直さずに送れば401、署名は正しくてもJSONとして壊れたボディなら400、X-GitHub-Delivery が無ければ400になります。FastAPI版も同じcurlで確かめられ、変えるのは起動したサーバーに合わせたポート番号だけです。
ngrok・Stripe CLIで外部サービスの通知を手元のサーバーへ転送する方法
送信元のサービスから実際に届けるには、インターネットから到達できるHTTPSのURLが要ります。開発中は ngrok http 3000 のようなトンネルで一時的な公開URLを発行し、それを送信元に登録します。設定と認証のかけ方はngrokでローカルサーバーを外部公開する手順|認証設定とTraffic Policy、Cloudflareを使う場合はCloudflare Tunnelとは?料金は無料か・仕組み・安全性とcloudflaredの設定手順を参照してください。
Stripeは公開URLなしで試せるCLIを用意しています。stripe listen --forward-to localhost:4242/webhook でアカウントのイベントを手元へ転送し、出力される whsec_ で始まるシークレットが検証用です。別のターミナルで stripe trigger payment_intent.succeeded を実行すると、テストイベントを発生させられます。
Google Apps Script(GAS)でWebhookを受けるときの制約と回避策
サーバーを用意せずにWebhookを受けたいとき、GASのWebアプリとして doPost(e) を公開する方法がよく紹介されます。デプロイすると https://script.google.com/macros/s/…/exec 形式のURLが発行され、POSTされたボディの読み出し口は e.postData.contents です。GAS自体の特徴はGAS(Google Apps Script)とは?できること・制限・始め方を開発者目線で解説にまとめています。
ただし、署名付きWebhookの受け口としては使えません。公式ドキュメントが示す doPost(e) のイベントオブジェクトは queryString・parameter・pathInfo・postData などで構成され、リクエストヘッダーを読むフィールドがありません(Google「Web Apps」)。GitHub・Stripe・Slack・Shopifyはいずれも署名をヘッダーで送るため、検証の材料が手に入らないのです。さらに、ContentService で返した応答はセキュリティ上の理由から script.googleusercontent.com の使い捨てURLへリダイレクトされます(Google「Content Service」)。リダイレクトを追わない送信元や、3xxを失敗として扱うStandard Webhooks準拠の送信元では、受け取れていても失敗と記録されるおそれがあります。
// 署名検証ができないため、URLのクエリに載せた合言葉で最低限の照合をする例
function doPost(e) {
const expected = PropertiesService.getScriptProperties().getProperty("WEBHOOK_TOKEN");
if (!e.parameter.token || e.parameter.token !== expected) {
return ContentService.createTextOutput("forbidden");
}
const payload = JSON.parse(e.postData.contents);
SpreadsheetApp.getActive().getSheetByName("log").appendRow([new Date(), JSON.stringify(payload)]);
return ContentService.createTextOutput("ok");
}
このコードはGASでできる精いっぱいの照合ですが、HTTPステータスを401などに変えることはできず、拒否しても送信元からは成功に見えます。合言葉はURLに含まれるので、送信元の管理画面や中継サーバーのアクセスログに残ります。結論として、GASで受けてよいのは「社内の監視ツールからの通知をスプレッドシートに記録する」ような、偽の通知が届いても実害の小さい用途までです。決済・受注・顧客データに関わる通知は、Cloud RunやAWS Lambdaなどに前の章の受信コードを置き、検証と保存を済ませた後でGASやスプレッドシートへ渡す構成にします。
送る側のWebhookを作る手順|Standard Webhooksの署名・再送・宛先検証
自社サービスからWebhookを提供する場合、受信側より設計の判断が多くなります。署名の形式・再送の間隔・打ち切り条件・送信先URLの検証を、すべて自分で決める必要があるためです。ここでは、Webhook送信の共通仕様として公開されているStandard Webhooksに合わせて作ります。独自形式にしないことで、受信側は各言語の既製ライブラリで検証でき、提供側は署名の説明を書く手間が減ります。
Standard Webhooks形式の署名付与と10回・約75時間の再送スケジュール
Standard Webhooksでは、webhook-id(メッセージID)・webhook-timestamp(UNIX秒)・webhook-signature の3ヘッダーを送ります。署名は「ID.タイムスタンプ.ボディ」をつないだ文字列をHMAC-SHA256で計算し、v1, に続けてBase64で書きます。秘密鍵は24〜64バイトの乱数をBase64にして whsec_ を前に付けた形式です。
import crypto from "node:crypto";
// 受信者ごとに発行する秘密鍵(Standard Webhooksの形式:whsec_ + base64)
const secret = process.env.ENDPOINT_SECRET;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
function sign(msgId, timestamp, body) {
const mac = crypto.createHmac("sha256", key).update(`${msgId}.${timestamp}.${body}`).digest("base64");
return `v1,${mac}`;
}
// 再送の間隔(Standard Webhooksの例示スケジュール。合計約75時間)
const S = 1_000, M = 60 * S, H = 60 * M;
const RETRY_DELAYS = [0, 5 * S, 5 * M, 30 * M, 2 * H, 5 * H, 10 * H, 14 * H, 20 * H, 24 * H];
const jitter = (ms) => ms + Math.floor(Math.random() * ms * 0.1); // 再送の集中を避ける
export async function deliver(url, event) {
const msgId = `msg_${crypto.randomUUID()}`; // 再送しても変えない
const body = JSON.stringify(event);
// 説明のため1プロセスで待っている。本番は遅延キューに次回時刻を積む
for (const [attempt, delay] of RETRY_DELAYS.entries()) {
await new Promise((r) => setTimeout(r, jitter(delay)));
const timestamp = Math.floor(Date.now() / 1000); // 送るたびに付け直す
try {
const res = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
"webhook-id": msgId,
"webhook-timestamp": String(timestamp),
"webhook-signature": sign(msgId, timestamp, body),
},
body,
redirect: "manual", // リダイレクト先へは送らない
signal: AbortSignal.timeout(15_000),
});
if (res.ok) return { ok: true, attempt };
if (res.status === 410) return { ok: false, attempt, disable: true }; // 受信停止の意思表示
// 3xx・4xx・5xxは失敗として次の再送へ進む
} catch (err) {
// タイムアウト・接続失敗も再送対象
}
}
return { ok: false, attempt: RETRY_DELAYS.length - 1 };
}
このコードで署名したリクエストを、Standard Webhooksの公式npmパッケージ standardwebhooks 1.1.1 の Webhook.verify() で検証したところ、そのまま通りました。受信側に1回目だけ503を返させると、2回目の送信(attempt: 1)で成功しています。
実装上の判断は3点です。メッセージIDは再送しても変えません(受信側がこれで重複を弾くため)。タイムスタンプと署名は送るたびに作り直します。リダイレクトは追わずに失敗扱いとし、これは仕様が3xxを失敗と見なしてURLの更新を利用者に求めていることに合わせた判断です。再送の間隔は仕様の例示スケジュール(直後・5秒・5分・30分・2時間・5時間・10時間・14時間・20時間・24時間)をそのまま使っており、合計で約75時間です。リクエストのタイムアウトは、仕様が推奨する15〜30秒の下限に合わせました。このサンプルは retry-after ヘッダーを読んでいません。仕様は503などに付くこの値を次回送信時刻に反映するよう求めているので、本番では次回時刻の計算に取り込み、429などを返す宛先への送信量も抑えます。
コードは説明のため1プロセスの中で待っていますが、数時間単位の待ちをメモリ上で持つと、デプロイや再起動で再送が消えます。本番では「次に送る時刻」を配信ログの表かキューの遅延メッセージに持たせ、ワーカーが時刻の来たものだけを拾う形が基本です。配信ログには試行ごとのステータスコードと応答時間を残し、利用者が管理画面から失敗した配信を確認・再送できるようにしておくと、問い合わせ対応が減ります。
410 Goneを受けたときの配信停止と長期失敗エンドポイントの打ち切り条件
受信側が 410 Gone を返したら、それは「もう受け取らない」という意思表示です。仕様は送信側がそのエンドポイントを無効化して送信をやめるよう求めているので、上のコードでも disable: true を返して打ち切っています。長期間失敗が続くエンドポイントも、メールなど別の経路で利用者に知らせたうえで配信を止めるよう推奨されています。
登録URLの宛先検証とSSRF対策|net.BlockListとプロキシ遮断の二重化
送信側にとって最大のリスクは、利用者が入力したURLへ自社のサーバーがリクエストを送ることです。http://169.254.169.254/(クラウドのメタデータ)や社内のAPIのアドレスを登録されると、外部からは届かないはずの場所へ自社サーバー経由で到達されます。Standard Webhooksも、Webhookは利用者が任意のURLを登録できるためSSRFに特に弱いと注意しています。攻撃の仕組みはSSRFとは?仕組み・クラウドメタデータ悪用と防御実装を解説が詳しいです。
最低限の防御として、名前解決した結果がループバック・プライベート・リンクローカルのアドレスなら送らないチェックを入れます。
import dns from "node:dns/promises";
import net from "node:net";
// 送信先に使わせないアドレス帯(ループバック・プライベート・リンクローカル=クラウドのメタデータ等)
const blocked = new net.BlockList();
for (const [addr, prefix] of [["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
["169.254.0.0", 16], ["172.16.0.0", 12], ["192.168.0.0", 16]]) blocked.addSubnet(addr, prefix, "ipv4");
for (const [addr, prefix] of [["::", 128], ["::1", 128], ["fc00::", 7], ["fe80::", 10]]) blocked.addSubnet(addr, prefix, "ipv6");
export async function assertDeliverable(rawUrl) {
const url = new URL(rawUrl);
if (url.protocol !== "https:") throw new Error("https only");
const host = url.hostname.replace(/^\[|\]$/g, ""); // IPv6リテラルの角括弧を外す
const addrs = await dns.lookup(host, { all: true });
for (const { address, family } of addrs) {
if (blocked.check(address, family === 6 ? "ipv6" : "ipv4")) throw new Error(`blocked address ${address}`);
}
return addrs;
}
9つのURLで試した結果、https://example.com/hook だけが通り、http:・127.0.0.1・169.254.169.254・localhost(::1 に解決)・[::1]・10.1.2.3・IPv4射影アドレスの [::ffff:127.0.0.1]・未指定アドレスの [::] はすべて拒否されました。
ここで1つ落とし穴がありました。IPv4射影アドレスも塞ごうとしてIPv6のリストに ::ffff:0:0/96 を足すと、example.com まで拒否されました。Node.jsの net.BlockList はIPv4とIPv4射影アドレスを相互に対応付けて判定するため、この範囲を足すとすべてのIPv4アドレスが該当してしまいます。射影アドレスはIPv4側の範囲だけで拒否できるので、追加は不要です。
ただし、このチェックだけでは十分ではありません。確認した時点と実際に送る時点で名前解決の結果を変える攻撃(DNSリバインディング)には効かないためです。Standard Webhooksは、内部IPアドレスを遮断するプロキシ(Stripeが公開している smokescreen など)を必ず経由させ、配信ワーカーを内部サービスに届かない専用のサブネットに置く二重の対策を勧めています。多くの利用者にWebhookを提供するなら、アプリ内のチェックは入力時のエラー表示用と割り切り、ネットワーク側で遮断するのが本筋です。
配信基盤の設計からSSRF対策、既存システムへのWebhook受信の組み込みまでまとめて任せたい場合は、一創のAPI開発・システム連携で対応しています。
Webhookを使わないほうがよい場面とポーリング・イベント基盤との使い分け
Webhookは「相手がこちらのURLへ届けてくれる」前提の仕組みです。次の条件に当てはまるなら、Webhookを主にせず別の方式を選ぶか組み合わせます。
- 受信側がインターネットに公開URLを出せない:社内ネットワークの基幹システムなどです。Stripeのように、イベントをAmazon EventBridgeやAzure Event Gridへ直接送れるサービスなら、クラウドのイベント基盤経由で取り込むほうが穴を開けずに済みます。
- 1件の取りこぼしも許されず、送信元が自動再送しない:GitHubがこれに当たります。Webhookは即時性のための通知と位置づけ、APIでの定期的な突き合わせ(ポーリング)を正として組み合わせます。
- 処理の順序が業務上重要:到着順は保証されないので、Webhookは「変化があった」という合図だけに使い、最新の状態は毎回APIから取得します。
- 署名検証を書けない実行環境しか用意できない:前の章のGASが典型です。検証できない受け口を業務データの更新に直結させるくらいなら、APIを定期的に読みに行くほうが安全です。
イベント基盤経由で受ける場合の構成と料金はAmazon EventBridgeとは?仕組み・料金とCLI・Pythonでの実装手順を解説で扱っています。公開URLを持たずに済むぶん、署名検証やSSRFの心配はIAMの権限設計に置き換わります。
逆に、即時性が必要でイベントの数が多く、送信元に再送の仕組みがある場合はWebhookが向いています。決済の確定通知がその典型で、実装例は決済Webhookとは?仕組み・主要イベントと署名検証・冪等な注文確定の実装を開発視点で解説にまとめています。
よくある質問
Webhookの作り方について、受信側・送信側の実装で問い合わせの多い5つの疑問に答えます。
WebhookのURLはどうやって取得しますか?
立場によって逆になります。他社サービスから通知を受け取る場合は、自分で受信エンドポイントを作り、そのHTTPSのURLを相手サービスの管理画面かAPIに登録する流れです。SlackやDiscordへ投稿する場合は、相手サービスがURLを発行するので、それを自分のプログラムの送信先に設定します。
Webhookの作り方で最低限必要なものは何ですか?
受信側なら、インターネットから到達できるHTTPSのURL、POSTを受け取って生のボディとヘッダーを読めるWebサーバー、送信元と共有するシークレットの3つです。サーバーの言語やフレームワークは問いません。Express・FastAPI・Spring Boot・Djangoのどれでも、ボディを変換前のバイト列で受け取れれば同じ手順で実装できます。ヘッダーを読めないGASのWebアプリは、この条件を満たしません。
署名の検証がどうしても一致しないときは何を確認しますか?
まず次の3点を確認してください。JSONに変換した後のボディで計算している、エンドポイントごとに違うシークレットを取り違えている(Stripeはテスト環境と本番環境でも別)、16進とBase64を取り違えている(GitHubは16進、ShopifyとStandard WebhooksはBase64)。GitHubが公開しているテスト用の値で自分の計算結果を照合すると、どこで間違えているかを切り分けられます。
webhook.siteのようなテスト用サービスは使ってよいですか?
送信元がどんなヘッダーとボディを送ってくるかを確かめる用途なら便利です。ただし届いた内容は外部サービスに保存されるため、本番のシークレットで署名された通知や個人情報を含むイベントは送らないでください。署名検証と重複排除の動作は、この記事のcurlの手順のように手元で確かめるほうが確実です。
Webhookのシークレットを入れ替えるときに検証エラーを出さない方法はありますか?
送信元が新旧の署名を併送できる場合は、その期間内に受信側の検証鍵を更新すれば、鍵の切り替えによる署名検証エラーを避けられます。Stripeの場合、署名シークレットの更新時に旧シークレットの有効期限を最大24時間延ばせる仕様です。その間はシークレットごとに1つずつ署名が生成されるので、受信側はどちらかが一致すれば受け付けます。Standard Webhooksも webhook-signature を空白区切りのリストにして、鍵の入れ替え中に複数の署名を送れるようにしています。受信側の検証コードは、最初から「複数の署名のどれかが一致すれば通す」形で書いておくと移行が楽です。
関連記事
- Webhookとは?読み方・仕組み・APIとの違いを実際のHTTPリクエストで解説:作る前に仕組みとAPIとの違いを押さえる入門
- Incoming Webhookとは?仕組みとSlack・Discordでの使い方を解説:発行されたURLへ送るだけの場合の手順
- 決済Webhookとは?仕組み・主要イベントと署名検証・冪等な注文確定の実装を開発視点で解説:決済通知に絞った受信実装
- SendGrid API Event Webhookの実装手順|署名検証と再送・障害設計:メール配信イベントを受ける具体例
- 冪等キーとは?Idempotency-Keyの発行・保存・再生の実装と二重課金の止め方を解説:重複排除の設計を深掘り