Quickstart
This page gives you a first useful result: read an 835 remittance advice (the electronic EOB) and pull out the money (the payment header, each claim's charge/paid/patient-responsibility split, and the CARC adjustment reasons) in a few lines. The 835 is the document an integration is most often handed first, so it is the quickest way to see what the library does.
The reader is lenient (vendor quirks become stable-coded warnings, never silent failures) and
money is decimal-exact throughout (X12Decimal, BigInt-backed; never parseFloat an EDI amount).
Every interchange below is synthetic: fabricated names, invented ids, made-up control numbers. X12 healthcare data is PHI; a fixture must never hold a real one.
Parse an 835 and read the money
parseX12 decodes the interchange; get835(delimiters, tx) walks an 835 transaction set into a typed
X12Remittance (or returns undefined if the transaction set is not an 835).
import { parseX12, get835 } from "@cosyte/x12";
const raw = `ISA*00* *00* *ZZ*MEDICARE *ZZ*SUBMITTER *260601*1200*^*00501*000000001*0*P*:~
GS*HP*MEDICARE*SUBMITTER*20260601*1200*1*X*005010X221A1~
ST*835*0001~
BPR*I*450.00*C*ACH*CCP*01*123456789*DA*987654321*1512345678**01*111111111*DA*222222222*20260601~
TRN*1*0012345*1512345678~
DTM*405*20260601~
N1*PR*MEDICARE PART A~
N3*123 PAYER WAY~
N4*BALTIMORE*MD*21244~
PER*BL*JANE COORDINATOR*TE*5551234567~
N1*PE*SAMPLE CLINIC INC~
N3*456 PROVIDER LN~
N4*CLEVELAND*OH*44113~
REF*TJ*123456789~
LX*1~
CLP*PT-ACCT-001*1*500.00*450.00*50.00*MC*PAYER-CLAIM-001*11*1~
NM1*QC*1*PATIENT*TEST*A***MI*MEMBER001~
NM1*82*2*RENDERING PROVIDER INC*****XX*1234567890~
DTM*232*20260501~
DTM*233*20260501~
SVC*HC:99213*500.00*450.00**1~
DTM*472*20260501~
CAS*PR*1*50.00~
REF*6R*LINE-CTRL-001~
SE*23*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"
// Per claim: your account number echoed back, and the charge/paid/responsibility split.
const claim = remit.claims[0];
claim?.patientControlNumber; // => "PT-ACCT-001"
claim?.totalChargeAmount?.toString(); // => "500.00"
claim?.totalPaymentAmount?.toString(); // => "450.00"
claim?.patientResponsibilityAmount?.toString(); // => "50.00"
// Per service line: the CARC adjustment. Group code is the safety-critical field.
const line = claim?.serviceLines[0];
line?.productServiceId; // => "99213"
const adj = line?.adjustments[0];
adj?.groupCode; // => "PR"
adj?.reasonCode; // => "1"
adj?.amount?.toString(); // => "50.00"
The groupCode (PR: the patient owes it, CO: the provider's contract writes it off, and so on)
is what tells you who owes the money. Never infer it, read it. The reasonDescription on each
adjustment is undefined: the bundled CARC list ships its codes and no description text, because
its own redistribution record says licence-required. Pass your own table to
lookupCarc(code, { descriptions }) to show text beside a code. An unrecognized code is preserved
verbatim and raises X12_UNKNOWN_CARC (the value is never dropped).
Respect the balance warning
The reader runs three balance equations over the 835
and emits X12_835_REMIT_BALANCE_MISMATCH on a mismatch. It never silently rebalances. Gate on two codes, not one: where a term of an
equation is undefined the equation cannot be run at all and you get
X12_835_BALANCE_NOT_EVALUABLE instead, which is a document you must not post automatically either. 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.
import { parseX12, get835, 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 = get835(ix.delimiters, tx!)!;
const doNotPost = remit.warnings.some(
(w) =>
w.code === WARNING_CODES.X12_835_REMIT_BALANCE_MISMATCH ||
w.code === WARNING_CODES.X12_835_BALANCE_NOT_EVALUABLE,
);
doNotPost; // => false
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.
}
Unrecoverable input throws: everything else is a warning
Only four structurally unrecoverable conditions throw a typed X12ParseError (empty input, no ISA
header, a truncated ISA, or unrecoverable delimiters). Vendor quirks never throw; they collect on
.warnings:
import { parseX12 } from "@cosyte/x12";
// Not an X12 interchange at all: a structural fatal, not a tolerated quirk.
parseX12("this is not an X12 interchange"); // throws X12ParseError (X12_NO_ISA_HEADER)
Next
- Post an 835 remittance: every claim and line, the remark codes, the balance gate, and the CORE code combination check.
- The other how-to guides, one task each: route 277CA and 277 responses, answer a 270 with a 271, read 270 and 276 inquiries, read and build 837 claims, read and build a 278 review, read and build an 834 roster, read and build an 820 premium payment, read 999, 997 and TA1 acknowledgments, answer an attachment request, build and serialize X12, and handle warnings.
- The envelope and loop model and Tolerance tiers and warning codes: the model behind every reader.
- Troubleshooting and known limitations: the common symptoms, and what this package does not do.
Every TypeScript sample on these pages is run by the package's test suite against the built
package, and its // => values are checked, so a sample cannot drift from the code.