Integrations
Bol.com naar NetSuite agent: 11 uur lang €0,01-facturen
Om 06:14 Amsterdamse tijd had een order-import agent al elf uur lang regels van €0,01 in NetSuite geboekt. Wat veranderde er, wat misten wij, en welke gate had er moeten staan.

Om 06:14 Amsterdamse tijd, op een woensdag in mei, opende de AR-controller van een middelgrote Nederlandse consumenten-merkklant NetSuite om de Bol.com-reconciliatie van de afgelopen nacht weg te werken. De order-import agent draaide al sinds 19:30 de avond ervoor. Ze zag 1.847 verkoopfacturen. Ze zag een subtotaal van €37,42. Ze sloot het tabblad, opende het opnieuw, en belde ons om 06:18.
De vier minuten daarna deelden we schermen en zagen we hetzelfde getal verder oplopen. De agent had elf uur lang regels van €0,01 in NetSuite geboekt. Echte klantorders, echte SKU's, echte verzendadressen, prijzen van één cent. De kill-switch die we van de vorige leverancier hadden overgenomen stond op nul fouten.
Dit is wat er upstream veranderde, wat de mapper ermee deed, en wat het alarm in negentig seconden had moeten opvangen.
De schemawijziging die Bol.com uitrolde
Bol.com heeft een Retailer API die verkopers gebruiken om orders op te halen, te verzenden en tracking terug te schrijven. Versie 10 draait al ruim een jaar stabiel. Op de dinsdagmiddag vóór het incident pushten ze een additieve wijziging naar het orders-endpoint: de prijs per item ging van een platte decimaal naar een gestructureerd pricing-object.
Voor:
{
"orderItem": "BOL-1234567890-001",
"ean": "8710398502537",
"unitPrice": 24.95,
"quantity": 2
}Na, op hetzelfde endpoint, zonder version bump:
{
"orderItem": "BOL-1234567890-001",
"ean": "8710398502537",
"pricing": {
"unitPrice": { "amount": 24.95, "currency": "EUR" },
"vatRate": 21.0
},
"quantity": 2
}De release note van Bol noemde dit "rijkere prijsmetadata voor btw-afhandeling". Het oude veld unitPrice bleef tijdens een deprecation-window van 30 dagen in de response staan. De Open Retailer API-documentatie van Bol beschreef beide vormen tijdens de overgang. Onze agent las het oude veld, en het oude veld stond er nog steeds.
De adder: het oude veld stond er niet altijd. Voor elke order met een promotionele korting of een marketplace-correctie vulde Bol alleen het nieuwe pricing.unitPrice.amount. De platte unitPrice kwam terug als null. Onze mapper kreeg null. Onze mapper had een fallback. De fallback was de bug.
Bol communiceert schemawijzigingen via een publieke changelog en een wekelijkse nieuwsbrief voor aangesloten verkopers. Beide hadden deze als additief en laag risico omschreven. Die beschrijving klopte op wire-niveau en klopte niet op integratieniveau. Additief betekent dat er nieuwe keys verschijnen. Het betekent niet dat oude keys hun oude waarden behouden. De impliciete belofte van stabiliteit zit één laag dieper dan de schema-diff.
Hoe de mapper terugviel naar één cent
Drie jaar geleden, toen deze klant voor het eerst aan Bol.com werd gekoppeld, weigerde NetSuite elke verkooporderregel met een eenheidsprijs van nul. De boekhouding wilde geen freebies als factuur zien. Begrijpelijk. Maar de agent kreeg af en toe legitieme weggeef-orders binnen, de import liep vast, en de oorspronkelijke integratie-engineer (niet wij) voegde wat eruitzag als een onschuldige guard toe:
function mapLinePrice(item) {
const raw = item.unitPrice;
// NetSuite rejects zero-value sales lines; use a cent
// so the line posts and finance can correct it manually.
return (raw === null || raw === undefined || raw === 0) ? 0.01 : raw;
}Drie jaar lang vuurde die fallback ongeveer twee keer per maand op echte promo-orders. Finance markeerde de cent-regels in een maandagcontrole, corrigeerde ze met de hand, en het systeem bleef stil.
Toen het schema van Bol verschoof, begon elke order met een promotionele korting null terug te geven voor de platte unitPrice. De mapper zag null en gaf 0,01 terug. De agent boekte een geldige verkoopfactuur met een regel van één cent. NetSuite accepteerde 'm. De agent logde 200 OK. Elf uur aan orders ging door dezelfde pijp.
Tegen de tijd dat we werden gebeld, waren 1.847 regels op €0,01 geland tegen een gemiddelde van €23,40. De blootstelling was ongeveer €43.400 aan verkeerd geprijsde facturen die de volgende ochtend mee zouden gaan in de Mollie-betaalbatch.
Dit patroon, een ontbrekende input op een sentinel zetten zodat de downstream write niet blokkeert, zit in heel veel legacy-integraties. Het overleeft omdat het werkt onder één specifieke aanname: dat de input zelden en opvallend ontbreekt. De dag dat een van die twee niet meer klopt, wordt de sentinel de meest voorkomende waarde in de kolom.
Een sentinel-default die de rij laat boeken zodat een mens 'm kan corrigeren, is een struikeldraad. De dag dat de zeldzame branch de gewone wordt, krijgt die mens het signaal nooit.
De kill-switch die elf uur lang sliep
Elke agent die we opleveren heeft een circuit breaker. Het patroon is standaard: bij N fouten binnen een rollend venster stopt de run, krijgen Slack en de on-call mailbox een alert, en moet iemand handmatig acknowledgen om te hervatten. Negentig seconden van breach tot alert is het ontwerpdoel.
De agent die wij hadden overgenomen had er ook een. Hij keek naar drie dingen: HTTP non-2xx responses van Bol.com, HTTP non-2xx responses van NetSuite, en uncaught exceptions in de mapper. Alle drie de tellers bleven het hele incident op nul. Bol gaf 200. NetSuite gaf 200. De mapper gooide nooit iets. Die gaf netjes 0,01 terug, acht keer per minuut.
De kill-switch bewaakte de verkeerde laag. Het was een transport-alarm in een business-incident. Het financiële signaal, het dal van €23 naar €0,01 in gemiddelde regelwaarde, was de hele tijd zichtbaar in NetSuite en voor geen enkele monitor.
Dit is de failure mode die een naam verdient. AI-agents en domme integratie-scripts delen 'm. Ze worden beoordeeld op de protocolgrens, en de zakelijke betekenis van de payload is iemands anders probleem. De recente Hacker News-thread waarin werd gevraagd of een AI-assistent meer bugs in rsync introduceerde, komt vanuit een andere hoek op hetzelfde punt uit. Als je een correct ogende wijziging niet van een verkeerde kunt onderscheiden zonder 'm tegen de realiteit te draaien, is je gate niet streng genoeg.
Van ontdekking tot stilstand in 44 minuten
06:18: klant belt. We openen de laatste 200 logregels van de agent en de NetSuite saved search die zij op het scherm had.
06:23: we bevestigen dat het cent-patroon niet geïsoleerd is. Elke factuur die na 19:31 de vorige avond is geboekt, heeft minstens één regel van €0,01.
06:31: we pauzeren de cron van de agent, trekken de NetSuite write-rechten van de integratierol in, en bevriezen de nacht-Mollie-batch door het hold-window op de payment-export job te verlengen.
07:02: agent gestopt, NetSuite vergrendeld, geen klantnotificatie de deur uit. De schade blijft beperkt tot 1.847 interne records.
Wat we in die 44 minuten goed deden, was de freeze op de Mollie-batch. NetSuite houdt facturen vast tijdens een export-window. Was het telefoontje om 08:30 binnengekomen in plaats van 06:18, dan waren er duizenden betaalverzoeken van één cent bij Mollie aangekomen en als verwarrende orderbevestigingen in klant-inboxes geland. Facturen intern terugdraaien is een database-klus. Aan 1.800 klanten je excuses aanbieden, is een merkklus.
Wat we vóór de lunch hebben opgeleverd
Drie dingen moesten staan vóór de volgende Bol-poll om 08:00.
Ten eerste: de mapper. We vervingen de sentinel door een expliciete lezing van het nieuwe veld, met fallback naar het oude veld, met fallback naar een harde weigering:
function mapLinePrice(item) {
const newPrice = item.pricing?.unitPrice?.amount;
const legacyPrice = item.unitPrice;
const price = newPrice ?? legacyPrice;
if (price === null || price === undefined) {
throw new MappingError('NO_UNIT_PRICE', {
orderItem: item.orderItem,
ean: item.ean,
});
}
if (price < 0.10) {
throw new MappingError('SUSPICIOUS_UNIT_PRICE', {
orderItem: item.orderItem,
price,
});
}
return price;
}Een gegooide MappingError stuurt de order nu naar een needs_review-queue in plaats van naar NetSuite. Twee echte weggeef-orders kwamen binnen de eerste dag bovendrijven. Beide correct gemarkeerd. De boekhouding heeft liever de queue dan de stille cent.
Ten tweede: de reversal. We trokken de 1.847 betroffen facturen uit NetSuite, markeerden de bijbehorende Bol order_id-waarden als ongeïmporteerd in onze state-tabel, en draaiden de import opnieuw tegen de nieuwe mapper. De prijzen kwamen netjes terug uit pricing.unitPrice.amount. De Mollie-batch ging de volgende ochtend gewoon op tijd weg. Geen enkele klant heeft ooit een factuur van één cent gezien.
Ten derde: de audit trail. NetSuite geeft je interne ID's maar niet altijd de upstream order-context. We voegden een custom field custbody_bol_payload_hash (SHA-256 van de ruwe Bol-response) toe op elke factuur die de agent schrijft. Mochten we ooit met een klant moeten discussiëren over wat Bol verstuurde versus wat NetSuite boekte, dan is de link één query verderop.
De nieuwe gate, in business-eenheden
De transport-kill-switch bleef staan. We voegden er twee aan toe, allebei draaiend in hetzelfde proces als de agent, allebei aangesloten op dezelfde 90-seconden-alertroute.
Een waardeondergrens over een rollend venster:
-- runs every 60s against the agent's own write log
WITH recent AS (
SELECT line_amount_eur
FROM agent_writes
WHERE source = 'bol_orders'
AND written_at > now() - interval '15 minutes'
)
SELECT
COUNT(*) AS n,
AVG(line_amount_eur) AS avg_eur,
MIN(line_amount_eur) AS min_eur
FROM recent
HAVING COUNT(*) >= 50
AND AVG(line_amount_eur) < 5.00;Zakt de gemiddelde regelwaarde over de laatste vijftien minuten onder €5 over vijftig of meer regels, dan stopt de agent en gaat de on-call af. €5 zit ruim onder de kleinste legitieme SKU die deze klant verkoopt, en ruim boven elke sentinel. Het venster is kort genoeg om een vers incident te vangen voordat een kwart werkdag verbrandt.
Een veld-aanwezigheids-assertion tegen het bronschema, één keer per poll, voordat er ook maar een order wordt gemapt:
const sample = orders.slice(0, 10);
const newFieldPresent = sample.filter(
o => o.orderItems?.[0]?.pricing?.unitPrice?.amount !== undefined
).length;
if (newFieldPresent < sample.length * 0.5) {
await alert('bol.com schema may have shifted', {
sample: sample.slice(0, 2),
});
}Deze houdt Bol vanaf onze kant in de gaten. Verandert de vorm van hun payload nog eens, dan horen we het van onze eigen monitor, niet van de boekhouding om 06:14. Het hoofdstuk over monitoring in het SRE-book van Google maakt hetzelfde punt in andere bewoording: alarmen die ertoe doen, meten wat de gebruiker ziet, niet de laag eronder.
De kill-switch was niet kapot. Hij keek naar het verkeerde. Elke integratie-agent zou minstens één alarm moeten dragen dat in eenheden van het bedrijf leest: euro's per minuut, refunds per uur, contracten per dag. Protocol-alarmen vertellen je dat de pijp aangesloten is. Business-alarmen vertellen je dat het juiste erdoorheen stroomt.
Dit geldt voor elke agent, niet alleen voor de integratiesoort. Een chat agent die binnen twee seconden antwoordt met het verkeerde beleid, haalt elke transport-check. Een invoice-chase agent die beleefde reminders naar de verkeerde klanten mailt, haalt elke transport-check. Het alarm moet aflezen wat het bedrijf daadwerkelijk kan schelen.
Toen we deze Bol.com-naar-NetSuite-integratie voor de klant bouwden, was dat precies het gat waar we tegenaan liepen: het verschil tussen gezond verkeer en correct verkeer. We dichtten 'm door een tweede klasse monitor toe te voegen die weet wat een order waard is, niet alleen of die geboekt is. Dat soort AI-agents-werk ziet er onspectaculair uit als het werkt, en dat is precies de bedoeling.
Het kleinste wat je vandaag kunt doen: open de integratie-agent waar je het meeste vertrouwen in hebt, zoek het alerting-blok op, en vraag jezelf af of die zou afgaan als elke regel die hij wegschrijft ineens een cent zou kosten.
Kern
Een kill-switch op protocolniveau vertelt je dat de pijp aangesloten is. Een kill-switch op business-niveau vertelt je dat het juiste erdoorheen stroomt. Elke integratie-agent heeft ze allebei nodig.
FAQ
Waarom gaf de agent op elke fout een 200 OK terug?
Omdat er op protocolniveau niets faalde. Bol gaf geldige JSON, de mapper produceerde een geldig getal (0,01), en NetSuite accepteerde de factuur. Elk transport-checkpoint stond op gezond. Alleen de zakelijke waarde klopte niet.
Hoe kwamen jullie op €5 als drempel voor de waardeondergrens?
Onder de goedkoopste SKU die deze klant verkoopt (€7,95) en ruim boven elke legacy-sentinel. Kies een ondergrens die tussen je echte minimum en je synthetische defaults ligt. Herijk per kwartaal als de catalogus verandert.
Moeten we deprecation-windows van upstream API's vertrouwen?
Behandel ze als waarschuwingen, niet als garanties. Een veld kan in de response blijven staan en voor een subset van records toch null worden. Lees het veld, maar assert ook op basis van een sample dat het gevuld is voordat je een volledige batch mapt.
Had een menselijke code review de sentinel opgemerkt?
Misschien op het moment dat hij werd geschreven, maar die had drie jaar lang geen kik gegeven. Reviews vangen nieuwe code, geen sluimerende aannames. Periodieke chaos-stijl audits tegen integratie-mappers vinden dit soort dingen beter dan regel voor regel lezen.