Rex Automaton
All posts
Operations & Admin AutomationJuly 31, 20268 min read

How to Set Up the Stripe invoice.payment_failed Webhook

We show how we ship reliable Stripe dunning: verify signatures, dedupe events, read next_payment_attempt, and trigger comms and retries without double-sends.

By Jacky Lei

Stripe failed-payment automation starts with a verified webhook: we receive invoice.payment_failed, dedupe by event.id, trigger on-brand customer comms, and schedule retries using the invoice's next_payment_attempt timestamp. For accounts using Billing Automations, we also listen to invoice.updated where Stripe now places next_payment_attempt. This post shows the exact steps and code we shipped.

Stripe invoice.payment_failed automation is: a signed webhook that reliably starts your dunning sequence and books the next retry window without double-notifying customers.

The problem it solves

You want failed payments to recover automatically: notify the customer with an on-brand email or SMS, update your CRM, and try the card again at the right time without manual work or double-sends.

Most teams start by watching the Dashboard and pasting emails. That breaks under volume and misses Stripe nuances like 3-day webhook retries and where next_payment_attempt lives when Billing Automations are enabled.

Manual processAutomated with webhooks
Check Dashboard for failed invoices once a day.Stripe posts invoice.payment_failed to your endpoint in real time.
Paste a templated email and hope you do not send twice.Idempotent handler dedupes by event.id and records one notification per invoice.
Guess when to retry.Read next_payment_attempt or listen on invoice.updated, then schedule the retry window.
Update CRM by hand.Push status and links to CRM automatically.
Export CSVs for reporting monthly.Use Stripe Reports or Reports API for scheduled CSVs, while webhooks handle real time.

How the automation works

We expose a TLS endpoint that verifies Stripe's signature, dedupes by event.id, then fans out two jobs: customer comms and retry scheduling. If Billing Automations are on, we also consume invoice.updated for next_payment_attempt. The flow is model-agnostic: Zapier or Make can send the email, or you can send directly.

  • Stripe webhook intake: Stripe posts events from https://api.stripe.com to your HTTPS endpoint. We verify the signature with the raw request body and the correct secret per mode.
  • Idempotency and logging: We upsert the event.id into a small ledger so Stripe's 3-day exponential retries do not duplicate downstream work.
  • Dunning engine: Compose and send on-brand messages. Zapier and Make both have official Stripe integrations, but we often send directly to keep one source of truth.
  • Retry scheduler: Read next_payment_attempt on the invoice. With Billing Automations, Stripe moves that timestamp onto invoice.updated, so we listen to both types and reconcile in one state row per invoice.
  • Reporting: Webhooks handle real time. Stripe Reports in the Dashboard and the Reports API produce scheduled CSVs for finance.

Stripe failed-payment webhook to dunning engine and retry scheduler

Step-by-step: how to build it

1) Create a verified webhook endpoint

Use HTTP Basic or Bearer for Stripe API calls. For incoming webhooks, verify the signature against the raw body and the mode-specific secret. Keep body parsing raw for this route.

// server.js
import express from "express";
import Stripe from "stripe";
import crypto from "crypto";
 
const app = express();
const stripe = new Stripe(process.env.STRIPE_API_KEY, { apiVersion: "2024-06-20" });
 
// Use raw body for signature verification
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), (req, res) => {
  const sig = req.headers["stripe-signature"]; // lowercase header key in Node
  const secret = process.env.STRIPE_WEBHOOK_SECRET; // separate per mode
 
  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, sig, secret);
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }
 
  // Enqueue for processing
  queueEvent(event).catch(console.error);
  res.status(200).send("ok");
});
 
app.listen(3000);

Gotcha: use TLS 1.2 or newer and do not JSON-parse the body before verification. Test and live use different signing secrets.

2) Dedupe with an event ledger

Stripe retries webhooks for up to 3 days with exponential backoff. Manual resends do not cancel automatic retries. Store event.id and short-circuit duplicates.

-- PostgreSQL
create table if not exists stripe_event_ledger (
  event_id text primary key,
  type text not null,
  received_at timestamptz not null default now()
);
// processing.js
import { sql } from "./db.js";
 
export async function queueEvent(event) {
  try {
    await sql`insert into stripe_event_ledger (event_id, type) values (${event.id}, ${event.type})`;
  } catch (e) {
    if (e.code === "23505") return; // already processed
    throw e;
  }
  return handleEvent(event);
}

Key point: idempotency belongs at your boundary so retried deliveries do not fan out duplicate emails or tasks.

3) Start comms on invoice.payment_failed

Trigger customer messaging and internal alerts when an invoice fails. Keep the message on-brand and link to a secure payment update.

async function handleEvent(event) {
  if (event.type === "invoice.payment_failed") {
    const inv = event.data.object; // Stripe invoice
    await Promise.all([
      sendCustomerEmail(inv.customer_email, inv.hosted_invoice_url),
      notifyOps(inv.id, inv.customer),
      upsertInvoiceState(inv.id, { status: "failed", next_attempt_at: inv.next_payment_attempt || null })
    ]);
  }
  // Billing Automations surface next_payment_attempt via invoice.updated
  if (event.type === "invoice.updated") {
    const inv = event.data.object;
    if (typeof inv.next_payment_attempt === "number") {
      await upsertInvoiceState(inv.id, { next_attempt_at: inv.next_payment_attempt });
    }
  }
}

