Stripe の決済が完了したのに、自分のサーバーでは記録が残っていありません。原因を追ったら Webhook の応答が 500 を返していて、Stripe 側で自動再送中だった——個人開発で SaaS を運用していると、こういう「届いているのに処理されない」状況に必ず一度はぶつかります。Webhook は受信側の小さな実装ミスが、決済の取りこぼしや状態の不整合という形で後から効いてくる、地味に怖い領域です。
ここではAntigravity の AI エージェントを実装の伴走者として使いながら、Stripe や GitHub からの Webhook を取りこぼさずに処理するエンドポイントを作る実践パターンをまとめました。フレームワークは Next.js(App Router)+ Cloudflare Workers を例にしますが、考え方は他のスタックでもそのまま使えます。
Antigravity でWebhook開発を進めると何が違うのか
Webhook の実装は、ローカルで動かしにくいことが最大のハードルです。Stripe CLI で擬似イベントを送れますが、それでも署名検証、冪等性、リトライ動作の3つを同時に検証するのは骨が折れます。私は当初 VS Code でこの作業をやっていましたが、Antigravity に乗り換えてから、エージェントに「Stripe の checkout.session.completed を受けて、KV に冪等キーを記録するエンドポイントを作って」と頼むと、署名検証のテストコードまで含めて出してくれるので、設計の精度が一段上がりました。
特に効いたのが、Manager Surface でエージェントの作業履歴を残しながら、複数の検証パターンを並行で試せることです。「冪等性チェックを Set にする vs KV にする」のような設計判断を、両方コードを生成させて比較できるのは、一人で開発しているとなかなかできない検証です。
Webhook 受信エンドポイントが満たすべき3つの要件
実装に入る前に、何を守れば「取りこぼさない」エンドポイントになるのか整理します。
ひとつ目は応答時間です。Stripe は受信側が 2xx を返すまで最大3日間リトライしますが、応答が30秒を超えるとタイムアウト扱いになります。重い処理をエンドポイント内でやってはいけません。
ふたつ目は署名検証です。リクエストヘッダーに含まれる署名を検証しないと、誰でも偽のイベントを送り込めてしまいます。これは認可の根幹なので省略不可です。
みっつ目は冪等性です。Stripe・GitHub・Slack いずれも、ネットワーク事情で同じイベントを2回送ることがあります。同じイベントを2回処理しても結果が変わらない設計が必須になります。
Stripe Webhook の最小実装(Next.js + Cloudflare Workers)
ここから実装に入ります。Next.js の Route Handler として書きますが、Cloudflare Workers 上で動く前提なので Stripe.createSubtleCryptoProvider() を使う点に注意してください。
// src/app/api/webhook/stripe/route.ts
import Stripe from "stripe";
import { getCloudflareContext } from "@opennextjs/cloudflare";
export const runtime = "edge";
export async function POST(request: Request) {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-10-28.acacia",
});
// 重要: Cloudflare Workers では非同期版を使う
const signature = request.headers.get("stripe-signature");
if (!signature) {
return new Response("Missing signature", { status: 400 });
}
// 生のリクエストボディを取得(パース前)
const body = await request.text();
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
undefined,
Stripe.createSubtleCryptoProvider()
);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return new Response("Invalid signature", { status: 400 });
}
// 冪等性チェック(後述)
const { env } = getCloudflareContext();
const idempotencyKey = `webhook:stripe:${event.id}`;
const existing = await env.KV.get(idempotencyKey);
if (existing) {
// すでに処理済み — 200 で応答してリトライを止める
return new Response("Already processed", { status: 200 });
}
// 実処理は別関数に切り出す
await handleEvent(event, env);
// 処理完了マーカー(24時間保持)
await env.KV.put(idempotencyKey, "1", { expirationTtl: 86400 });
return new Response("OK", { status: 200 });
}このコードのポイントは、stripe.webhooks.constructEventAsync を Subtle Crypto Provider 経由で呼んでいる箇所です。Node.js 環境でよく見る constructEvent(同期版)は Cloudflare Workers では動きません。私はこれで2時間溶かしました。
署名検証で詰まりやすい3つの落とし穴
実装中によくぶつかる問題と、その回避策を挙げておきます。
ひとつ目は、リクエストボディを JSON.parse してから署名検証してしまうケースです。署名は生のバイト列に対して計算されているので、一度パースすると検証が必ず失敗します。request.text() で生の文字列を受け取り、それをそのまま constructEventAsync の第一引数に渡してください。
ふたつ目は、Webhook シークレットを取り違えるケースです。Stripe ダッシュボードでは「ローカル開発用」と「本番用」のシークレットが別物として発行されます。Stripe CLI でリレーしているときは CLI が出力する whsec_... を使う必要があります。本番のシークレットをローカルテストに使うと、署名は一見通っているように見えてイベントが飛んでこない、という不可解な状態になります。
みっつ目は、ボディの文字エンコーディングです。Cloudflare Workers のリクエストは UTF-8 ですが、何らかの中間プロキシを噛ませていると BOM が混入することがあります。request.text() の戻り値の長さがダッシュボードのペイロード長と一致しているか、最初に確認するのが近道です。
冪等性をKVで保証する設計パターン
冪等性の実装は、Stripe や GitHub が払い出す event.id をキーにして「処理済みかどうか」を記録するのがシンプルで強固です。先ほどのコードで使った Cloudflare KV を例にすると、こうなります。
async function ensureIdempotent(
env: Env,
eventId: string,
source: "stripe" | "github" | "slack"
): Promise<{ alreadyProcessed: boolean }> {
const key = `webhook:${source}:${eventId}`;
const existing = await env.KV.get(key);
if (existing) {
return { alreadyProcessed: true };
}
// まずマーカーだけ書いて、処理失敗時にリトライできるようにする
// 実処理が成功したら status を "done" に更新
await env.KV.put(key, "processing", { expirationTtl: 600 });
return { alreadyProcessed: false };
}ここで意識したいのは、「処理中マーカー」と「処理完了マーカー」を分けることです。処理中マーカーだけだと、エンドポイント内でクラッシュした場合に永遠に「処理中」のまま残ってしまいます。処理完了時に上書きする運用にすれば、TTL の範囲内で次のリトライが拾い直してくれます。
長期保管が必要なら D1 や Postgres に切り替えますが、24時間以内に同じ event.id で重複が来るかどうかをチェックするだけなら KV で十分というのが個人的な感覚です。Cloudflare KV の結果整合性は、Webhook 用途では実害になりません。
Antigravity 上でのテストと検証ワークフロー
開発中のテストは、Stripe CLI のリレー機能と Antigravity のターミナル統合を組み合わせるのが一番楽です。
# ターミナル1: Next.js 開発サーバー
npm run dev
# ターミナル2: Stripe CLI で Webhook をローカルにリレー
stripe listen --forward-to localhost:3000/api/webhook/stripe
# ターミナル3: テストイベントを送る
stripe trigger checkout.session.completedAntigravity のターミナルは複数タブを横に並べられるので、3つのプロセスの出力を同時に追えます。エージェントに「いまの checkout.session.completed のレスポンスログから、署名検証が成功しているか確認して」と聞くと、ログを読んでくれて、失敗していれば原因の候補まで挙げてくれます。
GitHub Webhook も同じ要領で、gh webhook forward を使えば本番のリポジトリイベントをローカルに飛ばせます。Slack はローカル転送の公式ツールがないので、Cloudflare Tunnel か ngrok を経由させるのが現実的です。
全体を振り返って:明日のあなたが取りこぼさないために
Webhook の取りこぼしは、リリース直後ではなくユーザーが増えたあとに顕在化します。早めに「署名検証 → 冪等性記録 → 実処理 → 完了マーカー」という4段階を分離した設計に直しておくと、後から監視を足したりリトライ戦略を変えたりするときに楽です。
今日からできる一歩としては、いま動いている自分の Webhook エンドポイントを Antigravity で開いて、エージェントに「このエンドポイントが冪等性を満たしているか診断して」と聞いてみるのがおすすめです。10分で済む診断でも、本番で問題が起きる前に気づけることが多くあります。
決済まわりの設計をもっと深く
関連する記事として、AntigravityでのStripe決済フロー全体像 と Cloudflare KVを使ったエッジキャッシュとレート制限 も合わせて読むと、Webhook を含む決済基盤全体の設計が見えてきます。