Rex Automaton
All posts
Operations & Admin AutomationJuly 31, 2026Updated 13 min read

Stripe failed payment webhook: how to set up correctly

Set up the Stripe failed payment webhook with signature verification, idempotency and next_payment_attempt so dunning sends once and retries on time.

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.

What recovering failed payments is worth

Move the sliders to your own numbers. The defaults are only an example business, not client data.

Your own estimate. Card expiries and soft declines are the easiest to win back.

Revenue recovered each year

$9,000

$50000 × 5% failed × 30% recovered × 12 months

Staff cost of doing it by hand each year
$3,640
2 hrs a week × $35/hr × 52 weeks
Hours handed to the system each month
9 hrs
2 hrs a week × 52 ÷ 12

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. For message timing patterns, see our notes in failed payment retry strategy.
  • 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

Which Stripe events should you enable for dunning?

We recommend turning on these event types for the webhook endpoint that drives dunning and finance updates:

  • invoice.payment_failed: primary trigger for subscription or invoice failures.
  • invoice.updated: captures next_payment_attempt when Billing Automations are enabled. Also useful for status transitions to paid.
  • charge.failed: optional. Use only if you need to catch non-invoice card failures. Route carefully so you do not double-handle invoice failures. See our comparison of invoice.payment_failed vs charge.failed.
  • customer.subscription.updated: optional for UI and CRM state, not required for dunning timing.

Keep one authoritative invoice state row keyed by invoice.id. Route events into it and generate comms from that state so you do not send twice.

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. See tradeoffs in Stripe Smart Retries vs custom retry logic and patterns in failed payment retry strategy.

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. For Make stability at scale, our checklist in patching 200 Make scenarios safely applies.

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.

How to test invoice.payment_failed in Stripe

  • Use test mode: Generate test invoices and fail a payment in test mode so Stripe emits invoice.payment_failed to your endpoint. Verify signature handling and idempotency while Stripe retries delivery.
  • Send sample events: Use the Stripe tooling to resend recent events to your endpoint. Confirm you create exactly one notification for one event.id.
  • Advance timing: If you use a retry schedule, shorten timers in test to validate your reminder jobs around next_payment_attempt without waiting hours.

Troubleshooting: webhook not firing or missing next_payment_attempt

  • Endpoint returns non-2xx: Stripe will retry for up to 3 days. Return 200 quickly and process asynchronously so you do not time out.
  • JSON parsing broke signature: Many frameworks parse JSON by default. Mount a raw body route just for the webhook.
  • Wrong signing secret: Test and live share a URL but have different secrets. Load the right one per deploy.
  • Event type not enabled: Make sure invoice.payment_failed and invoice.updated are subscribed on the endpoint that handles dunning.
  • next_payment_attempt is null: No retry is scheduled yet or auto collection is off. Treat this as a comms-only branch and wait for the next invoice.update.

How to create a Gmail draft invoice email when invoice.payment_failed fires

If you want a rep to review messaging before send, create a Gmail draft instead of auto-sending the first dunning email. We use the Gmail API with OAuth2 and the drafts.create method.

High level: when invoice.payment_failed arrives, generate a pay link, render a short email body, then call Gmail to create a draft on the owner mailbox. We keep the same idempotency key so re-deliveries do not create multiple drafts.

// gmail/draft.js
const { google } = require('googleapis');
 
async function gmailClientForUser() {
  const oAuth2Client = new google.auth.OAuth2(
    process.env.GOOGLE_CLIENT_ID,
    process.env.GOOGLE_CLIENT_SECRET,
    process.env.GOOGLE_REDIRECT_URI
  );
  // Token must include Gmail scopes like gmail.modify or gmail.compose
  oAuth2Client.setCredentials({
    refresh_token: process.env.GOOGLE_REFRESH_TOKEN
  });
  return google.gmail({ version: 'v1', auth: oAuth2Client });
}
 
function buildRawMessage({ from, to, subject, html }) {
  const boundary = 'mixedBoundary';
  const lines = [
    `From: ${from}`,
    `To: ${to}`,
    `Subject: ${subject}`,
    'MIME-Version: 1.0',
    `Content-Type: multipart/alternative; boundary=${boundary}`,
    '',
    `--${boundary}`,
    'Content-Type: text/plain; charset=UTF-8',
    '',
    // Plain text fallback
    html.replace(/<[^>]+>/g, ''),
    `--${boundary}`,
    'Content-Type: text/html; charset=UTF-8',
    '',
    html,
    `--${boundary}--`
  ].join('\r\n');
  return Buffer.from(lines)
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, ''); // base64url
}
 
