ACH Return Codes Explained: R01–R85 and How to Handle Them in Production
Why ACH Return Codes Matter to Your Integration
If you're building a payout system, ACH returns are inevitable. A user's bank rejects the transfer, and your code needs to know why—and what to do next. The Nacha operating rules define 85 standardized return codes (R01 through R85). Understanding them isn't optional; it's the difference between a robust payment flow and one that silently fails in production.
This guide walks through the most common return codes, what triggers them, and how to handle each one programmatically.
The Big Three: R01, R03, R10
R01 – Insufficient Funds
What it means: The account holder doesn't have enough money to cover the debit.
When it fires: During ACH debit processing, typically 1–2 business days after the file is sent to the bank.
How to handle it:
- This is often temporary. Retry after 3–5 days if the customer is expected to receive funds.
- Flag the account for manual review if it's a recurring payout.
- Consider dunning: send an email asking the user to add funds, then retry.
if (returnCode === 'R01') {
// Insufficient funds – likely temporary
await scheduleRetry(payoutId, { delayDays: 5, maxRetries: 2 });
await notifyCustomer(customerId, 'ACH rejected due to insufficient funds.');
}
R03 – No Account
What it means: The account number doesn't exist, or the routing number is invalid.
When it fires: During validation, sometimes immediately, sometimes after a few days.
How to handle it:
- This is permanent. Don't retry.
- Require the user to re-enter or verify their bank details.
- Route future payouts to an alternate method (card, RTP, etc.).
if (returnCode === 'R03') {
// Account doesn't exist – permanent
await disablePayoutMethod(payoutMethodId);
await requestBankDetailsUpdate(customerId);
// Route to fallback rail
return await initiateCardPayout(customerId, amount);
}
R10 – Customer Advises Not Authorized
What it means: The account holder told their bank they didn't authorize this debit.
When it fires: 10–180 days after the original debit (yes, really).
How to handle it:
- This signals a dispute or fraud claim. Don't retry automatically.
- Log it for compliance and investigation.
- Reach out to the customer directly to resolve.
if (returnCode === 'R10') {
// Unauthorized claim – investigate
await flagForCompliance(payoutId, { reason: 'R10', priority: 'high' });
await contactCustomer(customerId, 'We received a dispute on your recent payout.');
}
Other Common Codes and Patterns
| Code | Reason | Retry? | Action |
|---|---|---|---|
| R02 | Account Closed | No | Update bank details |
| R04 | Invalid Account Number | No | Request new details |
| R05 | Reserved (not used) | — | — |
| R07 | Authorization Revoked | No | Contact customer |
| R08 | Payment Stopped | Maybe | Ask customer, retry after 5 days |
| R09 | Uncollected Funds | Yes | Retry after 5 days |
| R16 | Account Frozen | No | Escalate to support |
| R20 | Non-Transaction Account | No | Request new account |
Building Retry Logic That Doesn't Break
The key is classifying each return as permanent, temporary, or investigate.
javascript
const returnCodeClassification = {
'R01': { type: 'temporary', retryAfterDays: 5, maxRetries: 2 },
'R03': { type: 'permanent', retryAfterDays: null, maxRetries: 0 },
'R08': { type: 'temporary', retryAfterDays: 5, maxRetries: 1 },
'R10': { type: 'investigate', retryAfterDays: null, maxRetries: 0 },
'R16': { type: 'permanent', retryAfterDays: null, maxRetries: 0 },
};
async function handleReturn(payoutId, returnCode) {
const classification = returnCodeClassification[returnCode];
if (!classification) {
console.warn(`Unknown return code: ${returnCode}`);
return;
}
if (classification.type === 'temporary') {
await scheduleRetry(payoutId, classification.retryAfterDays);
} else if (classification.type === 'permanent') {
---
*Decoding ACH return codes programmatically? The [ACH Return Codes API](https://rapidapi.com/payoutrail-ach-return-codes/api/ach-return-codes-api?utm_source=nichestream&utm_medium=devto&utm_campaign=payoutrail-ach-returns) returns the full Nacha R01–R85 set with plain-language descriptions and handling guidance.*
Top comments (0)