Skip to main content
Version: v0.0.14

Cookbook

Task-oriented recipes for the transactions you actually get handed. Each one is: here's the problem, here's the code, here's what you get back. Every symbol below is a real @cosyte/x12 export, no pseudo-API. All sample EDI is synthetic (fabricated names, obviously-fake ids, pre-2024 control numbers); never paste a real interchange into a doc.

The parser is lenient by default: vendor deviations become warnings with a stable code, not failures. Read Getting started first for the envelope model; the recipes here assume you can already get a parsed interchange.


1. Parse an 835 ERA and post payments​

The problem: you have a remittance advice (electronic EOB) and need to post the cash. Walk each claim and service line, read the CARC/RARC adjustments, and refuse to post an out-of-balance remit.

get835(delimiters, tx) returns a typed X12Remittance, or undefined if the transaction set isn't an 835. Money is X12Decimal throughout (BigInt-exact, never parseFloat an EDI amount).

import { parseX12, get835, lookupCarc, lookupRarc, WARNING_CODES } from "@cosyte/x12";

const raw =
"ISA*00* *00* *ZZ*MEDPAY *ZZ*CLINIC001 " +
"*260601*1200*^*00501*000000001*0*P*:~" +
"GS*HP*MEDPAY*CLINIC001*20260601*1200*1*X*005010X221A1~" +
"ST*835*0001~" +
"BPR*I*450.00*C*ACH*CCP*01*021000021*DA*1234567*1512345678**01*021000021*DA*98765*20260601~" +
"TRN*1*0012345*1512345678~" +
"N1*PR*MEDICARE PART A~" +
"N1*PE*SAMPLE CLINIC INC*XX*1234567890~" +
"LX*1~" +
"CLP*PT-ACCT-001*1*500.00*450.00*50.00*MB*CLAIMREF001*11~" +
"NM1*QC*1*PATIENT*TEST****MI*MEMBER001~" +
"SVC*HC:99213*500.00*450.00~" +
"CAS*PR*1*50.00~" +
"SE*11*0001~GE*1*1~IEA*1*000000001~";

const ix = parseX12(raw);
const tx = ix.groups[0]?.transactions.find((t) => t.st.elements[1] === "835");
const remit = tx ? get835(ix.delimiters, tx) : undefined;
if (remit === undefined) throw new Error("not an 835");

// Payment header: the money movement primitive.
remit.payment.totalActualPayment?.toString(); // "450.00"
remit.payment.creditDebitFlag; // "C"
remit.payment.method; // "ACH"
remit.traces[0]?.referenceId; // "0012345": reassociation trace (EFT number)

for (const claim of remit.claims) {
claim.patientControlNumber; // "PT-ACCT-001": your account number, echoed back
claim.totalChargeAmount?.toString(); // "500.00"
claim.totalPaymentAmount?.toString(); // "450.00"
claim.patientResponsibilityAmount?.toString(); // "50.00"

for (const line of claim.serviceLines) {
line.productServiceId; // "99213"
line.paymentAmount?.toString(); // "450.00"

// CARC: Claim Adjustment Reason Code. `reasonDescription` is prefilled
// from the bundled snapshot; fall back to lookupCarc for the raw entry.
for (const adj of line.adjustments) {
adj.groupCode; // "PR": patient responsibility (the safety-critical field)
adj.reasonCode; // "1"
adj.reasonDescription ?? lookupCarc(adj.reasonCode)?.description; // "Deductible..."
adj.amount?.toString(); // "50.00"
}

// RARC: Remittance Advice Remark Code (LQ*HE), if present.
for (const remark of line.remarks) {
remark.code; // "N4"
remark.description ?? lookupRarc(remark.code)?.description;
}
}
}

