Build This Now
Build This Now
クロード・コードとは何か?Claude Code のインストールClaude Code ネイティブインストーラーClaude Code で最初のプロジェクトを作る
Subscription BillingMulti-Tenant SaaSAI Chat FeatureRate LimitingStripe WebhooksSemantic SearchRealtime UpdatesDrip Email SequencesClaude Code v2.1.122 Release NotesClaude Code の Dynamic Workflows:実際のコードベースで 1,000 個の subagents を動かす方法Claude Code ベストプラクティスClaude Opus 4.7 ベストプラクティスVPS上でのClaude CodeGit 統合Claude Code レビューClaude Code WorktreesClaude CodeリモートコントロールClaude Code ChannelsChannels、Routines、Teleport、DispatchClaude Code スケジュールタスクClaude Code権限管理Claude Code オートモードClaude Code で Stripe 決済を組み込むフィードバックループTodoワークフローClaude Code タスク管理プロジェクトテンプレートClaude Code の料金とトークン使用量Claude Code の料金:実際にいくら払うことになるのかClaude Code Ultra Review 完全ガイドClaude Code で Next.js アプリを作るClaude Code With Supabase: Database, Auth, RLSVercel deepsec with Claude CodeTest-Driven Development with Claude CodeClaude Code で SaaS の MVP を作る方法Claude Code で認証を追加する(Supabase Auth)Claude Code でトランザクションメールを追加する(Resend + React Email)Claude Code で型安全な API を作る(oRPC + Zod)How to Add File Uploads With Claude Code (Supabase Storage)How to Run Background Jobs with Claude Code and InngestHow to Build an Admin Dashboard With Claude CodeHow to Add Full-Text Search With Claude Code (Postgres)エージェント型コマース:AI エージェントが支払えるアプリの作り方Claude Code 1M Context in Practice: When Bigger Isn't BetterClaude Code GitHub Actions Setup Guide (@claude + Cron)Headless ModeMax Plan vs APIPrompt Caching2026年、Claude Code で SaaS を作るといくらかかるかRun a Team of AI Agents in Parallel with Git WorktreesPrompt Injection in Coding Agents: How to Not Get Pwned
speedy_devvkoen_salo
Blog/Handbook/Workflow/Stripe Webhooks

Stripe Webhooks with Claude Code

Build a signature-verified Stripe webhook handler that survives retries. Idempotency, event dedupe, and processing offloaded to Inngest so a slow handler never drops a payment event.

設定をやめて、構築を始めよう。

AIオーケストレーション付きSaaSビルダーテンプレート。

企業向けに構築している実績を見る →
speedy_devvWritten by speedy_devvPublished Jul 14, 20269 min readHandbook hubWorkflow index

Problem: A Stripe webhook that works in local testing quietly drops payment events in production. Stripe retries on timeout, so a slow handler double-fulfills orders. An unverified endpoint accepts forged events. And one exception mid-handler loses the event for good.

Quick Win: Verify the signature, dedupe on the event id, and hand the actual work to Inngest so the route returns 200 in milliseconds. Claude Code writes the whole thing correctly if you brief it on the three failure modes up front.


設定をやめて、構築を始めよう。

AIオーケストレーション付きSaaSビルダーテンプレート。

企業向けに構築している実績を見る →

Why Stripe Webhooks Break in Production

Three things go wrong, and they rarely show up until real money moves.

At-least-once delivery. Stripe promises to deliver each event at least once, not exactly once. If your endpoint is slow, times out, or returns anything other than a 2xx status, Stripe retries. The same checkout.session.completed event can arrive two, three, or ten times. If your handler fulfills an order on every call, your customer gets charged once and provisioned three times.

Inline work causes timeouts. Stripe expects a fast response. If you send a receipt email, write three tables, and call a third-party API all inside the webhook route, you are one slow dependency away from a timeout. The timeout makes Stripe retry, which makes the pileup worse.

Unverified endpoints accept forgery. Your webhook URL is public. Without signature verification, anyone who finds it can POST a fake payment_intent.succeeded and trigger fulfillment for a payment that never happened.

