Handling R01, R02, and R03 ACH Return Codes
The three most common administrative returns look almost identical on the wire — same Type-6 entry, same Type-99 addenda, a three-character code in positions 4–6 — and they demand three completely different responses. R01 says the money is not there right now; you may re-present it. R02 and R03 say the account is gone or was never there; re-presenting either is a NACHA rules violation, not a retry. Collapsing the three into one "returned, try again" branch is the single most expensive mistake in ACH collections, because it turns a permanent failure into a stream of illegal re-presentments against a closed account. This page drills into that decision inside the broader NACHA return-code handling reference, itself part of the Reg E, NACHA and audit controls framework, and gives you one annotated function that classifies the trio and returns a legally correct action.
All three are administrative returns on a two-banking-day clock, and all three arrive through the addenda-parsing path established in NACHA record layouts. What separates them is not timing but permanence, and the pipeline must encode that distinction as hard as a type check.
Concept Spec: Three Codes, One Permanence Axis
The precise definitions, straight from Appendix Four of the NACHA Operating Rules, are what the handler keys on:
R01— Insufficient Funds. The available balance in the Receiver's account is not sufficient to cover the debit. This is a transient condition: the account exists and is open, it simply lacked funds at posting.R01is one of only two administrative codes (withR09, uncollected funds) eligible for re-presentment.R02— Account Closed. A previously open account has been closed. This is permanent: the account will never accept the entry, and every future presentment fails identically. Re-presentment is prohibited.R03— No Account / Unable to Locate Account. The account number does not correspond to a valid open account, or does not match the individual named on the entry. Also permanent from the pipeline's perspective — the stored instruction is wrong and no retry can fix it without corrected data (which, if it arrives, comes as a Notification of Change, not a return).
The classification therefore reduces to a single boolean axis — transient versus permanent — plus the re-presentment cap. Evaluating one return is constant work, , and a return file of entries is with a streaming pass; the retry counter is the only state, and it lives against the original trace, not the file.
Full Annotated Implementation
The function below takes a parsed return and the retry history of its original entry and returns a typed action. It refuses to re-present R02/R03 even if a caller passes a retry count of zero, and it refuses to re-present R01 once two retries already exist — the two guards are independent because they fail for different reasons.
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from enum import Enum
class Action(str, Enum):
RETRY = "retry" # eligible for re-presentment within limits
WRITE_OFF = "write_off" # permanent failure — close the receivable
FLAG_ACCOUNT = "flag_account" # stop future entries against this instruction
MANUAL = "manual" # human review
MAX_REPRESENTMENTS = 2 # R01/R09 only: 2 retries after the original
REPRESENT_WINDOW_DAYS = 180 # calendar days from original settlement
@dataclass(frozen=True, slots=True)
class AdminReturn:
return_code: str # "R01" | "R02" | "R03"
original_trace: str
amount: Decimal # dollars, exact
settlement_date: date # of the ORIGINAL entry
sec_code: str # "PPD" | "WEB" | "TEL" | "CCD" | ...
prior_represents: int # retries already attempted on this trace
def classify_admin_return(ret: AdminReturn, today: date) -> dict[str, object]:
"""Classify R01/R02/R03 and return a legally correct action.
R01 (insufficient funds) is transient and re-presentable up to two times
within 180 days. R02 (account closed) and R03 (no account) are permanent:
re-presenting either violates the NACHA Operating Rules, so both stop.
"""
code = ret.return_code.upper()
# Permanent failures: never retry. Flag the stored instruction so no
# further entry is ever originated against this account, then write off.
if code in {"R02", "R03"}:
return {
"trace": ret.original_trace,
"action": Action.WRITE_OFF,
"flag": Action.FLAG_ACCOUNT,
"reason": f"{code}_permanent_no_retry",
"retryable": False,
}
if code == "R01":
# WEB and TEL debits carry stricter reinitiation gates: a re-presented
# WEB/TEL entry still relies on the original authorization, so if that
# authorization is single-use the retry is not permitted.
if ret.sec_code in {"WEB", "TEL"} and _single_use_auth(ret):
return {"trace": ret.original_trace, "action": Action.MANUAL,
"reason": "R01_single_use_auth_no_auto_retry"}
days_open = (today - ret.settlement_date).days
if days_open > REPRESENT_WINDOW_DAYS:
return {"trace": ret.original_trace, "action": Action.WRITE_OFF,
"reason": "R01_past_180_day_window", "retryable": False}
if ret.prior_represents >= MAX_REPRESENTMENTS:
return {"trace": ret.original_trace, "action": Action.WRITE_OFF,
"reason": "R01_represent_cap_reached", "retryable": False}
return {
"trace": ret.original_trace,
"action": Action.RETRY,
"attempt": ret.prior_represents + 1,
"company_entry_description": "RETRY PYMT", # mandated by the rules
"amount": ret.amount, # identical — no fee folded in
"reason": "R01_insufficient_funds_retry",
"retryable": True,
}
return {"trace": ret.original_trace, "action": Action.MANUAL,
"reason": f"unexpected_code:{code}"}
def _single_use_auth(ret: AdminReturn) -> bool:
"""Stub: look up whether the original authorization was single-use.
Recurring WEB/TEL authorizations permit re-presentment; a one-time
authorization does not, so this gates the R01 retry for those SEC codes.
"""
return False
Calibration & Configuration
Three levers decide how aggressively the handler retries, and each has a rule behind it:
- Retry count. Cap at two re-presentments after the original — three presentments total. Store the count against the original trace, persisted across files, not per-file; a counter that resets each morning lets a fourth presentment through against an account that has already refused three. Alert whenever a trace reaches the cap so collections can switch to a dunning path instead of silently writing off.
- 180-day window. Measure calendar days from the original entry's Settlement Date, not from the most recent return. An
R01retried on day 179 is legal; the same entry on day 181 must write off even if retries remain. Keep the window and the count as independent gates — either one closing ends re-presentment. - WEB/TEL reinitiation.
WEB(internet-authorized) andTEL(telephone-authorized) debits lean on the original authorization for every presentment. A recurring authorization supports re-presentment; a single-use authorization does not, so route those toMANUALrather than auto-retrying. Wire the_single_use_authlookup to your authorization store before enabling auto-retry on either SEC code.
Store these as an external, version-controlled profile so a change hot-reloads without a deploy — the same discipline used for amount tolerance bands elsewhere in the engine.
Validation Example: Before and After
Take three returns arriving on the morning file for entries settled 2026-07-01, evaluated on 2026-07-06. The naive "returned → retry" branch treats all three identically; the classifier does not.
| Original trace | Code | Addenda pos 4–6 | Naive handler | Correct action |
|---|---|---|---|---|
091000019500001 |
R01 | R01 |
RETRY | RETRY (attempt 1, RETRY PYMT, $482.10) |
091000019500002 |
R02 | R02 |
RETRY (illegal) | WRITE_OFF + FLAG_ACCOUNT |
091000019500003 |
R03 | R03 |
RETRY (illegal) | WRITE_OFF + FLAG_ACCOUNT |
The naive path re-presents two entries against a closed account and a non-existent account — two rules violations that will bounce again identically tomorrow and expose the Originator to an ODFI warranty claim. The classifier re-presents only the R01, tagging it RETRY PYMT with the identical $482.10 amount, and permanently flags the other two. Feeding the R01 back through on a later file with prior_represents = 2 flips it to WRITE_OFF with reason R01_represent_cap_reached, closing the loop cleanly.
Failure Modes & Guardrails
- Re-presenting R02/R03 illegally. The most dangerous bug is a single
if returned: retrybranch that never inspects the code. Encode permanence as a hard set membership test (code in {"R02", "R03"}) that returnsWRITE_OFFbefore any retry logic runs, and flag the stored instruction so no new entry is ever originated against that account either. - Losing the retry count. A counter scoped to the file, the session, or an in-memory dict that dies on restart lets the third and fourth presentment slip through. Persist
prior_representsagainst the original trace in durable storage and increment it atomically when aRETRYaction is emitted, not when the retry settles. - Reinitiation-window miscalculation. Anchoring the 180-day window on the return date instead of the original Settlement Date silently extends the legal retry period every time the item bounces — an entry could be re-presented well past 180 days from origination. Always measure from the original settlement, and treat the window and the count as two independent gates that each end re-presentment on their own.
Frequently Asked Questions
Can I re-present an R02 or R03 return if I think the account was reopened?
No. R02 (account closed) and R03 (no account) are permanent returns, and the rules prohibit reinitiating an entry returned for either reason. If you later obtain a valid, open account number, that is a new authorization and a new entry — not a re-presentment of the returned one — and it must originate fresh, not carry the RETRY PYMT description. Re-presenting the original against a supposedly reopened account is a rules violation even if the account happens to accept it.
How many times can I re-present an R01?
Up to two times after the original return, for three total presentments, and only within 180 calendar days of the original entry's Settlement Date. Each re-presentment must be identical in amount, routing number, and account number, and must carry the Company Entry Description RETRY PYMT. You may not add a returned-item fee to the retry amount; a fee is a separate entry under its own authorization. Once either the two-retry cap or the 180-day window is reached, the entry must be written off rather than re-presented again.
Do WEB and TEL debits follow the same R01 retry rules?
They follow the same two-retry and 180-day limits, but with an extra gate: a re-presented WEB or TEL entry still relies on the original authorization. A recurring authorization supports re-presentment, while a single-use authorization does not — so an R01 on a one-time WEB or TEL debit should route to manual review rather than auto-retry. Wire your authorization store into the retry decision before enabling automatic re-presentment on those SEC codes.
Should the account be flagged as well as written off on R02/R03?
Yes — write-off and account-flagging are two separate obligations. Writing off closes the receivable, but if you leave the stored account instruction active, your next scheduled origination will fire another entry at the same dead account and earn another R02/R03. Flag the stored instruction as invalid so no future entry is generated against it, and require a Notification of Change or a fresh authorization before the account is ever used again.
Related guides in this collection
- Handling NACHA Return Codes in Reconciliation Pipelines — the parent reference: full R-code taxonomy, return windows, and reconciliation to the original entry.
- Automating NACHA Notification of Change (NOC/COR) Processing — how corrected account data arrives when an R03 could have been avoided.
- Regulation E Error Resolution for ACH Disputes — where an unauthorized return, unlike these administrative codes, opens a consumer dispute clock.
- NACHA Record Layouts Explained — the byte-level entry and addenda grammar these return codes are parsed from.