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_mingeneral_aggregate_minproducts_comp_op_agg_minpersonal_adv_injury_minnumber ≥ 0additional_insured_requiredwaiver_of_subrogation_requiredtrueEach limit on the certificate is at least the minimum; each required flag is marked.
auto_liability-
combined_single_limit_minnumber ≥ 0additional_insured_requiredtrueSame, for automobile liability.
umbrella_excess-
each_occurrence_minaggregate_minnumber ≥ 0Same, for umbrella or excess liability.
workers_comp-
el_each_accident_minnumber ≥ 0requiredwaiver_of_subrogation_requiredtrueWorkers' 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.
0means “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:
{
"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:
- Primary and non-contributory wording.
- Ongoing versus completed-operations additional-insured status, or which endorsement form is attached.
- A waiver of subrogation on the automobile or umbrella lines, or additional-insured status on the umbrella line.
- Anything written in the Description of Operations box. The service does not evaluate it.
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
- Whether the general liability aggregate applies per policy, per project or per location.
- The workers' compensation box asking whether any proprietor, partner or executive officer is excluded.
- The authorized representative's signature.
- An attached Additional Remarks schedule (ACORD 101).
- Whether the document is an ACORD 25 at all: other certificate forms are read on a best-effort basis that has not been verified.
- Whether the certificate is genuine: a forged or edited certificate that prints passing values reads as compliant.
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.
-
{"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. -
{"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".” -
{"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. -
{"workers_comp": {"required": false}}Why a naive checker passes it: a flag set to
falserequires 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…” -
{} · {"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:
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
- An unrecognized key is an error, at every level. Never an ignored field.
- A limit is a number.
1000000, never"$1,000,000",nullortrue. - A rule that cannot fire is not a rule. Omit a flag you don’t require instead of sending
false. - An empty profile is refused. “Check this certificate against nothing” is never what anyone means.
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 -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