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:
- 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.
- Email with a link and a login. The common case. You need a session, a fetch, and a parse.
- 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.
| column | meaning |
|---|---|
holder_id | the certificate holder as your AMS knows it |
tracker | myCOI, Evident, Ebix, a carrier-agnostic portal, or email_only |
portal_url | the login entry point |
secret_arn | Secrets Manager entry holding the agency's credentials |
intake_mode | email_body, portal_scrape, or api |
accounts | which insureds are tracked by this holder |
last_success_at | set 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.