---
title: "Insurance document extraction: ACORD 25, FNOL, loss runs"
canonical_url: "https://www.nutrient.io/blog/insurance-document-extraction/"
md_url: "https://www.nutrient.io/blog/insurance-document-extraction.md"
last_updated: "2026-11-04T08:47:28.278Z"
description: "Extract data from ACORD 25 certificates, FNOL forms, and loss runs in Python, with compliance checks that tell a failed requirement from an unreadable one."
---

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](https://www.nutrient.io/api/data-extraction-api/insurance/).

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.

**TL;DR**

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](https://www.nutrient.io/api/data-extraction-api/)

- 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.

```python

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:

| Mode         | Cost per page | What it does                                              |
| ------------ | ------------- | --------------------------------------------------------- |
| `text`       | 1 credit      | Reads existing text. Born-digital documents only, no OCR. |
| `structure`  | 1.5 credits   | Runs OCR before producing output. Scans and images.       |
| `understand` | 9 credits     | Adds form semantics and key-value relationships.          |
| `agentic`    | 18 credits    | Vision 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](https://www.nutrient.io/guides/dws-data-extraction/parsing/processing-modes.md) 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.

```python

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.

```python

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.

```python

# 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`.

```python

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.

```python

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.

```python

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`.

```python

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.

```python

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.

```python

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.

```python

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](https://www.nutrient.io/legal/privacy/#nutrient-api) 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](https://www.nutrient.io/guides/dws-data-extraction/privacy.md) guide and the [API privacy policy](https://www.nutrient.io/legal/privacy/#nutrient-api) are authoritative, and the [security](https://www.nutrient.io/guides/dws-data-extraction/security.md) 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

- **Test in Studio** — The [insurance extraction API page](https://www.nutrient.io/api/data-extraction-api/insurance/) has a live browser demo, so you can try a certificate or FNOL form before writing code

- **Runnable samples** — [nutrient-extraction-samples](https://github.com/PSPDFKit/nutrient-extraction-samples) has self-contained Python demos with cached live responses, so the response shape can be inspected without spending credits

- **Accuracy benchmarks** — [200 PDFs with hand-annotated ground truth](https://www.nutrient.io/api/data-extraction-api/benchmarks/), rescored on every release

- **Schema reference** — [Defining a schema](https://www.nutrient.io/guides/dws-data-extraction/extract/define-a-schema.md) lists every supported keyword and the ones that are rejected

## Related reading

- [What should a document extraction confidence score actually tell you?](https://www.nutrient.io/blog/document-extraction-confidence-scores.md) — Why a source-grounded score behaves differently from a model-decisiveness score

- [PDF data extraction developer guide](https://www.nutrient.io/blog/pdf-data-extraction-developer-guide.md) — Parse modes, schema design, and how citations fit the wider pipeline

- [Citations and confidence](https://www.nutrient.io/guides/dws-data-extraction/extract/citations-and-confidence.md) — The full citation object, match labels, and coordinate space
---

## Related pages

- [The business case for accessibility: Five ways it drives enterprise value](/blog/5-ways-accessibility-drives-enterprise-value.md)
- [Accessibility Untangled Why It Matters Guide](/blog/accessibility-untangled-why-it-matters-guide.md)
- [Adobe Pdf Embed Api Alternatives](/blog/adobe-pdf-embed-api-alternatives.md)
- [Adobe Pdf Services Api Alternatives](/blog/adobe-pdf-services-api-alternatives.md)
- [Advanced Techniques For React Native Ui Components](/blog/advanced-techniques-for-react-native-ui-components.md)
- [`vector_store` holds your indexed documents (see the multimodal RAG post](/blog/agentic-rag.md)
- [Ai Assistant Agent Governance](/blog/ai-assistant-agent-governance.md)
- [How to build an AI agent for contract redlining against a compliance playbook](/blog/ai-contract-redlining-compliance-playbook.md)
- [Ai Document Automation Extraction To Action](/blog/ai-document-automation-extraction-to-action.md)
- [Ai Document Workflows Ocr Compliance Heavy Teams](/blog/ai-document-workflows-ocr-compliance-heavy-teams.md)
- [Ai Legal Assistant Document Authoring](/blog/ai-legal-assistant-document-authoring.md)
- [Ai Schema Generator Document Extraction](/blog/ai-schema-generator-document-extraction.md)
- [Amazon Textract Alternatives](/blog/amazon-textract-alternatives.md)
- [Start (clears any prior buffer), navigate the document, then stop into a file.](/blog/android-faster-pdf-rendering.md)
- [Android Pdf Out Of Memory Handling](/blog/android-pdf-out-of-memory-handling.md)
- [Angular File Viewer Pdf Image Office Files](/blog/angular-file-viewer-pdf-image-office-files.md)
- [Approval Workflow Software](/blog/approval-workflow-software.md)
- [Approvals Matrix](/blog/approvals-matrix.md)
- [Apryse To Nutrient Migration](/blog/apryse-to-nutrient-migration.md)
- [Auto Tagging And Document Accessibility In Dotnet Sdk](/blog/auto-tagging-and-document-accessibility-in-dotnet-sdk.md)
- [Simple PII redaction.](/blog/automated-pii-removal.md)
- [Azure Document Intelligence Alternatives](/blog/azure-document-intelligence-alternatives.md)
- [schema.py.](/blog/bank-statement-to-excel.md)
- [Best Ai Document Workflow Platforms](/blog/best-ai-document-workflow-platforms.md)
- [Best Csharp Pdf Library](/blog/best-csharp-pdf-library.md)
- [Best Document Ai Platforms](/blog/best-document-ai-platforms.md)
- [Best Document Classification Platforms](/blog/best-document-classification-platforms.md)
- [Best document parser for RAG: LlamaParse vs. Unstructured vs. Reducto vs. Nutrient](/blog/best-document-parser-llamaparse-unstructured-reducto.md)
- [Best Document Parsing Apis](/blog/best-document-parsing-apis.md)
- [Best Document Viewers](/blog/best-document-viewers.md)
- [Best Invoice Data Extraction Apis](/blog/best-invoice-data-extraction-apis.md)
- [Best Llm Document Understanding Platforms](/blog/best-llm-document-understanding-platforms.md)
- [Best Multilingual Ocr Software](/blog/best-multilingual-ocr-software.md)
- [Best Pdf Accessibility Apis](/blog/best-pdf-accessibility-apis.md)
- [Best Pdf Parsers For Rag](/blog/best-pdf-parsers-for-rag.md)
- [Best Pdf Processing Apis](/blog/best-pdf-processing-apis.md)
- [Best Pdf Viewer Apis](/blog/best-pdf-viewer-apis.md)
- [Best Salesforce Document Generation Apps](/blog/best-salesforce-document-generation-apps.md)
- [Best Secure Document Collaboration Platforms](/blog/best-secure-document-collaboration-platforms.md)
- [or](/blog/best-vue-pdf-viewers.md)
- [Bpm Guide](/blog/bpm-guide.md)
- [Bpm Tools](/blog/bpm-tools.md)
- [Build Vs Buy Document Extraction](/blog/build-vs-buy-document-extraction.md)
- [Business Automation](/blog/business-automation.md)
- [Capex Vs Opex](/blog/capex-vs-opex.md)
- [The CEO’s AI playbook: Why decision architecture beats model selection](/blog/ceo-ai-playbook-decision-architecture.md)
- [1. Extract and chunk the PDF.](/blog/chat-with-pdf.md)
- [Complete Guide To Pdfjs](/blog/complete-guide-to-pdfjs.md)
- [Construction Document Data Extraction](/blog/construction-document-data-extraction.md)
- [Convert One Drive Files To Pdf In Sharepoint](/blog/convert-one-drive-files-to-pdf-in-sharepoint.md)
- [Create And Edit Pdfs In Flutter](/blog/create-and-edit-pdfs-in-flutter.md)
- [Create Pdfs With React](/blog/create-pdfs-with-react.md)
- [Creating A Document Scanner With Ocr In Python](/blog/creating-a-document-scanner-with-ocr-in-python.md)
- [Creating And Filling Pdf Forms Programmatically In Javascript](/blog/creating-and-filling-pdf-forms-programmatically-in-javascript.md)
- [The CTO’s AI playbook: Why accountability architecture beats orchestration](/blog/cto-ai-playbook-accountability-architecture.md)
- [Nutrient document decision: Why we built a decision model for documents](/blog/decision-model-for-documents.md)
- [Digital Signatures](/blog/digital-signatures.md)
- [Digital Workflow Automation](/blog/digital-workflow-automation.md)
- [Docling Alternatives](/blog/docling-alternatives.md)
- [Document Ai Vs Ocr](/blog/document-ai-vs-ocr.md)
- [Document Authoring Audit Trail](/blog/document-authoring-audit-trail.md)
- [Document Authoring Spreadsheet Editor](/blog/document-authoring-spreadsheet-editor.md)
- [Document Extraction Confidence Scores](/blog/document-extraction-confidence-scores.md)
- [Document Extraction For Underwriting](/blog/document-extraction-for-underwriting.md)
- [Document Viewer](/blog/document-viewer.md)
- [Document Watermarking](/blog/document-watermarking.md)
- [Debian/Ubuntu.](/blog/easyocr-vs-tesseract-python.md)
- [Electronic signature API: How to sign PDFs programmatically](/blog/electronic-signature-api.md)
- [Emerging threats: Your logging system may be an agentic threat vector](/blog/emerging-threats-your-logging-system.md)
- [Q3 sales](/blog/excel-to-markdown-for-llms.md)
- [Extend Alternatives](/blog/extend-alternatives.md)
- [Extract Patient Data On Premises](/blog/extract-patient-data-on-premises.md)
- [Extract Tables From Complex Pdfs](/blog/extract-tables-from-complex-pdfs.md)
- [app.py](/blog/extract-text-from-pdf-using-python.md)
- [Stricter thresholds for fields that move money. Calibrate on your own sample.](/blog/extraction-hallucination-detection.md)
- [Fillable Pdf](/blog/fillable-pdf.md)
- [Google Document Ai Alternatives](/blog/google-document-ai-alternatives.md)
- [How To Add Digital Signature To Pdf Using React](/blog/how-to-add-digital-signature-to-pdf-using-react.md)
- [How To Build A Dotnet Maui Pdf Viewer](/blog/how-to-build-a-dotnet-maui-pdf-viewer.md)
- [How To Build A Flutter Pdf Viewer](/blog/how-to-build-a-flutter-pdf-viewer.md)
- [or](/blog/how-to-build-a-javascript-pdf-viewer-with-pdfjs.md)
- [or](/blog/how-to-build-a-javascript-pdf-viewer.md)
- [How To Build A Nextjs Pdf Viewer](/blog/how-to-build-a-nextjs-pdf-viewer.md)
- [How To Build A Powerpoint Viewer Using Javascript](/blog/how-to-build-a-powerpoint-viewer-using-javascript.md)
- [Using Yarn](/blog/how-to-build-a-react-excel-viewer.md)
- [How To Build A React Native Pdf Viewer](/blog/how-to-build-a-react-native-pdf-viewer.md)
- [How To Build A React Powerpoint Viewer](/blog/how-to-build-a-react-powerpoint-viewer.md)
- [Using Yarn](/blog/how-to-build-a-react-word-viewer.md)
- [How To Build A Reactjs File Viewer](/blog/how-to-build-a-reactjs-file-viewer.md)
- [How To Build A Reactjs Pdf Viewer With React Pdf](/blog/how-to-build-a-reactjs-pdf-viewer-with-react-pdf.md)
- [or](/blog/how-to-build-a-reactjs-pdf-viewer.md)
- [How To Build A Reactjs Viewer With Pdfjs](/blog/how-to-build-a-reactjs-viewer-with-pdfjs.md)
- [How To Build A Vuejs Pdf Viewer With Pdfjs](/blog/how-to-build-a-vuejs-pdf-viewer-with-pdfjs.md)
- [How To Build A Vuejs Pdf Viewer](/blog/how-to-build-a-vuejs-pdf-viewer.md)
- [How To Build An Android Pdf Viewer](/blog/how-to-build-an-android-pdf-viewer.md)
- [How To Build An Angular Pdf Viewer With Ng2 Pdf Viewer](/blog/how-to-build-an-angular-pdf-viewer-with-ng2-pdf-viewer.md)
- [How To Build An Angular Pdf Viewer With Pdfjs](/blog/how-to-build-an-angular-pdf-viewer-with-pdfjs.md)
- [How To Convert Docx To Pdf Using Javascript](/blog/how-to-convert-docx-to-pdf-using-javascript.md)
- [How To Convert Docx To Pdf Using Python](/blog/how-to-convert-docx-to-pdf-using-python.md)
- [How To Convert Html To Pdf Using Html2pdf](/blog/how-to-convert-html-to-pdf-using-html2pdf.md)
- [How To Convert Html To Pdf Using React](/blog/how-to-convert-html-to-pdf-using-react.md)
- [How To Convert Html To Pdf Using Wkhtmltopdf And Csharp](/blog/how-to-convert-html-to-pdf-using-wkhtmltopdf-and-csharp.md)
- [or](/blog/how-to-convert-html-to-pdf-using-wkhtmltopdf-and-python.md)
- [How To Convert Html To Pptx](/blog/how-to-convert-html-to-pptx.md)
- [How To Convert Pdf To Json](/blog/how-to-convert-pdf-to-json.md)
- [Quarterly report](/blog/how-to-convert-pdf-to-markdown-using-python.md)
- [How To Convert Word To Pdf In Nodejs](/blog/how-to-convert-word-to-pdf-in-nodejs.md)
- [How To Convert Word To Pdf Using Javascript](/blog/how-to-convert-word-to-pdf-using-javascript.md)
- [or](/blog/how-to-create-a-react-js-signature-pad.md)
- [How To Create Pdfs With React To Pdf](/blog/how-to-create-pdfs-with-react-to-pdf.md)
- [How to digitally sign PDFs in C# with Nutrient .NET SDK](/blog/how-to-digitally-sign-pdf-in-csharp.md)
- [How To Edit Pdfs Using Ios Pdf Library](/blog/how-to-edit-pdfs-using-ios-pdf-library.md)
- [How To Embed A Pdf Viewer In Your Website](/blog/how-to-embed-a-pdf-viewer-in-your-website.md)
- [How To Extract Tables From Pdf And Images](/blog/how-to-extract-tables-from-pdf-and-images.md)
- [How To Generate Pdf From Html With Nodejs](/blog/how-to-generate-pdf-from-html-with-nodejs.md)
- [How to generate PDFs in C# with Nutrient .NET SDK](/blog/how-to-generate-pdf-in-csharp.md)
- [base_url tells WeasyPrint where to resolve relative asset paths](/blog/how-to-generate-pdf-reports-from-html-in-python.md)
- [How To Merge Pdfs Using Javascript](/blog/how-to-merge-pdfs-using-javascript.md)
- [How To Ocr Pdfs In Linux](/blog/how-to-ocr-pdfs-in-linux.md)
- [How To Print Pdf In Csharp](/blog/how-to-print-pdf-in-csharp.md)
- [How To Programmatically Create And Fill Pdf Form In Angular](/blog/how-to-programmatically-create-and-fill-pdf-form-in-angular.md)
- [C# barcode scanner: How to scan and read 1D barcodes and QR codes](/blog/how-to-scan-barcodes-in-csharp.md)
- [Open an image.](/blog/how-to-use-tesseract-ocr-in-python.md)
- [From an HTML string.](/blog/html-in-pdf-format.md)
- [Html To Pdf In Javascript](/blog/html-to-pdf-in-javascript.md)
- [Ide Text Editors Cpp Large Scale](/blog/ide-text-editors-cpp-large-scale.md)
- [Intelligent Data Extraction](/blog/intelligent-data-extraction.md)
- [Invoice Approval Software](/blog/invoice-approval-software.md)
- [Javascript Document Editor](/blog/javascript-document-editor.md)
- [Javascript Pdf Editors](/blog/javascript-pdf-editors.md)
- [Javascript Pdf Libraries](/blog/javascript-pdf-libraries.md)
- [Keep Document Ai Private](/blog/keep-document-ai-private.md)
- [Kyc Onboarding Salesforce](/blog/kyc-onboarding-salesforce.md)
- [Landing Ai Alternatives](/blog/landing-ai-alternatives.md)
- [Langextract Vs Llamaindex Extraction Comparison](/blog/langextract-vs-llamaindex-extraction-comparison.md)
- [Linearized Pdf](/blog/linearized-pdf.md)
- [Uses OpenAI by default — set OPENAI_API_KEY.](/blog/llamaindex-vs-langchain-rag.md)
- [Llamaindex Vs Langchain Vs Haystack](/blog/llamaindex-vs-langchain-vs-haystack.md)
- [Llamaindex Workflows Vs Langgraph](/blog/llamaindex-workflows-vs-langgraph.md)
- [Llamaparse Alternatives](/blog/llamaparse-alternatives.md)
- [Llm Vs Document Parsing Api](/blog/llm-vs-document-parsing-api.md)
- [Low Code No Code Document Integrations](/blog/low-code-no-code-document-integrations.md)
- [One segment per document found, in page order:](/blog/mailroom-automation-api.md)
- [Material Requisition](/blog/material-requisition.md)
- [or](/blog/merge-pdfs.md)
- [Migrating Observability Hyperdx To Dash0](/blog/migrating-observability-hyperdx-to-dash0.md)
- [Swift Package Manager](/blog/mobile-pdf-sdk.md)
- [Numeric and identifier fields: exact single-block matches only.](/blog/mortgage-document-processing.md)
- [`elements` come from your document parser — each has a type and content.](/blog/multimodal-rag.md)
- [Multipage Extraction Page Boundaries](/blog/multipage-extraction-page-boundaries.md)
- [Nutrient Flutter 6 Bindings Api](/blog/nutrient-flutter-6-bindings-api.md)
- [Nutrient Flutter Bindings Architecture](/blog/nutrient-flutter-bindings-architecture.md)
- [Nutrient Vs Conga Composer](/blog/nutrient-vs-conga-composer.md)
- [Ocr Api Comparison](/blog/ocr-api-comparison.md)
- [Online Document Viewer](/blog/online-document-viewer.md)
- [Open Pdf In Your Web App](/blog/open-pdf-in-your-web-app.md)
- [Operations Management Software](/blog/operations-management-software.md)
- [Optical Character Recognition In Scanned Pdfs](/blog/optical-character-recognition-in-scanned-pdfs.md)
- [CPU](/blog/paddleocr-python-guide.md)
- [PDF accessibility for developers: Meeting WCAG 2.2, Section 508, and PDF/UA with an SDK](/blog/pdf-accessibility.md)
- [Extract data from PDF files: A developer guide to structured data from PDFs and scans](/blog/pdf-data-extraction-developer-guide.md)
- [Pdf Extraction Benchmark Opendataloader Bench](/blog/pdf-extraction-benchmark-opendataloader-bench.md)
- [Pdf Extraction Document Case Studies](/blog/pdf-extraction-document-case-studies.md)
- [Pdf Page Labels](/blog/pdf-page-labels.md)
- [Pdf Sdk Compliance Security Checklist](/blog/pdf-sdk-compliance-security-checklist.md)
- [Pdf Sdk Performance Benchmark](/blog/pdf-sdk-performance-benchmark.md)
- [Pdf Ua Compliance Guide](/blog/pdf-ua-compliance-guide.md)
- [Pdf Ua Validation](/blog/pdf-ua-validation.md)
- [Pdfjs Accessibility Structtree Printing](/blog/pdfjs-accessibility-structtree-printing.md)
- [Pdfjs Advanced Loading Streaming Workers](/blog/pdfjs-advanced-loading-streaming-workers.md)
- [Pdfjs Annotation Editor Layer](/blog/pdfjs-annotation-editor-layer.md)
- [Pdfjs Area Annotations Canvas Capture](/blog/pdfjs-area-annotations-canvas-capture.md)
- [Pdfjs Coordinate Systems Pdf To Screen](/blog/pdfjs-coordinate-systems-pdf-to-screen.md)
- [Pdfjs Document Outline Bookmarks Metadata](/blog/pdfjs-document-outline-bookmarks-metadata.md)
- [Pdfjs Eventbus Guide](/blog/pdfjs-eventbus-guide.md)
- [macOS](/blog/pdfjs-file-format-conversion-to-pdf.md)
- [macOS](/blog/pdfjs-generating-pdf-thumbnails-pdf2pic.md)
- [Pdfjs Limitations Commercial Upgrade](/blog/pdfjs-limitations-commercial-upgrade.md)
- [Pdfjs Native Annotation Layer Forms](/blog/pdfjs-native-annotation-layer-forms.md)
- [Pdfjs Navigation Zoom Rotation](/blog/pdfjs-navigation-zoom-rotation.md)
- [Pdfjs Pdf Page Manipulation Pdf Lib](/blog/pdfjs-pdf-page-manipulation-pdf-lib.md)
- [Pdfjs React Viewer Setup](/blog/pdfjs-react-viewer-setup.md)
- [Pdfjs Rendering Overlays React Portals](/blog/pdfjs-rendering-overlays-react-portals.md)
- [Pdfjs Server Side Text Extraction](/blog/pdfjs-server-side-text-extraction.md)
- [Pdfjs Sticky Note Annotations](/blog/pdfjs-sticky-note-annotations.md)
- [Pdfjs Text Highlight Annotations](/blog/pdfjs-text-highlight-annotations.md)
- [Pdfjs Text Search Pdffindcontroller](/blog/pdfjs-text-search-pdffindcontroller.md)
- [Pdfjs Thumbnail Sidebar](/blog/pdfjs-thumbnail-sidebar.md)
- [Pdfplumber Python Guide](/blog/pdfplumber-python-guide.md)
- [People Process Tools](/blog/people-process-tools.md)
- [Process Flows](/blog/process-flows.md)
- [React Docx Editor](/blog/react-docx-editor.md)
- [React Native Pdf Annotation](/blog/react-native-pdf-annotation.md)
- [React Pdf Annotation Layer Forms](/blog/react-pdf-annotation-layer-forms.md)
- [React Pdf Custom Rendering Hooks](/blog/react-pdf-custom-rendering-hooks.md)
- [Using Yarn](/blog/react-pdf-editor.md)
- [React Pdf Loading States Errors Passwords](/blog/react-pdf-loading-states-errors-passwords.md)
- [React Pdf Non Latin Fonts Special Pdfs](/blog/react-pdf-non-latin-fonts-special-pdfs.md)
- [React Pdf Outline Table Of Contents](/blog/react-pdf-outline-table-of-contents.md)
- [React Pdf Performance Optimization](/blog/react-pdf-performance-optimization.md)
- [React Pdf Setup Basic Rendering](/blog/react-pdf-setup-basic-rendering.md)
- [React Pdf Text Layer Custom Renderer](/blog/react-pdf-text-layer-custom-renderer.md)
- [React Pdf Thumbnails Page Navigation](/blog/react-pdf-thumbnails-page-navigation.md)
- [Reducto Alternatives](/blog/reducto-alternatives.md)
- [Requisition System](/blog/requisition-system.md)
- [labels.py](/blog/route-documents-automatically-classify-api.md)
- [or](/blog/sample-blog-updated.md)
- [Sdk Product Updates Q2 2026](/blog/sdk-product-updates-q2-2026.md)
- [System Of Record Vs Source Of Truth](/blog/system-of-record-vs-source-of-truth.md)
- [Add DWS MCP Server to your Claude Code project.](/blog/teaching-llms-to-read-pdfs.md)
- [Open an image file.](/blog/tesseract-python-guide.md)
- [The Six Best Pdf Generator Apis](/blog/the-six-best-pdf-generator-apis.md)
- [Define the HTML part of the document.](/blog/top-10-ways-to-generate-pdfs-in-python.md)
- [Top 5 Javascript Pdf Viewers](/blog/top-5-javascript-pdf-viewers.md)
- [Top Js Pdf Libraries](/blog/top-js-pdf-libraries.md)
- [Top Ten Ways To Convert Html To Pdf](/blog/top-ten-ways-to-convert-html-to-pdf.md)
- [Unstructured Alternatives](/blog/unstructured-alternatives.md)
- [Using Javascript In Pdf Form Fields](/blog/using-javascript-in-pdf-form-fields.md)
- [Vector Pdf](/blog/vector-pdf.md)
- [Wcag2 Accessibility Requirements Documents](/blog/wcag2-accessibility-requirements-documents.md)
- [Web Sdk Is Now Headless](/blog/web-sdk-is-now-headless.md)
- [What Are Annotations](/blog/what-are-annotations.md)
- [What Is A Vpat](/blog/what-is-a-vpat.md)
- [What Is Bpms](/blog/what-is-bpms.md)
- [What Is Business Logic](/blog/what-is-business-logic.md)
- [What Is Document Processing](/blog/what-is-document-processing.md)
- [What Is Intelligent Document Processing](/blog/what-is-intelligent-document-processing.md)
- [What Is Ocr Invoice Processing](/blog/what-is-ocr-invoice-processing.md)
- [What Is Pdf Ua](/blog/what-is-pdf-ua.md)
- [Why Pdfium Is A Trusted Platform For Pdf Rendering](/blog/why-pdfium-is-a-trusted-platform-for-pdf-rendering.md)
- [Why Your Ai Agent Hallucinates Pdf Table Data](/blog/why-your-ai-agent-hallucinates-pdf-table-data.md)
- [Workflow Analysis](/blog/workflow-analysis.md)

