NCPDP D.0 Message Parsing Strategies

The NCPDP D.0 Telecommunication Standard remains the foundational payload format for real-time pharmacy claims, yet its segment-delimited, positional architecture demands rigorous parsing discipline before any claim reaches the pricing engine. This page sits within the broader Claims Ingestion & NCPDP Parsing domain and owns one specific sub-problem: turning a raw D.0 byte stream — whether delivered on a pharmacy switch, inside a SOAP envelope, or as a batch container — into a validated, PHI-safe, adjudication-ready payload without losing a single claim segment or silently corrupting a 407-D7 product identifier. Every downstream capability (formulary tiering, prior authorization, MAC pricing, patient liability) inherits the correctness guarantees established here, so the parsing tier is treated as a deterministic, stateful extraction layer rather than a text-splitting utility.

Prerequisites

Before this workflow runs, the ingestion service assumes the following inputs and dependencies are in place:

  • Transport-decoded payload. The raw D.0 message must already be extracted from its transport frame (HTTPS body, MLLP frame, or SFTP batch file) and, if base64 or gzip encoded, decoded to the on-the-wire byte string. D.0 fields are delimited by ASCII control characters — Field Separator 0x1C, Group Separator 0x1D, and Segment Separator 0x1E — which survive transport but not naive text normalization, so upstream handlers must not strip or re-encode control bytes.
  • Character set. D.0 is transmitted in a single-byte encoding (ASCII/ISO-8859-1). Decode explicitly with latin-1 rather than utf-8 so that a stray high byte in a free-text field does not raise a decode error and drop the whole claim.
  • Reference data. An active NDC reference file and a resolved NDC-to-GPI map are required for identifier enrichment. This workflow depends on the NDC to GPI Crosswalk Automation pipeline to supply a versioned lookup keyed on the 11-digit NDC submitted in 407-D7 (qualified by 436-E1 = 03).
  • Library versions. The reference implementation targets Python 3.11+, pydantic>=2.5 for schema validation, lxml>=5.1 for XML-wrapped payloads, and opentelemetry-api>=1.24 for span emission. Pydantic v2 is assumed throughout for its Decimal coercion and model_config behavior.
  • PHI boundary. The service runs inside the claims-data trust zone defined by Security & Compliance Boundaries for Claims Data: raw claim bytes are never written to application logs, and cardholder identifiers (302-C2) and patient-name fields (310-CA) are stripped from the working object immediately after routing metadata is derived.

D.0 Segment and Field Specification

A D.0 request is a fixed-position transaction header followed by a sequence of variable-length segments. Each segment leads with field 111-AM (Segment Identification), a two-digit code, and each field on the wire is tagged with the two-character suffix of its NCPDP data-element reference (e.g. 407-D7 appears as wire tag D7). The parser must key on these codes rather than positional order within the segment, because vendors legally reorder optional fields.

D.0 request wire layout: fixed-position header and tag-keyed segments A raw D.0 byte stream is segment-separated by control byte 0x1E into a fixed-position transaction header followed by segments AM04 Insurance, AM07 Claim and AM11 Pricing. The header is decoded by byte offset — 101-A1 BIN at offset 0, 401-D1 Date of Service (CCYYMMDD) at offset 10, 104-A4 PCN at offset 18. The AM07 Claim segment is decoded by two-character field tag delimited by 0x1C — 111-AM, 402-D2, 407-D7, 436-E1, 442-E7 — because vendors legally reorder optional fields, so the parser keys on the tag rather than position. Both parse paths converge on the canonical, validated, PHI-safe AdjudicationPayload. Raw D.0 request — segment-separated by control byte 0x1E 1E 1E 1E Transaction header fixed position AM04 Insurance 302-C2 AM07 Claim D2 · D7 · E1 · E7 AM11 Pricing 409-D9 Header — parsed by byte offset 101-A1 401-D1 104-A4 0 10 18 28 BIN / IIN Date CCYYMMDD PCN numbers below the ruler are byte offsets AM07 Claim — parsed by 2-char field tag 111-AM = 07 402-D2 Rx Ref# 407-D7 NDC 436-E1 Qual 03 442-E7 Qty 1C 1C 1C 1C vendors reorder optional fields → key on the tag, not position by position by tag AdjudicationPayload canonical · validated · PHI-safe

Figure: A D.0 request is framed by control bytes — the fixed-position transaction header is decoded by byte offset (101-A1 at 0, 401-D1 at 10, 104-A4 at 18), while each 0x1E-separated segment is decoded by its 2-character field tag (0x1C-delimited), both converging on the canonical AdjudicationPayload.

