Templates / Failed payment retry
Retrying failed payments
Most declined card charges succeed on a later attempt. A daily pass through the dunning queue recovers revenue that would otherwise churn without anyone noticing it left.
The recipe
- Request
- POST https://{{domain}}/api/billing/dunning
- Schedule
- At 10:00 AM (0 10 * * *)
- Headers
- Authorization: Bearer {{secret}}encrypted
- Timeout
- 30s
- Attempts
- 3
- Counts as success
- 2xx
You supply
- Your domain
- example.com
Host only — no https:// and no trailing slash.
- Shared secret
- s3cr3t-valuestored encrypted
Whatever your endpoint checks the caller against.
Why this job exists
Card payments fail for reasons that are usually temporary: insufficient funds a few days before payday, a bank fraud rule that clears on a second attempt, an expired card the customer has already replaced. A meaningful share of declines succeed if retried a day or two later.
Without a dunning process those subscriptions simply end. The customer did not choose to leave and often does not know they have — the first they hear is when something stops working. That is involuntary churn, and it is generally the cheapest churn a business can fix, because the customer already wants the product.
Doing it well means spacing attempts out and stopping. Retrying immediately, repeatedly, mostly fails and gets you flagged by the card networks.
Setting it up
Space attempts with increasing gaps and cap them. The daily schedule provides the tick; the endpoint decides who is actually due today, which keeps the retry policy in one readable place:
A retry ladder
// Days after the original failure to attempt again.
const LADDER = [1, 3, 7, 14];
const due = await payments.find({
status: 'failed',
attempts: { $lt: LADDER.length },
$expr: { $lte: ['$nextAttemptAt', new Date()] },
});
for (const payment of due) {
const result = await gateway.charge(payment);
if (result.ok) {
await payments.markRecovered(payment.id);
continue;
}
const next = LADDER[payment.attempts];
await payments.update(payment.id, {
attempts: payment.attempts + 1,
nextAttemptAt: next ? daysFromNow(next) : null,
status: next ? 'failed' : 'exhausted',
});
}Pair each attempt with an email. The retry recovers the payments that were going to work anyway; the email recovers the ones that need the customer to do something, and those are the larger half.
Checking it actually works
- Track the recovery rate — recovered over attempted. Anything in the region of a third is normal, and a rate near zero means the ladder is too aggressive.
- Confirm subscriptions are actually reactivated on success, not merely marked paid.
- Check that exhausted payments stop being retried and hand off to whatever your cancellation flow is.
Where it goes wrong
Retrying too often
Repeated attempts on the same card within a short window get flagged as suspicious by issuers, and card networks impose limits on retries per authorisation. Beyond the fines, it lowers your success rate on the attempts you do make.
No end to the ladder
Retrying forever holds a dead subscription open indefinitely and distorts every revenue metric you have. Four attempts over a fortnight, then stop and cancel.
Silent retries
A customer whose card expired cannot be recovered by any number of retries — only by telling them. Sending nothing means the ones you could have saved churn alongside the ones you could not.
Questions
Does Stripe not do this?
Stripe Smart Retries handles it well if you use Stripe Billing. This recipe is for custom billing, other gateways, or invoice flows the provider does not manage.
What time of day?
Mid-morning local is a reasonable default: it avoids the overnight batch windows some banks run, and if it triggers an email the customer receives it while they are awake.
Should I retry the full amount or part?
Full amount. Partial charges complicate reconciliation and, in most jurisdictions, charging an amount the customer did not agree to is its own problem.
Running it here
This is a preset in the product, not an illustration. Pick it in the dashboard, fill in the 2 values above, and the job is created, scheduled and enabled — with retries, a record of every run showing status, latency and response, and an alert the first time one fails. Secret values are encrypted at rest and never rendered back.
The free plan runs 3 jobs hourly and needs no card. Where a recipe wants a finer cadence than your plan allows, it is slowed to the fastest schedule you are permitted rather than rejected.