Automating NACHA Notification of Change (NOC/COR) Processing
A Notification of Change is the return framework's quiet cousin: the entry posted fine, the money moved, but the RDFI is telling you that a piece of the routing data was stale and must be corrected before your next entry. Ignore it and nothing breaks today — but your next origination against the same receiver carries the same wrong account or routing number, earns an R03, and now you have a failed collection that a two-minute automated correction would have prevented. Worse, the rules are not optional: the Originator must act on a valid NOC within a defined window, and failing to do so can make you liable for the RDFI's re-notification costs. This page automates that correction inside the broader NACHA return-code handling reference, part of the Reg E, NACHA and audit controls framework, turning an inbound COR entry into a validated update against your stored originator instructions.
A NOC arrives as a COR entry — SEC code COR — carrying a Type-7 addenda whose Addenda Type Code is 98 (returns use 99; the one-digit difference is the whole distinction). It shares the byte-level parsing path of every other addenda established in NACHA record layouts, but where a return's addenda holds a reason code and drives retry-or-write-off, a NOC's addenda holds a change code and drives an update to the record you will originate from next time.
Concept Spec: Change Codes and the Corrected-Data Field
Inside the Type-98 addenda, two fields matter. The Change Code occupies positions 4–6 (a C-series code), and the Corrected Data field occupies positions 25–53 — a 29-character field whose contents depend on the change code. The code tells you which stored field to update and how to interpret the corrected-data bytes:
C01— Incorrect DFI Account Number. Corrected data is the new account number, left-justified.C02— Incorrect Routing Number. Corrected data is the new nine-digit routing number.C03— Incorrect Routing Number and Account Number. Corrected data packs the new routing number, a space, then the new account number.C05— Incorrect Transaction Code. Corrected data is the new two-digit transaction code.C06— Incorrect Account Number and Transaction Code. Corrected data packs the account number and the transaction code.C07— Incorrect Routing Number, Account Number, and Transaction Code. All three, positionally packed.C09— Incorrect Individual Identification Number. Corrected data is the new individual ID.
The Originator's obligation is precise: make the change within six banking days of receiving the NOC, or before initiating the next entry to that receiver, whichever is later. The RDFI warrants that the corrected information it supplies is accurate, which is exactly what makes safe auto-application possible — you are trusting a warranted correction from the account-holding institution, not guessing.
Processing one NOC is constant work, : parse the addenda, branch on the change code, write the corrected field. A file of NOCs is a single streaming pass.
Full Annotated Implementation
The function parses a COR addenda, decodes the corrected-data field according to its change code, and returns a structured update to apply against the stored originator instruction. It refuses to guess: an unknown change code or a malformed corrected-data field routes to review rather than writing partial data.
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, timedelta
from enum import Enum
class ApplyMode(str, Enum):
AUTO = "auto" # warranted, unambiguous — apply and audit
REVIEW = "review" # ambiguous or high-trust field — queue for a human
@dataclass(frozen=True, slots=True)
class ChangeSpec:
fields: tuple[str, ...] # stored instruction fields this code corrects
mode: ApplyMode
CHANGE_CODES: dict[str, ChangeSpec] = {
"C01": ChangeSpec(("account_number",), ApplyMode.AUTO),
"C02": ChangeSpec(("routing_number",), ApplyMode.AUTO),
"C03": ChangeSpec(("routing_number", "account_number"), ApplyMode.AUTO),
"C05": ChangeSpec(("transaction_code",), ApplyMode.AUTO),
"C06": ChangeSpec(("account_number", "transaction_code"), ApplyMode.AUTO),
"C07": ChangeSpec(("routing_number", "account_number", "transaction_code"),
ApplyMode.AUTO),
"C09": ChangeSpec(("individual_id",), ApplyMode.REVIEW),
}
@dataclass(frozen=True, slots=True)
class CorrectionResult:
change_code: str
updates: dict[str, str]
mode: ApplyMode
apply_by: date # six banking days from receipt
error: str | None = None
def parse_noc(addenda_rec: str, received: date, banking_calendar) -> CorrectionResult:
"""Parse a COR (Type-98) addenda into a validated set of field updates.
The Corrected Data field (positions 25-53) is decoded per the change code
in positions 4-6. A malformed field or unknown code yields an error result
rather than a partial write.
"""
change_code = addenda_rec[3:6].strip().upper()
corrected = addenda_rec[24:53].strip()
apply_by = banking_calendar.add_banking_days(received, 6)
spec = CHANGE_CODES.get(change_code)
if spec is None:
return CorrectionResult(change_code, {}, ApplyMode.REVIEW, apply_by,
error=f"unknown_change_code:{change_code}")
try:
updates = _decode_corrected_data(change_code, corrected)
except ValueError as exc:
return CorrectionResult(change_code, {}, ApplyMode.REVIEW, apply_by,
error=f"malformed_corrected_data:{exc}")
return CorrectionResult(change_code, updates, spec.mode, apply_by)
def _decode_corrected_data(code: str, data: str) -> dict[str, str]:
"""Split the 29-char corrected-data field by change-code layout."""
if code == "C01":
return {"account_number": _require(data, "account")}
if code == "C02":
return {"routing_number": _require_routing(data)}
if code == "C03":
routing, _, account = data.partition(" ")
return {"routing_number": _require_routing(routing),
"account_number": _require(account.strip(), "account")}
if code == "C05":
return {"transaction_code": _require_txcode(data)}
if code == "C06":
account, _, tx = data.partition(" ")
return {"account_number": _require(account.strip(), "account"),
"transaction_code": _require_txcode(tx.strip())}
if code == "C07":
routing, rest = data[:9], data[9:].strip()
account, _, tx = rest.partition(" ")
return {"routing_number": _require_routing(routing),
"account_number": _require(account.strip(), "account"),
"transaction_code": _require_txcode(tx.strip())}
if code == "C09":
return {"individual_id": _require(data, "individual_id")}
raise ValueError(f"no decoder for {code}")
def _require(value: str, label: str) -> str:
if not value:
raise ValueError(f"empty {label}")
return value
def _require_routing(value: str) -> str:
v = value.strip()
if len(v) != 9 or not v.isdigit():
raise ValueError(f"routing not 9 digits: {v!r}")
return v
def _require_txcode(value: str) -> str:
v = value.strip()
if len(v) != 2 or not v.isdigit():
raise ValueError(f"transaction code not 2 digits: {v!r}")
return v
Calibration & Configuration
The one real judgement call is auto-apply versus review, and it is a trust-boundary decision, not a convenience one:
- Auto-apply the routing and account corrections (
C01,C02,C03,C05,C06,C07). These are RDFI-warranted structural fields — the account-holding bank is telling you the correct account or routing number, and it stands behind that data. Auto-applying them, with an audit record, is both safe and required to meet the six-banking-day window at volume. - Route identity fields to review (
C09, individual ID). A changed individual identification number can shift which person an entry is attributed to, so treat it as a higher-trust boundary and queue it for a human rather than silently rewriting the receiver's identity on a stored mandate. - Validate before you write. Every corrected routing number must still pass the ABA checksum, and every transaction code must be in your accepted set, before the update touches the stored instruction. A warranted correction can still be transcription-damaged in transit; validate as if it were untrusted input, then trust it once it passes.
- Compute the deadline on banking days. The six-day window skips weekends and Federal Reserve holidays, so derive
apply_byfrom a banking-day calendar, nottimedelta(days=6).
Store the change-code-to-field map and the auto-versus-review policy externally so tightening a trust boundary is a config change, not a deploy.
Validation Example: Before and After
A NOC file arrives on 2026-07-16 with two COR entries for a receiver whose stored instruction is routing 021000021, account 55501234, transaction code 27.
| Change code | Corrected data (pos 25–53) | Stored before | Applied after |
|---|---|---|---|
C01 |
0009988776 |
account 55501234 |
account 0009988776 (auto) |
C02 |
011401533 |
routing 021000021 |
routing 011401533 (auto) |
Before: the next scheduled debit would originate against 021000021 / 55501234. After parsing the two NOCs, parse_noc returns {"account_number": "0009988776"} for the C01 and {"routing_number": "011401533"} for the C02, each in AUTO mode with apply_by = 2026-07-24 (six banking days on). Applying both, the stored instruction becomes routing 011401533, account 0009988776, and the next origination lands cleanly — the R03 that the stale account would otherwise have produced never happens. Every applied change is written to the audit trail with the change code, the before and after values, and the receipt date, so the six-day compliance obligation is provable.
Failure Modes & Guardrails
- Applying the change to the wrong record. A NOC identifies the receiver by the original entry's trace and account; if you match the correction to the wrong stored instruction, you corrupt a good mandate and break a working payment. Join the correction to the exact stored instruction the original entry originated from — by trace and account key — and never fuzzy-match a NOC onto the nearest-looking record.
- Ignoring the six-banking-day rule. Parsing a NOC and shelving it defeats the point: the next entry re-fails and you may owe the RDFI re-notification costs. Emit an
apply_bydeadline on receipt, track it as an SLA, and alert before it lapses so the correction is applied before the next origination cycle, whichever comes first. - Malformed corrected-data field. The 29-character field is positional and code-dependent; a packed
C03orC07with a missing delimiter, or a routing number that fails the ABA checksum, must never be written half-applied.parse_nocreturns an error result that routes to review instead of writing partial data — a corrupted correction is worse than no correction, because it silently poisons a stored mandate.
Frequently Asked Questions
What is the difference between a NOC and a return?
A return means the entry failed — the money did not move, and you must retry, write off, or dispute it. A Notification of Change means the entry succeeded, but the RDFI wants you to correct stored data (an account number, routing number, or transaction code) before the next entry to that receiver. Structurally, a return carries a Type-99 addenda with an R-series reason code, while a NOC carries a Type-98 addenda with a C-series change code in a COR entry. They share parsing machinery but drive opposite actions: retry-or-write-off versus update-and-continue.
How long do I have to act on a NOC?
The Originator must make the correction within six banking days of receiving the NOC, or before initiating the next entry to that receiver, whichever is later. Compute the deadline on banking days — skipping weekends and Federal Reserve holidays — not calendar days, and track it as an SLA so the change is applied before your next origination cycle. Failing to correct within the window can expose you to the RDFI's cost of re-sending the notification.
Can I apply every change code automatically?
Auto-apply the structural routing and account corrections (C01, C02, C03, C05, C06, C07) — the RDFI warrants that data and stands behind it, so applying it with an audit record is safe and necessary at volume. Route identity-bearing changes such as C09 (individual identification number) to human review, since they can alter which person an entry is attributed to. In every case, revalidate the corrected value — ABA checksum on routing numbers, accepted-set membership on transaction codes — before it touches a stored mandate.
How do I decode the corrected-data field for a multi-field change code?
The corrected-data field (positions 25–53) is positional and its layout depends on the change code. Single-field codes like C01 place the new value left-justified; multi-field codes like C03 (routing plus account) and C07 (routing, account, and transaction code) pack the values in a defined order with delimiting. Decode strictly per the code's layout and validate each extracted piece; if a delimiter is missing or a piece fails validation, treat the whole NOC as malformed and route it to review rather than writing a partial update.
Related guides in this collection
- Handling NACHA Return Codes in Reconciliation Pipelines — the parent reference covering the full R-code taxonomy and how returns reconcile to the original entry.
- Handling R01, R02, and R03 ACH Return Codes — the administrative returns a stale account causes when a NOC goes unapplied.
- NACHA Record Layouts Explained — the byte-level addenda grammar the COR change code and corrected-data field are parsed from.
- Validating NACHA Addenda Records with Pydantic — schema-level validation for the Type-98 addenda before a correction is applied.