Veröffentlicht von Markus Wals

Polling war gestern: So baust du Delta-Sync und Webhooks richtig

API Delta-Sync Idempotenz Integration n8n Webhooks weclapp

Titelgrafik: Polling war gestern. weclapp API, Das Handbuch, Teil 05: Delta-Sync und Webhooks richtig bauen.

Ein Flow, der jede Minute alle Aufträge abruft, läuft in der Testphase unauffällig. Im Dauerbetrieb taucht er im Request-Log als Lastspitze auf, und mit jedem weiteren Flow wird sie größer. Dieser Artikel zeigt den Gegenentwurf: Du holst nur ab, was sich geändert hat (Delta-Sync), und lässt dich von weclapp benachrichtigen, wenn etwas passiert (Webhooks). Dazu kommen die Muster, die beides im Betrieb stabil halten.

Den Anstoß gab Christian Lanig (weclapp): Er hat am Community Day 2026 im Vortrag „Jeder Request zählt“ gezeigt, wie man aus dem Request-Log konkrete Optimierungen ableitet. Den Rest liefert unsere Praxis. Wir betreiben Integrationen auf der weclapp API im Dauerbetrieb, mit Fulfillment 4 weclapp, 4Fulfillment, wals.pro AI 4 weclapp und n8n-Flows für unsere Kund:innen. Die Muster in diesem Artikel kommen aus Integrationen, die rund um die Uhr laufen müssen.

Vom Request-Log zur Optimierung: drei Symptome, drei Lösungen

Lanig ordnet in seinem Vortrag jedem Muster, das im Log sichtbar wird, eine Ursache und eine Lösung zu:

Symptom im Log Ursache Lösung
Lastspitzen Vollabgleich Delta-Sync
Viele gleichartige Aufrufe Polling Webhooks
Viele Einzelrequests N+1 referencedEntities

Grafik: Vom Request-Log zur Optimierung. Lastspitzen kommen vom Vollabgleich und werden mit Delta-Sync gelöst, gleichartige Aufrufe kommen vom Polling und werden mit Webhooks gelöst, viele Einzelrequests kommen von N+1 und werden mit referencedEntities gelöst.

Die ersten beiden Zeilen sind das Thema dieses Artikels, die dritte bekommt einen eigenen Teil der Serie. Auch weclapp selbst empfiehlt: kein ungefiltertes Polling, idealerweise eine ereignisgesteuerte Architektur mit Webhooks.

Ein Rechenbeispiel zeigt, warum. Ein Abruf alle fünf Minuten sind 288 Requests pro Tag und Entität, egal ob sich etwas geändert hat. Bei zehn Entitäten und fünf Flows sind das 14.400 Requests am Tag, in denen oft nichts Neues steht. Die Last entsteht aus Anzahl, Parallelität und Dauer der Requests, und jeder Abruf wiegt umso schwerer, je größer die Liste ist (mehr dazu in Teil 1 zum Lastmanagement).

Delta-Sync: nur holen, was sich geändert hat

Jede weclapp-Entität trägt das schreibgeschützte Feld lastModifiedDate, einen Zeitstempel in Epoch-Millisekunden. Mit dem Operator ge fragst du alles ab, was seit einem Zeitpunkt angefasst wurde:

GET https://<dein-mandant>.weclapp.com/webapp/api/v2/article?lastModifiedDate-ge=1783634400000&properties=id,articleNumber,name,lastModifiedDate&pageSize=1000
AuthenticationToken: <dein-token>
Accept: application/json

Die Parameterwerte stehen hier unkodiert, damit man sie lesen kann; dein HTTP-Client kodiert sie beim Senden. properties und pageSize=1000 halten den Request schmal (Standard sind 100 Treffer, in der Regel maximal 1000). Damit steht der Kern. Ob dein Delta vollständig ist, entscheidet sich aber an zwei Stellen: am Cursor und am Blättern.

Der Cursor: Startzeit statt Abschlusszeit

