← Blog

Integrations

Nederlandse boekhoud-API's: 16 quirks uit een Almere-rollout

Dinsdag, 23:47. De order-to-cash-run loopt groen. De ochtend erna belt de controller over een verschil van 4,18 euro. De cheatsheet die daaruit groeide.

Jacob Molkenboer· Oprichter · A Brand New Company· 15 sep 2025· 9 min
Messing telmachine, carbon bonnen, groen papieren label aan linnen touw, leren onderlegger, paperclip, potloodstompje.

Op een dinsdagavond in maart, 23:47, eindigde de order-to-cash-run groen. Tweeënzestig facturen geboekt in Exact Online, elf in AFAS Profit, vier in een Unit4 Multivers-tenant die de groothandel gebruikt voor zijn Belgische zustermaatschappij. Wij naar bed. De volgende ochtend belde de controller: de dagelijkse btw-aansluiting klopte 4,18 euro niet. Vier euro over zevenenzeventig facturen. Het soort verschil dat niemand escaleert en iedereen stilletjes met de hand bijwerkt, zes weken lang, tot iemand merkt dat het is opgelopen tot 312 euro en een ontbrekende grootboekrekening op een Belgische verkoopfactuur die niemand kan terugvinden.

Die ene call zette de derde poging van een Almeerse groothandel met 26 mensen in werking om order-to-cash te automatiseren. We hadden twee maanden om live te gaan. Tegen het einde was ons kladblok uitgegroeid tot een cheatsheet van zestien REST-eigenaardigheden die geen enkele vendor in zijn documentatie toegeeft. Dit is die cheatsheet.

De opstelling

De groothandel draait drie boekhoud-backends omdat drie eerdere eigenaren er ieder eentje uitkozen en niemand de tijd had om te consolideren. De Nederlandse werkmaatschappij zit in Exact Online. Inkoop en HR draaien op AFAS Profit. De Belgische zuster draait op Unit4 Multivers Online. Onze pipeline leest gepickte en verzonden orders uit een Magento 2-storefront, verdeelt ze naar de juiste backend en sluist alles terug naar één Metabase-dashboard voor de controller.

Het plumbing is wat je verwacht: een Node-worker per backend, Postgres voor staging en idempotency-keys, een kleine Redis-queue. Niets exotisch. De interessante fouten zitten allemaal in de naden tussen onze code en de vendor-API's. De cheatsheet hieronder is geordend op stille schade, van "dit verziekt je boeken zonder dat je het doorhebt" tot "dit kost je een middag, maar je testsuite vangt het op".

Tier 1: stille corruptie

Hier schrijven we tegenwoordig als eerste integratietests voor, vóór al het andere. Ze gooien geen exception. Ze loggen niets. Ze liegen gewoon.

1. Exact Online rondt VATAmount bij readback af op twee decimalen

Het OData SalesInvoiceLines-endpoint accepteert in de request body een VATAmount met vier decimalen. Bij de volgende GET komt diezelfde waarde afgerond op twee decimalen terug. De ledger-reconciliatie verderop in de pipeline verwacht vier. We boekten de ene waarde en lazen een andere terug, en berekenden vervolgens een verschil tegen onszelf.

// what we POSTed
{ "VATAmount": 19.9750 }
// what GET returned moments later
{ "VATAmount": 19.98 }
// what our ledger still believed
{ "vat_amount": 19.9750 }

De fix: vertrouw de round-trip niet. Houd de waarde met vier decimalen in je eigen invoice_line-tabel en reconcilieer tegen de afgeronde readback, niet tegen het oorspronkelijke totaal.

2. AFAS Profit kapt decimalen af op basis van session-locale

De UpdateConnector accepteert "1.50" of "1,50", afhankelijk van of de connector-token gekoppeld is aan een gebruiker met Nederlandse of Engelse locale. Onze service-account stond op Nederlands. De helft van onze payloads stuurde alsnog "1.50", omdat onze serializer invariant culture gebruikte. AFAS accepteerde ze, en sloeg vervolgens 1,00 op. Geen error. Geen waarschuwing. Geld weg.

3. Unit4 Multivers laat de grootboekrekening vallen bij multi-administratie-POSTs

POST /api/v2/{databaseId}/SalesInvoices geeft 201 Created terug met een volledig ingevuld factuurobject. Vergeet je de administrationId-queryparameter op een tenant met meer dan één administratie, dan boekt de factuur in de tussenrekening van de standaardadministratie. De 201-response echoot de GLAccountCode die je hebt gestuurd. Het werkelijk geboekte record bevat hem niet. We betrapten dit pas toen er zes weken later een Belgische factuur opdook op een Nederlandse grootboekrekening.

4. De VATCode van Exact is per divisie en niet consistent geformatteerd

VATCode "1" in divisie 1234567 is logisch dezelfde code als "01" in divisie 1234568, omdat iemand in 2019 een rekeningschema opnieuw heeft geïmporteerd. De UI laat beide zien als "1 - BTW hoog verkoop". De API behandelt ze als verschillende strings. Kopieer een default-config over divisies heen en de helft van je facturen boekt tegen een onbekende VATCode, die stilletjes terugvalt op 0%.

