COI Verification · implementation

Writing coverage requirements as data

A certificate check is only as good as the requirements it runs against. Once those requirements are data, the dangerous bug is not a wrong answer — it is a rule that quietly never runs, and a clean result that looks exactly like a real one. This is the requirements schema our API accepts, published so you can use it whether or not you use us, and the five shapes of that bug it is built to refuse.

The bug this exists to prevent

On 2026-09-03 we sent our own API a requirements profile with one transposed letter — general_liablity — and a $99,000,000 minimum no certificate could meet. It returned compliant: true with no deficiencies. Nothing crashed. It evaluated every rule it was given, and it had been given none.

A checker that returns a green light for a check it never ran is worse than one that errors, because the reason anyone calls it is to be told when something is missing. The same day we found four more variants of the same bug. All five now fail loudly. The responses below were captured from the live API on 2026-09-11.

The schema

A requirements profile is one JSON object. Every key is optional, but at least one rule must be present. Limits are plain JSON numbers in dollars; flags are true when required.

general_liability

each_occurrence_min general_aggregate_min products_comp_op_agg_min personal_adv_injury_min number ≥ 0

additional_insured_required waiver_of_subrogation_required true

Each limit on the certificate is at least the minimum; each required flag is marked.

auto_liability

combined_single_limit_min number ≥ 0

additional_insured_required true

Same, for automobile liability.

umbrella_excess

each_occurrence_min aggregate_min number ≥ 0

Same, for umbrella or excess liability.

workers_comp

el_each_accident_min number ≥ 0

required waiver_of_subrogation_required true

Workers' compensation is shown; the employers' liability limit meets the minimum; each required flag is marked.

min_days_until_expiration

number of days ≥ 0

Every policy shown on the certificate stays valid at least this many more days. 0 means “not expired today”. A certificate with no readable policy fails this rule rather than passing it.

certificate_holder_must_include

non-empty string

The certificate holder contains this text — case-insensitive, with whitespace collapsed.

name

string

A label for your own reference. Not a rule — a profile with only a name is empty.

A complete profile:

json
{
  "name": "Subcontractor — standard",
  "general_liability": {
    "each_occurrence_min": 1000000,
    "general_aggregate_min": 2000000,
    "additional_insured_required": true,
    "waiver_of_subrogation_required": true
  },
  "auto_liability": { "combined_single_limit_min": 1000000 },
  "workers_comp": { "required": true, "el_each_accident_min": 1000000 },
  "min_days_until_expiration": 30,
  "certificate_holder_must_include": "Vance Property Group"
}

The thresholds above are illustrative. Yours come from your contract. We never suggest, add, or default a requirement — the service checks the rules you send and nothing else.

What the schema does not check

There is no field for any of these, so no profile can ask for them — and a compliant verdict says nothing about them:

If your contract requires them, the endorsements themselves are the documents to read — see what a certificate of insurance actually proves.

Read from the certificate, but never evaluated

The service extracts more than any rule reads. These values come back in parsed so you can use them, but no requirement compares them with anything, so a compliant verdict says nothing about them either. This list is generated from the service's own code each time this page is built:

named_insured · producer · certificate_date · description_of_operations · insurers[].name · insurers[].naic_number · general_liability.policy_number · general_liability.policy_effective_date · general_liability.damage_to_rented_premises · general_liability.med_exp · general_liability.claims_made · auto_liability.policy_number · auto_liability.policy_effective_date · auto_liability.subrogation_waived · auto_liability.bodily_injury_per_person · auto_liability.bodily_injury_per_accident · auto_liability.property_damage_per_accident · auto_liability.any_auto · umbrella_excess.policy_number · umbrella_excess.policy_effective_date · umbrella_excess.additional_insured · umbrella_excess.subrogation_waived · umbrella_excess.is_umbrella · workers_comp.policy_number · workers_comp.policy_effective_date · workers_comp.additional_insured · workers_comp.per_statute · workers_comp.el_disease_ea_employee · workers_comp.el_disease_policy_limit

The ones people most often assume are checked: named_insured (no rule compares it with the company you contracted with), general_liability.claims_made (occurrence versus claims-made is returned, not judged), insurers[].naic_number (returned, not looked up), and certificate_date (no rule limits how old a certificate may be).

