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.
Most webhook failures fall into one of four buckets:
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.
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.
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.
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.
Stripe's retry schedule (as of 2026) for failed webhook deliveries:
| Attempt | Delay after previous attempt | Cumulative 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.
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.
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.
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 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:
STRIPE_SECRET_KEY — your Stripe secret keySTRIPE_WEBHOOK_SECRET — from Stripe dashboard → Webhooks → signing secretALERT_EMAIL — where to send failure alertsSMTP_URL — e.g. smtp://user:pass@smtp.gmail.com:587The 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.
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.
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 →Retry schedule sourced from Stripe's webhook documentation, September 2026. Behavior may change — verify against current Stripe docs.