Der naheliegende Cursor ist die Abschlusszeit des letzten Laufs. Er verliert Änderungen, die während des Laufs passieren:

11:00  Lauf startet, Cursor aus dem letzten Lauf: 10:00
11:01  Datensatz A wird gelesen
11:02  Datensatz A wird geändert
11:04  Lauf endet, neuer Cursor = 11:04 (Abschlusszeit)
11:05  Nächster Lauf fragt lastModifiedDate-ge=11:04 und A (11:02) fehlt

Robuster ist ein Fenster, dessen Obergrenze die Startzeit des Laufs ist. Der Lauf verarbeitet [Cursor, Start) und setzt den Cursor erst danach auf die Startzeit, und nur nach vollständigem Erfolg. Die Regeln dazu:

  • ge ist inklusiv. Damit bleiben Änderungen auf demselben Millisekundenwert im Fenster.
  • Ein Teilfehler lässt den Cursor stehen. Der nächste Lauf wiederholt das Fenster.
  • Dein Handler arbeitet idempotent (Upsert statt Insert), denn bewusste Überlappung erzeugt Dubletten. Ein paar Minuten Überlappung fangen kleine Uhrabweichungen ab und Datensätze, die in Listen kurz verspätet sichtbar werden.
  • Alternativ nimmst du als Cursor das Maximum von lastModifiedDate der verarbeiteten Datensätze. Dann kommt der Wert vom weclapp-Server, und deine Uhr spielt keine Rolle.
  • Reiche den Cursor als reine Zahl durch, ohne Umweg über Datumsobjekte (siehe Teil 9 zu den Datentypen).

lastModifiedDate zählt die Millisekunden seit dem 1.1.1970 in UTC. Dein Cursor und Date.now() sprechen also dieselbe Sprache.

Blättern, ohne Datensätze zu verlieren

page und pageSize sind Offset-Paginierung, ein Snapshot entsteht dabei nicht. Das reicht für ruhige Bestände. In einem Bestand, der sich während des Laufs bewegt, entsteht eine Lücke: Du hast Seite 1 gelesen, ein Datensatz davon wird geändert und wandert ans Ende der Sortierung, alle Folgedatensätze rücken um eins vor, und der erste von Seite 2 rutscht auf Seite 1. Er wird nie gelesen.

Robuster ist Blättern nach ID mit festem Fenster. Du sortierst nach id und fragst die jeweils nächste Seite mit id-gt=<letzte ID>. Die Obergrenze lastModifiedDate-lt hält das Fenster stabil. Datensätze, die während des Laufs geändert werden, landen im nächsten Fenster.

const OVERLAP_MS = 5 * 60 * 1000;   // Beispielwert, passe ihn an deine Latenz an
const PAGE_SIZE = 1000;

async function deltaRun(store, weclapp, handle) {
  const { cursor } = await store.load();        // Ende des letzten erfolgreichen Fensters
  const windowStart = cursor - OVERLAP_MS;
  const windowEnd = Date.now();                 // Startzeit dieses Laufs, nicht die Abschlusszeit
  let lastId;

  for (;;) {
    const params = {
      'lastModifiedDate-ge': windowStart,
      'lastModifiedDate-lt': windowEnd,
      sort: 'id',
      pageSize: PAGE_SIZE,
      properties: 'id,articleNumber,name,lastModifiedDate',
    };
    if (lastId) params['id-gt'] = lastId;

    const { result } = await weclapp.get('/article', params);
    for (const row of result) await handle(row);   // idempotent
    if (result.length < PAGE_SIZE) break;
    lastId = result[result.length - 1].id;
  }
  await store.save({ cursor: windowEnd });      // erst nach vollständigem Erfolg
}

Das Keyset-Blättern über id-gt setzen wir in wals.pro AI 4 weclapp für Auswertungen ein. Unbekannte oder nicht filterbare Properties ignoriert weclapp ohne Fehlermeldung. Prüfe deshalb bei jedem neuen Filter die Trefferzahl.

Ein Delta kennt keine Löschungen

