Webhooks#
Statt abzufragen, abonnierst du und lässt die Änderungen zu dir kommen.
Registrier einen Endpunkt auf der Ressource webhook_endpoints — sie braucht webhook_endpoints.write; die genauen Routen stehen in der API-Referenz. Das Anlegen gibt einmalig ein Signatur-Geheimnis (whsec_…) zurück. Speichere es sofort; es lässt sich nicht wiederherstellen.
Ereignisse sind schlank#
Der Body ist bewusst klein:
{
"id": "0194d1c0-…",
"object": "event",
"type": "shift.published",
"created": "2026-07-22T10:04:11.123456+00:00",
"related_object": {
"id": "0194d1c0-…",
"object": "shift",
"url": "/api/public/v1/shifts/0194d1c0-…/"
}
}
related_object.url ist ein Pfad — setz ihn mit dem api_base aus deinem Token zusammen. related_object.object benennt den Ressourcentyp, sodass ein gemischter Handler verzweigen kann, ohne die URL zu zerlegen.
Hol den aktuellen Zustand über diese URL, statt darauf zu vertrauen, dass das Ereignis ihn mitliefert. So bleibst du korrekt, wenn mehrere Änderungen kurz hintereinander eintreffen. Auch ein 404 dort ist eine Antwort — zum Beispiel wurde der Datensatz gelöscht, oder es ist eine nicht veröffentlichte Schicht, die die Ressource shifts nie zurückgibt.
Die Zustellung erfolgt mindestens einmal und möglicherweise ausser der Reihe, dein Handler muss also idempotent sein — entdopple über die id des Ereignisses.
Jede Zustellung prüfen#
Jedes POST trägt einen Webhook-Signature-Header (dazu Webhook-Id):
Webhook-Signature: t=1753178651,v1=5f3a…
So prüfst du:
Berechne
HMAC-SHA256(secret, "{t}.{raw_body}")neu — über die rohen Body-Bytes, vor jedem JSON-Parsen und erneuten Serialisieren.Vergleich sie in konstanter Zeit mit einem
v1=-Wert.Weise ab, wenn
tausserhalb einer Toleranz von ~5 Minuten liegt.
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
expected = hmac.new(
secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256
).hexdigest()
# A rotation sends several v1= values — accept a match against any of them.
signatures = [v for k, v in (p.split("=", 1) for p in header.split(",")) if k == "v1"]
return any(hmac.compare_digest(expected, sig) for sig in signatures)
Wichtig
Während einer Rotation des Geheimnisses trägt der Header mehrere v1=-Signaturen — alte und neue — für ein Überlappungsfenster. Akzeptier eine Übereinstimmung mit irgendeiner davon, sonst gehen bei Rotationen Zustellungen verloren.
Nach einem Ausfall aufholen#
Eine Zustellung gelingt, wenn dein Endpunkt innerhalb von 10 Sekunden mit 2xx antwortet. Eine Zustellung, die mit einem Netzwerkfehler, einem Timeout, einem 408, einem 429 oder einem 5xx scheitert, wird mit exponentiell wachsendem Abstand wiederholt — unter Beachtung des Retry-After eines 429 in Sekunden —, insgesamt bis zu acht Versuche. Jeder andere Status lässt die Zustellung sofort scheitern, und ein 410 deaktiviert zusätzlich den Endpunkt.
Der Verlauf der ausgelieferten Ereignisse bleibt in der schreibgeschützten Ressource webhook_log erhalten (webhook_log.read), und webhook_endpoints/<id>/deliveries/ zeigt Status und Versuche jeder Zustellung. War dein Endpunkt ausgefallen, kannst du aus diesem Verlauf abgleichen, statt die Fachressourcen erneut abzufragen.
Den ganzen Lebenszyklus abonnieren#
Der häufigste Integrationsfehler hier ist, nur den guten Fall zu abonnieren und eine tote Ausschreibung in der eigenen Kopie am Leben zu lassen.
Ereignisse der Schichtbörse kommen paarweise als open_shift.* / swap.* — ein Angebot wird als zwei Ressourcen veröffentlicht (open_shifts/ für Vakanzen, swaps/ für Abgaben und Tausche), und der Ereignistyp sagt dir, welche Sammlung du holen musst. Beide führen die Endübergänge ebenso wie die Vergaben:
open_shift.claimed,open_shift.awarded,swap.approvedopen_shift.rejected,swap.rejected,*.withdrawn,*.expired
Abonnier auch die Endübergänge, nicht nur die Vergaben. Neben einem manuellen Zurückziehen wird *.withdrawn ausgelöst, wenn eine Änderung an der Schicht ein laufendes Angebot vom Markt nimmt — zum Beispiel, wenn sie verschoben, zurückgezogen oder gelöscht wird — oder wenn das Unternehmen die Schichtbörse oder diese Angebotsart ausschaltet. *.expired wird ausgelöst, wenn ein Angebot ausläuft — zum Beispiel, weil seine Frist ohne offene Antwort verstrichen ist oder weil seine Schicht geendet hat, abgesagt wurde oder freigegebene Stunden hat. Hol das Angebot ab, um zu sehen, wie es geendet hat: Sein status ist expired, oder completed, wenn schon jemand ausgewählt wurde. *.auto_rejected wird in einem Abonnement weiterhin akzeptiert, aber nicht mehr gesendet.
Führ die gewünschten Typen in enabled_events des Endpunkts auf, oder lass das Feld leer, um alle Typen zu erhalten. Ein unbekannter Typ ergibt einen 400. Alle Typen, geordnet nach der Ressource, auf die ihr related_object zeigt:
|
Ereignistypen |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
leave_request.withdrawn bedeutet, dass die antragstellende Person ihren eigenen Antrag zurückgezogen hat; leave_request.revoked bedeutet, dass jemand anderes ihn beendet hat.
Wartende Zustellungen werden angehalten, solange ein Endpunkt deaktiviert ist. Ein verspäteter fehlgeschlagener Versuch kann einen erfassten Erfolg nicht ersetzen, und eine 410-Antwort einer alten URL deaktiviert keine Ersatz-URL. Abgeschlossene Zustellungen werden bei einer wiederholten Aufgabe nicht erneut gesendet. Eine stündliche Prüfung sendet alles, was noch aussteht, sobald es fällig ist — nachdem du den Endpunkt wieder aktiviert hast, nachdem der Plan des Unternehmens die API wieder enthält, oder wenn ein wartender Versuch verloren gegangen ist.