Stripe Webhook Failed Payment Handling: Complete Guide (And Automation)

Listen for the invoice.payment_failed webhook event, verify the signature with stripe.webhooks.constructEvent(), check the decline code to triage soft vs. hard declines, and trigger the appropriate recovery flow. Always use event.id for idempotency to prevent duplicate processing. For production, you also need invoice.payment_action_required (3D Secure) and customer.subscription.updated (status changes).

Why Webhook Handling Matters for Payment Recovery

When a subscription payment fails, Stripe fires several webhook events in a specific order. If you're not listening for these events, you have no way to trigger recovery flows. dunning emails, in-app notifications, or Slack alerts. Stripe's built-in emails recover 10-15% of failures. A custom webhook handler with proper recovery logic recovers 40-55%.

The key events in order: (1) invoice.payment_failed fires immediately when a charge attempt fails. (2) invoice.payment_action_required fires if the payment needs 3D Secure authentication. (3) customer.subscription.updated fires when the subscription status changes to past_due. (4) On final retry failure, customer.subscription.deleted fires if your setting is 'Cancel subscription,' or customer.subscription.updated fires again with status unpaid.

Webhook event payload for invoice.payment_failed
// The invoice.payment_failed event payload
{
  "id": "evt_xxx",
  "type": "invoice.payment_failed",
  "data": {
    "object": {
      "id": "in_xxx",
      "customer": "cus_xxx",
      "subscription": "sub_xxx",
      "status": "open",
      "attempt_count": 1,
      "next_payment_attempt": 1717200000,  // next retry timestamp
      "last_payment_error": {
        "code": "card_declined",
        "decline_code": "insufficient_funds",
        "message": "Your card has insufficient funds."
      }
    }
  }
}

The attempt_count field tells you which retry this is (1 = first failure, 2 = first retry failed, etc.). The next_payment_attempt field is null when all retries are exhausted; this is your signal that the payment will not be retried again and you need to take final action.

Warning: Stripe does not guarantee webhook delivery order. You may receive customer.subscription.updated before invoice.payment_failed in rare cases. Your handler must be idempotent and order-independent. Always check the current state of the object rather than assuming a sequence.

Quick Fix (Webhook Handler Code)

Here's a complete, production-ready webhook handler for failed payment events with signature verification, decline code triage, and idempotency:

Complete webhook handler with signature verification
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

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'];
    let event;

    // Step 1: Verify the webhook signature
    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        sig,
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      console.error('Webhook signature verification failed:', err.message);
      return res.status(400).send('Webhook Error');
    }

    // Step 2: Idempotency. skip if already processed
    const alreadyProcessed = await checkIfProcessed(event.id);
    if (alreadyProcessed) {
      return res.json({ received: true, skipped: true });
    }

    // Step 3: Handle the event
    try {
      switch (event.type) {
        case 'invoice.payment_failed':
          await handlePaymentFailed(event.data.object);
          break;

        case 'invoice.payment_action_required':
          await handleActionRequired(event.data.object);
          break;

        case 'customer.subscription.updated':
          await handleSubscriptionUpdated(event.data.object);
          break;
      }

      // Mark event as processed for idempotency
      await markAsProcessed(event.id);
    } catch (err) {
      console.error('Webhook handler error:', err);
      return res.status(500).send('Handler Error');
    }

    res.json({ received: true });
  }
);
Decline code triage. soft vs. hard declines
const SOFT_DECLINES = [
  'insufficient_funds',
  'processing_error',
  'try_again_later',
  'reenter_transaction',
  'issuer_not_available',
];

const HARD_DECLINES = [
  'stolen_card',
  'lost_card',
  'card_not_supported',
  'expired_card',
  'fraudulent',
  'do_not_honor',
  'pickup_card',
];

async function handlePaymentFailed(invoice) {
  const decline = invoice.last_payment_error?.decline_code;
  const isLastAttempt = invoice.next_payment_attempt === null;
  const customer = invoice.customer;

  if (SOFT_DECLINES.includes(decline)) {
    // Soft decline: Stripe will retry automatically.
    // Send a gentle heads-up email on first failure.
    if (invoice.attempt_count === 1) {
      await sendDunningEmail(customer, {
        template: 'soft_decline_notice',
        declineReason: decline,
      });
    }
  } else if (HARD_DECLINES.includes(decline)) {
    // Hard decline: retries won't help.
    // Send immediate card update request.
    const portal = await stripe.billingPortal.sessions.create({
      customer: customer,
      return_url: 'https://yourapp.com/account',
    });

    await sendDunningEmail(customer, {
      template: 'card_update_required',
      cardUpdateUrl: portal.url,
      declineReason: decline,
    });
  }

  // Final attempt failed. escalate urgently
  if (isLastAttempt) {
    await sendDunningEmail(customer, {
      template: 'final_warning',
      cardUpdateUrl: await createPortalUrl(customer),
    });

    await notifyTeam({
      type: 'payment_final_failure',
      customer: customer,
      invoice: invoice.id,
      decline_code: decline,
    });
  }
}
Handle 3D Secure authentication required
async function handleActionRequired(invoice) {
  // Customer needs to authenticate with their bank (3D Secure)
  // Send them the hosted invoice URL to complete authentication
  await sendDunningEmail(invoice.customer, {
    template: 'authentication_required',
    paymentUrl: invoice.hosted_invoice_url,
  });
}

