Constaia
Use-case guides

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.

Esta página ainda não está traduzida para o seu idioma. Mostramos a versão em inglês.

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 needWhere it isWho decides
Is this an ACORD 25, is it current, is it the right insured?verdictConstaia, with the checks you ask for
Policies, limits, additional insured, certificate holderfieldsYour code, with your contract rules

Step 1: verdict with acord_25

The acord_25 type returns these fields:

FieldContent
certificate_date, certificate_number, revision_numberCertificate date, number and revision.
producerProducer (agent or broker): name, address, contact_name, phone, email.
insuredInsured: name, address.
insurersInsurers A–F: letter, name, naic.
policiesOne 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_dateThe earliest expiry of all listed policies.
description_of_operationsDescription of operations, locations or vehicles.
certificate_holderCertificate holder: name, address.
authorized_representativeAuthorized 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"}'
coi-verdict.ts
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.

coi-rules.ts
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

Nesta página