Respect the balance warning. The walker runs the TR3 X221A1 §1.10.2 balance invariants (the claim-level CLP-04 + Σ(claim CAS + line CAS) == CLP-03, the per-line SVC-03 + Σ(line CAS) == SVC-02, and the top-of-remit BPR-02 == Σ(CLP-04) - Σ(PLB)) and emits X12_835_REMIT_BALANCE_MISMATCH on a mismatch. It never silently rebalances. The inbound values stand. Gate on two codes, not one: where a term of an equation is undefined the equation cannot be run and you get X12_835_BALANCE_NOT_EVALUABLE instead, which is equally a document you must not auto-post. Through 0.0.12 that case collapsed to zero and raised the mismatch, so a gate written against the mismatch alone stops firing on it when you upgrade:

const doNotPost = remit.warnings.some(
(w) =>
w.code === WARNING_CODES.X12_835_REMIT_BALANCE_MISMATCH ||
w.code === WARNING_CODES.X12_835_BALANCE_NOT_EVALUABLE,
);
if (doNotPost) {
// Do NOT auto-post. Route to a human. Either the payer's numbers don't add
// up, or an amount one of the equations needs did not decode at all.
}

2. Parse a 277CA and route rejections​

The problem: a clearinghouse sent back a 277CA claim acknowledgment for a batch you submitted. You need to know, per claim, whether it was accepted into adjudication or rejected at the front door, and route the rejects for rework.

get277CADisposition(delimiters, tx) admits only the X214 convention and returns an X12ClaimStatusResponse. The status lives in STC triples: CSCC (category, source 507) + CSC (status, source 508) + entity. Category A1/A2 = acknowledged/accepted; A7/A6 = rejected.

import { parseX12, get277CADisposition } from "@cosyte/x12";

const ix = parseX12(raw277ca);
const tx = ix.groups[0]?.transactions.find((t) => t.st.elements[1] === "277");
const ack = tx ? get277CADisposition(ix.delimiters, tx) : undefined;
if (ack === undefined) throw new Error("not a 277CA (005010X214)");

const rejected: string[] = [];
for (const claim of ack.claims) {
claim.traces[0]?.referenceId; // echoes your submitted TRN, reassociate here
const stc = claim.statuses[0]?.statuses[0];
if (stc === undefined) continue;

stc.categoryCode; // "A2" accepted | "A7" rejected
stc.statusCode; // e.g. "20" (accepted for processing) | "21" (missing/invalid)
stc.statusDescription; // "Accepted for processing." | "Missing or invalid information."
stc.entityCode; // e.g. "PR" | "85" (which entity the status is about)

// A1/A2 = accepted into the adjudication system; anything else is a reject.
const accepted = stc.categoryCode === "A1" || stc.categoryCode === "A2";
if (!accepted) rejected.push(claim.traces[0]?.referenceId ?? "(no trace)");
}

// `rejected` now holds the traces to pull and rework.

get277Status decodes the plain 277 (X212) response the same way; it admits either convention, while get277CADisposition refuses a non-X214 transaction (returns undefined). Unknown category/status codes are preserved verbatim and raise X12_UNKNOWN_CLAIM_STATUS_CATEGORY / X12_UNKNOWN_CLAIM_STATUS. The code is never dropped.


3. Build a 271, then parse it: the TRN-echo round-trip​

The problem: you're the payer side and need to emit an eligibility response, then prove the reassociation contract holds: the 271 echoes the requesting 270's TRN-02 verbatim so the provider can match your answer to their question. There is no build270, and no get270 either: the 270 inquiry has no typed surface on either side, so the round-trip you can demonstrate today is build271 → get271Eligibility.

build271(spec) computes the HL spine for you (source → receiver → subscriber → dependent) and refuses a structurally impossible hierarchy via Eligibility271BuildError. It returns a frozen X12Interchange. It never auto-sends, opens a socket, or touches the filesystem.

import { parseX12, build271, get271Eligibility, X12Decimal, type Build271Spec } from "@cosyte/x12";

const traceFromThe270 = "ELIG20220627001"; // <- pulled from the inbound 270's TRN-02

