Handling NACHA Return Codes in Reconciliation Pipelines
A return is not an exception your matcher failed to clear — it is an inbound file the RDFI sends back to say a debit or credit you already originated did not stick. Two banking days after settlement, a file lands carrying a copy of your own Entry Detail record with its transaction code flipped and an addenda record bolted on, and buried in that addenda is a three-character reason code that decides everything downstream: whether you may retry the item, must write it off, have to update a stored account, or now own a Reg E dispute clock. Getting this wrong does not merely inflate the exception desk — re-presenting an entry the rules forbid is itself a rules violation, and mis-timing a 60-day unauthorized return forfeits the RDFI's warranty you would otherwise rely on. This reference sits within the broader Reg E, NACHA and audit controls framework and shows how to parse, classify, and reconcile every return-reason code against the entry that produced it.
Returns arrive as addenda-bearing entries, so the parsing discipline is the one already established in NACHA record layouts: a return is a Type-6 Entry Detail record followed by a Type-7 addenda whose Addenda Type Code is 99, and the Return Reason Code lives in positions 4–6 of that addenda. Because the reconciliation of a return is a legal event — it can trigger a chargeback, a provisional credit, or a write-off — every decision the pipeline makes must be persisted to an immutable audit trail keyed by the original trace number, so an examiner can reconstruct why a short-settled item was retried instead of disputed. The trace number is the join key throughout: the return carries the original entry's 15-digit trace in positions 80–94, and reconciling it means finding that exact outbound entry in your settlement ledger.
The Return-Reason Code Taxonomy
NACHA return-reason codes are grouped by who is returning, why, and how long they have — and those three properties drive completely different handling. The administrative codes cover mechanical failures the RDFI detects automatically: R01 insufficient funds, R02 account closed, R03 no account / unable to locate account, R04 invalid account number structure, and R09 uncollected funds. These must be transmitted so the ODFI can retrieve them by the opening of business on the second banking day following the original Settlement Date. Administrative returns carry no dispute; they are a clean signal that the destination account cannot accept the entry as sent.
The unauthorized codes are a different regulatory animal. R05 (unauthorized debit to a consumer account using a corporate SEC code), R07 (authorization revoked by the customer), R10 (customer advises the originator is not known or the entry is not authorized), and R11 (customer advises the entry is not in accordance with the terms of the authorization) apply to consumer entries and give the receiver a 60 calendar day window from the Settlement Date. Each requires the receiver to sign a Written Statement of Unauthorized Debit (WSUD), and each strips the ODFI's warranty — meaning the loss flows back to the Originator. R06 is the ODFI's own request that the RDFI return an entry (used when the Originator asks its bank to claw back an item), and it stands apart from both groups.
Finally, the dishonored and contested ranges close the loop between institutions. When the ODFI believes a return was itself defective — sent late, duplicated, or wrong-amount — it can dishonor the return with an R6x code (R61 misrouted return, R67 duplicate return, R68 untimely return, and related codes), which sends it back to the RDFI. The RDFI can then contest that dishonored return with an R7x code (R70 permissible return entry not accepted, R71, R73 timely original return, and related codes). These are rare relative to first-party returns but they carry the tightest deadlines in the whole framework, and a pipeline that treats an R6x record as a normal return will double-count the reversal.
The core numeric arithmetic mirrors the outbound entry exactly: the amount in positions 30–39 is a 10-digit count of cents with an implied two-place decimal, so a return of 0000012345 reverses $123.45. Reconciling a return to its original is a single hash lookup on the trace number — per return, over a return file of entries — because the original outbound ledger is keyed by trace. No return depends on any other return in the file.
Return Timeframes and Re-Presentment Limits
Two clocks govern the framework, and confusing them is the most common source of forfeited warranties. Administrative returns run on banking days: the RDFI must make the return available to the ODFI by the opening of business on the second banking day after the original entry's Settlement Date. Unauthorized consumer returns run on calendar days: the RDFI has 60 calendar days from the Settlement Date, and a return transmitted on day 61 is untimely and returnable by the ODFI as a dishonored return under R68. Because banking days skip weekends and Federal Reserve holidays while calendar days do not, the two windows must be computed against different calendars — a naive timedelta(days=2) on an administrative return will land on a Sunday and mis-classify a perfectly timely Monday return as late.
Re-presentment (formally reinitiation) is tightly bounded. An entry returned R01 (insufficient funds) or R09 (uncollected funds) may be reinitiated up to two times following the return — a maximum of three total presentments, the original plus two retries — and every reinitiation must occur within 180 days after the Settlement Date of the original entry. The reinitiated entry must be identical in dollar amount, routing number, and account number to the original, and must carry the Company Entry Description RETRY PYMT. Crucially, you may not fold a returned-item fee into the retry amount: a fee must be a separate entry under its own authorization, so the retry stays a byte-for-byte re-presentment of the original obligation and not an inflated one. Entries returned for any other reason — R02, R03, R04, and the unauthorized codes — must never be reinitiated; re-presenting a closed or non-existent account is a rules violation, not a collection strategy. R11 is the modern exception: since the 2020 rules change it can be corrected and re-presented within 60 days of the original settlement without a fresh authorization, because it signals a fixable discrepancy rather than a repudiation.
Phase-by-Phase Implementation
The handler is built in three stages: a typed model of the return entry, a classification table that encodes each code's family, window, and re-presentment eligibility, and a dispatcher that reconciles the return to its original and emits an action. Every monetary value is decimal.Decimal, never float, so the reversal reconciles exactly against the outbound ledger.
1. Model the return entry
A return is parsed from the Type-6 record plus its Type-99 addenda. The Return Reason Code sits in positions 4–6 of the addenda (Python slice [3:6]), and the original trace it reverses sits in positions 7–21 of that same addenda.
from __future__ import annotations
from datetime import date
from decimal import Decimal
from enum import Enum
from pydantic import BaseModel, field_validator
class ReturnClass(str, Enum):
ADMINISTRATIVE = "administrative"
UNAUTHORIZED = "unauthorized"
ODFI_REQUEST = "odfi_request"
DISHONORED = "dishonored"
CONTESTED = "contested"
class ReturnEntry(BaseModel):
"""A single return parsed from a Type-6 entry and its Type-99 addenda."""
return_code: str # positions 4-6 of the type-99 addenda, e.g. "R01"
original_trace: str # 15-digit trace of the entry being returned
amount: Decimal # dollars, exact — parsed from cents / 100
settlement_date: date # settlement date of the ORIGINAL entry
dfi_account: str
sec_code: str # PPD, WEB, TEL, CCD, CTX, ...
is_consumer: bool
@field_validator("return_code")
@classmethod
def _upper(cls, v: str) -> str:
code = v.strip().upper()
if len(code) != 3 or code[0] not in {"R", "C"}:
raise ValueError(f"malformed return code {v!r}")
return code
def parse_return(entry_rec: str, addenda_rec: str, settlement: date,
is_consumer: bool) -> ReturnEntry:
"""Build a ReturnEntry from the raw 94-byte entry and addenda records."""
cents = entry_rec[29:39].strip() or "0"
return ReturnEntry(
return_code=addenda_rec[3:6],
original_trace=addenda_rec[6:21].strip(),
amount=Decimal(cents) / Decimal(100),
settlement_date=settlement,
dfi_account=entry_rec[12:29].strip(),
sec_code=entry_rec[50:53], # from the batch header context
is_consumer=is_consumer,
)
2. Encode the classification table
Each code maps to a spec carrying its family, return window, the calendar that window is measured on, and whether the code is eligible for re-presentment. This table is the single source of truth — no handler branches on a raw string literal.
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class RCodeSpec:
rclass: ReturnClass
window_days: int # maximum permitted return window
window_basis: str # "banking" | "calendar"
representable: bool # eligible for reinitiation (R01/R09/R11 only)
R_CODES: dict[str, RCodeSpec] = {
# Administrative — 2 banking days, no dispute
"R01": RCodeSpec(ReturnClass.ADMINISTRATIVE, 2, "banking", True), # NSF
"R02": RCodeSpec(ReturnClass.ADMINISTRATIVE, 2, "banking", False), # account closed
"R03": RCodeSpec(ReturnClass.ADMINISTRATIVE, 2, "banking", False), # no account
"R04": RCodeSpec(ReturnClass.ADMINISTRATIVE, 2, "banking", False), # bad acct structure
"R09": RCodeSpec(ReturnClass.ADMINISTRATIVE, 2, "banking", True), # uncollected funds
# Unauthorized consumer — 60 calendar days, WSUD required
"R05": RCodeSpec(ReturnClass.UNAUTHORIZED, 60, "calendar", False),
"R07": RCodeSpec(ReturnClass.UNAUTHORIZED, 60, "calendar", False),
"R10": RCodeSpec(ReturnClass.UNAUTHORIZED, 60, "calendar", False),
"R11": RCodeSpec(ReturnClass.UNAUTHORIZED, 60, "calendar", True), # correctable
# ODFI request
"R06": RCodeSpec(ReturnClass.ODFI_REQUEST, 2, "banking", False),
# Dishonored (ODFI -> RDFI) and contested (RDFI -> ODFI)
"R61": RCodeSpec(ReturnClass.DISHONORED, 5, "banking", False),
"R67": RCodeSpec(ReturnClass.DISHONORED, 5, "banking", False),
"R68": RCodeSpec(ReturnClass.DISHONORED, 5, "banking", False),
"R70": RCodeSpec(ReturnClass.CONTESTED, 2, "banking", False),
"R73": RCodeSpec(ReturnClass.CONTESTED, 2, "banking", False),
}
3. Reconcile and dispatch an action
The dispatcher looks up the original entry by trace, confirms the amount reverses exactly, checks the window against the correct calendar, and enforces the re-presentment cap before returning an action. An unknown code is never silently dropped — it becomes a MANUAL action so a human sees it.
from typing import Callable, Protocol
class Clock(Protocol):
def banking_days_between(self, a: date, b: date) -> int: ...
def calendar_days_between(self, a: date, b: date) -> int: ...
class OriginalLedger(Protocol):
def lookup(self, trace: str) -> "OutboundEntry | None": ...
def retry_count(self, trace: str) -> int: ...
def dispatch_return(ret: ReturnEntry, ledger: OriginalLedger,
clock: Clock, today: date) -> dict[str, object]:
"""Reconcile a return to its original entry and emit an action."""
spec = R_CODES.get(ret.return_code)
if spec is None:
return {"trace": ret.original_trace, "action": "MANUAL",
"reason": f"unknown_return_code:{ret.return_code}"}
original = ledger.lookup(ret.original_trace)
if original is None:
return {"trace": ret.original_trace, "action": "MANUAL",
"reason": "unmatched_return_no_original"}
if original.amount != ret.amount:
return {"trace": ret.original_trace, "action": "MANUAL",
"reason": f"amount_mismatch:{original.amount}!={ret.amount}"}
# Window check on the correct calendar.
if spec.window_basis == "banking":
age = clock.banking_days_between(ret.settlement_date, today)
else:
age = clock.calendar_days_between(ret.settlement_date, today)
timely = age <= spec.window_days
if spec.rclass is ReturnClass.UNAUTHORIZED:
return {"trace": ret.original_trace, "action": "DISPUTE",
"reason": ret.return_code, "timely": timely,
"requires_wsud": True}
if spec.representable and ledger.retry_count(ret.original_trace) < 2:
days_since = clock.calendar_days_between(ret.settlement_date, today)
if days_since <= 180:
return {"trace": ret.original_trace, "action": "RETRY",
"reason": ret.return_code,
"attempt": ledger.retry_count(ret.original_trace) + 1}
if ret.return_code in {"R02", "R03", "R04"}:
return {"trace": ret.original_trace, "action": "WRITE_OFF",
"reason": ret.return_code}
return {"trace": ret.original_trace, "action": "MANUAL",
"reason": f"exhausted_or_unhandled:{ret.return_code}"}
The dispatcher deliberately returns RETRY only when the code is representable and the retry count is below two and the 180-day window is open — three independent guards, because each protects against a different rules violation. Handling of the administrative trio in detail, including the WEB/TEL reinitiation nuances, is covered in handling R01, R02, and R03 returns.
Edge Cases & Known Failure Modes
| Failure scenario | Root cause | Mitigation |
|---|---|---|
| Return has no matching original entry | Trace parsed from wrong addenda positions, or original never persisted to the settlement ledger | Key the outbound ledger by trace at origination; emit MANUAL on a miss, never auto-write-off |
| Re-presentment count exceeded | Retry counter reset per file instead of per original trace, so a third presentment slips through | Persist retry_count against the original trace across files; cap at two before emitting RETRY |
| 60-day window miscalculated | Unauthorized window measured in banking days, or the clock anchored on the return date instead of the original Settlement Date | Measure calendar days from the original Settlement Date; anchor every window on settlement, not receipt |
| Contested dishonored return double-counted | An R6x/R7x record treated as a first-party return and reversed a second time |
Branch on ReturnClass.DISHONORED/CONTESTED before any ledger posting; net against the prior return, do not re-post |
| Amount does not reverse the original | Fee folded into a RETRY PYMT entry, or currency/cent scaling error |
Assert original.amount == ret.amount before acting; keep amounts Decimal end to end |
| Administrative return mis-timed | timedelta(days=2) crossed a weekend or Fed holiday |
Compute banking-day age against a Federal Reserve holiday calendar, not raw dates |
Compliance & Auditability
The return framework is defined by the NACHA Operating Rules & Guidelines: Article Three governs the obligations of the RDFI to return entries and of the ODFI to accept them, Appendix Four enumerates every Return Reason Code and its permissible timing, and the reinitiation rules (identical content, RETRY PYMT description, two-retry and 180-day caps) sit in Article Two's provisions on the Originator. Dishonored and contested returns are governed by the same Article Three sections that set the five-banking-day and two-banking-day response windows.
Because the unauthorized codes (R05, R07, R10, R11) apply to consumer entries, they fall inside Regulation E (12 CFR 1005). A WSUD-backed return is frequently the same event the consumer raises with their bank as an error under §1005.11, so the moment the pipeline classifies a return as UNAUTHORIZED it must hand off to the Reg E error-resolution clock rather than closing the item. Every reconciliation decision — the matched original trace, the classified code, the chosen action, and the timeliness result — must be written to an append-only log so the institution can prove, months later during a dispute or an examination, exactly why it retried, wrote off, or disputed a given entry.
Testing & Verification
Test the classifier against synthetic returns whose windows you compute by hand, and assert the guardrails as hard as the happy path: an illegal re-presentment must fail closed.
import pytest
from datetime import date
from decimal import Decimal
class _FakeClock:
def banking_days_between(self, a: date, b: date) -> int:
return (b - a).days # simplified; real impl skips weekends/holidays
def calendar_days_between(self, a: date, b: date) -> int:
return (b - a).days
class _FakeLedger:
def __init__(self, amount: Decimal, retries: int) -> None:
self._amount, self._retries = amount, retries
def lookup(self, trace): # noqa: ANN001
return type("O", (), {"amount": self._amount})()
def retry_count(self, trace: str) -> int:
return self._retries
def _ret(code: str, amount: str = "123.45") -> ReturnEntry:
return ReturnEntry(return_code=code, original_trace="091000010000001",
amount=Decimal(amount), settlement_date=date(2026, 7, 1),
dfi_account="ACCT1", sec_code="PPD", is_consumer=True)
def test_r01_first_retry_is_permitted():
out = dispatch_return(_ret("R01"), _FakeLedger(Decimal("123.45"), 0),
_FakeClock(), date(2026, 7, 3))
assert out["action"] == "RETRY" and out["attempt"] == 1
def test_r01_third_presentment_is_blocked():
out = dispatch_return(_ret("R01"), _FakeLedger(Decimal("123.45"), 2),
_FakeClock(), date(2026, 7, 3))
assert out["action"] != "RETRY"
def test_r02_never_retries_and_writes_off():
out = dispatch_return(_ret("R02"), _FakeLedger(Decimal("123.45"), 0),
_FakeClock(), date(2026, 7, 3))
assert out["action"] == "WRITE_OFF"
def test_r10_opens_a_dispute_requiring_wsud():
out = dispatch_return(_ret("R10"), _FakeLedger(Decimal("123.45"), 0),
_FakeClock(), date(2026, 7, 20))
assert out["action"] == "DISPUTE" and out["requires_wsud"] is True
def test_amount_mismatch_routes_to_manual():
out = dispatch_return(_ret("R01"), _FakeLedger(Decimal("999.99"), 0),
_FakeClock(), date(2026, 7, 3))
assert out["action"] == "MANUAL"
Frequently Asked Questions
How do I tell an administrative return from an unauthorized one at parse time?
Read the Return Reason Code from positions 4–6 of the Type-99 addenda and look it up in the classification table — the code alone determines the family. Administrative codes (R01–R04, R09) are mechanical failures on a two-banking-day clock and carry no dispute; unauthorized codes (R05, R07, R10, R11) are consumer repudiations on a 60-calendar-day clock and require a Written Statement of Unauthorized Debit. Never infer the family from the SEC code or the amount; the reason code is authoritative, and the two families route to completely different actions.
How many times can I re-present a returned ACH entry?
Only entries returned R01 (insufficient funds) or R09 (uncollected funds) may be reinitiated, and only up to two times — three total presentments including the original — within 180 days of the original Settlement Date. Each retry 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; the fee, if you charge one, is a separate entry under its own authorization. Entries returned for a closed account (R02), no account (R03), or any unauthorized code must never be reinitiated.
Why must the 60-day and two-day windows use different calendars?
The two-banking-day administrative window skips weekends and Federal Reserve holidays, while the 60-day unauthorized window counts every calendar day. Measuring an administrative return with a naive two-calendar-day delta will flag a timely Monday return that follows a Friday settlement as late, and measuring the unauthorized window in banking days will understate the receiver's rights and can forfeit the RDFI's warranty. Anchor both clocks on the original entry's Settlement Date — not the date you received the return — and run each against its correct calendar.
What happens when a return does not match any original entry I sent?
Treat it as a hard exception, never an auto-write-off. An unmatched return usually means the trace was parsed from the wrong addenda offset, or the original outbound entry was never persisted to the settlement ledger at origination. Route it to MANUAL review with the parsed trace and amount so an analyst can locate the original or confirm the return is misrouted (which may itself justify dishonoring it under R61). Auto-posting a reversal with no matched original is how phantom debits and unbalanced settlement totals enter the books.
How do dishonored and contested returns differ from a normal return?
A first-party return flows RDFI → ODFI. If the ODFI believes that return was defective — untimely, duplicated, or misrouted — it dishonors the return with an R6x code, sending it back RDFI-ward. The RDFI can then contest the dishonored return with an R7x code. These are second- and third-leg messages about an existing return, not new debits, so your pipeline must net them against the original return rather than posting a fresh reversal. They also carry the tightest deadlines in the framework, typically two to five banking days, so they need their own priority lane on the exception desk.
Where does a Notification of Change fit relative to returns?
A Notification of Change (NOC, carried in a COR entry with a C-series change code) is not a return at all — the entry posted successfully, but the RDFI is telling you to correct stored data such as the account or routing number before the next entry. It shares the addenda-parsing machinery with returns but dispatches to a correction handler instead of retry, write-off, or dispute. The mechanics of parsing the change code and applying the corrected data are covered in automating Notification of Change processing.
Related guides in this collection
- Handling R01, R02, and R03 ACH Return Codes — the administrative trio in depth: NSF retries versus closed/no-account write-offs, with WEB/TEL reinitiation rules.
- Automating NACHA Notification of Change (NOC/COR) Processing — parsing C-series change codes and applying corrected data within the six-banking-day window.
- Reg E, NACHA & Audit Controls — the parent reference that frames the regulatory envelope around the whole reconciliation pipeline.
- Immutable Audit Trails for Reconciliation Decisions — the append-only log every return decision must be written to for examination.
- Regulation E Error Resolution for ACH Disputes — where an unauthorized return hands off to the consumer error-resolution clock.
- NACHA Record Layouts Explained — the byte-level record grammar the return entry and its addenda are parsed from.