Entwicklerdoku

Webhooks

Ereignisse der eigenen Teamdaten signiert an eine eigene URL zugestellt — ohne zu pollen. Verwaltung über die Oberfläche (Team → Schnittstellen → Webhooks) oder über die API (Scope webhooks:manage, siehe Referenz).

Ereignisse

Ein Endpunkt abonniert eine Auswahl dieser Typen beim Anlegen:

TypAuslöser
training.completedTrainingssitzung beendet
evaluation.completedBewertung gespeichert
assignment.createdZuweisung angelegt
assignment.completedZuweisung abgeschlossen
member.joinedMitgliedschaft begonnen
member.removedMitgliedschaft beendet
crm.sync.failedCRM-Abgleich fehlgeschlagen
pingnur über „Testereignis senden“ — kein abonnierbarer Typ

assignment.created/assignment.completed feuern heute nur, wenn die Zuweisung über diese API selbst angelegt oder geändert wird — eine Zuweisung, die ein Team-Admin über die Oberfläche anlegt, löst noch kein Webhook-Ereignis aus.

Körper

Jedes Ereignis trägt dieselbe Hülle; data ist je Typ verschieden und enthält Kennungen und eine Zusammenfassung. Punktzahl und user_id stehen nur drin, wenn member_detail_enabled zum Zeitpunkt des Ereignisses an war — mehr holt der Empfänger bei Bedarf über die API.

{
  "id": "evt_7f3c1a2b",
  "type": "evaluation.completed",
  "created_at": "2026-10-14T08:42:10Z",
  "api_version": "2026-10",
  "tenant_id": "6a9c3e3e-9b8e-4b7b-9e9d-2b7b1a2c3d4e",
  "data": {
    "team_id": "3c2b1a0f-5678-4a3b-8c9d-0e1f2a3b4c5d",
    "training_id": "7f3cfe1a-1111-4b22-9c33-abcdef012345",
    "user_id": "1b9e2b3c-4d5e-6f70-8192-a3b4c5d6e7f8",
    "score": 74
  }
}

Signatur prüfen

Jede Zustellung trägt den Kopf Sip-Signature: t=<Unix-Zeit>,v1=<hex> und zur Duplikaterkennung Sip-Event-Id. v1 ist HMAC-SHA256(geheimnis, "<t>.<roher Körper>") — das Geheimnis wird nur bei POST /webhook-endpoints einmal angezeigt. Verwirf Zeitstempel, die älter als fünf Minuten sind, und vergleiche zeitkonstant (hmac.compare_digest bzw. das Äquivalent deiner Sprache) — nie mit ==.

import hashlib
import hmac
import time

def pruefen(geheimnis: str, kopf: str, koerper: bytes, *, max_alter_s: int = 300) -> bool:
    teile = dict(t.split("=", 1) for t in kopf.split(","))
    zeitstempel, empfangen = int(teile["t"]), teile["v1"]
    if abs(time.time() - zeitstempel) > max_alter_s:
        return False
    nachricht = f"{zeitstempel}.".encode() + koerper
    erwartet = hmac.new(geheimnis.encode(), nachricht, hashlib.sha256).hexdigest()
    return hmac.compare_digest(erwartet, empfangen)
const crypto = require("crypto");

function pruefen(geheimnis, kopf, koerper, maxAlterS = 300) {
  const teile = Object.fromEntries(kopf.split(",").map((t) => t.split("=")));
  const zeitstempel = parseInt(teile.t, 10);
  if (Math.abs(Date.now() / 1000 - zeitstempel) > maxAlterS) return false;
  const nachricht = Buffer.concat([Buffer.from(`${zeitstempel}.`), koerper]);
  const erwartet = Buffer.from(
    crypto.createHmac("sha256", geheimnis).update(nachricht).digest("hex"));
  const empfangen = Buffer.from(teile.v1 || "");
  // timingSafeEqual wirft bei unterschiedlicher Laenge (RangeError) statt false zu liefern —
  // eine falsch lange Signatur muss VORHER abgefangen werden.
  if (empfangen.length !== erwartet.length) return false;
  return crypto.timingSafeEqual(erwartet, empfangen);
}
<?php
function pruefen(string $geheimnis, string $kopf, string $koerper, int $maxAlterS = 300): bool {
    parse_str(str_replace(",", "&", $kopf), $teile);
    $zeitstempel = (int) $teile["t"];
    if (abs(time() - $zeitstempel) > $maxAlterS) {
        return false;
    }
    $erwartet = hash_hmac("sha256", "{$zeitstempel}." . $koerper, $geheimnis);
    return hash_equals($erwartet, $teile["v1"]);
}
Geheimnis erneuern: Es gibt keine Rotation mit Überlappung für Webhook-Geheimnisse (anders als bei Schlüsseln). Lege einen neuen Endpunkt mit denselben Ereignissen an, stelle die eigene Gegenseite auf dessen Geheimnis um und lösche danach den alten Endpunkt.

Zustellung und Wiederholung

Mindestens einmal zugestellt, Reihenfolge nicht garantiert; kein Folgen von Weiterleitungen, Antwortkörper nach 1000 Zeichen abgeschnitten, 10 Sekunden Zeitlimit. Jeder Fehlschlag (alles außer Status 2xx) wird wiederholt nach 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h (sieben Versuche, rund 45 Stunden), danach gave_up. Blieb ein Endpunkt drei Tage lang ohne eine einzige erfolgreiche Zustellung, pausiert ihn sip.coach (disabled_failures) und mailt jeden Team-Administrator; ein Testereignis startet ihn nicht automatisch wieder.

Das Ziel muss HTTPS sein und öffentlich erreichbar — eine private oder link-lokale Adresse wird beim Anlegen und vor jedem Versand abgelehnt.

Endpunkte verwalten

Anlegen, ändern, löschen, ein Testereignis senden und eine fehlgeschlagene Zustellung sofort erneut versuchen — über die Oberfläche oder per API mit dem Scope webhooks:manage. Jede dieser Aktionen protokolliert sip.coach, gleich auf welchem Weg sie ausgelöst wurde.

# Testereignis anstoßen (Verwaltungsschlüssel mit Scope webhooks:manage):
curl -X POST https://app.sip.coach/api/public/v1/webhook-endpoints/<id>/test \
  -H "Authorization: Bearer sipk_live_ab12cd34_…"

Jede Operation samt Parametern steht in der Referenz.