2026-03-23 · RetryKit Team
Stripe Webhook Payment Failed: What It Means and How to Act on It
A practical guide to handling the Stripe invoice.payment_failed webhook — what triggers it, what data it contains, and how to turn failed payments into recovered revenue.
If you run a SaaS business on Stripe, there's one webhook you cannot afford to treat as a generic alert: invoice.payment_failed. It fires every time a subscription payment doesn't go through — and what you do in the next 48 hours determines whether that customer recovers or silently churns.
Most teams handle this event too simply: send an email, queue a retry, hope it works. The data Stripe sends with this event is more useful than that. This is a guide to actually using it.
What Triggers invoice.payment_failed
Stripe fires invoice.payment_failed when a charge attempt on a subscription invoice is declined. This can happen because:
- The cardholder's account doesn't have enough balance (
insufficient_funds) - The stored card is past its expiration date
- The bank blocked the transaction for risk, fraud, or velocity reasons
- CVV, billing zip, or card number failed verification
- A transient connectivity problem between Stripe and the card network
Each of these failure modes requires a different response. They are not the same problem wearing the same label. insufficient_funds is retriable — the customer wants to pay, their balance was just low. An expired_card is not retriable — the card is dead, you need a new one.
Handling both identically is where most dunning systems break down.
What's Actually in the Webhook Payload
The invoice.payment_failed event payload looks like this:
{
"type": "invoice.payment_failed",
"data": {
"object": {
"id": "in_xxxxx",
"customer": "cus_xxxxx",
"subscription": "sub_xxxxx",
"amount_due": 4900,
"currency": "usd",
"attempt_count": 1,
"next_payment_attempt": 1745012800,
"last_finalization_error": null,
"charge": "ch_xxxxx"
}
}
}
Three fields do the heavy lifting here:
attempt_count — how many times Stripe has already tried this invoice. If it's 1, this is the first failure. If it's 3, you're at the end of your Smart Retries window. The urgency of your response should scale with this number.
next_payment_attempt — a Unix timestamp for Stripe's next automatic retry (when Smart Retries is enabled). Use this to time your dunning emails so they land before the next attempt, not after. An email sent between retries keeps the customer informed without being triggered by an event that already resolved.
charge — the ID of the failed charge object. This is the key. Fetch this object and you get the outcome.decline_code — which tells you exactly why the payment failed and what to do about it.
Reading the Decline Code: The Step Most Teams Skip
The charge object has an outcome field containing a decline_code. This is the most underused signal in Stripe webhook handling.
| Decline Code | What It Means | Best Response |
|---|---|---|
| insufficient_funds | Card works, account was empty at billing time | Wait 3-5 days and retry; no email on first failure |
| expired_card | Card is past expiry date | Email immediately with update link; stop retrying |
| do_not_honor | Bank blocked for unknown reason | Retry once at 24 hours, then email |
| fraudulent | Issuer flagged as fraud | Do not retry; reach out personally for high-value customers |
| stolen_card, lost_card | Card no longer exists | Email immediately; do not retry |
| processing_error | Transient network issue | Retry within 1-6 hours |
If you're retrying a fraudulent decline, you're burning attempts and potentially triggering additional fraud flags at the issuer level. If you're emailing a customer about insufficient_funds before running a single retry, you're creating friction for a problem that often resolves on its own.
The most common decline code we see across RetryKit's connected accounts is insufficient_funds — and it's recoverable precisely because we don't alarm the customer on the first failure. We retry it on a schedule (1 day, 3 days, 5 days, 7 days, 14 days) and only fire dunning emails at retry 2 and retry 4. A $5.99 invoice recovered after 4 attempts over 5 days is a good example of this approach working exactly as intended.
The Retry + Email Coordination Problem
Most dunning setups handle invoice.payment_failed at the invoice level without reading the charge. Here's the failure mode that creates:
invoice.payment_failedfires- System immediately sends a "please update your payment method" email
- Stripe's Smart Retries kicks in 24 hours later and the payment succeeds
invoice.payment_succeededfires- Customer gets a second email: "payment succeeded"
- Customer got two emails about a problem they never needed to see
That's noise. It creates confusion, erodes trust, and makes your dunning system look broken — even when it technically worked.
The better approach:
invoice.payment_failedfires- Fetch the charge, read the decline code
- Hard decline (
expired_card, wrong details) → email immediately with card update link - Soft decline (
insufficient_funds, bank decline) → queue retry, checknext_payment_attempt, suppress email until retry 2 fails - If Stripe's retry succeeds → no email, suppress dunning
- Only send dunning escalation after
attempt_counthits 2+
This keeps customers out of an unnecessary loop and focuses communication on the failures that actually need their attention.
Handling invoice.payment_action_required — Don't Miss This
There's a related event many developers miss entirely: invoice.payment_action_required. This fires when a payment fails because it requires 3D Secure authentication. The cardholder has to complete a verification step in their browser.
Stripe cannot auto-retry this one. The authentication step requires user action — there's no way to complete it programmatically on their behalf.
If you're not listening for this event separately, customers in EU/EEA regions who need to re-authenticate will silently reach subscription cancellation without ever getting a chance to complete the verification. They didn't decline to pay; the payment just required extra authentication that never happened.
Handle invoice.payment_action_required with an immediate email containing a hosted invoice link or payment confirmation URL. This is particularly important if you have customers in France, Germany, or other markets where SCA/3DS is common. One of our live connected accounts is in France — handling this event correctly makes a meaningful difference in recovery rates for that account.
Webhook Reliability: The Idempotency Requirement
Stripe webhooks can be delivered more than once. If your endpoint is slow or returns a non-2xx response, Stripe will retry delivery — sometimes multiple times over several hours.
Without idempotency protection, you'll get duplicate emails, duplicate database records, and duplicate CRM entries. Your recovery data becomes unreliable and your customers get bombarded.
The safest pattern:
// 1. On receipt, immediately save the event ID
const existingEvent = await db.stripeEvents.findOne({ eventId: event.id });
if (existingEvent) {
return res.status(200).json({ received: true }); // Already processed
}
await db.stripeEvents.create({ eventId: event.id, processedAt: new Date() });
// 2. Return 200 immediately, process async
res.status(200).json({ received: true });
// 3. Process the event in a background job
await queue.add('process-payment-failed', { eventId: event.id });
Return 200 before processing, not after. Stripe's delivery timeout is short. If your processing logic is slow and you try to do everything synchronously before responding, you'll get delivery failures and duplicate events.
The Recovery Window You Have
Stripe's Smart Retries attempt the payment up to 4 times over roughly 2 weeks. After the final attempt fails, Stripe marks the subscription as past_due or cancels it, depending on your subscription settings.
Once a subscription is fully canceled in Stripe, recovery drops significantly. The customer has to re-enter payment details and complete a new checkout flow. Reactivation rates on canceled subscriptions run 30-40% lower than on past_due subscriptions where the billing relationship is still intact.
The window between the first invoice.payment_failed and cancellation is your entire recovery opportunity. Most of it gets wasted on generic emails that don't segment by decline type and retries that don't adjust timing based on why the payment failed.
A Recovery Handler That Uses the Signal
A well-built invoice.payment_failed handler:
- Reads the decline code before deciding what to do
- Segments by failure type: retriable soft decline, dead card, fraud flag
- Times emails relative to
next_payment_attempt, not arbitrary fixed offsets - Listens separately for
invoice.payment_action_required(3DS) andcustomer.subscription.deleted - Suppresses dunning when a retry succeeds on its own
- Handles deduplication via event ID storage
Tools like RetryKit implement this out of the box — routing each failure through the right recovery path based on decline type, subscription age, and retry history. When you connect your Stripe account, it also auto-scans historical failed invoices, so you can see what's been sitting unrecovered before doing anything else.
The invoice.payment_failed event isn't a final verdict. It's the starting signal for a recovery process. The data is there. Most teams just aren't reading it.
Ready to recover lost revenue?
Connect your Stripe account in under 2 minutes. Pay only on recovered revenue.
Try RetryKit Free