← Blog

Integrations

HR API-quirks NL: 16 stille fouten in AFAS, Nmbrs en Loket

Een onboarding-agent voor een Apeldoornse detacheerder van 24 man legde zestien REST-quirks bloot in AFAS Profit, Nmbrs en Loket.nl. De helft dropte velden die de auditor wél leest.

Jacob Molkenboer· Oprichter · A Brand New Company· 27 jun 2026· 10 min
Drie manilla mappen met gekleurd touw op ivoren papier, groene tab, messing clip, rood lakzegel, zijlicht.

Op een woensdag in maart belde een loonadministrateur bij een Apeldoornse detacheerder van 24 man ons op, omdat in de loonrun van vrijdag drie medewerkers met een buitenlands paspoort nul uren op hun loonstrook hadden staan. De onboarding-agent had hun dossiers netjes afgerond. AFAS had op elke call 200 OK teruggegeven. De audit log van de agent stond op groen. Er was niets misgegaan. Behalve dat de mensen, in elke loonbare zin, niet waren aangenomen.

De zes weken erna hebben we fixes uitgerold tegen AFAS Profit HR, Nmbrs en Loket.nl. Dit is de cheatsheet die daaruit kwam: zestien quirks, gerangschikt naar welke stilletjes de BSN-validatie droppen op een buitenlandse-werknemer-dossier en welke 200 OK geven terwijl de loonheffingskorting-toggle op een tweede dienstverband boven 28 uur per week verdwijnt. Bouw of koop je in 2026 een HR-agent, lees dan de eerste zes goed.

De setup

Eén agent, drie tenants. Elke nieuwe hire komt binnen via een Slack-intake, de agent haalt identificatiedocumenten eruit, bouwt het dossier op en schrijft het naar de payroll-backend van de klant. AFAS via de App Connector en UpdateConnectors. Nmbrs via de REST-endpoints. Loket via de publieke REST API beschreven op developer.loket.nl.

De agent draait hetzelfde intake-schema tegen alle drie. Juist die uniformiteit maakte de bugs interessant: identiek goed-gevormde JSON leverde drie verschillende soorten stille corruptie op.

Klasse A: stille BSN-validatie drops op buitenlandse dossiers

Dit zijn de quirks die in stilte BSN-checks oversloegen of weghaalden bij werknemers zonder Nederlands paspoort. Precies het dossier dat de Belastingdienst opvraagt als ze straks willen weten waarom je loonheffing-aansluiting niet klopt.

1. AFAS UpdateConnector slaat 11-proef over als upstream "BSN onbekend" was gezet

Heeft een eerdere call in dezelfde connector-keten (KnPerson, KnSubject) BSN="" met het "BSN onbekend"-vinkje weggeschreven, dan accepteert de volgende UpdateConnector een verse BSN als platte string. Geen 11-proef. Het nummer landt in het dossier en de loonaangifte bouwt er gewoon tegenaan.

PUT /ProfitRestServices/connectors/KnEmployee
{ "KnEmployee": { "Element": {
  "Fields": { "EmId": "104", "BcSt": "U" },
  "Objects": [ { "KnPerson": { "Element": {
    "Fields": { "BcCo": "104", "BsNr": "11122233" }
  } } } ]
} } }

2. Nmbrs Employee_Insert met CountryISO != "NL" zet BSN stilzwijgend op null

Geef een Spanjaard met paspoort en een ingevulde BSN mee in dezelfde call als CountryISO="ES" en de BSN gaat als null door. De response toont elk ander veld als geschreven. De loonaangifte-connector zet vervolgens "BSN onbekend" in de aangifte.

3. Loket employments[].taxSettings wordt aangemaakt vóór de BSN is gecheckt

Loket laat je /employees POSTen en daarna /employments nog vóór de BSN-check microservice klaar is. Het dienstverband wordt aangemaakt met een pending status die nooit terug te lezen is via de publieke REST. Alleen via de audit trail in de admin-UI.

4. AFAS MatchKnPer="0" hergebruikt stilzwijgend een bestaand dossier

De match-parameter staat in de docs als "maak nieuw aan als niet gevonden". In de praktijk matcht MatchKnPer="0" op voornaam plus geboortedatum. We hadden twee Poolse broers met dezelfde DOB die in één dossier werden samengevoegd: de BSN van de eerste, de uren en het dienstverband van de tweede.

5. Nmbrs WageTaxSettings erft de BSN-onbekend flag van de vorige werkgever