async function handleSubscriptionUpdated(subscription) {
  if (subscription.status === 'past_due') {
    // Subscription moved to past_due. update your app's access logic
    await updateUserAccess(subscription.customer, {
      status: 'past_due',
      gracePeriodEnds: Date.now() + 7 * 24 * 60 * 60 * 1000,
    });
  }

  if (subscription.status === 'unpaid') {
    // All retries exhausted, subscription marked unpaid
    await updateUserAccess(subscription.customer, {
      status: 'suspended',
    });
  }
}

Tip: Test your webhook handler locally with the Stripe CLI: stripe listen --forward-to localhost:3000/webhooks/stripe and then stripe trigger invoice.payment_failed. This lets you verify your handler works before deploying to production.

Permanent Fix (Automated)

The webhook handler above is ~300 lines of production code once you add proper error handling, retry logic, email templating, idempotency storage (Redis or database), rate limiting, and monitoring. You also need to maintain it as Stripe's API evolves and handle edge cases like duplicate events, out-of-order delivery, and webhook endpoint downtime. This is one of the key Stripe billing automation limits that pushes founders toward dedicated involuntary churn prevention tools.

SaveMRR replaces all of this custom webhook infrastructure. Paste your Stripe restricted API key and SaveMRR immediately starts listening for all payment failure events. It handles decline code triage, sends a 7-email dunning sequence with one-click card update links, manages 3D Secure authentication flows, and tracks every recovery attempt in real-time.

What you get without writing a single line of webhook code: automatic signature verification and idempotency, smart decline code triage (soft vs. hard), 7-email dunning sequence with escalating urgency, pre-dunning card expiry alerts, one-click card update links via Billing Portal, real-time recovery dashboard, and Slack/email notifications for your team.

The first $200 recovered free. You don't pay until SaveMRR proves it works. Run a free Revenue Scan to see exactly how many failed payments you're losing and what the recovery opportunity is.

Tip: Most SaaS founders spend 20-40 hours building and maintaining a webhook-based recovery system. SaveMRR replaces it with a quick API key paste. That's 20-40 hours you can spend on features that grow revenue instead of infrastructure that prevents loss.

Related Stripe Billing Issues

Frequently Asked Questions

How many times does Stripe retry sending a failed webhook?

Stripe retries webhook deliveries up to 3 times over several hours using exponential backoff. If your endpoint returns a non-2xx status code, Stripe marks the event as failed and retries. After 3 consecutive failures, Stripe disables the endpoint. You can monitor webhook delivery in Dashboard > Developers > Webhooks. Always return a 200 response quickly and process the event asynchronously to avoid timeouts.

Can I receive webhook events out of order?

Yes. Stripe does not guarantee webhook delivery order. You may receive customer.subscription.updated before invoice.payment_failed for the same payment failure. Your handler must be idempotent and order-independent. Always fetch the current state of the object (e.g., stripe.invoices.retrieve()) rather than relying solely on the event payload if order matters for your logic.

How do I test webhooks locally with the Stripe CLI?

Install the Stripe CLI and run: stripe listen --forward-to localhost:3000/webhooks/stripe. This forwards live webhook events to your local server. To trigger specific events for testing, run: stripe trigger invoice.payment_failed. The CLI generates a temporary webhook signing secret (whsec_xxx). use this in your local environment. Never use your production webhook secret for local testing.

What is idempotency and why does it matter for webhooks?

Idempotency means processing the same event multiple times produces the same result as processing it once. Stripe may send the same webhook event more than once (due to retries or network issues). If your handler isn't idempotent, you could send duplicate dunning emails or create duplicate recovery records. Store the event.id after processing and skip any event you've already seen.

Should I use invoice.payment_failed or charge.failed?

Use invoice.payment_failed for subscription payment failures. This event includes the full invoice context: customer, subscription, attempt count, next retry timestamp, and decline code. The charge.failed event fires for all failed charges (including one-off payments) and lacks subscription context. For SaaS recovery workflows, invoice.payment_failed gives you everything you need in one event.

SaveMRR catches these automatically

Stop firefighting Stripe billing issues manually. Paste your API key, get a free Revenue Scan in 60 seconds, and let SaveMRR handle recovery automatically.

Run my free scan