DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Webhook Processing at Scale: Idempotency, Signature Verification, and Async Queues

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 })
}
Enter fullscreen mode Exit fullscreen mode

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 })
}
Enter fullscreen mode Exit fullscreen mode

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 })
}
Enter fullscreen mode Exit fullscreen mode

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 })
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)