Integrations
Payroll API quirks: 16 silent failures in Exact, AFAS, Visma
An Eindhoven payroll bureau closed May with 47 mutations on the wrong kostenplaats. Here are the sixteen REST quirks in Exact Online, AFAS Profit, and Visma.net that caused it.

Friday afternoon, last week of May. An Eindhoven payroll bureau, nineteen staff, runs the loonstrook batch for the eleventh administratie of the day. The job returns 201 Created on every line. Monday morning, the senior consulent opens the kostenplaats report and 47 mutations are sitting on the wrong cost center. Not random. All 47 belong to the administratie they processed tenth.
That is what a silent division swap looks like in Exact Online when one query parameter goes missing. Over the next four weeks we pulled every adjacent quirk out of the same rollout: three Dutch payroll-adjacent APIs, sixteen failure modes, ranked by how loudly they fail. This is the cheatsheet.
Why we ranked it by silence, not severity
Every payroll integration has bugs you can see. A 400, a 422 with a field path, a stack trace in the logs. Those are cheap. You write a retry, a Slack alert, a unit test, and you move on.
The expensive bugs are the ones where the API returns 201 Created, the dashboard shows a green checkmark, and the consulent finds out at month-end close. We sorted the sixteen quirks accordingly. The top of the list rewrites your data without telling you. The bottom is just paperwork.
Tier 1: silent data corruption
1. Exact Online inherits the last-used division when the parameter is missing
The division segment in every /api/v1/{division}/... endpoint is positional in the URL, but most client libraries fold it into a base config. If you set the base division at startup and never reset it between administraties, you are one missing line away from posting against whichever division you touched most recently.
The Exact Online REST docs are explicit about the URL shape but say nothing about how identical the response looks when the division silently differs. The fix is to never inherit a default:
// wrong: division leaks across administraties
const client = new ExactClient({ division: defaultDivision });
await client.post('/salary/SalaryMutations', mutation);
// right: division is per-call, never defaulted
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 drops the verloningstijdvak on a backdated mutatie
Post a salary mutation with a StartDate in a verloningstijdvak that has already been closed and Exact Online returns 201 Created. The entity exists. But the Period field on the saved record is null, which means the next run treats it as current period and applies it to the open cycle instead.
This is not in the public changelog. We found it by diffing GET-after-POST on every single mutation in a test administratie. Mitigation: always re-read the entity and assert the period.
3. AFAS Profit falls back to the functie default when a kostenplaats is omitted
If your KnEmployee envelope omits the CostCenterId field on insert, AFAS does not return 422. It looks up the default kostenplaats on the assigned functie and writes that. Fine until HR changes the functie default mid-month and your historical inserts drift.
The AFAS AppConnector documentation notes default-lookup behaviour for some fields, not all. Treat every nullable kostenplaats-like field as explicit-or-fail in your client.
4. Visma.net swaps employer when the OAuth scope goes stale
Visma.net binds the tenant to the access token at scope-grant time. Refresh the token without re-requesting the tenant scope and the next POST lands against the previous tenant. Returns 201. Wrong employer. We caught this once. We assume we missed it twice.
If your integration touches more than one administratie or tenant, write a GET-after-POST assertion that compares the saved employerId or division against the one you intended. Not a unit test. A production-time check on every write.
Tier 2: 201 Created with nothing created
5. Visma.net returns 201 before async batch validation runs
The salary batch endpoint accepts the payload, returns 201 with a batch ID, then validates asynchronously. If validation fails, the batch lands in a status the API never proactively reports. You have to poll. The 201 means received, not saved. See the Visma.net API index for the batch-status endpoints worth polling.
6. AFAS UpdateConnector returns 200 OK with empty results on validation failure
UpdateConnector returns 200 with an empty Results array when the inner XML fails validation. There is no error field. The client thinks the call succeeded. Always assert Results.length > 0 before treating the call as done.
7. Exact Online: conflicting Division header and URL produces a ghost 201
If you set the division in the URL and also send a conflicting X-Division header (some SDKs do this automatically), Exact returns 201 with an entity ID that does not resolve on a follow-up GET. The entity is never written. Pick one source of division and strip the other from every layer of your client.
Tier 3: pagination and projection
8. Exact Online: $select on write truncates the response entity
OData $select works on GET. On POST it is accepted and quietly applied to the response payload, which means your verify-the-saved-record code might be reading a stub instead of the full entity. Drop $select on writes.
9. AFAS GetConnector: skip past 5000 returns empty without warning
The undocumented skip ceiling is roughly 5000 rows. Past that you get a 200 with an empty rows array, no error, no header. Filter down with a real WHERE clause; do not paginate blind through a large connector.
10. Visma.net: $top capped at 1000 but the API accepts whatever you ask for
Documented cap is 1000. We saw responses with 1000 rows on a $top=5000 request and no link header to the next page. If you trust the count, you skip data. Always assume the response is the current page, not the total.
Tier 4: auth and rate
11. Exact Online: 60 requests per minute, but token refresh resets the counter
Per app per division you get 60 calls a minute. The rate-limit header resets on token refresh, which sounds like a feature until your integration accidentally refreshes mid-batch and blows through a soft throttle that the next batch will hit hard.
12. AFAS AppConnector token rotates silently when the beheerder clicks the wrong button
The AppConnector token is rotatable from the AFAS UI. If the beheerder rotates it, the API returns 401 with no body. No webhook, no email. Build a daily synthetic check that posts one no-op mutation and alerts on 401.
13. Visma.net: tenant scope must be re-requested per administratie
Adding a new tenant to an existing OAuth client does not extend the existing access token. You need a new authorization round. We script this; the bureau has 11 administraties, three of which were onboarded after the original consent screen.
Tier 5: data format
14. AFAS: timezone-less dates interpreted as Europe/Amsterdam, including DST
AFAS dates are written as 2026-03-29T02:30:00 with no offset. On DST transitions, that timestamp does not exist. The API rounds forward silently, which moves a verloningstijdvak start by one hour. Always send midnight Europe/Amsterdam or use UTC midnight explicitly.
15. Exact Online: amounts in euros on some endpoints, cents on others
Financial endpoints take amounts in euros with two decimals. The salary mutation endpoint takes amounts in cents as integers. Validation accepts both because the salary endpoint silently treats 1250.50 as 1250 cents. Wrap your money type and pick the unit per endpoint.
16. Visma.net: decimal separator differs by country endpoint
The Dutch endpoint uses a dot. The Swedish endpoint uses a comma. If your client serialises numbers as JSON without forcing the locale, you can write 1,50 as a string and get accepted on one endpoint but truncated to 1 on the other. Always send numbers as JSON numbers, never strings.
The diff between tier 1 and tier 5
Notice the pattern. Tier 1 fails silently because the API guesses on your behalf. Tier 5 fails silently because the API is too lenient about types. Both shapes of leniency are expensive. The cure is the same in both directions: never let the integration guess, never let the API guess, and always re-read what you wrote.
The deeper problem with quirks like number 2 above is that they are not in any vendor doc. Your team learns them once, in production, on a Friday. Then the knowledge lives in one head until that person leaves. The cheatsheet above is our attempt to spread that out.
What to actually do on Monday
If you are running multi-administratie writes against any of these three APIs, three actions today:
- Add a GET-after-POST assertion to every write that compares
divisionortenantIdagainst your intent. - Remove every default-division and default-tenant from your client config. Make them required arguments at every call site.
- Build a daily synthetic write that posts and immediately reverses a no-op mutation. Alert on any silent change to the response shape.
When we built the loonstrook automation for this Eindhoven bureau, the thing we ran into was not any single quirk above. It was the absence of a verification layer between the integration and the consulent's dashboard. We ended up adding a second pass that re-reads every mutation thirty seconds after it is written and compares the saved fields to the intent. Three of the sixteen quirks above only surface in that second pass.
If you want a five-minute audit: grep your integration for every place a division, tenant, employer, or kostenplaats can be defaulted, and rip the defaults out. The next batch will tell you which ones were carrying weight.
Key takeaway
Every silent payroll API failure starts with a default that should have been a required argument. Strip the defaults; the bugs surface immediately.
FAQ
How do you detect a silent division swap in Exact Online?
Re-read the entity right after POST and assert the Division field matches the one you intended. If it does not, fail the batch and page the on-call consulent before the next batch starts.
Does AFAS Profit return a clear error when a kostenplaats default is applied?
No. The insert succeeds and the saved kostenplaats reflects the functie default. Treat every nullable kostenplaats-like field as required in your client envelope and reject empty values before sending.
Can one Visma.net OAuth token cover multiple tenants?
Only if every tenant was scoped at consent time. Adding a new tenant later needs a fresh authorization round. The existing access token will not be extended automatically when you refresh it.
Are these quirks documented by the vendors?
Some are. The silent fallbacks, async validation behaviour, and skip-ceiling on AFAS GetConnector are mostly folklore, surfaced by diffing GET-after-POST in a test administratie before going live.