← Blog

Integrations

Exact Online API gotchas: a 14-item production cheatsheet

An Arnhem installation group ran their first automated purchase order against Exact Online. The API returned 201 Created. The GL code never made it into the ledger.

Jacob Molkenboer· Founder · A Brand New Company· 6 Apr 2025· 8 min
Open brass ledger, cream index card with green paper clip, red wax seal, iron shipping tag on ivory paper.

The operations lead at a 36-person installation services group in Arnhem ran her first automated purchase order against Exact Online at 16:47 on a Thursday. The API returned 201 Created. The order ID came back. The PDF generated. And the GL account code never made it into the ledger.

Three hours of accountant time the next morning, because nothing in the response said anything was missing.

This is the cheatsheet we wish we had handed our past selves. Fourteen Exact Online REST API gotchas, ranked by how badly they bite in production. The ones at the top of the list all share a property: the server returns a happy 201, your code logs success, and a field you sent gets quietly dropped on the floor.

The silent-drop tier

These are the ones that cost real money. The API accepts your payload, returns the entity ID, and you only notice the missing data weeks later when someone reconciles. There is no warning header, no validation error, no log line you can grep for. The only defence is reading back the fields you wrote.

1. GLAccountCode dropped on multi-administration tenants

If your token is scoped to multiple administrations and you send a GLAccount GUID that exists in administration A while writing to administration B, the API does not return 400. It writes the line without a GL link and returns 201. The field comes back null on the next read.

Fix: always resolve the GL account inside the division you are writing to. Treat division boundaries as 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 silently coerced to the division default

Send a VAT code that is valid in one administration but not registered against the division you are posting to, and the line is written with the administration default. No warning, no error. We saw this on German purchase invoices flowing into a BV that had not yet imported the EU VAT codes. Every line wrote with the standard NL high rate, and reconciliation flagged the mismatch two months later.

3. Currency coerced to administration currency

Same pattern. Send "Currency": "USD" on a tenant that does not have USD enabled, and Exact writes EUR with no conversion. Always pre-check /api/v1/{division}/system/Currencies before you write, and reject the order in your own code rather than letting it through.

4. PUT is a full replace, not a patch

Every field you do not send on a PUT is treated as null. There is no PATCH endpoint for most resources. If you fetch an order, change one line, and PUT the modified object back, you will overwrite anything the API did not return on the GET (default flags, computed fields, the works).

Warning

Always GET the full record, mutate the in-memory copy, and PUT the whole thing back. Never construct a PUT body from scratch with only the fields you want to change.

5. Quantity renames itself between write and read

On PurchaseOrderLines you POST Quantity. On read it comes back as QuantityOrdered. If you wrote your roundtrip test by comparing the request payload to the response, your test passes for everything except the field you actually care about. Map the names explicitly; do not rely on key equality.

6. Item expects a GUID, not a code

The Item field on a line is a GUID reference to /api/v1/{division}/logistics/Items. ItemCode on the read side is a convenience field. You cannot write the human-readable code into Item and have Exact resolve it. If you do, you get a 400 if you are lucky, or a created order with a phantom item link if you are not.

The auth and rate tier

These will not lose your data, but they will take your integration offline at 03:00 if you ignore them. None of them are subtle once they bite; they are simply easy to underweight during the build.

7. Refresh tokens rotate on every refresh

This one is in the docs but easy to miss. Every call to /api/oauth2/token with grant_type=refresh_token returns a new refresh token. The old one becomes invalid immediately. If your token store is not atomic, or two processes refresh in parallel, one of them ends up with a dead token and you are doing the OAuth dance by hand on Monday morning.

Wrap the refresh in a database-level lock and persist the new pair before any other process can read. The canonical flow lives in the Exact Online REST API knowledge base.

8. The 60-per-minute rate limit is per token, not per IP

On 429, Exact returns a Retry-After header in seconds. Honour it. If you have parallel workers sharing one token, you will burn through the budget in seconds. Either serialize through a queue or 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 is in the URL path, never inherited

Every resource URL starts with /api/v1/{division}/. There is no current-division session state. If you cache a base URL with a division baked in and someone switches administration in the UI, your writes go to the wrong place. Make division explicit at every call site.

The shape and format tier

These are the ones that produce confusing parse errors, not silent drops. Fix them once in an adapter layer and forget about them.

10. Pagination uses __next, not $skip

OData veterans assume $skip works. It does not. Exact returns a __next URL in the response envelope when more results exist, following the OData v2 JSON envelope convention. Follow that URL verbatim, including its query string. If you build your own $skip loop you will get duplicates and gaps.

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 format flips between endpoints

