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ビルダーテンプレート。
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.
- Read the raw body and verify the
stripe-signatureheader. - Record the event id in a Postgres table with a unique constraint (dedupe).
- Send the event id to Inngest and return 200.
- 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 resendThe 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_keyThe 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 devThe 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/webhookWith 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.completedTo 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.
| Failure | What goes wrong | What prevents it |
|---|---|---|
| Forged event | Attacker POSTs a fake payment | constructEvent rejects a bad signature |
| Parsed body | req.json() breaks verification | await req.text() sends raw bytes |
| Duplicate delivery | Order fulfilled twice | Primary key on stripe_events.id |
| Slow handler | Stripe times out and retries | Enqueue to Inngest, return 200 fast |
| Mid-process crash | Email fails, event lost | step.run retries the failed step only |
| Retry after success | Event reprocessed | status = '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 --noEmitClean production build:
npm run buildAsk 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
設定をやめて、構築を始めよう。
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.