Integrations
Exact Online API quirks: when 201 Created lies to you
A bookkeeping cooperative wanted an agent that books purchase invoices into Exact Online. Their API accepts every single one. About a third quietly miss the GLAccount link.

The cooperative occupies a first-floor office near Almere Centrum station. Twenty-nine bookkeepers, each shepherding between twelve and forty SME clients, each client a separate administratie in Exact Online. Their problem on the day we walked in was a stack of purchase invoices roughly the size of a phone book and a managing partner who wanted them booked by 18:00.
So we built an agent. PDF in, line items out, posted to Exact Online via the REST API. It worked on the first three clients. On the fourth it returned 201 Created for every invoice and produced no usable bookkeeping. The amounts were right. The supplier was right. The GLAccount field on every line was empty.
This post is a cheatsheet of the twenty-three Exact Online quirks we now carry around in our heads, ranked by how convincingly they pretend nothing went wrong. The top of the list is reserved for the ones that return a happy HTTP status and silently orphan a relationship.
The 201 problem
Exact Online's REST API is, on its surface, an OData v3 affair. You POST a JSON document, you get a 201 Created with a Location header, you trust it. On a single-division tenant that trust is mostly warranted. On a tenant with seventeen administraties, three of which were imported from Snelstart in 2019 and two from an acquired bookkeeping firm last spring, that trust is a bug.
The root cause is simple. Every entity in Exact Online belongs to exactly one division. GLAccounts, VAT codes, suppliers, projects, cost centres, journals. Each lives inside one administratie. A GUID from division A is meaningless in division B. The API will accept the POST, write the line, and quietly drop any foreign-division reference to null with no error in the response.
If your agent caches GLAccount GUIDs across runs, it will eventually post invoices into a different administratie and produce booked entries with empty ledger lines. Exact returns 201 Created. Your finance lead finds out at month-end close.
The cheatsheet, ranked by how loud the failure is
Lower in the list is louder. The top entries are the ones that pass tests, pass review, and detonate three weeks later.
1. GLAccount references silently dropped on cross-division POSTs
The fatal quirk. Posting to /api/v1/{divisionA}/purchaseentry/PurchaseEntries with a GLAccount GUID that lives in divisionB returns 201, writes the line, and sets GLAccount to null. Same shape applies to VATCode, CostCenter, CostUnit, and Project.
2. Supplier accepted but not booked when missing from the target division
Pass a Supplier GUID from another administratie and Exact will reject the entry with a 400, but only when the value is non-null and non-empty. Pass an empty string and you get a 201 with a phantom entry that has no creditor. The validator treats missing differently from foreign.
3. Refresh tokens that rotate once and never again until you ask nicely
OAuth 2.0 refresh tokens are single-use. Use one twice within ten minutes and you get an opaque 400 with body "unsupported_grant_type" that is not actually about the grant type. Race conditions between two workers sharing one token will produce this on roughly one in fifty refreshes. Fix it with a per-tenant mutex around the refresh call.
4. The default journal lives on the administratie, not on the API call
Omit Journal on a purchase entry and Exact picks the division's default purchase journal. That default differs per administratie. Two cooperative clients had their default purchase journal set to 20, three had 70, one had something the previous bookkeeper invented in 2014. Always read /api/v1/{division}/system/Divisions first and resolve the journal explicitly.
5. VATCode is a string, not a GUID, and it is also division-scoped
This catches almost everyone. VATCode in PurchaseEntryLines is a two-character string like "21" or "GE". It looks portable. It is not. The code maps to a row in /api/v1/{division}/vat/VATCodes, and the same string can mean 21% in one administratie and 9% reduced in another, depending on how the original bookkeeper set it up.
6. AmountFC vs AmountDC and the silent currency swap
If the supplier's Currency is EUR and the administratie's base currency is EUR, AmountFC and AmountDC should agree. If the supplier was imported with Currency: "USD" and you post AmountFC: 1000 against a default EUR administratie, Exact will accept the entry, book 1000 USD, and convert at the daily rate it pulls from somewhere it does not document. Pin the currency explicitly per line if you have any international suppliers.
7. The Sync API truncates at exactly 1000 rows with no warning
/api/v1/{division}/sync/Financial/GLAccounts is what you should call to populate the agent's mapping table. It returns at most 1000 rows per call and gives you a __next link in the OData envelope. Forget to follow __next and your chart of accounts ends at "Voorraad grondstoffen". We have seen this in production code from three different vendors.
8. The 60-requests-per-minute floor is per OAuth client, not per tenant
Document the limit honestly. Exact's REST resource list mentions a per-minute cap of 60 calls per client app per company, and a daily cap that varies by subscription. Run a single OAuth app across seventeen administraties and you will hit the minute cap during morning batch posting. Either request a higher limit (the form exists, it gets approved in 48 hours) or stagger.
9. Division switching is per-call, not per-token
There is no "set current division" endpoint that sticks. Every request must carry the division in its URL. The /api/v1/current/Me endpoint reports a CurrentDivision, but it is a hint, not a state. Workers that read CurrentDivision once at startup and then loop over invoices for fifteen tenants will post fifteen tenants' invoices into the first one.
10. DateTime fields use Microsoft's /Date(milliseconds)/ format on read
You POST ISO 8601. You receive "/Date(1717545600000)/". The official explanation is OData v3 legacy. The unofficial one is that no-one will fix it. Write a tiny parser, move on.
11. ETags absent on most financial entities
You cannot use If-Match on a PurchaseEntry update. Concurrency control is left to you. If two operators (or two agent runs) modify the same entry between read and write, last writer wins, no warning.
12. Soft-deleted GLAccounts still appear in $filter results
Add $filter=Status eq '1' or you will map invoices to ledgers that the bookkeeper marked inactive three years ago. Status 1 is active, 0 is inactive. The default response includes both.
13. Attachment uploads require a three-step dance
To attach the original PDF you POST to Documents, then POST to DocumentAttachments with the document GUID, then PUT the binary to the attachment URL. Skip step three and the document exists with a zero-byte file and Exact's UI shows a broken paperclip.
14. Empty arrays serialize differently in C# vs Python clients
Send PurchaseEntryLines: [] from .NET and the line collection is treated as "no change". Send the same from Python's requests with default JSON encoding and you get a 400 because the wrapped envelope expects {"results": []}. Wrap your collections.
15 through 23, briefly
The rest of the list, for completeness. Number 15: $select silently ignores fields it does not recognise instead of erroring. Number 16: the EntryNumber on a created PurchaseEntry is assigned asynchronously and may be 0 in the immediate response. Number 17: webhook subscriptions are per division, not per tenant. Number 18: the ReportingPeriod on a line defaults to the entry date's month, which is wrong for invoices booked across year-end. Number 19: Description is silently truncated at 60 characters. Number 20: line-level Notes exists but is not returned by $select=*. Number 21: the OAuth authorize endpoint returns a 200 HTML error page on invalid client_id, not a redirect with an error parameter. Number 22: Account.Code is unique within a division but not within the tenant. Number 23: bulk endpoints exist for some entities and not others, and the list of which is undocumented outside the developer portal.
The pattern that fixed it for us
One mapping table per division, refreshed nightly, keyed by what the agent actually sees on the PDF. Supplier name plus VAT number maps to Account.ID in that division. Cost line description plus heuristic maps to GLAccount.ID in that division. Never share a cached GUID across divisions.
def resolve_glaccount(division_id: str, hint: str) -> str:
"""Return GLAccount.ID for this hint, scoped to this division. Never cross."""
cache_key = (division_id, hint.lower().strip())
if cache_key in GLACCOUNT_CACHE:
return GLACCOUNT_CACHE[cache_key]
rows = sync_glaccounts(division_id) # follows __next, filters Status eq '1'
match = best_match(hint, rows)
if not match:
raise UnresolvedGLAccount(division_id, hint)
GLACCOUNT_CACHE[cache_key] = match["ID"]
return match["ID"]
Before every POST the agent runs a pre-flight check. It asserts that every line has a non-null GLAccount, a non-null VATCode, and an amount that round-trips through the currency it expects. If any field is null, the post is rejected client-side before it ever hits Exact's 201.
def post_purchase_entry(division_id: str, entry: dict) -> str:
for line in entry["PurchaseEntryLines"]:
assert line.get("GLAccount"), "GLAccount missing pre-POST"
assert line.get("VATCode"), "VATCode missing pre-POST"
res = session.post(
f"/api/v1/{division_id}/purchaseentry/PurchaseEntries",
json=entry,
)
res.raise_for_status()
created = res.json()["d"]
# Read back. Exact does not always echo our payload verbatim.
readback = session.get(
f"/api/v1/{division_id}/purchaseentry/PurchaseEntries"
f"(guid'{created['EntryID']}')?$expand=PurchaseEntryLines"
)
for line in readback.json()["d"]["PurchaseEntryLines"]["results"]:
if not line.get("GLAccount"):
raise OrphanedLedgerLink(created["EntryID"])
return created["EntryID"]
The read-back is the unglamorous part. It doubles the API calls. It catches the silent orphan every time. Exact's own knowledge base hints at this in places, mostly in forum threads from 2019 where someone with a Dutch accountancy degree explains it to someone else with a Dutch accountancy degree.
What we would do differently next time
We would build the division-resolver before the entry-builder. It feels backwards. It is not. The map from supplier-on-PDF to creditor-in-Exact has to be correct per administratie before any other code matters. We would also bake the read-back assertion into the agent's success criterion from day one, instead of adding it after the first month-end close revealed orphaned lines.
When we built the purchase-invoice AI agents for the Almere cooperative, the orphaned-GLAccount class of bug ate two weeks before we identified the cross-division pattern. We ended up solving it with the per-division resolver above plus a nightly reconciliation job that diffs Exact's booked entries against the agent's intent log and pages whoever is on rota if the two disagree.
If you are about to wire an agent into Exact Online and you only do one thing today, run a read-back on the next entry your code posts and check whether GLAccount on every line is still the GUID you sent. If it is null, you have already found quirk number one.
Key takeaway
Every entity in Exact Online belongs to one division. Cross that boundary in a single POST and you get a 201 with a silently orphaned ledger line.
FAQ
Why does Exact Online return 201 Created when the GLAccount link is missing?
Exact validates field shapes and division ownership separately. A foreign-division GUID is silently nulled rather than rejected, so the entry passes API validation while failing as bookkeeping.
Can I share GLAccount mappings across administraties?
No. Every GLAccount belongs to one division. Cache GUIDs per division only. Reusing them across tenants causes silent orphan links that pass HTTP validation and surface at month-end close.
What is the safest way to detect orphaned entries after posting?
Read back every created entry with $expand=PurchaseEntryLines and assert that GLAccount, VATCode and amounts match what you POSTed. Treat any null as a failed entry, not a soft warning.
How do I avoid hitting the 60 requests per minute cap during batch runs?
Stagger workers across administraties, batch reads through the Sync API, and request a quota increase from Exact. The form exists and approvals usually land within 48 hours.