Most write endpoints accept ISO 8601 strings. Reads return the Microsoft JSON date format /Date(1718150400000)/. A handful of endpoints (notably the sync resources) demand the Microsoft format on write too. Have a date adapter and centralise it; do not parse dates in calling code.

12. Supplier lookup mixes AccountCode and ID

On a purchase order, Supplier is a GUID pointing at /api/v1/{division}/crm/Accounts. SupplierCode is a string convenience field. Same trap as Item. We have seen integrations where someone built an Accounts CSV import that wrote Code only, and then the purchase order writer could not find suppliers by GUID because half of them still had a system-generated code.

The workflow tier

Architecture-level issues that bite once you scale past two or three divisions.

13. Webhooks are scoped per division

If a tenant has fifteen administrations and you want order-created webhooks across all of them, that is fifteen subscriptions. Track them in your own database. The list endpoint will tell you what exists, but if a colleague registered one by hand against an admin that later got archived, the webhook keeps firing into the void and you keep paying egress.

14. Sandbox responses are not a faithful mirror

The Exact Online sandbox is close enough to fool you on the happy path. A handful of resources return fewer fields than production. A few return different VAT setups by default. We run a smoke test that diffs the response schema of fifteen key endpoints against a known production payload before promoting any integration change. It has caught a dropped field three times in two years.

The ranking, repeated as a checklist

If you do nothing else after closing this tab, audit your integration against these in order. Numbers 1 through 6 are the ones that quietly cost money. Everything below number 6 is operational pain you will feel within a week.

  1. Resolve GL accounts inside the target division. Always.
  2. Pre-validate VAT codes against the division.
  3. Pre-validate currencies against the division.
  4. Treat PUT as full replace. GET first, mutate, PUT.
  5. Map Quantity on write, QuantityOrdered on read.
  6. Use GUIDs for Item, not codes.
  7. Persist rotating refresh tokens atomically.
  8. Respect Retry-After. Serialize per token.
  9. Put division in the URL at every call site.
  10. Follow __next, never $skip.
  11. Centralise date format conversion in one adapter.
  12. Look up suppliers by GUID, not code.
  13. Track webhooks per division in your own database.
  14. Schema-diff sandbox against production before promoting.

What this looked like in Arnhem

The 36-person group writes about 180 purchase orders a week, across two BVs and a Belgian subsidiary. The integration we built reads incoming supplier confirmations from a shared mailbox, matches them against an open-order register in Postgres, and writes the matched orders into Exact under the right division. The first version went live with a GL-account drop on roughly eight percent of cross-division orders. We caught it on day three because the accountant flagged a reconciliation gap. We would rather have caught it on day zero.

The second version added a per-write division resolver, a schema diff against the previous production response, and a five-field write-then-read check on every order (GL, VAT, Currency, Supplier, Item). Drop rate now: zero across six weeks of production traffic. When we build AI agents and integrations for clients on legacy ERPs, that boring write-then-read check is the cheapest insurance we know.

Takeaway

A 201 from Exact Online means the request parsed, not that the data landed. Read back every field you cared enough to write.

Today, audit one thing. Pick a recent purchase order from your integration, GET it back from /api/v1/{division}/purchaseorder/PurchaseOrders, and diff the GLAccount, VAT, Currency and Supplier fields against what you sent. Five minutes. It will tell you whether you have a problem.

Key takeaway

A 201 from Exact Online means the request parsed, not that the data landed. Read back every field you cared enough to write.

FAQ

Does Exact Online return a validation error when a GL account is missing?

No. On multi-administration tenants you can POST a GLAccount GUID that belongs to a different division and the API returns 201 Created with the field silently set to null. Always resolve GL codes inside the target division.

How often do Exact Online refresh tokens rotate?

Every refresh. Each call to the token endpoint with grant_type=refresh_token returns a new refresh token and invalidates the old one immediately. Persist the new pair atomically before any other process reads.

Why does my Quantity field disappear from Exact purchase order responses?

It does not disappear. The field is renamed: you POST Quantity, but reads return QuantityOrdered. Roundtrip tests that compare keys directly will flag a phantom mismatch.

Can I use OData $skip for pagination with Exact Online?

No. Exact ignores $skip and returns a __next URL in the response envelope. Follow that URL verbatim until it is null. Building your own skip loop produces duplicate rows and missing pages.

integrationsautomationworkflowarchitectureoperationstooling

Building something?

Start a project