Stripe の Webhook を実装するときに最初にぶつかる壁は、ローカルで起こした失敗をどうやって本番と同じ条件で再現するかという点です。stripe listen で localhost に転送するところまでは公式ドキュメントが教えてくれますが、署名検証の落とし穴・冪等性の検証・本物のイベントの再生まで一気通貫で扱った日本語記事は意外と少ないのが現状ではないでしょうか。
私自身、4 サイトで Stripe Checkout を運用していて、リファクタの度に「あれ、このパスでイベント取りこぼしてないか?」と冷や汗をかいた経験があります。ここではClaude Code を組み合わせて Webhook ハンドラを安全にテストする 3 つの実用パターンを、実際に動くコードと一緒にまとめます。
パターン1: Stripe CLI を stripe listen でローカルに転送する
公式の標準パターンですが、まずは私が実際に踏んだ失敗から見ていきます。
# 失敗しがちな例(署名検証で 400 を返す)
stripe listen --forward-to localhost:3000/api/webhookこれだけだと一見動いているように見えますが、ハンドラ側で署名を process.env.STRIPE_WEBHOOK_SECRET から読んでいる場合、本番用シークレットと stripe listen が発行する一時シークレット(同じ whsec_ 始まりでも別物)が一致せず、署名検証で必ず 400 を返してしまいます。
正しくは stripe listen の出力する一時シークレットを .env.local に書き換えて開発サーバーを起動します。
# ✅ 正しい例
stripe listen --print-secret > .stripe-secret.txt
# 出力された whsec_... を .env.local の STRIPE_WEBHOOK_SECRET にセットしてから
# 別ターミナルで stripe listen --forward-to を起動する
stripe listen --forward-to localhost:3000/api/webhookClaude Code に stripe listen の起動と環境変数の書き換えをまとめてやらせる場合、私は次のような短いプロンプトを使っています。
.env.local の STRIPE_WEBHOOK_SECRET を `stripe listen --print-secret` の出力で
書き換えて、開発サーバーを起動してください。終了時にシークレットを元に戻すこと。
この「終了時に元に戻すこと」を入れておくと、git の差分にローカル用シークレットが混ざる事故を防げます。
パターン2: 本物のペイロードをファイル化して再生する
stripe events resend <event_id> は本番の過去イベントを再送できる便利なコマンドです。ただし「同じイベントを 100 回連投したい」「ネット環境を切ってから再生したい」「CI 上で実行したい」場合には向きません。
そんなときは Stripe Dashboard から失敗したイベントの JSON をダウンロードし、ローカルから自前の署名付きで再生する方法が便利です。
// scripts/replay-webhook.ts
//
// 何を解決するコードか:
// 保存した本物の Webhook ペイロードを、ローカルの開発サーバーへ
// 正しい署名付きで再送する。Stripe CLI が起動していなくても動く。
import fs from "node:fs";
import crypto from "node:crypto";
async function main() {
const payload = fs.readFileSync(
"./fixtures/checkout-completed.json",
"utf8",
);
const secret = process.env.STRIPE_WEBHOOK_SECRET;
if (!secret) throw new Error("STRIPE_WEBHOOK_SECRET is required");
const timestamp = Math.floor(Date.now() / 1000);
const signedPayload = `${timestamp}.${payload}`;
const signature = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
const sigHeader = `t=${timestamp},v1=${signature}`;
const res = await fetch("http://localhost:3000/api/webhook", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Stripe-Signature": sigHeader,
},
body: payload,
});
console.log(res.status, await res.text());
}
main().catch((err) => {
console.error(err);
process.exit(1);
});期待する出力:
200 {"received":true}
なぜ自前で署名するかというと、Stripe CLI に依存せず固定の secret で署名できるので、CI 環境でもオフラインでも同じスクリプトが動く点が効きます。Claude Code に「失敗時のレスポンスをログに残す形で書き換えてください」と頼めば、ハンドラ側の問題を切り分けるための再現スクリプトとしてリポジトリに残ります。
ペイロードは fixtures/ に種類別で置いておくと、replay-webhook.ts checkout-completed のように引数で切り替えられます。実プロジェクトでは payment_intent.succeeded customer.subscription.updated checkout.session.completed の 3 つを揃えておくと困らないと感じています。
パターン3: 冪等性とリトライをまとめてテストする
Stripe は同じ event.id を最大 3 日間で複数回送ってくる仕様です。本番で痛い目に遭わないために、冪等性(Idempotency)はローカルで先に検証しておきたい挙動です。
私が使っている確認手順は次の通りです。
- パターン2 のスクリプトを 5 連続で実行する
- ハンドラがデータベース(または KV)に重複レコードを作っていないか確認する
- 1 回目で意図的に失敗させ(例: 通知メール送信失敗)、2 回目で成功する経路をシミュレートする
具体的にはこんな下書きをハンドラに入れています。
// app/api/webhook/route.ts (抜粋)
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const payload = await req.text();
const sig = req.headers.get("stripe-signature")!;
// Cloudflare Workers では constructEventAsync が必須
const event = await stripe.webhooks.constructEventAsync(
payload,
sig,
process.env.STRIPE_WEBHOOK_SECRET!,
undefined,
Stripe.createSubtleCryptoProvider(),
);
// なぜ最初に重複チェックを入れるか:
// メール送信や課金確定のような副作用を、同じ event.id で 2 回起こさないため。
const dupKey = `webhook:${event.id}`;
const seen = await env.KV.get(dupKey);
if (seen) {
return Response.json({ received: true, duplicate: true });
}
await env.KV.put(dupKey, "processed", { expirationTtl: 60 * 60 * 24 * 7 });
// 以降、event.type ごとに処理を分岐
// ...
return Response.json({ received: true });
}Claude Code に「このルートに冪等性チェックを入れて、テストでは同じ event.id で 5 回叩いて 2 回目以降が duplicate=true になることを確認してください」と頼むと、テストコードまで含めて一気に書いてくれるので、自分で検証手順を書くより速く確実に終わります。
「動いた」あとに必ず確認しておくべきこと
ローカルで通ってから本番投入するまでの間に、私は以下を毎回チェックしています。デプロイ後に気づくと夜中の対応コースになりがちな項目ばかりです。
- 本番用の
STRIPE_WEBHOOK_SECRETが.env.localの値と混ざっていないか - 失敗時のレスポンスが 4xx/5xx を正しく返しているか(無条件 200 を返すと Stripe が再送をやめてしまい、取りこぼしに気づけない)
- KV / DB の重複防止 TTL が、Stripe の最大再送期間(3 日)より長く設定されているか
- ハンドラの実行時間が Cloudflare Workers の CPU 時間制限(無料プランで 10 ms、有料プランで 30 s)内に収まっているか
特に 2 つ目の「無条件 200」は、CI/CD で誤って try { ... } catch { return 200 } に書き換わるとサイレント障害になるので、grep -r "return new Response" src/app/api/webhook をコミットフックに入れておくくらいでちょうど良いと感じています。
次にやってみてほしいこと
ローカルで Webhook を安心して触れる環境ができてしまえば、新しい決済フローの追加は急に怖くなくなります。まずは本記事のパターン2 で示した replay-webhook.ts を 1 ファイル作って、過去に失敗した Webhook を 1 件だけ再生してみてください。「触りたくない感」がそれだけで大きく減るはずです。
Stripe 周りの設計をより深く学びたい場合は、関連記事の Next.js + Stripe + VPS で小さな EC ショップを Claude Code と作るガイド や Claude API で Webhook の非同期エラーリカバリを設計する も合わせて読んでみてください。