Engineering
3 min read

Stripe Dunning Automation with Webhooks: A Technical Walkthrough

How to build reliable Stripe dunning automation on webhooks: signature verification, idempotency, decline-code routing, retry scheduling, and attribution.

PS
Palash Sarker
September 11, 2026

TL;DR — Key takeaways

  • Verify every webhook signature before parsing the body; reject anything unverified.
  • Make handlers idempotent — Stripe retries deliveries and will send duplicates.
  • Route on declinecode, not on the generic failure event.
  • Never retry hard declines; request a new payment method instead.
  • Close the loop on invoice.paid so recoveries are attributable and outreach stops.

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.paid so recoveries are attributable and outreach stops.

# The events you need

EventMeaning
invoice.payment_failedA subscription invoice failed to collect
payment_intent.payment_failedAn off-session or one-off charge was declined
customer.subscription.updatedSubscription moved to past_due / unpaid
invoice.paidRecovery 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.

NOTE

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.

The RRLabs recovery newsletter

One deep piece a week on failed-payment recovery, subscription retention, and billing infrastructure. No spam. Unsubscribe any time.

Recommended Reading

Estimate your recoverable revenue

Calculate now