Stripe webhooks deliver payment events to your server — stripe.webhooks.constructEvent(rawBody, signature, webhookSecret) verifies the signature and parses the event. Always return 200 immediately so Stripe doesn’t retry; process asynchronously if needed. Idempotency is critical: Stripe retries failed webhooks, so deduplicate by event.id in your database before processing. checkout.session.completed fires when a Checkout Session payment succeeds — provision access here. payment_intent.succeeded and payment_intent.payment_failed handle direct charge flows. customer.subscription.created/updated/deleted manage subscription lifecycle. invoice.paid/payment_failed handle recurring billing. charge.refunded processes refunds. The Stripe CLI stripe listen --forward-to localhost:3000/api/webhooks routes live events in development. Claude Code generates type-safe webhook handlers, idempotency guards, event-to-action routers, and subscription lifecycle management patterns.
CLAUDE.md for Stripe Webhooks
## Stripe Webhooks Stack
- Version: stripe >= 16.0
- Verify: stripe.webhooks.constructEvent(rawBody, sig, secret) — raw body required
- Idempotency: check event.id in DB before processing — Stripe retries on non-200
- Return 200 immediately — process async if needed, Stripe times out at 30s
- Checkout: checkout.session.completed → provision access (check payment_status)
- Subscriptions: customer.subscription.updated → sync plan, status, period_end
- CLI: stripe listen --forward-to localhost:3000/api/stripe/webhook
- Logs: store processed events in webhook_events table with event_id UNIQUE constraint
Webhook Route Handler
// app/api/stripe/webhook/route.ts — signature verification + routing
import Stripe from "stripe"
import { headers } from "next/headers"
import { processWebhookEvent } from "@/lib/stripe/event-processor"
import { markEventProcessed, wasEventProcessed } from "@/lib/stripe/event-log"
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const rawBody = await req.text() // Must be raw bytes for signature verification
const sig = (await headers()).get("stripe-signature")
if (!sig) {
return Response.json({ error: "Missing stripe-signature header" }, { status: 400 })
}
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
rawBody,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
console.error("[Stripe Webhook] Signature verification failed:", err)
return Response.json({ error: "Invalid signature" }, { status: 400 })
}
// Idempotency: skip if already processed
if (await wasEventProcessed(event.id)) {
console.log(`[Stripe Webhook] Skipping duplicate event ${event.id}`)
return Response.json({ received: true, duplicate: true })
}
// Process event (async for long operations)
try {
await processWebhookEvent(event)
await markEventProcessed(event.id, event.type)
} catch (err) {
console.error(`[Stripe Webhook] Processing failed for ${event.type}:`, err)
// Return 500 to trigger Stripe retry — only for recoverable errors
return Response.json({ error: "Processing failed" }, { status: 500 })
}
return Response.json({ received: true })
}
Event Processor
// lib/stripe/event-processor.ts — type-safe event routing
import Stripe from "stripe"
import {
handleCheckoutCompleted,
handlePaymentSucceeded,
handlePaymentFailed,
handleSubscriptionCreated,
handleSubscriptionUpdated,
handleSubscriptionDeleted,
handleInvoicePaid,
handleInvoicePaymentFailed,
handleRefund,
} from "./event-handlers"
export async function processWebhookEvent(event: Stripe.Event): Promise<void> {
console.log(`[Stripe] Processing ${event.type} (${event.id})`)
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session)
break
case "payment_intent.succeeded":
await handlePaymentSucceeded(event.data.object as Stripe.PaymentIntent)
break
case "payment_intent.payment_failed":
await handlePaymentFailed(event.data.object as Stripe.PaymentIntent)
break
case "customer.subscription.created":
await handleSubscriptionCreated(event.data.object as Stripe.Subscription)
break
case "customer.subscription.updated":
await handleSubscriptionUpdated(
event.data.object as Stripe.Subscription,
event.data.previous_attributes as Partial<Stripe.Subscription>
)
break
case "customer.subscription.deleted":
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription)
break
case "invoice.paid":
await handleInvoicePaid(event.data.object as Stripe.Invoice)
break
case "invoice.payment_failed":
await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice)
break
case "charge.refunded":
await handleRefund(event.data.object as Stripe.Charge)
break
default:
console.log(`[Stripe] Unhandled event type: ${event.type}`)
}
}
Event Handlers
// lib/stripe/event-handlers.ts — business logic per event
import Stripe from "stripe"
import { db } from "@/lib/db"
export async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
// Only process successful payments
if (session.payment_status !== "paid") {
console.log(`[Stripe] Checkout ${session.id} not paid — skipping`)
return
}
const customerId = session.customer as string
const metadata = session.metadata ?? {}
if (session.mode === "subscription") {
// Subscription checkout — subscription created event will handle provisioning
console.log(`[Stripe] Subscription checkout completed for customer ${customerId}`)
return
}
if (session.mode === "payment") {
// One-time payment — provision immediately
const userId = metadata.userId
const productId = metadata.productId
if (!userId || !productId) {
throw new Error(`Missing metadata on checkout session ${session.id}`)
}
await db.purchase.create({
data: {
userId,
productId,
stripeSessionId: session.id,
amountCents: session.amount_total ?? 0,
status: "completed",
},
})
// Send fulfillment email
await sendFulfillmentEmail(userId, productId)
}
}
export async function handleSubscriptionUpdated(
subscription: Stripe.Subscription,
previousAttributes: Partial<Stripe.Subscription>
) {
const stripeCustomerId = subscription.customer as string
// Find our user by Stripe customer ID
const user = await db.user.findFirst({
where: { stripeCustomerId },
})
if (!user) {
throw new Error(`No user found for Stripe customer ${stripeCustomerId}`)
}
const priceId = subscription.items.data[0]?.price.id
await db.subscription.upsert({
where: { stripeSubscriptionId: subscription.id },
create: {
userId: user.id,
stripeSubscriptionId: subscription.id,
stripePriceId: priceId,
status: subscription.status,
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
update: {
status: subscription.status,
stripePriceId: priceId,
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
})
// Handle downgrades/upgrades
if (previousAttributes.items) {
console.log(`[Stripe] Subscription ${subscription.id} plan changed`)
}
}
export async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
await db.subscription.update({
where: { stripeSubscriptionId: subscription.id },
data: {
status: "cancelled",
cancelledAt: new Date(),
},
})
}
export async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) {
const stripeCustomerId = invoice.customer as string
const user = await db.user.findFirst({ where: { stripeCustomerId } })
if (!user) return
// Send payment failure notification
await sendPaymentFailedEmail(user.email, invoice.hosted_invoice_url ?? undefined)
}
export async function handleRefund(charge: Stripe.Charge) {
if (!charge.refunded) return
const amountRefundedCents = charge.amount_refunded
await db.order.update({
where: { stripePaymentIntentId: charge.payment_intent as string },
data: {
refundedAmountCents: amountRefundedCents,
status: charge.amount === amountRefundedCents ? "fully_refunded" : "partially_refunded",
},
})
}
async function sendFulfillmentEmail(_userId: string, _productId: string) { /* ... */ }
async function sendPaymentFailedEmail(_email: string, _url?: string) { /* ... */ }
Idempotency Log
// lib/stripe/event-log.ts — prevent double processing
import { db } from "@/lib/db"
export async function wasEventProcessed(eventId: string): Promise<boolean> {
const existing = await db.stripeEvent.findUnique({
where: { stripeEventId: eventId },
})
return existing !== null
}
export async function markEventProcessed(eventId: string, eventType: string): Promise<void> {
await db.stripeEvent.create({
data: {
stripeEventId: eventId,
eventType,
processedAt: new Date(),
},
})
}
// Prisma schema addition:
// model StripeEvent {
// id String @id @default(cuid())
// stripeEventId String @unique
// eventType String
// processedAt DateTime
// createdAt DateTime @default(now())
// }
For the Paddle webhooks alternative when marketplace or software license billing with built-in tax compliance (VAT, sales tax) across countries is needed — Paddle handles tax collection as the merchant of record, removing VAT registration obligations for individual vendors, see the payment processor comparison for global SaaS billing. For the Lemon Squeezy alternative when a simpler Stripe alternative with digital product sales, license keys, and affiliate program management is needed specifically for indie developers selling software or courses — Lemon Squeezy is also a merchant of record, see the digital commerce platform guide. The Claude Skills 360 bundle includes Stripe webhook skill sets covering signature verification, idempotency, and subscription lifecycle. Start with the free tier to try payment webhook generation.