← Blog

Handling the invoice.payment_failed webhook without duplicate emails or dead links

June 4, 2026 · 6 min read

The Stripe invoice.payment_failed webhooklooks like the obvious place to send a “your payment failed” email — and it is. The trouble is what most first-draft handlers do aroundthat email: they fire on every retry, send a stale or self-built pay link, and keep nagging customers after Stripe has already given up. Get the event's fields right and the same webhook becomes a clean, single-track dunning sequence.

What invoice.payment_failed actually fires on

The event fires on everyfailed charge attempt for an invoice — not just the first. When Stripe runs its automatic retries (Smart Retries), each attempt that fails emits another invoice.payment_failed. So a single unpaid invoice can produce four or five of these events over a week or two. A naive handler that sends one email per event will spam the customer with N near-identical “payment failed” messages, which is the fastest way to train people to ignore the one email that actually matters.

The fields that carry the signal:

  • attempt_count— how many times Stripe has tried this invoice. Use it to drive escalating copy (a gentle nudge at 1, a firmer notice later) instead of resending the same message.
  • next_payment_attempt— a timestamp if another retry is scheduled, or null when Stripe is done trying. This is your signal to stop.
  • hosted_invoice_url— the current Stripe-hosted page where the customer can pay or update their card. Always read it from the event payload; never reconstruct or cache it.

It also helps to know the neighbors. invoice.payment_action_required fires when a charge needs customer authentication (3D Secure), which is a different message than a hard decline. And customer.subscription.updatedis where the subscription's status moves to past_due and eventually to unpaid or canceled— the terminal state you reconcile against when the retries run out.

Bug #1: emailing a stale or self-built pay link

The single most damaging mistake is sending the customer a link you built yourself, or one you cached from an earlier event. Stripe's hosted invoice URLs are not permanent — they can change as the invoice is re-finalized across retries, and a hand-assembled URL based on a stored invoice ID is even more fragile. When that link 404s or silently shows an already-paid page, the customer assumes they fixed it (or that you're broken) and the recovery quietly dies. No bounce, no error — just a dead end.

  • Send the payload's current hosted_invoice_url.Pull it from the exact event you're processing, not from a column you wrote last Tuesday.
  • Re-fetch rather than cache.If you must look the invoice up (for example, when processing asynchronously), re-retrieve it from the Stripe API so you get today's URL, not a snapshot.
  • Don't store the URL as the source of truth. Store the invoice ID and resolve the link at send time.

Bug #2: no dedupe, so customers get N failure emails

Because each retry re-fires the event, a handler with no deduplication sends one email per attempt. The fix is two layers of idempotency: one for the customer-facing cadence, and one for the webhook delivery itself.

  • Gate copy on attempt_count, not on the event.Decide which message to send (if any) based on how many attempts have happened — so attempt 1 gets the first email, a later attempt gets the escalation, and intermediate attempts may get nothing at all.
  • Dedupe redeliveries by Stripe event id. Stripe can deliver the same event more than once, and will retry delivery if your endpoint is slow or errors. Record each event idyou've processed and ignore repeats.
  • Use idempotency keys for downstream side effects.Any action with consequences — sending mail, charging, flipping a flag — should be keyed so a double-delivery can't double-fire it.

Together these mean a customer hears from you on a deliberate schedule, regardless of how many raw events Stripe emits behind the scenes.

Knowing when to stop: next_payment_attempt is null

The field that tells you the retries are finished is next_payment_attempt. When it's a timestamp, Stripe has another attempt queued, so a “we'll try again” message is honest. When it's null, there is no further retry scheduled — and continuing to promise another attempt is now a lie that erodes trust.

  • Stop the dunning sequence and switch to a final notice, then a one-click win-back, rather than repeating the recovery emails.
  • Reconcile with the terminal status. When retries exhaust, the subscription typically lands in unpaid or canceleddepending on your Stripe settings — confirm against customer.subscription.updated so your records and your messaging agree.

For the bigger picture on cadence and tone across the whole sequence, see our guide to Stripe dunning best practices.

A minimal correct handler

You don't need much code — you need the right order of operations. A correct handler tends to look like this:

  • Verify the signature first, then return 2xx fast. Validate the webhook signature, acknowledge the event quickly, and do the real work asynchronously so a slow email send never triggers Stripe to redeliver.
  • Branch on the two fields. Use attempt_count to choose the email (or skip), and check next_payment_attempt to decide whether to continue or stop.
  • Persist a last-processed marker per invoice.Keep the highest attempt you've acted on, plus the set of processed event ids, so redeliveries and out-of-order events are safe.
  • Tailor the message with the decline code.The failed charge carries a decline reason — insufficient funds reads differently than an expired card — so pull it through and match the copy to the cause.

If you're wiring this up by hand, the webhooks documentation and the REST API reference cover the event shape and how to re-fetch the invoice cleanly.

The hidden maintenance you're signing up for

Parsing the event is the easy 20%. The part that doesn't show up in a tutorial is everything that keeps a dunning system working month after month:

  • Deliverability.Recovery email is transactional-but-commercial and lands in spam easily. You'll be maintaining SPF/DKIM/DMARC alignment, a suppression list, and bounce handling so a hard bounce doesn't keep retrying a dead address.
  • Link freshness across edge cases.Partial payments, re-finalized invoices, and proration changes can all shift what the customer should see — the link has to stay correct through all of it.
  • Localized, escalating copy. Different messages per attempt, per locale, and the discipline to stop mid-sequence the moment a customer cancels or pays.
  • Staying correct as Stripe evolves. Retry behavior and invoice mechanics change; your handler has to be re-verified when they do.

Where Backstop fits

Backstop handles all of this plumbing for you: it dedupes the events, gates escalating copy on attempt_count and the underlying decline code, always sends today's fresh hosted card-update link, and stops cleanly the moment next_payment_attempt goes null — switching to a final notice and one-click win-back instead of another “we'll try again.” The 4-touch sequence sends from your own verified domain, so deliverability is handled rather than hoped for. If you'd rather not own this handler and its maintenance forever, see pricing or start free and have it running on your Stripe account in about ten minutes.

Backstop recovers failed Stripe payments and saves canceling subscribers.

Smart retries, a visual cancel flow, and a hosted portal — flat $79/mo, a free tier, and 0% revenue share.