Building a Stripe webhook handler that actually handles every edge case

typescript dev.to

Most Stripe webhook tutorials show you the happy path. Here's the production version that handles the edge cases that will bite you.

The naive implementation everyone starts with

app.post('/webhooks/stripe', express.raw({type: 'application/json'}), (req, res) => {
  const event = stripe.webhooks.constructEvent(
    req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET
  );
  if (event.type === 'checkout.session.completed') {
    // provision user
  }
  res.json({received: true});
});
Enter fullscreen mode Exit fullscreen mode

This breaks in production. Here's why.

Problem 1: Duplicate events

Stripe will deliver the same event multiple times if your endpoint is slow or fails. Make handlers idempotent.

async function handleWebhook(event: Stripe.Event) {
  const alreadyProcessed = await redis.get(`stripe:event:${event.id}`);
  if (alreadyProcessed) return { skipped: true };

  await processEvent(event);
  await redis.setex(`stripe:event:${event.id}`, 86400, '1'); // 24hr TTL
}
Enter fullscreen mode Exit fullscreen mode

Problem 2: Async processing timing out

Stripe expects a 200 response within 30 seconds. Complex provisioning takes longer.

app.post('/webhooks/stripe', async (req, res) => {
  const event = stripe.webhooks.constructEvent(...);

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

  // Process asynchronously via queue
  await queue.add('stripe-webhook', { event });
});
Enter fullscreen mode Exit fullscreen mode

Problem 3: Wrong events for the wrong use case

// checkout.session.completed — checkout UI finished
// invoice.payment_succeeded — money actually moved (use for subscriptions)
// payment_intent.succeeded — one-time payments

const handlers: Partial<Record<Stripe.Event.Type, Handler>> = {
  'checkout.session.completed': handleNewSubscription,
  'invoice.payment_succeeded': handleRenewal,
  'invoice.payment_failed': handleFailedPayment,
  'customer.subscription.deleted': handleCancellation,
  'customer.subscription.updated': handlePlanChange,
};
Enter fullscreen mode Exit fullscreen mode

Problem 4: Subscription state transitions

async function handleSubscriptionUpdated(event: Stripe.Event) {
  const sub = event.data.object as Stripe.Subscription;
  const prev = event.data.previous_attributes as Partial<Stripe.Subscription>;

  if (prev?.items) {
    const oldPrice = prev.items.data[0].price.id;
    const newPrice = sub.items.data[0].price.id;
    if (oldPrice !== newPrice) await handlePlanChange(sub.customer as string, oldPrice, newPrice);
  }

  if (prev?.status && prev.status !== sub.status) {
    await handleStatusChange(sub.customer as string, sub.status);
  }
}
Enter fullscreen mode Exit fullscreen mode

Problem 5: Raw body parsing behind proxies

// Webhook route MUST use raw body — register before express.json()
app.use('/webhooks/stripe', express.raw({ type: 'application/json' }));
app.use(express.json()); // all other routes
Enter fullscreen mode Exit fullscreen mode

Production-ready handler with BullMQ

app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      req.body,
      req.headers['stripe-signature'] as string,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    return res.status(400).send('Webhook Error');
  }

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

  await webhookQueue.add(event.type, event, {
    jobId: event.id, // BullMQ deduplicates by jobId — no duplicate processing
    attempts: 3,
    backoff: { type: 'exponential', delay: 1000 },
  });
});
Enter fullscreen mode Exit fullscreen mode

jobId: event.id is the key insight — BullMQ rejects duplicate jobs, so even if Stripe sends the same event 5 times, it processes once.


The complete Stripe integration (with webhook handler, customer portal, and plan upgrade logic) is in our AI SaaS starter kit at whoffagents.com.

Read Full Tutorial open_in_new
arrow_back Back to Tutorials