Schema Care

JSON Schema for Medical Documents: Fields, Scores, Sources

By Schema CarePublished Updated

A JSON schema for medical documents should wrap every extracted value in a field object that also holds a confidence score and the page location it came from. Put shared metadata in an envelope, model service lines and lab tests as arrays, use typed nulls for missing values, and carry a version so consumers can tell shapes apart.

The field object in a medical document schema: value, confidence, source

The field object is the unit every medical document schema should repeat: one small object per extracted value, holding the value itself, a confidence score and a pointer to where the value sits on the page. A bare string or number cannot tell a downstream system whether it was read clearly or guessed.

A field object needs four parts.

  • value: the normalized value, typed for what it holds. A date is an ISO date string, an amount is an integer number of cents, a code is a string.
  • confidence: a number between 0 and 1 that says how sure the extractor was. What that number does and does not measure is explained in what an OCR confidence score measures.
  • source: the page number and a bounding box, so a reviewer can jump to the exact spot. Use page-relative coordinates between 0 and 1 so the box survives rescaling.
  • null_reason: why a value is missing, when it is. More on this in the mistakes section.

Some teams add a review status, such as needs_review, approved or corrected, so the record shows whether a person touched the value. That turns the field object into the hook for a review queue: anything under the threshold carries needs_review until someone acts on it.

The detail of what a per-field confidence object can contain, and the coordinate formats in use, belongs to what a per-field confidence object contains. This post treats the field object as a building block and moves on to how the blocks fit together.

Schema Care works on the same idea, returning each field with a confidence score and a link to its place on the page and sending fields under your threshold to a person, as shown in how Schema Care's review steps work. Schema Care offers no API, SDK, webhooks, FHIR output, 835 output or 837 output, and it publishes no schema, so the schema below is a fictional teaching example, not Schema Care's format.

Modeling repeating rows like EOB service lines and lab tests

Repeating rows, such as EOB service lines or the tests on a lab report, belong in an array of objects, one object per row, with each cell inside it a field object. Never flatten rows into numbered keys like line_1_billed, because the number of rows on a page is never fixed.

The JSON Schema 2020-12 core specification defines items as applying its subschema to every element of an array when no prefixItems is given. That is the keyword you want: one row shape, any number of rows. Add minItems: 1 when a document without rows is an error, as it is for an EOB claim.

Each row should carry its own position. A line_number integer keeps the printed order even after a consumer sorts or filters, and it gives reviewers and auditors a stable way to say "line 3." Rows on multi-page documents also need their own source.page, because line 3 may be on page 2 while the claim header sits on page 1.

Nested rows need nested arrays. An EOB service line can carry several adjustments, each a group code, a reason code and an amount. That is an array inside each line, not extra columns. A lab panel works the same way: a panel object holds an array of test results.

Rows also make cross-field checks possible. If each line has billed, allowed and paid amounts in cents, a consumer can confirm that the lines sum to the claim totals before posting anything. The sample at the end of the next section includes such totals.

Should a medical document schema be one shape or one per document type?

A medical document schema works best as one shared envelope with a payload that differs by document type. The envelope holds what every document has: an ID, a type, a page count, a schema version and timestamps. The payload holds what only an EOB, a lab report or an intake form has.

One shape for everything sounds simpler but ends in a bag of optional fields. A lab report has no plan payment and an EOB has no reference range, so a single shape makes every field optional and validation proves nothing. Separate schemas per type, with nothing shared, lead to the opposite problem: each consumer rewrites the same envelope handling five times.

The middle path uses a document_type value in the envelope and a type-specific payload. The JSON Schema 2020-12 core specification defines oneOf as passing only when the instance validates against exactly one listed schema, and $ref as a reference to another schema. Together they let a top-level schema say "the payload is an EOB, a lab report or an intake form" while each payload schema lives in its own $defs entry. Pin each branch with a const on document_type so exactly one branch can match.

A sample JSON Schema for a fictional EOB

