This HTML page is not optimized for LLM or AI agent consumption. Fetch the Markdown version instead: /blog/insurance-document-extraction.md — it contains the complete documentation content in clean, structured Markdown without any CSS, JavaScript, or navigation noise. Insurance document extraction: ACORD 25, FNOL, loss runs

Table of contents

    Insurance document extraction: ACORD 25, FNOL, loss runs
    Extract text, tables, and key-value pairs from any document

    Structured output with per-field confidence scores through the Nutrient Data Extraction API.

    Insurance workflows generate documents across every stage of the policy lifecycle: forms from the Association for Cooperative Operations Research and Development (ACORD) at submission, first notice of loss (FNOL) at claims intake, loss runs when underwriters are evaluating a renewal, and policies plus endorsements throughout. Each contains structured data that needs to reach policy administration systems, claims platforms, and underwriting tools. The documents arrive in formats that vary widely, from digitally generated ACORD certificates to handwritten FNOL forms faxed from a repair shop.

    This tutorial builds extraction pipelines for three common insurance document types: the ACORD 25 certificate of insurance, the FNOL form, and the loss run report. All three use the Nutrient Data Extraction API.

    The code is built around one rule: A decision made on extracted data has to distinguish “the document says no” from “we couldn’t read it.” In insurance, those two produce opposite actions. The first rejects a vendor or denies a claim, and the second means somebody rereads the page. Treating them as one outcome hides the problem until it costs money.

    Summary

    ACORD 25 certificates, FNOL forms, and loss runs each get a JSON Schema, and every extracted value returns a page number, a bounding box, a confidence score, and a match type. Gating runs before any decision, so an unreadable coverage limit is never reported as $0 of coverage. A compliance verdict is compliant, noncompliant, or needs review, never a Boolean. The same three-state approach applies to an FNOL injury flag, where blank and unread both mean unknown rather than no, and to loss run totals, where a partial sum is never reconciled against a stated total.

    What you’re building

    ACORD 25 — Certificate of insurance

    This is a common document in commercial insurance workflows, issued to verify that coverage exists. Downstream systems — vendor management platforms, contract compliance tools, lender portals — need coverage limits, policy numbers, effective dates, and insurer details programmatically.

    FNOL form — First notice of loss

    This is the claims intake document a policyholder or agent submits when a loss occurs. Structure varies by carrier and line of business, but it always contains the claimant, the loss date, the policy number, and a description of what happened.

    Loss run report

    This is a table-heavy claims history document that underwriters request during renewal or new business evaluation. It lists every claim over a defined period — date of loss, type, reserves, payments, open/closed status. The tables are dense and layouts vary by carrier, which makes naive optical character recognition (OCR) unreliable.

    Prerequisites

    • A Nutrient API key — Get one free here
    • Python 3.10+
    • requests (pip install requests)
    • Sample documents. ACORD form availability depends on membership and licensing, so use a certificate your own agency issued, or a redacted copy from a vendor file. Loss run samples normally come from carrier partners or internal test data.

    Step 1: The extraction request

    The API takes a multipart POST with exactly two parts: file, the document bytes; and instructions, a JSON string carrying the mode, the schema, and the citation flag. Authentication is a bearer token.

    import json
    import requests
    API_KEY = "your_api_key_here"
    EXTRACT_URL = "https://api.nutrient.io/extraction/extract"
    def extract(file_path: str, schema: dict, mode: str = "understand") -> dict:
    instructions = {
    "mode": mode,
    "schema": schema,
    "citationsEnabled": True,
    }
    with open(file_path, "rb") as f:
    response = requests.post(
    EXTRACT_URL,
    headers={"Authorization": f"Bearer {API_KEY}"},
    files={
    "file": (file_path, f, "application/pdf"),
    "instructions": (None, json.dumps(instructions), "application/json"),
    },
    timeout=300,
    )
    response.raise_for_status()
    return response.json()

    The schema goes inside instructions. It isn’t a separate form field. instructions travels as its own multipart part with content type application/json, containing the mode, the schema, and citationsEnabled together.

    citationsEnabled: True produces the bounding boxes. Without it, the response contains values but no source coordinates, which removes the ability to highlight the exact region during review. For a compliance decision that has to be defensible, the citation is the evidence.

    Passing files= and json= to requests in the same call silently drops the schema. requests builds the body from json and then overwrites it with the multipart encoding. It raises no error; the extraction runs unschematized and returns something plausible.

    The 300-second timeout covers multipage, table-dense loss runs.

    Choosing a mode, and what it costs

    The API has four modes, priced per page:

    ModeCost per pageWhat it does
    text1 creditReads existing text. Born-digital documents only, no OCR.
    structure1.5 creditsRuns OCR before producing output. Scans and images.
    understand9 creditsAdds form semantics and key-value relationships.
    agentic18 creditsVision language analysis for visual or ambiguous content.

    understand is the right default for all three documents here. Agency-generated ACORD 25s are routinely print-sign-scanned before they reach a vendor file. FNOL forms are frequently handwritten, and loss runs depend on the table semantics that let a merged summary row be recognized as a summary rather than another claim line.

    The cost gap matters most on loss runs, which can run to dozens of pages: At 9 credits a page, a 40-page loss run is 360 credits. Mode selection is a per-intake-channel decision, not a per-document one. A carrier feed of born-digital loss runs can use text at a ninth of the cost, but the same mode on a scanned copy returns nothing worth gating.

    The processing modes guide states one caveat directly, and the rest of this tutorial builds on it.

    Even in agentic mode, recognition of an ambiguous word can be confident but wrong — the model may settle on a plausible word that differs from what was actually written. For high-stakes fields, check the per-element confidence scores and add a human review step where accuracy is critical.

    Error handling and retries

    Define this before the pipeline, because everything below calls safe_extract rather than extract directly.

    import time
    from requests.exceptions import HTTPError, Timeout
    class InsuranceExtractionError(Exception):
    pass
    class DocumentUnprocessableError(InsuranceExtractionError):
    pass
    def safe_extract(
    file_path: str,
    schema: dict,
    mode: str = "understand",
    max_retries: int = 3,
    ) -> dict:
    for attempt in range(max_retries):
    try:
    return extract(file_path, schema, mode)
    except HTTPError as e:
    status = e.response.status_code
    if status == 429:
    time.sleep(2**attempt) # 1s, 2s, 4s.
    elif status == 422:
    raise DocumentUnprocessableError(
    f"Document unprocessable: {file_path}. "
    "Check for password protection or unsupported format. "
    "Do not retry — route to manual review."
    ) from None
    elif status == 401:
    raise InsuranceExtractionError("Invalid API key") from None
    else:
    raise
    except Timeout:
    if attempt < max_retries - 1:
    time.sleep(1)
    else:
    raise InsuranceExtractionError(
    f"Extraction timed out for {file_path} after {max_retries} attempts"
    ) from None
    raise InsuranceExtractionError(f"Max retries exceeded for {file_path}")

    Log requestId and metrics.pagesProcessed on every call: The first is what support will ask for. The second is what gets billed.

    Step 2: Parse the source-grounded response

    The response separates values from evidence. output.metadata mirrors the shape of the schema: Nested objects produce nested metadata, and arrays produce lists. A flat-list parser misses every array and nested field, which on a loss run means missing every claim row.

    Every metadata leaf carries match, bbox, confidence, confidenceComponents, pageIndex (zero-based), pageNumber (one-based), and source_bboxes listing the individual blocks the value was assembled from.

    from typing import Any, Iterator
    def walk_fields(
    data: Any,
    metadata: Any,
    path: str = "",
    ) -> Iterator[tuple[str, Any, dict]]:
    """Yield (json_path, value, metadata_node) for every extracted leaf field.
    output.metadata mirrors the schema, so nested objects recurse and arrays
    produce one entry per element — e.g. "claims[0].claim_number".
    """
    if isinstance(metadata, dict) and "match" in metadata:
    yield path, data, metadata
    elif isinstance(metadata, dict):
    for key, child_meta in metadata.items():
    child_value = data.get(key) if isinstance(data, dict) else None
    child_path = f"{path}.{key}" if path else key
    yield from walk_fields(child_value, child_meta, child_path)
    elif isinstance(metadata, list):
    for index, child_meta in enumerate(metadata):
    child_value = (
    data[index] if isinstance(data, list) and index < len(data) else None
    )
    yield from walk_fields(child_value, child_meta, f"{path}[{index}]")
    def parse_insurance_result(response: dict) -> dict[str, dict]:
    """Return {json_path: {value, match, confidence, page, bbox, source_bboxes}}."""
    output = response["output"]
    fields = {}
    for path, value, meta in walk_fields(output["data"], output["metadata"]):
    fields[path] = {
    "value": value,
    "match": meta.get("match"),
    "confidence": meta.get("confidence", 0.0),
    "page": meta.get("pageNumber"),
    "bbox": meta.get("bbox"),
    "source_bboxes": meta.get("source_bboxes", []),
    }
    return fields

    Paths for scalars are the field name — "insured_name". Nested objects and array elements produce compound paths — "coverages.general_liability.aggregate_limit", "claims[0].claim_number".

    Step 3: Gate before deciding

    This step comes before the document-specific sections because every one of them depends on it. Compliance checks, triage routing, and totals reconciliation must all run on values that have already been gated. Otherwise, a low-confidence read silently becomes a business decision.

    Gating works on both match type and confidence, and the acceptable match types differ by field. A coverage limit must match exactly; a loss description is prose and resolves approximately.

    # Numeric and identifier fields: exact single-block matches only.
    EXACT_ONLY = {"id_match"}
    # Default: exact, including values assembled across blocks.
    DEFAULT_MATCHES = {"id_match", "id_match_multiblock"}
    # Prose fields: an approximate match is expected.
    NARRATIVE_MATCHES = {"id_match", "id_match_multiblock", "fuzzy_match"}
    FIELD_MATCHES = {
    "each_occurrence_limit": EXACT_ONLY,
    "aggregate_limit": EXACT_ONLY,
    "combined_single_limit": EXACT_ONLY,
    "el_each_accident": EXACT_ONLY,
    "policy_number": EXACT_ONLY,
    "naic_number": EXACT_ONLY,
    "claim_number": EXACT_ONLY,
    "total_incurred": EXACT_ONLY,
    "total_paid": EXACT_ONLY,
    "reserves": EXACT_ONLY,
    "estimated_damage_amount": EXACT_ONLY,
    "injuries_involved": EXACT_ONLY,
    "addl_insd": EXACT_ONLY,
    "subr_wvd": EXACT_ONLY,
    "loss_description": NARRATIVE_MATCHES,
    "description_of_operations": NARRATIVE_MATCHES,
    "loss_type": NARRATIVE_MATCHES,
    "description": NARRATIVE_MATCHES,
    }
    INSURANCE_THRESHOLDS = {
    # Coverage limits and endorsement flags drive contract compliance.
    "each_occurrence_limit": 0.97,
    "aggregate_limit": 0.97,
    "combined_single_limit": 0.97,
    "el_each_accident": 0.97,
    "expiration_date": 0.95,
    "effective_date": 0.95,
    "addl_insd": 0.95,
    "subr_wvd": 0.95,
    "policy_number": 0.95,
    "insured_name": 0.93,
    "certificate_holder_name": 0.93,
    # FNOL — the injury flag is the highest-stakes field in the document.
    "injuries_involved": 0.97,
    "date_of_loss": 0.93,
    "claimant_name": 0.93,
    "estimated_damage_amount": 0.90,
    "loss_description": 0.82,
    # Loss run totals feed underwriting arithmetic.
    "total_incurred": 0.97,
    "total_paid": 0.97,
    "reserves": 0.97,
    "total_claims_count": 0.95,
    }
    DEFAULT_THRESHOLD = 0.90
    def _leaf_key(path: str) -> str:
    """"claims[0].total_incurred" -> "total_incurred"."""
    return path.split(".")[-1].split("[")[0]
    def gate_insurance_fields(fields: dict[str, dict]) -> dict:
    """Split extracted fields into trusted values and a review queue.
    Returns `trusted` as {path: value} containing only fields that cleared both
    the match and confidence gates. Any decision logic downstream reads
    `trusted` — never the raw extraction — so an unreadable field can never be
    mistaken for a value.
    """
    trusted: dict[str, Any] = {}
    review: dict[str, dict] = {}
    for path, field in fields.items():
    leaf = _leaf_key(path)
    threshold = INSURANCE_THRESHOLDS.get(
    path, INSURANCE_THRESHOLDS.get(leaf, DEFAULT_THRESHOLD)
    )
    acceptable = FIELD_MATCHES.get(path, FIELD_MATCHES.get(leaf, DEFAULT_MATCHES))
    match = field["match"]
    if match == "not_found" or field["value"] is None:
    review[path] = {**field, "reason": "not_found"}
    elif match not in acceptable:
    review[path] = {**field, "reason": f"match_{match}"}
    elif field["confidence"] < threshold:
    review[path] = {
    **field,
    "reason": "low_confidence",
    "threshold": threshold,
    "gap": round(threshold - field["confidence"], 3),
    }
    else:
    trusted[path] = field["value"]
    return {
    "trusted": trusted,
    "review_queue": review,
    "review_count": len(review),
    }

    The gap on a low-confidence entry is the distance to the threshold. A field at 0.94 against a 0.95 bar has gap: 0.01 and needs a glance; one at 0.58 needs the page reopened. Sorting the queue by gap puts the worst fields first.

    Part 1: ACORD 25 — Certificate of insurance

    This part covers the structure of the ACORD 25, a schema for extracting it, and a compliance check that runs on the gated result.

    What the ACORD 25 contains

    The ACORD 25 is a one-page standardized form certifying that coverage exists. The form is standardized but dense. It includes a producer block, an insured block, and up to six insurer rows labeled A through F, each with a National Association of Insurance Commissioners (NAIC) number. Separate coverage rows cover general liability, automobile liability, umbrella/excess, and workers’ compensation, each with its own policy number, effective and expiration dates, and limits. A certificate holder block and a description of operations round it out.

    Two structural details drive the schema below.

    Insurers A through F are a repeating structure, so they’re an array.

    ADDL INSD and SUBR WVD are per-coverage-row checkbox columns, not global flags. Additional insured status on general liability and additional insured status on auto are different contractual facts, and a contract that requires the first isn’t satisfied by the second. Modeling them as two top-level Booleans throws away which coverage they belong to, so they live inside each coverage object.

    Schema

    The API takes plain JSON Schema. Dates are "type": "string" with "format": "date". Booleans are "type": "boolean". Repeating structures use "type": "array" with items.

    COVERAGE_COMMON = {
    "policy_number": {"type": "string", "description": "Policy number for this coverage row."},
    "effective_date": {
    "type": "string",
    "format": "date",
    "description": "Policy effective date for this coverage row.",
    },
    "expiration_date": {
    "type": "string",
    "format": "date",
    "description": "Policy expiration date for this coverage row.",
    },
    "addl_insd": {
    "type": "boolean",
    "description": "Whether the ADDL INSD column is marked for this coverage row.",
    },
    "subr_wvd": {
    "type": "boolean",
    "description": "Whether the SUBR WVD column is marked for this coverage row.",
    },
    }
    acord25_schema = {
    "type": "object",
    "properties": {
    "producer_name": {
    "type": "string",
    "description": "Insurance agency or broker name from the PRODUCER block.",
    },
    "producer_contact": {
    "type": "string",
    "description": "Producer contact name, phone or email from the PRODUCER block.",
    },
    "insured_name": {
    "type": "string",
    "description": "Named insured company or individual from the INSURED block.",
    },
    "insured_address": {
    "type": "string",
    "description": "Insured mailing address from the INSURED block.",
    },
    "insurers": {
    "type": "array",
    "description": "All populated insurer rows A through F.",
    "items": {
    "type": "object",
    "properties": {
    "insurer_letter": {
    "type": "string",
    "description": "Row letter, A through F.",
    },
    "insurer_name": {"type": "string"},
    "naic_number": {
    "type": "string",
    "description": "NAIC number from the NAIC # column.",
    },
    },
    "required": ["insurer_name"],
    },
    },
    "coverages": {
    "type": "object",
    "description": "One entry per coverage row present on the certificate.",
    "properties": {
    "general_liability": {
    "type": "object",
    "properties": {
    **COVERAGE_COMMON,
    "each_occurrence_limit": {
    "type": "number",
    "description": "EACH OCCURRENCE limit for general liability.",
    },
    "aggregate_limit": {
    "type": "number",
    "description": "GENERAL AGGREGATE limit for general liability.",
    },
    },
    },
    "automobile_liability": {
    "type": "object",
    "properties": {
    **COVERAGE_COMMON,
    "combined_single_limit": {
    "type": "number",
    "description": "COMBINED SINGLE LIMIT for automobile liability.",
    },
    },
    },
    "umbrella_excess": {
    "type": "object",
    "properties": {
    **COVERAGE_COMMON,
    "each_occurrence_limit": {
    "type": "number",
    "description": "EACH OCCURRENCE limit for umbrella or excess liability.",
    },
    "aggregate_limit": {
    "type": "number",
    "description": "AGGREGATE limit for umbrella or excess liability.",
    },
    },
    },
    "workers_compensation": {
    "type": "object",
    "properties": {
    **COVERAGE_COMMON,
    "el_each_accident": {
    "type": "number",
    "description": "E.L. EACH ACCIDENT limit for workers' compensation.",
    },
    },
    },
    },
    },
    "certificate_holder_name": {
    "type": "string",
    "description": "Certificate holder name from the CERTIFICATE HOLDER block.",
    },
    "certificate_holder_address": {
    "type": "string",
    "description": "Certificate holder address from the CERTIFICATE HOLDER block.",
    },
    "description_of_operations": {
    "type": "string",
    "description": "Free text from the DESCRIPTION OF OPERATIONS / LOCATIONS / VEHICLES block.",
    },
    },
    "required": ["insured_name", "certificate_holder_name"],
    }

    Note what isn’t in the schema: an AM Best rating. The ACORD 25 has no AM Best column, only NAIC #. A field describing something the form doesn’t contain returns not_found at best, and at worst attracts a fuzzy match onto whatever nearby text looks like a rating. Financial strength ratings come from AM Best directly, joined on the NAIC number.

    The required list is deliberately short. A certificate carries only the coverage lines that were bought, so requiring general liability limits would mark a valid auto-only certificate as incomplete.

    Coverage compliance verification

    A common downstream use of an ACORD 25 is answering “Does this vendor’s certificate meet our minimum requirements?”

    A requirement has three possible outcomes. It can pass, it can fail because the certificate shows less coverage than required, or it can be indeterminate because the limit couldn’t be read with enough confidence to act on. Collapsing the third into the second is what wrongly blocks a compliant vendor; collapsing it into the first is what lets an uninsured one through.

    from dataclasses import dataclass
    from datetime import date, datetime
    @dataclass(frozen=True)
    class Requirement:
    label: str
    path: str
    minimum: float
    mandatory: bool = True
    DEFAULT_REQUIREMENTS = (
    Requirement("GL each occurrence", "coverages.general_liability.each_occurrence_limit", 1_000_000),
    Requirement("GL aggregate", "coverages.general_liability.aggregate_limit", 2_000_000),
    # Umbrella is not mandatory by default: many certificates legitimately carry
    # no umbrella row, and absence is not the same as insufficiency.
    Requirement("Umbrella aggregate", "coverages.umbrella_excess.aggregate_limit", 5_000_000, mandatory=False),
    )
    def check_coverage_compliance(
    gated: dict,
    requirements: tuple[Requirement, ...] = DEFAULT_REQUIREMENTS,
    expiry_warning_days: int = 30,
    ) -> dict:
    """Verify gated coverage limits against contractual minimums.
    Reads only `gated["trusted"]`. A limit that failed gating is reported as
    indeterminate rather than as zero, because "$0 of coverage" and "we could
    not read the limit" call for different actions.
    """
    trusted = gated["trusted"]
    review = gated["review_queue"]
    passed: list[str] = []
    failures: list[str] = []
    indeterminate: list[dict] = []
    warnings: list[str] = []
    for req in requirements:
    if req.path in trusted:
    actual = trusted[req.path]
    if actual >= req.minimum:
    passed.append(f"{req.label}: ${actual:,.0f}")
    else:
    failures.append(
    f"{req.label} ${actual:,.0f} below required ${req.minimum:,.0f}"
    )
    continue
    problem = review.get(req.path)
    if problem and problem["reason"] == "not_found":
    # The coverage row is absent from the certificate.
    if req.mandatory:
    failures.append(f"{req.label} not present on the certificate")
    else:
    passed.append(f"{req.label}: not required, not present")
    else:
    # Present but unreadable, or below the confidence bar.
    indeterminate.append(
    {
    "requirement": req.label,
    "path": req.path,
    "reason": problem["reason"] if problem else "unknown",
    "page": problem.get("page") if problem else None,
    "bbox": problem.get("bbox") if problem else None,
    }
    )
    today = date.today()
    for label, path in [
    ("GL", "coverages.general_liability.expiration_date"),
    ("Auto", "coverages.automobile_liability.expiration_date"),
    ("Workers Comp", "coverages.workers_compensation.expiration_date"),
    ]:
    raw = trusted.get(path)
    if raw is None:
    continue
    try:
    expires = datetime.fromisoformat(raw).date()
    except ValueError:
    indeterminate.append(
    {"requirement": f"{label} expiration", "path": path, "reason": "unparseable_date"}
    )
    continue
    days = (expires - today).days
    if days < 0:
    failures.append(f"{label} policy expired {abs(days)} days ago")
    elif days < expiry_warning_days:
    warnings.append(f"{label} policy expires in {days} days")
    return {
    # Compliant requires every check to have passed AND nothing unresolved.
    "compliant": not failures and not indeterminate,
    "decision": (
    "non_compliant" if failures
    else "needs_review" if indeterminate
    else "compliant"
    ),
    "passed": passed,
    "failures": failures,
    "indeterminate": indeterminate,
    "warnings": warnings,
    "insured_name": trusted.get("insured_name"),
    "certificate_holder": trusted.get("certificate_holder_name"),
    }

    The check returns three decisions rather than a Boolean. non_compliant is a finding the vendor has to fix. needs_review is work for a human, and every entry carries the page and bbox needed to open the certificate at the right region. compliant means every requirement was read with confidence and met.

    The addl_insd and subr_wvd Booleans inside each coverage object feed the same pattern: A contract requiring additional insured status on general liability reads trusted.get("coverages.general_liability.addl_insd"), and None means unread rather than absent.

    Part 2: First notice of loss (FNOL)

    FNOL forms vary widely. Every carrier has its own format, different lines of business need different fields, and the form is often completed in the field — on paper, over the phone, or in a mobile app. The schema leans on broad descriptive fields rather than trying to anticipate every layout.

    fnol_schema = {
    "type": "object",
    "properties": {
    "policy_number": {"type": "string", "description": "Insurance policy number."},
    "claim_number": {
    "type": "string",
    "description": "Claim number, if pre-assigned by the carrier.",
    },
    "date_of_loss": {
    "type": "string",
    "format": "date",
    "description": "Date the loss or incident occurred.",
    },
    "time_of_loss": {
    "type": "string",
    "description": "Time the loss occurred, as stated on the form.",
    },
    "loss_location": {
    "type": "string",
    "description": "Address or location where the loss occurred.",
    },
    "loss_description": {
    "type": "string",
    "description": "Free-text narrative describing what happened.",
    },
    "loss_type": {
    "type": "string",
    "description": "Type or category of loss, for example fire, theft, water damage, auto accident or injury.",
    },
    "claimant_name": {
    "type": "string",
    "description": "Name of the person or entity making the claim.",
    },
    "claimant_phone": {"type": "string", "description": "Claimant contact phone number."},
    "claimant_email": {"type": "string", "description": "Claimant contact email address."},
    "policyholder_name": {"type": "string", "description": "Named insured on the policy."},
    "reported_by": {
    "type": "string",
    "description": "Name of the person reporting the claim, if different from the claimant.",
    },
    "report_date": {
    "type": "string",
    "format": "date",
    "description": "Date the claim was reported.",
    },
    "estimated_damage_amount": {
    "type": "number",
    "description": "Estimated dollar amount of damage or loss, if provided.",
    },
    "injuries_involved": {
    "type": "boolean",
    "description": "Whether any injuries are involved, from the injuries question or checkbox.",
    },
    "police_report_number": {
    "type": "string",
    "description": "Police or incident report number, if available.",
    },
    },
    "required": ["policy_number", "date_of_loss", "claimant_name"],
    }

    Triage routing

    Once extracted and gated, FNOL data drives a triage decision: which adjuster handles it, how urgently, and whether anything needs escalating now.

    The injury flag has three states, and only two of them are safe to automate. True means injuries, and the claim escalates. False means the form says no injuries. None means unknown, whether the question was left blank or the read didn’t clear the gate. An unknown injury flag must never be routed as “no injuries.” That’s why injuries_involved carries one of the highest thresholds in INSURANCE_THRESHOLDS and appears in EXACT_ONLY.

    def triage_fnol(gated: dict) -> dict:
    """Route a new claim from gated FNOL data.
    Reads only `gated["trusted"]`, so a field that failed gating is `None` here
    and is treated as unknown rather than as a negative answer.
    """
    trusted = gated["trusted"]
    review = gated["review_queue"]
    priority = "standard"
    queue = "auto_assignment"
    flags: list[str] = []
    injuries = trusted.get("injuries_involved")
    if injuries is True:
    priority, queue = "urgent", "injury_claims"
    flags.append("injuries_reported")
    elif injuries is None:
    # Unknown, not negative. Never auto-assign an unresolved injury flag.
    priority, queue = "urgent", "manual_review"
    reason = review.get("injuries_involved", {}).get("reason", "absent")
    flags.append(f"injury_status_unknown_{reason}")
    damage = trusted.get("estimated_damage_amount")
    if damage is not None and damage > 100_000:
    priority = "urgent"
    flags.append(f"high_value_loss_{damage:,.0f}")
    elif damage is None and "estimated_damage_amount" in review:
    flags.append("damage_estimate_unreadable")
    if "policy_number" not in trusted:
    queue = "manual_lookup"
    flags.append("policy_number_unavailable")
    loss_date = trusted.get("date_of_loss")
    report_date = trusted.get("report_date")
    if loss_date and report_date:
    try:
    gap = (
    datetime.fromisoformat(report_date) - datetime.fromisoformat(loss_date)
    ).days
    if gap > 30:
    flags.append(f"late_report_{gap}_days")
    except ValueError:
    # Don't swallow this: a date that won't parse is a review item,
    # not a reason to skip the late-report check silently.
    flags.append("report_timing_unverifiable")
    return {
    "priority": priority,
    "queue": queue,
    "escalation_flags": flags,
    "review_count": gated["review_count"],
    "claim_summary": {
    "policy_number": trusted.get("policy_number"),
    "claimant": trusted.get("claimant_name"),
    "date_of_loss": trusted.get("date_of_loss"),
    "loss_type": trusted.get("loss_type"),
    "estimated_damage": damage,
    },
    }

    Note that an unknown injury flag routes to manual_review at urgent priority rather than falling through to auto-assignment. The asymmetry is deliberate: Having a human glance at a form that turned out to have no injuries costs minutes, while auto-assigning an injury claim to a property queue risks regulatory exposure.

    Part 3: Loss run reports

    A loss run lists every claim on a policy over a period. The content is straightforward: claim number, date of loss, description, reserves, payments, and status. Layouts vary widely between carriers. Some are landscape, some have merged cells, and some carry subtotals mid-table. The column totals matter for underwriting calculations, and they often sit in a merged footer row that breaks column alignment.

    loss_run_schema = {
    "type": "object",
    "properties": {
    "insured_name": {
    "type": "string",
    "description": "Named insured from the top of the loss run report.",
    },
    "policy_number": {
    "type": "string",
    "description": "Policy number the loss run covers.",
    },
    "report_period_from": {
    "type": "string",
    "format": "date",
    "description": "Start date of the reporting period.",
    },
    "report_period_to": {
    "type": "string",
    "format": "date",
    "description": "End date of the reporting period.",
    },
    "line_of_business": {
    "type": "string",
    "description": "Line of business, for example General Liability, Auto, Workers Comp or Property.",
    },
    "issuing_carrier": {
    "type": "string",
    "description": "Carrier that issued this loss run.",
    },
    "claims": {
    "type": "array",
    "description": "One entry per claim row in the loss table. Exclude subtotal and total rows.",
    "items": {
    "type": "object",
    "properties": {
    "claim_number": {"type": "string"},
    "date_of_loss": {"type": "string", "format": "date"},
    "claimant_name": {"type": "string"},
    "description": {"type": "string"},
    "total_incurred": {"type": "number"},
    "total_paid": {"type": "number"},
    "reserves": {"type": "number"},
    "status": {
    "type": "string",
    "description": "Claim status exactly as printed, for example open, closed, reopened or pending.",
    },
    },
    "required": ["claim_number"],
    },
    },
    "total_claims_count": {
    "type": "integer",
    "description": "Total number of claims stated in the report summary.",
    },
    "total_incurred": {
    "type": "number",
    "description": "Total incurred across all claims from the report summary.",
    },
    "total_paid": {
    "type": "number",
    "description": "Total paid losses across all claims from the report summary.",
    },
    "open_claims_count": {
    "type": "integer",
    "description": "Number of claims with open status, from the report summary.",
    },
    },
    "required": ["insured_name", "policy_number"],
    }

    Two schema notes follow.

    status is a free string, not an enum. Constraining it to ["open", "closed"] fails on real loss runs, which also carry reopened, closed-reopened, pending, and carrier-specific variants. Because schemas are closed and enums are strict, an out-of-vocabulary status resolves to nothing, silently dropping the status of a legitimate claim row. Capture what’s printed and normalize in code, where the mapping is visible and testable.

    The array description tells the extraction to exclude subtotal rows. That one sentence guards against the merged-footer problem and keeps the reconciliation below from being circular.

    CLOSED_STATUSES = {"closed", "closed-reopened", "settled"}
    OPEN_STATUSES = {"open", "reopened", "pending", "active"}
    def normalise_status(raw: str | None) -> str | None:
    """Map a printed status onto open/closed, or None when unrecognised."""
    if raw is None:
    return None
    key = str(raw).strip().lower().replace("_", "-")
    if key in CLOSED_STATUSES:
    return "closed"
    if key in OPEN_STATUSES:
    return "open"
    return None
    def validate_loss_run(gated: dict, tolerance_pct: float = 0.01) -> tuple[bool, list[str]]:
    """Reconcile the claim rows against the report's own summary totals.
    A summary total that failed gating produces an "unverifiable" note rather
    than a mismatch against zero.
    """
    trusted = gated["trusted"]
    review = gated["review_queue"]
    notes: list[str] = []
    row_prefixes = sorted(
    {p.split(".")[0] for p in {**trusted, **review} if p.startswith("claims[")},
    key=lambda p: int(p[len("claims[") : -1]),
    )
    summed = 0.0
    rows_summed = 0
    unreadable_rows: list[str] = []
    open_rows = 0
    unknown_status_rows: list[str] = []
    for prefix in row_prefixes:
    incurred = trusted.get(f"{prefix}.total_incurred")
    if incurred is None:
    unreadable_rows.append(prefix)
    else:
    summed += incurred
    rows_summed += 1
    status = normalise_status(trusted.get(f"{prefix}.status"))
    if status == "open":
    open_rows += 1
    elif status is None:
    unknown_status_rows.append(prefix)
    if unreadable_rows:
    notes.append(
    f"{len(unreadable_rows)} claim row(s) had an unreadable incurred amount "
    f"({', '.join(unreadable_rows)}) — the sum below is incomplete"
    )
    if unknown_status_rows:
    notes.append(
    f"{len(unknown_status_rows)} claim row(s) had an unrecognised status "
    f"({', '.join(unknown_status_rows)}) — add the printed value to the status maps"
    )
    stated_incurred = trusted.get("total_incurred")
    if stated_incurred is None:
    notes.append(
    "Report total incurred was not readable — cannot reconcile the claim rows"
    )
    elif not unreadable_rows:
    tolerance = max(abs(stated_incurred) * tolerance_pct, 100)
    if abs(summed - stated_incurred) > tolerance:
    notes.append(
    f"Total incurred mismatch: rows sum to ${summed:,.0f}, "
    f"report states ${stated_incurred:,.0f}"
    )
    stated_count = trusted.get("total_claims_count")
    if stated_count is not None and len(row_prefixes) != stated_count:
    notes.append(
    f"Claim count mismatch: {len(row_prefixes)} rows extracted, "
    f"report states {stated_count}"
    )
    stated_open = trusted.get("open_claims_count")
    if stated_open is not None and not unknown_status_rows and open_rows != stated_open:
    notes.append(
    f"Open claim count mismatch: {open_rows} open rows, report states {stated_open}"
    )
    return not notes, notes

    This reconciliation gets two things right that the obvious version doesn’t. It only compares against a total it actually read: An unreadable summary produces “cannot reconcile” rather than a spurious mismatch against zero. And it won’t reconcile a partial sum: If any row’s incurred amount failed gating, the sum is known to be incomplete, so comparing it to the stated total would manufacture a discrepancy that isn’t there.

    A genuine mismatch doesn’t always mean extraction failed. It can mean a footnote or subtotal row was captured as a claim. The bbox on each claims[n].total_incurred shows exactly which rows were counted, which makes that distinction a few seconds’ work instead of a reread.

    Putting it together

    process_insurance_document runs the whole flow: It extracts, gates, and then applies the decision logic for the document type.

    from enum import Enum
    class InsuranceDocType(Enum):
    ACORD_25 = "acord_25"
    FNOL = "fnol"
    LOSS_RUN = "loss_run"
    SCHEMAS = {
    InsuranceDocType.ACORD_25: acord25_schema,
    InsuranceDocType.FNOL: fnol_schema,
    InsuranceDocType.LOSS_RUN: loss_run_schema,
    }
    def process_insurance_document(file_path: str, doc_type: InsuranceDocType) -> dict:
    """Extract, gate, then apply the document-specific decision logic.
    Gating always runs before any decision, so nothing downstream can mistake an
    unreadable field for a value.
    """
    try:
    response = safe_extract(file_path, SCHEMAS[doc_type])
    except DocumentUnprocessableError as e:
    return {
    "doc_type": doc_type.value,
    "file": file_path,
    "status": "unprocessable",
    "error": str(e),
    }
    fields = parse_insurance_result(response)
    gated = gate_insurance_fields(fields)
    result = {
    "doc_type": doc_type.value,
    "file": file_path,
    "status": "processed",
    "review_queue": gated["review_queue"],
    "review_count": gated["review_count"],
    "request_id": response.get("requestId"),
    "pages_processed": response.get("metrics", {}).get("pagesProcessed"),
    }
    if doc_type is InsuranceDocType.ACORD_25:
    result["compliance"] = check_coverage_compliance(gated)
    elif doc_type is InsuranceDocType.FNOL:
    result["triage"] = triage_fnol(gated)
    elif doc_type is InsuranceDocType.LOSS_RUN:
    reconciled, notes = validate_loss_run(gated)
    result["reconciled"] = reconciled
    result["reconciliation_notes"] = notes
    return result
    cert = process_insurance_document("vendor_cert.pdf", InsuranceDocType.ACORD_25)
    print(cert["compliance"]["decision"]) # `compliant` | `non_compliant` | `needs_review`.
    print(cert["compliance"]["failures"])
    print(cert["compliance"]["indeterminate"]) # Each entry carries `page` + `bbox`.
    print(f"{cert['review_count']} field(s) queued, {cert['pages_processed']} page(s) billed")

    pages_processed is worth surfacing from the start. It’s the billed quantity, and on loss runs, it’s the difference between a routine renewal check and a surprising invoice.

    What the bounding boxes enable in a review UI

    Every entry in review_queue and every indeterminate compliance item carries a page and a bbox. A review interface can open the certificate at that page, draw the box, show the extracted value with its match type and confidence, and let the reviewer confirm or correct in one action.

    For compliance work, this turns review from “read the certificate and find the aggregate limit” into “here’s the number we’re unsure about,” and it leaves an auditable trail. When a vendor disputes a noncompliance finding, the answer is the region of their own certificate the limit was read from, not an assertion about a model.

    source_bboxes covers the harder cases, such as a limit printed across two lines or a total read from a column, by reporting every contributing block rather than one box that only partly explains the value.

    A note on document storage

    Certificates, claims files, and loss runs carry commercially sensitive and often personal data, so storage is worth settling before production traffic.

    Processing runs (both the input document and the extracted output) are stored as a product feature so run history can be viewed and managed. Runs can be deleted, and they expire automatically after a retention period that depends on the plan.

    Whether anything is kept beyond that depends on the plan. Data retention is a plan-level setting, indicated for the subscription. It isn’t a request parameter, so nothing in the code above changes it. On plans where retention is enabled, a copy is also kept internally and used to operate, improve, and develop the service, including to train and evaluate models; that copy is separate from run history and isn’t removed by deleting a run. On plans where retention isn’t enabled, documents aren’t retained for those purposes.

    The plan a claims pipeline runs on matters. An FNOL form carries a named claimant, a loss narrative, and, frequently, injury information. The API privacy policy states that Nutrient doesn’t seek special-category or sensitive personal data, naming health data specifically, for retention or training. It also says such data shouldn’t be submitted for those purposes. An injury claim is health data by any reading, so a claims intake pipeline belongs on a plan without data retention.

    If a document has already gone through on the wrong plan, deletion is available: The policy commits to removing a file from storage, backups, and any training dataset on request to legal@nutrient.io.

    The privacy guide and the API privacy policy are authoritative, and the security guide covers transport and access controls.

    Schema keyword limits

    The API accepts only these JSON Schema keywords: type (one of object, array, string, number, integer, boolean), properties, required, items, description, enum on string fields, and format: "date" on string fields.

    Everything else is rejected — $ref, $defs, allOf/anyOf/oneOf, validation ranges such as minimum and maxLength, conditional schemas, and any format value other than date. Schemas are also closed: The response contains only declared properties, as though additionalProperties: false were set, and sending additionalProperties explicitly causes a rejection.

    Two consequences follow for the schemas above. COVERAGE_COMMON is repeated by dictionary spread rather than referenced with $ref, because $ref is rejected. And coverage minimums can’t be expressed as schema minimum values, which is why they live in DEFAULT_REQUIREMENTS. The schema declares shape, the requirements declare policy, and different people edit each.

    What’s next

    Marija Trpkovic

    Marija Trpkovic

    Product Marketing Manager

    Marija is a product marketing manager who likes to launch new products and features and target the right people with them. Outside of work, she likes spending time outdoors with her family and dogs.

    Free to start Start extracting structured data