Gelöschte Datensätze tauchen in keinem Delta auf. Du hast zwei Wege. Der erste ist ein atDelete-Webhook (nächster Abschnitt). Der zweite ist ein gelegentlicher Vollabgleich der ID-Liste:

GET https://<dein-mandant>.weclapp.com/webapp/api/v2/article?properties=id&sort=id&pageSize=1000
AuthenticationToken: <dein-token>

Lokal löschst du dabei nur, wenn jede Seite fehlerfrei gelesen wurde. Bricht eine Seite ab, löschst du nichts. Sicherheitsrelevante Pfade lesen den Datensatz vor dem Handeln einzeln nach (GET /article/id/{id}), statt dem Cache zu vertrauen. So machen wir es in Fulfillment 4 weclapp: Der Artikelkatalog kommt stündlich als Delta in einen Cache mit 90 Minuten Gültigkeit.

Webhooks: weclapp ruft dich an

Statt zu fragen, lässt du dich benachrichtigen. In der Oberfläche richtest du das unter Globale Einstellungen > Integration > Webhooks ein. Per API ist webhook eine normale Entität. Anlegen geht so:

POST https://<dein-mandant>.weclapp.com/webapp/api/v2/webhook
AuthenticationToken: <dein-token>
Content-Type: application/json
{
  "entityName": "shipment",
  "atCreate": true,
  "atUpdate": true,
  "atDelete": false,
  "requestMethod": "POST",
  "url": "https://hooks.example.com/weclapp/<langes-zufaelliges-geheimnis>"
}

Pflichtfelder sind entityName, atCreate, atUpdate, atDelete, requestMethod (GET oder POST) und url. Die Antwort ist 201 mit dem angelegten Webhook samt id. Dazu gibt es GET /webhook, GET /webhook/id/{id}, PUT und DELETE. Die Rechte heißen entity-webhook-read, -update und -delete.

Das Modell ist bewusst klein: eine Entität, drei Schalter für Anlegen, Ändern und Löschen, genau eine Ziel-URL. Mehrere Empfänger bekommen je einen eigenen Webhook.

Was ankommt: ein Anstupsen, kein Datensatz

Der Webhook liefert nur drei Felder:

{
  "entityId": "4711",
  "entityName": "shipment",
  "type": "UPDATE"
}

Mögliche Werte für type sind CREATE, UPDATE und DELETE. Du erfährst, dass etwas passiert ist, aber nicht was. Deshalb folgt immer ein Abruf:

GET https://<dein-mandant>.weclapp.com/webapp/api/v2/shipment/id/4711?properties=id,shipmentNumber,status,lastModifiedDate
AuthenticationToken: <dein-token>

Behandle den Payload nie als Wahrheit. Der Datensatz kann sich bis zu deinem Abruf schon wieder geändert haben, und bei DELETE gibt es nichts mehr zu lesen. Wenn du einen Flow löschst, bleibt seine Webhook-Registrierung in weclapp stehen und zeigt auf eine Karteileiche. Räume sie mit DELETE /webhook/id/{id} ab. Beim Umzug von einem System auf ein anderes, etwa von Power Automate auf n8n, hängst du die URL um, deaktivierst das alte System, beobachtest ein paar Werktage und löschst dann das alte System samt Webhook-Eintrag. Nie hängen zwei Systeme gleichzeitig am selben Webhook, sonst entstehen doppelte Nachrichten, zum Beispiel an einen Marktplatz.

Retry und Deaktivierung

Ist dein Endpunkt nicht erreichbar, versucht weclapp es nach 1, 5 und 30 Minuten sowie nach 5 Stunden und 1 Tag erneut. Ist er danach immer noch nicht erreichbar, wird der Webhook deaktiviert, und in der Webhook-Liste steht eine Fehlermeldung.

Daraus folgen zwei Regeln. Erstens quittierst du schnell mit 200 und arbeitest außerhalb des Requests weiter. Zweitens überwachst du deine Webhooks. Das Schema kennt dafür deactivatedDate und errorMessage:

