Decoding ACE X12 824 application advice errors
When CBP ACE rejects a transmitted filing, it answers with an X12 824 Application Advice — a terse EDI transaction whose TED (Technical Error Description) segments carry the reject reason codes that tell you exactly why an entry summary, ISF, or manifest was refused. Read wrong, an 824 sends a broker chasing the wrong line; read right, it pinpoints the failing element in seconds. This page gives you a deterministic Python parser that splits the raw interchange into segments, extracts every TED reason code, maps each to a human-readable cause, and classifies the fault as a hard error (the whole filing is dead and must be corrected) or a soft error (informational or advisory, filing accepted with a caveat). That hard-versus-soft split is the decision that drives whether you hold the entry for a broker or push it straight back through idempotent resubmission.
Prerequisites
The parser is a pure function over bytes you already have. Confirm each of these before wiring it in:
- Python 3.10+ with
dataclass,Enum, andOptionalfromtyping. No third-party EDI library is required; the 824 grammar we need is small enough to parse directly, and hand-rolling it keeps the reject-code mapping auditable. - The raw 824 payload as bytes or text, exactly as ACE returned it over your ABI connection — do not let a middleware layer re-wrap or pretty-print it. The interchange delimiters (element, sub-element, and segment terminators) are declared in the
ISAenvelope and can differ per trading-partner profile, so never hardcode*and~. - A reject-code reference table. CBP publishes ABI/ACE error dispositions; you maintain a lookup from reason code to a plain-language cause and a hard/soft disposition. The dictionary in the implementation is a starter, not the authoritative set — treat it as data, version it, and reconcile it against the current CATAIR error appendix.
- Structured logging (stdlib
loggingwith the%(asctime)s | %(levelname)s | %(name)s | %(message)sformat) so every decoded reject is greppable against the originating control number for audit under 19 CFR 163 recordkeeping. - The original filing’s control numbers (ISA/GS/ST and your internal entry id) retained, so a decoded 824 can be joined back to the transmission that produced it.
Anatomy of an 824
An 824 is a nested envelope. The ISA/IEA pair is the interchange; GS/GE is the functional group; ST/SE bounds one transaction set (ST*824). Inside, the OTI (Original Transaction Identification) segment names what is being acknowledged — it echoes the application reference and the control number of your original filing. Each problem is then reported by a TED segment: TED01 is the application error condition code (the reject reason), TED02 is free-text describing it, and later elements can pin the failing segment and element position. A NTE segment may carry additional notes. One 824 can acknowledge several original transactions and stack many TED segments, so the parser must collect them all rather than stopping at the first.
Implementation
The parser splits the interchange using the delimiters read from the ISA envelope, walks the segments, groups TED errors under their owning OTI, and classifies each reason code.
import logging
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
handlers=[logging.StreamHandler()],
)
logger = logging.getLogger("ace_824_parser")
class Severity(Enum):
HARD = "HARD" # filing rejected; must correct and resubmit
SOFT = "SOFT" # accepted with advisory; no resubmission required
UNKNOWN = "UNKNOWN"
# Reason code -> (human cause, disposition). Version this as data, not code;
# reconcile against the current CBP CATAIR error appendix.
REASON_CODES: dict[str, tuple[str, Severity]] = {
"024": ("Invalid or missing HTS number", Severity.HARD),
"026": ("Entered value fails ACE decimal-precision edit", Severity.HARD),
"601": ("Bond insufficient for entry type", Severity.HARD),
"701": ("Manufacturer/shipper ID not on file", Severity.HARD),
"805": ("Estimated duty mismatch vs. recomputed duty", Severity.HARD),
"900": ("Informational: entry accepted, census warning", Severity.SOFT),
"901": ("Advisory: quantity outside expected range", Severity.SOFT),
}
@dataclass(frozen=True)
class Delimiters:
element: str
sub_element: str
segment: str
@classmethod
def from_isa(cls, raw: str) -> "Delimiters":
# ISA is fixed-width: element separator is char 4 (index 3); the
# sub-element separator is ISA16 at index 104; the segment
# terminator is the char immediately after ISA16.
if len(raw) < 106 or not raw.startswith("ISA"):
raise ValueError("Payload does not begin with a valid ISA envelope")
return cls(element=raw[3], sub_element=raw[104], segment=raw[105])
@dataclass
class TedError:
reason_code: str
description_text: str
human_cause: str
severity: Severity
failing_segment: Optional[str] = None
@dataclass
class AcknowledgedTransaction:
original_control_number: str
application_reference: str
errors: list[TedError] = field(default_factory=list)
@property
def is_rejected(self) -> bool:
return any(e.severity is Severity.HARD for e in self.errors)
class Application824Parser:
"""Deterministic X12 824 decoder. Pure function of the raw interchange."""
def _segments(self, raw: str) -> list[list[str]]:
delims = Delimiters.from_isa(raw)
out: list[list[str]] = []
for chunk in raw.split(delims.segment):
chunk = chunk.strip()
if chunk:
out.append(chunk.split(delims.element))
return out
def _classify(self, reason_code: str) -> tuple[str, Severity]:
cause, severity = REASON_CODES.get(
reason_code, ("Unmapped reason code — escalate", Severity.UNKNOWN)
)
if severity is Severity.UNKNOWN:
logger.warning("Unmapped 824 reason code %s", reason_code)
return cause, severity
def parse(self, raw: str) -> list[AcknowledgedTransaction]:
acks: list[AcknowledgedTransaction] = []
current: Optional[AcknowledgedTransaction] = None
for seg in self._segments(raw):
tag = seg[0]
if tag == "OTI":
# OTI01=ack code, OTI02=ref qualifier, OTI03=original ctrl no.
current = AcknowledgedTransaction(
original_control_number=seg[3] if len(seg) > 3 else "",
application_reference=seg[2] if len(seg) > 2 else "",
)
acks.append(current)
elif tag == "TED" and current is not None:
reason = seg[1] if len(seg) > 1 else ""
text = seg[2] if len(seg) > 2 else ""
cause, severity = self._classify(reason)
failing = seg[4] if len(seg) > 4 else None
current.errors.append(
TedError(reason, text, cause, severity, failing)
)
logger.info(
"824 TED %s (%s) on ctrl %s: %s",
reason, severity.value,
current.original_control_number, cause,
)
return acks
def is_filing_dead(acks: list[AcknowledgedTransaction]) -> bool:
"""A single HARD error anywhere kills the whole filing."""
return any(ack.is_rejected for ack in acks)
The classification contract is the load-bearing part: an unmapped reason code returns Severity.UNKNOWN, never a silent SOFT. Treating an unknown code as harmless is how a real rejection slips through as accepted. is_filing_dead collapses the whole 824 to the one boolean the resubmission workflow needs.
ISA; each TED reason code is classified HARD, SOFT, or UNKNOWN, and one HARD anywhere marks the filing dead.Verification steps
Run these before trusting the parser against production ACE traffic:
- Delimiter independence. Parse the same logical 824 twice — once with
*/~delimiters and once re-encoded with different ISA delimiters (for example|element,\nsegment). Both must yield identicalAcknowledgedTransactionlists. A divergence means you hardcoded a delimiter instead of reading it fromDelimiters.from_isa. - Multi-OTI grouping. Feed an 824 that acknowledges two original transactions, each with its own
TEDstack. Assert eachTEDlands under the correctOTIcontrol number and none bleed across the boundary. - Hard/soft classification parity. Assert reason
024classifiesHARDand900classifiesSOFT, and that a filing carrying both is reported dead byis_filing_dead. One hard error must dominate any number of soft advisories. - Unmapped code escalation. Inject a reason code absent from
REASON_CODES(say"777"). It must returnSeverity.UNKNOWNand log a warning — never silently pass as accepted. - Control-number round-trip. Join every parsed
original_control_numberback to the ISA/GS/ST of the filing you transmitted. An 824 you cannot join to an outbound record is an orphan and must be quarantined, not discarded. - Empty-element safety. Truncate a
TEDsegment so it has onlyTED*024with no description. The parser must not raiseIndexError; missing trailing elements should default to empty, matching thelen(seg) > nguards.
Edge cases & gotchas
- Delimiters live in the envelope, not your config. The single most common 824 parsing bug is assuming
*and~. Trading-partner profiles vary, and CBP can send a different segment terminator. Always derive them from ISA positions 4, 105, and 106, and fail loudly if the payload does not start withISA. - A hard error can hide behind a soft one. An 824 may list an informational census warning before a fatal bond error. Never short-circuit on the first
TED; collect the whole stack and let severity, not position, decide disposition. OTIechoes your reference, not CBP’s. The control number inOTI03is the one you sent. If your transmitter mutated control numbers between generation and send, the 824 will not join back — freeze the control number at transmission time and store it before the wire, the same discipline that makes resubmission idempotent.- 824 is not 997. A functional acknowledgment (997/999) reports whether the EDI was syntactically well-formed; the 824 reports whether the application accepted the content. A clean 997 followed by an 824 full of hard TEDs is a rejected filing, not a delivered one. Do not mark an entry filed on the 997 alone.
- Reason-code drift. CBP revises error dispositions. A code that was advisory can become fatal across a CATAIR release. Because
REASON_CODESis data, diff it against each appendix update and re-run classification tests; a stale table silently misroutes rejects. - Encoding of TED free text.
TED02can carry punctuation that collides with your segment or element delimiter if the sender failed to release it. If a description looks truncated at a*or~, the sender did not escape a delimiter — log the raw segment and reconcile with the entry summary filing schemas that produced the original transaction rather than trusting the split text.
Related
- Resubmitting rejected ABI entries idempotently
- Entry Summary Filing Schemas
- Validating entered value with ACE decimal precision
- ACE and ABI Rejection Handling
Up: ACE and ABI Rejection Handling
Authoritative references: CBP ACE ABI CATAIR (Customs and Trade Automated Interface Requirements), Application Advice error appendix; ASC X12 824 Application Advice transaction set; 19 CFR 163 (recordkeeping).