getNodi

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.