blog·5 min read

Writing to the Exact Online API: what the documentation does not say

Reading from an accounting package is easy. Writing is where it bites. Five things we ran into while creating items in Exact Online that are not in the documentation.

Turn this supplier invoice into new items in the accounting package, with
supplier item and sales price, and show me what you are going to do first.

A connection that reads from an accounting package takes an afternoon. Fetch lists, put them in your own database, done. A connection that writes is a different thing. That is when you find out that a field in the documentation is simply ignored, that another field wants an internal number instead of the code you use everywhere else, and that at the fiftieth item you hit a limit you never saw while testing with three.

We built a tool for a customer that reads a supplier invoice and creates the new items in Exact Online. The customer stays anonymous and there are no prices, purchase amounts or margins here. This is about the API. The story about the read-only side is in query your accounting from Claude.

1. The sales price that disappears on create

You create an item and you neatly include StandardSalesPrice. The API answers with a 201 and the item exists. You look in the accounting package and the sales price is zero.

What happens: on create that field is ignored and Exact creates its own price line with value zero. You set the price afterwards with a separate PUT on logistics/SalesItemPrices. So that is two calls, not one, and nowhere an error message that tells you.

This is the kind of bug you can spend hours on, because everything is right except the outcome. The lesson we took from it: after every write, ask back what is actually there. Do not trust the 201, trust the data.

2. A supplier item wants an internal number

Recording a supplier item looks simple: supplier, the supplier's item code, price, unit. Except the field for the unit does not want the code you see in the interface, it wants the internal number of that unit. You look that number up in logistics/Units and then link it as ItemUnit.

That is a pattern you see more often in this API: the codes people use and the references the API wants are not the same. Count on keeping a few translation tables in your own database, and refresh them once a night rather than per call.

3. Not every field you can see is a field you may set

The average cost price of an item can be read but not written. That makes sense the moment you think about it, because that number comes out of the stock administration. It just is not marked in a way you can see before you try.

One addition we learned along the way: that average ignores items delivered for free. Base a calculation on it and you are working with a number that is slightly too high. That is not an API problem but an accounting problem, and it is exactly the sort of thing a connection makes visible.

4. A GET that refuses without $select

SalesItemPrices does not answer a plain GET. You have to add a $select with the fields you want. Not because the list is long, but because that endpoint wants it that way.

A small thing in itself, but one you build on the assumption that you can use the same pattern everywhere. We made a layer for it that sends the fields along for every endpoint, even where it is not needed. That is less elegant and saves a search every time.

5. The limit that leaves an item half finished

Exact allows 60 calls per minute and 5000 per day. You read your position from the X-RateLimit headers in every response, so you do not have to guess.

That sounds like a lot, until you realise that creating one item costs three to four calls in practice: the item itself, the price, the supplier item, and a check. Twelve items from one invoice is then already almost a full minute. In our case a row of items hit the limit and one was left half finished: created, but without a price.

That is the most important lesson of the whole project. Not "watch out for the limit", but: make one item the smallest unit of work that either fully succeeds or fully does not. Track per item how far you got, so that after an interruption you can continue where you left off instead of starting over. A queue you can resume is not a luxury on a connection that writes.

The same arithmetic applies on the reading side, only with bigger numbers. A first full synchronisation of the price lists cost us 1104 calls for 78 lists with 63,707 lines between them. After that you only fetch what changed, and it drops back to a handful of calls each time.

One thing that had nothing to do with the API

While we were creating items, someone in the accounting package was creating a few by hand as well. Those got codes from the same series our tool was using. A few numbers were suddenly taken.

There is no API fix for that. The only approach that prevents it: ask for the next free code at the moment you need it, not at the start of the row, and check after creating whether you got what you thought.

Common mistakes

"The documentation says so, therefore it works that way." Treat the documentation of an API you do not control as an assumption you test. Creating one item and then reading back what is there costs ten minutes and saves a day.

"I test with three lines, that is enough." Limits, colliding codes and half-finished rows only show up at volume. Test with a list long enough to run into something.

"I will let the connection write from my test environment too." Then sooner or later you write test items into the real accounting package. We let the connection exist only in production and run development work on a copy with no connection, with a test that guards that nothing is ever written.

"A 201 means it is stored correctly." A 201 means your request was accepted. What is stored, you only know once you have asked for it back.

"I handle the limit with a one second pause." That works until it does not. Read the headers from the response and wait for what they actually say, and make sure the queue can be resumed if you hit the limit anyway.

MK
Maarten Keizer

Founder of Invoker. Over twenty years in hosting, system administration and web development; builds the Claude hosting himself and tests everything on our own servers first.

about maarten