Zum Inhalt springen
Anmelden
Docs/Integrationen

Webhooks

Mit Webhooks halten wir Ihre eigenen Systeme auf dem Laufenden. Sobald in Ihrer Organisation etwas passiert, senden wir eine Nachricht an eine Adresse Ihrer Wahl. Kein regelmäßiges Abfragen, kein nächtlicher Export: Jemand tritt bei, schließt einen Kurs ab, und Ihr HR-System weiß es Sekunden später.

Endpunkte verwalten Sie unter Organisation, Webhooks. Diese Seite erklärt, was wir senden, und vor allem, wie Sie prüfen, dass eine Nachricht wirklich von uns kommt.

Einen Endpunkt hinzufügen

Öffnen Sie Organisation, Webhooks und wählen Sie Webhook hinzufügen. Sie geben uns drei Dinge.

  • URL: die Adresse, an die wir senden. Sie muss https sein und aus dem Internet erreichbar.
  • Name: für Ihre eigene Übersicht, zum Beispiel "HR-Sync".
  • Ereignisse: worüber Sie informiert werden möchten. Wählen Sie mindestens eines.

Beim Speichern zeigen wir Ihnen einen Signaturschlüssel. Das ist das einzige Mal, dass wir ihn anzeigen können, kopieren Sie ihn also, bevor Sie das Fenster schließen. Wir bewahren selbst keine Kopie auf.

Warnung

Bewahren Sie den Schlüssel auf wie ein Passwort: in Ihrem Secret Manager oder in Ihren Umgebungsvariablen, niemals im Quellcode.

Was wir nicht akzeptieren

Wir lehnen Adressen ab, über die ein Webhook in ein internes Netzwerk greifen könnte.

  • http-Adressen. Nur https.
  • Adressen mit Benutzername oder Passwort darin.
  • Ein anderer Port als der Standardport.

Wir folgen auch keinen Weiterleitungen. Registrieren Sie die endgültige Adresse; eine 3xx-Antwort gilt als fehlgeschlagene Zustellung.

Wie eine Zustellung aussieht

Jede Zustellung ist ein POST mit demselben Umschlag. Nur data unterscheidet sich je nach Ereignisart.

{
  "id": "3f2b9c14-0d51-4a7e-9a1b-8c2d6f0e4b77",
  "type": "course_progress.completed",
  "createdAt": "2026-08-29T13:45:02+00:00",
  "organizationId": 42,
  "data": { }
}

Dazu senden wir diese Header.

HeaderBedeutung
Content-TypeImmer application/json
User-AgentZunderwork-Webhooks/1
X-Zunderwork-EventDie Ereignisart, wie type im Body
X-Zunderwork-DeliveryDie Zustellnummer, wie id im Body
X-Zunderwork-SignatureDie Signatur, unten erklärt

Antworten Sie mit einem 2xx, um den Empfang zu bestätigen. Antworten Sie schnell: Nach zehn Sekunden brechen wir ab. Wenn echte Arbeit ansteht, legen Sie sie zuerst auf Ihre eigene Warteschlange und bestätigen Sie sofort.

Einen eingehenden Webhook validieren

Ihr Endpunkt ist eine öffentliche Adresse. Jeder, der sie findet, kann dorthin senden. Bevor Sie einer Nachricht vertrauen, müssen Sie also feststellen, dass sie von uns stammt. Dafür ist der Schlüssel da.

Jede Zustellung trägt einen Signatur-Header:

X-Zunderwork-Signature: t=1787925523,v1=c3044dcc4c74f78b530bc4c8a3bac4f65e04fb6…

t ist der Zeitpunkt der Signatur als Unix-Zeitstempel. v1 ist ein HMAC-SHA256 über die Zeichenkette {t}.{roher Request-Body}, mit Ihrem Signaturschlüssel als Schlüssel. Sie erzeugen diesen Hash auf Ihrer Seite erneut und vergleichen.

Zwei Details entscheiden, ob das funktioniert.

  1. Verwenden Sie den rohen Request-Body, genau so, wie er ankam, vor jedem JSON-Parsen. Wer parst und danach neu kodiert, verändert die Bytes, und die Signatur passt nie.
  2. Prüfen Sie das Alter von t und lehnen Sie alles ab, was älter als etwa fünf Minuten ist. Genau dafür steht der Zeitstempel in der signierten Zeichenkette: Ohne diese Prüfung kann jemand, der eine Zustellung abfängt, sie endlos bei Ihnen wiederholen.

Vergleichen Sie die beiden Hashes mit einer laufzeitsicheren Funktion, hmac.compare_digest oder hash_equals, nicht mit ==.

import hmac
import hashlib
import time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    timestamp, signature = parts["t"], parts["v1"]

    if abs(time.time() - int(timestamp)) > tolerance:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, signature)

Tipp

Die meisten Frameworks liefern standardmäßig einen geparsten Body. Achten Sie darauf, stattdessen den rohen Body zu lesen.

Den Schlüssel rotieren

Sie können einen Schlüssel jederzeit auf der Seite des Webhooks unter Signaturschlüssel ersetzen. Tun Sie das, wenn der alte durchgesickert ist, wenn jemand mit Zugriff das Unternehmen verlässt, oder einfach regelmäßig.

Das Rotieren wirkt sofort, eine Übergangszeit gibt es nicht. Der alte Schlüssel gilt in dem Moment nicht mehr, in dem Sie bestätigen, und bis Ihr Empfänger den neuen verwendet, scheitert jede Zustellung an Ihrer eigenen Signaturprüfung. Rotieren Sie also dann, wenn Sie den neuen Schlüssel ausrollen können, und halten Sie den neuen Wert bereit: Wie beim ersten zeigen wir ihn einmalig und können ihn danach nicht erneut anzeigen.

Wiederholungen, und wann wir aufhören

Eine fehlgeschlagene Zustellung wiederholen wir bis zu fünfmal mit wachsenden Abständen: etwa 10 Sekunden, 40 Sekunden, 2 Minuten, 10 Minuten, 45 Minuten.

Ihre AntwortWas wir tun
2xxErfolg. Der Fehlerzähler geht auf null zurück.
408, 429, 5xx, ein Timeout, ein Verbindungs- oder TLS-FehlerWir wiederholen nach obigem Zeitplan.
Jede andere 4xxWir wiederholen nicht. Sie haben die Anfrage verstanden und Nein gesagt; aus einer 404 wird beim dritten Versuch keine 200.
3xxGilt als Fehlschlag. Wir folgen keinen Weiterleitungen.

Nach 30 fehlgeschlagenen Versuchen in Folge schalten wir den Endpunkt ab und senden nichts mehr. Sie sehen das auf der Seite des Webhooks, mit dem letzten Fehler, den wir erhalten haben. Beim Wiedereinschalten wird auch der Fehlerzähler zurückgesetzt.

Fehler verfallen: Vergehen mehr als 20 Stunden ohne Fehler, beginnt der nächste wieder bei eins. Ein Endpunkt, der einmal am Tag stockt, schaltet sich also nie selbst ab.

Hinweis

Wir führen bewusst keine Zustellhistorie und können Ihnen daher nicht sagen, was wir letzten Dienstag gesendet haben. Protokollieren Sie Zustellungen auf Ihrer Seite, anhand von X-Zunderwork-Delivery. Über diese Nummer erkennen Sie auch Doppelungen: Eine Wiederholung verwendet dieselbe Nummer, behandeln Sie eine wiederholte Nummer also als dasselbe Ereignis.

Wofür Sie sich anmelden können

Personen

EreignisWann es ausgelöst wird
organization_user.joinedJemand hat eine Einladung angenommen und ist Mitglied geworden
organization_user.updatedRolle, Daten oder aktiver Status eines Mitglieds haben sich geändert
organization_user.deactivatedEin Mitglied ist nicht mehr aktiv
{
  "member": {
    "id": 812,
    "userId": 5501,
    "email": "[email protected]",
    "firstName": "Sam",
    "lastName": "de Vries",
    "role": "member",
    "enabled": false,
    "createdAt": "2026-03-01T09:12:44+00:00",
    "updatedAt": "2026-08-29T13:44:02+00:00",
    "verifiedAt": "2026-03-01T09:20:10+00:00",
    "startsAt": null
  },
  "reason": "disabled"
}

