Webhook Patterns for SaaS Products
Webhooks are the backbone of modern SaaS integrations. Stripe tells you a payment succeeded. Clerk tells you a user signed up. GitHub tells you a PR was merged. Your product needs to respond to these events reliably.
Implementing webhooks well is not complicated, but there are several places where naive implementations fail in production. Here's the complete picture.
Receiving webhooks: the basics
A webhook receiver is an API endpoint that accepts POST requests from an external service. In Nuxt, this goes in server/api/:
// server/api/stripe-webhook.post.ts
export default defineEventHandler(async (event) => {
const body = await readRawBody(event)
const signature = getHeader(event, 'stripe-signature')!
// Verify the signature (critical — see below)
const stripeEvent = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
// Handle the event
switch (stripeEvent.type) {
case 'checkout.session.completed':
await handleCheckoutCompleted(stripeEvent.data.object)
break
case 'customer.subscription.deleted':
await handleSubscriptionCancelled(stripeEvent.data.object)
break
}
return { received: true }
})
Note readRawBody — not readBody. Signature verification requires the raw request body, not the parsed JSON. Using readBody will break signature verification.
Signature verification: never skip this
Every reputable webhook provider signs their requests. Stripe uses a header stripe-signature; GitHub uses x-hub-signature-256; Clerk uses a signing secret.
Why this matters: Anyone who knows your webhook URL can POST arbitrary data to it. Without signature verification, a malicious actor could trigger your subscription cancelled handler without an actual cancellation, or mark orders as paid without payment.
Always verify signatures before processing:
// For GitHub webhooks
import { createHmac, timingSafeEqual } from 'crypto'
function verifyGithubSignature(body: string, signature: string, secret: string): boolean {
const expected = `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`
// Use timingSafeEqual to prevent timing attacks
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
}
Idempotency: handling duplicate deliveries
Webhook providers guarantee at-least-once delivery — meaning they'll retry failed deliveries. Your webhook handler might receive the same event multiple times.
If you charge a user's card or create a database record every time the webhook fires, duplicates will cause real problems.
Solution: idempotency keys
Store processed event IDs. Before processing, check if you've seen this event before:
export default defineEventHandler(async (event) => {
const stripeEvent = await verifyAndParseEvent(event)
// Check for duplicate
const existing = await db.processedEvents.findOne({
eventId: stripeEvent.id
})
if (existing) {
return { received: true, duplicate: true }
}
// Process the event
await handleEvent(stripeEvent)
// Mark as processed
await db.processedEvents.create({ eventId: stripeEvent.id })
return { received: true }
})
The processed_events table needs an index on event_id and a TTL (delete records older than 30 days — you don't need them indefinitely).
Return 200 quickly, process async
Webhook providers have short timeout windows (typically 5-30 seconds). If your handler takes too long, the provider marks the delivery as failed and retries.
For handlers that need to do significant work (sending emails, updating multiple database records, calling external APIs), return 200 immediately and process asynchronously:
export default defineEventHandler(async (event) => {
const stripeEvent = await verifyAndParseEvent(event)
// Queue the work
await queue.add('process-stripe-event', { eventId: stripeEvent.id, eventType: stripeEvent.type })
// Return 200 immediately
return { received: true }
})
The queue worker processes the event in the background. If it fails, the queue handles retries — not the webhook provider.
Sending webhooks (for your own API)
If your product has a developer API, your customers may want webhooks from you. Here's a minimal reliable implementation:
// lib/webhooks.ts
export async function sendWebhook(
endpoint: WebhookEndpoint,
payload: WebhookPayload
) {
const body = JSON.stringify(payload)
const signature = createHmac('sha256', endpoint.secret)
.update(body)
.digest('hex')
try {
const response = await fetch(endpoint.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Signature': `sha256=${signature}`,
'X-Webhook-ID': payload.id,
'X-Timestamp': payload.timestamp.toString()
},
body,
signal: AbortSignal.timeout(10000) // 10 second timeout
})
await db.webhookDeliveries.create({
endpointId: endpoint.id,
eventId: payload.id,
status: response.ok ? 'success' : 'failed',
statusCode: response.status
})
} catch (error) {
await db.webhookDeliveries.create({
endpointId: endpoint.id,
eventId: payload.id,
status: 'failed',
error: error.message
})
// Queue for retry with exponential backoff
await queue.add('retry-webhook', { endpointId: endpoint.id, eventId: payload.id }, {
delay: 5000,
attempts: 5
})
}
}
Log every delivery attempt and response. When a customer says "we didn't receive the webhook," you need to be able to show them exactly what happened.
Testing webhooks locally
External services can't reach localhost. For local development:
- Stripe CLI:
stripe listen --forward-to localhost:3000/api/stripe-webhook - ngrok: exposes a local port via a public URL
- Webhook.site: captures webhook payloads for inspection without a running server
Stripe CLI is the easiest option for Stripe-specific development. ngrok works for any provider.
Monitoring
In production, set up alerts for:
- Webhook handler errors (Sentry or your error tracker)
- High delivery failure rates (your webhook logs)
- Processing queue depth (if processing async)
Webhook failures are often silent — the external service retries quietly and eventually gives up. Without monitoring, you can have a broken integration for hours before anyone notices.