Certificates of insurance (ACORD 25)
Validate vendor certificates of insurance with the acord_25 type, with expiry and insured checked, and read policies, limits and additional insured to apply your contract rules.
Before a contractor, vendor or tenant starts work, you usually ask for a certificate of insurance (COI), most often on the ACORD 25 form. Someone then checks that the policies are current, that the limits meet your contract and that your company is listed as certificate holder or additional insured.
Constaia does the reading and the mechanical checks with the catalogue type acord_25. A single call gives you both:
| You need | Where it is | Who decides |
|---|---|---|
| Is this an ACORD 25, is it current, is it the right insured? | verdict | Constaia, with the checks you ask for |
| Policies, limits, additional insured, certificate holder | fields | Your code, with your contract rules |
Step 1: verdict with acord_25
The acord_25 type returns these fields:
| Field | Content |
|---|---|
certificate_date, certificate_number, revision_number | Certificate date, number and revision. |
producer | Producer (agent or broker): name, address, contact_name, phone, email. |
insured | Insured: name, address. |
insurers | Insurers A–F: letter, name, naic. |
policies | One entry per policy: insurer_letter, type, policy_number, effective_date, expiry_date, additional_insured, subrogation_waived and limits (list of name and amount in USD). |
policy_expiry_date | The earliest expiry of all listed policies. |
description_of_operations | Description of operations, locations or vehicles. |
certificate_holder | Certificate holder: name, address. |
authorized_representative | Authorized representative. |
In this type expiry (not_expired) is checked by default on policy_expiry_date, and reference_date lets you check
it on another date, for example the day the job starts. holder compares the insured's name (insured.name) with the
one you expect (normalized, ignoring accents and word order, small typos tolerated). max_age_days is measured from
certificate_date, and require_fields accepts dotted paths such as certificate_holder.name. The full list is in
GET /v1/document-types/acord_25 and in the catalogue.
curl https://api.constaia.com/v1/analyze \
-H "Authorization: Bearer $CONSTAIA_API_KEY" \
-F file=@coi.pdf \
-F 'options={"expect":"acord_25","checks":{"reference_date":"2026-11-02","holder":{"full_name":"Acme Roofing LLC"},"require_fields":["certificate_holder.name"]},"storage":"none"}'import { Constaia, ConstaiaError } from "@constaia/sdk";
import { fromPath } from "@constaia/sdk/node";
const constaia = new Constaia(); // reads CONSTAIA_API_KEY
export async function checkCoi(path: string, vendorName: string, jobStart: string) {
const analysis = await constaia.analyze(await fromPath(path), {
expect: "acord_25",
checks: {
referenceDate: jobStart,
holder: { fullName: vendorName },
requireFields: ["certificate_holder.name"],
},
storage: "none",
metadata: { vendor: vendorName },
});
return {
analysis,
status: analysis.verdict?.status ?? "pending", // "valid" | "invalid" | "review"
reasons: analysis.verdict?.reasons.map((r) => `${r.code} (${r.severity}): ${r.message}`) ?? [],
policyExpiryDate: analysis.fields.policy_expiry_date?.value ?? null,
};
}
checkCoi(process.argv[2] ?? "./coi.pdf", "Acme Roofing LLC", "2026-11-02")
.then(({ status, reasons, policyExpiryDate }) => console.log({ status, reasons, policyExpiryDate }))
.catch((err) => {
if (err instanceof ConstaiaError) console.error(`Constaia error ${err.status} ${err.code} (request ${err.requestId})`);
else console.error(err);
process.exit(1);
});A certificate with a policy expired on that date comes back invalid with a not_expired reason of severity error;
a different insured gives a holder error, and a document that is not an ACORD 25 gives a type_mismatch error. See
Verdicts and Checks.
policy_expiry_date is the earliest expiry of all listed policies. If the certificate includes a line your
contract does not require and it expires sooner, not_expired still gives invalid. In that case send
"not_expired": false and compare each policy's dates in your code, as in step 2.
Step 2: contract rules in your code
Minimum limits, required coverage lines and additional insured depend on each contract, so you check them in your code
with the fields from the same response. No second call is needed.
import type { Analysis } from "@constaia/sdk";
const minimumLimits: Record<string, Record<string, number>> = {
"general liability": { "each occurrence": 1_000_000, "general aggregate": 2_000_000 },
};
const requirements = {
holderMustContain: "YOUR COMPANY INC",
requiredLines: ["general liability", "workers compensation"],
additionalInsuredOn: "general liability",
minimumLimits,
};
type Limit = { name?: string; amount?: number };
type Policy = { type?: string; policy_number?: string; expiry_date?: string; additional_insured?: boolean; limits?: Limit[] };
const has = (text: string | undefined, needle: string) => (text ?? "").toLowerCase().includes(needle);
export function checkContractRules(analysis: Analysis, jobEnd: string) {
const issues: string[] = (analysis.verdict?.reasons ?? [])
.filter((r) => r.severity !== "info")
.map((r) => `${r.code}:${r.severity}`);
const f = analysis.fields;
const policies = (f.policies?.value as Policy[] | undefined) ?? [];
for (const line of requirements.requiredLines) {
const p = policies.find((x) => has(x.type, line));
if (!p) issues.push(`missing_line:${line}`);
else if (!p.expiry_date || p.expiry_date < jobEnd) issues.push(`expires_before_job_end:${line}`);
}
const ai = policies.find((x) => has(x.type, requirements.additionalInsuredOn));
if (ai && ai.additional_insured !== true) issues.push("additional_insured_not_marked");
for (const [line, mins] of Object.entries(requirements.minimumLimits)) {
const limits = policies.find((x) => has(x.type, line))?.limits ?? [];
for (const [name, min] of Object.entries(mins)) {
const amount = limits.find((l) => has(l.name, name))?.amount;
if (typeof amount !== "number" || amount < min) issues.push(`limit_below_minimum:${line}:${name}`);
}
}
const holder = (f.certificate_holder?.value as { name?: string } | null)?.name ?? "";
if (!holder.toUpperCase().includes(requirements.holderMustContain)) issues.push("certificate_holder_mismatch");
return { ok: analysis.verdict?.status === "valid" && issues.length === 0, issues };
}type and limit names come back as printed on the form (for example commercial general liability or
each occurrence), which is why they are matched by content. Dates are compared as YYYY-MM-DD strings, which sort
correctly. Send certificates with issues to human review or back to the vendor's
broker.
A COI is informational
An ACORD 25 certificate does not amend or extend coverage, and the "additional insured" box usually depends on a policy endorsement. Constaia reads what the certificate says; it does not contact insurers or brokers. For high-risk work, ask for the endorsements and review your requirements with your risk team or legal counsel; this guide is not legal advice.
Other certificates
For certificates that do not follow the ACORD 25 form, the generic insurance_certificate type (any country) extracts
insurer, policy_number, holder, coverage, valid_from and valid_until, with not_expired on by default. If
you need a data point no catalogue type returns, you can pass your own JSON Schema in extract; without expect
there is no verdict and the rules live in your code.
Test mode
The test-mode simulator (ck_test_ keys) has no US document scenarios. An ACORD 25 is answered with the generic
scenario, so with expect: "acord_25" the verdict is review with the reason type_unknown (filenames containing
invoice or receipt are answered with Spanish invoice and payment receipt scenarios). Use it to test your error
paths; to see real results use a live key. The free plan includes 150 credits a month; free live credits require a
verified email. See Test mode.
Coming soon: US region
Today all processing and storage, including for US customers, happens in the EU. A US region is planned, with no date yet. See Data residency and compliance.
Next steps
Income verification with paystubs and bank statements
Analyze US paystubs and bank statements in one batch with the us_paystub and us_bank_statement types, with recency and holder checked, and compute monthly income in your code.
Proof of address with utility bills
Verify a US utility bill or bank statement as proof of address, with its recency, the account holder and the ZIP code, and compare the address with your form.