member.id ist die Mitgliedschaft. member.userId ist die Person, die zu mehr als einer Organisation gehören kann.

reason kommt nur bei organization_user.deactivated vor und ist entweder disabled (die Mitgliedschaft wurde abgeschaltet) oder deleted (die Mitgliedschaft wurde entfernt).

Kurse

course.created, course.updated und course.deleted.

{
  "course": {
    "id": 101,
    "name": "Sicheres Arbeiten in der Höhe",
    "description": "Jährliche Auffrischung",
    "enabled": true,
    "createdAt": "2026-07-02T15:35:15+00:00",
    "updatedAt": "2026-08-29T11:02:00+00:00",
    "availableAt": null,
    "archivedAt": null,
    "tags": [{ "id": 101, "name": "Außendienst" }]
  }
}

Das Archivieren eines Kurses kommt als course.updated mit gefülltem archivedAt an, nicht als Löschung.

Lernfortschritt

course_progress.started, course_progress.lesson_completed, course_progress.completed und course_progress.all_completed.

Sie teilen dieselbe Form. Die Objekte course und member sind identisch mit den obigen, ein Mapper pro Entität deckt also alles ab.

{
  "progress": {
    "id": 9001,
    "startedAt": "2026-08-29T10:00:00+00:00",
    "finishedAt": "2026-08-29T13:45:00+00:00"
  },
  "course": { "id": 101, "name": "Sicheres Arbeiten in der Höhe" },
  "member": { "id": 812, "email": "[email protected]" }
}

progress.finishedAt ist leer, bis der Kurs abgeschlossen ist, also bei started und bei lesson_completed. Bei lesson_completed kommt ein lesson-Objekt hinzu:

"lesson": { "id": 55, "name": "Gurtkontrolle", "completedLessons": 3, "totalLessons": 7 }

Vier Dinge zum Fortschritt.

  • Eine Vorschau löst nie ein Ereignis aus. Eine Führungskraft, die sich einen Kurs ansieht, ist keine Person, die ihn beginnt.
  • all_completed kommt neben completed. War der gerade abgeschlossene Kurs der letzte offene, kommen beide an, als getrennte Zustellungen.
  • all_completed kann mehrfach ausgelöst werden. Es sagt "im Moment ist nichts mehr offen". Weisen Sie später einen neuen Kurs zu, wird es nach dessen Abschluss erneut ausgelöst. Rechnen Sie damit, es mehrfach zu erhalten.
  • Die Reihenfolge ist nicht garantiert. Jedes Ereignis steht einzeln in der Warteschlange und wird einzeln wiederholt, ein wiederholtes Ereignis kann also nach einem späteren ankommen. Nutzen Sie createdAt, wenn Sie die Reihenfolge brauchen.

"Offen" bedeutet die Kurse, die jemand in der eigenen Liste sieht: aktiviert, nicht archiviert, nach dem Startdatum und mit einem passenden Tag versehen. Ein Kurs, dessen Voraussetzungen noch nicht erfüllt sind, zählt weiterhin als offen.

Mit Änderungen Schritt halten

Wir fügen Dinge ohne Ankündigung hinzu und nehmen nichts weg.

  • Ein vorhandenes Feld wird nicht entfernt, nicht umbenannt und ändert seinen Typ nicht.
  • Die Bedeutung eines Feldes ändert sich nicht.
  • Neue Felder können jederzeit hinzukommen, neue Ereignisarten ebenso.

Bauen Sie Ihren Empfänger also so, dass er unbekannte Felder und Ereignisarten ignoriert, und verlassen Sie sich nicht auf die Reihenfolge der Felder. Ein Empfänger, der bei Unbekanntem abbricht, geht bei einem gewöhnlichen Release kaputt.

Sollte je eine wirklich brechende Änderung nötig sein, ändern wir kein bestehendes Ereignis. Dann kommt ein neues hinzu, wir senden eine Zeit lang beide, informieren die angemeldeten Organisationen und stellen erst danach das alte ein.

Auf dieser Seite