Learn

Handling Stripe invoice.payment_failed Webhooks

The invoice.payment_failed webhook is the starting gun for your payment recovery workflow. Every time Stripe fails to collect on a subscription invoice, this event fires. How you handle it determines whether you recover revenue or lose customers. This guide covers the webhook payload, code patterns, and the recovery logic you need to build — or skip entirely by using Revive.

Understanding the invoice.payment_failed Webhook Payload

When Stripe sends an invoice.payment_failed webhook, the payload contains everything you need to build a recovery workflow. Understanding the key fields saves you from parsing unnecessary data and missing critical signals.

The top-level event object has a `type` of 'invoice.payment_failed' and a `data.object` containing the full invoice object. Here are the fields that matter for recovery:

`data.object.subscription` — The subscription ID this invoice belongs to. Use this to look up the subscription status, the customer's plan, and their billing history.

`data.object.customer` — The customer ID. Use this to look up the customer's email, name, and payment methods.

`data.object.attempt_count` — How many times Stripe has attempted to collect this invoice. On the first failure, this is 1. On the second attempt (first retry), this is 2. Use this to determine where you are in the recovery sequence.

`data.object.next_payment_attempt` — A Unix timestamp of when Stripe will next retry this invoice. If this is null, Stripe has exhausted its retry schedule and will not retry again automatically.

`data.object.charge` — The ID of the failed charge. Use this to look up the specific decline code.

`data.object.payment_intent` — The Payment Intent ID. For businesses using the Payment Intents API (which is most businesses now), this is the primary object for understanding the failure. The Payment Intent contains the `last_payment_error` object with the decline code.

To get the decline code, you need to either expand the charge or the payment intent in your webhook handler. The decline code lives at `payment_intent.last_payment_error.decline_code` or `charge.failure_code`. This is the single most important data point for your recovery logic — it determines whether to retry, email, or send a 3DS authentication link.

Here is the essential data extraction pattern in pseudocode:

``` invoice = event.data.object customer_id = invoice.customer subscription_id = invoice.subscription attempt = invoice.attempt_count charge = stripe.charges.retrieve(invoice.charge) decline_code = charge.failure_code ```

With these five data points — customer, subscription, attempt count, next retry time, and decline code — you have everything needed to route the failure into the correct recovery path.

Building a Decline-Code Router

The core of a payment recovery system is a decline-code router: logic that reads the decline code and triggers the appropriate recovery action. Here is how to build one.

Your router should categorize decline codes into three buckets: retry-only (no customer contact needed), email-immediately (retries will not help), and blended (retry first, email as backup).

Retry-only codes: processing_error, try_again_later, reenter_transaction, issuer_not_available. These are infrastructure problems. Schedule a retry within 2-6 hours and do not contact the customer. If the retry succeeds, the customer never knows anything happened.

Email-immediately codes: expired_card, stolen_card, lost_card, card_not_supported, invalid_account, pickup_card. These are hard declines where the card is permanently unusable. Do not retry — it will never succeed and wastes your retry attempts. Send a recovery email with a payment update link immediately.

Blended codes: insufficient_funds, card_declined (generic), do_not_honor, card_velocity_exceeded. These might resolve on retry but also benefit from customer outreach. Schedule a retry for the optimal time (payday-aligned for insufficient funds, 24-48 hours for generic declines), and prepare a recovery email to send if the retry fails.

Authentication codes: authentication_required. Do not retry without customer interaction. Send a payment link with the 3DS challenge embedded so the customer can authenticate and pay in one step.

Here is the router in pseudocode:

``` switch decline_code: case 'processing_error', 'try_again_later', 'reenter_transaction': schedule_retry(invoice, hours=4) case 'expired_card', 'stolen_card', 'lost_card': send_recovery_email(customer, type='hard_decline') case 'insufficient_funds': schedule_retry(invoice, days=3, align_payday=true) prepare_backup_email(customer, send_if_retry_fails=true) case 'card_declined', 'do_not_honor': schedule_retry(invoice, hours=48) prepare_backup_email(customer, send_if_retry_fails=true) case 'authentication_required': send_payment_link(customer, include_3ds=true) default: schedule_retry(invoice, hours=48) prepare_backup_email(customer, send_if_retry_fails=true) ```

This router handles about 95% of real-world decline codes. For the edge cases (rare codes like 'withdrawal_count_limit_exceeded' or 'currency_not_supported'), the default case provides a reasonable fallback.

The most important implementation detail: make this handler idempotent. Stripe may deliver the same webhook event more than once. If your handler sends an email on each invocation, a duplicate event means a duplicate email. Use the event ID or invoice ID as a deduplication key. Check if you have already processed this event before taking action.

Coordinating Retries with Customer Communication

The hardest part of building a payment recovery system is not the individual pieces (retries, emails, payment links) — it is coordinating them so they work together without conflicting.

The nightmare scenario: your system retries a charge and succeeds at 10:15 AM. At 10:16 AM, your email system sends a 'please update your card' email that was queued before the retry. The customer clicks the link, sees that their subscription is active and payment is current, and wonders why you are harassing them. This erodes trust and makes your next recovery email less likely to be opened.

Preventing this requires a coordination layer between your retry system and your email system. Here is the pattern:

Step 1: Use a state machine for each failing invoice. Track the invoice through states: failed -> retrying -> retry_succeeded / retry_failed -> emailing -> recovered / lapsed. Each state transition triggers the appropriate action and suppresses inappropriate ones.