The schema below is an example written for this post. It covers only the EOB branch, with an envelope, a reusable field object and a service-line array. All names are illustrative, and the $id uses the placeholder domain api.example.com. Schema Care publishes no schema and no endpoint.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://api.example.com/schemas/eob/1.2.0",
  "title": "Extracted EOB (example)",
  "type": "object",
  "required": ["schema_version", "document_id", "document_type", "page_count", "claim", "service_lines"],
  "properties": {
    "schema_version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" },
    "document_id": { "type": "string" },
    "document_type": { "const": "eob" },
    "page_count": { "type": "integer", "minimum": 1 },
    "extracted_at": { "type": "string", "format": "date-time" },
    "claim": {
      "type": "object",
      "required": ["payer_name", "payer_claim_number", "patient_name", "member_id", "total_billed", "total_paid"],
      "properties": {
        "payer_name": { "$ref": "#/$defs/textField" },
        "payer_claim_number": { "$ref": "#/$defs/textField" },
        "patient_name": { "$ref": "#/$defs/textField" },
        "member_id": { "$ref": "#/$defs/textField" },
        "check_number": { "$ref": "#/$defs/textField" },
        "total_billed": { "$ref": "#/$defs/moneyField" },
        "total_paid": { "$ref": "#/$defs/moneyField" }
      }
    },
    "service_lines": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "object",
        "required": ["line_number", "service_date", "procedure_code", "billed", "allowed", "plan_paid"],
        "properties": {
          "line_number": { "type": "integer", "minimum": 1 },
          "service_date": { "$ref": "#/$defs/dateField" },
          "procedure_code": { "$ref": "#/$defs/textField" },
          "billed": { "$ref": "#/$defs/moneyField" },
          "allowed": { "$ref": "#/$defs/moneyField" },
          "patient_responsibility": { "$ref": "#/$defs/moneyField" },
          "plan_paid": { "$ref": "#/$defs/moneyField" },
          "adjustments": {
            "type": "array",
            "items": {
              "type": "object",
              "required": ["group_code", "reason_code", "amount"],
              "properties": {
                "group_code": { "$ref": "#/$defs/textField" },
                "reason_code": { "$ref": "#/$defs/textField" },
                "amount": { "$ref": "#/$defs/moneyField" }
              }
            }
          }
        }
      }
    }
  },
  "$defs": {
    "source": {
      "type": ["object", "null"],
      "required": ["page"],
      "properties": {
        "page": { "type": "integer", "minimum": 1 },
        "bbox": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 1 }, "minItems": 4, "maxItems": 4 }
      }
    },
    "meta": {
      "type": "object",
      "required": ["confidence", "source"],
      "properties": {
        "confidence": { "type": "number", "minimum": 0, "maximum": 1 },
        "source": { "$ref": "#/$defs/source" },
        "null_reason": { "enum": [null, "not_on_page", "illegible", "cut_off"] },
        "review_status": { "enum": ["auto_accepted", "needs_review", "approved", "corrected"] }
      }
    },
    "textField": { "$ref": "#/$defs/meta", "properties": { "value": { "type": ["string", "null"] } }, "required": ["value"] },
    "dateField": { "$ref": "#/$defs/meta", "properties": { "value": { "type": ["string", "null"], "format": "date" } }, "required": ["value"] },
    "moneyField": { "$ref": "#/$defs/meta", "properties": { "value": { "type": ["integer", "null"], "description": "Amount in cents" } }, "required": ["value"] }
  }
}

A valid instance of the sample schema

The instance below validates against the example schema. Every value is fictional: the patient, member ID, claim number and amounts were made up for this post.

