Integrations
Dutch accounting APIs: 16 quirks from an Almere rollout
Tuesday, 23:47. The order-to-cash run posts green. The morning after, the controller calls about a 4,18 euro variance. The cheatsheet that emerged.

At 23:47 on a Tuesday in March, the order-to-cash run finished green. Sixty-two invoices posted to Exact Online, eleven to AFAS Profit, four to a Unit4 Multivers tenant the wholesaler uses for its Belgian sister entity. We shipped to bed. The next morning the controller called: the daily btw aansluiting was off by 4,18 euro. Four euro across seventy-seven invoices. The kind of variance nobody escalates and everybody quietly fixes by hand for six weeks, until someone notices it has compounded into 312 euro and a missing grootboekrekening on a Belgian sales invoice that nobody can find.
That call kicked off a 26-person Almere wholesaler's third attempt at automating order-to-cash. We had two months to ship. By the end of it, our scratch notepad had grown into a sixteen-line cheatsheet of REST behaviours that no vendor doc admits to. This is that cheatsheet.
The setup
The wholesaler runs three accounting backends because three previous owners each picked one and nobody had time to consolidate. The NL operating company sits in Exact Online. Procurement and HR run on AFAS Profit. The BE sister entity runs on Unit4 Multivers Online. Our pipeline reads picked-and-shipped orders from a Magento 2 storefront, fans them out to the right backend, and reconciles back into a single Metabase dashboard for the controller.
The plumbing is what you would expect: a Node worker per backend, Postgres for staging and idempotency keys, a small Redis queue. Nothing exotic. The interesting failures all live in the seams between our code and the vendor APIs. The cheatsheet below is ranked by silent damage, from "this corrupts your books and you will not know" down to "this wastes an afternoon but the test suite catches it."
Tier 1: silent corruption
These are the ones we now write integration tests for first, before anything else. They do not throw. They do not log. They just lie.
1. Exact Online rounds VATAmount to two decimals on readback
The OData SalesInvoiceLines endpoint accepts a VATAmount with four decimals in the request body. It returns it back rounded to two on the next GET. The ledger reconciliation downstream expects four. We were posting one value and reading back another, then computing a variance against ourselves.
// what we POSTed
{ "VATAmount": 19.9750 }
// what GET returned moments later
{ "VATAmount": 19.98 }
// what our ledger still believed
{ "vat_amount": 19.9750 }
The fix: stop trusting the round-trip. Keep the four-decimal value in our own invoice_line table and reconcile against the rounded readback rather than against the original total.
2. AFAS Profit truncates decimals based on session locale
The UpdateConnector accepts "1.50" or "1,50" depending on whether the connector token's user has a Dutch or English locale. We had a service account configured in Dutch. Half our payloads were sending "1.50" anyway because our serializer used invariant culture. AFAS accepted them, then stored 1,00. No error. No warning. Money missing.
3. Unit4 Multivers drops the grootboekrekening on multi-administratie POSTs
POST /api/v2/{databaseId}/SalesInvoices returns 201 Created with a fully populated invoice object. If you forget the administrationId query parameter on a tenant that hosts more than one admin, the invoice books to the default admin's suspense ledger. The 201 response echoes the GLAccountCode you sent. The actual booked record does not have it. We caught this only when a Belgian invoice surfaced on a Dutch ledger six weeks later.
4. Exact's VATCode is per-division and not stably formatted
VATCode "1" in division 1234567 is the same logical code as "01" in division 1234568 because someone re-imported a chart of accounts in 2019. The UI shows both as "1 - BTW hoog verkoop." The API treats them as different strings. Copy a default config across divisions and half your invoices post against an unknown VATCode and silently default to 0%.
Never trust a Dutch accounting API to throw on a malformed VATCode. All three vendors will accept an unknown code and book it at 0% rather than 400 it back. Validate before you POST.
Tier 2: lying response codes
These return success but the operation either did not happen or happened wrong. They are noisier than tier 1 (the broken state is visible if you GET back) but they wreck idempotency.
5. Multivers returns 201 with id=null on missing debtors
POST a sales invoice that references a debtor not present in the active admin and Multivers responds 201 Created, body contains the request you sent back with id set to null. No error key. Our retry logic interpreted that as "succeeded, store the id" and stored null. Then the next reconciliation pass tried to GET id=null, got a 404, and triggered a re-POST. We made four phantom invoices before catching it.
6. AFAS GetConnector returns 200 with an empty array on revoked tokens
Profit's connector tokens can be revoked from the customer's admin without notifying anything that uses them. The connector then returns HTTP 200, an empty result set, and no warning. We only noticed because a daily counter went from "around 60" to "exactly 0." Always alert on "expected non-zero but got zero" for AFAS reads.
7. Exact OData $filter on non-indexed fields returns the wrong rows
Exact's OData layer paginates by ROWID, and $filter on a field without an index applies the filter after pagination. You get a page of rows, some of which match your filter and some of which do not, plus an @odata.nextLink that picks up from a ROWID that may have skipped matching rows. The workaround: filter on indexed fields only (the ones marked filterable in the Exact REST resources reference, which is not the same set the UI suggests). For anything else, page through everything and filter client-side.
8. Exact PATCH on a posted invoice returns 204
Once an invoice is posted (Status 50 in Exact's enum), PATCH returns 204 No Content but the change is not applied. The Exact UI throws a permission error in this case. The API does not. Check Status before any PATCH and reject the call client-side.
Tier 3: auth and session
Predictable once you know them. They eat one bad weekend per integration.
9. Exact refresh tokens are single-use, and concurrent refresh kills you for ten minutes
The OAuth refresh flow returns a new refresh token and invalidates the old one immediately. Two workers that refresh in the same second will both succeed once, then the loser's next refresh hits a ten-minute cooldown lockout. We serialise refreshes through a Postgres advisory lock per division. The Exact community thread on this is years old and still open at the vendor.
10. Multivers access tokens expire after thirty minutes despite the documented sixty
The token response includes expires_in: 3600. The token stops working at 1800. We treat expires_in as advisory and refresh at twenty-five minutes regardless.
Tier 4: formats and encoding
11. Date formats vary per backend and per endpoint
AFAS wants "2026-06-16T00:00:00" with no Z. Exact's OData wants /Date(1718496000000)/. Exact's newer REST endpoints want ISO 8601 with Z. Multivers wants ISO 8601 without a timezone offset. A single typed date helper that knows which backend it is serialising for saves you a week.
12. Newlines in description fields are not portable
Multivers requires CRLF in invoice line descriptions to render as multi-line in the printed PDF. Exact strips CR and keeps LF. AFAS rejects either with a 400 if the description exceeds 50 characters. Normalise on the way out per backend.
13. Postcode validation is inconsistent
AFAS rejects "1234 AB" (with the space) and wants "1234AB." Exact requires the space. Multivers accepts both. Store canonical "1234AB" and add the space on the way to Exact.
14. Debtor name length differs per backend
AFAS truncates at 50 characters, Exact at 60, Multivers at 40. None of them throw. They silently store the truncated value. A debtor name like "Bouwbedrijf Van der Velden Almere Holding B.V." gets clipped differently in each backend and then fails reconciliation because the names no longer match across systems.
Tier 5: operational papercuts
15. Exact's rate limit is per-app, not per-division
The published limit is 60 requests per minute per division and 300 per minute per app. If you have six divisions and you treat the app limit as the cap, you stop at 50 per minute per division before you ever hit the division limit. We learned this by tailing 429s on the busy division during month-end. The X-RateLimit-Remaining header reflects the app counter, not the division counter.
16. Multivers errors come back as XML even with Accept: application/json
Multivers returns successful responses as JSON when you ask for JSON. It returns errors as XML wrapped in a JSON envelope that contains a Message field with the XML serialised as a string. Parse twice.
Test your accounting integration against the readback, not against the response. The response is the vendor's promise. The readback is what your auditor sees.
A staging admin you can drop
One operational note. We keep a staging division per backend with throwaway data, and we wipe it weekly. A recent Hacker News thread reminded everyone that the only scalable delete in Postgres is DROP TABLE. The accounting backends agree: the only scalable reset of a Multivers admin is to recreate it. AFAS bulk-delete is per-record and rate-limited. Exact's "remove" is a soft delete that still counts against your storage tier. If you can convince ops to give you a disposable admin per backend, do it. We rebuild ours nightly from a JSON seed.
The post-then-GET pattern
The single most useful piece of code we wrote is fifteen lines. Every worker POSTs, then immediately GETs the just-created record, then diffs the fields it cares about against the request. If the diff is non-empty, the worker fails loudly instead of reporting success.
async function postAndVerify(client, payload, fields) {
const created = await client.post('/SalesInvoices', payload);
if (!created?.id) {
throw new Error('vendor returned 2xx with no id');
}
const fetched = await client.get(`/SalesInvoices/${created.id}`);
const drift = fields.filter(f => !near(payload[f], fetched[f]));
if (drift.length) {
throw new Error(`readback drift on ${drift.join(',')}`);
}
return fetched;
}
For VATAmount, near() tolerates rounding to two decimals. For GLAccountCode, VATCode, and debtor name, it does not. That single asymmetry caught fifteen of the sixteen quirks above in the first week of staging.
Sixty days in
The order-to-cash run now posts around 240 invoices a day across the three backends, reconciles to within 0,01 euro of the controller's spreadsheet on a typical day, and emails her a one-page diff at 07:00 when it is not. The cheatsheet above is what we wish someone had handed us on day one.
When we built the order-to-cash agent for the Almere wholesaler, the thing we ran into was that none of the three vendors agreed on what a 201 Created means. We ended up writing the small validator above and gating every worker behind it. If you are mid-rollout on a Dutch accounting backend and want a second pair of eyes on your AI agents and automation stack, the cheatsheet is the first thing to bring.
The smallest thing you can do today: pick the busiest invoice your pipeline posted yesterday, GET it back from the backend, and diff every field against what you sent. If you find a delta, you have found a quiet money leak.
Key takeaway
Test your accounting integration against the readback, not against the response. The response is the vendor's promise; the readback is what your auditor sees.
FAQ
Does Exact Online really round VATAmount silently?
Yes. The request body accepts four decimals; the readback returns two. Keep the original value in your own store and reconcile against the rounded readback, not against your total.
Why does Unit4 Multivers drop the grootboekrekening on a 201?
On multi-administratie tenants the administrationId query parameter is required. Without it, the invoice books to the default admin and the GLAccountCode is dropped, even though the 201 response echoes it back.
Can I share one OAuth refresh worker across Exact Online divisions?
Yes, but serialise refreshes per division through a lock. Refresh tokens are single-use; two concurrent refreshes will lock the loser out for ten minutes.
What is the safest way to wipe a staging accounting admin?
Recreate it. Bulk-delete in AFAS is rate-limited, Exact's remove is a soft delete that still counts toward storage, and Multivers has no equivalent of TRUNCATE. Rebuild from a seed file nightly.