Continuous sync
Step-by-step use case to keep a copy of a company up to date with sync/bootstrap and sync/pull, manage the cursor and send modifications in batches with sync/push.
The NovaFisko applications work offline thanks to a change stream. Your integration can use the same stream to keep a copy of a company up to date without reading all the lists again: a data warehouse, a reporting tool or another accounting package.
Prerequisites: a token with the sync ability. For the first load of the resources through their usual routes, add read.
The principle comes down to three phases:
- Bootstrap: you get the inventory of resources and a starting cursor.
- Initial load: you read each resource once.
- Pull: you regularly ask "what is new since my cursor?".
Step 1: bootstrap
curl https://api.novafisko.com/v1/companies/k3Jd9fPq2LmX/sync/bootstrap \
-H "Authorization: Bearer $NOVAFISKO_TOKEN" \
-H "X-Device-Id: bi-warehouse-prod" \
-H "X-Device-Name: Entrep%C3%B4t%20BI" \
-H "X-Device-Platform: integration"
{
"cursor": 48211,
"server_time": "2026-10-05T08:40:00+00:00",
"full_resync": false,
"retention_days": 90,
"resources": [
{"name": "company", "count": 1, "endpoint": "companies/k3Jd9fPq2LmX", "paginated": false},
{"name": "company_settings", "count": 1, "endpoint": "companies/k3Jd9fPq2LmX/settings", "paginated": false},
{"name": "third_parties", "count": 184, "endpoint": "companies/k3Jd9fPq2LmX/third-parties", "paginated": false},
{"name": "accounts", "count": 412, "endpoint": "companies/k3Jd9fPq2LmX/accounts", "paginated": false},
{"name": "entries", "count": 873, "endpoint": "companies/k3Jd9fPq2LmX/entries", "paginated": true},
{"name": "documents", "count": 640, "endpoint": "companies/k3Jd9fPq2LmX/documents", "paginated": true},
{"name": "bank_transactions", "count": 1290, "endpoint": "companies/k3Jd9fPq2LmX/bank-transactions", "paginated": true}
],
"company": {"id": 318, "public_token": "k3Jd9fPq2LmX", "name": "Le Comptoir Montois SRL", "version": 6},
"company_settings": {"vat": {"office_number": null}},
"user": {"id": 12, "name": "Claire Dumont", "role": "firm_admin", "role_rank": 90, "can_write": true, "can_force": true},
"push": {
"resources": {
"third_parties": {"ops": ["create", "update", "delete"], "actions": []},
"entries": {"ops": ["create", "action"], "actions": ["entry", "invoice", "reverse"]}
},
"max_mutations": 200,
"temp_id_prefix": "tmp-"
}
}
The lists are abridged. Save cursor before starting the initial load: everything that changes during the load will be replayed to you at the first pull.
Step 2: initial load
For each item of resources, call endpoint (relative to /v1/). When paginated is true, go through all the pages as described in Pagination. When it is false, the response is a complete array or a single object.
def initial_load(session, base, bootstrap):
store = {}
for resource in bootstrap["resources"]:
url = f"{base}/{resource['endpoint']}"
if not resource["paginated"]:
store[resource["name"]] = session.get(url, timeout=60).json()
continue
rows, page = [], 1
while True:
payload = session.get(url, params={"per_page": 200, "page": page}, timeout=60).json()
rows.extend(payload["data"])
if page >= payload["last_page"]:
break
page += 1
store[resource["name"]] = rows
return store
Step 3: incremental pull
curl "https://api.novafisko.com/v1/companies/k3Jd9fPq2LmX/sync/pull?since=48211&limit=500" \
-H "Authorization: Bearer $NOVAFISKO_TOKEN" \
-H "X-Device-Id: bi-warehouse-prod"
| Parameter | Default | Description |
|---|---|---|
since |
Last cursor received | |
resources |
all | Comma-separated list to follow only certain resources: entries,third_parties |
limit |
500 |
Number of changes per call, 1000 at most |
{
"cursor": 48236,
"has_more": false,
"full_resync": false,
"changes": [
{
"resource": "third_parties",
"op": "upsert",
"id": 41,
"version": 4,
"data": {"id": 41, "type": "supplier", "code": "BRASSCOL", "name": "Brasserie des Collines SA", "email": "compta@brasserie-collines.example", "version": 4},
"changed_at": "2026-10-05T08:41:02+00:00",
"seq": 48230
},
{
"resource": "journal_rules",
"op": "delete",
"id": 9,
"version": 3,
"data": null,
"changed_at": "2026-10-05T08:41:30+00:00",
"seq": 48236
}
],
"server_time": "2026-10-05T08:41:39+00:00"
}
Applying the changes
op |
What to do |
|---|---|
upsert |
Create or replace the record id of the resource with data |
delete |
Remove the record. It was deleted or moved to the recycle bin |
data has exactly the shape returned by the list route of the resource. Each record appears only once per response, in its latest state. If you receive a version lower than or equal to the one you hold, ignore the change.
Cursor rules
- Apply all the changes of a response, then save the new
cursor, in the same transaction if possible. - If
has_moreistrue, call again immediately with the new cursor. - If
full_resyncistrue, your cursor is unusable.changesis empty: redo the initial load of step 2, then start again from the returnedcursor.
full_resync occurs when since is absent, when it is older than the retention of the change log (90 days) or when it is unknown to the server. This is notably the case in the demo firm after the nightly reset.
A change becomes visible 2 seconds after it is written. This delay guarantees that a cursor never skips over a row that is still being saved. So do not be surprised if you do not immediately see a modification you have just made.
Complete loop
const BASE = "https://api.novafisko.com/v1";
const headers = {
Authorization: `Bearer ${process.env.NOVAFISKO_TOKEN}`,
Accept: "application/json",
"X-Device-Id": "bi-warehouse-prod",
};
async function syncOnce(company, store) {
let cursor = await store.getCursor(company);
for (;;) {
const url = `${BASE}/companies/${company}/sync/pull?since=${cursor ?? ""}&limit=1000`;
const page = await (await fetch(url, { headers })).json();
if (page.full_resync) {
await store.reloadEverything(company); // initial load again
await store.setCursor(company, page.cursor);
return;
}
await store.transaction(async (tx) => {
for (const change of page.changes) {
if (change.op === "delete") await tx.remove(change.resource, change.id);
else await tx.upsert(change.resource, change.id, change.version, change.data);
}
await tx.setCursor(company, page.cursor); // cursor saved with the data
});
cursor = page.cursor;
if (!page.has_more) return;
}
}
// Poll every 30 seconds; a webhook can trigger an immediate run
setInterval(() => syncOnce("k3Jd9fPq2LmX", store).catch(console.error), 30_000);
An interval of 30 to 60 seconds is enough in most cases. To react faster without polling more often, trigger a pull when you receive a webhook.
Step 4: monitor the status
curl https://api.novafisko.com/v1/companies/k3Jd9fPq2LmX/sync/status \
-H "Authorization: Bearer $NOVAFISKO_TOKEN" \
-H "X-Device-Id: bi-warehouse-prod"
{
"cursor": 48236,
"server_time": "2026-10-05T08:45:00+00:00",
"pending_conflicts": 0,
"last_push_at": "2026-10-05T07:58:12+00:00",
"devices": [
{"id": "bi-warehouse-prod", "name": "Entrepôt BI", "platform": "integration", "user": {"id": 12, "name": "Claire Dumont"}, "last_seen_at": "2026-10-05T08:45:00+00:00", "last_push_at": null, "last_cursor": 48236}
]
}
Compare your cursor with cursor to measure your lag. Your connector appears in devices thanks to the X-Device-Id header.
Sending modifications in batches
POST sync/push applies up to 200 mutations in one call, in order, each in its own transaction. Each mutation is replayed through the corresponding REST action, with the same validations.
curl -X POST https://api.novafisko.com/v1/companies/k3Jd9fPq2LmX/sync/push \
-H "Authorization: Bearer $NOVAFISKO_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"device": {"id": "erp-atelier-prod", "name": "ERP Atelier", "platform": "integration"},
"mutations": [
{
"client_mutation_id": "c1b9a8d2-51b5-4a61-8f52-3a2d1d8c5a01",
"occurred_at": "2026-10-05T08:00:00Z",
"resource": "third_parties",
"op": "create",
"client_temp_id": "tmp-7d1f",
"data": {"type": "supplier", "name": "Maraîchers du Borinage SC", "vat_number": "BE0999900233"}
},
{
"client_mutation_id": "c1b9a8d2-51b5-4a61-8f52-3a2d1d8c5a02",
"occurred_at": "2026-10-05T08:00:05Z",
"resource": "entries",
"op": "action",
"action": "invoice",
"data": {
"journal_id": 3,
"third_party_id": "tmp-7d1f",
"entry_date": "2026-10-03",
"reference": "2026-441",
"lines": [{"account": "604000", "amount": "318.40", "vat_code": "A06"}]
}
}
]
}'
The second mutation references the third party created by the first one through the temporary identifier tmp-7d1f. The server replaces it with the real id, in the same batch as well as in later ones.
{
"results": [
{
"client_mutation_id": "c1b9a8d2-51b5-4a61-8f52-3a2d1d8c5a01",
"status": "applied",
"resource": "third_parties",
"op": "create",
"id": 215,
"client_temp_id": "tmp-7d1f",
"version": 1,
"data": {"id": 215, "type": "supplier", "name": "Maraîchers du Borinage SC", "version": 1},
"conflict": null,
"retryable": false
},
{
"client_mutation_id": "c1b9a8d2-51b5-4a61-8f52-3a2d1d8c5a02",
"status": "applied",
"resource": "entries",
"op": "action",
"action": "invoice",
"id": 1851,
"data": {"id": 1851, "number": 213, "status": "posted"},
"conflict": null,
"retryable": false
}
],
"cursor": 48251,
"server_time": "2026-10-05T08:46:10+00:00"
}
status |
Meaning | What to do |
|---|---|---|
applied |
Mutation applied | Replace your copy with data |
duplicate |
client_mutation_id already processed, original result returned |
Nothing, the mutation is settled |
conflict |
Handled according to the priority rules | Replace your copy with data, remove the mutation from your queue |
rejected |
Refused, see code |
Final, unless retryable is true |
Only certain operations go through sync/push. The exact list is in push.resources of the bootstrap. Everything else comes back rejected with the not_supported_offline code: in that case use the regular REST route.
When two modifications affect the same record, NovaFisko merges field by field. Posted or locked accounting data always wins. The complete rules are in Conflicts and priorities.
Limits to be aware of
- Bulk operations (lettering, locking of periods at closing, resetting documents to be booked after a reversal) do not produce a row in the stream. After a reversal, reload
documentsandbank_transactions. - The change log is kept for 90 days. A connector stopped for longer will have to do a full load again.
- The stream is specific to one company. To follow a whole firm, run one loop per company.