The failure that does not announce itself
Nothing raises. Nothing retries. There is no 429, because no limit was exceeded. The sync reports success, because from its point of view it succeeded.
What you see is a store that looks fine. Stock levels are accurate for the products that happen to sort first and frozen for everything after. Nobody notices, because a stale number looks exactly like a fresh one. The discrepancy surfaces when someone counts — usually a customer, buying something you don't have.
The reasonable question is how you would know if that were happening to you. That is answerable, and the answer is near the bottom of this piece. First, what connectors are for, and where the boundary actually falls.
Connectors are good at the thing they do
If your operation is orders out, products in, stock levels updated — one warehouse, a catalogue that maps cleanly onto Shopify products, no conditional logic anywhere in the chain — buy a connector. They are inexpensive, someone else maintains them, and they work. Building that yourself is a bad use of money.
This is not a preamble before the real argument. It is most of the market, and it is served.
The boundary is not quality. It is that a connector maps fields, and a field mapping can only move a number that already exists from one system to another. It stops being enough at the point where the correct number has to be decided rather than copied.
Five places business logic exceeds field mapping
Available versus committed. Exact deducts stock when an order ships. Between the moment warehouse staff pick an order and the moment it leaves, the ERP still counts that stock as present — and a connector faithfully copies "present" onto your storefront, where it is sold a second time. Shopify has a committed state for exactly this, but you cannot write to it: Shopify computes committed itself from orders, draft-order reservations and transfers, and the Admin API refuses to adjust it. So the correction has to happen before the number is written. Building it for Weldaad, the formula came out as shelf stock minus everything already picked, recalculated across every open order sharing that SKU — and released again on shipment, so Exact's own deduction doesn't land twice.
Multi-warehouse filtering. Summed across warehouses the total is arithmetically correct and commercially wrong, because only one warehouse's stock is sellable to this storefront. No field says which. That is a rule someone has to write down.
Pre-order state at SKU level. Not at product level — SKU, because a product where one size is in stock and another is six weeks out is two different promises on one page. The Exact-to-Shopify pre-order system drives that state from Exact's own incoming-stock data across 1,500+ SKUs, holding pre-order status and cap in metafields the application owns, and reconciling on a schedule. The hard case is partial arrival: half the incoming quantity lands, and the product is now genuinely both.
Reservation against expected stock. Selling against stock that is incoming rather than held, capped at what is actually coming, so the promise stays keepable.
Anything where the answer is a decision. A wholesale order line for ten units with three available is not "in stock" and not "out of stock". Whether it ships partially depends on what the rest of the order is worth and how long the remainder will take. No field on either side holds that. Someone has to encode the rule.
None of these is hypothetical. All five came out of four Exact Online integrations built for one wholesale business.
What Exact's constraints force you to build
Exact's API is documented, consistent, and more restrictive than most. The restrictions are not obstacles to route around; they determine the architecture.
You cannot parallelise. Exact states it plainly: parallel API calls are not allowed, all integrations must be sequential, no pipelining. Throughput is therefore a queue design problem. Your only levers are fewer calls, better filters, and the sync endpoints, which return a thousand records instead of sixty. Worth knowing before you plan around them: there are no bulk endpoints for Inventory at all, so for stock positions the sync API is not the faster option, it is the only one.
Limits are per division, and they multiply. Five thousand calls per app per company per day, sixty per minute, with a fair-use ceiling of twenty thousand across the whole contract. Exact's own example is the one to sit with: an integration making fourteen thousand calls a day across four companies stays inside the limit on three of them and is rejected on the fourth, because the budget is per company and the work is not evenly distributed.
Retry logic can lock you out. Ten errors per key, per user, per company, per endpoint, per hour, and then the key is blocked — with the block lengthening if you keep going. A 404 counts. So does a 401. The naive retry loop, hammering a failing endpoint until it works, is precisely the thing that converts a small outage into a blocked key and a slower recovery. Retry transient failures with backoff. Never retry functional ones.
Pagination has to be followed, and completeness verified rather than assumed. Following __next is the easy half. The half that matters is proving afterwards that you got everything: counting records, detecting duplicates across pages, and failing loudly on a partial result instead of returning it.
/**
* Follows Exact Online's __next chain to completion and refuses to return
* a partial result. Sequential by design: Exact does not permit parallel calls.
*/
export async function fetchAll(startUrl, accessToken, { maxPages = 500 } = {}) {
const seen = new Set()
const records = []
let url = startUrl
let pages = 0
let duplicates = 0
let lastCallAt = 0
while (url) {
if (++pages > maxPages) {
throw new Error(`Pagination exceeded ${maxPages} pages — refusing to continue`)
}
// 60 calls per minute, per app, per division. One second apart is safe.
const wait = 1000 - (Date.now() - lastCallAt)
if (wait > 0) await new Promise((r) => setTimeout(r, wait))
lastCallAt = Date.now()
const res = await fetch(url, {
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
})
// 400/401/403/404 each count toward 10 errors per hour, after which the
// key is blocked. Never retry these — a retry loop makes recovery worse.
if (!res.ok) {
throw new Error(`Exact returned ${res.status} on page ${pages} of ${startUrl}`)
}
const body = await res.json()
const page = body.d?.results ?? []
for (const row of page) {
const key = row.ID ?? row.Code
if (key === undefined) throw new Error('Record has no ID or Code — cannot verify completeness')
if (seen.has(key)) {
duplicates++
continue
}
seen.add(key)
records.push(row)
}
const header = res.headers.get('X-RateLimit-Remaining')
const remaining = header === null ? NaN : Number(header)
if (Number.isFinite(remaining) && remaining < 50) {
throw new Error(`Daily budget nearly exhausted (${remaining} calls left) — stopping mid-sync`)
}
url = body.d?.__next ?? null
}
return { records, pages, duplicates, complete: true }
}Reconciliation is a feature, not an afterthought
Two things make this unavoidable rather than tidy. Exact's sync endpoints are built on row versions — the Timestamp field is documented as "represents a rowversion value, not convertible to date/time" — and Exact regenerates every one of them when it moves a division to another database. Its documentation says so on the field itself: "Please resync all data when this value changes because value of Timestamp is regenerated." Miss it, and your incremental sync quietly asks for records newer than a watermark that no longer means anything.
There is a second field with identical wording for divisions linked to or unlinked from Exact Online HR, so watching only the move date is not enough. And separately: the Sync API does not return deletions at all. There is a Deleted endpoint for that, and a sync that doesn't call it accumulates products that no longer exist, forever.
How to check whether this is happening to you
Count. Products in Shopify, items in Exact, compare the two. If Shopify's number is smaller and stubbornly stable while Exact's grows, that is the signature.
Look at where it stops. Sixty, a hundred and twenty, five hundred, a thousand — a round number is a pagination boundary, not a coincidence. Sort your catalogue by last-updated and look for a cliff: a date after which nothing has changed, on products that certainly have.
That is what it looked like on the pre-order integration above, in the case study's own words: failing to handle pagination correctly meant "large portions of the catalog could silently disappear from calculations". Nothing on the storefront said so.
Then ask what your integration logs. How many pages did it fetch last night? How many records did it expect? Did anything verify that the two matched? If nobody can answer, you have your answer, and it is the same one either way — the sync is unverified, and an unverified sync that happens to be correct is still unverified.
When this needs an engineer
The test is simple. If the number you need is stored in one system and wanted in another, a connector will move it. If the number has to be worked out — from stock that is present but spoken for, from a warehouse that counts and three that don't, from incoming quantities against caps you set — then no field mapping produces it, because the answer doesn't exist in either system until your rules create it.
That is the line. On one side, buy something. On the other, you need a custom Exact Online integration that encodes what your business actually knows.
Send us the store and the symptom.