For comparison, the values a rule can read — only when your requirements name that rule: certificate_holder · general_liability.policy_expiration_date · general_liability.additional_insured · general_liability.subrogation_waived · general_liability.each_occurrence · general_liability.personal_adv_injury · general_liability.general_aggregate · general_liability.products_comp_op_agg · auto_liability.policy_expiration_date · auto_liability.additional_insured · auto_liability.combined_single_limit · umbrella_excess.policy_expiration_date · umbrella_excess.each_occurrence · umbrella_excess.aggregate · workers_comp.policy_expiration_date · workers_comp.subrogation_waived · workers_comp.el_each_accident.

Not read from the certificate at all

Five ways a requirements check passes when it shouldn’t

Each of these is a profile a person could plausibly send. A checker that evaluates only the rules it recognizes will evaluate none of them — and report the certificate as compliant.

  1. {"general_liablity": {"each_occurrence_min": 1000000}}

    Why a naive checker passes it: the misspelled key matches no section, so no section is checked.

    Ours: 400 unknown_requirement_keys — names the key and lists the supported ones.

  2. {"general_liability": {"each_occurence_min": 1000000}}

    Why a naive checker passes it: the section is recognized, the misspelled field inside it is not, and the section has nothing left to check.

    Ours: 400 invalid_requirement_value — “Unrecognized field(s) in "general_liability": "each_occurence_min".”

  3. {"general_liability": {"each_occurrence_min": "$1,000,000"}}

    Why a naive checker passes it: a string compared against a number is never “below the minimum”, so the check can never fail.

    Ours: 400 invalid_requirement_value — must be a JSON number. Full response below.

  4. {"workers_comp": {"required": false}}

    Why a naive checker passes it: a flag set to false requires nothing, so a section of only false flags can never produce a deficiency — not even for a blank page.

    Ours: 400 invalid_requirement_value — “A flag set to false is not a check…”

  5. {}   ·   {"name": "GC standard"}   ·   {"general_liability": null}

    Why a naive checker passes it: there are no rules at all — so every certificate, and every document that is not a certificate, passes.

    Ours: 400 empty_requirements — “Requirements contained no rules to evaluate…”

The third case in full, exactly as the API served it:

response
HTTP/1.1 400
{
  "error": "invalid_requirement_value",
  "message": "\"general_liability.each_occurrence_min\" must be a finite, non-negative JSON number (e.g. 1000000), but got a string. A quoted amount like \"$1,000,000\", null, or a boolean cannot be compared against a dollar limit -- it silently compares as \"not below the minimum\" and returns \"compliant\" without checking anything."
}

The error explains itself on purpose. A developer who gets a bare 400 fixes the symptom; one who is told why the value was refused — that it would have returned “compliant” without checking anything — fixes the same mistake everywhere else it appears in their code.

The rule behind all five: fail loud

Every accepted /verify response also carries evaluated — the requirement groups that were actually checked, computed from the same test the rules engine uses. A clean result is never ambiguous about what it covered. If you build this yourself, return the same list: an absence of findings is not an absence of checking.

Using it with the API

Send the profile inline as requirements on POST /verify, or save it once with POST /requirements and pass the returned requirements_id after that. Both paths run the same validation, so a saved profile cannot be broken in a way an inline one could not.

curl
curl -X POST "https://certificate-of-insurance-verification-compliance-check-api.p.rapidapi.com/verify" \
  -H "content-type: application/json" \
  -H "X-RapidAPI-Key: $RAPIDAPI_KEY" \
  -H "X-RapidAPI-Host: certificate-of-insurance-verification-compliance-check-api.p.rapidapi.com" \
  -d '{
    "file_url": "https://aerviklabs.com/research/coi-honesty-test/trap-coi-blank-personal-adv.png",
    "requirements": {
      "general_liability": { "each_occurrence_min": 1000000, "additional_insured_required": true },
      "min_days_until_expiration": 30
    }
  }'

file_url takes an image of the certificate. Send a PDF as base64_image with "mime_type": "application/pdf" — the API page has Python and Node versions and the full response shape.

Or build a profile and run it against a real certificate right now, free and without signing up: the COI checker uses this schema and runs the same code.

What this is not

It is not advice on what to require. Which limits a contract should demand is a question for your broker and your counsel; this schema only makes sure that whatever you decided is actually checked. And a requirements check reads a certificate, not a policy — it cannot confirm that coverage is in force. See what a certificate of insurance actually proves.

What makes a certificate deficient · When a certificate can’t be read · The COI Extraction Honesty Test

Last updated . Corrections: see About.