The fix for all three is the same shape: verify, dedupe, enqueue, return fast. The heavy lifting happens in a durable background job that retries each step on its own.

The Reliable Shape

Here is the flow the rest of this guide builds. The route handler does only the fast, safe work. Everything slow moves to Inngest.

  1. Read the raw body and verify the stripe-signature header.
  2. Record the event id in a Postgres table with a unique constraint (dedupe).
  3. Send the event id to Inngest and return 200.
  4. An Inngest function loads the event, processes it in retryable steps, and marks it done.

Two layers of idempotency protect you. The database unique constraint catches Stripe retries at the door. Inngest's own event dedupe catches anything that slips through. A slow email send can fail and retry inside Inngest without Stripe ever knowing.

Set Up the Environment

Install the three SDKs you need on top of a Next.js 16 project. Stripe for the API and signature verification, Inngest for the durable queue, and the Supabase client for the dedupe table.

npm install stripe inngest @supabase/supabase-js resend

The handler reads its secrets from environment variables. The webhook secret is the one that starts with whsec_, and you get it from the Stripe dashboard or the Stripe CLI (covered later). The service role key lets the background job write to a table that has row-level security on and no public policies.

# .env.local
STRIPE_SECRET_KEY=sk_test_your_key
STRIPE_WEBHOOK_SECRET=whsec_your_signing_secret

NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key

RESEND_API_KEY=re_your_key
INNGEST_EVENT_KEY=your_event_key
INNGEST_SIGNING_KEY=your_signing_key

The Dedupe Table

The entire idempotency guarantee rests on one table with a primary key on the Stripe event id. When a retry arrives, the insert fails with a unique-violation error, and that failure is how you know you have seen the event before. The table also stores the full payload so the background job can process it without a second round trip to Stripe.

Run this migration against your Supabase database. Row-level security is on with no policies, so only the service role (which bypasses RLS) can touch it.

create table public.stripe_events (
  id           text primary key,
  type         text not null,
  payload      jsonb not null,
  status       text not null default 'pending',
  received_at  timestamptz not null default now(),
  processed_at timestamptz
);

alter table public.stripe_events enable row level security;
-- No policies on purpose. Only the service role key writes here.

The background job also needs a place to write results. This is a minimal orders table for the checkout example later. Adjust the columns to your product.

create table public.orders (
  id                 uuid primary key default gen_random_uuid(),
  stripe_session_id  text unique not null,
  customer_email     text,
  amount_total       bigint,
  status             text not null default 'paid',
  created_at         timestamptz not null default now()
);

alter table public.orders enable row level security;

The Service Client

The webhook route and the Inngest function both write to tables that have RLS enabled and no public policies. They authenticate with the service role key, which bypasses RLS. Keep this client server-only and never import it into a component that ships to the browser.

// lib/supabase/service.ts
import { createClient } from "@supabase/supabase-js";

export function createServiceClient() {
  return createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!,
    { auth: { persistSession: false } },
  );
}

The Webhook Route

This is the file that Stripe calls. It runs on the Node.js runtime because signature verification needs the raw request body, and it does the smallest amount of work possible before returning. Read the body as text (not JSON, which would break the signature), verify it, record the event for dedupe, enqueue, and respond.

The one subtle part is the ordering. Insert the event row first. If the insert hits a unique violation, this is a Stripe retry, so treat it as a duplicate but still enqueue. That last detail matters: if a previous attempt crashed after recording the event but before enqueuing, the retry is your only chance to get the work onto the queue. Inngest dedupes on the same id, so re-enqueuing a genuinely-processed event is harmless.

// app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { inngest } from "@/lib/inngest/client";
import { createServiceClient } from "@/lib/supabase/service";

