← Blog

Integrations

Exact Online API: 14 quirks from a Breda accountancy rollout

Our invoice-chase agent reported 47 invoices sent. The accountant opened Exact and saw 47 headers with zero amounts. The PHP SDK had returned 200 OK on every call.

Jacob Molkenboer· Founder · A Brand New Company· 23 Jan 2025· 7 min
Brass ledger stamp, open blank daybook, carbon receipt with green tag, red wax seal on ivory paper in side light.

The phone rang at 17:04 on a Wednesday. The operations lead at a 38-person accountancy in Breda was looking at her Exact Online screen. Our invoice-chase agent had logged 47 invoices sent that afternoon. The dashboard agreed. Exact showed 47 invoice headers, customer names, due dates, all green. Every total said €0,00.

The PHP SDK had returned 200 OK on every single call. Every line item we had POSTed was gone.

That call kicked off the cheatsheet below. Fourteen Exact Online API quirks, in roughly the order they ruin your week. The first five are the silent killers, the ones where the API accepts your payload, gives you a clean response, and quietly throws away the parts that matter. The other nine are the kind of thing you eventually find in the docs, on the third read.

Why Exact's silent failures are worse than they sound

Exact Online runs a meaningful share of Dutch SMB bookkeeping. If you build agents for accountancies, e-commerce shops, or wholesalers in the Benelux, you will hit it. The REST API is OData-flavoured, the auth is OAuth2, and there is a community PHP SDK (picqer/exact-php-client) that most builds end up using. None of that prepares you for the failure mode where a SalesInvoice POST succeeds, the header is created, and the lines just don't exist.

Exact's API does this because the SalesInvoice entity and the SalesInvoiceLines entity are coupled but not transactional. The header writes first. If a line fails validation, the line is rejected. The header stays. You get back a fresh GUID and a 200. There is no warning in the response body, no error array, nothing in the HTTP headers. Just an invoice that looks fine until somebody reads the total column. We have seen this pattern survive a full QA round because the test fixtures used Exact's default VAT and Item tables, and only broke against a real customer's data.

Warning

If Exact returns 200 OK on a SalesInvoice POST, you have not verified the lines wrote. Always GET the invoice back by GUID and check that the line count matches what you sent. We learned this the expensive way.

The five silent line-item killers

1. VATCode that does not exist in this administration

