Process automation
Idempotentie in zorg-webhooks: 312 routes dubbel gepland
Op zondag 26 oktober 2025, ergens tussen 02:00 en 03:00 lokale tijd, vuurde een Nedap Ons-webhook stilletjes opnieuw. Woensdagochtend stonden er 312 routes dubbel ingeboekt.

De planner van een thuiszorg met 24 medewerkers ten westen van Eindhoven opent woensdag om 06:40 haar laptop en kijkt naar het routebord voor die dag. Het eerste wat haar opvalt: Annelies, een van de wijkverpleegkundigen, heeft twaalf cliënten toegewezen gekregen vóór twaalven. Annelies doet er acht op een normale dag. Het tweede wat haar opvalt: elke andere route op het bord heeft dezelfde vorm. Dubbel. Het bord claimt 624 bezoeken vóór de lunch, met een team dat er fysiek 312 doet.
Ze belt ons om 06:43.
Dit is de reconstructie van wat we vonden, wat brak, en het kleine stukje code dat nu tussen elke ECD-mutatie en de VECOZO-tunnel zit.
Het systeem dat we bouwden
De planning-agent die we in maart hebben opgeleverd routeert wijkverpleegkundigen door de stad op een schuivende horizon van 14 dagen. Hij haalt het dienstrooster uit Nedap Ons, indicaties uit het ECD, verkeersvoorspellingen uit een publieke NDW-feed, en voorkeuren van de planner (wie woont waar, wie weigert bepaalde wijken na het donker, welke cliënten op maandag alleen dezelfde verpleegkundige accepteren). Hij schrijft terug naar Nedap Ons via de standaard Ons-API, die — zoals elke mutatie in de Nederlandse zorg — via de VECOZO-tunnel gaat.
Waarom de agent überhaupt bestaat: 24 verpleegkundigen verdelen over zo'n 90 geïndiceerde cliënten op een schuivende periode van twee weken is een constraint-probleem dat de vorige planner op vrijdagmiddag oploste in vier uur sleuren met gekleurde blokjes in een spreadsheet. De agent produceert in zo'n twee minuten een Pareto-front van drie opties en laat de planner kiezen. Daar zit de waarde. Alles onder deze alinea gaat over wat er gebeurt als de gate rond die waarde verkeerd staat.
De agent wordt getriggerd door een Nedap Ons-webhook. Telkens als het Q4-rooster wijzigt (een ziekmelding, een ruil, een nieuwe cliënt) post Ons de diff en plant de agent het betreffende venster opnieuw.
Het webhook-contract, geparafraseerd uit de docs:
Events worden ten minste één keer afgeleverd. Consumers moeten dedupliceren op basis van het event_id-veld. Bij 5xx en connection timeouts proberen we het tot 24 uur opnieuw.
Nedap Ons API, sectie webhook delivery
We hadden dit gelezen. We hebben het niet correct gehonoreerd.
Wat er gebeurde op 26 oktober
De nacht van 26 oktober 2025 was de nacht dat de klok terugging. Europa rolde om 03:00 lokaal van CEST (UTC+2) naar CET (UTC+1) en sprong terug naar 02:00. Het uur tussen 02:00 en 03:00 lokaal bestaat die nacht twee keer.
Om 02:47 CEST duwde de planner een ruil in Ons (Annelies en Inge wisselden twee routes). Ons vuurde de webhook. Onze handler consumeerde 'm, schreef een herplanning terug via VECOZO en ackte het event.
Om 02:13 CET (27 wandklok-minuten later, maar dezelfde geprinte lokale tijd) vuurde Ons wat het beschouwde als hetzelfde event opnieuw. We hebben nog geen volledige post-mortem van Nedap, maar de werkhypothese is een bekende klasse van retry-during-DST-bugs waarbij de retry-queue events groepeert per lokaal-tijdvenster en ze óf twee keer afvuurt óf events binnen het verdubbelde uur dropt.
We hebben de tijdlijn gereconstrueerd uit drie bronnen: het webhook-afleverlog van Nedap, onze eigen structured logs (we bewaren volledige payloads 90 dagen), en de audit trail van het ECD. De drie waren het eens over elk event, maar verschilden in timestamp tussen nul en 47 minuten, afhankelijk van welke systeemklok op dat moment van de overgang gezaghebbend was. De audit trail won.
Onze handler keyde z'n dedupe-check ook op een lokaal-tijdvenster: WHERE received_at BETWEEN now() - interval '1 hour' AND now(). Vanuit het perspectief van de agent landde het tweede event buiten dat venster. We behandelden het als nieuw.
Herplanning liep. Nieuwe routes werden weggeschreven. Ons ackte ze. Beide kopieën zaten in het ECD als legitieme dienst-mutaties, met geldige VECOZO-handtekeningen, niet te onderscheiden van een echte ruil.
De eerste achttien webhook-events op de ochtend van de 27ste (maandag, met daartussen een nieuwe cliëntintake) werden vervolgens verwerkt tegen een inmiddels verdubbeld rooster. "Ruil twee routes" van de planner werd "ruil twee van de vier, de andere twee blijven". De agent deed precies wat hem gezegd was.
Woensdagochtend was de divergentie opgelopen tot 312 dubbele routes.
Elk webhook-contract met "at-least-once" betekent dat je dedupe-gate onvoorwaardelijk én tijdonafhankelijk moet zijn. Als je dedupe-venster een wandklok-interval is, heb je een clock-skew bug geschreven en de zomertijd vindt 'm.
De roll-back
Om 07:20 hebben we het rooster teruggezet vanaf de gesigneerde snapshot van de vorige avond. De planner heeft haar echte wijzigingen om 09:00 handmatig opnieuw doorgevoerd. Geen enkele cliënt is twee keer bezocht; de dienstdoende verpleegkundigen pikten de duplicaten op tijdens de ochtendoverdracht, omdat geen mens gelooft dat Annelies twaalf cliënten vóór de lunch doet.
Die gesigneerde snapshot leveren we nu standaard mee in elke agent die we bouwen: een hash-chained dump van het wereldbeeld van de agent, elke vijftien minuten weggeschreven naar S3 met Object Lock. Voor een roll-back draaien we de mutaties tussen de laatste goede snapshot en nu opnieuw in dry-run, diffen tegen de live state, en produceren een lijst compenserende mutaties die de planner met één klik goedkeurt. De roll-back van 07:20 kostte elf minuten wandkloktijd, waarvan negen aan de planner die de diff las.
Daarna schreven we de gate.
De gate
De gate zit voor elke mutatie die de planning-agent uitstuurt. Hij draait vóór de VECOZO-envelop wordt opgebouwd en gesigneerd. Hij doet twee dingen: hij weigert een Ons-event door te zetten dat we al hebben geackt, en hij weigert elke mutatie door te zetten waarvan de bron-timestamp meer dan een instelbare drift verschilt met onze monotone klok.
// gate.ts — draait voor elke ECD-mutatie
import { createHash } from "node:crypto"
import { db } from "./db"
const MAX_SKEW_MS = 5 * 60 * 1000 // 5 minuten
const REPLAY_WINDOW_DAYS = 30
export async function gate(event: OnsEvent, mutation: EcdMutation) {
// 1. Idempotentie op het bron-event_id. Geen afgeleide hash,
// geen content-fingerprint, geen ontvangst-timestamp.
const key = event.event_id
const seen = await db.oneOrNone(
`SELECT acked_at FROM webhook_seen
WHERE event_id = $1
AND received_at > now() - interval '${REPLAY_WINDOW_DAYS} days'`,
[key],
)
if (seen) {
return { decision: "drop", reason: "replay", first_seen: seen.acked_at }
}
// 2. Clock-skew: vergelijk event.occurred_at (strikt UTC, uit Ons)
// met onze monotone UTC-klok. Reject als drift > MAX_SKEW_MS.
const skewMs = Math.abs(Date.now() - Date.parse(event.occurred_at))
if (skewMs > MAX_SKEW_MS) {
return { decision: "quarantine", reason: "skew", skewMs }
}
// 3. Reserveer de key in dezelfde transactie als de mutatie.
// Als de ECD-write faalt, rolt de row terug en kunnen we opnieuw proberen.
return db.tx(async t => {
await t.none(
`INSERT INTO webhook_seen (event_id, received_at, acked_at, mutation_hash)
VALUES ($1, now(), now(), $2)`,
[key, mutationHash(mutation)],
)
await sendToVecozo(mutation)
return { decision: "forward" }
})
}
function mutationHash(m: EcdMutation) {
return createHash("sha256").update(JSON.stringify(m)).digest("hex")
}
Vijf dingen om uit te lichten: de eerste drie omdat we ze in de eerste versie elk verkeerd hadden, de laatste twee omdat het de stukken zijn die meestal worden overgeslagen.
Het replay-venster is 30 dagen, geen 24 uur
De gedocumenteerde retry-window van Nedap is 24 uur, maar we hebben retries uit cold storage waargenomen na een incident aan Nedap-kant, na elf dagen. Dertig dagen aan webhook_seen-rijen is goedkoop; een dubbel bezoek bij een echte cliënt is dat niet.
occurred_at moet strikt UTC zijn aan de bron
Ons emit ISO-8601 met offset, dus we krijgen UTC-informatie gratis, maar de eerste versie parste het via een lokaal-tijdhelper die ambigue tijden oploste naar het eerste voorkomen in het verdubbelde DST-uur. We parsen nu strikt en rejecten elke timestamp zonder expliciete offset. Er zit nergens in de handler nog een "02:30 lokaal, kies maar"-tak.
De dedupe-insert en de VECOZO-send leven in dezelfde transactie
Als de send faalt, rolt de row terug. Als de row-insert faalt (unique violation op event_id), versturen we nooit. Er is geen venster waarin een retry "ack geschreven, send niet gedaan" of "send gedaan, ack niet geschreven" kan zien. Dit is hetzelfde patroon dat Stripe documenteert voor zijn eigen idempotency keys, en de reden waarom het bestaat is dezelfde: at-least-once delivery is de default omdat exactly-once een berucht duur distributed-systems probleem is.
Wat de gate niet doet
Hij vangt op zichzelf geen content-level duplicaat dat met een vers event_id binnenkomt. Als Nedap ooit dezelfde ruil opnieuw uitzendt met een nieuw id (we hebben het niet waargenomen, maar kunnen het niet uitsluiten), laat de gate 'm door. De kolom mutation_hash bestaat voor de tweede-laagse check die we 's nachts draaien: die markeert elk paar mutaties met dezelfde hash en dezelfde doelcliënt binnen een venster van 72 uur, en zet beide in quarantaine zodat de planner ze kan inspecteren. De in-line gate is de goedkope, snelle check; de hash-pair scan is de trage, out-of-line variant. Ze dekken verschillende failure modes en we draaien ze beide.
Hoe we het testen
Elke webhook-payload die we ooit hebben ontvangen is replay-baar uit een opgenomen fixture. De CI-suite speelt het volledige incident van 26 oktober opnieuw af, de achttien events die het maandagochtend opstapelden, en een synthetische set klok-anomalieën: een NTP-step vooruit, een NTP-step terug, container-clock drift voorbij MAX_SKEW_MS, een ontbrekend event_id, een mis-geformatteerde occurred_at. Een groene build betekent dat de gate al die gevallen aankan. Een rode build blokkeert de deploy.
Waarom dit zwaarder weegt in gereguleerd werk
Regelgevingsdruk op agentic systemen is niet langer theoretisch. De EU AI Act plaatst agents die acties ondernemen tegen patiëntendossiers in de high-risk tier, met eisen rond logging, traceerbaarheid en menselijk toezicht die tot augustus 2027 gefaseerd ingaan. De WGBO eist nu al dat elke regel in een patiëntendossier toewijsbaar is aan een benoemde auteur en aan een precies moment. Een verdubbelde VECOZO-mutatie is in dat kader geen facturatie-ongemak. Het is een integriteitsfout in de audit trail, en jouw agent is de benoemde auteur op beide kopieën.
Als je een AI-agent inzet tegen een gereguleerd dossiersysteem, is het deel van de stack waarop je niet kunt bezuinigen het saaie deel. De agent zelf is een opgepoetste prompt met tools. Wat bepaalt of je in business blijft, is de gate die voor de tool-calls staat.
Voor een betaalplatform is het equivalent van een verdubbelde webhook "twee keer gefactureerd". Voor een ECD is het "twee keer bezocht", of erger: "ingeroosterd en niet bezocht omdat de agent dacht dat het slot al gedaan was".
De kostenasymmetrie is wat de meeste teams onderschatten. De gate hierboven is grofweg tachtig regels code, zit achter een feature flag, en draait in minder dan twee milliseconden per event. De post-incident audit, de gesprekken met de ECD-leverancier, en de uitleg die we onze klant verschuldigd waren kostten drie weken senior-tijd in twee landen. De gate is het goedkoopste deel van dit hele verhaal.
Een audit van vijf minuten voor je eigen stack
Open de handler die je duurste webhook consumeert, degene die writes triggert naar een dossiersysteem dat niet van jou is. Zoek de dedupe-check. Stel vier vragen.
Eén: is de dedupe-key het event_id van de provider, of iets dat je zelf hebt berekend? Als je 'm zelf berekent, klopt je check niet op elke retry waar de payload één whitespace verschilt.
Twee: is je dedupe-venster een wandklok-interval? Zo ja, dan produceert de volgende DST-overgang of de volgende NTP-step een duplicaat.
Drie: zit de dedupe-insert in dezelfde transactie als de downstream-mutatie? Zo nee, dan is er een venster waarin je de een wel hebt en de ander niet.
Vier: als de gate een event dropt of in quarantaine zet, log je dat dan met reden en zet je het op een dashboard waar je on-call mens naar kijkt? Een stille gate is een ergere failure mode dan een ontbrekende. Het eerste wat we bij elke nieuwe gate meeleveren is de rij in het operations-dashboard die rood wordt zodra de drop-count groter is dan nul.
Als een van die antwoorden fout is, staat je volgende incident al in de wachtrij. Het wacht tot een klok iets vreemds doet, tot een vendor een backlog flusht, of tot een hardware-NTP daemon stept in plaats van slewt.
Toen we de planning-agent bouwden voor deze Eindhovense thuiszorg, was het stuk dat we onderschatten niet de routerings-wiskunde of de constraint solver. Het was het contract tussen twee systemen die niet van ons waren. De gate hierboven is de kleinste versie van dat contract die we konden schrijven. Als je een AI-agent draait tegen een zorg- of finance-dossiersysteem en een tweede paar ogen op die grens wilt, dat is het werk dat wij doen.
Kern
Een at-least-once webhook plus een wandklok-dedupe venster is een clock-skew bug, en de zomertijd vindt 'm vóór elke andere klok-anomalie.
FAQ
Wat is VECOZO?
VECOZO is de communicatie-ruggengraat van de Nederlandse zorg. Elke mutatie op een elektronisch cliëntendossier (ECD) gaat via z'n gesigneerde tunnel tussen zorgaanbieders, verzekeraars en registratiesystemen.
Waarom is at-least-once de default voor webhook-afleveringen?
Omdat exactly-once tussen twee systemen distributed consensus vereist, wat duur en traag is. At-least-once plus idempotentie aan consumer-kant is de standaard trade-off die Stripe, AWS, GitHub en de meeste anderen hanteren.
Hoe lang moet een webhook dedupe-venster zijn?
Lang genoeg om het gedocumenteerde retry-venster van de provider te dekken, met marge voor replays uit cold storage. Dertig dagen is een verstandige default; één uur niet. De opslagkosten van seen-event rijen zijn verwaarloosbaar.
Speelt dit alleen rond DST-overgangen?
Nee. DST is de meest voorspelbare klok-anomalie, maar NTP-steps, schrikkelseconden, container-clock drift en retry-queue bugs aan provider-kant produceren allemaal dezelfde klasse duplicaten. De gate moet tijdonafhankelijk zijn.