Build This Now
Build This Now
Qu'est-ce que le code Claude ?Installer Claude CodeL'installateur natif de Claude CodeTon premier projet Claude Code
Subscription BillingMulti-Tenant SaaSAI Chat FeatureRate LimitingStripe WebhooksSemantic SearchRealtime UpdatesDrip Email SequencesClaude Code v2.1.122 Release NotesClaude Code Dynamic Workflows : comment orchestrer 1 000 sous-agents sur une vraie codebaseBonnes pratiques Claude CodeMeilleures pratiques pour Claude Opus 4.7Claude Code sur un VPSIntégration GitRevue de code avec Claude CodeLes Worktrees avec Claude CodeClaude Code à distanceClaude Code ChannelsChannels, Routines, Teleport, DispatchTâches planifiées avec Claude CodePermissions Claude CodeLe mode auto de Claude CodeAjouter les paiements Stripe avec Claude CodeFeedback LoopsWorkflows TodoGestion des tâches dans Claude CodeTemplates de projetTarification et utilisation des tokens Claude CodeTarifs de Claude Code : ce que tu vas vraiment payerClaude Code Ultra ReviewConstruire une app Next.js avec Claude CodeClaude Code With Supabase: Database, Auth, RLSVercel deepsec with Claude CodeTest-Driven Development with Claude CodeConstruire un MVP SaaS avec Claude CodeAjouter l'authentification avec Claude Code (Supabase Auth)Ajouter les emails transactionnels avec Claude Code (Resend + React Email)Construire une API type-safe avec Claude Code (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)Commerce agentique : comment construire une app que les agents IA peuvent payerClaude Code 1M Context in Practice: When Bigger Isn't BetterClaude Code GitHub Actions Setup Guide (@claude + Cron)Headless ModeMax Plan vs APIPrompt CachingCombien coûte la création d'un SaaS avec Claude Code en 2026Run 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.

Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →
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.


Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →

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.

Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →

Posted by @speedy_devv

Continue in Workflow

  • Commerce agentique : comment construire une app que les agents IA peuvent payer
    Un guide en français simple du commerce agentique en 2026 : ce que font x402, ACP et le Machine Payments Protocol, plus un pas-à-pas d'un week-end pour livrer une API payante que les agents IA peuvent acheter.
  • Bonnes pratiques Claude Code
    Cinq habitudes séparent les ingénieurs qui livrent avec Claude Code : les PRDs, les règles CLAUDE.md modulaires, les slash commands personnalisés, les resets /clear, et un état d'esprit d'évolution du système.
  • Le mode auto de Claude Code
    Un second modèle Sonnet examine chaque appel d'outil Claude Code avant qu'il s'exécute. Ce que le mode auto bloque, ce qu'il autorise, et les règles d'autorisation qu'il place dans tes paramètres.
  • Channels, Routines, Teleport, Dispatch
    Les quatre fonctionnalités Claude Code livrées par Anthropic en mars et avril 2026 qui transforment le CLI en une couche de coordination orientée événements, entre téléphone, web et 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

  • Principes de base de l'agent
    Cinq façons de construire des agents spécialisés dans le code Claude : Sous-agents de tâches, .claude/agents YAML, commandes slash personnalisées, personas CLAUDE.md, et invites de perspective.
  • L'ingénierie du harness agent
    Le harness, c'est toutes les couches autour de ton agent IA sauf le modèle lui-même. Découvre les cinq leviers de contrôle, le paradoxe des contraintes, et pourquoi le design du harness détermine les performances de l'agent bien plus que le modèle.
  • Patterns d'agents
    Orchestrateur, fan-out, chaîne de validation, routage par spécialiste, raffinement progressif, et watchdog. Six formes d'orchestration pour câbler des sub-agents Claude Code.
  • Meilleures pratiques des équipes d'agents
    Patterns éprouvés pour les équipes d'agents Claude Code. Prompts de création riches en contexte, tâches bien calibrées, propriété des fichiers, mode délégué, et correctifs v2.1.33-v2.1.45.

Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →

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

Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →