Rex Automaton
All posts
Operations & Admin AutomationAugust 6, 20268 min read

Stripe invoice.payment_failed vs charge.failed: Which Webhook to Use

Use invoice.payment_failed for subscription dunning and access gating. Handle duplicate webhooks idempotently, align to Smart Retries with attempt_count and next_payment_attempt, and listen for invoice.paid to restore access.

By Jacky Lei

If you are running Stripe subscriptions, key your dunning and access gating off invoice.payment_failed. It carries invoice and subscription context and lines up with Stripe Smart Retries. charge.failed is a lower-level artifact and is not the recommended trigger for subscription recovery. Handle multiple deliveries idempotently, read attempt_count and next_payment_attempt, and restore access on invoice.paid.

Stripe failed-payment automation is: a webhook-driven flow that reacts to invoice.payment_failed, schedules customer notices aligned to Smart Retries, gates access while status is past_due or unpaid, and flips access back on invoice.paid.

We built and shipped this pattern in production for subscription SaaS and client portals. In production it prevented double-notices, avoided retry collisions with Smart Retries, and kept billing state and app access in lockstep.

The problem it solves

Stripe fires several events around a failed payment. Developers often wire dunning to charge.failed and then fight missing subscription context, duplicate notices, and out-of-sync access. The right anchor for subscriptions is invoice.payment_failed: it has the invoice object, the subscription, attempt_count, and next_payment_attempt, which matches Stripe's own retry cadence.

Manual handlingWebhook automation
Staff checks Dashboard for failed charges weekly and emails customers manually.invoice.payment_failed webhook enqueues notices instantly, aligned to Smart Retries.
Access turns off late or inconsistently.Access gates on subscription status and flips back on invoice.paid.
Duplicate emails when multiple attempts fail.Idempotent handler deduplicates events and suppresses repeat messages.
Guesswork on retry timing and race conditions.Use attempt_count and next_payment_attempt from the Invoice to schedule.

How the automation works

Use invoice.payment_failed as the primary trigger for subscription recovery. On each event: verify the webhook signature, dedupe by event.id, read invoice.attempt_count and invoice.next_payment_attempt, enqueue a dunning job, and gate access based on the subscription's status. Then listen for invoice.paid to clear dunning and restore access. charge.failed can still be useful for one-off payments, but not as the anchor for subscriptions.

  • invoice.payment_failed: preferred for subscriptions. It includes invoice and subscription context and aligns with Smart Retries timing (Stripe docs: Billing webhooks for subscriptions).
  • Smart Retries: Stripe controls retry timing. Read attempt_count and next_payment_attempt on the Invoice rather than guessing a schedule (Stripe docs: Smart Retries).
  • Idempotency: Stripe webhooks are delivered at least once. Duplicates and retries occur, so make your handler idempotent and consider queueing (Stripe docs: Webhooks).
  • invoice.paid: the event to clear dunning and restore access for the subscription period that just succeeded.

Stripe failed payment recovery flow: Stripe webhooks feed a verified handler that dedupes and reads attempt_count and next_payment_attempt, then enqueues dunning and gates access. Access is restored on invoice.paid.

Step-by-step: how to build it

1) Verify and route Stripe webhooks

Create a single webhook endpoint. Verify signatures, branch on event.type, and always handle at-least-once delivery safely.

// server/webhooks/stripe.js
import express from "express";
import Stripe from "stripe";
import { upsertEventDedup, alreadyHandled } from "../store/dedup.js";
 
const router = express.Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: "2024-06-20" });
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
 
router.post("/stripe", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    const sig = req.headers["stripe-signature"];
    event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }
 
  // Idempotency: skip if we already processed this event id
  if (await alreadyHandled(event.id)) return res.status(200).end();
  await upsertEventDedup(event.id);
 
  try {
    switch (event.type) {
      case "invoice.payment_failed":
        await handleInvoiceFailed(event.data.object);
        break;
      case "invoice.paid":
        await handleInvoicePaid(event.data.object);
        break;
      case "charge.failed":
        await handleChargeFailedForOneOffs(event.data.object);
        break;
      default:
        // no-op for unrelated events
        break;
    }
    res.status(200).end();
  } catch (e) {
    // Let Stripe retry on 5xx to satisfy at-least-once delivery
    console.error(e);
    res.status(500).end();
  }
});
 
export default router;

Key gotcha: Stripe webhooks are at-least-once. Build explicit dedup on event.id and return 2xx only after durable write to your dedup store (Stripe docs: Webhooks).

2) Read attempt_count and next_payment_attempt

Use the invoice fields to decide what to send and when to send it. Do not invent your own retry schedule while Smart Retries is active.

async function handleInvoiceFailed(invoice) {
  // Important invoice fields
  const { id, customer, subscription, attempt_count, next_payment_attempt, hosted_invoice_url, status } = invoice;
 
  // Mark account access state in your DB
  await gateAccess({ subscriptionId: subscription, status });
 
  // Enqueue a dunning job aligned to Smart Retries
  await enqueueDunning({
    customerId: customer,
    subscriptionId: subscription,
    invoiceId: id,
    attemptCount: attempt_count,
    nextAttemptAt: next_payment_attempt ? new Date(next_payment_attempt * 1000) : null,
    payUrl: hosted_invoice_url
  });
}

Smart Retries control timing. Rely on invoice.attempt_count and invoice.next_payment_attempt to time notices so you do not conflict with Stripe's own attempts (Stripe docs: Smart Retries).

3) Gate access on past_due or unpaid, restore on invoice.paid

