How to Set Up Stripe Webhooks for Failed Payment Recovery (Step-by-Step)
Stop polling for failures. Use Stripe webhooks to catch failed payments in real time and trigger recovery automatically.
Every failed payment is a ticking clock. The longer it takes you to notice, the harder it is to recover. If you're checking Stripe's dashboard manually — or worse, running a daily cron job to poll for failures — you're already losing revenue.
Stripe webhooks let you react to failed payments the instant they happen. A webhook fires, your server receives it, and you can trigger retry logic, send a dunning email, or generate a card update link — all within seconds.
This guide walks you through setting up a stripe webhook failed payment listener from scratch: which events to subscribe to, how to verify signatures, what to do when a failure arrives, and how to test everything locally with the Stripe CLI.
Why Webhooks Beat Polling for Payment Recovery
There are two ways to know when a payment fails: you can ask Stripe repeatedly (polling), or you can tell Stripe to notify you (webhooks).
| Approach | Latency | Reliability | Complexity |
|---|---|---|---|
| Polling (cron job) | Minutes to hours | Misses events between polls | Simple but fragile |
| Webhooks (event-driven) | Seconds | Stripe retries on failure | More setup, much more robust |
With polling, a payment could fail at 2:01 AM and you won't know until your next cron run. With webhooks, you know within seconds. That matters because recovery emails sent within 2 hours have a 3× higher success rate than those sent the next day.
Stripe also handles retries for you. If your webhook endpoint is down, Stripe will retry the delivery with exponential backoff for up to 3 days. Polling gives you none of that resilience.
Which Stripe Webhook Events to Listen For
Stripe fires dozens of event types. For failed payment recovery, you need four:
| Event | When It Fires | What to Do |
|---|---|---|
invoice.payment_failed | A subscription invoice payment attempt fails | Primary trigger. Start your recovery flow: retry logic + dunning email. |
payment_intent.payment_failed | Any PaymentIntent fails (one-time or subscription) | Catch one-time payment failures. Log the decline code for segmentation. |
customer.subscription.updated | Subscription status changes (e.g., active → past_due) | Update your database. Gate features if status is past_due or unpaid. |
charge.failed | A charge attempt is declined | Lower-level event. Useful for logging and analytics on failure reasons. |
Start with invoice.payment_failed. This is the most important event for subscription businesses. The stripe invoice.payment_failed webhook gives you the customer ID, the subscription ID, the amount, and the failure reason — everything you need to trigger recovery.
Event Payload: What You Get
Here's a simplified view of what Stripe sends when an invoice.payment_failed event fires:
{
"type": "invoice.payment_failed",
"data": {
"object": {
"id": "in_1abc123",
"customer": "cus_xyz789",
"subscription": "sub_def456",
"amount_due": 2900,
"currency": "usd",
"attempt_count": 1,
"next_payment_attempt": 1711929600,
"last_payment_error": {
"code": "card_declined",
"decline_code": "insufficient_funds",
"message": "Your card has insufficient funds."
}
}
}
}The decline_code is the key field. It tells you why the payment failed, which determines your recovery strategy.
Step-by-Step: Setting Up a Webhook Endpoint
Let's build a webhook endpoint that receives Stripe failure events and triggers recovery. We'll use Node.js and Express, but the pattern works with any server framework.
Step 1: Install Dependencies
npm install stripe expressStep 2: Create the Webhook Endpoint
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const express = require('express');
const app = express();
// Important: use raw body for signature verification
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature'];
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
endpointSecret
);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Handle the event
switch (event.type) {
case 'invoice.payment_failed':
await handleInvoicePaymentFailed(event.data.object);
break;
case 'payment_intent.payment_failed':
await handlePaymentIntentFailed(event.data.object);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdated(event.data.object);
break;
case 'charge.failed':
await handleChargeFailed(event.data.object);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
// Always return 200 quickly — do heavy work async
res.status(200).json({ received: true });
}
);
app.listen(3000, () => console.log('Webhook server running on port 3000'));Step 3: Register the Endpoint in Stripe
- Go to Stripe Dashboard → Developers → Webhooks
- Click Add endpoint
- Enter your URL:
https://yourdomain.com/webhooks/stripe - Select events:
invoice.payment_failed,payment_intent.payment_failed,customer.subscription.updated,charge.failed - Copy the Signing secret (starts with
whsec_) and save it asSTRIPE_WEBHOOK_SECRETin your environment
Signature Verification: Don't Skip This
The stripe.webhooks.constructEvent()call in Step 2 isn't optional. It verifies that the webhook actually came from Stripe using HMAC-SHA256 signature verification.
Without signature verification, anyone can POST fake events to your endpoint. An attacker could:
- Trigger fake “payment succeeded” events to get free access
- Flood your endpoint with fake failures to spam your customers
- Manipulate customer IDs to access other accounts
Common Signature Verification Mistakes
// WRONG: Using express.json() parses the body before
// stripe can verify the raw signature
app.use(express.json()); // <-- This breaks verification
// CORRECT: Use express.raw() for the webhook route only
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
handler
);
// If you need JSON parsing for other routes, scope it:
app.use('/api', express.json()); // JSON for API routes
// Webhook route uses raw body (defined before this)Key rule: The request body must be the raw buffer, not a parsed JSON object. If you use express.json() globally, Stripe signature verification will fail every time.
What to Do When a Payment Fails
Receiving the webhook is only half the job. Here's what your handleInvoicePaymentFailed function should do:
1. Extract the Decline Code
async function handleInvoicePaymentFailed(invoice) {
const declineCode =
invoice.last_payment_error?.decline_code || 'unknown';
const customerId = invoice.customer;
const attemptCount = invoice.attempt_count;
// Route to the right recovery strategy
switch (declineCode) {
case 'insufficient_funds':
// Don't email yet — retry in 2-3 days
await scheduleRetry(invoice, { delayDays: 2 });
if (attemptCount >= 2) {
await sendDunningEmail(customerId, 'insufficient_funds');
}
break;
case 'expired_card':
// Retrying won't help — email immediately
await sendCardUpdateEmail(customerId);
break;
case 'card_declined':
case 'do_not_honor':
// Soft decline — retry once, then email
if (attemptCount === 1) {
await scheduleRetry(invoice, { delayDays: 1 });
} else {
await sendDunningEmail(customerId, declineCode);
}
break;
default:
// Unknown decline — log and email
console.warn(`Unknown decline code: ${declineCode}`);
await sendDunningEmail(customerId, 'generic');
}
}2. Send a Dunning Email
Your dunning email should include a one-click card update link — a Stripe Customer Portal URL or a custom page with Stripe Elements:
async function sendCardUpdateEmail(customerId) {
// Generate a Stripe Customer Portal session
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: 'https://yourapp.com/account',
});
await sendEmail({
to: customer.email,
subject: 'Quick heads-up about your payment',
body: `Hi — your card on file needs updating.
Click here to fix it in 30 seconds:
${session.url}`,
});
}3. Schedule Smart Retries
Stripe Smart Retries handle automatic retries, but you can layer your own retry logic for more control — especially for routing different decline codes to different retry schedules:
async function scheduleRetry(invoice, { delayDays }) {
// Option 1: Let Stripe handle it (recommended baseline)
// Stripe will retry on its own schedule via Smart Retries
// Option 2: Manual retry with a delay
// Store the retry task in your job queue
await jobQueue.schedule({
type: 'retry_invoice',
invoiceId: invoice.id,
runAt: new Date(Date.now() + delayDays * 86400000),
});
}Handling Idempotency
Stripe may deliver the same webhook event more than once. Your handler must be idempotent — processing the same event twice should have no side effects.
async function handleInvoicePaymentFailed(invoice) {
// Check if we've already processed this event
const alreadyProcessed = await db.webhookEvents.findOne({
eventId: invoice.id,
type: 'invoice.payment_failed',
attemptCount: invoice.attempt_count,
});
if (alreadyProcessed) {
console.log('Duplicate event, skipping');
return;
}
// Process the event...
await processFailure(invoice);
// Mark as processed
await db.webhookEvents.insert({
eventId: invoice.id,
type: 'invoice.payment_failed',
attemptCount: invoice.attempt_count,
processedAt: new Date(),
});
}Pro tip: Use a compound key of event.id + attempt_count for deduplication. The same invoice can fail multiple times, and each failure is a new event you do want to process.
Common Mistakes That Cost You Revenue
| Mistake | Impact | Fix |
|---|---|---|
| Not verifying webhook signatures | Security vulnerability — attackers can forge events | Always use stripe.webhooks.constructEvent() |
Using express.json() globally | Breaks signature verification silently | Use express.raw() for the webhook route |
| Not handling idempotency | Duplicate emails, double retries | Deduplicate by event ID + attempt count |
| Treating all declines the same | Wrong recovery action (e.g., retrying an expired card) | Route by decline_code |
| Slow webhook response (>30s) | Stripe marks delivery as failed, retries | Return 200 immediately, process async |
| No grace period before cancellation | Customers churn before they can fix their card | Give 7-14 days before pausing access |
Ignoring attempt_count | No escalation — same email on attempt 1 and attempt 4 | Escalate urgency based on attempt count |
Testing with the Stripe CLI
You don't need to deploy to test webhooks. The Stripe CLI lets you forward events to your local server:
Step 1: Install and Login
# macOS
brew install stripe/stripe-cli/stripe
# Login to your Stripe account
stripe loginStep 2: Forward Events to Localhost
stripe listen --forward-to localhost:3000/webhooks/stripeThis prints a webhook signing secret (starts with whsec_). Use this as your STRIPE_WEBHOOK_SECRET for local testing.
Step 3: Trigger Test Events
# Trigger a failed invoice payment
stripe trigger invoice.payment_failed
# Trigger a failed payment intent
stripe trigger payment_intent.payment_failed
# Trigger a failed charge
stripe trigger charge.failedYou should see the event arrive in your terminal and your handler function fire. Verify that:
- Signature verification passes (no 400 errors)
- The correct handler function runs for each event type
- Your recovery logic triggers (email queued, retry scheduled)
- Duplicate events are handled gracefully
Step 4: Test Edge Cases
# Resend a specific event from your Stripe logs
stripe events resend evt_1abc123
# Trigger multiple events quickly to test idempotency
stripe trigger invoice.payment_failed
stripe trigger invoice.payment_failedProduction Checklist
Before going live, make sure you've covered these:
- Signature verification — Using
constructEvent()with raw body - Idempotency — Deduplication by event ID
- Fast response — Return 200 within 5 seconds, process async
- Decline code routing — Different actions for
insufficient_fundsvsexpired_cardvscard_declined - Logging — Log every event for debugging and audit trails
- Alerting — Get notified if your webhook endpoint starts failing
- HTTPS only — Stripe requires HTTPS for production endpoints
- Retry awareness — Know that Stripe retries failed deliveries for up to 3 days
When to Use a Dedicated Tool vs. DIY
Building webhook handlers is straightforward. Building a completepayment recovery system is not. Here's what goes beyond a basic webhook listener:
- Multi-stage dunning sequences — Escalating emails over 14 days with different messaging per decline code
- Smart retry scheduling — ML-optimized retry timing based on decline code, customer history, and time of day
- Hosted card update pages — Branded, no-login pages where customers can update their card in one click
- Recovery analytics — Dashboard showing recovery rates by decline code, email performance, and revenue impact
- Idempotency and deduplication at scale — Handling millions of events without duplicates or race conditions
If you're at the stage where you have a handful of customers, a DIY webhook handler is fine. But once failed payments start costing you real revenue (typically around $5K+ MRR), the engineering time to build, maintain, and optimize a full recovery stack rarely makes sense.
A dedicated tool handles all of this out of the box and continuously optimizes recovery rates based on data from thousands of businesses.
Webhooks are the foundation of any payment recovery system. Get them right, and you've built the real-time pipeline that everything else — retries, emails, card updates — depends on.
Get them wrong, and you're flying blind while revenue leaks out the door.
Recover failed payments automatically
RetryHero handles smart retries, branded emails, and one-click card updates for $29/mo flat.
Join the waitlist →