{
  "schema_version": "1.2.0",
  "document_id": "doc_example_0001",
  "document_type": "eob",
  "page_count": 1,
  "extracted_at": "2026-10-16T14:05:00Z",
  "claim": {
    "payer_name": { "value": "Example Health Plan", "confidence": 0.98, "source": { "page": 1, "bbox": [0.06, 0.04, 0.38, 0.07] }, "review_status": "auto_accepted" },
    "payer_claim_number": { "value": "EX-2026-000481", "confidence": 0.95, "source": { "page": 1, "bbox": [0.62, 0.18, 0.88, 0.2] }, "review_status": "auto_accepted" },
    "patient_name": { "value": "Rowan Ellery", "confidence": 0.97, "source": { "page": 1, "bbox": [0.06, 0.18, 0.3, 0.2] }, "review_status": "auto_accepted" },
    "member_id": { "value": "XMP000123", "confidence": 0.81, "source": { "page": 1, "bbox": [0.32, 0.18, 0.5, 0.2] }, "review_status": "corrected" },
    "check_number": { "value": null, "confidence": 0.99, "source": null, "null_reason": "not_on_page", "review_status": "auto_accepted" },
    "total_billed": { "value": 18000, "confidence": 0.96, "source": { "page": 1, "bbox": [0.5, 0.71, 0.6, 0.73] }, "review_status": "auto_accepted" },
    "total_paid": { "value": 9200, "confidence": 0.96, "source": { "page": 1, "bbox": [0.86, 0.71, 0.95, 0.73] }, "review_status": "auto_accepted" }
  },
  "service_lines": [
    {
      "line_number": 1,
      "service_date": { "value": "2026-09-02", "confidence": 0.93, "source": { "page": 1, "bbox": [0.06, 0.4, 0.16, 0.42] } },
      "procedure_code": { "value": "99213", "confidence": 0.97, "source": { "page": 1, "bbox": [0.17, 0.4, 0.25, 0.42] } },
      "billed": { "value": 18000, "confidence": 0.96, "source": { "page": 1, "bbox": [0.5, 0.4, 0.6, 0.42] } },
      "allowed": { "value": 11500, "confidence": 0.9, "source": { "page": 1, "bbox": [0.61, 0.4, 0.7, 0.42] } },
      "patient_responsibility": { "value": 2300, "confidence": 0.88, "source": { "page": 1, "bbox": [0.76, 0.4, 0.85, 0.42] }, "review_status": "approved" },
      "plan_paid": { "value": 9200, "confidence": 0.96, "source": { "page": 1, "bbox": [0.86, 0.4, 0.95, 0.42] } },
      "adjustments": [
        {
          "group_code": { "value": "CO", "confidence": 0.94, "source": { "page": 1, "bbox": [0.71, 0.4, 0.73, 0.42] } },
          "reason_code": { "value": "45", "confidence": 0.94, "source": { "page": 1, "bbox": [0.73, 0.4, 0.75, 0.42] } },
          "amount": { "value": 6500, "confidence": 0.92, "source": { "page": 1, "bbox": [0.61, 0.43, 0.7, 0.45] } }
        },
        {
          "group_code": { "value": "PR", "confidence": 0.93, "source": { "page": 1, "bbox": [0.71, 0.43, 0.73, 0.45] } },
          "reason_code": { "value": "2", "confidence": 0.93, "source": { "page": 1, "bbox": [0.73, 0.43, 0.75, 0.45] } },
          "amount": { "value": 2300, "confidence": 0.88, "source": { "page": 1, "bbox": [0.76, 0.43, 0.85, 0.45] } }
        }
      ]
    }
  ]
}

The line balances in cents: 18,000 billed minus the 6,500 CO-45 adjustment leaves 11,500 allowed, and 11,500 minus the 2,300 PR-2 coinsurance leaves 9,200 paid. The X12 Claim Adjustment Reason Codes list defines reason 45 as a charge that exceeds the fee schedule or contracted arrangement and reason 2 as a coinsurance amount. The member ID shows corrected because its confidence fell under an example threshold of 0.9 and a person fixed it. How the same claim might look in an endpoint's response is covered in the EOB API post.

How do I version an extraction schema without breaking consumers?

