Integrations
Dutch HR API quirks: 16 silent fails in AFAS, Nmbrs, Loket
A 24-person Apeldoorn detacheerder onboarding-agent surfaced sixteen REST quirks across AFAS Profit, Nmbrs and Loket.nl. Half silently dropped fields the auditors care about.

On a Wednesday in March, a payroll administrator at a 24-person Apeldoorn detacheerder called us because Friday's loonrun had three foreign-passport employees showing zero hours on their loonstrook. The onboarding-agent had finished their files cleanly. AFAS had returned 200 OK for every call. The agent's audit log was green. Nothing had failed. Except the people had not, in any payable sense, been hired.
We spent the next six weeks shipping fixes against AFAS Profit HR, Nmbrs and Loket.nl. This is the cheatsheet that came out of it: sixteen quirks, ranked by which ones silently drop BSN-validatie on a buitenlandse-werknemer dossier and which ones return 200 OK while losing the loonheffingskorting-toggle on a tweede-dienstverband above 28 hours per week. If you are building or buying an HR agent in 2026, read the first six closely.
The setup
One agent, three tenants. Every new hire enters through a Slack-based intake, the agent extracts identification documents, builds the dossier, and writes it to whichever payroll backend the client uses. AFAS via the App Connector and UpdateConnectors. Nmbrs via the REST endpoints. Loket via the public REST API documented at developer.loket.nl.
The agent runs the same intake schema against all three. That uniformity is what made the bugs interesting: identical well-formed JSON produced three different categories of silent corruption.
Class A: silent BSN-validatie drops on buitenlandse dossiers
These are the quirks that quietly stripped or skipped BSN checks on employees without a Dutch passport, which is exactly the dossier the Belastingdienst will pull when they ask why your loonheffing reconciliation does not match.
1. AFAS UpdateConnector skips 11-proef when "BSN onbekend" was set upstream
If any earlier call in the same connector chain (KnPerson, KnSubject) wrote BSN="" with the "BSN onbekend"-vinkje, the next UpdateConnector will accept a fresh BSN as a plain string. No 11-proef. The number lands in the dossier and the loonaangifte builds against it.
PUT /ProfitRestServices/connectors/KnEmployee
{ "KnEmployee": { "Element": {
"Fields": { "EmId": "104", "BcSt": "U" },
"Objects": [ { "KnPerson": { "Element": {
"Fields": { "BcCo": "104", "BsNr": "11122233" }
} } } ]
} } }
2. Nmbrs Employee_Insert with CountryISO != "NL" silently nulls BSN
Pass a Spanish passport-holder with a populated BSN in the same call as CountryISO="ES" and the BSN goes in as null. The response lists every other field as written. The loonaangifte connector then writes "BSN onbekend" into the aangifte.
3. Loket employments[].taxSettings is created before BSN is verified
Loket lets you POST /employees followed by /employments before the BSN-check microservice has finished. The employment is created with a pending status that never reads back as pending via the public REST. Only via the admin UI's audit trail.
4. AFAS MatchKnPer="0" silently re-uses an existing dossier
The match parameter is documented as "create new if not found". In practice, MatchKnPer="0" matches on voornaam plus geboortedatum. We had two Polish brothers with the same DOB merged into one dossier: BSN of the first, hours and dienstverband of the second.
5. Nmbrs WageTaxSettings inherits the previous employer's BSN-onbekend flag
If you import a starter via the Import_Employee_FromUWV pattern, the loonheffing flags from the previous employer carry over for the first effective-date period. The dossier reads correct in the UI, but the eerste-dienstbetrekking flag is wrong for the first loonrun.
6. Loket /individuals BSN field accepts whitespace-padded strings
" 123456782" with a leading space passes server-side validation. Downstream loonheffing exports trim the string, then mismatch against the verloning record. Two systems disagree about which employee owns which BSN. The agent thinks both are happy.
Class B: 200 OK, lost loonheffingskorting on a second job over 28 hours
The Dutch loonheffingskorting can only be applied at one employer. For an employee with two dienstverbanden, the toggle on the smaller (or later) contract must be explicitly off. These six quirks all returned 200 OK while quietly flipping that bit the wrong way.
7. AFAS LhKrt has three valid values; two of them silently mean "yes"
"J" (ja), "N" (nee), and empty. Empty is documented as "use tenant default". The tenant default is almost always "J". The agent dutifully sent "" for second jobs, expecting tenant policy to flip to "N". It does not.
8. Nmbrs WageTaxSettings.LoonheffingskortingApply is a date-bounded list
You do not set a boolean. You POST a record with StartDate/EndDate. Send only StartDate and the record is open-ended. Send EndDate without StartDate (e.g. when an integration assumes the period is bounded by the contract) and the API returns 200 but persists nothing.
POST /api/v3/employees/{id}/wagetaxsettings
{ "loonheffingskortingApply": false, "endDate": "2026-12-31" }
HTTP/1.1 200 OK
{ "id": null, "status": "accepted" }
Look at the response body. id is null. Nothing was written.
9. Loket payrollTaxes.applyTaxCredit defaults true on the second employment
The first employment for a person defaults to false unless asked. The second employment for the same person, confusingly, defaults to true. Loket support's reasoning is that "the second contract is usually with the new employer where the credit should apply". In detachering with concurrent contracts that assumption is exactly wrong.
10. AFAS Verloning rounds hours when a contract starts mid-period
A 32-hour contract that starts on the 17th of the month gets 32 * (days remaining / days in month) on the first loonstrook. Fine. But the loonheffingskorting calculation runs against the rounded hours, and 28 is the threshold for several sector CAOs to require the toggle off. Round to 27.8 and the toggle stays on.
11. Nmbrs second-dienstverband effective date silently snaps to start-of-month
POST a contract with startDate="2026-06-22" and the API stores 2026-06-01 for the loonheffing record but 2026-06-22 for the contract itself. The two records disagree by 21 days. Loonaangifte uses the earlier date.
12. Loket employments PATCH does not roll the taxSettings forward
PATCH the contract to increase weekly hours from 24 to 32 and the existing taxSettings record (with applyTaxCredit=true from when it was a single small job) stays as-is. The contract now triggers the over-28h CAO clause, but the tax-credit toggle is unchanged. No warning. No 4xx.
Class C: the long tail
13. AFAS DateFormat negotiation
The REST layer accepts ISO-8601 in the JSON body, but the underlying GetConnector returns dd-MM-yyyy unless you pass useUtcDate=true as a query parameter on every call. Mix them in one session and the agent's diff-checker thinks every dossier changed every night.
14. Nmbrs 429 returns no Retry-After
The REST endpoints throttle at roughly 100 req/min/tenant but return 429 with an empty body and no Retry-After header. Naive backoff overshoots by 30s. Aggressive backoff gets you rate-limited for ten minutes.
15. Loket OAuth refresh token rotates without warning
Refresh tokens are single-use. The new one comes back in the same response and must be persisted atomically. Lose the response (process killed mid-write) and you are locked out of that tenant until a human reauthorises.
16. AFAS UpdateConnector envelope size limit
Roughly 32KB per call, undocumented. Larger payloads return a generic 400 with "Onbekende fout". Splitting on nested KnSubject arrays gets you under. The agent now pre-flights every payload.
What the agent does now
Three rules, in priority order:
- Every write is followed by a read-back, and the read-back diff must match the intent log. 200 OK is treated as "the request was received", not "the field was written".
- BSN validation runs client-side first, against an 11-proef implementation we own, before any vendor call. The vendor's check is a second opinion, not the first.
- Loonheffingskorting is computed from the full employment graph for that person (across tenants where the client has visibility), not from per-call inputs. The agent asserts the resulting boolean explicitly on every write, never relying on "tenant default" or "previous record".
If your HR integration treats 200 OK as success, you almost certainly have at least two of the bugs above in production right now. The cheapest fix today: add a read-back on every write that touches BSN or loonheffingskorting, and diff against your intake.
This matches the broader argument trending on Hacker News this week about reliable agentic systems: model output is not the source of truth, and neither is a vendor's HTTP response code. The truth is what comes back when you read the record. We treat every connector as adversarial and assume it will lie about success at least once per thousand calls, because measurably it does.
The Apeldoorn rollout, six months in
The agent now onboards four to seven new detacheerders per week at this client with zero loonrun-blockers since week three of the rollout. The client-side BSN-validatie catches around 2% of intake submissions before they touch any vendor API, which turns out to be mostly photo-OCR errors on EU passports.
When we built the onboarding agent for this Apeldoorn detacheerder, the first wall we hit was AFAS' silent dossier-merge on duplicate DOBs. We ended up solving it by requiring an explicit MatchKnPer="7" (match on BSN only) and refusing any intake without a verified BSN, including foreign-passport hires where we generate a fictief BSN and flag the record for the loonadministrateur.
One thing to do today
Grep your integration code for the literal string 200. Anywhere you treat a 2xx response as "the field was written", insert a read-back. You will find at least one of the sixteen bugs above before lunch.
Key takeaway
Treat every vendor 200 OK as "the request arrived", not "the field was written" — read every record back before you call the dossier complete.
FAQ
Does AFAS Profit HR validate BSN on every UpdateConnector call?
No. UpdateConnectors honour an upstream "BSN onbekend" flag and will accept invalid BSNs without re-running the 11-proef. Validate client-side first, then send.
Why does Nmbrs return 200 OK on a wagetaxsettings POST that was not persisted?
When required fields like StartDate are missing, Nmbrs still returns a success-shaped envelope. Check the response id: if it is null, the record was not written.
What is the loonheffingskorting toggle and why does it matter for second jobs?
It tells the loonheffing calculation to apply the personal tax credit. It must be on at exactly one employer. Two on means under-withholding and a tax bill for the employee.
Is Loket's REST API safe to use with concurrent requests?
Mostly yes, but refresh tokens are single-use and rotate. If you parallelise across a refresh window, persist the new token atomically or you will lock yourself out of the tenant.