async function createGmailDraft({ to, subject, html, ownerEmail }) {
  const gmail = await gmailClientForUser();
  const raw = buildRawMessage({ from: ownerEmail, to, subject, html });
  const res = await gmail.users.drafts.create({
    userId: 'me',
    requestBody: { message: { raw } }
  });
  return res.data.id; // store draft id to avoid duplicates
}
 
// In your failed-payment worker
async function onFirstFailureCreateDraft({ invoice, ownerEmail }) {
  const payLink = invoice.hosted_invoice_url || invoice.invoice_pdf; // hosted link preferred
  const subject = `Action required: Invoice ${invoice.number} payment failed`;
  const html = `
    <p>Hi ${invoice.customer_name || ''},</p>
    <p>Your payment for invoice <strong>${invoice.number}</strong> could not be processed.</p>
    <p>Please update your card or pay securely here: <a href="${payLink}">View invoice</a>.</p>
    <p>Thank you.</p>
  `;
  const draftId = await createGmailDraft({ to: invoice.customer_email, subject, html, ownerEmail });
  await db.none('insert into comms_log(external_key, channel, ref) values($1,$2,$3) on conflict do nothing', [
    `stripe:${invoice.id}:first-draft`,
    'gmail_draft',
    draftId
  ]);
}

Notes:

  • Use a user-authorized OAuth2 client. Service accounts do not access consumer Gmail. For Google Workspace, domain-wide delegation may be appropriate but still requires per-user impersonation.
  • Keep scopes minimal. gmail.compose or gmail.modify is sufficient for drafts.
  • Decide the owner mailbox: assign by account owner in your CRM or a shared billing inbox.

For a deeper pattern on wiring Gmail to downstream systems, see our guide on a Gmail API quickstart and CRM sync pattern.

Gmail Drafts.create: exact method and scopes

If you searched for draftCreated or Drafts.create, the Gmail API method you want is users.drafts.create. This is unrelated to Streak CRM and separate from Stripe webhooks. You need an OAuth2 user token with gmail.compose or gmail.modify.

POST https://gmail.googleapis.com/gmail/v1/users/me/drafts
Authorization: Bearer ya29.your-token
Content-Type: application/json
 
{
  "message": {
    "raw": "<base64url-encoded-MIME>"
  }
}
  • Encode the MIME message as base64url, not standard base64. Replace plus with hyphen and slash with underscore, and trim padding.
  • Store the returned draft id so webhook retries do not create duplicate drafts.

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.

Should you use invoice.payment_failed or charge.failed?

For subscription or invoice-driven billing, use invoice.payment_failed. It ties cleanly to hosted_invoice_url and customer_email for messaging and to next_payment_attempt for timing. charge.failed is lower level. It can fire for non-invoice charges and lacks invoice context. If you must react to card-level failures across one-off payments, you can listen to both and route by object presence. For a deeper comparison, see our breakdown of invoice.payment_failed vs charge.failed.

Smart Retries or custom retry logic for timing?

Stripe Smart Retries optimize when Stripe attempts to charge again. Your webhook should still drive comms and state. Most teams pair Smart Retries with lightweight reminder jobs keyed to next_payment_attempt. If you need stricter control, run a custom schedule and keep your state row authoritative. We covered tradeoffs in Stripe Smart Retries vs custom retry logic and practical patterns in failed payment retry strategy.

How to test and monitor safely

  • Test mode events: Use test mode to produce failed invoices and deliver sample events to your endpoint. This validates signature verification and idempotency under retry.
  • Replay and retries: Use Stripe's tooling to resend recent events to your endpoint to confirm your duplicate guard. Monitor that only one notification was created.
  • Observability: Log event.id, type, and derived invoice state. Alert on signature verification failures, repeated 5xx, and drifting next_attempt_at.

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 write-up on failed payment retry strategy and our workflow automation services. When you are ready, book a 20-minute call.

Want us to build this for you?

Nine questions, about 90 seconds. You see the revenue it is costing you, then pick a time. No pitch.

See your options

Related reading