← Blog

Integrations

Exact Online API: 14 valkuilen die stil falen in productie

Een installatiebedrijf uit Arnhem draaide hun eerste geautomatiseerde inkooporder tegen Exact Online. De API gaf 201 Created terug. De grootboekcode kwam nooit in het boek.

Jacob Molkenboer· Oprichter · A Brand New Company· 6 apr 2025· 8 min
Open koperen register, crème indexkaart met groene paperclip, rode lakzegel, ijzeren label op ivoorpapier.

De operations lead bij een installatiebedrijf van 36 man in Arnhem draaide haar eerste geautomatiseerde inkooporder tegen Exact Online op een donderdag om 16:47. De API gaf 201 Created terug. Het order-ID kwam binnen. De PDF werd gegenereerd. En de grootboekcode kwam nooit in het boek.

Drie uur accountantswerk de volgende ochtend, omdat niets in de response aangaf dat er iets ontbrak.

Dit is de cheatsheet die we ons eerdere zelf wensten te overhandigen. Veertien Exact Online REST API valkuilen, gerangschikt op hoe hard ze bijten in productie. De bovenste delen één eigenschap: de server geeft een vrolijke 201, je code logt succes, en een veld dat je stuurde verdwijnt stilletjes in het niets.

De stille-drop categorie

Dit zijn de valkuilen die echt geld kosten. De API accepteert je payload, geeft het entiteit-ID terug, en pas weken later valt het op als iemand gaat reconciliëren. Geen waarschuwingsheader, geen validatie-error, geen logregel waar je op kunt grep'en. De enige verdediging is de velden die je schreef weer teruglezen.

1. GLAccountCode valt weg bij multi-administratie tenants

Als je token bereik heeft over meerdere administraties en je stuurt een GLAccount GUID die in administratie A bestaat terwijl je naar administratie B schrijft, dan komt er geen 400. Hij schrijft de regel zonder GL-koppeling en geeft 201 terug. Bij de volgende read komt het veld als null terug.

Oplossing: resolve de grootboekrekening altijd binnen de division waar je naartoe schrijft. Behandel division-grenzen als hard.

// Wrong: pulled GL from a cached lookup that hit division 12345
const gl = cache.get('omzet-installaties').ID

// Right: resolve per write target
const gl = await fetch(
  `${base}/api/v1/${divisionId}/financial/GLAccounts?$filter=Code eq '8000'&$select=ID`,
  { headers: auth }
).then(r => r.json()).then(d => d.d.results[0]?.ID)

if (!gl) throw new Error(`GL 8000 not in division ${divisionId}`)

2. VATCode wordt stilletjes naar de division default geforceerd

Stuur een BTW-code die geldig is in één administratie maar niet geregistreerd staat tegen de division waar je naartoe post, en de regel wordt weggeschreven met de administratie-default. Geen waarschuwing, geen error. We zagen dit bij Duitse inkoopfacturen die binnenkwamen in een BV die de EU BTW-codes nog niet had geïmporteerd. Elke regel schreef weg met het standaard NL hoge tarief, en de reconciliatie zag de mismatch pas twee maanden later.

3. Currency wordt naar administratievaluta geforceerd

Zelfde patroon. Stuur "Currency": "USD" op een tenant zonder USD aan, en Exact schrijft EUR weg zonder conversie. Check altijd /api/v1/{division}/system/Currencies voordat je schrijft, en weiger de order in je eigen code in plaats van 'm door te laten.

4. PUT is een volledige replace, geen patch

Elk veld dat je niet meestuurt in een PUT wordt als null behandeld. Voor de meeste resources is er geen PATCH endpoint. Als je een order ophaalt, één regel wijzigt en het object via PUT terugstuurt, overschrijf je alles wat de API niet teruggaf bij de GET (default flags, berekende velden, alles).

Waarschuwing

Doe altijd een GET op het volledige record, muteer de in-memory kopie en PUT het geheel terug. Bouw nooit een PUT-body vanaf nul met alleen de velden die je wilt wijzigen.

5. Quantity hernoemt zichzelf tussen write en read

Op PurchaseOrderLines POST je Quantity. Bij de read komt het terug als QuantityOrdered. Als je je roundtrip-test schreef door de request payload met de response te vergelijken, slaagt je test voor alles behalve het veld waar het je echt om gaat. Map de namen expliciet, vertrouw niet op key-gelijkheid.

6. Item verwacht een GUID, geen code

