DEV Community

Payout Rail
Payout Rail

Posted on

Building ACH-Aware Code: Detect, Decode, and Route Around Failures

Building ACH-Aware Code: Detect, Decode, and Route Around Failures

The Problem: ACH Returns Break Silently

You've built a payout system. A customer initiates a transfer. Three business days later, the ACH return arrives—but your code doesn't know what happened. Was it insufficient funds? A closed account? Did the customer dispute it? Without decoding the return code, you can't retry intelligently, notify the user properly, or route to an alternate rail (RTP, Visa Direct) in time.

This article walks through a concrete pattern: detect the return, decode the NACHA R-code, and decide the next action programmatically.

Understanding NACHA Return Codes

The National Automated Clearing House Association defines 85 return codes (R01–R85). Each maps to a specific failure reason and dictates whether a retry makes sense.

Code Reason Retryable? Next Action
R01 Insufficient funds Yes Retry in 2–3 days or notify user
R03 No account / invalid account number No Route to alternate rail or mark failed
R04 Invalid account type No Request new bank details from user
R07 Authorization revoked No Contact user; may need consent renewal
R10 Customer advises unauthorized No Investigate dispute; may require reversal
R29 Corporate account closed No Update customer records; route alternate

Critically: R01 and R09 (uncollected funds) are retryable. Most others are terminal and require user intervention or a different payment method.

Pattern: Detect → Decode → Route

Here's a production-ready flow:


javascript
// 1. Listen for ACH return webhook from your processor
app.post('/webhooks/ach-return', async (req, res) => {
  const { transaction_id, return_code, return_reason } = req.body;

  // 2. Decode the return code
  const decision = decideRetryOrRoute(return_code);

  if (decision.action === 'retry') {
    // Retryable: insufficient funds, uncollected funds
    await scheduleRetry(transaction_id, decision.delayDays);
    await notifyUser(transaction_id, 'Retry scheduled');
  } else if (decision.action === 'alternate_rail') {
    // Terminal ACH error; try RTP or Visa Direct
    await initiateAlternateRail(transaction_id, decision.rail);
    await notifyUser(transaction_id, 'Trying faster method');
  } else if (decision.action === 'manual_review') {
    // Dispute or authorization issue
    await escalateToSupport(transaction_id, return_code);
    await notifyUser(transaction_id, 'Please verify your account');
  }

  res.status(200).json({ processed: true });
});

// 3. Decode function: map R-code to action
function decideRetryOrRoute(returnCode) {
  const retryable = ['R01', 'R09']; // Insufficient funds, uncollected
  const noAccount = ['R03', 'R04']; // Invalid/closed account
  const dispute = ['R10', 'R29'];   // Unauthorized, account closed

  if (retryable.includes(returnCode)) {
    return { action: 'retry', delayDays: 2 };
  } else if (noAccount.includes(returnCode)) {
    return { action: 'alternate_rail', rail: 'rtp' }; // RTP is faster
  } else if (dispute.includes(returnCode)) {
    return { action: 'manual_review' };
  }
  return { action: 'manual_review' }; // Default: escalate
}

// 4. Schedule retry with exponential backoff
async function scheduleRetry(txnId, delayDays) {
  const nextAttempt = new Date();
  nextAttempt.setDate(nextAttempt.getDate() + delayDays);

  await db.payouts.update(
    { id: txnId },
    { 
      status: 'retry_scheduled',
      retry_count: db.raw('retry_count + 1'),
      next_attempt_at: nextAttempt
    }
  );
}

// 5. Route to RTP (Real-Time Payments) or Visa Direct
async function initiateAlternateRail(txnId, rail) {
  const payout = await db.payouts.findOne({ id: txnId });

  if (rail === 'rtp') {
    // RTP: near-instant, higher cost (~$1 per txn)
    await rtpClient.send({
      amount: payout.amount,
      beneficiary_account: payout.account_number,
      beneficiary_routing: payout

---

*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.*
Enter fullscreen mode Exit fullscreen mode

Top comments (0)