Keep your app's access in sync with billing state. Listen for invoice.paid to flip access back.

async function handleInvoicePaid(invoice) {
  const { customer, subscription, id } = invoice;
  await clearDunning({ customerId: customer, subscriptionId: subscription, invoiceId: id });
  await restoreAccess({ subscriptionId: subscription });
}

Use invoice.payment_failed to gate access as soon as a failure occurs, and invoice.paid to restore access when the payment succeeds (Stripe docs: Billing webhooks for subscriptions).

4) Send notices from a queue, not the webhook thread

Move email, SMS, and in-app notifications to a background worker. The webhook should only write state and enqueue a job so you respond 2xx quickly and let Stripe stop retrying.

// jobs/dunning-worker.js
export async function processDunningJob(job) {
  const { customerId, attemptCount, nextAttemptAt, payUrl } = job;
  const subject = attemptCount === 1 ? "We could not process your payment" : `Retry ${attemptCount} failed`;
  const body = nextAttemptAt
    ? `We will retry on ${nextAttemptAt.toLocaleString()}. You can pay now: ${payUrl}`
    : `Please update your payment method: ${payUrl}`;
  await sendEmail(customerId, subject, body);
  await sendInAppNotice(customerId, body);
}

5) Keep retries single-sourced

If you run your own charge retries while Smart Retries is on, you risk conflicting attempts and poor UX. Pick one source of truth. We defer to Smart Retries for scheduling and only nudge the user via dunning. If you choose to disable Smart Retries and own the schedule, do it in one place and document it clearly in code comments and runbooks.

6) Use charge.failed only for one-offs

For one-time charges that are not tied to a subscription, charge.failed can still power a recovery email or support ticket. For subscriptions, keep invoice.payment_failed as the anchor and treat charge.failed as informative at best.

async function handleChargeFailedForOneOffs(charge) {
  if (charge.invoice) return; // subscription case handled via invoice events
  await enqueueOneOffRecovery({
    customerId: charge.customer,
    chargeId: charge.id,
    amount: charge.amount,
    currency: charge.currency,
    receiptUrl: charge.receipt_url
  });
}

Where it gets complicated

Duplicate and out-of-order deliveries. Stripe guarantees at-least-once delivery. Retries and duplicates happen, and different event types can arrive out of order. Persist a processed event.id set and make all handlers idempotent (Stripe docs: Webhooks).

Smart Retries timing. The retry schedule is Stripe's domain when Smart Retries is on. Reading attempt_count and next_payment_attempt is the safe way to time customer communication without guessing the cadence (Stripe docs: Smart Retries).

Access gating semantics. A subscription can be past_due after one failure, then unpaid if final payment is missed. Gate based on what your product promises, but always let invoice.paid be the single source of truth to restore.

Mixing subscription and one-off flows. It is common to sell both subscriptions and one-offs. Keep two code paths: invoice.* drives subscription recovery and access, charge.* handles single payments. Avoid cross-wiring.

No-code vs code. Zapier and Make have official Stripe integrations that can send emails or create tickets on invoice.payment_failed. They are useful for ops alerts. For access gating and idempotent recovery logic, we ship a code path and keep no-code for notifications only.

What this actually changes

After we shipped this pattern, failed subscription payments were handled deterministically. Dunning went out once per failed attempt, aligned to Stripe's Smart Retries. Access toggled immediately on invoice.payment_failed and restored on invoice.paid. Engineering never had to guess retry timing or chase double-emails because the handler was idempotent and the queue owned sends. Stripe's documentation confirms the pieces we leaned on: invoice.payment_failed is the recommended subscription trigger, Smart Retries control timing via invoice fields, and webhooks are at-least-once with required signature verification and idempotency.

References: Stripe Billing webhooks for subscriptions, Smart Retries, Webhooks, and API authentication.

Frequently asked questions

Which webhook should I use for subscription dunning in Stripe?

Use invoice.payment_failed. It contains invoice and subscription context and aligns with Stripe's Smart Retries. Listen for invoice.paid to clear dunning and restore access. charge.failed is lower-level and not the recommended anchor for subscriptions.

How do I prevent duplicate emails or jobs from Stripe webhooks?

Make your handler idempotent. Verify the webhook signature, store event.id in a processed table, and return 2xx only after the durable write. Stripe webhooks are at-least-once, so duplicates and retries are expected.

Can I run my own retry schedule on top of Smart Retries?

You can, but we do not recommend mixing schedules. When Smart Retries is on, rely on invoice.attempt_count and next_payment_attempt to time notices. Running your own charge retries on top risks conflicts and a confusing customer experience.

When is charge.failed appropriate to act on?

For one-off charges not tied to a subscription, charge.failed is a useful trigger for a recovery email or support workflow. For subscriptions, keep invoice.payment_failed as the anchor and treat charge.failed as informational only.

How do I authenticate to Stripe from my server code?

Use your Stripe secret key against https://api.stripe.com over HTTPS. Authenticate with HTTP Basic where the key is the username and the password is blank, or with a Bearer token header. Never put the secret key in client code.


If you want this wired into your stack without guesswork, we have shipped this exact flow in production: idempotent handlers, Smart Retry alignment, and access gating tied to invoice events. See our related post on Stripe timing choices in dunning: Stripe failed payment retry strategy. Or review our broader workflow automation services. When you are ready, book a short call and we will map your subscription and product specifics to this pattern.

Want us to build this for you?

15-minute discovery call. No pitch. We tell you what to automate first.

Book a Discovery Call

Related reading