Het Item-veld op een regel is een GUID-verwijzing naar /api/v1/{division}/logistics/Items. ItemCode aan de read-kant is een gemaksvariant. Je kunt niet de leesbare code in Item schrijven en verwachten dat Exact dat oplost. Doe je dat toch, dan krijg je met geluk een 400, en zonder geluk een aangemaakte order met een phantom item-link.

De auth en rate-limit categorie

Deze gaan je data niet kwijtmaken, maar ze halen je integratie wel offline om 03:00 als je ze negeert. Ze zijn allemaal niet subtiel zodra ze toeslaan, ze worden simpelweg onderschat tijdens het bouwen.

7. Refresh tokens roteren bij elke refresh

Deze staat in de docs, maar wordt vaak gemist. Elke call naar /api/oauth2/token met grant_type=refresh_token geeft een nieuwe refresh token terug. De oude is direct ongeldig. Als je token-store niet atomic is, of twee processen refreshen parallel, dan eindigt één met een dode token en mag jij maandagochtend de OAuth-dans handmatig doen.

Pak de refresh in een database-level lock en sla het nieuwe paar op voordat enig ander proces het kan lezen. De canonieke flow staat in de Exact Online REST API kennisbank.

8. De 60-per-minuut rate limit geldt per token, niet per IP

Bij een 429 geeft Exact een Retry-After header terug in seconden. Respecteer 'm. Als je parallelle workers één token laat delen, brand je het budget in seconden op. Serialiseer via een queue, of shard tokens per worker.

async function exactFetch(url, opts) {
  const res = await fetch(url, opts)
  if (res.status === 429) {
    const wait = parseInt(res.headers.get('Retry-After') || '5', 10)
    await new Promise(r => setTimeout(r, wait * 1000))
    return exactFetch(url, opts)
  }
  return res
}

9. Division staat in het URL-pad, wordt nooit overgenomen

Elke resource-URL begint met /api/v1/{division}/. Er is geen sessie-state voor de huidige division. Als je een base URL cachet met een vastgebakken division en iemand wisselt van administratie in de UI, gaan je writes naar de verkeerde plek. Maak division expliciet op elke call site.

De shape en format categorie

Deze leveren verwarrende parse errors op, geen stille drops. Los ze één keer op in een adapter-laag en vergeet ze daarna.

10. Paginatie gebruikt __next, geen $skip

OData-veteranen gaan ervan uit dat $skip werkt. Dat is niet zo. Exact geeft een __next URL terug in de response-envelope wanneer er meer resultaten zijn, volgens de OData v2 JSON envelope conventie. Volg die URL letterlijk, inclusief query string. Bouw je je eigen $skip loop, dan krijg je duplicaten én gaten.

async function* paginate(url, auth) {
  let next = url
  while (next) {
    const res = await fetch(next, { headers: auth }).then(r => r.json())
    for (const row of res.d.results) yield row
    next = res.d.__next || null
  }
}

11. DateTime-formaat verschilt per endpoint

De meeste write-endpoints accepteren ISO 8601 strings. Reads geven het Microsoft JSON datumformaat /Date(1718150400000)/ terug. Een handvol endpoints (met name de sync-resources) eist het Microsoft-formaat ook bij write. Bouw een date-adapter en centraliseer 'm. Parse geen datums in calling code.

12. Supplier-lookup vermengt AccountCode en ID

Op een inkooporder is Supplier een GUID die wijst naar /api/v1/{division}/crm/Accounts. SupplierCode is een handige stringvariant. Zelfde valkuil als bij Item. We zagen integraties waar iemand een Accounts CSV-import had gebouwd die alleen Code wegschreef, en daarna kon de inkooporder-writer de helft van de leveranciers niet vinden op GUID, omdat ze nog een systeem-gegenereerde code hadden.

De workflow categorie

Architectuurniveau-problemen die toeslaan zodra je voorbij twee of drie divisions schaalt.

13. Webhooks zijn per division

Heeft een tenant vijftien administraties en wil je order-created webhooks over alle, dan zijn dat vijftien subscriptions. Houd ze bij in je eigen database. Het list-endpoint vertelt je wat er bestaat, maar als een collega er eentje handmatig heeft geregistreerd tegen een administratie die later is gearchiveerd, blijft de webhook in het luchtledige vuren en blijf jij egress betalen.

14. Sandbox responses zijn geen trouwe spiegel

