DEV Community

Nic Polotnianko πŸ‡ΊπŸ‡¦
Nic Polotnianko πŸ‡ΊπŸ‡¦

Posted on Edited on

Stripe failed payment emails: a dunning flow that recovers revenue (Node.js)

Failed payments are the quietest kind of churn. Nobody decided to leave: a card expired, a bank flagged a renewal, a corporate card hit its limit. If nobody tells the customer, Stripe retries a few times, gives up, and the subscription ends.

Stripe will send a basic failed payment email for you (Settings β†’ Billing β†’ Subscriptions and emails). It's worth turning on, but it's one generic email. A short sequence that explains what happened, links straight to a page where the customer can fix it, and stops the moment they pay recovers noticeably more.

This post walks through building that flow on Stripe webhooks in Node.js: the events you need, the timing, the copy, and the edge cases that make dunning emails go wrong.

The events that matter

You need three webhook events:

Event What it tells you
invoice.payment_failed A charge attempt on an invoice failed. Fires on the first attempt and on every automatic retry.
invoice.paid The invoice was paid, either by a retry or by the customer. This is your "stop emailing" signal.
customer.subscription.updated / customer.subscription.deleted The subscription moved to past_due, unpaid or canceled after retries ran out.

The invoice object on invoice.payment_failed carries most of what an email needs:

  • id: the invoice ID. It stays the same across every retry of that invoice, which makes it the right key for "one recovery flow per failed invoice".
  • attempt_count: 1 on the first failure, then increments with each retry.
  • next_payment_attempt: a Unix timestamp for the next automatic retry, or null when Stripe will not retry again.
  • hosted_invoice_url: Stripe's hosted page where the customer can pay this invoice with a new card.
  • amount_due and currency: for "Your $49.00 payment didn't go through".
  • customer and customer_email.

Step 1: receive and verify the webhook

Verify the signature against the raw body. Parsing JSON first breaks verification, which is the number one "why does my Stripe webhook fail" issue.

import express from "express";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();

app.post(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    let event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        req.headers["stripe-signature"],
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    // Acknowledge fast, do the work in a queue.
    await enqueue("stripe-event", { id: event.id, type: event.type, data: event.data.object });
    res.json({ received: true });
  }
);
Enter fullscreen mode Exit fullscreen mode

Two things to get right here:

  1. Return 2xx quickly. Stripe retries webhooks it considers failed (for up to three days in live mode). If you send emails inline and your email provider is slow, Stripe times out, retries, and you send the same email twice.
  2. Deduplicate by event.id. Even with fast responses, Stripe can deliver an event more than once. Store processed event IDs (a unique index is enough) and skip repeats.

Step 2: decide what to send

Here's a sequence that works for most B2B SaaS:

When Email Goal
First failure (attempt_count === 1) "Your payment didn't go through" Calm, factual, one button to update the card.
~3 days later, still unpaid "Quick reminder: update your card" Shorter. Mention what they'd lose (their data, their team's access).
Final retry (next_payment_attempt === null) "Last chance before your subscription is canceled" Clear deadline, same single button.
After cancellation "Your subscription was canceled" How to reactivate in one click. No guilt.

The key detail: drive the flow off the first failure, not every failure. invoice.payment_failed fires on every retry. If you send an email per event, a customer on Stripe's default Smart Retries schedule (Stripe's recommended default is up to 8 tries within 2 weeks) can get eight near-identical emails. Start one flow when attempt_count === 1, and use next_payment_attempt === null to trigger the final notice.

async function handleStripeEvent({ id, type, data }) {
  if (await alreadyProcessed(id)) return;

  if (type === "invoice.payment_failed") {
    const invoice = data;

    if (invoice.attempt_count === 1) {
      await startDunningFlow({
        invoiceId: invoice.id,
        email: invoice.customer_email,
        amount: formatMoney(invoice.amount_due, invoice.currency),
        payUrl: invoice.hosted_invoice_url,
      });
    }

    if (invoice.next_payment_attempt === null) {
      await sendFinalNotice(invoice.id);
    }
  }

  if (type === "invoice.paid") {
    await stopDunningFlow(data.id); // keyed by invoice ID
  }

  await markProcessed(id);
}

function formatMoney(amountInCents, currency) {
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: currency.toUpperCase(),
  }).format(amountInCents / 100);
}
Enter fullscreen mode Exit fullscreen mode

