Building ACH-Aware Code: Detect, Decode, and Route Returns Programmatically
When an ACH transaction fails, your payout system has seconds to decide: retry, escalate, or switch rails. Most developers discover this the hard way—after a customer's direct deposit bounces and your reconciliation breaks.
This article walks you through a production pattern for handling ACH returns in code: detecting the return code, decoding its meaning, and routing to the next action without manual intervention.
Why ACH Return Codes Matter
The National Automated Clearing House Association (Nacha) defines 85 return codes (R01–R85). Each one tells you why a transaction failed and whether it's recoverable. A bank won't retry R03 (no account) no matter how many times you send it. But R01 (insufficient funds) might clear tomorrow.
Your integration needs to parse these codes and act accordingly—or you'll leak failed payouts into support queues.
The Core Return Codes You'll See
| Code | Meaning | Recoverable? | Typical Action |
|---|---|---|---|
| R01 | Insufficient funds | Yes | Retry in 1–3 days |
| R03 | No account / invalid account | No | Contact customer, update bank details |
| R04 | Invalid account type | No | Switch account type or rail |
| R07 | Authorization revoked by customer | No | Request new authorization |
| R10 | Customer advises unauthorized | No | Escalate; possible fraud |
| R29 | Corporate account closed | No | Update customer's bank account |
For a complete reference, consult the Nacha Operating Rules.
Pattern: Detect → Decode → Route
Here's a concrete integration pattern:
async function handleAchReturn(returnEvent) {
const returnCode = returnEvent.code; // e.g., "R01"
const payoutId = returnEvent.payout_id;
const recipientId = returnEvent.recipient_id;
// Step 1: Classify the return
const returnClass = classifyReturn(returnCode);
switch (returnClass) {
case 'RECOVERABLE':
// Insufficient funds, temporary issue
await scheduleRetry(payoutId, { delayDays: 2 });
await logEvent(payoutId, 'RETRY_SCHEDULED', returnCode);
break;
case 'PERMANENT_FAILURE':
// No account, account closed, unauthorized
await markPayoutFailed(payoutId, returnCode);
await notifyRecipient(recipientId, {
message: 'Bank transfer failed. Please update your account.',
returnCode,
});
await logEvent(payoutId, 'FAILED_PERMANENT', returnCode);
break;
case 'FRAUD_RISK':
// Customer claims unauthorized
await freezeRecipient(recipientId);
await escalateToCompliance(payoutId, returnCode);
await logEvent(payoutId, 'ESCALATED_FRAUD', returnCode);
break;
default:
await logEvent(payoutId, 'UNKNOWN_RETURN', returnCode);
}
}
function classifyReturn(code) {
const recoverable = ['R01', 'R02', 'R21', 'R22'];
const permanentFailure = ['R03', 'R04', 'R29', 'R07'];
const fraudRisk = ['R10', 'R11'];
if (recoverable.includes(code)) return 'RECOVERABLE';
if (permanentFailure.includes(code)) return 'PERMANENT_FAILURE';
if (fraudRisk.includes(code)) return 'FRAUD_RISK';
return 'UNKNOWN';
}
Retry Logic for Recoverable Returns
When you retry, respect ACH timing:
- Standard ACH: 1–2 business days settlement. Retry after 2–3 days.
- Same-Day ACH: Settled same day. Retry the next business day.
- Cap retries: Most processors allow 2 attempts. After that, escalate.
async function scheduleRetry(payoutId, { delayDays }) {
const nextAttempt = addBusinessDays(new Date(), delayDays);
await db.payouts.update(payoutId, {
status: 'RETRY_PENDING',
next_attempt_at: nextAttempt,
attempt_count: (await getAttemptCount(payoutId)) + 1,
});
}
When to Switch Rails
Some returns signal you should route the next payout differently:
- R03 (no account) + customer confirms valid details → Try RTP (Real-Time Payments) instead. Faster feedback loop.
- **R04 (invalid account type)
Decoding ACH return codes programmatically? The ACH Return Codes API returns the full Nacha R01–R85 set with plain-language descriptions and handling guidance.
Top comments (0)