Stripe Webhook Security: Verifying Signatures and Handling Retries
Stripe webhooks without signature verification are a security hole.
Anyone can POST to your endpoint and fake a payment. Here's the right way.
Why Signature Verification Matters
Without it:
POST /api/webhooks/stripe
{ "type": "checkout.session.completed", "data": { ... } }
Anyone can send this. Stripe signs every webhook with your webhook secret.
Only Stripe has the secret, so only Stripe can produce a valid signature.
Correct Webhook Handler (Next.js)
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'
import { headers } from 'next/headers'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(request: Request) {
const body = await request.text() // MUST be raw text, not parsed JSON
const signature = headers().get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
console.error('Webhook signature verification failed:', err)
return Response.json({ error: 'Invalid signature' }, { status: 400 })
}
// Handle the event
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutCompleted(event.data.object)
break
case 'customer.subscription.created':
await handleSubscriptionCreated(event.data.object)
break
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object)
break
default:
console.log(`Unhandled event type: ${event.type}`)
}
return Response.json({ received: true })
}
Critical: Raw Body
The most common mistake — parsing the body before verification:
// WRONG — destroys the signature
const body = await request.json() // Do NOT do this
// CORRECT — raw text
const body = await request.text()
Idempotency: Handle Retries Safely
Stripe retries failed webhooks for up to 3 days. Your handler must be idempotent:
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
// Check if already processed
const existing = await db.order.findUnique({
where: { stripeSessionId: session.id }
})
if (existing) {
console.log(`Order ${existing.id} already processed for session ${session.id}`)
return // Safe to return 200 — Stripe will stop retrying
}
// Process the order
await db.order.create({
data: {
stripeSessionId: session.id,
customerEmail: session.customer_email!,
amount: session.amount_total!,
status: 'paid',
}
})
await deliverProduct(session)
await sendConfirmationEmail(session.customer_email!)
}
Webhook Event Store (Audit Trail)
async function processWebhook(event: Stripe.Event) {
// Store every event (useful for debugging)
await db.webhookEvent.upsert({
where: { stripeEventId: event.id },
create: {
stripeEventId: event.id,
type: event.type,
payload: event as unknown as Prisma.JsonObject,
processedAt: null,
},
update: {}, // Don't overwrite if exists
})
// Handle the event
await handleEvent(event)
// Mark as processed
await db.webhookEvent.update({
where: { stripeEventId: event.id },
data: { processedAt: new Date() },
})
}
Local Testing with Stripe CLI
# Forward Stripe webhooks to localhost
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Trigger a test event
stripe trigger checkout.session.completed
Common Event Handlers
// Subscription events
case 'customer.subscription.created':
case 'customer.subscription.updated':
await syncSubscriptionToDb(event.data.object)
break
case 'customer.subscription.deleted':
await cancelUserSubscription(event.data.object.customer as string)
break
case 'invoice.payment_failed':
await notifyPaymentFailed(event.data.object.customer_email!)
break
// One-time payment
case 'checkout.session.completed':
if (event.data.object.mode === 'payment') {
await deliverProduct(event.data.object)
}
break
The AI SaaS Starter Kit ships with a complete Stripe webhook handler: signature verification, idempotency checks, subscription sync, and product delivery. $99 one-time.
Top comments (0)