Handling ACH Return Codes and Retry Eligibility
An ACH debit that comes back carries a three-character reason code, and that code is the entire recovery strategy. R01 means the account had insufficient funds and may well have them next week. R02 means the account is closed and will never have them. Retrying the first is sensible; retrying the second is futile and, past the scheme’s limits, a compliance problem. This guide sits under Alternative Payment Methods & Local Rails and covers the classification and the recovery flow it drives.
The reason this matters more than the equivalent card logic is that ACH returns are governed by rules with teeth. The scheme sets limits on re-presentment and monitors your return rate; sustained rates above the published thresholds attract scrutiny and can end with the rail being withdrawn. A retry loop that treats every failure identically will eventually cross one of those lines.
Trade-offs
| Return class | Example codes | Retry? | Recovery action | Typical share |
|---|---|---|---|---|
| Funding | R01, R09 | Yes, up to the limit | Retry after several business days | Majority of returns |
| Account invalid | R02, R03, R04 | Never | Request new bank details | Small but permanent |
| Authorisation | R07, R08, R10, R29 | Never | Stop debits, resolve with customer | Small, high risk |
| Administrative | R05, R06, R16 | Case by case | Human review | Rare |
The authorisation class deserves particular care. R10 means the customer told their bank they did not authorise the debit. Continuing to debit after that is exactly what the scheme’s unauthorised-return threshold measures, and it is also the fastest way to turn a billing dispute into a formal one. Stop, and resolve with the customer directly.
Step-by-Step Implementation
1. Encode the classification once
RETRYABLE = {"R01", "R09"} # funding
INVALID = {"R02", "R03", "R04", "R13", "R20"} # account cannot be debited
UNAUTHORIZED = {"R05", "R07", "R08", "R10", "R11", "R29"} # authorisation withdrawn or absent
ADMIN = {"R06", "R16", "R17"} # requires human review
MAX_REPRESENTMENTS = 2 # in addition to the original debit
def recovery_action(code: str, prior_attempts: int) -> str:
if code in UNAUTHORIZED:
return "stop_debits_and_contact"
if code in INVALID:
return "request_new_bank_details"
if code in RETRYABLE and prior_attempts < MAX_REPRESENTMENTS:
return "retry_in_business_days"
if code in ADMIN:
return "human_review"
return "request_new_payment_method"
One function, one source of truth. The failure mode this prevents is a second implementation appearing in the dunning worker that disagrees with the one in the payment handler — and disagreements here are invisible until a rate threshold is breached.
2. Enforce the re-presentment limit in data, not just code
ALTER TABLE payment_attempts
ADD COLUMN representment_index INT NOT NULL DEFAULT 0,
ADD CONSTRAINT ach_representment_limit
CHECK (rail <> 'ach_debit' OR representment_index <= 2);
A database constraint survives a refactor of the retry scheduler. Given that exceeding the limit is a scheme violation rather than merely a wasted attempt, it belongs somewhere a code change cannot silently remove it.
3. Space retries in business days
Aligning retries to likely payday cycles improves recovery meaningfully for consumer accounts, in the same way smart retry timing does for cards — but the constraint here is the scheme’s, so timing optimisation happens inside the permitted window rather than by adding attempts.
4. Escalate terminal returns to a method change
For invalid-account and authorisation returns, the recovery is a conversation, not a retry. The message should name the problem plainly — “your bank returned the payment because the account is closed” — and link directly to a page where new details can be entered. Generic “payment failed” copy produces a support ticket instead of a resolution, because the customer has no idea which of their several accounts is involved.
5. Monitor the rates that put the rail at risk
-- Rolling 60-day return rates by class, the figures the scheme monitors.
SELECT
count(*) FILTER (WHERE return_code IS NOT NULL)::numeric / count(*) AS overall_rate,
count(*) FILTER (WHERE return_code IN ('R05','R07','R10','R11','R29'))::numeric
/ nullif(count(*), 0) AS unauthorized_rate,
count(*) FILTER (WHERE return_code IN ('R02','R03','R04'))::numeric
/ nullif(count(*), 0) AS admin_rate
FROM payment_attempts
WHERE rail = 'ach_debit' AND created_at > now() - INTERVAL '60 days';
Alert well below the published thresholds. By the time a rate is at the limit, the population that caused it has already been debited, and the only remaining lever is to stop debiting a cohort — which is a business decision made under time pressure.
Reducing Returns Before They Happen
Most return-rate problems are acquisition problems wearing a payments costume, and the highest-leverage fixes sit upstream of the debit.
Validate the account before the first debit. Micro-deposit verification or an instant account-verification service confirms that the account exists, is open, and belongs to the person authorising the debit. It adds friction at signup and removes the entire invalid-account return class, which is the one that damages the administrative rate.
Set the debit date deliberately. Consumer accounts are far more likely to be funded shortly after a payday than at the end of a month, and a subscription whose renewal happens to fall on the 28th will see a materially higher insufficient-funds rate than one on the 3rd. Offering the customer a choice of debit date is a small feature with an outsized effect on returns.
Notify before debiting, even where the scheme does not require it. A message two days before the debit, naming the amount and the date, converts a surprise into an expectation and gives the customer time to move money. It also removes a common cause of unauthorised returns: the customer who does not recognise the debit on their statement and disputes it reflexively.
Finally, watch the cohort composition of your returns. If a single acquisition channel or a single plan produces a disproportionate share, the problem is that segment’s fit with the rail rather than the rail itself, and the fix is to route that segment to cards rather than to tune the retry logic.
Verification & Testing
Build a table-driven test over the full code set you intend to support, asserting the action for each. Include codes you do not currently expect — the catch-all branch should be conservative, asking for a new payment method rather than retrying an unrecognised failure.
Test the re-presentment limit at the boundary: two prior attempts allows a retry decision only if the limit is defined as “two in addition to the original”, and the third must not. Then assert the database constraint rejects an over-limit attempt even when the application logic is bypassed.
Test the business-day spacing across a holiday weekend using a fixed calendar fixture, and assert that no retry is scheduled before the prior attempt’s expected return-reporting date. Finally, run a rate-monitoring test over a synthetic population that crosses the alert threshold, and assert the alert fires — a monitoring query that has never been seen to fire is a query nobody has validated.
Gotchas & Production Pitfalls
- Retrying an R10. The customer has told their bank the debit was unauthorised. Another attempt escalates a dispute and counts against the rate the scheme watches most closely.
- Counting the original debit as a re-presentment. An off-by-one here either wastes a permitted attempt or exceeds the limit; define the counter explicitly and test both ends.
- Retry scheduled in calendar days. Across a weekend or holiday the retry can precede the prior return, producing two failures for one payment.
- Return codes stored only as free text. Reporting and routing both need the code as a first-class column; parsing it out of a description field breaks the first time a provider changes wording.
- Bank details updated without a fresh authorisation. A new account needs a new authorisation; carrying the old one forward is exactly the condition an unauthorised return describes.
Frequently Asked Questions
How many retries are actually allowed? The scheme permits a limited number of re-presentments for a returned entry — commonly two beyond the original for funding-related returns — and other classes are not retryable at all. Confirm the current rule with your provider and encode it as a constant rather than folklore.
Should ACH failures use the same dunning emails as cards? No. The language differs (“your bank returned the payment” rather than “your card was declined”), the timing is slower, and the resolution is often a different bank account rather than a new card.
What return rate is safe? Well below the published thresholds, with the unauthorised rate being the one to watch most closely because it is both the lowest limit and the most damaging to breach. Alert at a fraction of the limit so there is time to act.
Is instant account verification worth the cost? For any product with meaningful ACH volume, yes. It removes the invalid-account return class almost entirely, and those returns are the ones that arrive with no recovery path.