Webhook受信で二重課金した僕が辿り着いた、署名検証・冪等性・リトライの実装
Webhook受信エンドポイントを本番で壊さないための実装メモ。raw bodyの扱い、HMAC署名検証、冪等性で重複配信を止める方法、リトライとタイムアウト、ngrokでのローカルデバッグを動くコードで。
ある朝、ユーザーから「決済したのに二回引き落とされてる」とメールが来ました。
ログを追うと、外部サービスが同じイベントを3回送ってきていた。こっちは律儀に3回とも処理して、メールも3通送り、課金フラグも3回立てていました。相手は何も悪くない。再送は仕様どおりの動きです。壊れていたのは、僕の受信側でした。
Webhookの受信は、POST /webhooks/foo を作ってJSONを読むだけなら15分で書けます。でも本番で使えるものは別物です。「本当にそのサービスから来たのか」を確かめる署名検証、受信した本文をバイト列のまま壊さず扱うraw body、同じ通知が何回来ても結果が一度きりになる冪等性(べきとうせい:何回やっても同じ結果になる性質)、失敗したら後で再実行する仕組み。このどれか一つでも抜けると、冒頭の僕みたいに二重課金で謝ることになります。
この記事は、その受信側だけに絞ります。Claude Codeに任せるときも指示が浅いと「動くが危ない」コードが出てくるので、何を要件に渡すか、そして実際にコピペで動くExpress + TypeScriptの受信コードまでをまとめました。
この記事の要点
- Webhook受信で本番が壊れる原因はほぼ3つ。署名検証の順番ミス・重複配信の無対策・失敗イベントの再実行不能。
- 署名は「受信した生の本文(raw body)」に対して作られる。
express.json()で先にparseすると、空白や改行が変わってHMACが一致しなくなる。raw bodyを先に確保し、検証してからparseする。 - 重複は止めるのではなく冪等性で吸収する。プロバイダーが再送時も維持するID(GitHubは
X-GitHub-Delivery、Stripeはevent.id)をキーにして、二度目以降は処理せず2xxを返す。 - 受信時は保存とキュー投入だけして、すぐ
2xxを返す。重い処理を同期でやるとプロバイダーがタイムアウトし、再送が雪だるま式に増える。 - ローカルは ngrok で公開URLを一時的に作り、プロバイダーの「テスト送信」を本物の経路で受ける。署名検証まで通るかをデプロイ前に確認できる。
受信エンドポイントの「契約」を先に固める
受信してから考えると、たいてい後手に回ります。コードを書く前に、相手が何を送ってくるかを表にしておく。これだけで設計の8割が決まります。
| 項目 | GitHubの例 | Stripeの例 | 受信側で見る場所 |
|---|---|---|---|
| エンドポイント | POST /webhooks/github | POST /webhooks/stripe | ルーティング |
| 配信ID | X-GitHub-Delivery | event.id | 冪等性キー |
| イベント種別 | X-GitHub-Event | event.type | ハンドラー分岐 |
| 署名ヘッダー | X-Hub-Signature-256 | Stripe-Signature | 署名検証 |
| 検証の対象 | raw body | raw body | body parserの設定 |
| 返すべき応答 | 早めに 2xx | 早めに 2xx | キュー投入後の応答 |
正確な仕様は必ず一次情報を見てください。GitHubはValidating webhook deliveries、StripeはWebhook signatures、ExpressでのバッファのままのbodyはApi Reference のexpress.rawが出発点です。
決済まわりの受信は注意点が多いので、Stripe確定の流れは成功画面で「購入済み」にして事故った僕のStripe決済実装メモに分けて書きました。受信したイベントをアプリ内部でどう流すか、pub/subやSagaの設計はイベント駆動アーキテクチャとは?pub/sub・Saga・冪等性を動くコードでが土台になります。
Claude Codeに渡す「失敗してはいけない条件」
Claude Codeは「Webhookを作って」だけだと、JSONを読むだけのエンドポイントを返してきます。賢いから手を抜いているわけではなく、こちらが失敗条件を伝えていないだけです。harness(エージェントの足場:AIに渡す前提・道具・ゴールの枠組み)として、依頼文に守るべき条件を最初から並べます。
Express + TypeScriptでGitHub Webhookの受信を実装してください。
守ること:
- POST /webhooks/github を追加する
- Webhookルートだけ express.raw({ type: "*/*" }) でraw bodyを保持する
- JSON.parse は署名検証の後にだけ行う
- X-Hub-Signature-256 を HMAC SHA-256 で検証する(timingSafeEqualを使う)
- X-GitHub-Delivery を冪等性キーにし、同じIDは二度処理しない
- 受信時は 202 をすぐ返し、重い処理はキューで後から実行する
- 署名失敗は 401、JSON不正は 400、受理済みは 202 を返す
- 成功・署名失敗・重複配信を node:test で検証する
- 秘密鍵は WEBHOOK_SECRET 環境変数から読む
ポイントは、コードの書き方ではなく「壊れてはいけない場所」を渡すことです。署名検証・raw body・冪等性・リトライをこちらが先に言語化しておくと、レビューで見る差分も「この4点が守られているか」に絞れます。考え方の出どころは、Anthropic公式のClaude Code best practicesにある「検証できる作業を渡す」「具体的なコンテキストを渡す」です。
コピペで動く受信エンドポイント
まず検証用のプロジェクトを作ります。
npm init -y
npm install express
npm install -D typescript tsx @types/node @types/express
src/server.ts を作ります。保存先は説明用に Map にしていますが、本番ではPostgreSQLやRedis、DynamoDBなどに置き換えてください。下のコードはそのまま起動して動きます。
import crypto from "node:crypto";
import express from "express";
type EventStatus = "queued" | "processing" | "processed" | "failed";
type WebhookEvent = {
id: string;
provider: "github";
type: string;
headers: Record<string, string>;
rawBody: Buffer;
payload: unknown;
receivedAt: string;
status: EventStatus;
attempts: number;
lastError?: string;
};
export const app = express();
export const eventStore = new Map<string, WebhookEvent>();
export const processedEvents = new Set<string>();
export const retryQueue: string[] = [];
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET ?? "dev-secret-change-me";
// Webhookルートだけ raw body を保持する。順番が肝心。
app.use("/webhooks", express.raw({ type: "*/*", limit: "1mb" }));
app.use(express.json());
function firstHeader(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
// 文字列長で早期returnしないよう、timingSafeEqual でタイミング攻撃を防ぐ
function safeCompare(leftValue: string, rightValue: string): boolean {
const left = Buffer.from(leftValue);
const right = Buffer.from(rightValue);
return left.length === right.length && crypto.timingSafeEqual(left, right);
}
// 受信した生の本文(raw body)に対して HMAC SHA-256 を計算する
export function signGitHubBody(
rawBody: Buffer | string,
secret = WEBHOOK_SECRET
): string {
return (
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex")
);
}
export function verifyGitHubSignature(
rawBody: Buffer,
signatureHeader: string | undefined,
secret = WEBHOOK_SECRET
): boolean {
if (!signatureHeader?.startsWith("sha256=")) return false;
return safeCompare(signGitHubBody(rawBody, secret), signatureHeader);
}
function headersForStorage(req: express.Request): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(req.headers)) {
if (typeof value === "string") result[key] = value;
}
return result;
}
app.post("/webhooks/github", (req, res) => {
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from("");
const signature = firstHeader(req.headers["x-hub-signature-256"]);
const deliveryId = firstHeader(req.headers["x-github-delivery"]);
const eventType = firstHeader(req.headers["x-github-event"]) ?? "unknown";
// 1) まず署名検証。ここを通るまで本文を信用しない
if (!verifyGitHubSignature(rawBody, signature)) {
return res.status(401).json({ error: "invalid_signature" });
}
if (!deliveryId) {
return res.status(400).json({ error: "missing_delivery_id" });
}
// 2) 冪等性チェック。再送されても二度は処理しない
const id = `github:${deliveryId}`;
if (processedEvents.has(id) || eventStore.has(id)) {
return res.status(202).json({ id, status: "duplicate" });
}
// 3) 検証を通ってから初めて JSON.parse する
let payload: unknown;
try {
payload = JSON.parse(rawBody.toString("utf8"));
} catch {
return res.status(400).json({ error: "invalid_json" });
}
eventStore.set(id, {
id,
provider: "github",
type: eventType,
headers: headersForStorage(req),
rawBody,
payload,
receivedAt: new Date().toISOString(),
status: "queued",
attempts: 0,
});
// 4) 重い処理はキューへ。受信応答は早く返す
retryQueue.push(id);
void processNextEvent();
return res.status(202).json({ id, status: "queued" });
});
export async function processNextEvent(): Promise<void> {
const id = retryQueue.shift();
if (!id) return;
const event = eventStore.get(id);
if (!event || event.status === "processed") return;
event.status = "processing";
event.attempts += 1;
try {
await handleWebhookEvent(event);
event.status = "processed";
processedEvents.add(id);
} catch (error) {
event.status = "failed";
event.lastError = error instanceof Error ? error.message : String(error);
// 指数バックオフで再試行(最大5回、最長30秒)
if (event.attempts < 5) {
const delayMs = Math.min(30_000, 1_000 * 2 ** event.attempts);
setTimeout(() => {
event.status = "queued";
retryQueue.push(id);
void processNextEvent();
}, delayMs);
}
}
}
async function handleWebhookEvent(event: WebhookEvent): Promise<void> {
if (event.type === "ping") {
console.log("GitHub ping received", event.id);
return;
}
if (event.type === "push") {
console.log("GitHub push received", event.id);
return;
}
console.log("Webhook ignored", event.provider, event.type);
}
if (process.env.NODE_ENV !== "test") {
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => {
console.log(`Webhook server listening on http://127.0.0.1:${port}`);
});
}
起動します。
WEBHOOK_SECRET=dev-secret-change-me npx tsx src/server.ts
Windows PowerShellなら環境変数の入れ方が変わります。
$env:WEBHOOK_SECRET="dev-secret-change-me"
npx tsx src/server.ts
このコードの読みどころは、エンドポイント内のコメント 1) から 4) の順番です。署名検証 → 冪等性 → parse → キュー投入。この並びが一つでも入れ替わると、後の章で挙げる落とし穴のどれかを踏みます。
raw bodyと署名検証:ここを最初に間違える
受信実装でいちばん多い事故は、express.json() を全ルートに先がけて適用してしまうことです。
署名は「相手が送ってきたバイト列そのもの」に対して計算されています。ところが express.json() を通すと、req.body はパース済みのオブジェクトに化けます。そこからこちらが JSON.stringify し直しても、空白の有無・改行・キーの順序・文字コードのどれかが元と変わり、HMACは一致しません。「署名検証が通らない、鍵は合っているのに」というハマりの大半はこれです。
対策はコードのこの2行に集約されます。
app.use("/webhooks", express.raw({ type: "*/*", limit: "1mb" }));
app.use(express.json());
/webhooks 以下だけ生のバッファで受け取り、検証を済ませてから自分で JSON.parse する。残りの通常APIは今までどおり express.json() でいい。順番が命なので、Claude Codeのレビュー依頼でも「署名検証がJSON parseより前にあるか」を最初に確認させます。
比較を一目で。
| やり方 | req.body の中身 | 署名検証 | 判定 |
|---|---|---|---|
全ルート express.json() | パース済みオブジェクト | 元のバイト列を復元できず不一致 | 落とし穴 |
Webhookだけ express.raw() | 生のBuffer | そのままHMAC計算で一致 | 正解 |
冪等性で重複配信を吸収する
プロバイダーの再送は異常ではなく、正常な機能です。GitHubもStripeも「2xxが返るまで再送する」と明記しています。つまり、こちらがタイムアウトしたり一瞬落ちたりすれば、必ず同じイベントが複数回届く。これを前提に作ります。
やってはいけないのは、受信のたびに crypto.randomUUID() で自分のIDを振ることです。それでは同じ配信かどうか判別できません。使うのは、プロバイダーが再送時も変えないID。GitHubなら X-GitHub-Delivery、Stripeなら event.id です。
コードでは、このIDを見て二度目以降を弾いています。
const id = `github:${deliveryId}`;
if (processedEvents.has(id) || eventStore.has(id)) {
return res.status(202).json({ id, status: "duplicate" });
}
ここで返すのは 4xx ではなく 202 です。重複は相手のミスではないので、「受け取った、もう処理済みだよ」と正常応答を返して再送を止めます。Map や Set は説明用なので、本番ではDBの一意制約(同じIDを二度INSERTできない仕組み)で守るのが堅いです。受信側で吸収しきれない複雑なワークフローは、キュー側でも冪等性とDLQ(処理に失敗し続けたジョブの退避先)を持たせます。その実装はジョブキューで二重課金を止める:リトライ・冪等性・DLQ実装にまとめました。
リトライとタイムアウト:受信は速く、処理は後で
冒頭の二重課金は、実は冪等性だけの問題ではありませんでした。受信エンドポイントの中で、メール送信やDB更新まで同期でやっていたんです。処理が3秒かかれば、プロバイダーのタイムアウト(多くは10秒前後)に近づき、たまにタイムアウトする。すると再送が来る。再送を冪等に弾けていなかったので、二重課金。完全な連鎖です。
直し方はシンプルで、受信時は保存とキュー投入で打ち切り、すぐ 202 を返す。重い処理は processNextEvent に逃がして非同期で回す。失敗したら指数バックオフで再試行します。
// 指数バックオフ:1秒 → 2秒 → 4秒 …(最長30秒、最大5回)
if (event.attempts < 5) {
const delayMs = Math.min(30_000, 1_000 * 2 ** event.attempts);
setTimeout(() => {
event.status = "queued";
retryQueue.push(id);
void processNextEvent();
}, delayMs);
}
そして、失敗イベントは「ログに出して終わり」にしない。raw body・主要ヘッダー・受信時刻・ステータス・試行回数・最後のエラーを保存しておくと、障害対応が「ログを眺める」から「対象イベントを再実行する」に変わります。リプレイ用に保存するファイルはこんな形です。bodyの空白や改行を1文字でも変えると署名検証が落ちるので、bodyは受信したまま保存します。
{
"url": "http://127.0.0.1:3000/webhooks/github",
"headers": {
"content-type": "application/json",
"x-github-event": "push",
"x-github-delivery": "replay-001",
"x-hub-signature-256": "sha256=actual-signature-for-this-body"
},
"body": "{\"ref\":\"refs/heads/main\"}"
}
ローカルでデバッグする:ngrokと自前送信スクリプト
Webhookは画面で確認できないので、ローカルでの試し方を最初に用意しておくと開発が一気に楽になります。やり方は2つ。
ひとつは自前の送信スクリプト。署名を自分で作って投げるので、外部サービスに登録しなくても署名検証・parse・キュー投入までを通せます。scripts/send-local-webhook.ts を作ります。
import crypto from "node:crypto";
const secret = process.env.WEBHOOK_SECRET ?? "dev-secret-change-me";
const url =
process.env.WEBHOOK_URL ?? "http://127.0.0.1:3000/webhooks/github";
const body = JSON.stringify({ ref: "refs/heads/main", after: "local-test" });
// サーバーと同じ計算式で署名を作る
const signature =
"sha256=" + crypto.createHmac("sha256", secret).update(body).digest("hex");
const response = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
"x-github-event": "push",
"x-github-delivery": `local-${Date.now()}`,
"x-hub-signature-256": signature,
},
body,
});
console.log(response.status, await response.text());
別ターミナルで実行して、202 が返ればOKです。
WEBHOOK_SECRET=dev-secret-change-me npx tsx scripts/send-local-webhook.ts
もうひとつは ngrok。GitHubやStripeの管理画面から本物のテスト送信を受けたいときは、ローカルのポートを一時的な公開URLに転送します。
ngrok http 3000
表示された https://xxxx.ngrok-free.app を、プロバイダー側のWebhook設定URLに https://xxxx.ngrok-free.app/webhooks/github の形で登録する。あとは管理画面の「Send test delivery」ボタンを押せば、署名つきの本物のリクエストがローカルに届きます。これでデプロイ前に、署名検証まで含めた経路全体を確認できます。
自前スクリプトは速い、ngrokは本物に近い。僕は「ロジックは自前スクリプト、最終確認はngrok」と使い分けています。
テストで署名・重複・失敗を固定する
受信ロジックは目視で確かめにくいので、署名成功・署名失敗・重複配信の3本だけは必ずテストにします。test/webhook.test.ts を作ります。
import assert from "node:assert/strict";
import { AddressInfo } from "node:net";
import { beforeEach, test } from "node:test";
import {
app,
eventStore,
processedEvents,
retryQueue,
signGitHubBody,
verifyGitHubSignature,
} from "../src/server";
const secret = "dev-secret-change-me";
beforeEach(() => {
eventStore.clear();
processedEvents.clear();
retryQueue.length = 0;
});
async function postWebhook(port: number, deliveryId: string, body: string) {
return fetch(`http://127.0.0.1:${port}/webhooks/github`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-github-event": "push",
"x-github-delivery": deliveryId,
"x-hub-signature-256": signGitHubBody(body, secret),
},
body,
});
}
test("正しい署名は受理され、重複しても1件しか保存されない", async (t) => {
const server = app.listen(0);
t.after(() => server.close());
const { port } = server.address() as AddressInfo;
const body = JSON.stringify({ ref: "refs/heads/main" });
const first = await postWebhook(port, "delivery-1", body);
assert.equal(first.status, 202);
assert.equal(eventStore.has("github:delivery-1"), true);
// 同じ delivery id を再送しても保存は増えない
const duplicate = await postWebhook(port, "delivery-1", body);
assert.equal(duplicate.status, 202);
assert.equal(eventStore.size, 1);
});
test("不正な署名は弾かれる", () => {
const body = Buffer.from(JSON.stringify({ ok: true }));
assert.equal(verifyGitHubSignature(body, "sha256=bad", secret), false);
});
実行します。
NODE_ENV=test npx tsx --test test/webhook.test.ts
このテストごとClaude Codeに渡すと、修正のたびに自分で再実行して、署名や重複の挙動が崩れていないかを確かめてくれます。検証できる信号を渡すほど、AIの差分は安定します。
よくある質問
Q. 署名検証が通りません。鍵は合っているのに何が原因?
ほぼ100%、parse順序です。express.json() を全ルートに先に適用していると、req.body がオブジェクトになって元のバイト列を復元できません。Webhookルートだけ express.raw() を先に当て、検証してから JSON.parse してください。
Q. 冪等性キーは自分でUUIDを振ってもいい?
だめです。受信ごとに新しいIDを作ると、同じ配信かどうか判別できません。プロバイダーが再送時も変えないID(GitHubは X-GitHub-Delivery、Stripeは event.id)を使ってください。
Q. 重複が来たとき、エラー(4xx)を返すべき?
返さないでください。重複は相手の正常な再送なので、202 など 2xx を返して「受け取った、処理済み」と伝え、再送を止めます。4xx を返すと相手がさらに再送してくることがあります。
Q. 受信エンドポイントで重い処理をしてはいけない理由は?
プロバイダーには受信タイムアウト(多くは10秒前後)があり、超えると失敗扱いで再送されます。受信は保存とキュー投入で打ち切り 2xx を即返し、メール送信やDB更新は非同期に逃がします。
Q. localhostにどうやってWebhookを届ける?
ngrok http 3000 で一時的な公開URLを作り、その https://.../webhooks/github をプロバイダーのWebhook設定に登録します。管理画面のテスト送信ボタンで、署名つきの本物のリクエストをローカルで受けられます。
実際に試した結果
冒頭の二重課金以来、僕はWebhook受信を「3つの門番」で考えるようになりました。署名検証・冪等性・受信応答の速さ。この順番でガードを置いてから、再送由来の事故はゼロになりました。
特に効いたのは、raw bodyを最初に固定してから署名検証と冪等性テストを先に書く流れです。「Webhookを実装して」とだけClaude Codeに頼むと出やすかった、JSON parserの順序ミス・重複配信の見落とし・失敗イベントを再実行できない設計を、最初のプロンプトとテストの段階でまとめて潰せました。受信エンドポイントは見た目が地味なぶん、壊れたときの被害が大きい。だからこそ、賢いコードより「転んでも二重課金しない受信」を先に作る。それが、謝罪メールを二度と書かないための近道でした。
受信したイベントをアプリ内部でどう疎結合に流すかはイベント駆動アーキテクチャの記事、Webhookやキュー、Secrets管理をチームでまとめて整えたいときの実装レビューや研修はTrainingから相談できます。テンプレートや教材はProductsにまとめています。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeのチーム利用でコストが読めない時に作る予算ログ
チーム導入前に、誰が何に使い、どの成果が出たかを見える化する予算ログの作り方。
コミット前の3分チェック: Claude Codeが触った範囲を確認してから確定する
Claude Codeが勝手に広げた変更を、コミット前に3分で見抜く確認手順。差分の範囲、検証ログ、ステージするファイルの絞り込みを順番に解説します。
Claude Codeをチーム導入する前に作る「リスク台帳」の中身
Claude Codeを個人実験で終わらせずチーム導入するための、権限・CI・公開の事故を防ぐリスク台帳の作り方を実例とコードで解説します。