Webhooks are the backbone of event-driven SaaS integrations. Stripe fires one when a payment succeeds. GitHub fires one when a PR is merged. Vercel fires one when a deploy completes. Here's how to receive, verify, and process them reliably.
The Core Problem: Idempotency
Webhooks are delivered at-least-once. Your endpoint will receive duplicates. Every webhook handler must be idempotent — processing the same event twice must produce the same result as processing it once.
// Bad -- creates duplicate records on retry
export async function POST(req: Request) {
const event = await req.json()
await db.order.create({ data: parseOrder(event) })
return Response.json({ ok: true })
}
// Good -- idempotent via upsert on event ID
export async function POST(req: Request) {
const event = await req.json()
await db.webhookEvent.upsert({
where: { eventId: event.id },
create: { eventId: event.id, processed: false, payload: event },
update: {}, // no-op if already exists
})
// Process asynchronously
await processWebhookEvent(event.id)
return Response.json({ ok: true })
}
Stripe Webhook Verification
Always verify the signature. Never trust the payload alone:
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const body = await req.text() // raw body required for signature
const signature = req.headers.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
return Response.json({ error: 'Invalid signature' }, { status: 400 })
}
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutComplete(event.data.object)
break
case 'customer.subscription.deleted':
await handleSubscriptionCanceled(event.data.object)
break
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object)
break
}
return Response.json({ received: true })
}
Processing with a Job Queue
Return 200 immediately and process asynchronously. Never do slow work in the webhook handler:
export async function POST(req: Request) {
const body = await req.text()
const sig = req.headers.get('stripe-signature')!
const event = stripe.webhooks.constructEvent(body, sig, secret)
// Enqueue for async processing -- return 200 in < 3 seconds
await webhookQueue.add(event.type, event, {
jobId: event.id, // prevents duplicate processing
attempts: 5,
backoff: { type: 'exponential', delay: 5000 },
})
return Response.json({ received: true })
}
If you process synchronously and your handler times out, Stripe retries the webhook — causing duplicate processing.
Generic Webhook Receiver
A reusable pattern for any webhook source:
async function receiveWebhook({
source,
eventId,
eventType,
payload,
}: {
source: string
eventId: string
eventType: string
payload: unknown
}) {
// Idempotency check
const existing = await db.webhookEvent.findUnique({
where: { source_eventId: { source, eventId } },
})
if (existing?.processedAt) {
logger.info({ eventId, source }, 'Duplicate webhook, skipping')
return
}
await db.webhookEvent.upsert({
where: { source_eventId: { source, eventId } },
create: { source, eventId, eventType, payload: JSON.stringify(payload) },
update: { receivedAt: new Date() },
})
await eventQueue.add(`${source}:${eventType}`, { source, eventId, payload })
}
Testing Webhooks Locally
# Stripe CLI -- forwards live events to localhost
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Trigger a test event
stripe trigger checkout.session.completed
# ngrok -- expose localhost for any webhook source
ngrok http 3000
# Use the ngrok URL as your webhook endpoint
Monitoring and Alerting
Track webhook processing health:
- Log every event received with its ID and type
- Log processing success/failure per event
- Alert if the error rate on webhook processing exceeds 1%
- Keep a 30-day window of raw payloads for debugging
The AI SaaS Starter at whoffagents.com ships with Stripe webhook handling pre-built: signature verification, idempotency, checkout completion handler, and subscription lifecycle management. $99 one-time.
Top comments (0)