Build This Now
Build This Now
What Is Claude Code?Claude Code InstallationClaude Code Native InstallerYour First Claude Code Project
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.

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →
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.


Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →

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.

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →

Posted by @speedy_devv

Continue in Workflow

  • Agentic Commerce: How to Build an App AI Agents Can Pay For
    A plain-English guide to agentic commerce in 2026: what x402, ACP, and the Machine Payments Protocol do, plus a weekend walkthrough for shipping a paid API that AI agents can buy from.
  • Claude Code Best Practices
    Five habits separate engineers who ship with Claude Code: PRDs, modular CLAUDE.md rules, custom slash commands, /clear resets, and a system-evolution mindset.
  • Claude Code Auto Mode
    A second Sonnet model reviews every Claude Code tool call before it fires. What auto mode blocks, what it allows, and the allow rules it drops in your settings.
  • Channels, Routines, Teleport, Dispatch
    The four Claude Code features Anthropic shipped in March and April 2026 that turn the CLI into an event-driven coordination layer across phone, web, and desktop.
  • 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

  • Agent Fundamentals
    Five ways to build specialist agents in Claude Code: Task sub-agents, .claude/agents YAML, custom slash commands, CLAUDE.md personas, and perspective prompts.
  • Agent Harness Engineering
    The harness is every layer around your AI agent except the model itself. Learn the five control levers, the constraint paradox, and why harness design determines agent performance more than the model does.
  • Agent Patterns
    Orchestrator, fan-out, validation chain, specialist routing, progressive refinement, and watchdog. Six orchestration shapes to wire Claude Code sub-agents with.
  • Agent Teams Best Practices
    Battle-tested patterns for Claude Code Agent Teams. Context-rich spawn prompts, right-sized tasks, file ownership, delegate mode, and v2.1.33-v2.1.45 fixes.

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →

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

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →