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 Separator0x1D, and Segment Separator0x1E— 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-1rather thanutf-8so 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 by436-E1 = 03). - Library versions. The reference implementation targets Python 3.11+,
pydantic>=2.5for schema validation,lxml>=5.1for XML-wrapped payloads, andopentelemetry-api>=1.24for span emission. Pydantic v2 is assumed throughout for itsDecimalcoercion andmodel_configbehavior. - 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.
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.
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 payloadFor 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
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-D7that resolves to no GPI must not be silently dropped. The parser emitsgpi=Noneand lets the pricing tier raise reject70(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 always03(NDC); some submitters send07(HRI) or28(First DataBank). Hard-fail with reject21rather 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 aslatin-1and split on the exact control characters only; neverstrip()the whole payload of non-printables. - Truncated headers and short segments. A
partitionon0x1Ethat returns no separator means the header never terminated — reject04(M/I PCN) is safer than indexing into a short string. The guardlen(header) < 28prevents anIndexErrorfrom masquerading as a business reject. - Repeating segment groups.
AM07may repeat within a single transaction for compound claims (multiple ingredients). A parser that keeps only the last-seenD7loses ingredients; production code must accumulate repeating0x1D-delimited groups into a list, then validate the compound total against109-A9. - Reject categorization, not generic logging. Every failure maps to a
511-FBcode 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-D9Ingredient Cost,426-DQUsual & Customary, and430-DUGross Amount Due are parsed asdecimal.Decimal. Passing afloatinto 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
AdjudicationPayloadso 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
- Parsing NCPDP D.0 segments with Python regex vs lxml — when compiled regex boundary detection beats schema-aware
lxmltraversal, and how to combine both for XML-wrapped versus raw-switch payloads without duplicating validation.
Related
- Schema Validation & Error Categorization — the FATAL / TRANSIENT / WARN taxonomy and
511-FBreject-code contract this parser emits into. - Asynchronous Batch Adjudication Workflows — queue-based handoff of parsed payloads with
109-A9control-total verification. - PBM API Sync & Rate Limiting — backpressure and retry policy for the crosswalk fallback service.
- NDC to GPI Crosswalk Automation — the versioned lookup that enriches
407-D7into an adjudication-ready GPI. - Security & Compliance Boundaries for Claims Data — PHI handling rules for
302-C2/310-CAand raw-payload retention.
← Back to Claims Ingestion & NCPDP Parsing