取り組みの背景 — Stripe から届いた警告メール
ある朝、Stripe から「Webhook の配信に問題が発生しました」という通知メールが4サイト分届きました。すべてのエンドポイントが HTTP 500 を返し続けており、合計34回のリトライが失敗している状態です。
サブスクリプション課金を導入している以上、Webhook の障害は収益に直結します。放置すれば請求書の発行が最大3日遅延し、Stripe 側が自動的にエンドポイントへの送信を停止してしまいます。
環境構成
今回の障害が発生していた環境は以下の通りです。
- フレームワーク: Next.js 16(App Router)
- ホスティング: Cloudflare Workers(OpenNext アダプター経由)
- 決済: Stripe(Webhook + Checkout)
- ストレージ: Cloudflare KV(プレミアムアクセス管理)
- 言語: TypeScript
- 影響範囲: 4サイト(Claude Lab / Gemini Lab / Antigravity Lab / Rork Lab)
原因調査 — Claude in Chrome でコードを横断分析
まず、Claude in Chrome を使って4サイト全体の Webhook コードを横断的に調査しました。各リポジトリの src/app/api/webhook/route.ts を読み込み、パターンを比較します。
すぐに2つの問題が見つかりました。
問題1: Edge ランタイムの明示宣言
// ❌ 問題のあったコード
export const runtime = "edge";
export async function POST(request: NextRequest) {
// ...
}Webhook ルートには export const runtime = "edge" が宣言されていましたが、正常に動作していた Checkout ルートにはこの宣言がありませんでした。
Cloudflare Workers + OpenNext の環境では、すべてのルートがデフォルトで Workers(Edge)ランタイムで実行されます。明示的に runtime = "edge" を宣言すると、OpenNext がルートのバンドル方法を変更し、結果として Worker 全体がクラッシュしていました。
問題2: constructEvent(同期版)の使用
// ❌ 同期版 — Node.js の crypto モジュールに依存
event = stripe.webhooks.constructEvent(body, sig, secret);
// ✅ 非同期版 — Web Crypto API を使用(Edge 互換)
event = await stripe.webhooks.constructEventAsync(body, sig, secret);Stripe SDK の constructEvent() は Node.js の crypto.createHmac に依存する同期メソッドです。Edge ランタイムでは Web Crypto API しか利用できないため、constructEventAsync() を使う必要があります。
問題3: エラーハンドリングの不備
KV(Key-Value ストレージ)への読み書き操作が try-catch で囲まれておらず、KV 側でエラーが発生すると未処理例外として HTTP 500 が返っていました。
修正内容
以下の3点を全4サイトに適用しました。
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
interface KVNamespace {
get(key: string): Promise<string | null>;
put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;
delete(key: string): Promise<void>;
}
// ✅ runtime = "edge" を削除(Cloudflare Workers ではデフォルトで Edge)
export async function POST(request: NextRequest) {
try {
const body = await request.text();
const sig = request.headers.get("stripe-signature");
if (!sig || !process.env.STRIPE_WEBHOOK_SECRET) {
return NextResponse.json(
{ error: "Missing signature or secret" },
{ status: 400 }
);
}
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2026-02-25.clover",
httpClient: Stripe.createFetchHttpClient(),
});
let event: Stripe.Event;
try {
// ✅ constructEventAsync を使用(Web Crypto API)
event = await stripe.webhooks.constructEventAsync(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch {
return NextResponse.json(
{ error: "Webhook signature invalid" },
{ status: 400 }
);
}
// KV 接続
let kv: KVNamespace | null = null;
try {
kv = (process.env as unknown as { PREMIUM_ACCESS: KVNamespace })
.PREMIUM_ACCESS;
} catch {
// KV not available
}
if (!kv) {
return NextResponse.json({ received: true, note: "KV not available" });
}
// ✅ KV 操作を try-catch で保護
try {
switch (event.type) {
case "customer.subscription.updated": {
const sub = event.data.object as Stripe.Subscription;
const email = (sub as unknown as { customer_email?: string })
.customer_email;
if (email) {
const kvKey = `site:claudelab:email:${email}`;
const existing = await kv.get(kvKey);
if (existing) {
const record = JSON.parse(existing);
record.expires_at = new Date(
Date.now() + 31 * 24 * 3600 * 1000
).toISOString();
await kv.put(kvKey, JSON.stringify(record), {
expirationTtl: 31 * 24 * 3600,
});
}
}
break;
}
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
const email = (sub as unknown as { customer_email?: string })
.customer_email;
if (email) {
await kv.delete(`site:claudelab:email:${email}`);
}
break;
}
}
} catch {
return NextResponse.json({
received: true,
note: "KV operation error",
});
}
return NextResponse.json({ received: true });
} catch {
// トップレベルの catch で 500 を防止
return NextResponse.json({ received: true, note: "Internal error" });
}
}修正のポイント:
export const runtime = "edge"を削除 — OpenNext のデフォルト動作に任せるconstructEvent→constructEventAsync— Web Crypto API 対応- KV 操作を
try-catchで保護 — KV エラー時も Webhook を受信済みとして応答 - トップレベル
try-catchを追加 — 予期しないエラーでも HTTP 200 を返す
Claude in Chrome でのライブ検証
コードを修正して全4サイトに push した後、Claude in Chrome でリアルタイムに動作確認を行いました。
各サイトのページ上から JavaScript の fetch で Webhook エンドポイントに POST リクエストを送信し、レスポンスを確認します。
// Claude in Chrome がブラウザ上で実行した検証コード
const res = await fetch('/api/webhook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
});
console.log(res.status, await res.text());
// 修正前: 500 "Internal Server Error"
// 修正後: 400 {"error":"Missing signature or secret"}全4サイトで HTTP 400(署名なしリクエストの正常な拒否)を確認でき、Checkout ボタンのクリックテストでも Stripe Checkout ページへの正常なリダイレクトを確認しました。
Cloudflare Workers + Next.js で Stripe Webhook を実装する際の注意点
今回の障害から得られた教訓をまとめます。
export const runtime = "edge" は慎重に
Cloudflare Workers(OpenNext)環境では、すべてのルートがデフォルトで Edge ランタイムで実行されます。export const runtime = "edge" を明示すると、バンドル方法が変わり予期しないクラッシュが発生する場合があります。他の API ルート(Checkout など)と揃えるのが安全です。
Stripe SDK は constructEventAsync を使う
Edge ランタイム(Cloudflare Workers, Vercel Edge Functions など)では、Node.js の crypto モジュールが使えません。Stripe SDK v10 以降で利用可能な constructEventAsync を使いましょう。
Webhook ハンドラーは「必ず 200 を返す」設計にする
Webhook ハンドラーが 500 を返し続けると、Stripe はリトライを繰り返し、最終的にエンドポイントを無効化します。ビジネスロジックでエラーが起きても、トップレベルの try-catch で HTTP 200 を返す設計にしましょう。内部エラーのログは別途モニタリングツールで収集します。
まとめ
Stripe Webhook の HTTP 500 エラーは、Cloudflare Workers 環境固有の問題(Edge ランタイムの宣言の競合と、同期版 API の使用)が原因でしました。Claude in Chrome を使えば、コードの調査・修正・デプロイ・検証のすべてを一連の作業として効率的に完了できました。
同じ技術スタック(Next.js + Cloudflare Workers + Stripe)でサービスを運営されている方の同じ課題に取り組んでいる方の参考になれば幸いです。