Process automation
Payroll automation incident: 1,840 payslips, wrong DigiD
An incident walkthrough: 1,840 loonstroken posted to the wrong employees after a vendor tenant-merge silently re-issued GUIDs. What broke, and the gate we now run.

It was a Tuesday in May, 09:42 CEST. The controller at a 26-person payroll bureau in Tilburg opened her support inbox and saw nine emails inside two minutes, all variations of: "I just downloaded my loonstrook in MijnOverheid and it isn't mine." Her bureau's payroll-automation agent had dispatched the run the night before.
By 09:48 we had the number. The agent had posted 1,840 loonstroken to the wrong employee-DigiD pairings the night before, in one batch, through a clean SBR submission that returned 200 OK and a signed receipt from the Belastingdienst. Nothing in the pipeline had flagged a thing. The Loonaangifte itself was structurally valid; the totals reconciled to the cent; the XBRL passed the SBR taxonomy. It was just pointed at the wrong people.
This is the walkthrough.
The bureau, the agent, the stack
The bureau runs payroll for 312 SME werkgevers across Noord-Brabant. Their stack before us was the usual mid-market shape: NMBRS as the system of record, Twinfield for ledger, Outlook for everything else, and a Dropbox folder named "DIT IS DE GOEDE" that nobody trusted but everybody used.
We built them a process-automation agent in late 2025. Its job was unglamorous: every 27th of the month, pull the closed payroll period from NMBRS via the bulk export API, generate per-employee PDFs from the company template, push them to MijnOverheid through the Berichtenbox-koppeling, and submit the Loonaangifte through the SBR-tunnel. Then write a row into a Postgres audit table so the controller could reconcile in the morning.
It had been running cleanly for seven months. November through April: 312 werkgevers, around 4,200 loonstroken a month, zero incidents. Then May.
What the log said at 22:11
The run started at 22:00 on the 27th. By 22:11 the SBR submission had returned its signed receipt and the agent had moved on. The Postgres audit table looked like this for the affected batch:
SELECT werkgever_id, count(*), min(employee_guid), max(employee_guid)
FROM payroll_dispatch_log
WHERE batch_id = '2026-05-27-T2200'
GROUP BY werkgever_id
ORDER BY count(*) DESC
LIMIT 5;
werkgever_id | count | min_guid | max_guid
--------------+---------+----------------+----------------
wg_004412 | 1840 | e1f3a... | 9c0d7...
wg_004413 | 94 | 3b421... | b8e90...
wg_004414 | 71 | c7f10... | d2a44...Nothing screamed. wg_004412 was a logistics holding with a lot of warehouse heads; 1,840 employees was unusual but plausible. The counts matched what NMBRS had returned.
The first thing that gave it away was a single employee writing in. Her name was on her loonstrook, but her BSN wasn't. The BSN belonged to someone at a completely different werkgever, wg_001188, a school in Eindhoven. The PDF rendering was correct. The MijnOverheid routing was wrong.
The stale GUID
The NMBRS bulk export returns one row per employee with a stable EmployeeId (a GUID) that we use as the join key to look up the citizen's DigiD-koppeling in our own mapping table. That table is the only place where employee-GUID becomes BSN becomes MijnOverheid-mailbox.
The lookup was a single function:
def resolve_recipient(employee_guid: str) -> Recipient:
row = db.execute(
"SELECT bsn, mailbox_id FROM employee_digid_map WHERE employee_guid = %s",
(employee_guid,),
).fetchone()
if row is None:
raise UnmappedEmployee(employee_guid)
return Recipient(bsn=row.bsn, mailbox_id=row.mailbox_id)That function ran 1,840 times for wg_004412 and returned a row every single time. No exception. No log warning. Every lookup succeeded.
The problem was deeper. In April 2024, NMBRS had merged two of its multitenant clusters as part of a backend consolidation. Customers were assured the merge was transparent: same EmployeeId values, same API contracts. For 99.7% of records, that was true. For the rest, including a batch of soft-deleted employees from a 2023 werkgever-overname, the post-merge EmployeeId was a re-issue of a GUID that had previously belonged to a different employee at a different tenant.
NMBRS's own documentation calls EmployeeId globally unique within the production environment. It is, today. It wasn't, retroactively, after the merge. The bulk export endpoint returns the canonical present-day GUID. Our mapping table contained the pre-merge GUIDs from when we onboarded that bureau in 2024.
So when the agent looked up e1f3a... for an employee at wg_004412, it got a row. That row pointed at a real BSN with a real MijnOverheid mailbox. It just happened to belong to a teacher in Eindhoven from a payroll bureau we had never worked with.
"Globally unique" in vendor docs almost always means "unique within the live dataset right now." It does not mean "unique across all data the vendor has ever issued you." Treat GUIDs from any system that has been merged, migrated, or re-platformed as namespaced, not global.
Three guards, all silent
We had three guards in place. None of them fired.
The first was a row-count guard: refuse the batch if len(dispatch) != len(nmbrs_export). It passed. Counts matched.
The second was a BSN-format guard: every recipient must have an 11-digit BSN that passes the elfproef. It passed. Every BSN was valid; they just belonged to the wrong people.
The third was sample-and-verify: after rendering, the agent picks five PDFs at random and compares the displayed BSN to the recipient mailbox metadata. This is where it should have caught it. The bug: the PDF template renders the employee's BSN from the NMBRS payload, while the mailbox routing uses the BSN from our local mapping table. The comparison was comparing the mapping table to itself. The PDF was wrong, the metadata was wrong, and they were consistently wrong together.
That last one stung. It was a guard we had specifically written for this class of bug. It was checking the wrong thing.
The 36 hours after
We pulled the agent at 09:51. By 10:30 we had a list of every affected loonstrook and the actual intended recipient. By noon we had recalled the messages from MijnOverheid (Logius supports a recall window for unread Berichtenbox messages; most operators don't know this, see the MijnOverheid integration docs). A small fraction had already been opened. Those we handled by phone, one by one, with the bureau's controller and a privacy officer on the line.
The first call out was the bureau to its 26 affected werkgevers, before any of them heard about it from their own staff. The controller scripted that morning's call: what happened, what data was exposed, what we had already recalled, and what each werkgever needed to communicate to its own employees. Three of the 26 werkgevers handled internal communication themselves; the rest asked us to draft a template letter that they edited and sent. By Wednesday afternoon every affected employee at every werkgever had been contacted by their own employer, not by us. That sequencing mattered for trust more than anything else we did that week.
We filed an AVG-meldplicht datalek notification with the Autoriteit Persoonsgegevens within the 72-hour window. The notification cited the affected categories of data (BSN, salaris, woonadres on some templates), the population (1,840 employees across one werkgever), and the containment timeline. The AP came back with questions about the mapping table provenance and the audit trail. We had both, which we owe to the Postgres audit table being write-once and append-only.
No fines, in the end. One formal warning. A lot of trust to rebuild with one specific bureau.
Auditing the other 311 werkgevers
The night of the 27th, before we built the structural fix, we did an inventory pass on every other werkgever in the bureau's book. The question we needed answered before sunrise: how many stale-GUID rows live in the mapping table for clients we had not yet dispatched to in May?
We re-pulled the NMBRS bulk export for the remaining 311 werkgevers and joined it back to employee_digid_map on employee_guid alone, then compared the BSN we had on file to the BSN NMBRS now reports for that GUID:
SELECT m.werkgever_id, m.employee_guid,
m.bsn AS stored_bsn, e.bsn AS current_bsn
FROM employee_digid_map m
JOIN nmbrs_export_current e
ON e.employee_guid = m.employee_guid
WHERE e.bsn IS DISTINCT FROM m.bsn;The query returned 47 rows across 6 werkgevers. None had been dispatched to in the May batch (they were either soft-deleted on the bureau side or the werkgever had toggled automated dispatch off the previous quarter). They would have fired in June. We froze the automated dispatch path across the entire bureau until the new schema was live and every mapping row had been re-keyed under (werkgever_id, employee_guid). The freeze cost the bureau five working days of manual loonstrook-PDF generation. Cheaper than the alternative.
The fix: a per-werkgever GUID namespace
The structural fix is a single property we now enforce before any Loonaangifte batch leaves the SBR-tunnel. Every employee-GUID, before it is used as a lookup key, must be qualified by its werkgever-id and validated against a per-werkgever namespace.
In schema terms, the mapping table changed from this:
CREATE TABLE employee_digid_map (
employee_guid uuid PRIMARY KEY,
bsn char(9) NOT NULL,
mailbox_id text NOT NULL
);To this:
CREATE TABLE employee_digid_map (
werkgever_id text NOT NULL,
employee_guid uuid NOT NULL,
bsn char(9) NOT NULL,
mailbox_id text NOT NULL,
source_export_id text NOT NULL, -- NMBRS export this row came from
first_seen_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (werkgever_id, employee_guid)
);
CREATE UNIQUE INDEX employee_digid_map_bsn_per_werkgever
ON employee_digid_map (werkgever_id, bsn);Backfilling the old table into the new schema was the awkward part. The legacy table had no werkgever_id column at all, so the right tenant per GUID had to be reconstructed from the original NMBRS export logs. We had kept those, gzipped, in cold storage: every export since 2024-onboarding was on disk. We replayed them oldest-first, inserted into the new schema, and trusted the most recent export per (werkgever_id, employee_guid) pair as authoritative. Where we found conflicts (the same GUID appearing under two different werkgevers across the historical record), the new schema simply refused the second insert and the row got flagged for manual review. There were 47 such rows. They matched the audit query from the night of the 27th, exactly.
The agent's resolve function now requires the werkgever-id at the call site, and the SBR submission step refuses to run if any recipient's (werkgever_id, employee_guid) pair was not seen in the current export run:
def resolve_recipient(werkgever_id: str, employee_guid: str, export_id: str) -> Recipient:
row = db.execute(
"""
SELECT bsn, mailbox_id, source_export_id
FROM employee_digid_map
WHERE werkgever_id = %s AND employee_guid = %s
""",
(werkgever_id, employee_guid),
).fetchone()
if row is None:
raise UnmappedEmployee(werkgever_id, employee_guid)
if row.source_export_id != export_id:
raise StaleMapping(werkgever_id, employee_guid, row.source_export_id, export_id)
return Recipient(bsn=row.bsn, mailbox_id=row.mailbox_id)We also rewrote the sample-and-verify check to read from an independent source, the NMBRS employee-detail endpoint, not our mapping table. It now flags the batch on more than zero mismatches, not on more than a threshold. If the agent cannot reach the second source, the batch halts.
We wrote to NMBRS support with the GUID samples and a redacted timeline. Their response, three working days later, confirmed the April 2024 cluster merge and the soft-deleted re-issue pattern. They flagged it for the docs team. Whether the public API reference ever names this behaviour is not the bet we want to make twice; the lookup gate above runs whether NMBRS updates its documentation or not.
What we believe now
Identity keys from vendor APIs are namespaced by their tenant, full stop. We no longer treat any GUID as global, no matter what the docs say, unless the vendor specifies their re-use policy after merges, restores, and undeletes. The Microsoft Graph team writes this up well, see their deleted-items lifecycle docs, and it is a good reference for how the question should be answered.
Verification reads must come from a different system than the one being verified. If you cannot get a second source, you have not verified.
Refuse-to-run is cheaper than recall. The agent now defaults to halting on any uncovered case (unmapped GUID, stale export ID, count drift) rather than logging a warning and continuing. The bureau's controller agreed: she would rather get paged at 22:11 to approve a batch than read 1,840 apology emails at 09:42.
The small thing you can do today
Open the agent, automation, or script in your stack that touches identity. Find the line where it resolves a vendor ID to a person. Ask: which tenant is this ID scoped to, and where is that tenant written down on this line? If the answer is "it isn't", you have the same bug we did, and you have not noticed yet because nobody has merged a tenant on you.
When we built the process automation for this bureau, the thing that got us was trusting a vendor's "globally unique" promise across a backend migration we hadn't been told about. We fixed it with the namespace gate above, and we now write that gate into every agent on day one.
Key takeaway
Globally unique IDs from a vendor that has ever merged tenants are not globally unique. Namespace every lookup by tenant before it ships.
FAQ
Why didn't the SBR submission catch the wrong recipients?
SBR validates structure, taxonomy and totals against the Belastingdienst. It does not know whether a BSN belongs to a given employer. A Loonaangifte can be structurally perfect and addressed to the wrong person.
What is a per-werkgever GUID namespace, in one sentence?
A rule that every vendor employee-GUID must be qualified by the employer it came from, and looked up using the (werkgever, GUID) pair instead of the GUID alone.
Do we need to notify the AP for every datalek like this?
Yes, the AVG-meldplicht requires notification within 72 hours when a leak is likely to risk the rights of those involved. Wrong-recipient payslips containing BSN and salary qualify.
How do we audit existing mapping tables for stale vendor GUIDs?
Re-pull the current vendor export, join it back to your mapping table on the GUID, and flag any row where the vendor's current employer for that GUID differs from the one stored. That flag is your work list.