Note: when you enable Stripe Billing Automations, next_payment_attempt is no longer on invoice.payment_failed. It is on invoice.updated, so listen to both and reconcile into one invoice state row.

4) Schedule and run retries

Use your job runner to wake up near next_payment_attempt and perform your side effects: reminder nudges, UI banners, CRM updates. Stripe will handle the charge attempt at the platform level.

// jobs/schedule.js
import { addJob, at } from "./runner.js";
 
export async function upsertInvoiceState(invoiceId, patch) {
  const state = await mergeState(invoiceId, patch);
  if (state.next_attempt_at) {
    await addJob("retry_nudge", { invoiceId }, at(new Date(state.next_attempt_at * 1000 - 5 * 60 * 1000))); // 5 min before
  }
}
 
export async function runRetryNudge({ invoiceId }) {
  const s = await getState(invoiceId);
  if (s.status === "paid") return; // guard if paid early
  await sendReminder(s.customer_email, s.portal_url);
}

Strategy: let Stripe perform Smart Retries or your custom schedule. Your system surrounds it with the right comms at the right times.

5) Wire Zapier or Make for simple comms paths

You can keep messaging no-code. Stripe has official apps on Zapier and Make. Trigger on invoice.payment_failed and route to your email or SMS tool. Still verify and dedupe at your boundary to prevent double-sends.

Zapier: Stripe trigger invoice.payment_failed -> Filter by product or amount -> Gmail or SendGrid action
Make: Stripe Watch Events -> Router by type -> Email/SMS module -> Google Sheets log

Tip: keep the webhook as the source of truth even if you use Zapier or Make for delivery.

6) Feed finance with scheduled reports

Use Dashboard CSVs or the Reports API for monthly summaries, while webhooks keep operations real time.

- Dashboard: Reports -> Create report -> Schedule delivery to finance@...
- API: Use the Reports API to programmatically create and fetch files on a cadence

Reports complement webhooks: webhooks drive action, reports drive reconciliation.

Where it gets complicated

  • Signature verification and raw bodies: If your framework auto-parses JSON, Stripe signature checks will fail. Mount an express.raw route just for webhooks and pass the raw buffer to constructEvent.
  • Separate secrets per mode: Test and live share the same URL but use different signing secrets. Load the correct secret from environment per deployment.
  • When next_payment_attempt moves: With Billing Automations, invoice.payment_failed no longer contains next_payment_attempt. Your retry timing must be sourced from invoice.updated.
  • Retries for up to 3 days: Stripe retries deliveries with exponential backoff. Manual resends do not cancel the automatic schedule, so your handler must be idempotent.
  • Downstream dedupe as well: Some email or task systems will happily accept duplicates. Add idempotency keys or lookup-before-create in each downstream integration.

What this actually changes

We shipped this pattern in production for Stripe-backed billing surfaces where finance needed clean visibility and customers needed timely, on-brand recovery nudges. In production it removed manual dashboard checks, prevented double-sends during webhook retries, and aligned comms to Stripe's actual next attempt window rather than guesses.

One useful Stripe fact to design around: Stripe retries webhook delivery for up to 3 days with exponential backoff, so handlers must be idempotent and tolerant of delayed delivery (source: docs.stripe.com/webhooks).

Frequently asked questions

Does Stripe have an official API and how do I authenticate?

Yes. Stripe exposes a public API at https://api.stripe.com. Authenticate with HTTP Basic using your API key as the username and no password, or with a Bearer token. Webhooks are separate: verify signatures with your signing secret and the raw request body.

Where do I get the next retry time for dunning?

If you do not use Billing Automations, next_payment_attempt is present on the invoice payload. With Billing Automations enabled, Stripe surfaces next_payment_attempt on invoice.updated. Listen to both and write one invoice state row that tracks status and next attempt.

How do I prevent duplicate notifications when Stripe retries webhooks?

Store event.id in a small ledger table with a unique constraint. On insert conflict, short-circuit processing. Add lookup-before-create or idempotency keys on downstream systems like email or task APIs to avoid duplicates there too.

Can I build this without code in Zapier or Make?

Yes. Stripe has official apps on Zapier and Make. You can trigger on invoice.payment_failed and send emails or SMS. We still recommend a minimal verified webhook that dedupes and then calls Zapier or Make so you remain safe against Stripe's automatic retries.

How do finance teams get monthly summaries?

Use Stripe Reports in the Dashboard to schedule CSV deliveries, or the Reports API to programmatically create and fetch files. Webhooks solve the real-time operational side. Reports solve reconciliation and month-end packages.

If you want this wired into your stack with verified signatures, clean dunning comms, and safe retries, we have shipped it before. See our related post on Stripe failed-payment automation and our workflow automation services. When you are ready, book a 15-minute call.

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