DEV Community

Cover image for Building Retry Logic for Recurring Payments: What Failed Direct Debit Collections Teach You About Idempotency
Ben Clifford
Ben Clifford

Posted on

Building Retry Logic for Recurring Payments: What Failed Direct Debit Collections Teach You About Idempotency

Recurring billing is one of those systems that looks trivial in a demo and turns into a distributed-systems problem the moment it touches production. The core issue: payment rails fail in ways that aren't binary, and your retry logic needs to reflect that.

Why "just retry" isn't a strategy

With Direct Debit specifically, a failed collection can mean a few very different things: insufficient funds, an expired or cancelled mandate, an account that no longer exists, or a bank-side technical failure. Treating all of these the same way, retry in 24 hours, is how you end up hammering a dead mandate for weeks or, worse, retrying a payment that already technically succeeded on the bank's side but hasn't reported back yet.

A better mental model is to bucket failures:

Transient failures (technical error, timeout) → retry quickly, maybe within hours

  • Insufficient funds → retry, but with backoff timed around typical pay cycles rather than a fixed interval
  • Mandate invalid/cancelled → do not retry, surface it to the customer immediately
  • Duplicate/already processed → don't retry at all, this is where idempotency keys save you

Idempotency is not optional

Every payment initiation call should carry an idempotency key generated client-side, not server-side, so that a network timeout followed by a client retry doesn't create two charges. This matters more with bank rails than cards because the feedback loop is slower. If your API call times out on a Direct Debit submission, you genuinely don't know whether the bank received it. Retrying blind without an idempotency key means you might double-collect from a real customer's real bank account, which is a much worse failure mode than a failed card charge.

Webhooks as the source of truth

Polling for payment status works until it doesn't. The scalable pattern is to treat webhooks as your source of truth for state transitions (submitted → confirmed → paid, or submitted → failed → retried), and to build your system so it's resilient to webhooks arriving out of order or being delivered more than once. Store the event ID, dedupe on it, and never assume webhook delivery is exactly-once, because it almost never is in practice.

The dunning problem is a UX problem, not just a backend one

A surprising amount of churn in subscription businesses isn't voluntary cancellation, it's failed payments that never got resolved. If your retry logic silently fails three times and then cancels the subscription without ever nudging the customer, you're optimizing for backend simplicity at the cost of revenue. The systems that handle this well pair backend retry logic with proactive customer messaging, "your payment failed, update your details," timed to land before the account actually lapses.

None of this is unique to payments. It's the same class of problem as any system dealing with unreliable delivery, partial failure, and eventual consistency; payments just make the stakes for getting it wrong immediately visible in someone's bank balance.
It's also a recurring theme in startup founder stories: the unglamorous failure-handling work is usually what separates a subscription business that scales from one that quietly bleeds churn.

Top comments (0)