De Exact Online sandbox komt dicht genoeg in de buurt om je op de happy path te misleiden. Een handvol resources geeft minder velden terug dan productie. Een paar geven standaard andere BTW-setups terug. We draaien een smoke test die het response-schema van vijftien sleutel-endpoints vergelijkt met een bekende productie-payload voordat we een integratiewijziging promoten. Dat heeft in twee jaar drie keer een verdwenen veld opgevangen.

De ranglijst, herhaald als checklist

Doe je niets anders na het sluiten van dit tabblad, audit dan je integratie op deze volgorde. Nummers 1 tot en met 6 kosten stilletjes geld. Alles onder nummer 6 is operationele pijn die je binnen een week voelt.

  1. Resolve grootboekrekeningen binnen de doel-division. Altijd.
  2. Valideer BTW-codes vooraf tegen de division.
  3. Valideer currencies vooraf tegen de division.
  4. Behandel PUT als volledige replace. Eerst GET, dan muteren, dan PUT.
  5. Map Quantity bij write, QuantityOrdered bij read.
  6. Gebruik GUIDs voor Item, geen codes.
  7. Sla roterende refresh tokens atomic op.
  8. Respecteer Retry-After. Serialiseer per token.
  9. Zet division in de URL op elke call site.
  10. Volg __next, nooit $skip.
  11. Centraliseer datumformaat-conversie in één adapter.
  12. Zoek leveranciers op via GUID, niet via code.
  13. Houd webhooks per division bij in je eigen database.
  14. Vergelijk sandbox-schema met productie voordat je promoot.

Hoe het in Arnhem uitpakte

Het bedrijf van 36 mensen schrijft ongeveer 180 inkooporders per week, verdeeld over twee BV's en een Belgische dochter. De integratie die wij bouwden leest binnenkomende leveranciersbevestigingen uit een gedeelde mailbox, matcht ze tegen een register van openstaande orders in Postgres, en schrijft de gematchte orders weg in Exact onder de juiste division. De eerste versie ging live met een GL-account drop op ongeveer acht procent van de cross-division orders. We pakten het op dag drie, omdat de accountant een reconciliatie-gat aanmerkte. We hadden het liever op dag nul gepakt.

De tweede versie kreeg een per-write division resolver, een schema-diff tegen de vorige productie-response, en een vijf-velden write-then-read check op elke order (GL, BTW, Currency, Supplier, Item). Drop rate nu: nul over zes weken productieverkeer. Als wij AI-agents en integraties bouwen voor klanten op verouderde ERP's, dan is die saaie write-then-read check de goedkoopste verzekering die we kennen.

Takeaway

Een 201 van Exact Online betekent dat de request is geparsed, niet dat de data is geland. Lees elk veld terug dat belangrijk genoeg was om te schrijven.

Audit vandaag één ding. Pak een recente inkooporder uit je integratie, doe een GET op /api/v1/{division}/purchaseorder/PurchaseOrders, en vergelijk de velden GLAccount, BTW, Currency en Supplier met wat je hebt verstuurd. Vijf minuten. Dan weet je of je een probleem hebt.

Kern

Een 201 van Exact Online betekent dat de request is geparsed, niet dat de data is geland. Lees elk veld terug dat belangrijk genoeg was om te schrijven.

FAQ

Geeft Exact Online een validatie-error als een grootboekrekening ontbreekt?

Nee. Op multi-administratie tenants kun je een GLAccount GUID POST'en die bij een andere division hoort, en de API geeft 201 Created terug met het veld stilletjes op null. Resolve grootboekcodes altijd binnen de doel-division.

Hoe vaak roteren Exact Online refresh tokens?

Bij elke refresh. Elke call naar het token-endpoint met grant_type=refresh_token geeft een nieuwe refresh token terug en maakt de oude direct ongeldig. Sla het nieuwe paar atomic op voordat enig ander proces leest.

Waarom verdwijnt mijn Quantity-veld uit Exact inkooporder-responses?

Hij verdwijnt niet. Het veld wordt hernoemd: je POST Quantity, maar reads geven QuantityOrdered terug. Roundtrip-tests die keys direct vergelijken markeren een phantom-mismatch.

Kan ik OData $skip gebruiken voor paginatie met Exact Online?

Nee. Exact negeert $skip en geeft een __next URL terug in de response-envelope. Volg die URL letterlijk tot 'ie null is. Een eigen skip-loop bouwen geeft dubbele rijen en ontbrekende pagina's.

integrationsautomationworkflowarchitectureoperationstooling

Iets bouwen?

Start een project