← Blog  ·  September 2026

Why your Stripe webhooks fail silently — and how to stop missing orders

The customer said "I never received my order." You checked your app. No errors. The webhook failed three days ago — and nobody knew.

A customer opens a support ticket: their payment went through but their order was never created. You dig in. The Stripe charge is there. Your app has no logs about this customer. Then you check Stripe's webhook dashboard and find it: a delivery failure from 72 hours ago, marked as "Failed after all retries."

Stripe tried to reach your server seven times over three days. Your server returned 500 each time. And then Stripe stopped trying — silently, without emailing you, without flagging it in your logs, without doing anything except updating a row in their database that you'd only find if you knew to look.

This happens more than it should. Here's why, and what to do about it.


Why webhooks fail silently in the first place

Most webhook failures fall into one of four buckets:

1. Your handler returns 500 (or times out)

The most common cause. Your handler throws an exception — database connection drop, null pointer, bad JSON parsing — and returns a 500 before Stripe marks the event as failed. Stripe sees the 500, waits, and retries. This looks completely normal in your server logs as a spike in 500 errors, which may or may not be monitored.

2. Your handler takes too long

Stripe has a 30-second timeout. If your handler does something slow — expensive DB query, synchronous email sending, external API call — the connection times out from Stripe's side. Stripe treats this as a delivery failure and retries. Your server logs may show the request completing successfully (it did — eventually), but Stripe already gave up.

3. TLS/certificate errors

Expired SSL cert, mismatched hostname, or a reverse proxy misconfiguration. Stripe can't establish a connection, the delivery fails immediately, and nothing in your app logs because your app never received the request.

4. Handler crashes mid-processing

Your handler starts processing, partially updates the database, then throws. You return 500. Stripe retries. Now you have a partial order state and six more incoming retries that each try to create the order again — potentially causing duplicate fulfillment if you're not idempotent.

⚠️ The dangerous scenario Failures during the retry window aren't dangerous by themselves. The dangerous part is that Stripe retries for 72 hours and then stops. At that point, the event is gone from the retry queue. If your webhook powers something critical — order fulfillment, subscription activation, usage reset — it just never happened.

What Stripe actually does when a webhook fails

Stripe's retry schedule (as of 2026) for failed webhook deliveries:

AttemptDelay after previous attemptCumulative time
1 (initial)0
2~1 hour~1 hour
3~3 hours~4 hours
4~5 hours~9 hours
5~10 hours~19 hours
6~24 hours~43 hours
7~24 hours~67 hours (~72h)
Gives up silently

After attempt 7, Stripe marks the event as failed and no longer retries. You won't receive an email. The event won't resurface. The only record is in Stripe's dashboard under Developers → Webhooks → [your endpoint] → Failed deliveries.

See Stripe's webhook retry documentation for the authoritative reference.


How to detect failures today (manual approaches)

Check Stripe's webhook dashboard

Go to Stripe Dashboard → Developers → Webhooks → [your endpoint]. Filter by "Failed" status. This is retroactive — you'll see what failed, but only if you go looking.

Enable Stripe's built-in webhook delivery alerts

Stripe has an optional email alert when a webhook endpoint has consecutive failures. It's not on by default. Enable it under Developers → Webhooks → [endpoint] → Edit → Alert settings. This is the minimum baseline — you'll get an email after multiple failures, but only for that one endpoint, with a delay.

Write a reconciliation script

Run hourly: compare Stripe charges from the last hour against your orders table. Any charge with no corresponding order is a missed webhook. This is the most reliable detection method for order fulfillment specifically, but it's bespoke per use case and doesn't generalize to subscription events, refund events, etc.

The coverage gap None of these approaches give you real-time alerting. The Stripe dashboard is reactive. The email alert fires after consecutive failures, not on the first one. And reconciliation only works if you know exactly what events should create what DB rows. For teams using webhooks from multiple providers — GitHub, Twilio, Shopify, PagerDuty — each has a completely separate detection mechanism.

A webhook receiver that actually tells you when it fails

The following is a minimal Express handler with two additions over the default: error logging to a file, and an email alert on failure. Drop this in and you'll have at least a local record and an immediate notification when something goes wrong.

// webhook-receiver.js
const express = require('express')
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY)
const fs = require('fs')
const nodemailer = require('nodemailer') // npm install nodemailer

const app = express()