Every Exact administration (each customer's books) has its own VAT code table. 1 in one admin is 21 in another, HOOG in a third. If your payload sends a VATCode the admin does not have, the line is rejected and the header is kept. We now fetch /api/v1/{division}/vat/VATCodes once per admin at the start of every batch and cache the GUIDs.

2. Item code where the API expects an Item GUID

The Item property on a SalesInvoiceLine expects the Item's GUID, not the human readable item code your accountant types. Send the code string and the line silently drops. The SDK will not help you, it just forwards what you give it. Resolve item codes to GUIDs at the edge of your system, never inside the invoice builder.

3. UnitCode mismatch

If you POST a UnitCode that is not defined as a valid sales unit for that specific item, the line is rejected. The default is usually stuk (piece), but accountancies that handle service-heavy clients often customise this to uur, dag, maand. Read the item, copy its SalesVatCode and a valid UnitCode, then build the line.

4. AmountFC without a Currency, or a Currency the admin does not transact in

FC means foreign currency. If you set AmountFC but the invoice header has no Currency, Exact sometimes accepts and stores 0,00 in the base currency. Always set the header Currency explicitly, even when it matches the admin default. A 5-second sanity check that has saved us four times.

5. Posting lines after the header to the wrong division

The community pattern of POST header, then POST each SalesInvoiceLine separately, works, but the /api/v1/{division}/... URL must match. If your access token's selected division differs from the URL division, you get 200 OK on the line and the line just vanishes. We now always POST the header with nested SalesInvoiceLines in a single call.

Here is the shape we ship now:

$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 and division quirks

6. The refresh token rotates on every refresh

Exact's OAuth2 issues a fresh refresh token alongside every new access token. If you cache the old one and reuse it ten minutes later when the access token expires, you will get invalid_grant and the customer is locked out until they re-authorise. Write the new refresh token to your DB inside the same transaction that consumes the old one. The Exact OAuth docs are explicit about this but easy to skim past.

7. One accountancy means dozens of divisions

The Breda firm has 41 client administrations in their Exact tenant. Each has its own division ID. Most agent code we audit assumes one division per token. Build your code to accept a division at the call site, never as a singleton. The same goes for VAT codes, journals, and item lookups: scope everything by division or you will leak data between clients.

8. The access token lives ten minutes

This catches teams who batch overnight. If your batch run takes longer than ten minutes, you need to refresh in-flight. Do not wait for the 401. We refresh proactively at the eight-minute mark.

Filter and pagination quirks

9. OData $filter wants single quotes and no braces around GUIDs

This is Exact-specific. The correct filter is $filter=ID eq guid'1ab23...'. Standard OData braces around the GUID get rejected. Easy ten minutes lost the first time, two hours lost the second time when you forget you already solved it.

10. There is no $skip, only $skiptoken

If a list response is longer than 60 records, you get a __next URL in the JSON. Follow it. Do not try to build pagination with $skip, it is not implemented. The SDK handles this correctly, but only if you actually iterate, not if you call once and assume you have the whole set.

11. DateTime filters need the OData v2 wrapper

You cannot write Created gt '2026-06-01'. You need Created gt datetime'2026-06-01T00:00:00'. Forget the wrapper and the filter is ignored. The endpoint still returns 200 with everything, which is the worst possible combination: looks like a working call, gives you a full result set, and quietly destroys your incremental sync.

Rate limits, webhooks, and the long tail

12. 60 requests per minute per division, 5000 per day

Per division. For a 41-admin accountancy that is fine in theory. In practice your bulk import hits one admin hard and you blow the minute window in seconds. The X-RateLimit-* headers are accurate but the minute counter is a sliding window, not a calendar minute. Back off on a 429 by reading X-RateLimit-Reset, do not guess.

13. Webhooks do not retry

If your endpoint returns 5xx or times out past five seconds, the webhook event is gone. There is no retry queue. We mirror critical events by polling Exact's Sync endpoints every fifteen minutes for the entities the agent cares about. It is belt and braces, but losing a new invoice paid event because your container restarted is not a story you want to tell a CFO.

14. The PHP SDK's exception handling masks 400-class errors

The picqer client wraps Guzzle, which is fine, but the exception messages are not always surfaced cleanly when Exact returns a partial success. We patch the client locally to log the full response body on any non-2xx, and we wrap the save() call in a read-back check on anything that has line items. This is the single highest-value change we made in the rollout.

What we ship with now

Three rules that landed in our internal playbook after the Breda rollout:

  • One bootstrap call per administration at session start, pulling VAT codes, journals, item GUIDs, and unit codes into a per-division cache. Invalidated on a 24-hour TTL or when a webhook says the master data changed.
  • Every entity write returns through a read-back guard. SalesInvoices verify line count and total. Documents verify file size. Bank entries verify the booking date and amount.
  • Webhooks plus a fifteen-minute Sync poll for the four entity types the agent acts on, never one or the other. Webhooks are the fast path, Sync is the truth.

The cost is one extra GET per write. For an invoice-chase agent doing 200 invoices a day on a 60-per-minute window, that is still well under quota, and it turns a silent failure mode into a loud one.

When we built the invoice-chase AI agent for the Breda accountancy, the silent line-drop was the bug that nearly shipped to production. We ended up writing the read-back guard into our SDK fork before we even finished the prompts. If you are integrating with Exact Online this quarter, the smallest useful thing you can do today is grep your codebase for every SalesInvoice POST and add a read-back assertion next to it. Five minutes, and you will know whether you have silent drops.

Key takeaway

Exact Online's 200 OK on a SalesInvoice POST tells you the request was syntactically valid, not that the line items wrote. Always read back by GUID.

FAQ

Why does Exact Online return 200 OK when line items are dropped?

The SalesInvoice header and SalesInvoiceLines write in sequence, not as a transaction. If a line fails validation (bad VAT code, wrong Item type, unknown unit), it is rejected while the header stays. The response shows the header GUID, no error.

Should I use the picqer PHP SDK or write a thin client?

The SDK is fine and we still use it, but patch it to log full non-2xx response bodies and wrap save() with a read-back check on line-bearing entities. Rolling your own usually costs more than fixing those two gaps.

How do I handle an accountancy with dozens of client administrations?

Never store one division as a singleton on the client. Pass the division explicitly into every call, and cache the per-division VAT codes, journals, units, and item GUIDs at session start. Do not share caches across divisions.

Are Exact's webhooks reliable enough to drive an agent on their own?

No. They do not retry on 5xx or timeout. Use webhooks as the fast path and a 15-minute poll on the Sync endpoints for the entities you cannot afford to miss. Webhooks for speed, Sync for truth.

integrationsai agentsautomationphpcase studyoperations

Building something?

Start a project