Step 2: Listen for invoice.payment_succeeded. When a retry succeeds, Stripe fires this event. Your handler should immediately cancel any pending recovery emails for this invoice and remove any in-app banners.

Step 3: Add a pre-send check to your email system. Before sending any recovery email, check the current invoice status in Stripe. If the invoice is paid, do not send the email. This is a safety net for race conditions between retries and emails.

Step 4: Use send delays, not immediate sends. Instead of sending recovery emails immediately when triggered, queue them with a short delay (15-30 minutes). This gives retries time to complete and allows your payment_succeeded handler to cancel the email if the retry worked.

Here is the coordination in pseudocode:

``` // On invoice.payment_failed: set_invoice_state(invoice_id, 'failed') if should_retry(decline_code): schedule_retry(invoice_id, calculate_retry_time(decline_code)) set_invoice_state(invoice_id, 'retrying') if should_email(decline_code, attempt_count): queue_email(customer_id, type=decline_code, delay_minutes=30)

// On invoice.payment_succeeded: set_invoice_state(invoice_id, 'recovered') cancel_pending_emails(invoice_id) remove_in_app_banner(customer_id)

// Pre-send email check: if get_invoice_state(invoice_id) == 'recovered': skip_email() else: send_email() ```

This pattern prevents conflicting messages, but it requires infrastructure: a state store (database or cache), a job queue for delayed emails, and webhook handlers for both failed and succeeded events. For a team building this from scratch, expect 2-4 weeks of development time to get it right, including edge cases like multiple invoices failing simultaneously for the same customer.

Production Considerations and Edge Cases

Building a webhook handler that works in development is straightforward. Building one that works reliably in production requires handling several edge cases that are easy to miss.

Webhook signature verification. Always verify the webhook signature using Stripe's library before processing the event. Without verification, anyone can send fake webhook events to your endpoint, potentially triggering recovery emails or retries for non-existent failures. Stripe provides helper methods in every SDK: `stripe.webhooks.constructEvent(body, signature, secret)`.

Idempotency. Store processed event IDs and skip duplicates. Stripe's retry policy means you may receive the same event 2-3 times. Without idempotency, you will send duplicate emails or schedule duplicate retries.

Ordering. Webhook events can arrive out of order. You might receive invoice.payment_succeeded before invoice.payment_failed if Stripe's retry happened quickly. Your handler should be order-independent. The state machine pattern helps: if the invoice is already in 'recovered' state when a 'failed' event arrives, ignore the 'failed' event.

Timeouts. Stripe expects your webhook endpoint to respond within 20 seconds. If your handler does database writes, API calls, and email sends synchronously, you risk timing out. Best practice: acknowledge the webhook immediately (return 200), then process asynchronously in a background job. If you time out, Stripe will retry the event, potentially causing duplicate processing.

Multiple subscriptions per customer. A customer may have multiple subscriptions, and multiple invoices can fail simultaneously. Your recovery logic should deduplicate customer outreach — sending two recovery emails for two failed invoices on the same day is confusing. Consolidate into one email that covers all outstanding invoices.

Invoice finalization vs payment failure. Do not confuse `invoice.finalized` (invoice is ready for payment) with `invoice.payment_failed` (payment was attempted and failed). Some webhook implementations accidentally trigger recovery flows when an invoice is finalized but has not been attempted yet.

Testing. Use Stripe's CLI to forward webhook events to your local development environment: `stripe listen --forward-to localhost:3000/webhooks`. Use Stripe's test mode decline cards (4000 0000 0000 0002 for generic decline, 4000 0000 0000 9995 for insufficient funds) to simulate specific failure scenarios. Test every decline code path in your router before going to production.

Monitoring. Log every webhook event, every routing decision, and every action taken. When a customer complains that they did not receive a recovery email, or that they received one after their payment succeeded, you need the audit trail to debug the issue.

Or Just Use Revive and Skip All of This

The webhook handler, decline-code router, retry scheduler, email coordinator, state machine, idempotency layer, signature verification, and production monitoring described in this guide represent 3-6 weeks of engineering time. And that is just the initial build — maintaining it, handling edge cases as they appear, and optimizing performance is an ongoing commitment.

Revive does all of this out of the box. When you connect your Stripe account, Revive registers its own webhook listeners, parses every decline code, routes charges through optimized recovery paths, coordinates retries with branded recovery emails, and handles every production edge case described in this guide.

You do not need to write a webhook handler. You do not need to build a decline-code router. You do not need to coordinate retries with emails. You do not need to manage a state machine or worry about idempotency. Revive handles the entire invoice.payment_failed lifecycle from first failure to recovery or lapse.

The engineering time you save can go toward building features your customers actually see. The revenue you recover goes straight to your bottom line. Connect Stripe at [/api/connect](/api/connect) and Revive starts handling your payment failures on the next invoice.payment_failed event.

For developers who want to understand the system they are replacing, this guide serves as a reference. For developers who want to ship product instead of billing infrastructure, Revive is the answer.

Key Takeaways

  • The invoice.payment_failed webhook contains customer, subscription, attempt count, and decline code — all you need for recovery routing
  • Build a decline-code router that categorizes failures into retry-only, email-immediately, blended, and authentication buckets
  • Always verify webhook signatures and make handlers idempotent to prevent duplicate actions
  • Coordinate retries with emails using a state machine and pre-send checks to avoid conflicting messages
  • Respond to webhooks within 20 seconds by processing asynchronously in background jobs
  • Test every decline code path using Stripe's test mode cards before deploying to production
  • Revive implements the entire invoice.payment_failed workflow — saving 3-6 weeks of engineering time

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.