Extraction gets the attention. A certificate request arrives, the agent reads it, matches the account, checks the additional insured wording. Then someone still has to produce the actual ACORD 25 and put it in front of a CSR.
On most engagements that last step is where the AMS is doing the least for you. Epic and EZLynx will issue a certificate from their own certificate module, but only from data already keyed there, in their own sequence, one holder at a time. When the agent has assembled the holder block, the policy numbers, the limits, and the endorsement verdict from four systems, you want that filled onto the form as a document, checksummed and queued, so the CSR reviews one PDF and clicks once.
This tutorial fills an ACORD 25 (2016/03) AcroForm from a structured payload in Python, flattens it, and attaches it to an approval item. It does not send anything. It does not decide whether the coverage is correct. That decision was made upstream, and a licensed person confirms it downstream.
Get a fillable form you are licensed to use
ACORD forms are copyrighted and licensed. Your agency has access to the current fillable PDFs through its ACORD membership or through the AMS vendor; use those, not a copy found on a holder's website. Two reasons beyond the license: revision dates matter to the certificate holders who audit them, and a scraped PDF is often a flattened print, with no form fields at all.
Confirm you have real fields before writing any mapping code:
from pypdf import PdfReader
reader = PdfReader("acord25_2016-03.pdf")
fields = reader.get_fields()
print(len(fields))
for name, f in list(fields.items())[:20]:
print(name, "|", f.get("/FT"), "|", f.get("/V"))
If get_fields() returns nothing, stop. You have a scan, and the rest of this does not apply.
Field names are the integration, so pin them
ACORD AcroForm field names are stable within a revision and unhelpful to read. You will see things like Text8 next to PRODUCER_FULLNAME_A, depending on who prepared the file. Do not guess them from the visual layout. Dump every name once, fill each field with its own name as the value, and print the result:
from pypdf import PdfWriter
writer = PdfWriter(clone_from="acord25_2016-03.pdf")
page = writer.pages[0]
writer.update_page_form_field_values(
page, {name: name[:18] for name in reader.get_fields()}
)
with open("acord25_fieldmap.pdf", "wb") as fh:
writer.write(fh)
Open that PDF and you have a labeled map of the form. Record the mapping in a version-controlled YAML file keyed by form revision, not in the code:
form: ACORD_25
revision: "2016/03"
fields:
holder_name: CERTIFICATE_HOLDER_FULLNAME_A
insured_name: NAMED_INSURED_FULLNAME_A
producer_name: PRODUCER_FULLNAME_A
gl_policy_number: POLICY_NUMBER_COMMLIABILITY_A
gl_eff_date: POLICY_EFFECTIVEDATE_COMMLIABILITY_A
gl_exp_date: POLICY_EXPIRATIONDATE_COMMLIABILITY_A
gl_each_occurrence: LIMIT_COMMLGENLIABILITY_EACHOCCURRENCE_A
description: DESCRIPTION_OF_OPERATIONS_A
When ACORD publishes a new revision, you add a second mapping file. You do not touch the filler.
Build the payload from the AMS, not from the request
The holder's email is the trigger. It is not the source of the policy data. Every value that ends up on the certificate comes from the AMS record, read at fill time, with the record ID and read timestamp carried alongside it:
@dataclass(frozen=True)
class CertPayload:
request_id: str
account_id: str
holder_name: str
holder_address: str
insured_name: str
insured_address: str
producer_name: str
policies: dict # line -> {number, eff, exp, limits, carrier, naic}
description: str
ai_verdict: str # "on_policy" | "not_found" | "needs_review"
ai_citation: str | None
source_read_at: str
Two rules hold on every engagement. First, if ai_verdict is anything other than on_policy, the additional insured and waiver checkboxes stay unchecked and the item routes to a CSR with the reason attached. The agent does not tick a box it could not evidence. Second, the description of operations is drafted from the holder's requested wording but never invented; if the request asks for language the policy does not support, that goes in the review note, not on the form.
Fill, then flatten
def fill_acord25(template: str, mapping: dict, payload: CertPayload) -> bytes:
writer = PdfWriter(clone_from=template)
values = build_values(mapping, payload) # dict of pdf_field -> string
for page in writer.pages:
writer.update_page_form_field_values(page, values, auto_regenerate=False)
writer.set_need_appearances_writer(True)
buf = io.BytesIO()
writer.write(buf)
return buf.getvalue()
Three details that cause support tickets:
- Dates. ACORD 25 wants
MM/DD/YYYY. Format them once, inbuild_values, from date objects. Never pass through whatever string the AMS returned. - Limits. Right-aligned currency without decimals, e.g.
1,000,000. Agree the format with the agency and keep it in one function. - Checkboxes. The on/off value is per-field, not universally
/Yes. Read the/APdictionary for each checkbox once and store the export value in the mapping file.
Flatten before anyone sees it. An unflattened certificate is an editable certificate, and a holder who can retype a limit is an E&O problem you handed out yourself. pypdf flattens on write with flatten=True in recent versions; if you are pinned to an older one, run the output through a pdftk or qpdf step in the same task. Then hash it:
pdf_bytes = fill_acord25(template, mapping, payload)
sha = hashlib.sha256(pdf_bytes).hexdigest()
The hash is what makes the approval honest. The CSR approves a specific document, and the send step refuses to transmit any file whose hash does not match the approved one.
Queue it, do not send it
The filled PDF, the payload, the mapping revision, and the hash go into the approval item, which is where the human gate picks it up. Store the document in the agency's own S3 bucket, keyed by request, and write the activity into the AMS at the same time so the account history shows a certificate was prepared even if it is later declined.
What the reviewer should see, in this order: the rendered PDF, the holder's original request, the endorsement citation behind the additional insured verdict, and a short list of every field that came from something other than a direct AMS read. On a clean request that list is empty and the review takes under a minute. On a messy one it is the whole point of the screen.
Test it against certificates you already issued
Pull 100 certificates the agency issued in the last quarter, along with the requests that produced them. Re-run the filler against the same accounts and diff field by field against what actually went out. You are looking for three categories: exact matches, formatting differences that no holder would reject, and content differences. Only the third category matters, and each one gets read by a person before you ship anything.
Expect the first pass to disagree on descriptions of operations more than on anything else. That is normal, and it is why the description field stays reviewer-facing rather than becoming a template.
What this does not do
It does not decide coverage. It does not add an insured to a policy, request an endorsement, or contact a carrier. It does not replace the AMS certificate module for renewals and holder lists that already live there; on Epic in particular, keep issuing recurring holder certificates where the AMS tracks them, and use this path for the inbound one-off requests that currently sit in a mailbox. And it does not send. A licensed person approves the document, by hash, before it leaves the agency.