Extracting an Explanation of Benefits Into Structured Fields
11 min read · updated August 11, 2026
An explanation of benefits is the one document in this cluster that checks itself. Every service line carries four amounts that must reconcile, so an extraction can be validated arithmetically rather than by asking a model how sure it is — provided you know the handful of situations where the identity is supposed to fail.
What the document is
An EOB is the payer’s statement to the member of how a claim was adjudicated. It is not a bill and it is not the provider’s statement of account; the corresponding document sent to the provider is a remittance advice, and its electronic form is the ASC X12N 835 transaction. If you can obtain the 835 you should parse that instead, because it is a defined machine-readable format and the PDF is a rendering of a subset of it. This page is about the case where the PDF is what exists.
Structurally it is a header plus a service-line table. The header carries the member and subscriber identifiers, the group number, the claim number, the provider, and dates. The table carries one row per service, keyed by a date of service and a procedure code — CPT or HCPCS, the code sets used to identify professional services, CPT being maintained and revised annually by the American Medical Association. Then a running set of accumulators at the foot: deductible applied year to date, out-of-pocket applied, sometimes remaining.
The per-line identity
Four amounts appear on each line and their relationship is the check. Named in the vocabulary EOBs generally use:
- Billed (or charged) — what the provider submitted. A list price, and for an in-network service, largely fictional.
- Allowed — the contracted maximum the payer recognises for that service under that provider’s agreement. This, not billed, is the base for everything that follows.
- Plan paid — what the payer sent to the provider.
- Patient responsibility — itemised into deductible, copay and coinsurance, plus any non-covered amount assigned to the member.
The identity that holds on an ordinary in-network line is: billed minus allowed equals the contractual adjustment the provider writes off, and allowed equals plan paid plus the sum of the patient responsibility components. Together, billed equals write-off plus plan paid plus patient responsibility. Two independent equations from four extracted numbers plus three components, which is enough to catch a transposed digit, a dropped minus sign, or a column read out of alignment.
Note the order in which the components are applied, because it determines the arithmetic and it is a source of confusion: the deductible is satisfied out of the allowed amount first, and coinsurance is a percentage of what remains of the allowed amount, not of the billed amount. A member with a 20% coinsurance on a line allowed at a fraction of the billed charge owes a fifth of the allowed figure. Any validation that computes coinsurance from the billed column is wrong by the size of the network discount, which is usually large.
CO and PR are different questions
Every adjustment on a line is explained by a claim adjustment reason code with a group code in front of it, from the code lists maintained by X12 and republished on a regular cycle. The group code is the part that changes what the number means:
- CO — contractual obligation. The amount is the provider’s to absorb under the network contract. The member does not owe it.
- PR — patient responsibility. The amount is the member’s.
- OA — other adjustment and PI — payer initiated reduction, which cover the remainder and are the ones you will least often see and most often mis-file.
An extraction that records adjustment amounts without their group codes cannot answer the only question anybody asks of this document — who owes what — and cannot be repaired later, because the amounts alone do not carry the distinction. Remittance advice remark codes sit alongside the reason codes and add narrative detail; extract them as a list rather than folding them into a single note string, since a line frequently carries several.
Where the arithmetic legitimately fails
A validator that treats every reconciliation failure as an extraction error will route perfectly good documents to review, and after two weeks reviewers will stop reading the queue. These are the cases where the identity is supposed to break, and each one is detectable from the document itself:
- Out of network. There is no contractual write-off because there is no contract, so billed minus allowed is not absorbed by the provider and may be billed to the member. The network status is on the EOB; branch on it.
- Coordination of benefits. With a secondary payer, the primary payer’s payment appears as a prior payment and the secondary’s EOB reconciles against the remaining balance, not against the billed amount. Look for a prior-payment or other-insurance column before concluding the line is broken.
- Bundled and denied lines. A line adjudicated as included in another service is allowed at zero with a reason code saying so. Zero allowed with a non-zero billed is not a failed extraction.
- Interest and penalties. Prompt-payment interest arrives as a line with no service, and appears on the claim total without appearing in any service line’s arithmetic.
- Rounding. Percentage-based coinsurance rounds to the cent, so a claim-level total can differ from the sum of the lines by a few cents. Use a tolerance, and make it a tolerance in cents rather than a percentage.
Layout adds two more. Amounts in parentheses are negative — a reversal or a takeback of a previous payment — and a naive numeric parse turns a credit into a charge. And a claim spanning two pages repeats the column headers with the totals only on the last page, so a per-page extraction produces two partial claims that each fail reconciliation. Reassemble by claim number before validating.
Build the extractor
{
"type": "object",
"required": ["claimNumber", "lines"],
"properties": {
"claimNumber": { "type": "string" },
"networkStatus": { "enum": ["in_network", "out_of_network", "unknown"] },
"dateProcessed": { "type": "string", "format": "date" },
"lines": {
"type": "array",
"items": {
"type": "object",
"required": ["dateOfService", "billed", "allowed",
"planPaid", "patientResponsibility"],
"properties": {
"dateOfService": { "type": "string", "format": "date" },
"procedureCode": { "type": ["string", "null"] },
"billed": { "type": "number" },
"allowed": { "type": "number" },
"planPaid": { "type": "number" },
"patientResponsibility": {
"type": "object",
"properties": {
"deductible": { "type": "number" },
"copay": { "type": "number" },
"coinsurance": { "type": "number" },
"notCovered": { "type": "number" }
}
},
"adjustments": {
"type": "array",
"items": {
"type": "object",
"required": ["groupCode", "reasonCode", "amount"],
"properties": {
"groupCode": { "enum": ["CO", "PR", "OA", "PI"] },
"reasonCode": { "type": "string" },
"amount": { "type": "number" }
}
}
}
}
}
}
}
}- Redact before you send. Member identifiers, subscriber name and the group number are not needed to extract amounts; strip them from the page image and keep the mapping locally, so the model sees a claim with amounts and codes and nothing that identifies a person.
- Extract per claim, not per page. Detect the claim boundary from the claim number in the header and stitch continuation pages before parsing lines, or every multi-page claim fails validation for the wrong reason.
- Require the schema with an
adjustmentsarray that always carries a group code. A model asked for adjustments in free text will drop the CO and PR prefixes, and they are the whole point. - Run the two equations per line with a two-cent tolerance, then run the claim-level totals against the sum of the lines. Record which equation failed rather than a single boolean; the failing equation names the column that was misread.
- Suppress the known-good exceptions before routing anything to a human: out of network, zero allowed with a bundling reason code, lines with a prior payment. Everything remaining is a genuine candidate for a review queue, and the queue stays small enough that people read it.