Process automation
Vet clinic reconciliation: an Animana to InfoMedics agent
How a 36-vet Tilburg chain stopped hand-matching 2,840 Animana rows against InfoMedics every week, and what we got wrong about the €400 sign-off.

It is Monday, 07:40, at the head practice of a four-clinic veterinary chain in Tilburg. The office manager opens her browser and starts the weekly ritual she has run for four years: export the previous week from Animana, download the matching declaratie batch from InfoMedics, and start matching them row by row. Last Friday a senior partner wrote off €1,840 because nobody could find the original verrichting in either system. That was the call we got.
The shape of the problem
The chain runs 36 people across four clinics in the Tilburg region. Each week they generate roughly 2,840 verrichtingen in Animana: consults, vaccinations, dental work, chip implants, the occasional orthopaedic surgery. Those rows have to be reconciled against the declaratie pipeline at InfoMedics, the third party that invoices pet owners and chases payment.
Each row has a NHV code, a price, a date, an animal_id, an owner_id, and a free-text note. Matching should be trivial. It is not. Vets type free-text codes for novel procedures. A senior partner adjusts a price in Animana after the fact while InfoMedics has already shipped that day's batch. An owner moves between clinics and the chart follows on paper, but the IDs do not merge cleanly.
The result, before we showed up: about four to six percent of rows fell out of automatic match every week. That is roughly 140 rows weekly that someone had to chase by hand. At twenty minutes per bad row, that is a full working day every week, plus a Friday afternoon of cross-checking, plus the occasional write-off when nobody could resolve a row in time.
Limits of a join-key script
This problem looks like the kind you solve with a Python script and a join key. We tried that first. It catches the easy 94 percent, which is also the part that was not burning anyone out. The leftover rows live where the messy human signal is, and they need judgment, not a fuzzy-match threshold.
Three constraints made those rows resistant to a naive script.
First, the €400 rule. Vet liability law in the Netherlands, plus house policy at this chain, means any verrichting over €400 has to be confirmed by the responsible veearts before the declaratie ships. You cannot quietly re-match and resubmit a row tagged for orthopaedic surgery just because the prices align.
Second, the chip-registratie sync. Chip implants have to be registered in a national database within two weeks of placement. The chip ID lives in Animana, the implantation date in the patient file, and the owner contact in InfoMedics. A reconciler that does not tie those three together leaves chips unregistered and pets unfindable.
Third, the audit trail. Vet practices get audited by their accountant and, periodically, by the NVWA. Every change to a financial record needs a who, a when, and a why. A black-box reconciler is unauditable, which means in practice it gets switched off the first time a partner cannot explain a row.
The architecture we ended up with
The agent runs on a small VPS. Every Monday at 06:00 Amsterdam time it pulls the previous week's verrichtingen from Animana and the matching InfoMedics declaratie batch. Both arrive as CSV. They get loaded into a single Postgres table with a status column and a rules_version column.
Match logic happens in three passes.
Pass one is an exact join on (animal_id, code, date, price). About 92 percent of rows close here. Pass two is a fuzzy join on (animal_id, date ±2 days, price ±5%). Another four to five percent close here, each tagged with a confidence score and a reason. Pass three sends everything left to a queue.
The queue is the part that turned this from a script into an agent. Each unmatched row gets a context bundle: the original Animana entry, the closest InfoMedics candidate, the patient file note from that day, and the previous three visits for that animal. The agent writes a one-sentence neutral summary of what it thinks happened, then proposes an action: re-match to candidate X, void the declaratie, or hold for vet review.
def classify_row(row, candidates, patient_file):
if row.price >= 400:
return Action.HOLD_FOR_VET, "Over \u20ac400, requires veearts sign-off"
if not candidates:
return Action.VOID, f"No matching declaratie for {row.code} on {row.date}"
best = candidates[0]
if best.confidence >= 0.85:
return Action.RE_MATCH, f"High-confidence match to {best.id}"
return Action.HOLD_FOR_REVIEW, summarize(row, candidates, patient_file)
The queue lives in a small internal web app, not embedded inside Animana (we tried; see below). The practice manager sees 15 to 25 rows on a Monday morning. Each row has a one-click approve, edit, or escalate. Approvals push back to InfoMedics through their REST endpoint. Escalations notify the responsible veearts in their Animana inbox and on their phone.
The €400 approval queue, in detail
This is the part we got wrong the first time. We assumed the €400 rule was a soft check. It is not. It is a liability question. The original prototype let the practice manager approve any row, including high-value ones. The senior partner stopped using the system in week two because she could not prove, on audit, that she had personally signed off on the orthopaedic verrichtingen. The audit trail showed the practice manager's name where her name should have been.
We rebuilt the approval flow around named signatories. Every veearts has a row in a vets table with their BIG-registration number and a named backup. Rows over €400 route by the verrichting's responsible_vet_id to that vet, not to a generic queue. The veearts approves from their phone, the signature gets stamped with their BIG number and a timestamp, and the row state moves to vet_approved before it can ship to InfoMedics. Rows that sit unsigned for more than 48 hours fall through to the backup and both signatures are stored on the audit row.
Liability-bound approvals cannot share a queue with operational ones. The moment the signing vet cannot point at one screen and say "those are the ones I approved", they stop trusting the system. Build the approval surface around the named human, not around the data.
The chip-registratie cron
The chip-registratie sync runs as a separate weekly job on Friday at 18:00. Why separate? Two reasons.
Different failure mode. A missed reconciliation costs money the practice can recover next week. A missed chip registration costs trust and, eventually, a fine. Different urgency, different alerting, different on-call. Mixing them puts the noisy alerts on top of the quiet but important ones.
Idempotent by design. Chip registrations are write-once. The cron pulls every chip implanted in the previous seven days, checks whether the registry already has it through their lookup endpoint, and posts only the new ones. Run it twice on the same week and nothing duplicates.
The registry endpoint is XML-over-SOAP, which is a fun reminder that Dutch government-adjacent systems were built in 2009 and have no urgent plans to change. The cron uses a thin wrapper around python-zeep to handle the envelope. The full payload, including owner contact, sits in roughly 4 KB of XML per chip. We log every post and every response to a flat file the practice keeps for seven years, per their auditor's instruction.
Three things we got wrong
First, we built the queue UI on top of Animana with an iframe embed. The idea was that the practice manager already lived there, so why move her. Animana's frame contract changed silently twice in the first month and our embed broke both times. We pulled the queue out into a standalone tab and the maintenance burden dropped to zero.
Second, we underestimated how much the vets cared about the wording in the agent's summary line. Phrases like "probable duplicate" or "likely mismatch" read as accusations. We rewrote the summary template to be neutral and factual: "Two rows share animal_id and date; prices differ by €12." Adoption climbed the same week.
Third, we did not version the matching rules. When a vet asked "why did this row close last week and not this week", we could not answer. We now store the rules as a JSON document with a version number, and every match decision references the version that produced it. Every report can be re-run against the rules that were live at the time. Audit-ready.
Eleven weeks in
The numbers, current as of last Friday.
- 2,840 average rows per week. About the same weekly volume as before.
- 132 rows per week routed to the queue. Down from the four to six percent baseline because pass two catches a chunk that previously fell through.
- 4.5 hours per week of practice-manager time on the queue, down from a full day. Most of it is just clicking approve.
- 14 chip-registratie posts per week to the national registry. Zero misses in eleven weeks.
- One audit note from the chain's accountant: positive. The reconciler's audit log was cited as best practice.
The senior partner now signs €400+ verrichtingen on her phone during her commute. The practice manager runs the queue before the first consult on Monday and is done before coffee.
The right place to draw the human line is wherever the liability lives. Automate the matching, route the judgment to the named signatory, and keep the audit log boring.
What this is, and what it is not
This is not an AI miracle. It is a reconciler with a queue, an approval flow, and a weekly SOAP cron. The agent part, the bit that summarises an unmatched row in one sentence and proposes an action, is maybe eight percent of the codebase. The other 92 percent is plumbing: the Postgres schema, the Animana export client, the InfoMedics REST integration, the SOAP envelope for the chip registry, the audit log, the email notification, the version-controlled rules, and the small standalone web app the practice manager actually clicks in.
That ratio is the honest one for most process automation work in healthcare-adjacent industries. The headline goes to the model. The hours go to the integrations and the boundaries.
When we built this for the Tilburg chain, the thing we ran into was the liability boundary. We ended up solving it by giving each veearts their own approval surface tied to their BIG number, so the agent never holds a signing authority that does not belong to a named human. The same pattern shows up in most reconciliation work we ship, and it is usually the thing that decides whether the system gets used in month three or quietly switched off.
The smallest thing you could do today: open the spreadsheet your team reconciles every Monday morning, and circle the rows where the liability lives. That circle is where your queue belongs.
Key takeaway
Draw the human line where the liability lives: automate the matching, route the judgment to the named signatory, and keep the audit log boring.
FAQ
Why not just run a Python script with a fuzzy join?
A naive join catches about 94 percent of rows. The remaining six percent need judgment, vet sign-off on high-value rows, and an audit trail. Those are not problems a join key solves on its own.
How does the €400 sign-off handle vet vacations?
Each veearts has a named backup in the vets table. Rows that sit unsigned for more than 48 hours route to that backup, and both signatures are stored on the audit row so the chain of approval stays intact.
Can the same setup work for other practice management systems?
The matching and queue layers are system-agnostic. We have repointed similar reconcilers to other PMS exports. The integration layer, meaning the export endpoint and the regulator sync, is the part that needs rewriting per client.
What happens when InfoMedics changes their API?
The client is versioned and pinned to a specific contract. We monitor their changelog and alert on any unexpected response code. A version bump is a code change with a test suite, never a silent upgrade in production.