To secure Stripe webhooks, you need to: (1) verify webhook signatures using the Stripe signing secret, (2) implement idempotency to handle duplicate events safely, (3) respond quickly with 200 status before processing, (4) use the raw request body for signature verification, and (5) store the webhook endpoint secret securely. This blueprint covers webhook security patterns applicable to any backend.
TL;DR
Stripe webhooks must be verified with signature checking-anyone can POST to your webhook URL. Use the raw request body for verification, handle events idempotently, and always respond quickly (within 30 seconds). Never trust client-side payment confirmations.
Signature Verification (Node.js) Stripe
import Stripe from 'stripe'
import { headers } from 'next/headers'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!
export async function POST(req: Request) {
const body = await req.text() // Raw body required
const signature = headers().get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
} catch (err) {
console.error('Webhook signature verification failed:', err)
return new Response('Invalid signature', { status: 400 })
}
// Process the verified event
try {
await handleStripeEvent(event)
} catch (err) {
console.error('Error processing webhook:', err)
return new Response('Webhook handler failed', { status: 500 })
}
return new Response(JSON.stringify({ received: true }))
}
Idempotent Event Handling
async function handleStripeEvent(event: Stripe.Event) {
// Check if we've already processed this event
const existing = await db.webhookEvent.findUnique({
where: { stripeEventId: event.id },
})
if (existing) {
console.log(`Event ${event.id} already processed`)
return
}
// Record the event before processing
await db.webhookEvent.create({
data: {
stripeEventId: event.id,
type: event.type,
status: 'processing',
},
})
try {
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutComplete(event.data.object)
break
case 'customer.subscription.updated':
await handleSubscriptionUpdate(event.data.object)
break
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object)
break
}
await db.webhookEvent.update({
where: { stripeEventId: event.id },
data: { status: 'completed' },
})
} catch (err) {
await db.webhookEvent.update({
where: { stripeEventId: event.id },
data: { status: 'failed', error: err.message },
})
throw err
}
}
Common Mistakes
// WRONG - This breaks signature verification
const body = await req.json() // Don't do this!
stripe.webhooks.constructEvent(JSON.stringify(body), sig, secret)
// CORRECT - Use raw body
const body = await req.text()
stripe.webhooks.constructEvent(body, sig, secret)
Use raw body for signature verification. Parsing the body as JSON then re-stringifying it changes the format, causing signature verification to fail. Always use the raw request body.
Security Checklist
Pre-Launch Checklist
Webhook signature verified
Raw body used (not parsed JSON)
Events handled idempotently
Response time under 30 seconds
Different secrets for test/live modes
Webhook endpoint uses HTTPS
Related Integration Stacks
Supabase + Stripe Integration Firebase + Stripe Integration NextAuth + Prisma Sessions