How to automate certificate of insurance verification for a large fleet
Aug 12, 2026

How to automate certificate of insurance verification for a large fleet

A working COI verification pipeline on the bem V3 API. Real ACORD 25 certificates in; limit checks, expiration flags, and a live carrier knowledge graph out. About three seconds per certificate, with a trace you can audit.

Antonio Bustamante
Antonio Bustamante
Aug 12, 2026·9 min read·

A fleet of 5,000 trucks is also a filing cabinet of 5,000 insurance certificates. Every carrier you onboard, every owner-operator you lease on, every vendor that touches your freight hands you the same one-page PDF, the ACORD 25 certificate of liability insurance. Someone on your team opens each one and checks the limits, the dates, the insurer, the certificate holder. Then a year passes, the policy renews, and they do it again.

This guide builds a working COI verification pipeline on the bem V3 API, end to end, with real public documents. Everything below actually ran. The JSON responses are unedited API output, the timings are from the calls themselves, and the knowledge graph at the bottom is live.

TL;DR. Describe the certificate fields you need as a JSON schema, create an extract function, wrap it in a workflow, and POST each PDF with wait=true. bem returns schema-shaped JSON in about three seconds per certificate, with a trace you can audit. Your code then applies the rules that make it a verification: limits at or above your floor, policies in force, gaps routed to a human. A parallel parse function builds a knowledge graph of carriers, insurers, and brokers as a side effect.

Why certificates of insurance break fleets at scale

The problem is not any single certificate. It is the shape of the industry. There are almost 580,000 active motor carriers registered with FMCSA, and 91.5 percent of them operate ten or fewer trucks. If you run a large fleet program or a brokerage, your counterparty network is thousands of very small companies, each with its own broker, its own insurer, and its own renewal date. Policies renew annually, so roughly a twelfth of your certificate stack goes stale every month.

The manual workflow does not survive that arithmetic. myCOI, a COI tracking vendor, reports that about 70 percent of certificates are non-compliant as first received, and that reaching 90 percent compliance takes an average of 38 minutes of review per certificate. At 10,000 certificates a year that is roughly three full-time employees doing nothing but reading the same form.

And the cost of missing one keeps going up:

  • Regulatory. Interstate carriers must maintain minimum financial responsibility under 49 CFR 387.9 ($750,000 for general freight, and effectively $1,000,000 as the market floor in most broker and shipper contracts). When an insurer cancels, it files notice with FMCSA and the carrier’s operating authority is revoked 30 days later if no replacement appears (49 CFR 387.313). Carriers heading out of business often let insurance lapse weeks before they actually stop hauling.

  • Legal. In May 2026 the Supreme Court held unanimously in Montgomery v. Caribe Transport II that state-law negligent-selection claims against freight brokers are not federally preempted. Documented carrier vetting is now the difference between a defense and a settlement. Juries were already unfriendly: a 2023 U.S. Chamber Institute for Legal Reform study of trucking litigation found a mean verdict of $31.9 million. In one earlier case, a broker was hit with punitive damages after a fatal crash because it had never requested a certificate of insurance at all. The carrier’s coverage had lapsed six days after they signed.

  • Fraud. Carrier identity fraud rose 219 percent year over year by one industry index, which flagged over 48,700 fake carrier identities in a single quarter. One documented tactic: submit a real certificate from a real policy, then cancel the policy hours after onboarding clears.

The ACORD 25 itself tells you the punchline in its own fine print: "limits shown may have been reduced by paid claims." A certificate is a snapshot, not a guarantee. Which means verification is not a one-time glance at a PDF. It is a standing process. Standing processes are what you automate.

What you will build

The pipeline

StageWhat happensWhat runs it
IngestCertificates arrive by API call or get forwarded to a function email addressyour systems, or plain email
ExtractEach PDF becomes schema-shaped JSON: insured, insurers, NAIC numbers, policies, limits, datesbem extract function
VerifyYour rules run over clean JSON: limit floors, expiration windows, required coveragesordinary code, shown below
RememberEvery certificate also feeds a knowledge graph of carriers, insurers, and brokers per bucketbem parse function + memory APIs

Four stages, three bem API calls to set up, one call per certificate after that.

The document, as it actually arrives