const spec: Build271Spec = {
envelope: {
senderId: "MEDPAY",
receiverId: "PROVIDER",
interchangeDate: "220601",
interchangeTime: "1200",
interchangeControlNumber: "000000001",
groupControlNumber: "1",
transactionSetControlNumber: "0001",
},
informationSources: [
{
entity: {
entityIdentifierCode: "PR",
entityTypeQualifier: "2",
name: "MEDPAY INSURANCE",
idQualifier: "PI",
idCode: "00123",
},
receivers: [
{
entity: {
entityIdentifierCode: "1P",
entityTypeQualifier: "2",
name: "ANYTOWN CLINIC",
idQualifier: "XX",
idCode: "1234567890",
},
subscribers: [
{
traces: [{ traceTypeCode: "2", referenceId: traceFromThe270 }], // echo it back
name: {
entityIdentifierCode: "IL",
entityTypeQualifier: "1",
lastName: "DOE",
firstName: "JANE",
idQualifier: "MI",
idCode: "MBR0001",
},
benefits: [
{
eligibilityCode: "1", // active coverage
coverageLevelCode: "IND",
serviceTypeCodes: [{ code: "30" }],
monetaryAmount: X12Decimal.fromString("1000.00")!,
},
],
},
],
},
],
},
],
};

const ix = build271(spec);

// Round-trip it back through the reader:
const tx = ix.groups[0]?.transactions[0];
const elig = tx ? get271Eligibility(ix.delimiters, tx) : undefined;

// The safety-critical reassociation property holds byte-for-byte:
elig?.subscribers[0]?.traces[0]?.referenceId === traceFromThe270; // true
elig?.subscribers[0]?.name?.lastName; // "DOE"
elig?.subscribers[0]?.benefits[0]?.eligibilityCode; // "1"

