Decision Trees in Production: When to Execute Plan B (ACH Return Handling)
The Fourth-and-Eight Problem in Payment Systems
A coach's decision to "go for it on fourth-and-eight if coverage is right" mirrors a critical pattern in payment infrastructure: conditional execution based on real-time signal analysis. In fintech, this translates directly to ACH return handling—knowing when to retry, when to switch rails, and when to escalate.
Your payout system, like a play call, needs to evaluate conditions in flight and execute the right branch. This article walks through building that decision tree for ACH returns.
Why ACH Returns Demand Conditional Logic
ACH transfers fail at predictable rates. NACHA data shows roughly 0.5–1% of B2B ACH transactions return. But "return" isn't monolithic. An R01 (insufficient funds) is recoverable in 3–5 days. An R03 (no account) is permanent.
Your code cannot treat all returns identically. You need conditional branches:
- R01, R09 (insufficient funds, unsigned account): Retry after 2–3 days.
- R03, R04 (no account, invalid account): Flag for manual review or switch to Visa Direct.
- R07 (authorization revoked): Escalate; do not retry automatically.
- R10 (unauthorized): Investigate with the originating customer.
Without this logic, you either retry permanently (wasting time and fees) or give up too early (leaving money on the table).
Building the Decision Tree
Here's a practical pattern:
async function handleAchReturn(returnCode, payoutRecord) {
const returnMetadata = ACH_RETURN_CODES[returnCode];
if (returnMetadata.recoverable && payoutRecord.retryCount < 2) {
// Schedule retry after window (e.g., R01 after 3 days)
await scheduleRetry(payoutRecord.id, returnMetadata.retryWindowDays);
return { action: 'RETRY', nextAttempt: futureDate };
}
if (returnMetadata.requiresAltRail) {
// Route to Visa Direct or RTP instead
const altResult = await routeToVisaDirect(payoutRecord);
return { action: 'ALT_RAIL', method: 'VISA_DIRECT', result: altResult };
}
if (returnMetadata.requiresManualReview) {
// Escalate to ops team
await createManualReviewTicket(payoutRecord, returnCode);
return { action: 'MANUAL_REVIEW', escalated: true };
}
// Permanent failure
return { action: 'FAIL', reason: returnMetadata.description };
}
Key Return Codes & Conditions
| Code | Meaning | Recoverable? | Typical Window | Recommended Action |
|---|---|---|---|---|
| R01 | Insufficient funds | Yes | 3–5 days | Retry once |
| R03 | No account | No | N/A | Manual review + alt rail |
| R04 | Invalid account | No | N/A | Manual review |
| R07 | Authorization revoked | No | N/A | Escalate; contact originator |
| R09 | Unsigned account | Yes | 2–3 days | Retry |
| R10 | Unauthorized | No | N/A | Investigate with customer |
| R29 | Corporate account closed | No | N/A | Manual review |
Timing Matters: ACH Settlement Windows
ACH batches settle in windows:
- Standard ACH: 1–2 business days.
- Same-day ACH: 4 settlement windows per day (8:45 AM, 12:45 PM, 3:45 PM, 5:15 PM ET).
Returns typically land 1–2 business days after the original transfer. Your retry logic must account for this lag:
const retrySchedule = {
R01: { delayDays: 3, maxAttempts: 2 },
R09: { delayDays: 2, maxAttempts: 1 },
R03: { delayDays: 0, maxAttempts: 0 }, // No retry
};
When to Switch Rails
If ACH fails twice, consider Visa Direct or RTP:
- Visa Direct: 30-min settlement, higher cost (~$0.25–0.50 per transaction), reversible for 10 days.
- RTP: Near-instant, lower cost (~$0.05–0.15), available only at participating banks.
Decide at the second return:
javascript
if (payoutRecord.retryCount >= 2 && returnCode === 'R01') {
return await routeToVisaDirect(pay
---
*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)