Stripe failed payment automation works by subscribing to Stripe webhooks for invoice.payment_failed and payment_intent.payment_failed, verifying the signature against the raw request body, and queuing idempotent workflows that trigger alerts, coordinate dunning emails, run Smart Retries, and sync your CRM. We shipped this pattern for subscription products so finance sees issues in minutes and customers get a clean path to pay.
Definition: Stripe failed payment automation is a webhook-driven workflow that recovers declined or expired card charges with retries, notifications, and CRM updates without manual intervention.
If you run subscriptions or recurring invoices, this guide shows how we built it, the exact Stripe events we watch, and the traps to avoid when you go live.
The problem it solves
Teams usually notice failed payments late, send manual emails from shared inboxes, and lose track of who retried and who updated their card. A proper dunning flow listens to Stripe in real time, retries intelligently, and coordinates clean customer outreach plus CRM tasks.
| Manual handling | Automated with Stripe webhooks |
|---|---|
| Someone checks the dashboard weekly for failed charges | Webhooks fire invoice.payment_failed within seconds of failure |
| A rep writes an email asking to update the card | Branded dunning email is sent automatically with a secure update link |
| Unclear who is following which account | CRM task and owner assignment created automatically |
| Finance exports CSVs for status | Status lives in the CRM and Slack, with scheduled Stripe reports for audit |
| Duplicate emails after each retry | One idempotent workflow with a suppression window guards duplicates |
How the automation works
A secure webhook endpoint receives Stripe events, verifies the signature using the raw request body, and acknowledges quickly. Heavy work is queued. The job reads event context, fetches the latest invoice or payment intent via the Stripe API, decides the next action, and fans out: Smart Retries, dunning comms, CRM sync, and Slack alerts. We record a dedup key so re-deliveries do not create duplicates.
- Webhook ingestion: Stripe posts events like invoice.payment_failed. We verify signatures and respond 2xx quickly so Stripe does not retry.
- Orchestrator queue: A worker performs the heavy logic: fetch invoice, pick dunning step, and enqueue comms.
- Smart Retries and schedule: Stripe Billing Smart Retries is enabled. We also keep a custom schedule for edge cases and track resend suppression.
- CRM and alerts: We upsert a contact or account, log an activity, and post a Slack alert so the owner sees context.
- Reporting: We schedule Stripe reports for audit and export recovery stats to a dashboard.
Step-by-step: how to build it
1) Create a verified Stripe webhook endpoint
Use the official API key authentication and verify event signatures with the raw body. Respond 2xx fast and offload heavy work to a queue.
// server/webhooks.js
const express = require('express');
const Stripe = require('stripe');
const bodyParser = require('body-parser');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: '2024-06-20' });
const router = express.Router();
// Use raw body for signature verification
router.post('/stripe', bodyParser.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
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: ${err.message}`);
}
// Ack first to avoid retries, then queue the job
res.status(200).send('ok');
try {
if (event.type === 'invoice.payment_failed' || event.type === 'payment_intent.payment_failed') {
await queueFailedPaymentJob({ id: event.id, type: event.type });
}
} catch (err) {
console.error('Enqueue failed-payment job error', err);
}
});
module.exports = router;Key gotcha: signature verification requires the raw request body. Many frameworks parse JSON by default and break verification. Keep this route on a raw parser.
2) Make the workflow idempotent and out-of-order safe
Stripe webhooks are delivered at least once and not guaranteed in order. Store a processed-events table keyed by event.id and guard all side effects.
-- migrations/001_failed_events.sql
create table if not exists stripe_event_log (
event_id text primary key,
event_type text not null,
received_at timestamptz not null default now()
);// jobs/failed-payment.js
async function queueFailedPaymentJob(meta) {
const already = await db.oneOrNone('select event_id from stripe_event_log where event_id = $1', [meta.id]);
if (already) return; // drop duplicate delivery
await db.none('insert into stripe_event_log(event_id, event_type) values($1,$2)', [meta.id, meta.type]);
return worker.enqueue('failed-payment', meta);
}This pattern is what kept our production runs clean when Stripe retried deliveries for hours after a brief network blip.
3) Fetch fresh invoice context and choose the next action
Always re-fetch the relevant object to avoid acting on stale data.
// workers/failed-payment.js
const ACTION = { SMART_RETRY: 'smart_retry', EMAIL: 'email', TASK: 'task' };
worker.process('failed-payment', async (job) => {
const evt = await stripe.events.retrieve(job.data.id); // authoritative snapshot
let customerEmail, invoice, paymentIntent;
if (evt.type === 'invoice.payment_failed') {
invoice = await stripe.invoices.retrieve(evt.data.object.id, { expand: ['customer'] });
customerEmail = invoice.customer_email || invoice.customer.email;
paymentIntent = invoice.payment_intent ? await stripe.paymentIntents.retrieve(invoice.payment_intent) : null;
} else {
paymentIntent = await stripe.paymentIntents.retrieve(evt.data.object.id);
const invId = paymentIntent.invoice;
invoice = invId ? await stripe.invoices.retrieve(invId, { expand: ['customer'] }) : null;
customerEmail = invoice?.customer_email || invoice?.customer?.email || paymentIntent.receipt_email;
}
const retryAllowed = Boolean(invoice?.collection_method === 'charge_automatically');
const action = retryAllowed ? ACTION.SMART_RETRY : ACTION.EMAIL;
await Promise.all([
postSlackAlert({ invoice, paymentIntent }),
upsertCrmTask({ invoice, paymentIntent })
]);
if (action === ACTION.SMART_RETRY) await scheduleSmartRetry({ invoice });
await sendDunningEmail({ to: customerEmail, invoice });
});We prefer Slack alerts and CRM tasks to fire regardless of the chosen next action so an owner always has context.
4) Enable and lean on Stripe Billing Smart Retries
Stripe Billing includes Smart Retries that use machine learning to choose better retry times. We turn it on in Billing settings and still keep a thin custom safety net for edge cases. Listen to subsequent invoice.payment_failed and invoice.paid to update CRM status.
// retries/schedule.js
async function scheduleSmartRetry({ invoice }) {
// Smart Retries is configured in Stripe Billing settings.
// Here we record intent and suppress duplicate comms until the next terminal event.
await db.none('insert into dunning_suppressions(invoice_id, until) values($1, now() + interval \'24 hours\') on conflict (invoice_id) do update set until = excluded.until', [invoice.id]);
}Reference: Stripe Billing Smart Retries documentation describes AI-driven retry timing and custom schedules you can define in Billing settings.
5) Sync your CRM and post alerts without duplicates
Use an upsert pattern keyed by customer or invoice. If you prefer no-code, Stripe has official Zapier and Make apps for visual workflows.
// crm/sync.js
async function upsertCrmTask({ invoice, paymentIntent }) {
const key = `stripe:${invoice?.id || paymentIntent?.id}`;
const payload = {
external_key: key,
title: `Failed payment: ${invoice?.number || paymentIntent?.id}`,
status: 'open',
amount: invoice?.amount_due,
currency: invoice?.currency,
customer_email: invoice?.customer_email
};
await http.post(process.env.CRM_TASK_UPSERT_URL, payload); // your CRM upsert endpoint
}
async function postSlackAlert({ invoice }) {
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: `Invoice ${invoice.number} failed for ${invoice.customer_email}. Owner: ${invoice.customer_name || 'Unassigned'}` })
});
}This mirrors what we run in production. Alerts are immediate, CRM stays the source of truth, and duplicates are suppressed by the external_key.
6) Schedule reports for audit and feed your dashboard
Stripe supports CSV exports and scheduled reports from the Dashboard. We schedule weekly invoice and payment reports and push a summary to a finance dashboard.
Dashboard: Reports -> Scheduled. Add Payments and Invoices weekly. Send to finance@ and archive to your reporting folder. Document the schedule in runbooks.We also listen to invoice.paid and customer.subscription.updated to mark recoveries in the CRM so owners see win rates without exporting CSVs daily.
Where it gets complicated
Raw body vs JSON body. Signature verification fails if JSON parsing runs before the webhook route. Keep a raw body parser scoped to this one route.
At-least-once and out-of-order. Stripe webhooks are not ordered and retry for days after delivery failures. Store event IDs and always re-fetch the invoice or payment intent before acting.
2xx quickly, do heavy work async. If you block on email or CRM calls before acknowledging, Stripe marks the attempt failed and keeps retrying. A fast 200 OK with a queued job prevents thundering herds.
Test and live key isolation. Keep environments fully separate. We saw teams accidentally fire live dunning emails from test events because a shared webhook URL was used.
Smart Retries vs your comms cadence. When Smart Retries triggers a background attempt, do not send a new email every time. We use a suppression window per invoice and only re-email on material state changes.
CRM idempotency. Without a true upsert keyed by invoice or payment intent, you create duplicate tasks. We key all side effects to a single external key.
What this actually changes
In production this system handled every failure event within minutes, posted owner-visible Slack alerts, queued clean dunning emails with secure update links, and marked recoveries automatically when invoice.paid landed. Finance stopped exporting CSVs to find declines and reps stopped stepping on each other with duplicate outreach.
One concrete delivery fact from Stripe docs: webhook deliveries are retried automatically for up to three days on failures, with exponential backoff, so your handlers must be idempotent and fast to acknowledge (Stripe webhooks guide). That reliability is what makes a webhook-first dunning loop viable without polling.
Frequently asked questions
Which Stripe events should I subscribe to for failed payments?
Invoice and payment intent failures are the core: invoice.payment_failed and payment_intent.payment_failed. Also listen to invoice.paid to mark recoveries and customer.subscription.updated for status changes. These cover the dunning loop without polling.
Do I need Stripe Billing for Smart Retries?
If you use Stripe Billing, Smart Retries provides AI-driven retry timing you can configure in settings. Without Billing you can still run your own retry logic, but we recommend enabling Billing when subscriptions are your core model so Stripe handles the heavy lift.
How do I prevent duplicate emails and CRM tasks?
Store and check event.id in a log table, key all side effects by invoice or payment intent ID, and set a suppression window per invoice so background retries do not trigger new emails. Always re-fetch the latest invoice before acting to avoid stale state.
Can I build this without writing code?
Yes at a basic level. Stripe has official Zapier and Make integrations for visual workflows. For production scale we still add a small webhook service to verify signatures, enforce idempotency, and coordinate CRM plus alerting with better control.
What does this cost to run monthly?
Stripe webhooks and API usage do not carry separate fees. Your costs are the small serverless or container runtime and your email or SMS provider. Most of the lift is the one-time engineering to wire verification, idempotency, CRM sync, and the comms cadence.
If you want this shipped as a turnkey workflow with proper verification and idempotency, we have built it and can adapt it to your stack. See our service overview at /services#workflow-automation, our related write-up on no-code CRM flows in /blog/automate-gohighlevel-crm-make, and book a working session at /book.
Want us to build this for you?
15-minute discovery call. No pitch. We tell you what to automate first.
Book a Discovery Call