ACH Return Codes Explained: R01, R03, R10 and How to Handle Them
When a payout fails, the reason matters. ACH return codes tell you exactly what went wrong—and whether your retry logic should fire immediately, wait, or pivot to a different payment rail entirely.
This guide covers the most common ACH return codes you'll encounter building payment integrations, what triggers them, and how to code a response.
The Big Three: R01, R03, R10
R01: Insufficient Funds
The account exists, routing is valid, but the balance is too low. This is the most frequent return (roughly 30–40% of all ACH returns in production systems).
When it fires: 1–2 business days after the debit entry is submitted.
How to handle it:
- Flag the transaction as "insufficient funds" in your database.
- Do not retry immediately; the customer's balance won't change in the next hour.
- Implement exponential backoff: retry after 3 days, then 7 days.
- Offer an alternative: email the customer with a link to retry, or suggest a lower payout amount.
async function handleACHReturn(returnCode, transaction) {
if (returnCode === 'R01') {
await db.updateTransaction(transaction.id, {
status: 'failed_insufficient_funds',
nextRetryAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), // 3 days
requiresCustomerAction: true
});
await sendCustomerEmail(transaction.userId, {
subject: 'Your payout needs attention',
body: 'Your account doesn't have enough funds. Try again in 3 days.'
});
}
}
R03: No Account / Unable to Locate Account
The routing number is valid, but the account number doesn't exist at that bank—or was closed.
When it fires: Usually within 24 hours.
How to handle it:
- Do not retry. This is permanent.
- Mark the bank account as invalid.
- Prompt the user to re-enter their banking details.
- If this is a recurring payout setup, pause all future payouts to this account.
if (returnCode === 'R03') {
await db.updateBankAccount(account.id, { status: 'invalid' });
await db.updateTransaction(transaction.id, {
status: 'failed_invalid_account',
requiresNewBankDetails: true
});
// Pause any scheduled payouts
await db.pauseRecurringPayouts(account.userId);
}
R10: Customer Advises Unauthorized
The customer contacted their bank and said they didn't authorize the debit. This is a dispute, not a technical failure.
When it fires: 10–180 days after the original entry (customers have up to 60 days to dispute).
How to handle it:
- Log this as a chargeback or dispute.
- Do not retry automatically.
- Contact the customer directly—they may have disputed in error.
- Preserve all evidence: timestamps, consent records, IP logs.
- Consider flagging the customer's account for review if disputes are frequent.
if (returnCode === 'R10') {
await db.createDispute({
transactionId: transaction.id,
type: 'unauthorized_claim',
status: 'under_review',
createdAt: new Date()
});
// Alert compliance/support
await notifyComplianceTeam(transaction.userId, {
reason: 'Customer dispute filed',
transactionAmount: transaction.amount
});
}
Other Common Codes
| Code | Meaning | Retry? | Timeline |
|---|---|---|---|
| R02 | Account closed | No | 24h |
| R04 | Invalid routing | No | 24h |
| R05 | Unauthorized user / account holder | No | 24h |
| R07 | Authorization revoked | No | 1–2 days |
| R08 | Payment stopped by customer | No | 1–2 days |
| R09 | Uncollected funds | Yes (after 5 days) | 1–2 days |
| R16 | Account frozen | No | 1–2 days |
| R20 | Non-transaction account | No | 24h |
Integration Pattern: Decode and Route
javascript
const returnCodeActions = {
'R01': { retry: true, delay: 3 * 24 * 60 * 60, action: 'notify_customer' },
'R03': { retry: false, action: 'invalidate_account' },
'R10': { retry: false, action: 'create_dispute' },
'R02': { retry
---
*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)