Handle failed payments through Stripe webhooks, execute controlled retries, and trigger Resend emails while preserving subscription state and revenue.
Dunning as an Event-Driven Process
Dunning succeeds when every payment failure triggers a deterministic sequence of retries, notifications, and state updates rather than ad-hoc manual work. Stripe emits invoice.payment_failed, invoice.payment_succeeded, and customer.subscription.updated events that form the spine of this workflow.
Treat these events as the single source of truth. Store the invoice ID, attempt count, and next action timestamp in your database so the system can resume after restarts or deploys. Event-Driven SaaS Automation shows why this pattern eliminates drift between Stripe and your internal records.
Receiving and Verifying Stripe Webhooks
Create a single endpoint that validates the Stripe signature before any business logic runs. Use the official stripe package to construct the event and reject requests missing the signature header.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET);
export async function handleWebhook(req) {
const sig = req.headers['stripe-signature'];
const event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
// queue event for processing
}
Return 200 only after the event is durably queued. Any earlier response risks duplicate processing when Stripe retries delivery.
Idempotent Retry and State Machine
Implement a state machine keyed on invoice.id. On payment_failed, increment attempt count, calculate next retry using Stripe’s built-in schedule or your own exponential backoff, and record the state as “retry_scheduled”.
Idempotency keys prevent double-charging or duplicate emails when the same event arrives twice. Store the Stripe event ID in a unique index and skip processing if it already exists. Resilient API Workflows details the exact patterns that survive network partitions and worker restarts.
Never call Stripe’s retry endpoint from the webhook handler itself; schedule a background job instead so the handler remains fast and the retry can be retried independently.
Sending Dunning Emails via Resend
When a retry is scheduled or the final attempt fails, emit an internal event that triggers Resend. Map attempt numbers to distinct email templates: first failure gets a gentle reminder, third failure escalates to “action required”, and final failure notifies of upcoming cancellation.
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_KEY);
await resend.emails.send({
from: 'billing@yourdomain.com',
to: customer.email,
subject: `Payment failed – attempt ${attempt}`,
html: renderDunningTemplate(attempt, invoice)
});
Include the hosted invoice URL so customers can pay without logging in. Track opens and clicks by passing Resend’s message ID back into your database for later analytics.
Escalation, Grace Periods, and Cancellation
After the configured number of retries, update the subscription to past_due and start a grace-period timer. At the end of the grace period, call subscriptions.cancel only if the invoice remains unpaid. Record the cancellation reason as “payment_failed” so downstream analytics stay accurate.
Expose an admin override that lets support mark an invoice paid manually; this path must also emit the same internal events so email and state logic stay consistent.
Production Hardening and Observability
Log every state transition with invoice ID and correlation ID. Set alerts on the gap between expected retry time and actual execution; a growing queue indicates worker saturation or Stripe rate-limit throttling.
Cost control comes from keeping the webhook handler stateless and pushing all email rendering and retry scheduling into a queue that can be scaled independently. Test the full path with Stripe’s test clock so you can simulate 14-day retry sequences in minutes rather than days.
