Fortlaufend synchronisieren
Anwendungsfall Schritt für Schritt, um die Kopie eines Mandats mit sync/bootstrap und sync/pull aktuell zu halten, den Cursor zu verwalten und Änderungen stapelweise mit sync/push zu senden.
Die NovaFisko-Anwendungen funktionieren offline dank eines Änderungsstreams. Ihre Integration kann denselben Stream nutzen, um die Kopie eines Mandats aktuell zu halten, ohne alle Listen neu zu lesen: ein Data Warehouse, ein Reporting-Werkzeug oder eine andere Buchhaltungssoftware.
Voraussetzungen: ein Token mit der Berechtigung sync. Für das erste Laden der Ressourcen über ihre üblichen Routen fügen Sie read hinzu.
Das Prinzip besteht aus drei Phasen:
- Bootstrap: Sie erhalten das Inventar der Ressourcen und einen Startcursor.
- Erstladen: Sie lesen jede Ressource einmal.
- Pull: Sie fragen regelmäßig „Was gibt es Neues seit meinem Cursor?“.
Schritt 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-"
}
}
Die Listen sind gekürzt. Speichern Sie cursor, bevor Sie mit dem Erstladen beginnen: Alles, was sich während des Ladens ändert, wird Ihnen beim ersten pull erneut geliefert.
Schritt 2: Erstladen
Rufen Sie für jeden Eintrag von resources den endpoint auf (relativ zu /v1/). Hat paginated den Wert true, durchlaufen Sie alle Seiten wie unter Paginierung beschrieben. Hat es den Wert false, ist die Antwort ein vollständiges Array oder ein einzelnes Objekt.
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
Schritt 3: inkrementeller Abruf
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 | Standard | Beschreibung |
|---|---|---|
since |
Zuletzt erhaltener Cursor | |
resources |
alle | Kommagetrennte Liste, um nur bestimmte Ressourcen zu verfolgen: entries,third_parties |
limit |
500 |
Anzahl der Änderungen pro Aufruf, höchstens 1000 |
{
"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"
}
Die Änderungen anwenden
op |
Was zu tun ist |
|---|---|
upsert |
Den Datensatz id der Ressource anlegen oder durch data ersetzen |
delete |
Den Datensatz entfernen. Er wurde gelöscht oder in den Papierkorb verschoben |
data hat genau die Form, die die Listenroute der Ressource liefert. Jeder Datensatz erscheint nur einmal pro Antwort, in seinem letzten Zustand. Wenn Sie eine version erhalten, die kleiner oder gleich der bei Ihnen vorhandenen ist, ignorieren Sie die Änderung.
Regeln für den Cursor
- Wenden Sie alle Änderungen einer Antwort an und speichern Sie danach den neuen
cursor, möglichst in derselben Transaktion. - Hat
has_moreden Werttrue, rufen Sie sofort mit dem neuen Cursor erneut auf. - Hat
full_resyncden Werttrue, ist Ihr Cursor unbrauchbar.changesist leer: Führen Sie das Erstladen aus Schritt 2 erneut durch und setzen Sie dann beim zurückgegebenencursorneu an.
full_resync tritt auf, wenn since fehlt, wenn es älter ist als die Aufbewahrungsdauer des Änderungsprotokolls (90 Tage) oder wenn es dem Server unbekannt ist. Das ist insbesondere in der Demo-Kanzlei nach dem nächtlichen Zurücksetzen der Fall.
Eine Änderung wird 2 Sekunden nach ihrem Schreiben sichtbar. Diese Verzögerung stellt sicher, dass ein Cursor niemals eine Zeile überspringt, die gerade noch gespeichert wird. Wundern Sie sich also nicht, wenn Sie eine soeben vorgenommene Änderung nicht sofort sehen.
Vollständige Schleife
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);
Ein Intervall von 30 bis 60 Sekunden genügt in den meisten Fällen. Um schneller zu reagieren, ohne häufiger abzufragen, lösen Sie beim Empfang eines Webhooks einen pull aus.
Schritt 4: den Zustand überwachen
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}
]
}
Vergleichen Sie Ihren Cursor mit cursor, um Ihren Rückstand zu messen. Ihr Konnektor erscheint dank des Headers X-Device-Id in devices.
Änderungen stapelweise senden
POST sync/push wendet bis zu 200 Mutationen in einem Aufruf an, der Reihe nach, jede in ihrer eigenen Transaktion. Jede Mutation wird über die entsprechende REST-Aktion ausgeführt, mit denselben Validierungen.
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"}]
}
}
]
}'
Die zweite Mutation verweist über die temporäre Kennung tmp-7d1f auf den Geschäftspartner, den die erste angelegt hat. Der Server ersetzt sie durch die tatsächliche id, im selben Stapel wie in den folgenden.
{
"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 |
Bedeutung | Was zu tun ist |
|---|---|---|
applied |
Mutation angewendet | Ihre Kopie durch data ersetzen |
duplicate |
client_mutation_id bereits verarbeitet, ursprüngliches Ergebnis zurückgegeben |
Nichts, die Mutation ist übernommen |
conflict |
Nach den Prioritätsregeln verarbeitet | Ihre Kopie durch data ersetzen, die Mutation aus Ihrer Warteschlange entfernen |
rejected |
Abgelehnt, siehe code |
Endgültig, außer wenn retryable den Wert true hat |
Nur bestimmte Vorgänge laufen über sync/push. Die genaue Liste steht in push.resources des Bootstraps. Alles andere kommt als rejected mit dem Code not_supported_offline zurück: Verwenden Sie dann die klassische REST-Route.
Betreffen zwei Änderungen denselben Datensatz, führt NovaFisko sie Feld für Feld zusammen. Gebuchte oder gesperrte Buchhaltungsdaten haben immer Vorrang. Die vollständigen Regeln finden Sie unter Konflikte und Prioritäten.
Grenzen, die Sie kennen sollten
- Massenverarbeitungen (Ausziffern, Sperren der Perioden beim Abschluss, Zurücksetzen auf „zu buchen“ nach einer Stornobuchung) erzeugen keine Zeile im Stream. Laden Sie nach einer Stornobuchung
documentsundbank_transactionsneu. - Das Änderungsprotokoll wird 90 Tage aufbewahrt. Ein Konnektor, der länger stillstand, muss ein vollständiges Laden wiederholen.
- Der Stream gehört zu einem einzelnen Mandat. Um eine ganze Kanzlei zu verfolgen, starten Sie eine Schleife pro Mandat.