# Insurance Loss Run Extractor: Loss Run PDF to Excel & CSV (`hanielazevedo/insurance-loss-run-extractor`) Actor

Insurance loss run extractor: turn carrier loss run PDFs (Travelers, Hartford, Liberty, Zurich, CNA, Chubb, AmTrust, state funds, TPAs) into validated claims and policy-year totals in Excel, CSV and JSON: paid/reserve/incurred, frequency, severity, large losses. Rules-based, no AI.

- **URL**: https://apify.com/hanielazevedo/insurance-loss-run-extractor.md
- **Developed by:** [Haniel Azevedo](https://apify.com/hanielazevedo) (community)
- **Categories:** Business, Automation, Developer tools
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $40.00 / 1,000 claim validateds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Insurance Loss Run Extractor: PDF loss runs to JSON, CSV and Excel

Send carrier or TPA **loss run PDFs**. You get back every **claim** and **policy-year total** as structured data: paid, reserve and incurred split into indemnity, medical and expense, plus recoveries. Each row is **checked**: paid + reserve = incurred, and the result must agree with the totals printed in the report. You only pay for rows that pass those checks.

- **Carriers with layout rules:** Travelers, The Hartford, Liberty Mutual, Zurich, CNA, Chubb, AmTrust, Employers, State Compensation Insurance Fund and other state funds, Sedgwick, Gallagher Bassett, AIG, TASB-style pools, Sompo. A generic table reader handles everything else.
- **Lines of business:** Workers' Comp (WC), General Liability (GL), Auto and Property. One PDF can bundle several carriers, lines and policy years.
- **Outputs:** a JSON dataset, plus `CLAIMS.csv`, `POLICY_YEARS.csv`, `CARRIER_SUMMARIES.csv` and `LOSS_RUNS.xlsx` in the run's key-value store.
- **Analytics:** totals per policy year, frequency, severity, large-loss flags and your largest claims.
- **Pricing:** $0.04 per validated claim and $0.02 per validated policy-year row. Rows that do not reconcile, failed files and the demo file are free.
- **Rules only. No AI or LLM, and no third-party API.** Text PDFs are read directly. Scanned pages can optionally go through Tesseract OCR.

> Re-keying a 5-year loss run into a spreadsheet takes an account manager or underwriter about 20–40 minutes. This Actor does it in seconds and tells you which numbers it could prove.

### Who it is for

| You are | Use it to |
|---|---|
| **Agent / broker** | Build marketing submissions and stewardship reports, and compare years across carriers in one sheet. |
| **Underwriter** | Get frequency, severity, large losses and open reserves per policy year without re-keying. |
| **Insurtech developer** | Call one API that takes a PDF and returns validated JSON, with stable field names and error codes. |
| **AI agent** | Use deterministic numbers with a `validated` flag, instead of asking a model to read a table. |

### Example input

The Console form is prefilled with a real public loss run: a 2-page Travelers property loss run published by New Braunfels ISD (Texas). Click **Start** without changing anything:

```json
{
  "pdfUrls": [
    "https://nbisd.org/wp-content/uploads/2024/06/NBIndependentSchoolLossRunsTravelers.pdf"
  ]
}
```

### Example output

That run takes about 13 seconds and returns **2 claims, both validated**, and all 3 printed report totals reconciled. The demo file is free. One claim row (run of 1 Nov 2026, trimmed):

```json
{
  "type": "claim",
  "carrier": "Travelers",
  "lineOfBusiness": "PROPERTY",
  "lineOfBusinessRaw": "P - PROPERTY",
  "policyYear": 2020,
  "valuationDate": "2024-01-02",
  "claimNumber": "FHZ8795",
  "lossDate": "2021-02-17",
  "reportDate": "2021-03-10",
  "closeDate": "2021-04-25",
  "status": "closed",
  "description": "ACCORDING TO THE INSURED, TWO BOILERS SUFFERED DAMAGE IN NATURE.",
  "paid": {
    "indemnity": 0,
    "medical": 0,
    "expense": 600,
    "total": 600
  },
  "reserve": {
    "indemnity": 0,
    "medical": 0,
    "expense": 0,
    "total": 0
  },
  "incurredTotal": 600,
  "largeLoss": false,
  "validation": {
    "validated": true,
    "arithmetic": "valid",
    "formula": "paid+reserve",
    "reportTotals": "reconciled",
    "flags": []
  },
  "source": {
    "fileName": "NBIndependentSchoolLossRunsTravelers.pdf",
    "page": 1
  },
  "charged": false,
  "demo": true
}
```

The `OUTPUT` record adds `overview.byLineOfBusiness` (here PROPERTY: 2 claims, $600 incurred, severity $300) and links to `CLAIMS.csv`, `POLICY_YEARS.csv`, `CARRIER_SUMMARIES.csv` and `LOSS_RUNS.xlsx`.

### Quick start (API first)

```bash
curl -X POST "https://api.apify.com/v2/acts/hanielazevedo~insurance-loss-run-extractor/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"pdfUrls": ["https://nbisd.org/wp-content/uploads/2024/06/NBIndependentSchoolLossRunsTravelers.pdf"], "redactClaimants": "initials"}'
```

Python:

```python
from apify_client import ApifyClient
client = ApifyClient("<APIFY_TOKEN>")
run = client.actor("hanielazevedo/insurance-loss-run-extractor").call(run_input={
    "pdfUrls": ["https://nbisd.org/wp-content/uploads/2024/06/NBIndependentSchoolLossRunsTravelers.pdf"],
    # "pages": "94-104",            # optional: loss runs inside a bigger RFP / submission
    "largeLossThreshold": 100000,
})
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    if item["type"] == "claim":
        print(item["claimNumber"], item["incurredTotal"], item["validation"]["validated"])
summary = client.key_value_store(run["defaultKeyValueStoreId"]).get_record("OUTPUT")["value"]
print(summary["overview"]["byLineOfBusiness"])
```

#### Private files (no public link)

1. In Apify Console, open **Storage → Key-value stores** and create a store, for example `loss-runs`.
2. Upload your PDFs as records, for example `acme-wc-2025.pdf`.
3. In the Actor input, pick that store in **PDFs from your key-value store**. Optionally list the record keys. If you leave the keys empty, every `*.pdf` record is read.

The Actor runs with **limited permissions**. Picking the store gives that one run **read-only** access to that one store, and nothing else.

### Step by step (Console)

1. Paste one or more PDF links into **Loss run PDF URLs**, or pick a key-value store.
2. Optional settings:
   - **Pages** (for example `3-8,12`) when the loss run sits inside a larger document.
   - **Claimant privacy**: `initials`, `hash` or `remove`.
   - **Large-loss threshold**.
   - **Rounding tolerance**.
3. Click **Start**.
4. Open the **Output** tab to see the claims table, or download `LOSS_RUNS.xlsx` from the run's key-value store. The link is also in `OUTPUT.downloads`.

### Input

| Field | Type | Default | What it does |
|---|---|---|---|
| `pdfUrls` | array of URLs | – | Public http(s) links to loss run PDFs. |
| `pdfStore` | KV store (read-only picker) | – | A store holding your PDFs. |
| `pdfKeys` | array | all `*.pdf` | Record keys in that store. |
| `pages` | string | all | 1-based pages, for example `94-104` or `3-8,12`. Applies to every file. |
| `redactClaimants` | `none` / `initials` / `hash` / `remove` | `none` | Privacy for claimant and injured-worker names. `hash` gives a stable token within a run. |
| `largeLossThreshold` | integer | 100000 | Incurred at or above this amount sets `largeLoss: true`. |
| `toleranceAbs` | number | 1 | Allowed rounding per term (plus 0.05% relative). |
| `includeCarrierSummaries` | boolean | true | Also return the carrier's own policy-year summary lines. |
| `enableOcr` | boolean | true | OCR scanned pages with Tesseract. |
| `maxPages` / `ocrMaxPages` | integer | 60 / 15 | Page limits per file. |
| `maxFileSizeMb` | integer | 25 (max 50) | Download size limit. |

### Output

The dataset has one item per row. The `type` field is one of:

- `claim`: one row per claim;
- `policyYearSummary`: a carrier-printed summary line;
- `error`: a file that failed.

#### Claim item

```json
{
  "type": "claim",
  "carrier": "Chubb",
  "writingCompany": "FEDERAL INSURANCE COMPANY",
  "insured": "WESLACO I.S.D",
  "policyNumber": "79580818",
  "lineOfBusiness": "PROPERTY",
  "lineOfBusinessRaw": "INLAND MAR",
  "policyEffective": "2012-09-01",
  "policyExpiration": "2013-09-01",
  "policyYear": 2012,
  "valuationDate": "2018-06-19",
  "claimNumber": "040512146091",
  "claimant": "Weslaco I.s.d.",
  "lossDate": "2012-12-25",
  "reportDate": "2012-12-26",
  "closeDate": "2013-02-21",
  "status": "closed",
  "description": "A backhoe was stolen.",
  "location": "Unknown, TX",
  "paid":     {"indemnity": 35146.0, "expense": 0.0, "total": 35146.0},
  "reserve":  {"indemnity": 0.0, "expense": 0.0, "total": 0.0},
  "incurred": {"total": 35146.0},
  "recoveries": 0.0,
  "deductible": 0.0,
  "incurredTotal": 35146.0,
  "largeLoss": false,
  "validation": {"validated": true, "arithmetic": "valid", "formula": "paid+reserve", "reportTotals": "not-printed", "flags": []},
  "source": {"fileName": "Chubb_Federal.pdf", "pdfUrl": "https://…/Chubb_Federal.pdf", "page": 2},
  "charged": true
}
```

What the claim fields mean:

- **`lineOfBusiness`**: `WC`, `GL`, `AUTO`, `PROPERTY`, another normalised value, or `null`. `lineOfBusinessRaw` keeps the carrier's own wording.
- **`status`**: `open`, `closed` or `reopened`.
- **Amounts**: a component that a carrier does not print is simply absent, never invented.
- **`validation.formula`** shows which identity held: `paid+reserve`, `paid+reserve+deductible` (pools that print incurred including the member deductible), `paid+reserve-recovery`, or similar.
- **`validation.reportTotals`**:
  - `reconciled`: the row's section adds up to a printed subtotal;
  - `not-printed`: there is no total to check against;
  - `mismatch`: there is a printed total and it disagrees, so the row is not validated.

#### Policy-year table (`POLICY_YEARS.csv`, Excel sheet "Policy years", `OUTPUT.policyYears`)

There is one row per carrier, line and policy year, with these columns:

- `claimCount`, `openCount`;
- `paid`, `reserve`, `incurred` and `recoveries`;
- `severity` (incurred per claim), `largeLossCount` and `largeLossIncurred`;
- `basis`: `claimDetail` (computed from claim rows), `carrierSummary` (the carrier's printed summary), or `noLossesReported`.

#### OUTPUT record

The `OUTPUT` record holds:

- the run counts and `chargedEvents`;
- `downloads`: links to the CSV and XLSX files;
- `overview.byLineOfBusiness`: years, claim count, incurred, frequency per year, severity and large losses;
- `largestClaims`;
- per-document `validation` stats;
- `errors`.

### Validation and billing (pay per event)

| Event | Price | When it is charged |
|---|---|---|
| `claim-validated` | **$0.04** | A claim row where paid + reserve = incurred within tolerance (allowing for a printed deductible or recovery convention), that does not contradict any printed report total, and that has a claim number or loss date. |
| `policy-year-validated` | **$0.02** | A carrier policy-year summary row that passes the same checks. |

Example: a 5-year loss run with 40 validated claims and 5 validated policy-year rows costs $1.70. An Actor start fee of $0.00005 per GB of memory applies once per run.

These are always free:

- rows that do not reconcile (`validated: false`, `charged: false`);
- failed files and error items;
- scans that cannot be read;
- non-loss-run PDFs;
- the demo prefill file.

If the next file's validated rows do not fit in your **maximum total charge**, the Actor skips that file whole and returns a `BUDGET_EXCEEDED` error item. You never get a half-billed file.

### Error codes

| Code | Meaning |
|---|---|
| `INVALID_INPUT` / `INVALID_URL` / `BLOCKED_URL` | Bad input or URL. Private and internal addresses are refused. |
| `DNS_ERROR` / `DOWNLOAD_FAILED` / `DOWNLOAD_TIMEOUT` / `HTTP_ERROR` / `NOT_FOUND` | The download failed (`retryable` tells you whether retrying may help). |
| `ACCESS_DENIED` | The run cannot read the chosen store. |
| `FILE_TOO_LARGE` / `EMPTY_FILE` / `UNSUPPORTED_FORMAT` / `CORRUPT_FILE` / `ENCRYPTED_PDF` | There is a problem with the file. |
| `SCANNED_NOT_SUPPORTED` / `OCR_FAILED` | The file is image-only and OCR is off or failed. |
| `NOT_LOSS_RUN` | No claim or policy-period table with money columns was found. |
| `NO_DATA_FOUND` | It looks like a loss run, but no row could be read (unsupported layout). |
| `PARSE_FAILED` | Unexpected parser error. |
| `BUDGET_EXCEEDED` | Your max total charge was reached. |

### Accuracy

Accuracy is measured with `pytest` (`tests/test_accuracy.py`) and is field-level: claim number, dates, status, claimant, description, policy number and period, line, and every printed amount.

| Set | Claims | Field accuracy | Charged rows with broken arithmetic |
|---|---|---|---|
| Synthetic dev (12 carrier styles × 3) | 599 | 100% | 0 |
| Synthetic held-out (frozen, unseen seeds and print variants) | 385 | 100% | 0 |
| Hand-checked public loss runs (Travelers, Chubb, TASB) | 9 | 100% | 0 |

Real public loss runs, validated rows out of rows read:

| Loss run | Claims validated | Summaries validated |
|---|---|---|
| Chubb | 2/2 | 5/5 |
| Travelers | 2/2 | – |
| Liberty Mutual summaries | – | 80/80 |
| AIG bundle | 12/12 | 1/7 |
| TASB (two RFPs) | 37/37 | – |
| Zurich (truncated print) | 0/6 | – |

The synthetic sets test the layout rules and the charging guard. They are cleaner than real-world prints, so expect lower numbers on unusual layouts. In those cases the rows come back flagged and free, not wrong and billed.

### FAQ

**Does it use AI?** No. It reads the PDF's text geometry: header bands, column stacks, row records and totals blocks. Then it checks the arithmetic. The same file always gives the same result.

**What if my carrier is not in the list?** The generic table reader handles most "one row per claim" layouts with labelled money columns. Rows it can read but cannot prove come back flagged and free.

**Scanned loss runs?** OCR is available. However, OCR'd amounts rarely reconcile exactly, so those rows usually come back unvalidated and free. Ask the carrier for the native PDF when you can.

**Where is my Excel file?** It is the `LOSS_RUNS.xlsx` record in the run's default key-value store. The link is in `OUTPUT.downloads`.

**Is claimant data stored?** Only in your own run's dataset and store. Use `redactClaimants` to mask names before they are written.

**Why is a total row "count-only"?** Some prints show only claim counts in their totals. A count alone never validates a row's money.

### Limitations

- Text PDFs work best. Amounts read by OCR are unreliable and almost never billed.
- If the carrier's own print is truncated or misaligned, its rows come back unvalidated (as with the Zurich sample).
- Some layouts print no claim number. Those rows use the loss date as the key.
- Pool or TPA prints without a carrier name return `carrier: null`.
- The carrier name is detected from page text. The writing company is reported separately.
- Hartford summary pages sometimes print adjacent rows glued together, and those rows stay flagged.
- Per file: at most 60 pages, of which at most 15 OCR'd, and at most 50 MB.

### Other Actors by Haniel

- [ACORD 25 COI Checker: Certificate of Insurance Compliance](https://apify.com/hanielazevedo/acord-25-coi-checker): Read ACORD 25 certificate of insurance PDFs into JSON and get PASS / FAIL / REVIEW against your limits, expiry and additional insured rules. $0.08 per certificate.
- [Rent Roll to Excel: Yardi, AppFolio & RealPage Normalizer](https://apify.com/hanielazevedo/rent-roll-normalizer): Normalize Yardi, AppFolio, RealPage, Entrata and Buildium rent rolls (XLSX, CSV, PDF) into one validated Excel with occupancy and loss-to-lease. $0.02 per unit.
- [Property Owner Lookup & Absentee Owner List (PA, SF, CT, DC)](https://apify.com/hanielazevedo/property-details-by-address): Property details by address and absentee owner lists from official assessor data for Pittsburgh (Allegheny County), San Francisco, Connecticut and DC. $0.008 per lookup.
- [PDF to Markdown Converter & PDF Text Extractor (OCR, DOCX)](https://apify.com/hanielazevedo/document-to-markdown-ocr): PDF, scanned PDF, DOCX, HTML and images to clean Markdown or text for RAG and AI agents. $3 per 1,000 documents.

# Actor input Schema

## `pdfUrls` (type: `array`):

Public http(s) links to carrier or TPA loss run PDFs. One PDF may bundle several carriers / lines / policy years. To use files from your Apify account, put them in a key-value store and select it below: this Actor runs with limited permissions and cannot read a store you have not selected. The prefilled public sample (Travelers, 2 pages) is processed free of charge.

## `pdfStore` (type: `string`):

Your Apify key-value store holding loss run PDFs (Console > Storage > Key-value stores). Selecting it grants this run read-only access to that one store. Without record keys, every record whose key ends in ".pdf" is processed.

## `pdfKeys` (type: `array`):

Optional. Keys of the PDF records in the selected store, e.g. "acme-wc-lossrun-2025.pdf". Leave empty to use every "\*.pdf" record.

## `pages` (type: `string`):

Optional, applies to every file. 1-based pages to read, e.g. "94-104" or "3-8,12". Use it when the loss runs sit inside a larger RFP or submission package. Empty = all pages (up to "Max pages per file").

## `redactClaimants` (type: `string`):

Privacy option for claimant / injured worker names. "hash" gives the same token for the same name within one run.

## `largeLossThreshold` (type: `integer`):

Claims with total incurred at or above this amount get largeLoss: true and are counted in largeLossCount / largeLossIncurred per policy year. incurredCappedAtThreshold caps each claim at this amount.

## `toleranceAbs` (type: `number`):

Allowed rounding difference per term when checking paid + reserve = incurred and printed totals (plus 0.05% relative). Carriers that print whole dollars need about 1.

## `includeCarrierSummaries` (type: `boolean`):

Return the carrier's own policy-year / line summary lines (claim count, paid, reserve, incurred) as items of type "policyYearSummary". Validated ones are billed as policy-year-validated.

## `enableOcr` (type: `boolean`):

Read image-only (scanned) pages with Tesseract OCR. OCR text is less reliable: OCR'd amounts rarely reconcile, so those rows usually come back flagged and free.

## `maxPages` (type: `integer`):

Safety limit per file: at most this many pages are read (text pages are fast; long RFP bundles: use "pages" instead).

## `ocrMaxPages` (type: `integer`):

At most this many scanned (image-only) pages are OCR'd per file. OCR takes a few seconds per page.

## `maxFileSizeMb` (type: `integer`):

Files bigger than this are skipped with FILE_TOO_LARGE (hard cap 50 MB).

## Actor input object example

```json
{
  "pdfUrls": [
    "https://nbisd.org/wp-content/uploads/2024/06/NBIndependentSchoolLossRunsTravelers.pdf"
  ],
  "redactClaimants": "none",
  "largeLossThreshold": 100000,
  "toleranceAbs": 1,
  "includeCarrierSummaries": true,
  "enableOcr": true,
  "maxPages": 60,
  "ocrMaxPages": 15,
  "maxFileSizeMb": 25
}
```

# Actor output Schema

## `claims` (type: `string`):

No description

## `xlsx` (type: `string`):

No description

## `claimsCsv` (type: `string`):

No description

## `policyYearsCsv` (type: `string`):

No description

## `summary` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "pdfUrls": [
        "https://nbisd.org/wp-content/uploads/2024/06/NBIndependentSchoolLossRunsTravelers.pdf"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("hanielazevedo/insurance-loss-run-extractor").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "pdfUrls": ["https://nbisd.org/wp-content/uploads/2024/06/NBIndependentSchoolLossRunsTravelers.pdf"] }

# Run the Actor and wait for it to finish
run = client.actor("hanielazevedo/insurance-loss-run-extractor").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "pdfUrls": [
    "https://nbisd.org/wp-content/uploads/2024/06/NBIndependentSchoolLossRunsTravelers.pdf"
  ]
}' |
apify call hanielazevedo/insurance-loss-run-extractor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,hanielazevedo/insurance-loss-run-extractor"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/GcMKx8ebhqw8D769F/builds/CzMnTX7yt67TImZcd/openapi.json