GET https://<dein-mandant>.weclapp.com/webapp/api/v2/webhook?properties=id,entityName,url,deactivatedDate,errorMessage
AuthenticationToken: <dein-token>

Ein Webhook mit gesetztem deactivatedDate löst einen Alarm aus. Das Fallback-Netz weiter unten fängt in der Zwischenzeit auf, was dir entgangen ist.

Absichern ohne Signatur

Eine Signaturprüfung wie bei anderen Plattformen bieten weclapp-Webhooks nicht, es gibt kein Feld für ein Geheimnis oder einen Signatur-Header. Das lässt sich mit einfachen Mitteln auffangen:

  1. Geheimnis im URL-Pfad: lang, zufällig, nur über HTTPS. Bei falschem Pfad antwortet dein Endpunkt mit 404.
  2. Die URL wie ein Passwort behandeln: Wer GET /webhook lesen darf, sieht sie.
  3. Dem Payload misstrauen: Der Folgeabruf läuft mit deinem eigenen Token. Ein gefälschtes Event kostet im schlimmsten Fall einen überflüssigen Lesezugriff.
const { timingSafeEqual } = require('node:crypto');
const SECRET = Buffer.from(process.env.HOOK_SECRET);

function receiver(queue) {
  return (req, res) => {
    const given = Buffer.from(req.params.secret || '');
    const ok = given.length === SECRET.length && timingSafeEqual(given, SECRET);
    if (!ok) return res.sendStatus(404);   // verrät nicht, dass der Pfad existiert
    res.sendStatus(200);                   // sofort quittieren
    queue.push(req.body);                  // { entityId, entityName, type }
  };
}

In n8n erledigst du dasselbe mit einem Webhook-Node, dessen Pfad das Geheimnis trägt und der sofort antwortet.

Webhook-Stürme und Endlosschleifen

Für weclapp ist ein Geschäftsvorgang selten ein einzelnes Ereignis. Im Versandhandel kann ein einzelner Auftrag innerhalb weniger Sekunden ein CREATE und drei UPDATE auslösen. Startet jedes Event einen eigenen Flow-Lauf, sehen die Läufe unterschiedliche Zwischenzustände und scheitern an Race-Fehlern wie „create standard sales invoice is not possible“.

Dazu kommt ein zweiter Effekt: Deine eigenen Writes lösen Events aus. Legt dein Flow eine Lieferung an oder setzt einen Status, ändert sich der Auftrag, weclapp feuert den nächsten Webhook, und dein Flow läuft wieder:

Auftrag geändert --Webhook--> dein Flow
      ^                          |
      '---- PUT Status / createShipment ----'

Plane das von Beginn an ein. Eine Wartezeit von ein paar Sekunden hilft als Notbehelf, löst das Problem aber nicht. Was es wirklich löst, zeigen die folgenden fünf Muster.

Fünf Muster, die Stürme beherrschbar machen

1. Quittieren, dann arbeiten. Der Endpunkt nimmt nur an und legt das Event in eine Queue. Queues liefern in der Regel mindestens einmal aus, also kommen Dubletten vor. In 4Fulfillment geht jedes Event über eine Queue mit Dead-Letter-Topic nach fünf Zustellversuchen, und die Parallelität ist je Handler festgelegt: zehn parallele Läufe für die Label-Erzeugung, genau einer für den Artikelsync.

2. Früh aussteigen. Der Handler liest die Entität frisch und prüft Status und Typ, bevor er irgendetwas tut. Verzweigungen steuerst du über Felder des frisch gelesenen Datensatzes, nicht über das Ergebnis übersprungener Schritte.

3. Zustand am Datensatz speichern. Ein Zusatzfeld vom Typ Checkbox („Label erstellt“, „Label gedruckt“) sagt dem nächsten Lauf, was schon erledigt ist. Setzt ein:e Anwender:in das Häkchen zurück, verarbeitet der nächste Webhook den Vorgang neu, ganz ohne Code. Wie du Zusatzfelder sauber liest und schreibst, steht in Teil 9 zu den Datentypen.

