Stripe Smart Retries will recover some failed invoices on their own. What they will not do is treat an insufficient_funds decline differently from a card_declined, or tell you which of your messages caused a recovery. Building that layer takes surprisingly little code — most of the work is in doing the boring parts correctly.
# TL;DR
- Verify every webhook signature before parsing the body; reject anything unverified.
- Make handlers idempotent — Stripe retries deliveries and will send duplicates.
- Route on
decline_code, not on the generic failure event. - Never retry hard declines; request a new payment method instead.
- Close the loop on
invoice.paidso recoveries are attributable and outreach stops.
# The events you need
| Event | Meaning |
|---|---|
invoice.payment_failed | A subscription invoice failed to collect |
payment_intent.payment_failed | An off-session or one-off charge was declined |
customer.subscription.updated | Subscription moved to past_due / unpaid |
invoice.paid | Recovery succeeded — stop outreach, attribute the win |
# Verification and idempotency
export async function handleStripeWebhook(request: Request) {
const signature = request.headers.get("stripe-signature");
const body = await request.text();
const event = stripe.webhooks.constructEvent(
body,
signature!,
process.env["STRIPE_WEBHOOK_SECRET"]!,
);
// Stripe redelivers. Store the event id and exit early on a repeat.
const seen = await recordEventOnce(event.id);
if (seen) return new Response("duplicate", { status: 200 });
await route(event);
return new Response("ok");
}
Two rules that save you later: always return 2xx quickly and do the real work on a queue, and never trust a payload you have not verified.
constructEvent needs the raw request body. Parsing the JSON first breaks signature verification — a classic afternoon lost to a "signature mismatch" that is really a body-mutation bug.
# Routing on the decline code
const SOFT = new Set([
"insufficient_funds",
"try_again_later",
"processing_error",
"issuer_not_available",
]);
const HARD = new Set([
"stolen_card",
"lost_card",
"pickup_card",
"revocation_of_authorization",
]);
function planFor(declineCode: string) {
if (HARD.has(declineCode)) return { retry: false, ask: "new_payment_method" };
if (declineCode === "authentication_required") return { retry: false, ask: "authenticate" };
if (SOFT.has(declineCode)) return { retry: true, delaysHours: [24, 72, 168] };
return { retry: true, delaysHours: [48] };
}
Retrying a hard decline is worse than doing nothing: the charge will fail again and repeated declines can attract issuer scrutiny.
# Scheduling retries
Stripe can retry for you, or you can drive stripe.invoices.pay() on your own schedule. Pick one — running both produces double charges attempts and confused customers. If you take over scheduling, disable Smart Retries for the affected subscriptions.
Pair each retry with a message on a channel the customer reads, and space them so the customer has time to act between attempts.
# Closing the loop
When invoice.paid arrives, look up the open recovery attempt for that invoice, mark it recovered, record which message preceded it, and cancel every queued follow-up. This one step turns dunning from a cost centre into something you can measure and improve.
# FAQ
# Why is my Stripe webhook signature verification failing?
Almost always because the raw body was parsed or re-serialised before verification. Pass the exact bytes Stripe sent.
# Should I use Stripe Smart Retries or my own schedule?
Use one, not both. Smart Retries are a good default; take over only when you want retry timing to depend on the decline reason or on customer behaviour.
# How many retries are sensible?
Three or four over roughly a week for soft declines. Beyond that the marginal recovery is negligible and the issuer signal turns negative.
# Does building this mean leaving Stripe?
No. Stripe remains the processor and the system of record. The dunning layer only reads events and orchestrates retries and messaging on top.