Learn

Handling invoice.payment_failed Webhooks

Stripe's invoice.payment_failed webhook is the cornerstone signal for any payment recovery system. When a subscription charge fails, this webhook fires — and what you do next determines whether that failure becomes lost revenue or a successful recovery. This guide covers how to handle invoice.payment_failed properly in production, including the patterns that separate robust recovery systems from fragile ones.

When invoice.payment_failed Fires

Stripe emits invoice.payment_failed when an invoice associated with a subscription fails to collect payment. This includes the first failure on a new invoice and subsequent failures during Stripe's automatic retry cycle (if you have Smart Retries enabled).

The event payload includes the full invoice object, with critical fields: customer, subscription, attempt_count (how many times Stripe has tried), next_payment_attempt (the Unix timestamp of Stripe's next automatic retry, or null if Stripe will not retry again), and last_finalization_error (details about why the charge failed).

Importantly, invoice.payment_failed fires for every failure attempt, not just the first. If you want to take action only on the first failure, check attempt_count === 1 in your handler. If you want to take action only when Stripe has stopped trying, check next_payment_attempt === null.

Minimum Viable Webhook Handler

A production-ready handler needs to do four things: verify the webhook signature, idempotently process the event, take appropriate action, and return a 200 response quickly.

Signature verification uses your Stripe webhook secret and Stripe's signing algorithm. Reject any request with an invalid signature — this prevents attackers from forging events. Stripe's SDK includes constructEvent() which handles this correctly.

Idempotency matters because Stripe retries webhook delivery if your endpoint returns a non-2xx response or times out. Store the event ID in your database and check it before processing. If you have already seen the event, return 200 immediately without re-running your logic.

Action depends on your business. At minimum: log the failure, update your internal state to reflect past-due status, and queue a recovery action (retry, email, or both).

Return 200 within Stripe's 30-second timeout. If your recovery action takes longer, queue it in a background job and return 200 immediately.

Extracting the Decline Code

The decline code is the single most important piece of information in the invoice.payment_failed event — it determines your recovery strategy. Unfortunately, extracting it requires a little work.

The decline code is in invoice.last_finalization_error.decline_code or invoice.charge.outcome.reason, depending on how the invoice was attempted. In some cases you need to retrieve the associated charge or PaymentIntent via the API to get the full failure details.

A robust handler tries multiple extraction paths: first invoice.last_finalization_error.decline_code, then invoice.charge.outcome.reason, then stripe.charges.retrieve(invoice.charge) as a fallback. Cache the result on your internal invoice record so you do not re-fetch.

Once you have the decline code, map it to a recovery strategy. insufficient_funds → delayed retry. processing_error → fast retry. expired_card → immediate customer email, no retry.

Coordinating With Stripe's Automatic Retries

If you have Stripe Smart Retries enabled, Stripe will automatically retry the invoice on its own schedule. You need to coordinate your own recovery actions with Stripe's to avoid double-retries or conflicting customer messaging.

Check the next_payment_attempt field. If it is a future timestamp, Stripe is planning another retry. You generally should not manually retry before that timestamp — it wastes API calls and can trigger additional declines.

Instead, use Stripe's automatic retries for the baseline and layer your own actions on top: send a recovery email after the first failure, send a reminder after the second failure, and escalate if Stripe's final retry fails (next_payment_attempt === null).

This coordination pattern gives you the best of both worlds — Stripe's network-optimized retry timing plus your own customer communication layer.

Common Mistakes to Avoid

Mistake 1: Not verifying webhook signatures. This is a security hole that lets attackers forge payment failure events. Always verify.

Mistake 2: Not handling duplicate events idempotently. Stripe may deliver the same event twice. Without idempotency, you send duplicate emails and take duplicate actions. Store event IDs and check before processing.

Mistake 3: Processing recovery logic synchronously inside the webhook handler. If your logic takes more than a few seconds, Stripe times out and retries the webhook, causing duplicate processing. Always queue recovery work in a background job and return 200 quickly.

Mistake 4: Sending a recovery email on every failed attempt. If Stripe retries 4 times, the customer gets 4 emails. Instead, send one email on the first failure (or escalate cadence: email 1 after first failure, email 2 after third failure).

Mistake 5: Not distinguishing between recoverable and non-recoverable decline codes. Sending a 'please update your card' email for a processing_error confuses the customer — nothing is wrong with their card.

Why Revive Eliminates the Entire Webhook Layer

Building a production-grade invoice.payment_failed handler takes 1-2 weeks of engineering work: signature verification, idempotency, decline code extraction, retry coordination, background job queue, email templating, customer portal integration, analytics. And then you have to maintain it forever as Stripe's API evolves.

Revive handles all of this. You connect Stripe via OAuth, and Revive subscribes to invoice.payment_failed (and a dozen other relevant events) automatically. Decline codes are extracted, routed to the right recovery flow, and acted on within seconds. You do not write any webhook handlers, do not maintain any recovery infrastructure, and do not worry about Stripe API changes.

For most SaaS businesses, Revive pays for itself in saved engineering time alone — before you even count the recovered revenue.

Key Takeaways

  • invoice.payment_failed fires on every failure attempt, not just the first
  • Always verify webhook signatures and handle duplicates idempotently
  • Extract the decline code from invoice.last_finalization_error to drive recovery strategy
  • Coordinate your own recovery actions with Stripe's automatic retry schedule
  • Queue recovery work in background jobs to stay within the 30-second webhook timeout
  • Revive handles all of this automatically so you never write a webhook handler

Automate Your Payment Recovery

Revive uses everything in this guide — smart retries, decline-code routing, and branded recovery emails — on autopilot. Connect Stripe in 30 seconds.