Here is our primary test input. It is a real, publicly posted sample certificate from Cottingham & Butler, one of the larger trucking insurance brokers in the country. The insured is a motor carrier in Bismarck, North Dakota. It carries the four coverage rows a fleet compliance team actually checks: general liability, automobile liability at a $1,000,000 combined single limit, workers compensation, and motor truck cargo at $300,000 per vehicle.

Sample ACORD 25 certificate of liability insurance for a trucking carrier, issued by Cottingham and Butler

An ACORD 25 is a deceptively dense form. The producer (the issuing agency) is in the top left, the insurers with their NAIC numbers top right, and each coverage row maps to an insurer letter, a policy number, effective and expiration dates, and a stack of limits that do not share a layout between coverage types. Auto liability is one combined single limit. General liability is six different numbers. Cargo is often a handwritten afterthought. This is exactly the kind of document that breaks template-based OCR, because every agency management system prints it slightly differently.

Step 1: describe the JSON you want

With bem you do not write a parser. You write a schema, which is a contract for what comes out. Field descriptions do real work here, the same way a good spec does. Save this as coi-schema.json:

json
1{
2 "type": "object",
3 "required": ["insured", "policies", "certificateHolder"],
4 "properties": {
5 "insured": { "type": "object", "description": "The named insured (the carrier or owner-operator)",
6 "properties": { "name": { "type": "string" }, "address": { "type": "string" } } },
7 "producer": { "type": "object", "description": "The agency or broker that issued the certificate",
8 "properties": { "name": { "type": "string" }, "address": { "type": "string" },
9 "phone": { "type": "string" }, "email": { "type": "string" } } },
10 "insurers": { "type": "array", "description": "Insurers affording coverage (letters A-F on an ACORD 25)",
11 "items": { "type": "object", "properties": {
12 "letter": { "type": "string", "description": "Insurer letter A-F" },
13 "name": { "type": "string" },
14 "naic": { "type": "string", "description": "NAIC company number" } } } },
15 "policies": { "type": "array", "description": "Each coverage row on the certificate",
16 "items": { "type": "object", "properties": {
17 "coverageType": { "type": "string", "description": "e.g. automobile liability, motor truck cargo" },
18 "insurerLetter": { "type": "string" },
19 "policyNumber": { "type": "string" },
20 "effectiveDate": { "type": "string", "description": "ISO 8601" },
21 "expirationDate": { "type": "string", "description": "ISO 8601" },
22 "limits": { "type": "array", "items": { "type": "object", "properties": {
23 "name": { "type": "string" }, "amountUSD": { "type": "number" } } } } } } },
24 "certificateHolder": { "type": "object", "description": "The party the certificate was issued to",
25 "properties": { "name": { "type": "string" }, "address": { "type": "string" } } },
26 "certificateDate": { "type": "string", "description": "Date the certificate was issued, ISO 8601" }
27 }
28}

One deliberate omission. The ACORD 25 has ADDL INSD and SUBR WVD checkbox columns, and we left them out of the schema. The form’s own language says a statement on the certificate does not confer additional insured status without the policy endorsement behind it. If additional insured status matters to your contracts, verify the endorsement pages, not a checkbox. Scope your schema to what a document can actually prove.

Step 2: create the function and the workflow

Two setup calls. The function binds your schema to bem’s extraction engine. The workflow makes it callable, versioned, and composable with other functions later.

bash
1curl -X POST https://api.bem.ai/v3/functions \
2 -H "Content-Type: application/json" \
3 -H "x-api-key: $BEM_API_KEY" \
4 -d '{
5 "functionName": "coi-extractor",
6 "type": "extract",
7 "displayName": "COI Extractor",
8 "outputSchemaName": "Certificate of Insurance",
9 "outputSchema": '"$(cat coi-schema.json)"'
10 }'
bash
1curl -X POST https://api.bem.ai/v3/workflows \
2 -H "Content-Type: application/json" \
3 -H "x-api-key: $BEM_API_KEY" \
4 -d '{
5 "name": "coi-verification",
6 "displayName": "COI Verification",
7 "mainNodeName": "coi-extractor",
8 "nodes": [
9 { "name": "coi-extractor", "function": { "name": "coi-extractor" } }
10 ]
11 }'

Both are one-time. From here on, everything is the same call per certificate.

Step 3: send the certificate

bash
1curl -X POST "https://api.bem.ai/v3/workflows/coi-verification/call?wait=true" \
2 -H "x-api-key: $BEM_API_KEY" \
3 -F "referenceID=coi-north-american-logistics" \
4 -F "file=@certificate.pdf"

With wait=true the call blocks until the result is ready. Here is the actual response for the certificate above, trimmed only for length. Note the timestamps: 3.4 seconds, wall clock, for a four-coverage trucking certificate.

json
1{
2 "call": {
3 "callID": "wc_3HpFO5taPP1kMLIGl2A5Jrkk5g0",
4 "status": "completed",
5 "workflowName": "coi-verification",
6 "createdAt": "2026-08-12T17:51:07.903078Z",
7 "finishedAt": "2026-08-12T17:51:11.364278Z",
8 "outputs": [
9 {
10 "eventType": "extract",
11 "functionName": "coi-extractor",
12 "functionVersionNum": 3,
13 "transformedContent": {
14 "insured": {
15 "name": "North American Logistics",
16 "address": "1929 Hancock Dr\nBismarck ND 58501"
17 },
18 "producer": { "name": "Cottingham & Butler", "address": "800 Main St.\nDubuque IA 52001" },
19 "insurers": [
20 { "letter": "A", "name": "Protective Insurance Company", "naic": "12416" }
21 ],
22 "policies": [
23 {
24 "coverageType": "automobile liability",
25 "insurerLetter": "A",
26 "policyNumber": "XA-1107",
27 "effectiveDate": "2021-05-01",
28 "expirationDate": "2022-05-01",
29 "limits": [
30 { "name": "COMBINED SINGLE LIMIT (Ea accident)", "amountUSD": 1000000 }
31 ]
32 },
33 {
34 "coverageType": "cargo",
35 "insurerLetter": "A",
36 "policyNumber": "XA-1107",
37 "effectiveDate": "2021-05-01",
38 "expirationDate": "2022-05-01",
39 "limits": [ { "name": "Per Vehicle", "amountUSD": 300000 } ]
40 }
41 // commercial general liability and workers comp rows omitted for length
42 ],
43 "certificateHolder": { "name": "For Information Only" },
44 "certificateDate": "2022-02-17"
45 }
46 }
47 ],
48 "traceURL": "/v3/calls/wc_3HpFO5taPP1kMLIGl2A5Jrkk5g0/trace"
49 }
50}

Every call carries a traceURL. Pull it and you get the full execution record: which function version ran, what the model saw, and the per-field evaluation. When a compliance decision gets questioned two years later, in a deposition or an audit, that trace is the difference between "the system said so" and an answer.

Step 4: turn JSON into a verdict

Extraction is not verification. Verification is your policy, expressed as code, running over data clean enough to trust. Ours is deliberately boring:

python
1from datetime import date, timedelta
2
3REQUIREMENTS = {
4 "AUTOMOBILE LIABILITY": 1_000_000, # $1M CSL, the standard shipper/broker floor
5 "COMMERCIAL GENERAL LIABILITY": 1_000_000,
6 "CARGO": 100_000,
7}
8EXPIRY_WARNING = timedelta(days=30)
9
10def check(coi, today):
11 findings = []
12 rows = {p["coverageType"].upper(): p for p in coi["policies"]}
13 for coverage, minimum in REQUIREMENTS.items():
14 p = rows.get(coverage)
15 if not p:
16 findings.append(("FAIL", coverage, "coverage not on certificate"))
17 continue
18 best = max((l.get("amountUSD") or 0) for l in p["limits"]) if p["limits"] else 0
19 findings.append(("PASS" if best >= minimum else "FAIL", coverage,
20 f"limit ${best:,.0f} vs required ${minimum:,.0f}"))
21 try:
22 exp = date.fromisoformat(p["expirationDate"])
23 if exp < today:
24 findings.append(("FAIL", coverage, f"policy expired {exp}"))
25 elif exp < today + EXPIRY_WARNING:
26 findings.append(("WARN", coverage, f"policy expires {exp}"))
27 else:
28 findings.append(("PASS", coverage, f"in force until {exp}"))
29 except ValueError:
30 findings.append(("FAIL", coverage,
31 f"unreadable expiration {p['expirationDate']!r} -> human review"))
32 return findings

We ran three publicly posted sample certificates through the pipeline. Here is the output, trimmed to the rows that matter:

bash
1North American Logistics -> HOLD
2 [PASS] AUTOMOBILE LIABILITY: limit $1,000,000 vs required $1,000,000
3 [FAIL] AUTOMOBILE LIABILITY: policy expired 2022-05-01
4 [PASS] COMMERCIAL GENERAL LIABILITY: limit $2,000,000 vs required $1,000,000
5 [FAIL] COMMERCIAL GENERAL LIABILITY: policy expired 2022-05-01
6 [PASS] CARGO: limit $300,000 vs required $100,000
7 [FAIL] CARGO: policy expired 2022-05-01
8
9A+ Case Management Services -> HOLD
10 [PASS] AUTOMOBILE LIABILITY: limit $2,000,000 vs required $1,000,000
11 [FAIL] AUTOMOBILE LIABILITY: policy expired 2024-07-01
12 [FAIL] CARGO: coverage not on certificate
13
14(Insert name, address, city, state zip code of business/comany) -> HOLD
15 [PASS] AUTOMOBILE LIABILITY: limit $1,000,000 vs required $1,000,000
16 [FAIL] AUTOMOBILE LIABILITY: unreadable expiration '(Expire date)' -> human review

Two things worth noticing. First, not one public sample certificate we could find is still in force, which is fitting, because that is the natural state of a certificate folder nobody is watching. Wherever the coverage exists, the limits clear the floor. The dates are what fail. That is the failure mode this pipeline exists to catch, and it is invisible to a human skimming for a dollar amount.

Second, look at the third certificate. It is a scanned image with no text layer, an annotated training sample where the policy number field literally reads "(Insert Policy Number)". bem returned exactly that string instead of inventing something plausible, and the unreadable expiration date routed the certificate to human review. A verification pipeline is only as good as its refusal to guess.

Step 5: run it at fleet scale

The per-certificate call is the whole integration, so scaling is mostly plumbing you already have:

  • Email ingestion. Every extract function gets its own address (ours came back as eml_...@action.bem.ai). Tell carriers and their agents to send certificates there, and the PDFs flow through the same workflow with no portal, no chasing, no retyping.

  • Webhooks. Attach a webhook subscription and results land in your TMS or compliance system as they finish. Or poll the calls endpoint if you would rather pull.

  • The nightly sweep. Expiration is a property of your stored JSON, not of a new document, so a scheduled job over yesterday’s extractions gives you the 30-day expiry list for free. Cross-check flagged carriers against FMCSA’s public SAFER data before anyone books the next load.

  • Corrections. When a reviewer fixes a field, send the correction back. bem uses it to train your functions, so the pipeline gets more accurate on your documents, not documents in general.


One more thing

Did you know you can offer each customer, or each team, their own document knowledge graph, with their own agents?

While the extract function was producing JSON, we also ran each certificate through a parse function. Parse functions build memory: they pull out the entities in a document, resolve them against every document that came before, and store the relationships between them. One certificate is a form. A thousand certificates are a map of your carrier network: who insures whom, which broker produced which certificate, which counterparties keep showing up.

Memory works best when it knows your vocabulary, so first we seeded the ontology with the carrier roster a fleet already has in its TMS:

bash
1curl -X POST https://api.bem.ai/v3/entities/bulk \
2 -H "Content-Type: application/json" \
3 -H "x-api-key: $BEM_API_KEY" \
4 -d '{
5 "onConflict": "merge",
6 "entities": [
7 { "canonical": "North American Logistics", "type": "carrier",
8 "synonyms": ["NAL", "North American Logistics LLC"],
9 "attributes": { "domicile": "Bismarck, ND" } },
10 { "canonical": "Protective Insurance Company", "type": "insurer",
11 "synonyms": ["Protective"], "attributes": { "naic": "12416" } },
12 { "canonical": "Cottingham & Butler", "type": "insurance_broker",
13 "synonyms": ["C&B", "Cottingham and Butler"] }
14 ]
15 }'
16
17# {"results":[
18# {"canonical":"North American Logistics","outcome":"created","entityID":"ent_3HpEkSSJsUoL9Pf46N3cz51CCew"},
19# {"canonical":"Protective Insurance Company","outcome":"created","entityID":"ent_3HpEkTMSwFtfSGEsF7wpddVu4Ac"},
20# {"canonical":"Cottingham & Butler","outcome":"created","entityID":"ent_3HpEkPn2nUjAufn20Q4aaRa0BsX"}],
21# "summary":{"created":3,"merged":0,"rejected":0}}

