Reconcile the IVANS download against what your AMS already says

Carrier download is the one integration every commercial agency already has. Policies, endorsements, cancellations, and renewals arrive nightly from IVANS as AL3 transactions, the AMS applies what it can, and the rest lands in a download queue that somebody works through on Tuesday. On a book with 1,200 commercial policies that queue is rarely empty.

The queue is not the real problem. The real problem is the transactions that apply cleanly and change something nobody looked at: a limit that dropped at renewal, a premium that moved 40 percent, an additional insured endorsement that came off the policy while nine certificate holders are still relying on it. The AMS records the new value. It does not tell you the old value mattered.

This tutorial builds the missing piece: a job that reads the download transaction, compares it to the policy as your AMS held it before the transaction applied, classifies the difference, and writes one activity per material change. No sends, no edits to the policy record. An exception list a licensed person works.

You need access to your agency's download feed or to the AMS records the feed produced, read access to policy data through your management system's API, and somewhere to run a nightly job.

What an AL3 transaction actually gives you

AL3 is a fixed-position, group-based format, not XML and not CSV. A transaction is a sequence of groups; each group has a four-character identifier, a length, and positional fields defined by the ACORD AL3 data dictionary for that group and version. A commercial policy transaction typically carries a transaction control group, a policy-level group, one or more line-of-business groups, and coverage groups underneath them.

Two practical notes before you write a parser.

First, do not write a parser. The dictionary is large, versioned, and carrier implementations vary in the optional groups they populate. Agencies get AL3 through their AMS or through a commercial library; if you can read the applied result out of the AMS instead of the raw file, do that and skip this section. Parse raw AL3 only when you need the pre-apply view the AMS will not give you back.

Second, the field you care about most is the transaction type code on the control group. It tells you whether you are looking at a new business issue, a renewal, an endorsement, a cancellation, a reinstatement, or an audit. Everything downstream branches on it, and treating a cancellation like an endorsement is how a non-renewal gets missed.

A minimal reader, assuming a library that hands you groups as dictionaries:

from collections import defaultdict

def index_transaction(groups):
    """Group an AL3 transaction by group id for lookup."""
    out = defaultdict(list)
    for g in groups:
        out[g["group_id"]].append(g["fields"])
    return out

def transaction_summary(idx):
    ctl = idx["2TRG"][0]
    pol = idx["5POL"][0]
    return {
        "transaction_type": ctl.get("transaction_type_code"),
        "policy_number": pol.get("policy_number"),
        "carrier_naic": pol.get("naic_code"),
        "effective": pol.get("effective_date"),
        "expiration": pol.get("expiration_date"),
        "premium": pol.get("full_term_premium"),
    }

Group identifiers differ by AL3 version. Read them from your dictionary, not from this page.

Snapshot the policy before the download applies

The diff needs a before. Download applies overnight, so the snapshot has to be taken on a schedule that runs ahead of it, not after.

Run a nightly export of the fields you intend to compare, keyed by policy number and carrier, and keep 90 days of it. Small table, cheap storage, and it is also the only way to answer "when did this limit change" six months later when a claim is denied.

SNAPSHOT_FIELDS = [
    "policy_number", "carrier_naic", "effective_date", "expiration_date",
    "full_term_premium", "status", "gl_occurrence_limit", "gl_aggregate_limit",
    "auto_csl", "umbrella_limit", "wc_el_each_accident", "additional_insured_endorsements",
    "waiver_of_subrogation", "primary_and_noncontributory",
]

def snapshot(ams, policy_ids, run_date):
    for pid in policy_ids:
        p = ams.get_policy(pid)
        store.put(
            key=(p["policy_number"], p["carrier_naic"], run_date),
            value={f: p.get(f) for f in SNAPSHOT_FIELDS},
        )

Snapshot by policy number plus carrier NAIC, not by AMS internal id. Renewals sometimes arrive as a new policy record, and you still want the expiring term on the other side of the diff.

Classify the difference, do not just report it

A raw field-by-field diff on a commercial policy produces noise: formatting changes, a reordered address line, a premium that moved by four dollars on an endorsement. Classify instead. Each rule names the field, the test, the severity, and who it goes to.

ChangeTestSeverityRoutes to
Coverage limit decreasedany limit field lower than snapshothighAccount manager, same day
Additional insured endorsement removedendorsement present in snapshot, absent nowhighCSR who owns certificates
Premium moved more than 15 percentabs(delta) / prior > 0.15highProducer
Cancellation or non-renewaltransaction type in cancel sethighAccount manager, same day
Deductible increaseddeductible above snapshotmediumAccount manager
Renewal issued with no changetype renewal, no field deltaslowLog only
Address or contact editdemographic fields onlylowLog only

The low rows matter as much as the high ones. If everything is an exception, the list gets ignored by week three. Aim for a queue an account manager can clear in fifteen minutes.

def classify(before, after, txn_type):
    findings = []
    for field in LIMIT_FIELDS:
        old, new = money(before.get(field)), money(after.get(field))
        if old and new and new < old:
            findings.append(("high", f"{field} decreased from {old} to {new}"))
    lost = set(before.get("additional_insured_endorsements") or []) - set(
        after.get("additional_insured_endorsements") or []
    )
    for endt in sorted(lost):
        findings.append(("high", f"additional insured endorsement {endt} no longer on policy"))
    prior, now = money(before.get("full_term_premium")), money(after.get("full_term_premium"))
    if prior and now and abs(now - prior) / prior > 0.15:
        findings.append(("high", f"full term premium moved {prior} to {now}"))
    if txn_type in CANCEL_TYPES:
        findings.append(("high", "cancellation or non-renewal received on download"))
    return findings

Compare money as decimals, never as strings, and normalize limit fields that carriers express in thousands.

Write one activity, attached to the policy

One transaction produces one activity, not one per finding. A CSR reading a download exception wants the whole picture in a single record: policy, transaction type, findings in severity order, and the link to the document the carrier sent.

Write it through the published API: an activity in Applied Epic, a log note in HawkSoft, a task in EZLynx. Key the write on the transaction's control number so a reprocessed batch does not create a second copy; the pattern is the one in writing agent output back to the AMS without duplicates.

The agent does not edit the policy record, reverse the download, or email the carrier. It reports and routes. Anything that leaves the agency, an email to the underwriter asking why the endorsement dropped, a reissued certificate, waits for a licensed person to approve it.

The certificate consequence

One class of finding deserves its own path. When an additional insured or waiver of subrogation endorsement comes off a policy on download, every active certificate referencing it is now wrong, and the agency issued those certificates. Join the finding against open certificate holders on that policy and put the holder count in the activity subject. "AI endorsement removed, 9 active holders" gets worked. "Policy change received" does not. The checking logic is in checking an additional insured request against the policy, and the reissue side is in reissuing certificates to every holder when a policy renews.

Run it silently first

Point the job at 30 days of historical download and the matching snapshots, and read the exceptions it would have raised. You are measuring two numbers: how many findings a senior account manager agrees were worth surfacing, and how many real changes the rules missed. Tune thresholds until the first number is high enough that people keep reading the queue. That is the same scoring method we use before any go-live, described in scoring a silent run before go-live.

Download reconciliation is unglamorous and it is where a lot of commercial-lines E&O exposure actually sits. If you want a senior engineer to build this against your Applied Epic, HawkSoft, or EZLynx data, contact us.