export const runtime = "nodejs";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(req: NextRequest) {
  const body = await req.text();
  const signature = req.headers.get("stripe-signature");

  if (!signature) {
    return NextResponse.json({ error: "Missing signature" }, { status: 400 });
  }

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch (err) {
    const message = err instanceof Error ? err.message : "Invalid signature";
    console.error(`Stripe signature verification failed: ${message}`);
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  const supabase = createServiceClient();

  // Record the event. The primary key rejects any id we have already stored.
  const { error: insertError } = await supabase.from("stripe_events").insert({
    id: event.id,
    type: event.type,
    payload: event as unknown as Record<string, unknown>,
    status: "pending",
  });

  // 23505 is the Postgres unique-violation code: this is a Stripe retry.
  const isDuplicate = insertError?.code === "23505";

  if (insertError && !isDuplicate) {
    console.error(`Failed to record ${event.id}: ${insertError.message}`);
    // Return 500 so Stripe retries instead of dropping the event.
    return NextResponse.json({ error: "Storage error" }, { status: 500 });
  }

  // Enqueue. Inngest dedupes on this id, so a retry never double-processes.
  await inngest.send({
    id: event.id,
    name: "stripe/webhook.received",
    data: { stripeEventId: event.id, type: event.type },
  });

  return NextResponse.json({ received: true, duplicate: isDuplicate });
}

Notice what this route does not do. It does not send an email. It does not call the Stripe API again. It does not write to five tables. All of that is slow and failure-prone, and none of it belongs in the request that Stripe is timing.

The Inngest Client

Inngest needs a typed client so inngest.send() and your function handlers agree on the event shape. Declaring the event schema once means a typo in an event name or a wrong data field becomes a TypeScript error instead of a silent runtime miss.

// lib/inngest/client.ts
import { EventSchemas, Inngest } from "inngest";

type Events = {
  "stripe/webhook.received": {
    data: {
      stripeEventId: string;
      type: string;
    };
  };
};

export const inngest = new Inngest({
  id: "my-app",
  schemas: new EventSchemas().fromRecord<Events>(),
});

The Background Function

This is where the real work happens, and where retries become cheap. Each step.run is a checkpoint: Inngest runs it, saves the result, and if a later step throws, only the failed step retries. The successful steps do not run again. That means a receipt email that fails on a Resend hiccup retries on its own without re-inserting the order.

The function loads the stored event, short-circuits if it was already processed, branches on the event type, and marks the event done at the end. Add more if branches as you handle more event types.

// lib/inngest/functions/process-stripe-event.ts
import Stripe from "stripe";
import { inngest } from "@/lib/inngest/client";
import { createServiceClient } from "@/lib/supabase/service";
import { resend } from "@/lib/resend";

export const processStripeEvent = inngest.createFunction(
  { id: "process-stripe-event", retries: 4 },
  { event: "stripe/webhook.received" },
  async ({ event, step }) => {
    const { stripeEventId } = event.data;

    // Load the stored event. If this step fails, it retries on its own.
    const stored = await step.run("load-event", async () => {
      const supabase = createServiceClient();
      const { data, error } = await supabase
        .from("stripe_events")
        .select("payload, status")
        .eq("id", stripeEventId)
        .single();

      if (error) {
        throw new Error(`Event ${stripeEventId} not found: ${error.message}`);
      }
      return data;
    });

    // Belt-and-suspenders: never process the same event twice.
    if (stored.status === "processed") {
      return { skipped: true, reason: "already processed" };
    }

    const payload = stored.payload as Stripe.Event;

    if (payload.type === "checkout.session.completed") {
      const session = payload.data.object as Stripe.Checkout.Session;

      // Fulfillment. The unique constraint on stripe_session_id makes this
      // insert safe to retry.
      await step.run("fulfill-order", async () => {
        const supabase = createServiceClient();
        const { error } = await supabase.from("orders").insert({
          stripe_session_id: session.id,
          customer_email: session.customer_details?.email ?? null,
          amount_total: session.amount_total,
          status: "paid",
        });
        // Ignore a duplicate order row from a retried step.
        if (error && error.code !== "23505") {
          throw new Error(`Order insert failed: ${error.message}`);
        }
      });

      // A slow email lives here, isolated. If it fails, only this step retries.
      await step.run("send-receipt", async () => {
        const email = session.customer_details?.email;
        if (!email) return;
        await resend.emails.send({
          from: "receipts@yourdomain.com",
          to: email,
          subject: "Your receipt",
          text: "Thanks for your purchase.",
        });
      });
    }

    // Mark done so any later delivery of the same event short-circuits above.
    await step.run("mark-processed", async () => {
      const supabase = createServiceClient();
      await supabase
        .from("stripe_events")
        .update({ status: "processed", processed_at: new Date().toISOString() })
        .eq("id", stripeEventId);
    });

    return { processed: true, type: payload.type };
  },
);

The Resend client is a one-liner. Keep it in its own module so both the function and the rest of your app share one instance.

// lib/resend.ts
import { Resend } from "resend";

export const resend = new Resend(process.env.RESEND_API_KEY!);

Register the Inngest Handler

Inngest serves your functions over HTTP through one route. This is where you list every function you want Inngest to run. Next.js 16 exports the named methods directly from the serve handler.

// app/api/inngest/route.ts
import { serve } from "inngest/next";
import { inngest } from "@/lib/inngest/client";
import { processStripeEvent } from "@/lib/inngest/functions/process-stripe-event";

export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: [processStripeEvent],
});

