Integrations
Exact, AFAS, Twinfield REST: a debiteuren-agent cheatsheet
Tuesday morning in Nijmegen: the debiteuren-agent had been live nine days when a partner pointed at a €14,200 ICP invoice and asked where the BTW code went.

Tuesday morning in Nijmegen, 09:14. The debiteuren-agent we'd shipped for a 25-person accountancy praktijk had been live for nine working days. It had touched 312 open invoices, cleared 184 without human review, routed 47 to the team, and parked 81 with a question for the partner. Then a partner walked over with a printed Opgaaf ICP and asked why a €14,200 intracommunautaire invoice to a Brussels machine-parts supplier showed up with no BTW code on the Exact Online side. The agent had posted it. The API had returned 200 OK. The field was just empty.
Over the following six weeks we catalogued sixteen of these. Some had been silently corrupting boekingen for months before the agent went in — they just hadn't been caught because the bookkeeper would re-enter or reverse a line whenever the data looked wrong, and no one tracked how often. The agent surfaced them because the agent doesn't re-enter; it logs, and moves on.
What follows is the ranked cheatsheet. We've grouped by severity rather than by vendor, because the real question when you're shipping an agent into Dutch accounting middleware is which of these will lose money quietly — not which vendor has the prettiest OpenAPI doc.
What the agent does
The debiteuren-agent has a narrow job. Every morning at 06:30 it reads the open debiteuren ledger from whichever boekhoudsysteem the client is on, segments by age and amount, drafts a herinnering for invoices between 14 and 60 days overdue, escalates anything over 60 days to a partner with the matching MT940 transactions attached, and reconciles paid invoices against the bank import. It writes back to the boekhouding only when it matches a payment to an invoice and when it posts a credit-nota for a disputed line that the partner has approved in Slack.
That narrowness is what made the quirks visible. A human bookkeeper handling fifty writes a day spreads errors thin enough that no single account looks broken. An agent handling three hundred writes a day concentrates them. If two percent of writes drop a BTW code, in two weeks you have a hundred missing codes — and they all sit together in the Opgaaf ICP review screen, where the partner notices.
The two that lose money silently
1. Exact Online SalesEntry — PATCH without Type flips credit-nota to debet
Exact Online's /salesentry/SalesEntries endpoint is OData v4. The Type field on a SalesEntry takes 20 for a verkoopfactuur and 21 for a credit-nota. The OData spec says PATCH should update only the fields you send. In practice, if your agent resolves an existing entry by document reference rather than by the entry GUID, and two siblings share the reference — one invoice, one credit-nota — Exact picks the lower entry-number, which is almost always the invoice. The PATCH lands on the wrong row. The credit-nota sits untouched, the invoice gets the credit-nota's status update, and the debiteurensaldo grows by twice the line amount because the credit-nota never closed.
We saw this happen on partial-match updates four times in the first week. The cure is unsexy: always resolve by EntryID (the GUID), include Type and Journal on every PATCH so the row identity is never ambiguous, and never accept a server-side match. The Exact REST reference documents the fields but says nothing about the match precedence.
PATCH /api/v1/{division}/salesentry/SalesEntries(guid'9c0e...') HTTP/1.1
Content-Type: application/json
{
"EntryID": "9c0e...",
"Type": 21,
"Journal": "70",
"Status": 50
}
2. AFAS Profit FiEntries — 200 OK with empty BTW code on ICP
AFAS Profit's FiEntries UpdateConnector accepts a sales entry payload as nested elements wrapped in JSON. For an intracommunautaire factuur with BTW verlegd binnen EU, the BTW code must be set explicitly (typically code 5 in a default scheme) and the BTW percentage must be 0. If the payload arrives with percentage 0 and the BTW code field missing or empty string, the connector returns 200 OK, writes the line with no code, and the invoice never appears on the Opgaaf ICP.
The €10,000 threshold in the title of this section is not a vendor threshold; it is the threshold at which a Dutch praktijk individually reviews an intracommunautaire invoice on the ICP screen. Smaller ones flow through in a batch the partner skims. Larger ones get clicked open. Which is why the bug had been invisible for eleven months on smaller invoices and surfaced the day a €14,200 one landed.
We later found ninety-one prior cases on the same administration. The praktijk filed a Suppletie.
If your agent posts ICP invoices to AFAS Profit, write a post-write reconciler that reads the entry back and asserts the BTW code is non-empty for every line where the debtor country differs from the administration country and the BTW percentage is 0. The API will not refuse a malformed payload — you have to refuse it from the client side.
The next four that quietly corrupt your data
3. Twinfield — VAT codes are office-scoped, and the session office is sticky
Twinfield's BrowseTransactions and SalesInvoice endpoints both read VAT codes from the office context of the session. VAT codes are defined per office. If your agent opened a session against office A and you post an invoice for a customer whose primary office is B, the VAT code string may not exist in B's codebook. Twinfield either swaps it for the office default (often 0% domestic) or rejects — and which it does depends on the customer's tax country and whether the string exists at all in B's scheme.
Switch office explicitly before every write: SessionSwitchCompany on the SOAP side, or set the office in the request body on REST. Don't trust that the session you opened twenty minutes ago is still pointed where you think. The Twinfield webservices documentation describes the session model but is quiet on what happens when the office and customer mismatch.
4. Exact Online — refresh-token rotation kills cross-division agents
Exact Online's OAuth refresh tokens rotate on every use. The grant returns a new refresh token; the old one is invalidated immediately. If two processes share a token — for instance, a write-side agent and a read-side dashboard — the second one to refresh loses, returns 401, and now you've broken your read path and your write path on alternating runs.
We had three days of mystery 401s before we realised the dashboard's 06:00 cron was eating tokens five minutes before the agent woke up. Use a token broker. The simplest version is a Redis key holding the current refresh token and a lock around the refresh call.
5. AFAS Profit — GetConnector skip silently caps at 10,000
The default take on a GetConnector is 100, the maximum is 10,000, and if you ask for skip=10001 you get a response that looks like a normal empty page. No error, no total-count header, no warning in the response envelope. If you are walking a long debtor ledger you must filter by date range and re-window, not paginate past 10,000. The day a praktijk crosses ten-thousand debtors, the agent that worked yesterday silently starts losing the tail.
6. Twinfield — a matched line can have a zero matched amount
On Twinfield's transaction-lines payload, the matchstatus field can read matched while the matchedamount is 0.00. This happens when an old proposal was confirmed and then reversed by a write-off in the same matching session. Your agent should reconcile on the cash amount, not the status flag. We learned this on a single afternoon where the agent decided every line was paid because every line was, technically, matched.
The ten you can survive but will still lose an afternoon to
The remaining ten are smaller. Each cost us between twenty minutes and a full afternoon. Inventoried here so you don't.
- Exact Online — the Journals lookup paginates at 60, not 100. The doc says 100. It's 60. Walk the cursor to be safe.
- Exact Online — BankEntry requires the Division in the URL even when it is in the session. Omitting it returns 401, not 400. You will spend forty minutes thinking the token is broken.
- AFAS Profit — UpdateConnector treats an empty string as leave unchanged. Send
nullto actually clear a field. - AFAS Profit — the debtor balance endpoint rounds to whole euros on the response. Compute the balance from the ledger lines, not the convenience endpoint.
- Twinfield — invoice PDF URLs are tokenised and expire after 60 minutes. Cache the bytes, not the URL.
- Twinfield — REST and SOAP can disagree on transaction status during a 30-second eventual-consistency window after a write. Read your writes through SOAP for the first minute.
- Exact Online — Subscriptions filtered by
Status eq 1omit subscriptions in grace period. Use the StatusDescription string instead. - AFAS Profit — date fields silently truncate timezone. Send dates in Europe/Amsterdam local time. UTC will land on the wrong day for late-evening writes.
- Twinfield — customer creation accepts an existing code and returns 200 OK, overwriting the existing customer. Check before write. Always.
- Exact Online — webhook payloads fire before the entity is queryable. The webhook arrives in milliseconds; the read endpoint sees the row two to five seconds later. Add a retry loop on the immediate fetch.
The five-minute pre-flight we now run before any go-live
Before a debiteuren-agent touches production now, we run a pre-flight against the target administration. It takes five minutes and would have saved us six weeks.
- POST a test SalesEntry of each type — verkoopfactuur, credit-nota, EU ICP, EU non-ICP, third-country — and read each back. Assert the Type, BTW code, and BTW percentage round-trip byte-for-byte.
- PATCH each test entry with a partial body, change one field, and read back. Assert nothing else moved. Assert the row that came back has the same EntryID as the row you patched.
- Walk the journals list with pagination and assert the count matches the UI count exactly.
- Open two parallel OAuth sessions in two processes and assert both can refresh in sequence without 401.
- POST a duplicate customer code and assert the API refuses, not overwrites.
If any of those five fail, you do not go to production that day. You write the reconciler first.
What we built around the quirks
When we shipped the debiteuren-agent for the Nijmegen praktijk, the thing we did not anticipate was how many of these quirks had already been silently corrupting their books before the agent went in — the agent had the discipline to log instead of re-enter, which is the only reason they surfaced. The fix wasn't smarter AI agents; it was a reconciliation layer that reads every write back and asserts the fields we care about survived the round trip. We now ship every Dutch accounting integration with that reconciler by default, and we have an opinion about which fields the assertion should cover on each system.
If you are about to point an agent at Exact, AFAS, or Twinfield this week: spend a morning writing the round-trip assertion for SalesEntry Type and BTW code on a five-row test set. If those two hold, you are past the worst of it.
Key takeaway
Two of the sixteen lose money silently: Exact's PATCH-without-Type can flip credit-nota signs, and AFAS returns 200 OK with no BTW code on ICP. Write a reconciler.
FAQ
Do these quirks affect Snelstart or Yuki too?
Different systems, different bugs. The class of failure — silent 200 OK on a malformed BTW payload, server-side partial-match resolution — generalises. The specific endpoints don't. Audit each one separately.
Does the credit-nota flip happen on a fresh POST or only on PATCH?
We have only reproduced it on PATCH where the entry is matched server-side by reference. A fresh POST writes a new row and the Type field is honoured. The risk lives in the update path.
Can the agent refuse writes when the BTW code looks wrong?
Yes, and it should. Validate ICP invoices client-side before the write: country mismatch, percentage 0, code must be set. Refuse the payload if not. The vendor APIs will not refuse it for you.
Why not just use the official Exact .NET SDK?
Only Exact ships a maintained SDK and it does not cover every OData edge case. AFAS and Twinfield clients are mostly hand-rolled. Even the Exact SDK does not save you from the Type-on-PATCH issue — it's a usage pattern, not a wire-format bug.