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, and Optional from typing. 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 ISA envelope 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 logging with the %(asctime)s | %(levelname)s | %(name)s | %(message)s format) 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.

How an X12 824 interchange is parsed into classified TED errors The raw 824 interchange is a nested envelope. The outer ISA/IEA interchange declares the element, sub-element, and segment delimiters. Inside it, a GS/GE functional group contains an ST 824 transaction set. Within the transaction set, one OTI segment identifies the original transaction being acknowledged by its control number, and one or more TED segments each report a reject reason code and free text. The parser splits the raw bytes on the delimiters read from the ISA, groups TED segments under their owning OTI, and maps each reason code through a lookup table to a human cause and a severity. Every TED is classified HARD, SOFT, or UNKNOWN; any single HARD error marks the whole filing dead and routes it to correction, while a filing with only SOFT advisories is accepted. ISA / IEA interchange declares element · sub · segment delimiters GS / GE functional group ST 824 transaction set OTI original control number TED 024 Invalid HTS number TED 805 Duty mismatch TED 900 Census advisory classify() reason code lookup table cause + severity HARD filing dead → correct SOFT accepted · advisory UNKNOWN escalate · never silent any single HARD error → is_filing_dead() is true for the whole 824 only-SOFT filing → accepted, join back to original control number
The 824's nested envelope is split on delimiters read from 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:

  1. Delimiter independence. Parse the same logical 824 twice — once with */~ delimiters and once re-encoded with different ISA delimiters (for example | element, \n segment). Both must yield identical AcknowledgedTransaction lists. A divergence means you hardcoded a delimiter instead of reading it from Delimiters.from_isa.
  2. Multi-OTI grouping. Feed an 824 that acknowledges two original transactions, each with its own TED stack. Assert each TED lands under the correct OTI control number and none bleed across the boundary.
  3. Hard/soft classification parity. Assert reason 024 classifies HARD and 900 classifies SOFT, and that a filing carrying both is reported dead by is_filing_dead. One hard error must dominate any number of soft advisories.
  4. Unmapped code escalation. Inject a reason code absent from REASON_CODES (say "777"). It must return Severity.UNKNOWN and log a warning — never silently pass as accepted.
  5. Control-number round-trip. Join every parsed original_control_number back 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.
  6. Empty-element safety. Truncate a TED segment so it has only TED*024 with no description. The parser must not raise IndexError; missing trailing elements should default to empty, matching the len(seg) > n guards.

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 with ISA.
  • 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.
  • OTI echoes your reference, not CBP’s. The control number in OTI03 is 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_CODES is data, diff it against each appendix update and re-run classification tests; a stale table silently misroutes rejects.
  • Encoding of TED free text. TED02 can 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.

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).