Integrations
Payroll API's: 16 stille fouten in Exact, AFAS, Visma.net
Een payrollkantoor in Eindhoven sloot mei af met 47 mutaties op de verkeerde kostenplaats. Dit zijn de zestien REST quirks in Exact, AFAS en Visma die dat veroorzaakten.

Vrijdagmiddag, laatste week van mei. Een payrollkantoor in Eindhoven, negentien medewerkers, draait de loonstrookbatch voor de elfde administratie van die dag. Elke regel komt terug met 201 Created. Maandagochtend opent de senior consulent het kostenplaatsrapport en daar staan 47 mutaties op de verkeerde kostenplaats. Niet willekeurig. Alle 47 horen bij de administratie die ze als tiende verwerkten.
Zo ziet een stille division-swap in Exact Online eruit als één query parameter ontbreekt. De vier weken erna trokken we elke aangrenzende quirk uit dezelfde rollout: drie Nederlandse payroll-API's, zestien faalmodi, gerangschikt op hoe luid ze falen. Dit is de cheatsheet.
Waarom we sorteerden op stilte, niet op ernst
Elke payroll-integratie heeft bugs die je kunt zien. Een 400, een 422 met een veldpad, een stack trace in de logs. Die zijn goedkoop. Je schrijft een retry, een Slack-melding, een unit test, en je gaat door.
De dure bugs zijn de bugs waarbij de API 201 Created teruggeeft, het dashboard een groen vinkje toont, en de consulent het pas merkt bij de maandafsluiting. We hebben de zestien quirks daarom in die volgorde gezet. Bovenaan de lijst staan de bugs die je data herschrijven zonder dat je het ziet. Onderaan staat puur administratie.
Tier 1: stille datacorruptie
1. Exact Online erft de laatst gebruikte division als de parameter ontbreekt
Het division-segment in elk /api/v1/{division}/...-endpoint staat op een vaste positie in de URL, maar de meeste client libraries verstoppen het in een base config. Als je bij startup een base division zet en die niet reset tussen administraties, ben je één ontbrekende regel verwijderd van posten tegen de division die je het laatst aanraakte.
De Exact Online REST docs zijn duidelijk over de URL-vorm maar zeggen niets over hoe identiek de response eruitziet als de division stilletjes afwijkt. De fix is om nooit een default te erven:
// fout: division lekt tussen administraties
const client = new ExactClient({ division: defaultDivision });
await client.post('/salary/SalaryMutations', mutation);
// goed: division per call, nooit default
async function postMutation(division: number, mutation: SalaryMutation) {
return fetch(
`https://start.exactonline.nl/api/v1/${division}/salary/SalaryMutations`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(mutation),
},
);
}
2. Exact Online Salary verliest het verloningstijdvak bij een terugwerkende mutatie
Post een salarismutatie met een StartDate in een verloningstijdvak dat al gesloten is en Exact Online geeft 201 Created. De entiteit bestaat. Maar het Period-veld op het opgeslagen record is null, waardoor de volgende run hem als huidige periode behandelt en op de open cyclus toepast.
Dit staat niet in de publieke changelog. We vonden het door GET-after-POST te diffen op elke mutatie in een testadministratie. Mitigatie: lees de entiteit altijd opnieuw uit en asserteer de periode.
3. AFAS Profit valt terug op de functie-default als een kostenplaats ontbreekt
Als je KnEmployee-envelope het veld CostCenterId weglaat bij een insert, geeft AFAS geen 422. Het zoekt de default kostenplaats op de toegewezen functie op en schrijft die weg. Prima totdat HR de functie-default halverwege de maand wijzigt en je historische inserts gaan schuiven.
De AFAS AppConnector-documentatie beschrijft default-lookup voor sommige velden, niet voor alle. Behandel elk nullable kostenplaats-achtig veld in je client als verplicht-of-faalt.
4. Visma.net wisselt van werkgever als de OAuth scope verloopt
Visma.net koppelt de tenant aan het access token op het moment dat de scope wordt verleend. Refresh het token zonder de tenant scope opnieuw aan te vragen en de volgende POST belandt op de vorige tenant. Geeft 201. Verkeerde werkgever. Eén keer betrapt. We gaan ervan uit dat we het twee keer hebben gemist.
Als je integratie meer dan één administratie of tenant raakt, schrijf dan een GET-after-POST assertion die de opgeslagen employerId of division vergelijkt met wat jij bedoelde. Geen unit test. Een runtime check op elke write in productie.
Tier 2: 201 Created zonder dat er iets is aangemaakt
5. Visma.net geeft 201 terug voordat async batchvalidatie draait
Het salarisbatch-endpoint accepteert de payload, geeft 201 terug met een batch-ID, en valideert daarna async. Als de validatie faalt belandt de batch in een status die de API nooit uit zichzelf rapporteert. Je moet pollen. De 201 betekent ontvangen, niet opgeslagen. Zie de Visma.net API-index voor de batch-status endpoints die polling waard zijn.
6. AFAS UpdateConnector geeft 200 OK met lege resultaten bij validatiefouten
UpdateConnector geeft 200 met een lege Results-array terug als de interne XML niet valideert. Er is geen error field. De client denkt dat de call gelukt is. Asserteer altijd Results.length > 0 voordat je de call als gelukt beschouwt.
7. Exact Online: conflicterende Division-header en URL produceren een ghost 201
Als je de division in de URL zet én een conflicterende X-Division-header meestuurt (sommige SDK's doen dit automatisch), geeft Exact 201 terug met een entiteit-ID die op een follow-up GET niet meer bestaat. De entiteit wordt nooit weggeschreven. Kies één bron voor de division en strip de andere uit elke laag van je client.
Tier 3: paginering en projectie
8. Exact Online: $select op write knipt de response entiteit af
OData $select werkt op GET. Op POST wordt het geaccepteerd en stilletjes toegepast op de response payload, wat betekent dat je verify-the-saved-record code mogelijk een stub leest in plaats van de volledige entiteit. Laat $select weg bij writes.
9. AFAS GetConnector: skip voorbij 5000 geeft leeg terug zonder waarschuwing
Het ongedocumenteerde skip-plafond ligt rond de 5000 rijen. Daarboven krijg je een 200 met een lege rows-array, geen error, geen header. Filter naar beneden met een echte WHERE-clausule. Pagineer niet blind door een grote connector.
10. Visma.net: $top gecapt op 1000 maar de API accepteert wat je vraagt
Het gedocumenteerde plafond is 1000. We zagen responses met 1000 rijen bij een $top=5000-request en geen link-header naar de volgende pagina. Als je de telling vertrouwt, sla je data over. Ga er altijd vanuit dat de response de huidige pagina is, niet het totaal.
Tier 4: auth en rate limits
11. Exact Online: 60 requests per minuut, maar token refresh reset de teller
Per app per division krijg je 60 calls per minuut. De rate-limit header reset bij een token refresh, wat als feature klinkt totdat je integratie per ongeluk midden in een batch refresht en door een soft throttle scheurt waar de volgende batch hard tegenaan loopt.
12. AFAS AppConnector-token roteert in stilte als de beheerder op de verkeerde knop drukt
Het AppConnector-token kan vanuit de AFAS UI geroteerd worden. Als de beheerder dat doet, geeft de API 401 terug zonder body. Geen webhook, geen mail. Bouw een dagelijkse synthetische check die één no-op mutatie post en bij een 401 alarmeert.
13. Visma.net: tenant scope moet per administratie opnieuw aangevraagd worden
Een nieuwe tenant aan een bestaande OAuth-client toevoegen verlengt het bestaande access token niet. Je hebt een nieuwe autorisatieronde nodig. We scripten dat. Het bureau heeft 11 administraties, waarvan er drie na het oorspronkelijke consent-scherm zijn aangesloten.
Tier 5: dataformaat
14. AFAS: datums zonder timezone worden als Europe/Amsterdam geïnterpreteerd, inclusief zomertijd
AFAS schrijft datums als 2026-03-29T02:30:00 zonder offset. Op zomertijdovergangen bestaat dat tijdstip niet. De API rondt stilletjes voorwaarts, wat een verloningstijdvak een uur opschuift. Stuur altijd middernacht Europe/Amsterdam of expliciet UTC middernacht.
15. Exact Online: bedragen in euro's op sommige endpoints, in centen op andere
Financiële endpoints nemen bedragen in euro's met twee decimalen. Het salarismutatie-endpoint neemt bedragen in centen als integers. Validatie accepteert beide, want het salaris-endpoint behandelt 1250.50 stilletjes als 1250 cent. Wikkel je money type en kies de eenheid per endpoint.
16. Visma.net: decimaalscheidingsteken verschilt per land-endpoint
Het Nederlandse endpoint gebruikt een punt. Het Zweedse endpoint gebruikt een komma. Als je client getallen als JSON serialiseert zonder de locale af te dwingen, kun je 1,50 als string sturen, op het ene endpoint geaccepteerd worden en op het andere afgekapt zien tot 1. Stuur getallen altijd als JSON numbers, nooit als string.
Het verschil tussen tier 1 en tier 5
Zie je het patroon. Tier 1 faalt stil omdat de API namens jou gokt. Tier 5 faalt stil omdat de API te coulant is met types. Beide vormen van coulance zijn duur. De oplossing is in beide richtingen dezelfde: laat de integratie nooit gokken, laat de API nooit gokken, en lees altijd terug wat je hebt geschreven.
Het diepere probleem met quirks zoals nummer 2 hierboven is dat ze in geen enkele vendor-docs staan. Je team leert ze één keer, in productie, op een vrijdag. Daarna woont die kennis in één hoofd tot diegene weggaat. De cheatsheet hierboven is onze poging om dat breder te verspreiden.
Wat je maandag concreet kunt doen
Als je multi-administratie writes draait tegen één van deze drie API's, drie acties voor vandaag:
- Voeg een GET-after-POST assertion toe aan elke write die
divisionoftenantIdvergelijkt met je intentie. - Verwijder elke default-division en default-tenant uit je client config. Maak ze verplichte argumenten op elke aanroepplek.
- Bouw een dagelijkse synthetische write die een no-op mutatie post en direct weer terugdraait. Alarmeer bij elke stille verandering in de response shape.
Toen we de loonstrook-automatisering voor dit Eindhovense kantoor bouwden, liepen we niet tegen één specifieke quirk hierboven aan. Het probleem was de afwezigheid van een verificatielaag tussen de integratie en het dashboard van de consulent. We hebben uiteindelijk een tweede pass toegevoegd die elke mutatie dertig seconden na het wegschrijven opnieuw inleest en de opgeslagen velden vergelijkt met de intentie. Drie van de zestien quirks hierboven komen alleen in die tweede pass aan het licht.
Wil je een audit van vijf minuten: grep je integratie op elke plek waar een division, tenant, werkgever of kostenplaats een default kan krijgen, en sloop de defaults eruit. De volgende batch laat zien welke ervan gewicht droegen.
Kern
Elke stille payroll-API-fout begint met een default die een verplicht argument had moeten zijn. Sloop de defaults eruit en de bugs komen meteen boven water.
FAQ
Hoe detecteer je een stille division-swap in Exact Online?
Lees de entiteit direct na de POST opnieuw uit en asserteer dat het Division-veld klopt met wat je bedoelde. Klopt het niet, faal dan de batch en bel de dienstdoende consulent voordat de volgende batch start.
Geeft AFAS Profit een duidelijke fout als een kostenplaats-default wordt toegepast?
Nee. De insert slaagt en de opgeslagen kostenplaats reflecteert de functie-default. Behandel elk nullable kostenplaats-achtig veld als verplicht in je client envelope en weiger lege waarden voordat je verstuurt.
Kan één Visma.net OAuth-token meerdere tenants dekken?
Alleen als elke tenant op het moment van consent al in scope zat. Een nieuwe tenant later toevoegen vereist een nieuwe autorisatieronde. Het bestaande access token wordt niet automatisch verlengd bij een refresh.
Staan deze quirks gedocumenteerd bij de leveranciers?
Sommige wel. De stille fallbacks, het async validatiegedrag en het skip-plafond van AFAS GetConnector zijn vooral folklore, opgespoord door GET-after-POST te diffen in een testadministratie voordat je live gaat.