Test It Locally

Two dev servers run side by side. Your Next.js app, and the Inngest Dev Server that discovers your function and gives you a dashboard of every run and every step.

Start the Inngest Dev Server. It auto-discovers the function at your Inngest route and serves a dashboard at http://localhost:8288.

npx inngest-cli@latest dev

The Stripe CLI forwards real test events to your local endpoint and prints the whsec_ signing secret you paste into .env.local. Point it at your webhook route.

stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook

With both running, trigger a checkout event and watch it flow through. The route returns 200, the event lands in stripe_events, Inngest picks it up, and the Dev Server shows each step turning green.

stripe trigger checkout.session.completed

To prove the idempotency actually works, run stripe trigger twice. The first call inserts the event and enqueues it. The second call hits the unique constraint, returns { received: true, duplicate: true }, and Inngest dedupes the re-sent id. Your order table gets exactly one row.

Failure Modes and What Prevents Each

Every one of these has bitten someone in production. The table maps the failure to the line of defense that stops it.

FailureWhat goes wrongWhat prevents it
Forged eventAttacker POSTs a fake paymentconstructEvent rejects a bad signature
Parsed bodyreq.json() breaks verificationawait req.text() sends raw bytes
Duplicate deliveryOrder fulfilled twicePrimary key on stripe_events.id
Slow handlerStripe times out and retriesEnqueue to Inngest, return 200 fast
Mid-process crashEmail fails, event loststep.run retries the failed step only
Retry after successEvent reprocessedstatus = 'processed' short-circuits

Brief Claude Code Before It Writes a Line

Claude Code will produce a clean version of all of this, but only if you name the three failure modes in the prompt. Left unbriefed, it tends to write the naive inline handler that works in the demo and drops events under load. Tell it the shape you want.

claude "build a Stripe webhook route at app/api/stripe/webhook/route.ts.
Verify the signature with constructEvent using await req.text() for the raw
body. Dedupe on event.id in a stripe_events table with a primary key. Do not
process inline: send the event to Inngest and return 200. Write the Inngest
function separately with step.run for each side effect so retries are cheap."

Then have Claude add a second event type to prove the branch pattern holds. Because the processing lives in one function with a clear if (payload.type === ...) structure, extending it to invoice.paid or customer.subscription.deleted is a small, contained edit rather than a rewrite of the route.

Quality Gates Before You Ship

Two checks catch the mistakes that this pattern is prone to: a wrong event-data type between the client and the function, and a route that accidentally reads the body twice.

Type check with zero errors:

npx tsc --noEmit

Clean production build:

npm run build

Ask Claude to run both and fix what breaks:

claude "run tsc --noEmit and npm run build, fix any errors, and confirm both pass"

The typed Inngest client earns its keep here. If you send stripeEventId but the function reads eventId, that is a compile error, not a 2am page about a customer who paid and never got provisioned.

A single Claude Code session writes this handler well when you brief it. Running it the same way on every webhook, every payment path, every retry corner, across a whole codebase, is the harder part. That is what the $29 Code Kit adds on top of Claude Code: a harness that plans the change, builds it, evaluates the output, tests it against a real flow, and runs the quality gates (zero type errors, zero lint errors, a clean build) before anything ships. It is a one-time $29, no subscription. Claude Code itself still needs a paid Anthropic plan to run the agent. The Kit is the pipeline that makes the good version the default instead of the version you remembered to ask for.

