DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Implementing Webhooks: Receiving, Validating, and Retrying Events

Implementing Webhooks: Receiving, Validating, and Retrying Events

Webhooks are how Stripe, GitHub, and every modern API notify your server of events. Getting them right requires signature validation, idempotency, and retry handling.

The Basics

// Basic webhook receiver
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
  // CRITICAL: Use raw body for signature validation
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Process event
  switch (event.type) {
    case 'payment_intent.succeeded':
      await handlePayment(event.data.object);
      break;
  }

  res.json({ received: true });
});
Enter fullscreen mode Exit fullscreen mode

Signature Validation (Generic)

import { createHmac, timingSafeEqual } from 'crypto';

function validateWebhookSignature(payload: string, signature: string, secret: string): boolean {
  const expected = createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  // timingSafeEqual prevents timing attacks
  return timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}
Enter fullscreen mode Exit fullscreen mode

Idempotency — Process Each Event Once

// Store processed event IDs to prevent duplicate processing
async function processWebhookIdempotently(eventId: string, handler: () => Promise<void>) {
  const existing = await db.processedWebhook.findUnique({ where: { eventId } });
  if (existing) {
    console.log(`Skipping duplicate event: ${eventId}`);
    return;
  }

  await handler();

  await db.processedWebhook.create({
    data: { eventId, processedAt: new Date() }
  });
}
Enter fullscreen mode Exit fullscreen mode

Async Processing with a Queue

// Return 200 immediately, process async
app.post('/webhooks', async (req, res) => {
  // Validate signature first
  if (!isValidSignature(req)) return res.status(401).send('Invalid signature');

  // Acknowledge immediately
  res.json({ received: true });

  // Process in background
  await queue.add('process-webhook', { event: req.body });
});

// Worker processes events without blocking the HTTP response
queue.process('process-webhook', async (job) => {
  await processEvent(job.data.event);
});
Enter fullscreen mode Exit fullscreen mode

Retrying Failed Events

// Store events that fail processing for retry
async function handleWebhookWithRetry(event: WebhookEvent) {
  try {
    await processEvent(event);
  } catch (error) {
    await db.failedWebhook.create({
      data: {
        eventId: event.id,
        payload: JSON.stringify(event),
        error: error.message,
        nextRetryAt: new Date(Date.now() + 5 * 60 * 1000), // 5 min
        retryCount: 0,
      }
    });
  }
}

// Cron job retries failed events with exponential backoff
async function retryFailedWebhooks() {
  const events = await db.failedWebhook.findMany({
    where: { nextRetryAt: { lte: new Date() }, retryCount: { lt: 5 } }
  });

  for (const event of events) {
    try {
      await processEvent(JSON.parse(event.payload));
      await db.failedWebhook.delete({ where: { id: event.id } });
    } catch {
      const backoff = Math.pow(2, event.retryCount) * 5 * 60 * 1000;
      await db.failedWebhook.update({
        where: { id: event.id },
        data: { retryCount: { increment: 1 }, nextRetryAt: new Date(Date.now() + backoff) }
      });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Next.js Route Handler

// app/api/webhooks/stripe/route.ts
export async function POST(request: Request) {
  const body = await request.text(); // Raw body needed
  const sig = request.headers.get('stripe-signature')!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return new Response('Invalid signature', { status: 400 });
  }

  await handleStripeEvent(event);
  return Response.json({ ok: true });
}
Enter fullscreen mode Exit fullscreen mode

Stripe webhooks ship pre-wired (with signature validation, idempotency, and delivery confirmation) in the AI SaaS Starter Kit. $99 at whoffagents.com.

Top comments (0)