(Zero-decimal currencies like JPY don't divide by 100. If you bill in them, check the currency before formatting.)

startDunningFlow should schedule the follow-ups as delayed jobs (BullMQ, Sidekiq, Cloud Tasks, whatever you use) and re-check the invoice status before each send:

async function sendReminderIfStillUnpaid(invoiceId) {
  const invoice = await stripe.invoices.retrieve(invoiceId);
  if (invoice.status !== "open") return; // paid, voided or uncollectible: stop
  await sendEmail("dunning-reminder", { invoice });
}
Enter fullscreen mode Exit fullscreen mode

The re-check is cheap insurance. Webhooks arrive out of order sometimes, and "we already charged you, please pay" is the fastest way to get a support ticket.

Step 3: where the button should go

Your email needs one button that lets the customer fix the problem in under a minute. You have two good options:

  • hosted_invoice_url: Stripe's hosted invoice page. The customer can pay that exact invoice with a new card, no login needed. Best for the dunning emails themselves.
  • The Stripe customer portal: lets the customer replace their default payment method for future invoices too. Portal session URLs are short-lived, so don't paste one into an email. Link to a route in your app that creates a session on click:
app.get("/billing/update-card", requireLogin, async (req, res) => {
  const session = await stripe.billingPortal.sessions.create({
    customer: req.user.stripeCustomerId,
    return_url: "https://app.example.com/settings/billing",
  });
  res.redirect(session.url);
});
Enter fullscreen mode Exit fullscreen mode

Avoid sending people to a generic "log in to your account" page. Every extra step loses recoveries, and a customer whose card failed is not in the mood to hunt through settings.

Step 4: the copy

Dunning emails are transactional in tone even though they feel like marketing. A few things consistently help:

  • Lead with the fact, not the apology. "Your $49.00 payment for Acme Pro didn't go through" beats "Uh oh! Something went wrong."
  • Say what happens next and when. "We'll try again on November 4. If it still fails, your workspace will be paused on November 12."
  • Never blame the customer. Most failures are expired cards or bank fraud filters. "Your bank declined the charge" is accurate and neutral.
  • One button. "Update payment method". Not a menu of links.
  • Send from a human-looking address, and let replies land in a real inbox. People reply to dunning emails with "I changed jobs, can you invoice my new company?", and those are recoveries too.

A first email can be this short:

Subject: Your Acme payment didn't go through

Hi Sam,

We tried to charge $49.00 for your Acme Pro plan, but your bank declined it. This usually means the card expired or was replaced.

[Update payment method]

We'll retry automatically on November 4. Nothing changes in your account until then.

If you have questions, just reply to this email.

Edge cases that cause double (or wrong) emails

These come up in almost every dunning implementation:

  1. Two invoices fail at once. A customer with two subscriptions can have two open invoices. Key the flow by invoice ID, not by customer, or the second failure gets swallowed (or the first payment stops both flows).
  2. Customer pays between retries. Handled by invoice.paid plus the status re-check before each send.
  3. Manual invoices. Invoices with collection_method: "send_invoice" have no automatic retries, so next_payment_attempt is null from the start. Decide whether they get the same flow or a different "your invoice is overdue" email.
  4. One-off invoices. Not every failed invoice belongs to a subscription. Check billing_reason (subscription_create, subscription_cycle, subscription_update) if you only want to chase renewals.
  5. API version drift. Stripe's 2025-03-31 (basil) API moved some invoice fields, for example the subscription reference now lives under invoice.parent.subscription_details. Pin your webhook endpoint's API version and read fields that exist in it.

Measuring it

Track two numbers:

  • Recovery rate: failed invoices that end up paid Γ· failed invoices. Split it by "paid by automatic retry" and "paid after an email click". Automatic retries recover some without any email, and you want to know what the emails add.
  • Time to recovery: days from first failure to payment. If most recoveries happen after email 1, your later emails can be gentler; if they cluster at the final notice, the deadline is doing the work.

If you'd rather not maintain this

Sequenzy, an email platform for SaaS, handles this flow out of the box. You connect Stripe, and failed invoices arrive as a saas.payment_failed event carrying invoiceId, attemptCount, amountDueFormatted, hostedInvoiceUrl and willRetry. A sequence triggered on the first attempt and stopped by the matching saas.purchase handles the "one flow per invoice, stop on payment" logic above without your own queue. The Stripe integration docs show the setup.

Whether you build it yourself or not, the pattern is the same: start on the first failure, key by invoice, re-check before every send, and make the button go straight to a page where the customer can pay.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.