Importeer je een starter via het Import_Employee_FromUWV-patroon, dan worden de loonheffing-flags van de vorige werkgever overgenomen voor de eerste periode-met-ingangsdatum. Het dossier oogt goed in de UI, maar de eerste-dienstbetrekking flag staat fout voor de eerste loonrun.

6. Het Loket /individuals BSN-veld accepteert strings met spaties ervoor

" 123456782" met een voorloop-spatie komt door de server-side validatie. Downstream loonheffing-exports trimmen de string en klappen daarna niet meer op het verloning-record. Twee systemen zijn het oneens over wie welke BSN heeft. De agent denkt dat alles goed gaat.

Klasse B: 200 OK, loonheffingskorting weg op tweede baan boven 28 uur

De Nederlandse loonheffingskorting mag maar bij één werkgever toegepast worden. Heeft een werknemer twee dienstverbanden, dan moet de toggle op het kleinere (of latere) contract expliciet uit. Deze zes quirks gaven allemaal 200 OK terug terwijl ze die bit stiekem de verkeerde kant op zetten.

7. AFAS LhKrt heeft drie geldige waarden; twee daarvan betekenen stilzwijgend "ja"

"J" (ja), "N" (nee), en leeg. Leeg staat gedocumenteerd als "gebruik tenant-default". De tenant-default is bijna altijd "J". De agent stuurde keurig "" voor tweede banen, in de verwachting dat het tenant-beleid 'm naar "N" zou flippen. Dat doet 'ie niet.

8. Nmbrs WageTaxSettings.LoonheffingskortingApply is een lijst met datumgrenzen

Je zet geen boolean. Je POST een record met StartDate/EndDate. Stuur je alleen StartDate, dan is het record open-ended. Stuur je EndDate zonder StartDate (bijvoorbeeld als een integratie ervan uitgaat dat de periode door het contract begrensd wordt), dan geeft de API 200 maar wordt er niets gepersisteerd.

POST /api/v3/employees/{id}/wagetaxsettings
{ "loonheffingskortingApply": false, "endDate": "2026-12-31" }

HTTP/1.1 200 OK
{ "id": null, "status": "accepted" }

Kijk naar de response body. id is null. Er is niets geschreven.

9. Loket payrollTaxes.applyTaxCredit staat default op true bij het tweede dienstverband

Het eerste dienstverband van een persoon staat default op false tenzij je er om vraagt. Het tweede dienstverband van dezelfde persoon staat — verwarrend genoeg — default op true. De redenering van Loket support: "het tweede contract is meestal met de nieuwe werkgever waar de korting moet worden toegepast". In detachering met gelijktijdige contracten is die aanname precies verkeerd.

10. AFAS Verloning rondt uren af als een contract midden in de periode start

Een contract van 32 uur dat op de 17e van de maand start krijgt 32 * (resterende dagen / dagen in maand) op de eerste loonstrook. Prima. Maar de loonheffingskorting-berekening loopt op de afgeronde uren, en 28 is de grens waarboven verschillende sector-CAO's eisen dat de toggle uit gaat. Rond af op 27,8 en de toggle blijft aan.

11. De ingangsdatum van een Nmbrs tweede-dienstverband snapt stilzwijgend naar begin van de maand

POST een contract met startDate="2026-06-22" en de API slaat 2026-06-01 op voor het loonheffing-record, maar 2026-06-22 voor het contract zelf. De twee records lopen 21 dagen uiteen. De loonaangifte gebruikt de eerdere datum.

12. Loket employments PATCH rolt de taxSettings niet mee

PATCH het contract om de weekuren van 24 naar 32 te brengen en het bestaande taxSettings-record (met applyTaxCredit=true van toen het nog een enkele kleine baan was) blijft ongewijzigd. Het contract triggert nu de boven-28u CAO-clausule, maar de tax-credit toggle is onveranderd. Geen waarschuwing. Geen 4xx.

Klasse C: de long tail

13. AFAS DateFormat-onderhandeling

De REST-laag accepteert ISO-8601 in de JSON body, maar de onderliggende GetConnector geeft dd-MM-yyyy terug tenzij je useUtcDate=true als query parameter op elke call meegeeft. Meng je ze binnen één sessie, dan denkt de diff-checker van de agent elke nacht dat elk dossier is gewijzigd.

14. Nmbrs 429 geeft geen Retry-After

De REST-endpoints throttlen op ongeveer 100 req/min/tenant maar geven 429 terug met een lege body en zonder Retry-After header. Naïeve backoff schiet er 30s overheen. Agressieve backoff zorgt dat je tien minuten lang rate-limited bent.

15. Loket OAuth-refresh-token roteert zonder waarschuwing

