DEV Community

Payout Rail
Payout Rail

Posted on

Building ACH-Aware Code: Detect, Decode, and Route Returns Programmatically

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';
}
Enter fullscreen mode Exit fullscreen mode

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,
  });
}
Enter fullscreen mode Exit fullscreen mode

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)