4. Lease-Lock mit Double-Check. Pro Entität gibt es einen Lock mit Ablaufzeit (bei uns fünf Minuten). Nach dem Erwerb liest der Handler noch einmal, denn zwischen erster Prüfung und Lock kann ein anderer Lauf fertig geworden sein. Freigegeben wird im finally.

async function handleEvent(evt, { weclapp, locks, allowedStatus }) {
  const { entityId, type } = evt;
  if (type === 'DELETE') return;                    // nichts mehr zu lesen

  const read = () => weclapp.get(`/shipment/id/${entityId}`);
  const isDone = (s) => s.customAttributes?.some(
    (a) => String(a.attributeDefinitionId) === DONE_FLAG_ID && a.booleanValue);

  const shipment = await read();                    // Payload nie als Wahrheit
  if (!allowedStatus.includes(shipment.status) || isDone(shipment)) return;

  const lock = await locks.acquire(`shipment:${entityId}`, { leaseMs: 5 * 60_000 });
  if (!lock) return;                                // eine andere Instanz arbeitet daran
  try {
    const fresh = await read();                     // Double-Check nach dem Lock
    if (isDone(fresh)) return;
    await doWork(fresh);
    await weclapp.put(`/shipment/id/${entityId}?ignoreMissingProperties=true`, {
      customAttributes: [{ attributeDefinitionId: DONE_FLAG_ID, booleanValue: true }],
    });
  } finally {
    await lock.release();
  }
}

Bei einem Fehler schreibst du nur das Teilergebnis zurück (nur Erfolgreiches) und protokollierst den Fehler als Kommentar am Beleg, damit Anwender:innen sehen, was passiert ist.

5. Idempotenz ab dem ersten Entwurf. Bevor dein Flow etwas anlegt, prüft er, ob es das schon gibt:

GET https://<dein-mandant>.weclapp.com/webapp/api/v2/shipment?mainSalesOrderId-eq=<auftrags-id>&properties=id,status
AuthenticationToken: <dein-token>

Prüfe die Trefferzahl einmal per /count: Einen Filter, den weclapp nicht kennt, ignoriert die API still und liefert dann alle Lieferungen.

Für Fälle, in denen du Läufe gezielt bremsen willst, gibt es drei Hebel mit jeweils einem Preis:

Hebel Gewinn Preis
Trigger-Concurrency 1 Läufe laufen nacheinander Durchsatz sinkt, Umkehr nur per Redeploy
Dedup-Gate über lastModifiedDate oder version Dubletten fallen weg Zu grob, eine echte Folgeänderung wird verworfen
„Schon passiert“ gilt als Erfolg Race-Fehler verschwinden Echte Fehler werden maskiert

Idempotenz ist die Lösung, die ohne diesen Preis auskommt.

Fallback: Webhook als Auslöser, Delta als Sicherheitsnetz

Webhooks können verloren gehen. Dein Endpunkt ist länger als einen Tag weg, ein Deployment hängt, der Webhook wurde deaktiviert. Deshalb bekommt jede Webhook-Integration ein Netz: einen geplanten Lauf, der mit dem Delta aus dem ersten Teil des Artikels alles findet, was die Webhooks nicht erledigt haben. Die Verarbeitung ist dieselbe. Der Webhook ruft den Handler mit einer ID auf, der Schedule mit allen IDs des Deltas. Dank Statusfilter, Zustandsflag und Lock ist doppelte Verarbeitung harmlos. Ein Takt von zum Beispiel 15 Minuten ist ein Ausgangswert, den du an deinen Prozess anpasst.

Grafik: Webhook als Auslöser, Delta als Netz. Webhook, Ausfall und Schedule-Lauf münden in denselben idempotenten Handler. Was der Webhook verpasst, findet der nächste Delta-Lauf.

Ein leeres Ergebnis ist kein Erfolg