// CRITICAL: use raw body for Stripe signature verification
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['stripe-signature']

  let event
  try {
    event = stripe.webhooks.constructEvent(
      req.body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET
    )
  } catch (err) {
    logFailure('signature_verification', err.message, req.headers)
    return res.status(400).send(`Webhook signature error: ${err.message}`)
  }

  try {
    await handleEvent(event)
    res.status(200).json({ received: true })
  } catch (err) {
    logFailure(event.type, err.message, { eventId: event.id })
    await sendFailureAlert(event, err)
    // Return 500 so Stripe retries — but now you're also alerted immediately
    res.status(500).json({ error: 'Handler failed' })
  }
})

async function handleEvent(event) {
  switch (event.type) {
    case 'checkout.session.completed':
      await fulfillOrder(event.data.object)
      break
    case 'customer.subscription.deleted':
      await cancelSubscription(event.data.object)
      break
    default:
      // Unknown event type — log and return 200 so Stripe stops retrying
      console.log(`Unhandled event type: ${event.type}`)
  }
}

function logFailure(type, message, context = {}) {
  const entry = {
    timestamp: new Date().toISOString(),
    type,
    message,
    context,
  }
  fs.appendFileSync('webhook-failures.log', JSON.stringify(entry) + '\n')
  console.error('[WEBHOOK FAILURE]', entry)
}

async function sendFailureAlert(event, err) {
  if (!process.env.ALERT_EMAIL || !process.env.SMTP_URL) return

  const transporter = nodemailer.createTransport(process.env.SMTP_URL)
  await transporter.sendMail({
    from: process.env.ALERT_FROM || 'alerts@yourapp.com',
    to: process.env.ALERT_EMAIL,
    subject: `⚠️ Webhook handler failed: ${event.type}`,
    text: [
      `Event ID: ${event.id}`,
      `Event type: ${event.type}`,
      `Error: ${err.message}`,
      `Time: ${new Date().toISOString()}`,
      '',
      'Stripe will retry this event. Check your handler immediately.',
      `Stripe dashboard: https://dashboard.stripe.com/webhooks`,
    ].join('\n'),
  })
}

async function fulfillOrder(session) {
  // Your order fulfillment logic here
  // Make this idempotent — Stripe may retry the same event
  const idempotencyKey = session.id
  // ... create order with idempotency_key check
}

async function cancelSubscription(subscription) {
  // Your subscription cancellation logic here
}

app.listen(3000, () => console.log('Webhook receiver listening on port 3000'))

Environment variables needed:

The key behavior: when your handler throws, you return 500 (so Stripe retries the event — the right call), AND you immediately log to a file and email an alert. You'll know about the failure within minutes, not after a customer complains three days later.


The provider coverage problem

This handler pattern works for Stripe. But if you're also receiving webhooks from GitHub (deployment hooks, PR events), Twilio (SMS delivery receipts), Shopify (order events), or PagerDuty — each one has its own failure mode and its own separate monitoring story. GitHub doesn't retry at all. Twilio has different retry behavior than Stripe. Shopify's webhook dashboard is in a completely different place.

Most teams end up with a patchwork: Stripe's built-in alerts for Stripe, a custom reconciliation job for orders, nothing for GitHub webhooks, and a support ticket whenever Twilio has a problem. The failure surface is large and the monitoring is inconsistent.

🔔 Passive webhook monitoring across all providers

WebhookPulse monitors every delivery from Stripe, GitHub, Twilio, Shopify, and more — and alerts you within seconds of a failure, with the full response body so you know exactly what broke. One integration, all your providers.

Early access pricing for waitlist members.

Join the WebhookPulse waitlist →

Summary: what to do right now

  1. Enable Stripe's delivery alerts — Developers → Webhooks → [endpoint] → Edit → Alert settings. Takes 30 seconds and catches the worst failures.
  2. Add logging to your webhook handler — at minimum, log every failure with the event ID so you can replay it from the Stripe dashboard.
  3. Make your handlers idempotent — check for an existing record before creating a new one, using the Stripe event ID as a unique key. Stripe will retry, and you don't want duplicate orders.
  4. Return 500 on real failures — don't swallow exceptions and return 200. Return 200 means "I got it and processed it." Return 500 means "retry me." Use them correctly.
  5. Return 200 fast — respond within 30 seconds or Stripe times out. Do expensive work async (queue it) if needed.

Retry schedule sourced from Stripe's webhook documentation, September 2026. Behavior may change — verify against current Stripe docs.

← All articles  ·  WebhookPulse waitlist  ·  AI Crawler Check