Then we read the graph back. This endpoint is the bulk view; there is one knowledge graph per bucket, and buckets are how you make this multi-tenant. Every customer or team gets its own bucket, logically separated, with its own graph:

bash
1curl "https://api.bem.ai/v3/knowledge-graph?type=organization&type=person&type=institution&since=2026-08-12T00:00:00Z" \
2 -H "x-api-key: $BEM_API_KEY"
json
1{
2 "nodes": [
3 { "id": "ent_3HpEpXe...", "canonical": "Protective Insurance Company", "type": "organization" },
4 { "id": "ent_3HpEohQ...", "canonical": "North American Logistics", "type": "organization" },
5 { "id": "ent_3HpEnfS...", "canonical": "Cottingham & Butler", "type": "organization" }
6 // 7 more nodes
7 ],
8 "edges": [
9 { "sourceId": "ent_3HpEpXe...", "targetId": "ent_3HpEohQ...", "relationType": "insures" },
10 { "sourceId": "ent_3HpEnfS...", "targetId": "ent_3HpEohQ...", "relationType": "is producer for" }
11 // 6 more edges
12 ]
13}

And here it is rendered. This is not an illustration. It is the response from the calls above, drawn as a graph, minus two stray entities left over from earlier documents in our shared demo account. Which is its own small lesson: give every tenant its own bucket. Drag the nodes:

Three public certificates produced three connected clusters: a trucking carrier with its broker and insurer, a services vendor with two insurers and a county as additional insured, and a university system. Now scale that to a real certificate folder. "Which of my carriers are insured by a company we just saw downgraded?" stops being a two-day spreadsheet exercise and becomes one API call.

The "their own agents" part is the File System API. An agent pointed at POST /v3/fs can navigate a customer’s documents the way a developer navigates source code: find to list entities, open to read one, xref to jump to every section of every document that mentions it:

bash
1curl -X POST https://api.bem.ai/v3/fs \
2 -H "Content-Type: application/json" \
3 -H "x-api-key: $BEM_API_KEY" \
4 -d '{ "op": "xref", "path": "ent_3HpEpXe..." }'
5# returns the exact certificate sections, across every document,
6# that mention Protective Insurance Company

So a fleet management platform can hand every fleet on its platform a private graph of that fleet’s own paperwork, and a chat agent that answers from it, without building an extraction pipeline, an entity resolver, or a graph store. Ingest documents, get memory.

Frequently asked questions

What is COI verification?

Checking that a counterparty’s certificate of insurance proves the coverage your contract requires: the right coverage types, limits at or above your floor, policies currently in force, and the right certificate holder. At fleet scale it also means re-checking continuously, because certificates expire and policies get cancelled mid-term.

Can this detect fraudulent certificates?

It narrows the window. Structured extraction catches internally inconsistent certificates, and the knowledge graph surfaces anomalies like one producer issuing certificates for suspiciously many unrelated carriers. But a certificate is evidence, not proof: pair extraction with authority and insurance checks against FMCSA data, and treat same-day policy cancellations as the reason continuous monitoring exists.

What file types does this handle?

PDFs with text layers, scans with none, photos of paper, faxes, spreadsheets, and email bodies with attachments. The scanned certificate in step 4 went through the identical workflow as the clean ones.

What about accuracy?

Every extraction carries per-field evaluations and a trace, so you can set confidence thresholds and route low-confidence fields to review instead of trusting them. Corrections you send back train your functions. The design goal is not "never wrong," it is "never wrong silently."

Does this replace my TMS or compliance system?

No. bem is API-first and sits behind whatever you run today. It replaces the manual reading and retyping between the PDF and that system.

Run it yourself

The whole pipeline is three setup calls and a loop. The documents we used are public: the trucking sample certificate, the county vendor sample, and the scanned annotated sample. Grab an API key, point the calls above at your own certificate folder, and see what fraction of it is quietly expired.

Docs: quickstart, extract functions, parse functions, knowledge graph API, customer ontology, file system API.

Antonio Bustamante

Written by

Antonio Bustamante

Aug 12, 2026

CTA accent 1CTA accent 2

Ready to see it in action?

Talk to our team to walk through how Bem can work inside your stack.

Talk to the team
How to automate certificate of insurance verification for a large fleet | bem