Integrations
Exact Online API quirks: als 201 Created tegen je liegt
Een boekhoudcoöperatie wilde een agent die inkoopfacturen in Exact Online boekt. Hun API accepteert ze allemaal. Ongeveer een derde mist stilletjes de GLAccount-link.

De coöperatie zit op de eerste verdieping van een pand vlakbij station Almere Centrum. Negenentwintig boekhouders, elk met twaalf tot veertig MKB-klanten onder zich, elke klant een aparte administratie in Exact Online. Hun probleem op de dag dat we binnenliepen: een stapel inkoopfacturen ter dikte van een telefoonboek en een managing partner die ze om 18:00 geboekt wilde hebben.
Dus bouwden we een agent. PDF erin, regels eruit, weggeschreven naar Exact Online via de REST API. Het werkte bij de eerste drie klanten. Bij de vierde gaf hij 201 Created terug voor elke factuur en leverde geen bruikbare boekhouding op. De bedragen klopten. De leverancier klopte. Het GLAccount-veld op elke regel was leeg.
Deze post is een spiekbriefje van de drieëntwintig Exact Online quirks die we nu in ons hoofd dragen, gerangschikt naar hoe overtuigend ze doen alsof er niets aan de hand is. Bovenaan staan de quirks die een vrolijke HTTP-status teruggeven en stilletjes een relatie wegmieteren.
Het 201-probleem
De REST API van Exact Online is op het eerste gezicht een OData v3-aangelegenheid. Je POST een JSON-document, je krijgt een 201 Created met een Location-header, je vertrouwt erop. Op een tenant met één divisie is dat vertrouwen meestal terecht. Op een tenant met zeventien administraties, waarvan er drie in 2019 vanuit Snelstart zijn geïmporteerd en twee vorig voorjaar van een overgenomen boekhoudkantoor, is dat vertrouwen een bug.
De oorzaak is simpel. Elke entiteit in Exact Online hoort bij precies één divisie. GLAccounts, btw-codes, leveranciers, projecten, kostenplaatsen, dagboeken. Allemaal leven ze binnen één administratie. Een GUID uit divisie A is betekenisloos in divisie B. De API accepteert de POST, schrijft de regel weg, en zet stilletjes elke referentie naar een andere divisie op null, zonder fout in de response.
Als je agent GLAccount-GUIDs cachet tussen runs, post hij vroeg of laat facturen in een andere administratie en levert hij geboekte entries op met lege grootboekregels. Exact geeft 201 Created terug. Je financiële collega ontdekt het bij de maandafsluiting.
Het spiekbriefje, gerangschikt naar hoe luid het misgaat
Lager in de lijst is luider. De bovenste quirks komen door je tests, komen door review, en ontploffen drie weken later.
1. GLAccount-referenties stilletjes gedropt bij cross-division POSTs
De fatale quirk. POSTen naar /api/v1/{divisionA}/purchaseentry/PurchaseEntries met een GLAccount-GUID die in divisionB leeft, geeft 201, schrijft de regel weg, en zet GLAccount op null. Hetzelfde patroon geldt voor VATCode, CostCenter, CostUnit en Project.
2. Leverancier geaccepteerd maar niet geboekt als hij in de doeldivisie ontbreekt
Geef een Supplier-GUID uit een andere administratie mee en Exact gooit de entry eruit met een 400, maar alleen als de waarde niet-null en niet-leeg is. Geef een lege string mee en je krijgt een 201 met een spookentry zonder crediteur. De validator behandelt ontbrekend anders dan vreemd.
3. Refresh tokens die één keer roteren en daarna nooit meer, tenzij je het netjes vraagt
OAuth 2.0 refresh tokens zijn voor eenmalig gebruik. Gebruik er één twee keer binnen tien minuten en je krijgt een ondoorgrondelijke 400 met body "unsupported_grant_type" die helemaal niet over de grant type gaat. Race conditions tussen twee workers die één token delen leveren dit op bij ongeveer één op de vijftig refreshes. Los het op met een per-tenant mutex rond de refresh-call.
4. Het standaarddagboek staat op de administratie, niet op de API-call
Laat Journal weg bij een purchase entry en Exact kiest het standaard inkoopdagboek van de divisie. Dat standaard verschilt per administratie. Twee klanten van de coöperatie hadden hun standaard inkoopdagboek op 20 staan, drie op 70, één op iets dat de vorige boekhouder in 2014 verzon. Lees altijd eerst /api/v1/{division}/system/Divisions en bepaal het dagboek expliciet.
5. VATCode is een string, geen GUID, en ook divisie-gebonden
Dit krijgt bijna iedereen te pakken. VATCode in PurchaseEntryLines is een string van twee tekens zoals "21" of "GE". Hij oogt overdraagbaar. Dat is hij niet. De code mapt naar een regel in /api/v1/{division}/vat/VATCodes, en diezelfde string kan in de ene administratie 21% betekenen en in de andere 9% verlaagd, afhankelijk van hoe de oorspronkelijke boekhouder het heeft ingericht.
6. AmountFC versus AmountDC en de stille valutawissel
Als de Currency van de leverancier EUR is en de basisvaluta van de administratie EUR, moeten AmountFC en AmountDC hetzelfde zijn. Is de leverancier geïmporteerd met Currency: "USD" en post je AmountFC: 1000 tegen een standaard EUR-administratie, dan accepteert Exact de entry, boekt 1000 USD, en rekent om tegen de dagkoers die hij ergens vandaan haalt op een plek die hij niet documenteert. Pin de valuta expliciet per regel als je internationale leveranciers hebt.
7. De Sync API kapt zonder waarschuwing af op precies 1000 rijen
/api/v1/{division}/sync/Financial/GLAccounts is wat je hoort aan te roepen om de mapping-tabel van de agent te vullen. Hij geeft maximaal 1000 rijen per call terug en levert een __next-link in de OData-envelope. Vergeet je __next te volgen, dan stopt je rekeningschema bij "Voorraad grondstoffen". We zijn dit tegengekomen in productiecode van drie verschillende leveranciers.
8. De ondergrens van 60 requests per minuut geldt per OAuth-client, niet per tenant
Documenteer de limiet eerlijk. De REST-resourcelijst van Exact noemt een limiet van 60 calls per minuut per client-app per onderneming, en een daglimiet die per abonnement verschilt. Draai één OAuth-app over zeventien administraties en je tikt de minuutlimiet aan tijdens de ochtendelijke batch. Vraag een hogere limiet aan (het formulier bestaat, goedkeuring binnen 48 uur) of stagger je calls.
9. Wisselen van divisie is per call, niet per token
Er is geen "stel huidige divisie in"-endpoint dat blijft hangen. Elke request moet de divisie in zijn URL meedragen. Het endpoint /api/v1/current/Me rapporteert een CurrentDivision, maar dat is een hint, geen toestand. Workers die CurrentDivision één keer bij start uitlezen en daarna door facturen voor vijftien tenants loopen, posten de facturen van vijftien tenants in de eerste.
10. DateTime-velden komen op read terug in Microsofts /Date(milliseconds)/-formaat
Je POST ISO 8601. Je krijgt "/Date(1717545600000)/" terug. De officiële uitleg is OData v3 legacy. De onofficiële is dat niemand het gaat oplossen. Schrijf een kleine parser, ga door.
11. ETags ontbreken op de meeste financiële entiteiten
Je kunt geen If-Match gebruiken bij een PurchaseEntry-update. Concurrency control is jouw probleem. Als twee operators (of twee runs van de agent) dezelfde entry tussen read en write aanpassen, wint de laatste schrijver, zonder waarschuwing.
12. Soft-deleted GLAccounts komen nog steeds terug in $filter-resultaten
Voeg $filter=Status eq '1' toe of je mapt facturen naar grootboekrekeningen die de boekhouder drie jaar geleden op inactief heeft gezet. Status 1 is actief, 0 is inactief. De default-response bevat beide.
13. Bijlagen uploaden vereist een dans in drie stappen
Om de originele PDF te koppelen POST je naar Documents, daarna POST je naar DocumentAttachments met de document-GUID, en als laatste PUT je de binary naar de attachment-URL. Sla stap drie over en het document bestaat met een bestand van nul bytes en de UI van Exact toont een kapotte paperclip.
14. Lege arrays serializen anders in C# dan in Python clients
Stuur PurchaseEntryLines: [] vanuit .NET en de regelcollectie wordt behandeld als "geen wijziging". Stuur hetzelfde vanuit Python's requests met de standaard JSON-encoding en je krijgt een 400, omdat de envelope {"results": []} verwacht. Wrap je collecties.
15 tot en met 23, in het kort
De rest van de lijst, voor de compleetheid. Nummer 15: $select negeert velden die hij niet kent stilletjes in plaats van te erroren. Nummer 16: het EntryNumber op een aangemaakte PurchaseEntry wordt asynchroon toegekend en kan in de directe response 0 zijn. Nummer 17: webhook-subscriptions staan per divisie, niet per tenant. Nummer 18: de ReportingPeriod op een regel default naar de maand van de entry-datum, wat fout uitpakt bij facturen die over de jaarwisseling worden geboekt. Nummer 19: Description wordt stilletjes afgekapt op 60 tekens. Nummer 20: Notes op regelniveau bestaat wel, maar wordt niet teruggegeven door $select=*. Nummer 21: het OAuth authorize-endpoint geeft bij een ongeldige client_id een 200 met een HTML-foutpagina terug, geen redirect met error-parameter. Nummer 22: Account.Code is uniek binnen een divisie, maar niet binnen de tenant. Nummer 23: bulk-endpoints bestaan voor sommige entiteiten en niet voor andere, en de lijst van welke is buiten het developer-portal niet gedocumenteerd.
Het patroon dat het bij ons oploste
Eén mapping-tabel per divisie, 's nachts ververst, met als sleutel wat de agent daadwerkelijk op de PDF ziet. Leveranciersnaam plus btw-nummer mapt naar Account.ID in die divisie. Kostenregel-omschrijving plus heuristiek mapt naar GLAccount.ID in die divisie. Deel nooit een gecachete GUID tussen divisies.
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"]
Voor elke POST draait de agent een pre-flight check. Hij asserteert dat elke regel een niet-null GLAccount heeft, een niet-null VATCode, en een bedrag dat round-trip past binnen de verwachte valuta. Is een veld null, dan wordt de post client-side geweigerd voor hij ooit Exact's 201 raakt.
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"]
De read-back is het onaantrekkelijke deel. Hij verdubbelt het aantal API-calls. Hij vangt de stille orphan elke keer. De eigen kennisbank van Exact hint hier en daar naar, vooral in forumdraden uit 2019 waar iemand met een Nederlandse accountancy-opleiding het uitlegt aan iemand anders met een Nederlandse accountancy-opleiding.
Wat we de volgende keer anders zouden doen
We zouden de divisie-resolver bouwen voor de entry-builder. Het voelt achterstevoren. Dat is het niet. De mapping van leverancier-op-PDF naar crediteur-in-Exact moet per administratie kloppen voordat enige andere code er nog toe doet. We zouden de read-back-assertie ook vanaf dag één in het succescriterium van de agent inbakken, in plaats van hem pas toe te voegen nadat de eerste maandafsluiting orphaned regels aan het licht bracht.
Toen we de AI-agents voor inkoopfacturen bouwden voor de coöperatie in Almere, kostte de orphaned-GLAccount-categorie van bugs ons twee weken voor we het cross-division-patroon doorhadden. We hebben het uiteindelijk opgelost met de per-divisie-resolver hierboven plus een reconciliation-job die 's nachts de geboekte entries van Exact diff't met het intent-log van de agent en degene oppiept die dienst heeft als de twee niet overeenkomen.
Sta je op het punt een agent aan Exact Online te koppelen en doe je vandaag maar één ding, draai dan een read-back op de volgende entry die je code post en controleer of GLAccount op elke regel nog steeds de GUID is die je verstuurd hebt. Is hij null, dan heb je quirk nummer één al gevonden.
Kern
Elke entiteit in Exact Online hoort bij één divisie. Steek die grens over in één POST en je krijgt een 201 met een stilletjes orphaned grootboekregel.
FAQ
Waarom geeft Exact Online 201 Created terug terwijl de GLAccount-link ontbreekt?
Exact valideert veldvormen en divisie-eigendom apart. Een GUID uit een andere divisie wordt stilletjes op null gezet in plaats van geweigerd, dus de entry passeert de API-validatie en faalt pas als boekhouding.
Kan ik GLAccount-mappings delen tussen administraties?
Nee. Elke GLAccount hoort bij één divisie. Cache GUIDs alleen per divisie. Hergebruiken tussen tenants veroorzaakt stille orphan-links die door de HTTP-validatie heen komen en pas opduiken bij de maandafsluiting.
Wat is de veiligste manier om orphaned entries na het posten te detecteren?
Lees elke aangemaakte entry terug met $expand=PurchaseEntryLines en asserteer dat GLAccount, VATCode en bedragen overeenkomen met wat je hebt gePOST. Behandel elke null als een mislukte entry, niet als een softe waarschuwing.
Hoe voorkom ik dat ik de limiet van 60 requests per minuut raak tijdens batch-runs?
Stagger workers over administraties, batch reads via de Sync API, en vraag bij Exact een hogere limiet aan. Het formulier bestaat en goedkeuringen komen meestal binnen 48 uur binnen.