DEV Community

Payout Rail
Payout Rail

Posted on

ACH Return Codes Explained: R01–R85 and How to Handle Them in Production

ACH Return Codes Explained: R01–R85 and How to Handle Them in Production

Why ACH Return Codes Matter to Your Integration

If you're building a payout system, ACH returns are inevitable. A user's bank rejects the transfer, and your code needs to know why—and what to do next. The Nacha operating rules define 85 standardized return codes (R01 through R85). Understanding them isn't optional; it's the difference between a robust payment flow and one that silently fails in production.

This guide walks through the most common return codes, what triggers them, and how to handle each one programmatically.

The Big Three: R01, R03, R10

R01 – Insufficient Funds

What it means: The account holder doesn't have enough money to cover the debit.

When it fires: During ACH debit processing, typically 1–2 business days after the file is sent to the bank.

How to handle it:

  • This is often temporary. Retry after 3–5 days if the customer is expected to receive funds.
  • Flag the account for manual review if it's a recurring payout.
  • Consider dunning: send an email asking the user to add funds, then retry.
if (returnCode === 'R01') {
  // Insufficient funds – likely temporary
  await scheduleRetry(payoutId, { delayDays: 5, maxRetries: 2 });
  await notifyCustomer(customerId, 'ACH rejected due to insufficient funds.');
}
Enter fullscreen mode Exit fullscreen mode

R03 – No Account

What it means: The account number doesn't exist, or the routing number is invalid.

When it fires: During validation, sometimes immediately, sometimes after a few days.

How to handle it:

  • This is permanent. Don't retry.
  • Require the user to re-enter or verify their bank details.
  • Route future payouts to an alternate method (card, RTP, etc.).
if (returnCode === 'R03') {
  // Account doesn't exist – permanent
  await disablePayoutMethod(payoutMethodId);
  await requestBankDetailsUpdate(customerId);
  // Route to fallback rail
  return await initiateCardPayout(customerId, amount);
}
Enter fullscreen mode Exit fullscreen mode

R10 – Customer Advises Not Authorized

What it means: The account holder told their bank they didn't authorize this debit.

When it fires: 10–180 days after the original debit (yes, really).

How to handle it:

  • This signals a dispute or fraud claim. Don't retry automatically.
  • Log it for compliance and investigation.
  • Reach out to the customer directly to resolve.
if (returnCode === 'R10') {
  // Unauthorized claim – investigate
  await flagForCompliance(payoutId, { reason: 'R10', priority: 'high' });
  await contactCustomer(customerId, 'We received a dispute on your recent payout.');
}
Enter fullscreen mode Exit fullscreen mode

Other Common Codes and Patterns

Code Reason Retry? Action
R02 Account Closed No Update bank details
R04 Invalid Account Number No Request new details
R05 Reserved (not used)
R07 Authorization Revoked No Contact customer
R08 Payment Stopped Maybe Ask customer, retry after 5 days
R09 Uncollected Funds Yes Retry after 5 days
R16 Account Frozen No Escalate to support
R20 Non-Transaction Account No Request new account

Building Retry Logic That Doesn't Break

The key is classifying each return as permanent, temporary, or investigate.


javascript
const returnCodeClassification = {
  'R01': { type: 'temporary', retryAfterDays: 5, maxRetries: 2 },
  'R03': { type: 'permanent', retryAfterDays: null, maxRetries: 0 },
  'R08': { type: 'temporary', retryAfterDays: 5, maxRetries: 1 },
  'R10': { type: 'investigate', retryAfterDays: null, maxRetries: 0 },
  'R16': { type: 'permanent', retryAfterDays: null, maxRetries: 0 },
};

async function handleReturn(payoutId, returnCode) {
  const classification = returnCodeClassification[returnCode];

  if (!classification) {
    console.warn(`Unknown return code: ${returnCode}`);
    return;
  }

  if (classification.type === 'temporary') {
    await scheduleRetry(payoutId, classification.retryAfterDays);
  } else if (classification.type === 'permanent') {

---

*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)