Waarschuwing

Reken er nooit op dat een Nederlandse boekhoud-API een foutieve VATCode weigert. Alle drie de leveranciers accepteren een onbekende code en boeken hem op 0%, in plaats van een 400 terug te geven. Valideer vóór je POST't.

Tier 2: liegende response codes

Deze geven success terug, maar de operatie is óf niet uitgevoerd óf verkeerd uitgevoerd. Ze zijn luidruchtiger dan tier 1 (de kapotte staat is zichtbaar als je terug GET't), maar ze slopen je idempotency.

5. Multivers geeft 201 met id=null bij ontbrekende debiteuren

POST een verkoopfactuur die verwijst naar een debiteur die niet bestaat in de actieve administratie, en Multivers antwoordt met 201 Created, met als body de request die je hebt gestuurd en id op null. Geen error-key. Onze retry-logica las dat als "gelukt, sla het id op", en sloeg null op. De volgende reconciliatie-pass probeerde id=null te GET'en, kreeg een 404 en triggerde een re-POST. We maakten vier spookfacturen voordat we het doorhadden.

6. AFAS GetConnector geeft 200 met een lege array bij ingetrokken tokens

De connector-tokens van Profit kunnen vanuit de administratie van de klant worden ingetrokken zonder dat de afnemers iets horen. De connector geeft dan HTTP 200, een lege resultset en geen waarschuwing. Wij merkten het pas omdat een dagelijkse teller van "ongeveer 60" naar "precies 0" sprong. Alert dus altijd op "verwachtte niet-nul, kreeg nul" bij AFAS-reads.

7. Exact OData $filter op niet-geïndexeerde velden geeft de verkeerde rijen

De OData-laag van Exact pagineert op ROWID, en $filter op een veld zonder index past het filter pas toe ná de paginering. Je krijgt een pagina rijen waarvan een deel matcht en een deel niet, plus een @odata.nextLink die verdergaat vanaf een ROWID die mogelijk matchende rijen heeft overgeslagen. De workaround: filter alleen op geïndexeerde velden (de velden die als filterable staan in de Exact REST resources reference, wat niet dezelfde set is die de UI suggereert). Voor de rest: pagineer alles binnen en filter client-side.

8. Exact PATCH op een geboekte factuur geeft 204

Zodra een factuur is geboekt (Status 50 in de enum van Exact), geeft PATCH 204 No Content, maar de wijziging wordt niet doorgevoerd. De UI van Exact gooit hier wel een rechtenfout. De API niet. Controleer Status vóór elke PATCH en wijs de call client-side af.

Tier 3: auth en sessions

Voorspelbaar zodra je ze kent. Per integratie kosten ze je één rotweekend.

9. Exact refresh-tokens zijn single-use, en gelijktijdige refreshes leggen je tien minuten plat

De OAuth-refreshflow geeft een nieuw refresh-token terug en maakt het oude direct ongeldig. Twee workers die binnen dezelfde seconde refreshen slagen allebei één keer; daarna belandt de verliezer bij zijn volgende refresh in een lockout van tien minuten. Wij serialiseren refreshes via een advisory lock in Postgres per divisie. De thread op de Exact community hierover is jaren oud en bij de vendor nog steeds open.

10. Multivers access-tokens verlopen na dertig minuten, ondanks de gedocumenteerde zestig

De tokenresponse bevat expires_in: 3600. Het token stopt met werken op 1800. Wij behandelen expires_in als advisory en refreshen sowieso op vijfentwintig minuten.

Tier 4: formats en encoding

11. Datumformaten verschillen per backend en per endpoint

AFAS wil "2026-06-16T00:00:00" zonder Z. De OData van Exact wil /Date(1718496000000)/. De nieuwere REST-endpoints van Exact willen ISO 8601 met Z. Multivers wil ISO 8601 zonder timezone-offset. Eén getypeerde date-helper die weet voor welke backend hij serialiseert, scheelt je een week.

12. Newlines in omschrijvingsvelden zijn niet portabel

Multivers vereist CRLF in factuurregel-omschrijvingen om in de geprinte PDF op meerdere regels te tonen. Exact strijkt de CR weg en houdt de LF. AFAS weigert allebei met een 400 als de omschrijving langer is dan 50 tekens. Normaliseer per backend op weg naar buiten.

13. Postcode-validatie is inconsistent

AFAS weigert "1234 AB" (met spatie) en wil "1234AB". Exact verlangt juist de spatie. Multivers accepteert allebei. Sla canonical "1234AB" op en voeg de spatie pas toe op weg naar Exact.

14. Lengte van debiteurnaam verschilt per backend

AFAS kapt af op 50 tekens, Exact op 60, Multivers op 40. Geen van drieën gooit een error. Ze slaan stilletjes de afgekapte waarde op. Een debiteurnaam als "Bouwbedrijf Van der Velden Almere Holding B.V." wordt in elke backend anders bijgesneden en valt vervolgens uit bij reconciliatie, omdat de namen tussen de systemen niet meer matchen.

Tier 5: kleine operationele ergernissen

15. De rate limit van Exact is per app, niet per divisie

De gepubliceerde limiet is 60 requests per minuut per divisie en 300 per minuut per app. Heb je zes divisies en behandel je de app-limiet als plafond, dan stop je bij 50 per minuut per divisie voordat je überhaupt de divisielimiet raakt. We ontdekten dit door tijdens maandafsluiting de 429's te tailen op de drukke divisie. De X-RateLimit-Remaining-header reflecteert de app-counter, niet de divisie-counter.

16. Multivers-errors komen terug als XML, zelfs met Accept: application/json

Multivers geeft geslaagde responses terug als JSON, als je om JSON vraagt. Errors komen terug als XML, verpakt in een JSON-envelop met een Message-veld waarin de XML als string is geserialiseerd. Twee keer parsen dus.

Kernpunt

Test je boekhoudintegratie tegen de readback, niet tegen de response. De response is de belofte van de vendor. De readback is wat je accountant ziet.

Een staging-administratie die je kunt droppen

Eén operationele noot. We houden per backend een staging-divisie aan met wegwerpdata, en die wissen we wekelijks. Een recente Hacker News-draad herinnerde iedereen eraan dat de enige schaalbare delete in Postgres DROP TABLE is. De boekhoud-backends zijn het daarmee eens: de enige schaalbare reset van een Multivers-administratie is hem opnieuw aanmaken. AFAS bulk-delete gaat per record en is rate-limited. De "remove" van Exact is een soft delete die alsnog meetelt voor je storage-tier. Krijg je ops zover om je per backend een wegwerpadministratie te geven, doen. Wij rebuilden de onze 's nachts vanuit een JSON-seed.

Het post-en-direct-GET-patroon

De meest waardevolle vijftien regels code die we hebben geschreven. Elke worker POST't, GET't onmiddellijk daarna het zojuist aangemaakte record, en diff't de velden waar hij om geeft tegen de request. Is de diff niet leeg, dan faalt de worker hard in plaats van succes te melden.

async function postAndVerify(client, payload, fields) {
  const created = await client.post('/SalesInvoices', payload);
  if (!created?.id) {
    throw new Error('vendor returned 2xx with no id');
  }
  const fetched = await client.get(`/SalesInvoices/${created.id}`);
  const drift = fields.filter(f => !near(payload[f], fetched[f]));
  if (drift.length) {
    throw new Error(`readback drift on ${drift.join(',')}`);
  }
  return fetched;
}

Voor VATAmount tolereert near() afronding op twee decimalen. Voor GLAccountCode, VATCode en debiteurnaam niet. Die ene asymmetrie ving in de eerste stagingsweek vijftien van de zestien quirks hierboven.

Zestig dagen verder

De order-to-cash-run boekt nu zo'n 240 facturen per dag over de drie backends, sluit op een normale dag aan tot op 0,01 euro met de spreadsheet van de controller, en mailt haar om 07:00 een diff van één pagina als dat een keer niet lukt. De cheatsheet hierboven is wat we hadden gewild dat iemand ons op dag één had aangereikt.

Toen we de order-to-cash-agent bouwden voor de Almeerse groothandel, liepen we er vooral tegenaan dat geen van de drie leveranciers het eens was over wat een 201 Created precies betekent. We eindigden met de kleine validator hierboven en hingen elke worker erachter. Zit je middenin een rollout op een Nederlandse boekhoud-backend en wil je een tweede paar ogen op je AI-agents en automatiseringsstack, neem deze cheatsheet dan als eerste mee.

Het kleinste wat je vandaag kunt doen: pak de drukste factuur die je pipeline gisteren heeft geboekt, GET hem terug uit de backend, en diff elk veld tegen wat je hebt gestuurd. Vind je een delta, dan heb je een stil geldlek te pakken.

Kern

Test je boekhoudintegratie tegen de readback, niet tegen de response. De response is de belofte van de vendor; de readback is wat je accountant ziet.

FAQ

Rondt Exact Online VATAmount echt stilletjes af?

Ja. De request body accepteert vier decimalen; de readback geeft er twee terug. Houd de oorspronkelijke waarde in je eigen store en reconcilieer tegen de afgeronde readback, niet tegen je totaal.

Waarom laat Unit4 Multivers de grootboekrekening vallen bij een 201?

Op multi-administratie-tenants is de administrationId-queryparameter verplicht. Zonder die parameter boekt de factuur in de standaardadministratie en wordt de GLAccountCode gedropt, ook al echoot de 201-response hem keurig terug.

Kan ik één OAuth-refreshworker delen over Exact Online-divisies?

Ja, mits je per divisie serialiseert met een lock. Refresh-tokens zijn single-use; twee gelijktijdige refreshes leggen de verliezer tien minuten plat.

Wat is de veiligste manier om een staging-boekhoudadministratie te wipen?

Opnieuw aanmaken. Bulk-delete in AFAS is rate-limited, de remove van Exact is een soft delete die meetelt voor je storage, en Multivers heeft geen equivalent van TRUNCATE. Rebuild 's nachts vanuit een seed-bestand.

integrationsprocess automationautomationcase studyworkflow

Iets bouwen?

Start een project