Pull certificate requests out of a COI tracking portal

The certificate request that costs the most is not the one in your mailbox. It is the one that arrives as a link.

A general contractor's compliance department uses a third-party certificate tracking service. Your insured is a subcontractor on their job. The notice lands in the certificates mailbox with almost nothing in it: a job name, a deadline, and a button that says "Upload Certificate". The requirements, the holder wording, the endorsement list, and the rejection history all live behind the login on the other side of that button.

The mailbox pipeline cannot read any of it. So a CSR logs in, reads a requirement grid, goes back to the AMS, checks the policy, issues an ACORD 25, uploads it, and three days later the tracker rejects it because the holder name on the certificate is missing an "LLC". That loop runs two to four times per holder on some accounts, and none of it is visible in the activity log.

This tutorial covers the intake side of that loop: getting the requirement grid out of a tracking portal and into the same structure your email-based certificate pipeline already uses, then handling the rejection that comes back. The policy check and the certificate itself do not change. What changes is that the agent can see the requirements at all.

Three shapes of portal intake

Before writing any code, work out which of these each holder uses. The engineering differs:

  1. Email with a full requirement summary. Some trackers put the whole grid in the notification body. Parse the email, skip the portal. Always check for this first: if the grid is in the body, the portal work disappears for that holder.
  2. Email with a link and a login. The common case. You need a session, a fetch, and a parse.
  3. Vendor API access. A few tracking services will issue API credentials to an agency on request. If one exists for a holder, use it and delete the scraper. Do not plan an engagement around the assumption that one will exist.

Step 0: build the portal registry

Do not let portal knowledge live in a CSR's browser bookmarks. Make a table.

columnmeaning
holder_idthe certificate holder as your AMS knows it
trackermyCOI, Evident, Ebix, a carrier-agnostic portal, or email_only
portal_urlthe login entry point
secret_arnSecrets Manager entry holding the agency's credentials
intake_modeemail_body, portal_scrape, or api
accountswhich insureds are tracked by this holder
last_success_atset by every successful fetch

The registry is also the report. Sort by accounts descending and you know which four portals are worth automating and which nineteen should stay manual. Expect a short head and a long tail: build the two or three portals that carry most of the volume, and leave the rest manual.

Step 1: credentials that belong to the agency

Use an agency-owned login per portal, created by the agency, stored in Secrets Manager, never a personal CSR account. When an account manager leaves, the automation keeps working and the offboarding checklist does not have to know about it.

Two practical points. First, most trackers enforce MFA; get the portal set to a TOTP secret the agency holds rather than an SMS code to somebody's phone, and generate the code in the job. Second, check the portal's terms of use before you automate a login, and have the agency confirm they accept them. That is a decision for the principal, not for the engineer.

import pyotp
from playwright.sync_api import sync_playwright

def portal_session(secret, on_page):
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        ctx = browser.new_context(storage_state=secret.get("storage_state"))
        page = ctx.new_page()
        page.goto(secret["portal_url"], wait_until="networkidle")

        if page.locator("input[name='password']").count():
            page.fill("input[name='username']", secret["username"])
            page.fill("input[name='password']", secret["password"])
            page.click("button[type='submit']")
            if page.locator("input[name='otp']").count():
                page.fill("input[name='otp']", pyotp.TOTP(secret["totp"]).now())
                page.click("button[type='submit']")
            page.wait_for_load_state("networkidle")

        result = on_page(page)
        state = ctx.storage_state()
        browser.close()
        return result, state

Persist storage_state back to the secret after each run. Reusing the session is the difference between one MFA prompt a month and one a night, and portals notice the difference.

Step 2: normalize the grid into your existing request schema

This is the step that keeps the project small. A portal requirement grid and a well-written email request are the same object. Parse into one schema and everything downstream, the policy check, the ACORD 25 fill, the approval queue, works unchanged.

from dataclasses import dataclass, field