Poll-und-Filter-Flows scheitern gern lautlos. Eine Abfrage ohne Treffer meldet Erfolg, auch wenn die Vorstufe aufgehört hat, Datensätze zu markieren. Dann bleiben Aufträge liegen, während n8n keinen einzigen Fehler zeigt. Genauso tückisch: Steht an einem entscheidenden weclapp-Write-Node „bei Fehler weitermachen“, feuert der Error-Workflow nie. Prüfe deshalb bei jedem bestehenden Flow, wo diese Option gesetzt ist.

Die Gegenmaßnahme ist eine Erwartungsprüfung statt nur Fehlerbehandlung: „Seit zwei Stunden keine Treffer verarbeitet, obwohl Kandidaten offen sind.“

GET https://<dein-mandant>.weclapp.com/webapp/api/v2/shipment/count?status-eq=DELIVERY_NOTE_PRINTED&warehouseId-eq=<deine-lager-id>
AuthenticationToken: <dein-token>

Liefert count offene Kandidaten, aber seit Stunden hat kein Lauf etwas verarbeitet, schlägt der Alarm an. Entprelle die Alarme: Fingerprint plus Datum, eine Mail pro Fehler und Tag. Ein Dauerfehler im 15-Minuten-Takt sind sonst 96 Mails am Tag.

Webhook oder Schedule?

Beides hat seinen Platz:

Kriterium Webhook Schedule mit Delta
Reaktionszeit Sekunden Takt des Schedules
Typischer Einsatz Einzelne Geschäftsereignisse (Versand auslösen, Benachrichtigung) Stammdaten-Sync, Preise, Berichte
Volumen Gering bis mittel Hoch, auch Massenimporte
Löschungen atDelete Nur über Vollabgleich der IDs
Eigene Writes Erzeugen Folge-Events Keine Rückkopplung
Ausfall Events können fehlen Holt beim nächsten Lauf nach

Bei hohem Volumen ist der Schedule die ruhigere Wahl, weil jedes Event sonst einen eigenen Lauf mit Folgeabruf startet. Vor Massenänderungen oder Backfills pausierst du die Webhook-Verarbeitung und den Fallback-Lauf, damit dein eigener Flow nicht in die Änderungswelle hineinläuft.

Checkliste

  • Kein ungefiltertes Intervall-Polling: Listen holst du mit lastModifiedDate-ge und properties.
  • Cursor = Startzeit des Laufs (oder Maximum von lastModifiedDate), nie die Abschlusszeit. Der Cursor rückt erst nach vollständigem Erfolg vor.
  • Überlappung eingeplant, Verarbeitung idempotent.
  • Blättern nach id-gt bei großen Deltas, Trefferzahl per count gegengeprüft.
  • Löschungen über atDelete oder über einen vollständig gelesenen ID-Abgleich abgedeckt.
  • Webhook-Endpunkt: Geheimnis im Pfad, sofort 200, Verarbeitung dahinter in einer Queue.
  • Handler liest die Entität frisch, steigt früh aus, schützt sich mit Zustandsflag und Lease-Lock mit Double-Check.
  • Eigene Writes lösen keine Endlosschleife aus, weil der nächste Lauf nichts mehr findet, was zu tun ist.
  • Fallback-Schedule mit Delta vorhanden, Erwartungsprüfung und entprellter Alarm eingerichtet.
  • deactivatedDate deiner Webhooks wird überwacht, Karteileichen werden abgeräumt.

Weiterlesen und Ausprobieren

Dieser Artikel gehört zur Serie „weclapp API: Das Handbuch“. Weiter geht es mit Teil 1 zum Lastmanagement und Teil 9 zu den Datentypen. Die Endpunkte aus diesem Artikel kannst du in unserer kostenlosen Postman Collection 4 weclapp gegen einen Testmandanten ausprobieren. Eine Stunde Grundlagen zu Token, Filtern und Entitäten gibt es im Webinar „weclapp API Deep Dive“ auf YouTube. Und wenn du eine Integration stabilisieren willst, sprechen wir im Vorgespräch darüber: wals.pro/termin