The same pattern (build277 / build277CA echoing the 276's trace) covers claim-status responses.


4. Parse an 837 claim: variant, hierarchy, diagnoses​

The problem: you received a claim and need to know which flavor it is (Professional / Institutional / Dental), walk the HL hierarchy (billing provider → subscriber → claim), and read the diagnosis codes with their code-system provenance.

get837Claims(delimiters, tx) returns an X12_837Submission. Variant is resolved from the ST-03 implementation-convention reference (an X222 guide → P, X223 → I, X224 → D), with an SVx fallback; an unresolvable one raises X12_837_UNKNOWN_VARIANT.

🩺 Which references resolve changed in this release, and it changes how some already-published files decode. Through 0.0.13 the reader recognised exactly three: 005010X222A2, 005010X223A3 and 005010X224A2. That set contained none of the identifiers HIPAA adopts at 45 CFR 162.1102, and it was missing 005010X222A1 and 005010X223A2, which are what CMS and state Medicaid companion guides require in ST-03 on production professional and institutional claims. So a conformant 837P declaring 005010X222A1 resolved to nothing and fell through to the SVx fallback, where one stray SV2 re-typed the whole submission. The reader now recognises each base guide and each of its published errata. If you read 837 files on 0.0.13 or earlier, re-check any routing you drove off submission.variant, and any predicate you wrote on X12_837_UNKNOWN_VARIANT or X12_837_AMBIGUOUS_VARIANT: on a file whose ST-03 is now recognised, the variant can differ, neither code fires any more, and a service line whose SVx kind disagrees with the declaration is no longer decoded - its charge and units read undefined, the rest of the service segment is undecoded, and X12_837_SERVICE_LINE_NOT_DECODED is raised at that line's LX, on a document that may have been silent before. Gate on the warning, not on a slot: an undecoded line SEEDS its identity fields, so procedureCode is "" on a P or D line and revenueCode is "" on an I one. Read all of that as one property and not a closed list: where ST-03 is recognised, the document's own declaration decides the variant instead of its first service segment. The set is a list of cited identifiers, never a pattern: a reference outside it, in a different case, or padded still falls through exactly as before.

🩺 When the fallback is what decided and the body contradicts itself, that is reported too. The fallback takes the first SV1 / SV2 / SV3 in the transaction body, whether or not a Loop 2400 was open at it, so one stray SV2 ahead of a Professional claim re-types the whole submission Institutional. Where the body carries service segments for more than one variant and the fallback is what resolved it, X12_837_AMBIGUOUS_VARIANT is raised at the ST: submission.variant is a guess between contradictory evidence, and which segment is the stray one is not decided, because this reader cannot tell a stray service segment from a conformant one. Re-read with the type option to decode against a variant you trust. Read the bound as a property of the resolution: a type you pass, or an ST-03 naming a known convention, means no guess was made and the code is not raised however mixed the body is. It is additive, so every existing warning on such a document still fires exactly where it did.

The reference wins over the segments when the two disagree, and the type option wins over both. A service line whose SVx then does not match is not decoded (reading an SV2 into a Professional line would mis-read the charge), so its charge and units read undefined and X12_837_SERVICE_LINE_NOT_DECODED is raised against the LX that opened it. Gate on that warning before you post a line amount, alongside X12_837_SERVICE_SEGMENT_REPEATED (a second service segment inside one Loop 2400: the line carries only the last matching one, so a charge and a procedure code the sender also sent are not on the model - see below), X12_UNPARSEABLE_DECIMAL (the SVx decoded but the amount itself did not) and X12_AMOUNT_ROW_DROPPED (an AMT whose own amount decoded nothing, so the whole supplemental-amount row is off the model - off the line where one is open, and off the claim otherwise). Gate on X12_STATED_AMOUNT_DISCARDED beside it: an AMT arriving while a Loop 2430 adjudication is open decodes fine and is discarded anyway, so that row is off the model even though nothing failed to read. That is specific to this route: read the code's own bounds before generalising it, because its other route reports rows whose bytes may not decode at all. The two are disjoint and never name the same segment. None of them is a complete account of every way an amount can fail to reach the model, and Decimal-exact money states the guarantee in the only direction it holds.

A third code covers one more way a line goes missing: X12_837_SERVICE_LINE_DROPPED, raised at an LX that opened no Loop 2400, either because no CLM was open or because the variant is not one of P / I / D. An empty serviceLines is therefore not on its own evidence that the claim had none - check the warning channel before concluding it. Read that code's scope literally: it is anchored at the LX, so an SVx arriving with no Loop 2400 open is reported by a fourth code, X12_837_SERVICE_SEGMENT_WITHOUT_LX, anchored at the service segment itself (through 0.0.9 that case was dropped silently). It does not travel with X12_837_UNKNOWN_VARIANT (a caller-supplied type outside the union reaches the same route without it, so read submission.variant); and what becomes of a DTP / AMT / NTE / REF after a dropped LX is route-dependent, so do not assume it is simply absent (see Troubleshooting and the package's KNOWN-LIMITATIONS.md).

A fifth code covers the cost of that route. Where the dropped LX had no CLM open and landed inside an entity loop, it closes that loop, so an N3 / N4 / PER / REF following it reaches no party at all while nothing since has opened a loop, which the next NM1 / HL / CLM does. Each such segment raises X12_837_ENTITY_SEGMENT_DISCARDED_AFTER_LX, anchored at the segment itself. Read its bound literally: it reports only that route, and only for that stretch, so it is not a general "this segment reached no party" report. It reports that the segment reached no party, and not that it would otherwise have reached one: this reader surfaces neither a PER on a patient nor one on a pay-to address on any release, so on those the alternative was also no party. The bytes stay verbatim on tx.segments, which is still the only complete account of the document.

