Skip to the content.
How U.S. Payments Really Work Part 17
How U.S. Payments Really Work Part 17

ACH Retries: Designing a Safe and Compliant Retry Engine

Why retries are not just a technical decision — they’re a regulatory minefield.

Suma Manjunath
Author: Suma Manjunath
Published on: October 01, 2025

ACH Limits at Scale

For: Payments engineers, fintech architects, compliance-aware developers
Reading Time: 12 minutes
Prerequisites: Familiarity with ACH basics (ODFI/RDFI, NACHA return codes), Ruby or Python coding skills
Why now: U.S. ACH volumes are at record highs, and CFPB scrutiny is increasing. A retry mistake can mean CFPB enforcement, NACHA fines, and reputational loss.

TL;DR:

⚠️ Disclaimer: All scenarios, accounts, names, and data used in examples are not real. They are realistic scenarios provided only for educational and illustrative purposes.


Problem Definition

The challenge: ACH retries are often implemented like API retries — fire until success. But that violates Reg E, NACHA rules, and customer trust.

Who faces this: Any payment originator (lenders, subscription merchants, utilities) that debits customer accounts.

Cost of inaction:

Why current approaches fail: Standard retry libraries don’t encode NACHA/Reg E logic, so teams either over-retry (violating rules) or under-retry (leaving money uncollected).


Reg E, NACHA, and Other Governing Rules

ℹ️ Note: Retries are finite, conditional, and consent-bound — not infinite loops.


The ACH Retry Decision Tree

flowchart TD
  A["ACH Return Received"] --> B{"Return Code Classification"}
  B -->|"R01 Insufficient Funds OR R09 Uncollected Funds"| C["R01 Insufficient Funds OR R09 Uncollected Funds"]
  C --> D["Retry Eligible (max 2 additional within 180 days)"]
  B -->|"R07 Revoked / R08 Stop Payment / R10 Unauthorized"| E["R07 Revoked / R08 Stop Payment / R10 Unauthorized"]
  E --> F["Never Retry"]
  B -->|"R02 Account Closed / R03 No Account / R20 Non-Transaction Account"| G["R02 Account Closed / R03 No Account / R20 Non-Transaction Account"]
  G --> H["Never Retry"]

Solution Implementation: Compliance-First Retry Engine

Here’s how to build a compliant, auditable retry system.

1. Return Code Classification

RETURN_CODE_CLASSIFICATION = {
  retryable: { 'R01' => 'Insufficient Funds', 'R09' => 'Uncollected Funds' },
  non_retryable: { 'R02' => 'Account Closed', 'R03' => 'No Account',
                   'R07' => 'Authorization Revoked', 'R08' => 'Stop Payment',
                   'R10' => 'Consumer Advises Unauthorized',
                   'R20' => 'Non-Transaction Account' }
}.freeze

def retryable?(code)
  RETURN_CODE_CLASSIFICATION[:retryable].key?(code)
end

2. Retry Tracking and Limits

class RetryTracker
  def initialize(max_retries = 2, retry_window_days = 180)
    @max_retries = max_retries
    @retry_window_days = retry_window_days
    @attempts = {}
  end

  def can_retry?(tx_id, return_code)
    return false unless retryable?(return_code)

    valid_attempts = (@attempts[tx_id] || []).select do |a|
      (Time.now - a[:timestamp]) <= (@retry_window_days * 86400)
    end
    valid_attempts.length < @max_retries
  end

  def record_retry(tx_id)
    @attempts[tx_id] ||= []
    @attempts[tx_id] << { timestamp: Time.now }
  end

  # Ensure retry is within the allowed window
  def validate_retry_window(original_date, retry_date)
    days_elapsed = (retry_date - original_date) / 86400
    days_elapsed <= @retry_window_days
  end
end

3. POA & Revocation Handling

class AuthorizationTracker
  def initialize
    @authorizations = {}
    @revoked = Set.new
  end

  def add_poa(tx_id, customer, amount_cents)
    @authorizations[tx_id] = {
      customer: customer, amount_cents: amount_cents,
      status: 'ACTIVE', created_at: Time.now
    }
  end

  def revoke(tx_id)
    @revoked.add(tx_id)
    if auth = @authorizations[tx_id]
      auth[:status] = 'REVOKED'
      auth[:revoked_at] = Time.now
    end
  end

  def active?(tx_id)
    !@revoked.include?(tx_id)
  end
end

💡 Tip: Treat revocations as a kill switch — stop immediately.

4. Smart Scheduling

Retries should align with paydays or known funding dates, not random intervals.

def next_retry_date(initial_date, return_code)
  case return_code
  when 'R01' then initial_date + 2*24*60*60 # retry after 2 days
  when 'R09' then initial_date + 1*24*60*60 # retry after 1 day
  else nil
  end
end

5. Customer Notifications

def notify_retry(customer, amount_cents, retry_date)
  puts "📩  Email to #{customer}: " \
       "We will retry your payment of $#{amount_cents / 100.0} " \
       "on #{retry_date.strftime('%Y-%m-%d')}."
end

Warning: Silent retries create CFPB complaints and chargeback disputes.


Validation & Monitoring


Takeaways

  1. ACH retries are compliance-first, not tech-first.
  2. Only R01/R09 can be retried, and only twice in 180 days (3 attempts total).
  3. Every retry needs POA, counters, revocation checks, audit logs, and documentation.
  4. Align retries with pay cycles — not brute force.
  5. Notify customers before retries to preserve trust and transparency.
  6. Strengthen audit readiness with exception handling and regulator-expected logs.

Acronyms & Terms


References

  1. NACHA ACH Operating Rules - NACHA Operating Rules & Guidelines, 2024–2025
  2. CFPB Reg E Guidance - Electronic Fund Transfers (Regulation E), 12 CFR Part 1005, 2024
  3. Federal Reserve Compliance Guide - Compliance Considerations for ACH Originators, 2023
  4. ABA Banking Journal - ACH Returns and Retry Risk Management, 2024
  5. UCC Article 4A - Uniform Commercial Code: Funds Transfers, 2024
  6. FFIEC Payments Guidance - FFIEC Payment System Risk, 2023

Comments & Discussion

Share your thoughts, ask questions, or start a discussion about this article.