設定をやめて、構築を始めよう。

AIオーケストレーション付きSaaSビルダーテンプレート。

企業向けに構築している実績を見る →

Posted by @speedy_devv

Continue in Workflow

  • エージェント型コマース:AI エージェントが支払えるアプリの作り方
    2026年のエージェント型コマースをわかりやすく解説するガイド。x402、ACP、Machine Payments Protocol が何をするのか、そして AI エージェントが購入できる有料 API を週末で出荷するための手順を紹介します。
  • Claude Code ベストプラクティス
    Claude Codeで成果を出すエンジニアを分ける5つの習慣: PRD、モジュラーなCLAUDE.mdのルール、カスタムスラッシュコマンド、/clearリセット、そしてシステム進化の思考法。
  • Claude Code オートモード
    2つ目の Sonnet モデルが、Claude Code のすべてのツール呼び出しを実行前に審査します。オートモードがブロックするもの・許可するもの、そして settings.json に追加される許可ルールについて解説します。
  • Channels、Routines、Teleport、Dispatch
    Anthropic が2026年3月と4月に出荷した4つの Claude Code 機能。これらは CLI を、スマホ・ウェブ・デスクトップをまたぐイベント駆動の調整レイヤーに変えます。
  • Claude Code 1M Context in Practice: When Bigger Isn't Better
    The 1M-token context window is GA at flat pricing, but bigger isn't always better. A decision framework, token-cost math, and when to use /compact, subagents, and dynamic workflows instead.
  • How to Build an Admin Dashboard With Claude Code
    Ship an internal admin panel with Claude Code: role-gated routes, a searchable users and orders table with pagination, impersonation-safe RLS, and metrics tiles pulled through a type-safe API.

More from Handbook

  • エージェントの基礎
    Claude Codeでスペシャリストエージェントを構築する5つの方法:タスクサブエージェント、.claude/agents YAML、カスタムスラッシュコマンド、CLAUDE.mdペルソナ、パースペクティブプロンプト。
  • エージェント・ハーネス・エンジニアリング
    ハーネスとは、AIエージェントを構成するモデル以外のすべての層のことです。5つの制御レバー、制約のパラドックス、そしてなぜハーネス設計がモデルよりもエージェントのパフォーマンスを左右するのかを学びましょう。
  • エージェントパターン
    オーケストレーター、ファンアウト、バリデーションチェーン、スペシャリストルーティング、プログレッシブリファインメント、ウォッチドッグ。Claude Code のサブエージェントを組み合わせる6つのオーケストレーション形状。
  • エージェントチームのベストプラクティス
    Claude Code エージェントチームの実証済みパターン。コンテキストが豊富なスポーンプロンプト、適切なサイズのタスク、ファイルオーナーシップ、デリゲートモード、v2.1.33〜v2.1.45 の修正内容。

設定をやめて、構築を始めよう。

AIオーケストレーション付きSaaSビルダーテンプレート。

企業向けに構築している実績を見る →

Rate Limiting

Protect your oRPC procedures and Next.js 16 API routes from abuse and runaway costs with Upstash Redis sliding-window limits, per-user and per-IP keys, clean 429 responses, and graceful client retries.

Semantic Search

Add embeddings-backed semantic search to a Supabase app. Generate embeddings, store them in pgvector, rank results by meaning, and serve it all through a type-safe oRPC endpoint.

On this page

Why Stripe Webhooks Break in Production
The Reliable Shape
Set Up the Environment
The Dedupe Table
The Service Client
The Webhook Route
The Inngest Client
The Background Function
Register the Inngest Handler
Test It Locally
Failure Modes and What Prevents Each
Brief Claude Code Before It Writes a Line
Quality Gates Before You Ship

設定をやめて、構築を始めよう。

AIオーケストレーション付きSaaSビルダーテンプレート。

企業向けに構築している実績を見る →