Refresh tokens zijn single-use. De nieuwe komt in dezelfde response terug en moet atomair worden weggeschreven. Verlies je de response (proces gekilld halverwege de write), dan zit je opgesloten uit die tenant tot een mens opnieuw autoriseert.

16. AFAS UpdateConnector envelope-grootte limiet

Ongeveer 32KB per call, niet gedocumenteerd. Grotere payloads geven een generieke 400 terug met "Onbekende fout". Splitsen op geneste KnSubject-arrays brengt je eronder. De agent pre-flight nu elke payload.

Wat de agent nu doet

Drie regels, in volgorde van prioriteit:

  1. Elke write wordt gevolgd door een read-back, en de diff van die read-back moet overeenkomen met de intent-log. 200 OK behandelen we als "het verzoek is ontvangen", niet als "het veld is geschreven".
  2. BSN-validatie draait eerst client-side, tegen een 11-proef-implementatie die we zelf in beheer hebben, vóór elke vendor-call. De check van de vendor is een second opinion, niet de eerste.
  3. Loonheffingskorting wordt berekend vanuit de volledige dienstverband-graaf van die persoon (over tenants heen waar de klant zicht op heeft), niet vanuit per-call inputs. De agent zet die uitkomst expliciet op elke write en vertrouwt nooit op "tenant-default" of "vorig record".
Waarschuwing

Behandelt jouw HR-integratie 200 OK als success, dan draaien er bijna zeker minstens twee van bovenstaande bugs in productie. De goedkoopste fix vandaag: bouw een read-back in op elke write die BSN of loonheffingskorting raakt en diff tegen je intake.

Dit sluit aan op het bredere argument dat deze week op Hacker News rondgaat over betrouwbare agentic systemen: de output van het model is niet de bron van waarheid, en de HTTP-responsecode van een vendor evenmin. Waarheid is wat terugkomt als je het record leest. We behandelen elke connector als adversarial en gaan ervan uit dat hij minstens één keer per duizend calls liegt over succes, want meetbaar doet hij dat.

De Apeldoornse rollout, zes maanden verder

De agent onboardt bij deze klant nu vier tot zeven nieuwe detacheerders per week, zonder loonrun-blockers sinds week drie van de rollout. De client-side BSN-validatie pakt rond de 2% van de intake-submissies eruit voordat ze ooit een vendor-API raken — meestal OCR-fouten op foto's van EU-paspoorten.

Toen we de onboarding-agent bouwden voor deze Apeldoornse detacheerder, was de eerste muur waar we tegenaan liepen de stille dossier-merge van AFAS bij duplicaat-geboortedata. We hebben dat opgelost door MatchKnPer="7" (alleen matchen op BSN) verplicht te stellen en elke intake zonder geverifieerde BSN te weigeren — inclusief buitenlandse-paspoort-hires, waarvoor we een fictief BSN genereren en het record markeren voor de loonadministrateur.

Eén ding dat je vandaag kunt doen

Grep je integratie-code op de letterlijke string 200. Overal waar je een 2xx-response behandelt als "het veld is geschreven", bouw een read-back in. Je vindt vóór de lunch minstens één van de zestien bugs hierboven.

Kern

Behandel elke vendor 200 OK als "de request is binnen", niet als "het veld is geschreven" — lees elk record terug voor je het dossier rond verklaart.

FAQ

Valideert AFAS Profit HR de BSN op elke UpdateConnector-call?

Nee. UpdateConnectors respecteren een upstream "BSN onbekend"-flag en accepteren ongeldige BSN's zonder opnieuw 11-proef te draaien. Valideer eerst client-side en stuur dan pas.

Waarom geeft Nmbrs 200 OK op een wagetaxsettings POST die niet is opgeslagen?

Als verplichte velden zoals StartDate ontbreken, geeft Nmbrs nog steeds een succes-vormige envelope terug. Check het response-id: is het null, dan is het record niet geschreven.

Wat is de loonheffingskorting-toggle en waarom telt hij bij tweede banen?

Hij vertelt de loonheffing-berekening om de heffingskorting toe te passen. Hij mag bij precies één werkgever aan staan. Twee keer aan betekent te weinig inhouding en een belastingaanslag voor de werknemer.

Is de REST API van Loket veilig om met gelijktijdige requests te gebruiken?

Grotendeels wel, maar refresh tokens zijn single-use en roteren. Parallelliseer je over een refresh-window heen, persist dan de nieuwe token atomair, anders sluit je jezelf uit van de tenant.

integrationsai agentsautomationworkflowoperationscase study

Iets bouwen?

Start een project