🩺 A sixth code covers the one that costs money on a line that decoded perfectly well. Where a second SV1 / SV2 / SV3 arrives inside a Loop 2400 that is already open, X12_837_SERVICE_SEGMENT_REPEATED is raised at that repeated segment. The line carries one service segment's worth of slots, so it holds what the last segment matching the resolved variant wrote, and every decoder writes all of the slots its kind writes: SV1*HC:99213*8500*… followed by SV1*HC:99999*12*… leaves one line reading charge 12 and procedureCode 99999, and through 0.0.13 it did so with warnings: []. Where the repeat's own charge element is absent it writes undefined over the amount the first one stated, and X12_837_SERVICE_LINE_NOT_DECODED does not fire there, because a service segment did decode. A repeat whose kind does not match the resolved variant is read into nothing and overwrites nothing, and is reported the same way. Which of them the sender meant is not decided - this reader cannot tell a stray service segment from a conformant one - and the decode is byte-for-byte what it was at 0.0.13, so gate on the code and read the segments off tx.segments rather than expecting the model to reconcile them. It never names the same segment as X12_837_SERVICE_SEGMENT_WITHOUT_LX, which requires that no Loop 2400 be open where this one requires that one is. Read that as disjointness and not as coverage: a service segment following an LX that opened no line is named by neither, because X12_837_SERVICE_LINE_DROPPED at that LX already reports the loss.

import { parseX12, get837Claims, HL_LEVEL_CODES, WARNING_CODES } from "@cosyte/x12";

const ix = parseX12(raw837);
const tx = ix.groups[0]?.transactions.find((t) => t.st.elements[1] === "837");
const sub = tx ? get837Claims(ix.delimiters, tx) : undefined;
if (sub === undefined) throw new Error("not an 837");

sub.variant; // "P" | "I" | "D"

// Walk the HL hierarchy. HL-02 parent pointers are validated, never
// re-numbered; a broken pointer surfaces as X12_HL_PARENT_MISMATCH.
for (const hl of sub.hierarchies) {
hl.hlId; // "1", "2", ...
hl.parentHlId; // undefined at the top (Information Source)
hl.levelCode; // compare against HL_LEVEL_CODES.*
const isSource = hl.levelCode === HL_LEVEL_CODES.INFORMATION_SOURCE;
const isSubscriber = hl.levelCode === HL_LEVEL_CODES.SUBSCRIBER;
}

for (const claim of sub.claims) {
claim.billingProvider?.name; // "BILLING CLINIC INC"
claim.billingProvider?.idCode; // "1234567890" (NPI)
claim.subscriber?.info.claimFilingIndicator; // "MB" (Medicare Part B)

// HI diagnoses: the qualifier tells you the code system AND the role.
for (const dx of claim.diagnoses) {
dx.qualifier; // "ABK" (principal ICD-10-CM), "ABF" (other), "ABJ" (admitting)...
dx.codeSystem; // "ICD-10-CM" | "ICD-10-PCS" | ... | "unknown"
dx.category; // "principal-diagnosis" | "other-diagnosis" | ...
}

for (const line of claim.serviceLines) {
// Variant-discriminated union: narrow on `line.variant` before reading SVx fields.
if (line.variant === "P") line.diagnosisPointers; // e.g. ["1"]
}
}

// An unknown HI qualifier is preserved verbatim (codeSystem: "unknown") + warns.
const unknownHi = sub.warnings.some((w) => w.code === WARNING_CODES.X12_UNKNOWN_HI_QUALIFIER);

5. Parse a 999 acknowledgment: disposition + segment errors​

The problem: you submitted an 837 and got a 999 back. Was the batch accepted? If not, which segments and elements failed, and where?

parse999(raw) takes the raw bytes directly (it parses the envelope for you) and returns an X12Ack999, or undefined if there's no 999 in the interchange.

import { parse999, isAcceptDisposition, X12_ACK_DISPOSITION_CODES } from "@cosyte/x12";

const ack = parse999(raw999);
if (ack === undefined) throw new Error("no 999 in interchange");