The segments and fields this workflow extracts, and the reject codes each drives, are specified below.

Segment (111-AM) Key fields Meaning Missing/invalid → reject
Header (fixed position) 101-A1 BIN/IIN, 104-A4 PCN, 109-A9 Transaction Count Routing + control totals 01 M/I BIN, 04 M/I Processor Control Number
AM04 Insurance 302-C2 Cardholder ID Member identity 07 M/I Cardholder ID
AM07 Claim 402-D2 Rx Ref Number, 407-D7 Product/Service ID, 436-E1 Product ID Qualifier, 442-E7 Quantity Dispensed, 401-D1 Date of Service Drug + dispensing facts 19 M/I Days Supply, 21 M/I Product/Service ID, 26 M/I PCN, 70 Product/Service Not Covered
AM03 Prescriber 411-DB Prescriber ID Prescriber identity 25 M/I Prescriber ID
AM11 Pricing 409-D9 Ingredient Cost Submitted, 426-DQ Usual & Customary, 430-DU Gross Amount Due Submitted financials 75 Prior Authorization Required (routed after enrichment), 76 Plan Limitations Exceeded

Reject codes are returned to the switch in field 511-FB (Reject Code). This workflow resolves structural and identifier rejects (01, 04, 07, 21, 26, 70) at the parse boundary; benefit-driven rejects (75, 76, 608) are deferred to the formulary and prior-authorization engines because they require enriched, tier-aware context. The categorization contract itself is owned by Schema Validation & Error Categorization, which this parser emits into rather than duplicating.

The transaction header is fixed-position and precedes the first Segment Separator: 101-A1 BIN (6 chars) at offset 0, 401-D1 Date of Service (8 chars, CCYYMMDD) at offset 10, and 104-A4 PCN (10 chars) at offset 18. Everything after the first 0x1E is the variable segment body.

Reference Python Implementation

The implementation below combines boundary detection, Pydantic v2 validation, structured telemetry, and PHI-safe handling. Money fields use decimal.Decimal — never float — so that ingredient cost and gross-amount-due values survive downstream copay arithmetic without binary-rounding drift. Raw claim bytes are never logged; only a salted hash and non-PHI structural facts are emitted.

python
import hashlib
import hmac
import logging
from decimal import Decimal, InvalidOperation
from typing import Optional

from opentelemetry import trace
from pydantic import BaseModel, Field, field_validator

logger = logging.getLogger(__name__)
tracer = trace.get_tracer(__name__)

# NCPDP D.0 Telecommunication Standard control characters.
FIELD_SEP = "\x1c"    # separates fields within a segment
GROUP_SEP = "\x1d"    # separates repeating groups
SEGMENT_SEP = "\x1e"  # separates segments

# 111-AM Segment Identification codes we extract.
SEG_INSURANCE = "04"  # AM04 Insurance
SEG_CLAIM = "07"      # AM07 Claim
SEG_PRICING = "11"    # AM11 Pricing


class ParseReject(Exception):
    """Structural reject carrying the NCPDP 511-FB code for the switch."""
    def __init__(self, reject_code: str, message: str):
        self.reject_code = reject_code  # returned in field 511-FB Reject Code
        super().__init__(f"[{reject_code}] {message}")


class AdjudicationPayload(BaseModel):
    """Canonical, adjudication-ready view of one D.0 claim."""
    model_config = {"frozen": True}

    bin_iin: str                 # 101-A1 BIN / IIN (transaction header)
    pcn: str                     # 104-A4 Processor Control Number
    rx_number: str               # 402-D2 Prescription/Service Reference Number
    ndc: str                     # 407-D7 Product/Service ID (qualifier 436-E1 = 03)
    gpi: Optional[str] = None    # enriched from the NDC-to-GPI crosswalk
    patient_ref: str             # SALTED token of 302-C2 — never the raw Cardholder ID
    service_date: str            # 401-D1 Date of Service (CCYYMMDD)
    quantity: Decimal            # 442-E7 Quantity Dispensed
    ingredient_cost: Decimal     # 409-D9 Ingredient Cost Submitted (money -> Decimal)

    @field_validator("service_date")
    @classmethod
    def _iso_date(cls, v: str) -> str:
        # 401-D1 is CCYYMMDD; a malformed date is reject 07/19 territory.
        if len(v) != 8 or not v.isdigit():
            raise ValueError("401-D1 Date of Service must be CCYYMMDD")
        return v


class NCPDPD0Parser:
    def __init__(self, gpi_lookup: dict[str, str], phi_salt: bytes):
        self.gpi_lookup = gpi_lookup      # versioned NDC(407-D7) -> GPI-14 map
        self._phi_salt = phi_salt         # HMAC key from the secrets manager

    def _tokenize_phi(self, cardholder_id: str) -> str:
        # 302-C2 is direct-identifier PHI. Replace it with a stable HMAC token
        # so joins still work downstream without carrying the raw value.
        return hmac.new(self._phi_salt, cardholder_id.encode("latin-1"),
                        hashlib.sha256).hexdigest()[:32]

    def _split_fields(self, segment: str) -> dict[str, str]:
        """Map each 0x1C-delimited field to its 2-char Field Identifier."""
        fields: dict[str, str] = {}
        for field in segment.split(FIELD_SEP):
            if len(field) < 2:
                continue
            field_id, value = field[:2], field[2:]
            fields[field_id] = value.strip()
        return fields

    @staticmethod
    def _money(raw: str, field_ref: str) -> Decimal:
        try:
            return Decimal(raw or "0")
        except InvalidOperation as exc:
            raise ParseReject("07", f"M/I {field_ref}") from exc

    def parse(self, raw_payload: str) -> AdjudicationPayload:
        # PHI-safe telemetry: hash the payload, never log its bytes.
        payload_hash = hashlib.sha256(raw_payload.encode("latin-1")).hexdigest()
        with tracer.start_as_current_span("ncpdp.d0.parse") as span:
            span.set_attribute("ncpdp.payload_sha256", payload_hash)

            header, sep, body = raw_payload.partition(SEGMENT_SEP)
            if not sep or len(header) < 28:
                raise ParseReject("04", "truncated D.0 transaction header")

            bin_iin = header[0:6].strip()      # 101-A1 BIN / IIN
            service_date = header[10:18].strip()  # 401-D1 Date of Service
            pcn = header[18:28].strip()        # 104-A4 Processor Control Number
            if not bin_iin:
                raise ParseReject("01", "M/I BIN")

            collected: dict[str, str] = {}
            for segment in body.split(SEGMENT_SEP):
                if not segment:
                    continue
                fields = self._split_fields(segment)
                seg_id = fields.get("AM")   # 111-AM Segment Identification
                if seg_id == SEG_CLAIM:
                    collected["D2"] = fields.get("D2", "")  # 402-D2 Rx Ref Number
                    collected["D7"] = fields.get("D7", "")  # 407-D7 Product/Service ID
                    collected["E1"] = fields.get("E1", "")  # 436-E1 Product ID Qualifier
                    collected["E7"] = fields.get("E7", "")  # 442-E7 Quantity Dispensed
                elif seg_id == SEG_INSURANCE:
                    collected["C2"] = fields.get("C2", "")  # 302-C2 Cardholder ID (PHI)
                elif seg_id == SEG_PRICING:
                    collected["D9"] = fields.get("D9", "")  # 409-D9 Ingredient Cost

            ndc = collected.get("D7", "")
            if not ndc:
                raise ParseReject("21", "M/I Product/Service ID (missing AM07)")
            if collected.get("E1") not in ("", "03"):
                # qualifier 436-E1 must be 03 (NDC) for this pipeline
                raise ParseReject("21", "unsupported 436-E1 Product ID Qualifier")

            span.set_attribute("ncpdp.gpi_hit", ndc in self.gpi_lookup)

            payload = AdjudicationPayload(
                bin_iin=bin_iin,
                pcn=pcn,
                rx_number=collected.get("D2", ""),
                ndc=ndc,
                gpi=self.gpi_lookup.get(ndc),       # None -> reject 70 downstream
                patient_ref=self._tokenize_phi(collected.get("C2", "")),
                service_date=service_date,
                quantity=self._money(collected.get("E7", ""), "442-E7"),
                ingredient_cost=self._money(collected.get("D9", ""), "409-D9"),
            )
            # 302-C2 already tokenized; nothing raw is retained on the object.
            return payload

For XML-wrapped D.0 (SOAP envelopes, some gateway APIs), the boundary-detection step is replaced by namespace-aware lxml traversal with entity resolution and external DTD loading disabled to block XXE — the tradeoffs of the two approaches are worked through in detail on Parsing NCPDP D.0 segments with Python regex vs lxml. Both paths converge on the same AdjudicationPayload model, so validation and telemetry are written once.

Execution Flow

D.0 parsing execution flow Top-down flow: raw D.0 payload, split header on 0x1E, extract header fields 101-A1, 401-D1, 104-A4 by position, split body into segments on 0x1E, split each segment into fields on 0x1C, read 111-AM for the Segment ID. A decision on Segment ID branches to 04 (collect 302-C2 Cardholder ID, tokenized), 07 (collect 402-D2, 407-D7, 442-E7), or 11 (collect 409-D9 Ingredient Cost). All three branches resolve the NDC 407-D7 to a GPI via a versioned lookup, then build the AdjudicationPayload. Raw D.0 payload Split header on 0x1E (Segment Separator) Extract header fields by position: 101-A1 · 401-D1 · 104-A4 Split body into segments on 0x1E Split each segment into fields on 0x1C Read 111-AM (wire tag AM) → Segment ID Segment ID? AM04 → collect 302-C2 Cardholder ID (tokenized) AM07 → collect 402-D2 · 407-D7 · 442-E7 AM11 → collect 409-D9 Ingredient Cost Resolve NDC (407-D7) → GPI via versioned lookup Build AdjudicationPayload 04 07 11

Figure: D.0 raw message split by control characters, segment identified via 111-AM, fields extracted by 2-char IDs into the canonical AdjudicationPayload

Engineering Constraints and Known Failure Modes

Real-world D.0 traffic violates the implementation guide constantly; a parser that assumes perfect input drops revenue and, worse, misprices claims. The failure modes below are the ones that recur in production:

  • NDC gaps and stale crosswalks. A syntactically valid 407-D7 that resolves to no GPI must not be silently dropped. The parser emits gpi=None and lets the pricing tier raise reject 70 (Product/Service Not Covered), because a missing crosswalk entry is often a reference-data lag, not a bad claim — the recovery path belongs to NDC to GPI Crosswalk Automation, not to the parser.
  • Qualifier drift. Field 436-E1 (Product ID Qualifier) is not always 03 (NDC); some submitters send 07 (HRI) or 28 (First DataBank). Hard-fail with reject 21 rather than crosswalking a code from the wrong namespace and mispricing.
  • Control characters inside free-text. Prescriber notes and DUR free-text (544-FY) can legally contain bytes that resemble delimiters after a mangled transport hop. Decode as latin-1 and split on the exact control characters only; never strip() the whole payload of non-printables.
  • Truncated headers and short segments. A partition on 0x1E that returns no separator means the header never terminated — reject 04 (M/I PCN) is safer than indexing into a short string. The guard len(header) < 28 prevents an IndexError from masquerading as a business reject.
  • Repeating segment groups. AM07 may repeat within a single transaction for compound claims (multiple ingredients). A parser that keeps only the last-seen D7 loses ingredients; production code must accumulate repeating 0x1D-delimited groups into a list, then validate the compound total against 109-A9.
  • Reject categorization, not generic logging. Every failure maps to a 511-FB code and a severity, feeding the FATAL / TRANSIENT / WARN taxonomy defined in Schema Validation & Error Categorization. A crosswalk timeout is TRANSIENT (auto-retry); an unrecognized BIN is FATAL (manual review).

Performance and Correctness Tuning

  • Idempotency keys. Network retries from the switch replay identical payloads. Derive an idempotency key from hash(101-A1 + 104-A4 + 402-D2 + 401-D1) and deduplicate before adjudication so a retried claim is never priced twice. The SHA-256 payload hash already computed for telemetry doubles as the dedup fingerprint.
  • Decimal precision everywhere money flows. 409-D9 Ingredient Cost, 426-DQ Usual & Customary, and 430-DU Gross Amount Due are parsed as decimal.Decimal. Passing a float into copay math produces cent-level drift that fails payer reconciliation — the arithmetic contract is shared with Tier Mapping & Copay Calculation Logic.
  • Crosswalk cache locality. The NDC-to-GPI map is read on every claim; hold it as an in-process dict snapshot (versioned, swapped atomically on reference-data refresh) rather than a per-claim database round trip. Cache the version identifier on each AdjudicationPayload so the pricing tier can prove which snapshot priced the claim during an audit.
  • Control-total verification. Compare the parsed claim count against 109-A9 (Transaction Count) before handoff. A mismatch means a segment was lost in parsing or queueing and must fail the batch rather than adjudicate a partial transaction — this guard is what keeps the Asynchronous Batch Adjudication Workflows queue handoff lossless.
  • SLA budget. Real-time D.0 adjudication targets a sub-second point-of-sale response. Keep parsing off the critical path of any network call: enrichment reads a local snapshot, and only genuinely absent crosswalk entries fall back to the slower reference service. Backpressure and retry ceilings for that fallback are governed by PBM API Sync & Rate Limiting.

In This Section

← Back to Claims Ingestion & NCPDP Parsing