Integrations
Exact Online API: 14 valkuilen die je PHP SDK verzwijgt
Onze factuur-chase agent meldde 47 verstuurde facturen. De accountant opende Exact en zag 47 kopregels met overal €0,00. De PHP SDK gaf op elke call 200 OK terug.

De telefoon ging om 17:04 op een woensdag. De operations lead bij een accountancy van 38 mensen in Breda keek naar haar Exact Online-scherm. Onze factuur-chase agent had die middag 47 facturen verstuurd. Het dashboard bevestigde dat. Exact toonde 47 factuurkopregels, klantnamen, vervaldatums, allemaal groen. Elk totaal stond op €0,00.
De PHP SDK had op elke call 200 OK teruggegeven. Elke regel die we hadden gePOST was weg.
Dat telefoontje is de aanleiding voor de cheatsheet hieronder. Veertien valkuilen in de Exact Online API, ongeveer in de volgorde waarin ze je week verpesten. De eerste vijf zijn de stille moordenaars: de API accepteert je payload, geeft een schone response, en gooit de belangrijke delen ongemerkt weg. De andere negen kom je uiteindelijk tegen in de documentatie, bij de derde keer lezen.
Waarom stille fouten in Exact erger zijn dan ze klinken
Exact Online draait een flink deel van de Nederlandse mkb-boekhouding. Bouw je agents voor accountancies, webshops of groothandels in de Benelux, dan kom je hem tegen. De REST API is OData-achtig, de auth is OAuth2, en er is een community PHP SDK (picqer/exact-php-client) die de meeste builds uiteindelijk gebruiken. Niets daarvan bereidt je voor op het scenario waarin een SalesInvoice-POST slaagt, de header wordt aangemaakt, en de regels gewoon niet bestaan.
De API van Exact doet dit omdat de SalesInvoice-entity en de SalesInvoiceLines-entity gekoppeld zijn, maar niet transactioneel. De header schrijft eerst weg. Faalt een regel op validatie, dan wordt die regel afgewezen. De header blijft staan. Je krijgt een nieuwe GUID en een 200 terug. Geen waarschuwing in de response body, geen error array, niets in de HTTP-headers. Gewoon een factuur die er prima uitziet, totdat iemand de totaalkolom leest. We hebben dit patroon een volledige QA-ronde zien overleven omdat de test fixtures Exacts standaard btw- en Item-tabellen gebruikten, en het pas brak op echte klantdata.
Krijg je 200 OK terug op een SalesInvoice-POST, dan heb je nog niet geverifieerd dat de regels zijn weggeschreven. Doe altijd een GET op de factuur via GUID en check of het aantal regels klopt met wat je verstuurde. Dat hebben we de dure manier geleerd.
De vijf stille killers op regelniveau
1. Een VATCode die deze administratie niet kent
Elke Exact-administratie (de boekhouding van één klant) heeft zijn eigen btw-codetabel. 1 in de ene administratie is 21 in een andere, HOOG in een derde. Stuurt je payload een VATCode die de administratie niet heeft, dan wordt de regel afgewezen en blijft de header staan. We halen nu één keer per administratie aan het begin van elke batch /api/v1/{division}/vat/VATCodes op en cachen de GUIDs.
2. Een Item-code waar de API een Item-GUID verwacht
De property Item op een SalesInvoiceLine verwacht de GUID van het Item, niet de leesbare itemcode die je accountant intypt. Verstuur je de codestring, dan valt de regel stilletjes weg. De SDK helpt je niet; hij stuurt door wat jij geeft. Vertaal itemcodes naar GUIDs aan de rand van je systeem, nooit binnen de factuurbouwer.
3. UnitCode-mismatch
POST je een UnitCode die niet is gedefinieerd als geldige verkoopeenheid voor dat specifieke item, dan wordt de regel afgewezen. Standaard is meestal stuk, maar accountancies met veel dienstverlenende klanten passen dit vaak aan naar uur, dag, maand. Lees het item, kopieer zijn SalesVatCode en een geldige UnitCode, en bouw dan pas de regel.
4. AmountFC zonder Currency, of een Currency waarin de administratie niet handelt
FC staat voor foreign currency. Zet je AmountFC terwijl de factuurheader geen Currency heeft, dan accepteert Exact het soms en slaat 0,00 op in de basisvaluta. Zet de Currency op de header altijd expliciet, ook als hij overeenkomt met de standaard van de administratie. Een sanity check van vijf seconden die ons al vier keer heeft gered.
5. Regels POSTen na de header naar de verkeerde division
Het bekende patroon, POST de header en daarna elke SalesInvoiceLine apart, werkt, maar de /api/v1/{division}/...-URL moet kloppen. Wijkt de gekozen division van je access token af van de division in de URL, dan krijg je 200 OK op de regel en verdwijnt de regel gewoon. Wij POSTen nu altijd de header met geneste SalesInvoiceLines in één call.
Dit is de vorm waarmee we nu shippen:
$client->setDivision($adminDivision); // verified GUID-to-int map
$invoice = new SalesInvoice($client);
$invoice->Currency = 'EUR';
$invoice->InvoiceTo = $debtorGuid;
$invoice->OrderedBy = $debtorGuid;
$invoice->Journal = $salesJournalGuid;
$invoice->SalesInvoiceLines = [
[
'Item' => $itemGuid, // GUID, never the code
'Quantity' => 1,
'UnitCode' => $item->SalesUnit, // copied from the item
'AmountFC' => 125.00,
'VATCode' => $vatGuidForThisAdmin,
'Description' => 'May retainer',
],
];
$invoice->save();
// Verify the read-back. If lines are empty, you have a silent drop.
$check = SalesInvoice::find($client, $invoice->InvoiceID);
if (count($check->SalesInvoiceLines) !== 1) {
throw new SilentLineDropException($invoice->InvoiceID);
}
Auth- en division-valkuilen
6. De refresh token roteert bij elke refresh
De OAuth2 van Exact geeft bij elke nieuwe access token ook een nieuwe refresh token uit. Cache je de oude en hergebruik je hem tien minuten later als de access token verloopt, dan krijg je invalid_grant en zit de klant op slot tot ze opnieuw autoriseren. Schrijf de nieuwe refresh token weg naar je DB binnen dezelfde transactie waarin je de oude verbruikt. De Exact OAuth-documentatie is hier expliciet over, maar je leest er makkelijk overheen.
7. Eén accountancy betekent tientallen divisions
Het Bredase kantoor heeft 41 klantadministraties in hun Exact-tenant. Elk heeft zijn eigen division ID. De meeste agent-code die we auditen gaat uit van één division per token. Bouw je code zo dat hij een division op de aanroep accepteert, nooit als singleton. Hetzelfde geldt voor btw-codes, journals en item-lookups: scope alles per division of je lekt data tussen klanten.
8. De access token leeft tien minuten
Hier struikelen de teams die 's nachts batchen. Duurt je batch run langer dan tien minuten, dan moet je tussentijds refreshen. Wacht niet op de 401. Wij refreshen proactief rond de acht-minuten-grens.
Filter- en pagination-valkuilen
9. OData $filter wil enkele quotes en geen accolades om GUIDs
Dit is Exact-specifiek. De juiste filter is $filter=ID eq guid'1ab23...'. De standaard OData-accolades om de GUID worden afgewezen. Tien minuten kwijt de eerste keer, twee uur kwijt de tweede keer als je vergeet dat je dit al een keer hebt opgelost.
10. Geen $skip, alleen $skiptoken
Is een list response langer dan 60 records, dan krijg je een __next-URL in de JSON. Volg die. Probeer geen pagination te bouwen met $skip, dat is niet geïmplementeerd. De SDK regelt dit goed, maar alleen als je daadwerkelijk itereert en niet één keer aanroept en aanneemt dat je de hele set hebt.
11. DateTime-filters hebben de OData v2-wrapper nodig
Je kunt niet Created gt '2026-06-01' schrijven. Je hebt Created gt datetime'2026-06-01T00:00:00' nodig. Vergeet je de wrapper, dan wordt de filter genegeerd. Het endpoint retourneert nog steeds 200 met alles erin. Dat is de slechtst denkbare combinatie: ziet eruit als een werkende call, geeft je een volledige resultaatset, en sloopt stilletjes je incrementele sync.
Rate limits, webhooks en de lange staart
12. 60 requests per minuut per division, 5000 per dag
Per division. Voor een accountancy met 41 administraties is dat in theorie prima. In de praktijk hamert je bulk import op één administratie en blaas je het minuutvenster binnen seconden op. De X-RateLimit-*-headers kloppen, maar de minuut-teller is een sliding window, geen kalenderminuut. Back off op een 429 door X-RateLimit-Reset te lezen; gok niet.
13. Webhooks doen geen retry
Geeft je endpoint 5xx terug of timed het uit boven de vijf seconden, dan is het webhook event weg. Er is geen retry queue. Wij spiegelen kritieke events door elke vijftien minuten de Sync-endpoints van Exact te pollen voor de entities waar de agent op steunt. Riem en bretels, maar een 'nieuwe factuur betaald'-event verliezen omdat je container herstartte is geen verhaal dat je een CFO wil vertellen.
14. De exception handling van de PHP SDK maskeert 400-fouten
De picqer client wrapt Guzzle, op zich prima, maar de exception messages worden niet altijd schoon doorgegeven als Exact een partial success retourneert. Wij patchen de client lokaal zodat hij bij elke non-2xx de volledige response body logt, en we wrappen de save()-aanroep in een read-back check bij alles met line items. Dat is de enkele meest waardevolle wijziging die we in de rollout hebben gedaan.
Waarmee we nu shippen
Drie regels die na de Bredase rollout in ons interne playbook zijn beland:
- Eén bootstrap call per administratie bij sessiestart, die btw-codes, journals, item-GUIDs en unit-codes in een cache per division trekt. Geïnvalideerd op een TTL van 24 uur of als een webhook meldt dat de masterdata is veranderd.
- Elke entity write gaat door een read-back guard. SalesInvoices verifiëren regelaantal en totaal. Documents verifiëren bestandsgrootte. Bank entries verifiëren boekdatum en bedrag.
- Webhooks plus een Sync-poll van vijftien minuten voor de vier entity types waar de agent op handelt, nooit alleen één van de twee. Webhooks zijn het snelle pad, Sync is de waarheid.
De kosten zijn één extra GET per write. Voor een factuur-chase agent die 200 facturen per dag doet op een venster van 60 per minuut zit je nog ruim onder de quota, en je verandert een stille fout in een luide.
Toen we de factuur-chase AI-agent bouwden voor de accountancy in Breda, was de stille line-drop de bug die bijna naar productie ging. We hebben de read-back guard in onze SDK-fork geschreven nog vóórdat we klaar waren met de prompts. Integreer je dit kwartaal met Exact Online, dan is het kleinste nuttige dat je vandaag kunt doen: grep je codebase op elke SalesInvoice-POST en zet er een read-back assertion naast. Vijf minuten werk, en je weet of je stille drops hebt.
Kern
Een 200 OK van Exact Online op een SalesInvoice-POST zegt alleen dat het request syntactisch klopt, niet dat de line items zijn weggeschreven. Lees altijd terug via GUID.
FAQ
Waarom geeft Exact Online 200 OK terug als line items wegvallen?
De SalesInvoice-header en de SalesInvoiceLines schrijven sequentieel weg, niet als één transactie. Faalt een regel op validatie (verkeerde btw-code, fout Item-type, onbekende eenheid), dan wordt de regel afgewezen en blijft de header staan. De response toont de header-GUID en geen error.
Moet ik de picqer PHP SDK gebruiken of een eigen dunne client schrijven?
De SDK is prima en we gebruiken hem nog steeds, maar patch hem zodat hij bij non-2xx de volledige response body logt, en wrap save() met een read-back check op entities met regels. Zelf bouwen kost meestal meer dan die twee gaten dichten.
Hoe ga ik om met een accountancy met tientallen klantadministraties?
Sla nooit één division op als singleton op de client. Geef de division expliciet mee aan elke call, en cache de btw-codes, journals, eenheden en item-GUIDs per division bij sessiestart. Deel geen caches tussen divisions.
Zijn de webhooks van Exact betrouwbaar genoeg om een agent op te draaien?
Nee. Ze doen geen retry op 5xx of timeout. Gebruik webhooks als snel pad en een poll van 15 minuten op de Sync-endpoints voor de entities die je niet kunt missen. Webhooks voor snelheid, Sync voor waarheid.