Integrations
Dutch accounting APIs: 16 quirks that broke our boekhoud-agent
Sixteen Exact Online, AFAS Profit, and Twinfield REST quirks from a four-month boekhoud-agent rollout, ranked by which silently corrupt the trial balance.

Monday morning, Zwolle. The senior controller at a 25-person MKB-accountantskantoor is reconciling Friday's batch. The trial balance is off by €0.03. Then €0.07. By coffee the gap is €1.41 across 47 boekingen. The boekhoud-agent ran clean overnight — green checkmarks, every line acknowledged with a 200 OK. The money is just... rounded.
We spent four months last winter shipping that agent for a kantoor with twelve klanten. It reads inkoopfacturen out of a shared inbox, classifies them, posts to each klant's accounting platform, and chases approvals through Teams. Two klanten run on Exact Online, eight on AFAS Profit, two on Twinfield. Along the way, sixteen REST quirks bit us. The cheap ones throw 400s and you fix them on day one. Four others return a clean 200 OK and silently corrupt your trial balance. Below is the ranking — silent killers at the top, fix-on-day-one at the bottom.
Tier 1: silent data corruption
These are the four that returned a clean 200 OK and quietly distorted the books. We caught them in week three after the controller flagged an aangifte-preview.
1. Exact Online — BTW-verlegd rounded to €0.05 on partial credits
POST a credit memo against a reverse-charge sales invoice and Exact recomputes the implicit VAT line server-side, then rounds to the nearest €0.05. The response shows your submitted amounts. The journal entry written to SalesEntryLines shows the rounded value. Original invoice for €1,247.83 at 21% verlegd, credit of €312.41, expected VAT €65.61, actual €65.60. Three cents nobody notices until the kwartaalaangifte preview is off across forty klanten.
Workaround: post the credit as a manual journal entry on GeneralJournalEntries, not against the source invoice. You lose the linkage but keep the cents.
2. Twinfield — tussenrekening above €5,000 drops the grootboekrekening mapping
Journal entries above €5,000 hitting a tussenrekening (kruisposten, vraagposten, kas-in-transit) lose the dim1 mapping if you submit the four-digit code instead of the office-prefixed form. 200 OK. The entry shows in the UI. The grootboekrekening field is null. Your trial balance still tallies because the offsetting line carries its mapping — but the kolommenbalans export drops the line into the unassigned bucket.
<!-- Silently strips mapping above €5,000 -->
<line type="detail">
<dim1>2510</dim1>
<value>6712.40</value>
</line>
<!-- Works -->
<line type="detail">
<dim1>KRUIS-2510</dim1>
<value>6712.40</value>
</line>
3. AFAS UpdateConnector — case-sensitive field IDs, silently ignored
DaRe is "Datum reservering". DARE is nothing. The connector returns 200, every correctly-cased field is persisted, and the misspelled one is dropped on the floor. Older codebases that normalize to upper or lower case will silently NULL half a record. AFAS documents canonical casing per field but does not return a validation error on mismatch.
4. Exact Online — empty CostCenter cascades across linked journal lines
POST a TransactionLine with "CostCenter": null on a journal that is already linked to a second line, and Exact clears the CostCenter on every linked line. Documented as "expected" in a 2019 forum thread, undocumented in the official reference. Always echo the previous value back unchanged unless you genuinely want to clear it.
Quirks 1, 2, and 3 distort a trial balance without producing a single error log line. A verification layer that re-reads every posted entry and checksums it against what you sent is the only reliable defense.
Tier 2: 200 OK but quietly wrong
5. Exact OAuth refresh — 30-second clock skew sinks you in production
Refresh tokens are valid 30 seconds in the future on the auth endpoint. If your auth server runs more than 30 seconds behind NTP, you get invalid_grant. If it runs ahead, the access token comes back with zero seconds of life. Worked-on-my-laptop incarnate. Exact's REST reference mentions the window in one sentence on the OAuth page.
6. Twinfield — SessionID expires mid-batch, response envelope still 200
Sessions expire after 20 minutes of inactivity. Long-running ProcessXML batches sometimes hit the wall mid-flight. The HTTP layer returns 200. The SOAP envelope is intact. The inner <result> is <error>Session expired</error>, which an XML parser walking only top-level nodes will ignore. Always assert result == "success" explicitly.
7. AFAS GetConnector — skip parameter caps at 49,999
Above 49,999, skip silently falls back to 0. Cursor through a 200k-record HrEmployee dataset and you process the first page four times. Use orderbyfieldids plus a stable cursor on a date or id field instead of offset pagination.
8. Exact division parameter — defaults to the wrong administration
Omit ?division= and Exact uses the user's default. For OAuth service accounts the "default" is the first administration ever created on the tenant — usually a holding or test administration, never the operational one. Boekingen ended up in the wrong klant during our first week. We now hard-fail any request without an explicit division.
Tier 3: fails loudly, recoverable
9. Exact rate limits — reset is wall-clock, not rolling window
60 requests per minute on /current/Me and most metadata endpoints. The reset is at the wall-clock minute boundary, not 60 seconds after your first request. Burst 60 at 10:00:59 and you can burst another 60 at 10:01:00. Most rate-limit clients assume a rolling window and back off too long.
10. Twinfield XML — namespace prefixes checked against a hard-coded table
Use <finance> instead of <twf:finance> with a default xmlns declared and the entire batch is rejected with Invalid envelope. The XML is valid by spec. Twinfield's parser checks prefixes against an internal table and ignores the actual namespace binding.
11. AFAS Profit — tokens rotate every 90 days, no warning
Profit tokens expire after 90 days. No email. No deprecation header. Friday afternoon, your integration starts returning 401. Add a calendar reminder at day 80 or rotate programmatically through the token management endpoint.
12. Exact JSON dates — Microsoft format on POST, ISO 8601 rejected
GET returns "/Date(1718841600000)/". POST requires the same. ISO 8601 like 2026-06-20T00:00:00Z returns 400 with a generic "format error" that doesn't name the field. We wasted a day on this before grepping for the millisecond timestamps in the docs.
Tier 4: known, annoying, documented somewhere
13. Twinfield — UTF-8 BOM in JSON responses
Some endpoints prepend a byte-order mark to JSON. Go's encoding/json rejects it. Most other languages tolerate it. Strip the first three bytes if they match EF BB BF.
14. AFAS — undocumented subscription gating returns empty 403
Endpoints like HrPerson require a tenant subscription flag. The 403 body is empty — no message, no hint. When a previously working endpoint goes dark, check the Profit license page before you start chasing your auth code.
15. Exact Modified field — UTC suffix required for concurrency check
PUT requires Modified as UTC with trailing Z. Drop the Z and Exact treats it as Amsterdam local time. During DST you either silently overwrite a newer change or get a 409 with no explanation. Always send Z.
16. Twinfield browse codes truncate at 250 chars
Browse codes (used for finding a transaction by reference) truncate silently at 250 characters. Long PO descriptions get cut, the transaction becomes unfindable through the API even though it shows fine in the UI. Twinfield's webservice docs mention the limit on the browse-code page but not on the reference field where it actually bites.
The shape of the verification layer
After week three we stopped trusting 200 OK. The boekhoud-agent now does this for every posted entry:
def verify_posted(entry_id: str, expected: JournalEntry) -> None:
# Re-read after 5 seconds to cover eventual consistency
time.sleep(5)
actual = client.get_journal_entry(entry_id)
if actual.total_debit != expected.total_debit:
alert("debit mismatch", entry_id)
if actual.total_credit != expected.total_credit:
alert("credit mismatch", entry_id)
for line in actual.lines:
if line.gl_account is None:
alert("dropped grootboekrekening", entry_id, line.id)
if line.vat_amount != expected.vat_for(line.id):
alert("vat rounding", entry_id, line.id)
It adds five seconds of latency per entry. For a nightly batch of 800 inkoopfacturen, that is about 70 minutes of sleep time wall-clock. We run the verifier in a parallel worker pool and the whole batch finishes in twelve. The agent posts at 22:00, the verifier finishes at 22:30, the controller opens her email at 08:00 to either nothing or a triaged list of mismatches with entry IDs and links straight into the platform UI.
What we shipped, in two sentences
When we built the boekhoud-agent for the Zwolle kantoor, the worst week was a BTW-aangifte preview off by €184 across three klanten — all of it traced to quirk #1. We solved it by writing the verification layer above and folding it into every AI agent we ship that talks to a financial API.
If you already run a bookkeeping integration, the five-minute audit is this: pull last month's journal entries from your source-of-truth and from the platform, diff the line totals by entry-id, count the mismatches. If the count is anything but zero, you have a tier-1 quirk somewhere in your stack.
Key takeaway
Stop trusting 200 OK from a financial API. Re-read every posted entry and checksum the lines against what you sent — that is the only reliable defense.
FAQ
Which of these quirks is the most dangerous in production?
Quirk #1 (Exact BTW-verlegd rounding on partial credits) and #2 (Twinfield grootboekrekening dropped above €5,000) both pass with 200 OK and only surface at quarter-end aangifte. Always verify by re-reading.
Does the verification layer add too much latency for real-time posting?
For nightly batches no, it adds about ten minutes wall-clock in parallel. For interactive posting it adds five seconds per entry. Most accounting workflows tolerate the delay; user-facing flows need a different design.
Are these quirks documented anywhere official?
Some are buried in forum threads or release notes. The four tier-1 silent-corruption quirks are essentially undocumented. AFAS, Exact, and Twinfield each have public docs but the silent-failure modes rarely make the official reference.
Do these apply to Exact Online Premium or just Standard?
We saw quirks 1, 4, 5, 8, 9, 12, and 15 on both Standard and Premium tenants. The Exact REST surface is shared between the editions, so most quirks travel with it.