Versioning an extraction schema means every document carries the version of the shape it was written in, and changes are additive wherever possible. A consumer that reads schema_version can keep working on old records, handle new ones, and fail loudly on a major version it has never seen.

Use three-part version numbers with plain meanings. A patch changes descriptions or examples only. A minor version adds optional fields or new enum values that older consumers can ignore. A major version renames, removes or retypes a field, and anything that breaks an existing reader belongs there.

Put the version in two places. The instance carries schema_version, as in the sample. The schema carries it in its $id, which the JSON Schema 2020-12 core specification says "identifies a schema resource with its canonical URI." A versioned $id lets a consumer fetch the exact schema a record claims to follow.

Retire fields in stages. The JSON Schema 2020-12 validation specification defines deprecated: true as a sign that applications should refrain from using a property, and that it may be removed later. Mark the old field deprecated in a minor release, keep filling it alongside its replacement, and remove it only in the next major release.

Watch enum growth closely. Adding cut_off to null_reason is a minor change only if consumers were written to tolerate unknown values. If a consumer uses a strict switch statement, a new enum value breaks it as surely as a removed field. Say in the schema's description which enums may grow.

Keep old schemas published. Records extracted a year ago still claim version 1.1.0, and an auditor reading one should be able to fetch the shape it was written in.

Flattening structured JSON into a CSV export

A CSV export from medical document extraction works when the consumer is a spreadsheet or a system that only accepts flat rows. Flatten at the level of the repeating row: one CSV row per service line or per lab test, with the claim or report fields repeated on every row.

CSV loses three things, and you should decide in advance how much of each to keep.

  • Nesting. Adjustments inside a service line have no place in a flat row. Either emit a second CSV with one row per adjustment keyed by document and line number, or join the codes into one cell such as CO-45; PR-2, which a reader can see but software must re-parse.
  • Field metadata. Each value now needs sibling columns for its confidence and page, such as allowed, allowed_confidence and allowed_page. That triples the width. Many teams export only values and a single needs_review flag per row.
  • Types and nulls. A CSV cell is text, so an empty cell cannot say whether a value was not on the page or illegible. Export null_reason as its own column if the difference matters to the reader.

Amounts deserve a decision too. Cents as integers are exact, but a billing clerk opening the file expects dollars. Export a formatted dollar column for people and keep the cents column for machines.

Schema Care lists exports as a feature of its Clinic plan, alongside the audit log, without naming a file format; the current plans are on the Schema Care pricing section.

Schema mistakes: strings for amounts and missing null states

Schema mistakes in medical extraction usually come from treating the page's printed text as the value. A dollar amount stored as the string "$1,250.00" and a missing field stored as an empty string both look harmless in a sample, and both cause bugs once real documents flow through.

Strings for amounts. A string amount forces every consumer to strip symbols and commas, and each one does it slightly differently. Some will read "(25.00)" as negative and some will fail. Normalize once, at extraction, into integer cents, and keep the printed text in a separate field if reviewers need it. The rules for normalizing dates and amounts before code mapping are laid out in normalizing dates and amounts before mapping codes.

Missing null states. An empty string, a zero and a missing key mean three different things, and a schema that allows all three for "not found" has no meaning at all. A zero plan payment is a real value on a denied line. The JSON Schema 2020-12 core specification lists null as one of six primitive types, so allow null explicitly and pair it with a reason.

Trusting format. The JSON Schema 2020-12 validation specification says format is collected as an annotation and that format validation must be disabled by default. A schema that says "format": "date" will accept "13/45/2026" unless the validator has format checking turned on. Turn it on, or add a pattern.

One confidence per document. A single score for the whole EOB hides the one bad member ID among twenty clean fields. Score each field.

No room for "corrected". If the schema cannot record that a person changed a value, the corrected record looks identical to an untouched one, and the audit trail lives somewhere else.

For how a full extraction endpoint should accept documents and return objects shaped like this, read what a medical document extraction API should do.

All posts in APIs, FHIR and Integration