// AK9: the functional-group disposition + counts.
ack.ak9.disposition; // "A" accepted | "E" accepted-with-errors | "R" rejected | ...
ack.ak9.numberOfReceivedTransactionSets; // e.g. 1
ack.ak9.numberOfAcceptedTransactionSets; // e.g. 0

// One boolean for "did this pass?": accept dispositions are A / E / P.
if (!isAcceptDisposition(ack.ak9.disposition)) {
// The group was rejected. Dig into the per-transaction responses.
}

for (const response of ack.transactionResponses) {
response.ak2.transactionSetIdCode; // "837"
response.ak2.transactionSetControlNumber; // "0001": matches your ST-02
response.ik5.disposition; // per-transaction disposition (=== X12_ACK_DISPOSITION_CODES.R?)

// IK3: segment-level error notes.
for (const segNote of response.segmentNotes) {
segNote.ik3.segmentIdCode; // "NM1": which segment
segNote.ik3.segmentPositionInTransactionSet; // 8
segNote.ik3.loopIdentifier; // "2010BA"
segNote.ik3.syntaxErrorCode; // "8" (segment has data element errors)

// IK4: element-level notes nested under the segment.
for (const elemNote of segNote.elementNotes) {
elemNote.ik4.position.element; // 1
elemNote.ik4.position.component; // 2 (composite subelement)
elemNote.ik4.syntaxErrorCode; // "7" (invalid code value)
}
}
}

X12_ACK_DISPOSITION_CODES is the code registry if you prefer explicit comparisons (ack.ak9.disposition === X12_ACK_DISPOSITION_CODES.R). The TA1 interchange ack has a parallel pair, parseTA1(ix) / buildTA1(spec).


6. Handle warnings: the lenient, never-throw contract​

The problem: you want to log or triage every tolerated deviation without your pipeline throwing on a vendor quirk. @cosyte/x12 is liberal on input: only four Tier-3 structural errors ever throw; everything else is a warning carrying a stable code and positional context.

Every warning is collected on the returned model (ix.warnings, remit.warnings, sub.warnings, …). You can also stream them live via the onWarning callback:

import { parseX12, WARNING_CODES, type X12ParseWarning } from "@cosyte/x12";

const seen: X12ParseWarning[] = [];
const ix = parseX12(raw, {
onWarning: (w) => {
seen.push(w);
// w.code: a stable string from WARNING_CODES
// w.message: a frozen-registry entry, never built from your document
// w.position: where in the interchange it occurred
},
});

// Or read them after the fact:
for (const w of ix.warnings) {
if (w.code === WARNING_CODES.X12_PRE_005010) {
// sender is on a pre-005010 version family: tolerated, not fatal
}
}

Escalate when you want strictness. Pass { strict: true } to turn every tolerated deviation into a thrown X12ParseError carrying the same warning code, useful for a spec-conformance gate on a trusted trading partner.

The four fatal codes. These are unrecoverable structural corruption and always throw an X12ParseError regardless of strict:

import { parseX12, FATAL_CODES, X12ParseError } from "@cosyte/x12";

try {
parseX12(maybeGarbage);
} catch (err) {
if (err instanceof X12ParseError) {
switch (err.code) {
case FATAL_CODES.X12_EMPTY_INPUT: // nothing to parse
case FATAL_CODES.X12_NO_ISA_HEADER: // not an X12 interchange at all
case FATAL_CODES.X12_ISA_TOO_SHORT: // ISA truncated below 106 bytes
case FATAL_CODES.X12_INVALID_DELIMITERS: // delimiters unrecoverable from ISA
// A malformed interchange: the bytes aren't X12.
break;
}
}
}

Everything a real-world payer or clearinghouse does short of that (miscounts, dangling release chars, unknown CARC/RARC/HI codes, HL parent mismatches, balance mismatches, pre-005010 versions) is a warning you triage, not an exception you catch.