@dataclass
class CertRequest:
    source: str                  # "mailbox" | "portal:evident" | "api:mycoi"
    source_ref: str              # message id or portal request id
    insured_name: str
    holder_name_raw: str         # exactly as the holder spells it
    holder_address_raw: str
    project_ref: str | None      # job name, contract number, PO
    due_date: str | None
    lines: list[str] = field(default_factory=list)      # GL, AUTO, WC, UMB
    limits: dict[str, int] = field(default_factory=dict)
    requirements: list[str] = field(default_factory=list)  # AI, PNC, WOS, 30-day notice
    required_forms: list[str] = field(default_factory=list)  # "CG 20 10", "CG 20 37"
    raw_grid: dict = field(default_factory=dict)        # keep the source rows verbatim
    fetched_at: str = ""

Two fields earn their place. holder_name_raw and holder_address_raw are stored exactly as the portal spells them, punctuation and all, because a mismatch there is the single most common rejection reason and your AMS holder record is often the wrong version. raw_grid keeps the original rows so a reviewer can see what the agent read without logging back into the portal.

Extract the grid deterministically where the portal renders a real table. Fall back to a schema-constrained model call only on free-text requirement blocks, and keep the source text attached to every field it produced.

Step 3: nothing new happens in the middle

The request now goes through the same path as any other: match to the AMS account and policy, check each requirement against the endorsement forms on the policy, produce the answer with a citation, and stop. If the policy does not carry the wording, the outcome is not a certificate. It is a drafted note to the producer that this holder wants a blanket additional insured endorsement the policy does not have, which is a carrier conversation and a licensed decision.

The agent drafts. A licensed person approves. That does not loosen because the request came from a portal.

Step 4: uploading is a send

Treat the portal upload exactly like sending an email. It goes through the approval gate, it is recorded as a send, and it writes an AMS activity with the portal name, the request id, the approver, and a copy of the uploaded PDF.

One portal-specific hazard: many trackers accept an upload and then queue it for their own review, so a successful upload is not an accepted certificate. Record the upload as submitted, not as complete, and let the status come from Step 5.

Step 5: parse the rejection, then classify it

The rework loop is where the hours actually go, and it is the part nobody instruments. Poll each open portal request on a schedule and classify the rejection reason into three buckets, because they have three different owners:

COSMETIC = [
    "holder name", "legal name", "address", "certificate holder box",
    "description of operations", "expiration date", "wrong form",
]
COVERAGE = [
    "additional insured", "primary and non-contributory", "waiver of subrogation",
    "limit", "aggregate", "umbrella", "endorsement", "notice of cancellation",
]

def classify_rejection(reason: str) -> str:
    text = reason.lower()
    if any(k in text for k in COVERAGE):
        return "coverage"        # producer or carrier; never an auto-redraft
    if any(k in text for k in COSMETIC):
        return "cosmetic"        # agent redrafts, CSR approves
    return "unknown"             # a person reads it

Cosmetic rejections are the ones worth automating: the agent corrects the holder block from holder_name_raw, regenerates the certificate, and puts one approval in front of a CSR. Coverage rejections must never trigger an automatic redraft, because the only way to satisfy them is to change what the policy says or to tell the holder no. unknown goes to a person and, if the phrase repeats, becomes a new rule next week.

Count all three by holder. A tracker that rejects 40% of your certificates for holder-name formatting is a data-cleanup project in the AMS, not an agent problem, and the count is how you prove it.

What this will not do

  • It will not negotiate with a compliance reviewer, and it will not appeal a rejection.
  • It will not create, request, or promise an endorsement.
  • It will not upload anything a licensed person has not approved.
  • It will not survive a portal redesign on its own. Same posture as any carrier-portal connector: assert on the shape of what you fetched, alert the morning it changes, and fall back to the manual queue rather than guessing.
  • It will not work at all on portals whose terms forbid automated access. Check first.

Where this fits

Portal intake is the unglamorous half of certificate work, and it is usually the half that is invisible in any measurement of how long certificates take, because the CSR's twenty minutes inside a compliance portal never became an activity. Instrument it before you automate it: two weeks of counting requests, rejections, and reasons per holder will tell you whether this is worth building on your book.

We build this as part of COI Agent work, inside the agency's own AWS account, hourly and time-and-materials. If certificate rework is eating your service team, contact us.