Zum Inhalt

Webhooks-Referenz

Diese Seite ist der Vertrag zwischen Expecto und einem Webhook-Konsumenten: welche Ereignisse ausgeliefert werden, wie der Envelope aussieht, wie Signaturen verifiziert werden und wie sich der Empfänger im Fehlerfall verhalten sollte. Wer Endpoints aus der UI heraus einrichtet, beginnt am besten mit dem Org-Admin-Leitfaden und nutzt diese Seite als Spezifikation für den Empfänger.

Expecto folgt der HTTP-Signatur-Konvention von Standard Webhooks: ein versioniertes HMAC-SHA256 über id.timestamp.body, ausgeliefert im Header webhook-signature.

Ereignisse

Session-Lebenszyklus-, Kontext-Abruf-, Kommentar- und Job-Ereignisse plus ein synthetisches webhook.ping. Jedes Ereignis feuert beim Stufenübergang, unmittelbar nachdem Expecto den neuen Zustand committed hat — die Datenbank ist also mit dem im Payload beschriebenen Zustand konsistent.

Type Auslöser
session.created Eine Session wird angelegt.
session.uploaded Eine Datei wird für eine Session hochgeladen. Feuert pro hochgeladener Datei einmal.
session.parsed Alle Pflichtdateien fehlerfrei geparst — das technische Ergebnis. Wartet nicht auf den Kontextabruf; trägt parse_error_count / parse_warning_count.
session.transformed Alle Transformer haben fehlerfrei Ausgabe erzeugt.
session.checked Business-Rules des Projekts liefen fehlerfrei.
session.confirmed Übermittler hat die Daten bestätigt.
session.approved Approver hat freigegeben (nur im Modus dual).
session.exported Export-Paket erzeugt und abgelegt.
session.failed Ein Lauf endete ohne session.checked: eine Stufe ist fehlgeschlagen, oder der Lauf wurde am Kontext-Gate gestoppt — nach dem Parsen, vor der Transformation (Kontextabruf fehlgeschlagen oder Timeout, ungültiger Kontext). Der Payload enthält data.stage, data.reason, data.message und data.phase.
session.context_fetch_started Der automatische Kontext-Abruf wurde bei der Session-Erstellung eingereiht (Projekt hat Integrations-Kontextquellen).
session.context_fetch_completed Der automatische Kontext-Abruf war erfolgreich.
session.context_fetch_failed Der automatische Kontext-Abruf ist fehlgeschlagen (die Pipeline ist bis zum erneuten Lauf blockiert).
session.comment_added Ein Nutzer hat einen Kommentar zu einer Session hinzugefügt.
job.updated Ein asynchroner Job (Pipeline-Lauf / Export / Import) hat den Status geändert. Payload: data.{job_id, job_type, status, session_id}. Nur org-weite Abonnements.
webhook.ping Synthetischer Ping auf Anforderung aus UI / API.

session.approved wird im Projekt-Modus single bewusst übersprungen — auf session.confirmed folgt direkt der Export.

Envelope-Form

Jede Zustellung ist ein einziges JSON-Objekt dieser Form:

{
  "id": "618d0e8d-22c7-4a4a-9cdb-20fc9510921f",
  "type": "session.exported",
  "timestamp": "2026-06-09T13:30:11.859321+00:00",
  "data": {
    "session_id": "7c3a76c0-6a00-44a5-a0ad-9093e6d219e1",
    "organization_id": "6042680b-e742-4628-8b62-3d16c4e6e5a9",
    "project_id": "201569bb-5171-4ad0-bfc7-ab8740d6bf37",
    "data_contract_id": "f46976f3-700d-49c8-979d-e0ffbeb1ff04",
    "data_provider_id": null,
    "period": "2026-06",
    "status": "exported",
    "health": "completed",
    "verdict": "passed",
    "phase": null,
    "parse_error_count": null,
    "parse_warning_count": null,
    "occurred_at": "2026-06-09T13:30:11.859321+00:00",
    "export": {
      "package_name": "Webhook_E2E_demo_2026-06_20260609_133011.zip",
      "artifacts": [
        {
          "filename": "echo.csv",
          "role": "echo",
          "row_count": 3,
          "sha256": "8d8c559fe7f3f9f26919a438bde92fddf03099630559c3eedfd92fce0a7bf3c1",
          "size_bytes": 44
        }
      ]
    }
  }
}
  • id — UUID der Zustellung. Stabil über Retries desselben Versuchs; bei einem Replay neu vergeben. Nutze sie als Idempotency-Key.
  • type — einer der oben genannten Ereignistypen.
  • timestamp — ISO-8601 der logischen Ereigniszeit (Commit in Expecto). Entspricht data.occurred_at.
  • data — ereignisspezifischer Payload. Die oben genannten Identifier- Felder (session_id, organization_id, project_id, data_contract_id, data_provider_id, period, status, health, verdict, phase, parse_error_count, parse_warning_count, occurred_at) sind in jedem Session-Ereignis vorhanden. Drei davon werden leicht verwechselt:

    • status — wie weit die Session in der Pipeline gekommen ist (created … exported).
    • verdict — das Ergebnis der automatischen Prüfungen: passed, warnings, failed oder null, solange es noch keines gibt. Auf dieses Feld sollte eine Integration reagieren: es ist rein ergebnisbezogen und stabil, eine Warnung wird von einer späteren Stufe nie überdeckt.
    • health — der Dashboard-Chip (created, in_progress, blocked, attention, ready, completed). Er mischt Fortschritt und Ergebnis und kann mit der UI wachsen — in Integrationen daher verdict bevorzugen.
    • phase — welche Hälfte des zweiphasigen Ablaufs das Ereignis meldet: technical (Parsen — session.parsed, session.failed mit stage = parse), context (das Kontext-Gate — session.failed mit stage = context_fetch / context_validation), content (alles nach dem Gate — session.transformed, session.checked, session.failed mit stage = transform / validation_gating / validate / check), null bei allen anderen Ereignissen. Auf eine Auswahlliste mappen, wenn das Zielsystem technisches und inhaltliches Ergebnis getrennt führt.
    • parse_error_count / parse_warning_count — Ganzzahlen bei den technical-Ereignissen: Parse-Befunde der Schwere error über alle Dateien (eine gar nicht lesbare Datei zählt als einer) und Befunde der Schwere warning; null bei allen anderen Ereignissen.

Typspezifisch: - session.exported.data.export.{package_name, artifacts} — Paketmetadaten. artifacts[*] mit filename, role, sha256, row_count, size_bytes. Keine Download-URL — das Paket wird über die authentifizierte API unter GET /api/sessions/{session_id}/export/download mit deinem API-Key geholt. - session.failed.data.{stage, reason, message} — stage ist context_validation, context_fetch, parse, transform, validate, validation_gating oder check; reason ist ein kurzer Fehlercode (z. B. context_invalid, context_fetch_failed, context_fetch_timeout, parse_error, transform_error, system_error, blocking_violations, check_error); message ist eine lesbare Erklärung oder null. validation_gating / blocking_violations heißt: die Entity-Validierung hat Verstöße auf oder über der Blocking-Schwere des Projekts gefunden, der Lauf endete vor der Business-Rule-Prüfung — die Daten sind durchgefallen, verdict ist failed. context_fetch / context_validation heißt: der Lauf wurde am Kontext-Gate gestoppt — nach dem Parsen, vor der Transformation: der automatische Kontextabruf ist fehlgeschlagen (oder beim Warten in das Timeout gelaufen), oder der zusammengeführte Sitzungskontext passt bei strenger Validierung nicht zum Schema des Projekts. Das Parsen lief bereits (session.parsed kam vorher), danach wurde nichts ausgeführt, es gibt keine inhaltlichen Befunde zum Abrufen und verdict ist null, sofern das Parsen keine Warnungen gemeldet hat; message nennt die fehlgeschlagenen Kontextquellen und ihre Fehler (z. B. Halted: context fetch is failed (sap_prev_balance: BuiltAPI rejected the connection credentials (401))). Ein ausstehender Abruf erzeugt kein Ereignis mehr: der Lauf wartet darauf und läuft von selbst weiter. Jeder Weg, auf dem ein Lauf ohne session.checked endet, erzeugt ein session.failed; ein Abonnent muss einen Abbruch nie aus dem Schweigen erraten. - webhook.ping.data.{sent_at, sent_by_user_id} — für Konnektivitäts-Tests.

HTTP-Header

Jeder POST trägt:

Header Beispiel Bedeutung
webhook-id 618d0e8d-22c7-4a4a-9cdb-20fc9510921f Zustellungs-UUID. Stabil über Retries, neu bei Replay.
webhook-timestamp 1781008807 Unix-Sekunden, wann der Body signiert wurde.
webhook-signature v1,WtkBkfcIcZB3iHOB+wE/jCeCnIkMjNw7BSiaWmHj7Kg= Signaturzeile. version,base64-HMAC.
content-type application/json Body ist immer JSON.
user-agent Expecto-Webhooks/0.0.1 Stabiles Präfix; Version kann sich erhöhen.

Body ist immer UTF-8-JSON. Expecto folgt keinen Redirects.

Signatur verifizieren

Der zu signierende String lautet:

<webhook-id>.<webhook-timestamp>.<body>

— wobei <body> die exakten Bytes des Request-Bodys sind und der zur Signatur verwendete Schlüssel der whsec_…-Klartext ist, der dem Admin bei Endpoint-Anlage (oder Rotation) angezeigt wurde.

Der Header-Wert ist v1,<base64-Standard-HMAC-SHA256>. Mehrere Signaturen können künftig leerzeichengetrennt vorkommen (z. B. während einer Rotationsüberlappung); lehne den Request ab, sofern nicht mindestens eine verifiziert.

Lehne jeden Request ab, dessen webhook-timestamp mehr als 5 Minuten von der aktuellen Zeit abweicht (webhook_signature_tolerance_seconds, Standard 300). Das blockiert naives Replay aufgezeichneter Payloads.

Python (FastAPI / Flask / Django)

import base64
import hashlib
import hmac
import time

WEBHOOK_TOLERANCE_SECONDS = 5 * 60


def verify(secret: str, headers: dict, body: bytes) -> dict:
    webhook_id = headers["webhook-id"]
    webhook_timestamp = headers["webhook-timestamp"]
    webhook_signature = headers["webhook-signature"]

    if abs(int(time.time()) - int(webhook_timestamp)) > WEBHOOK_TOLERANCE_SECONDS:
        raise ValueError("Timestamp außerhalb des Toleranzfensters")

    # secret hat die Form "whsec_<base64>" — Standard Webhooks dekodiert das
    # Präfix nicht base64; Expecto signiert mit den Klartext-Bytes.
    secret_bytes = secret.encode("utf-8")

    signed_payload = f"{webhook_id}.{webhook_timestamp}.{body.decode('utf-8')}".encode("utf-8")
    expected = base64.b64encode(hmac.new(secret_bytes, signed_payload, hashlib.sha256).digest()).decode("ascii")

    for sig in webhook_signature.split(" "):
        version, _, value = sig.partition(",")
        if version == "v1" and hmac.compare_digest(value, expected):
            import json
            return json.loads(body)

    raise ValueError("Keine passende Signatur")

Node.js / Express

import crypto from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

export function verify(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const ts = headers["webhook-timestamp"];
  const sigHeader = headers["webhook-signature"];

  if (Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE_SECONDS) {
    throw new Error("Timestamp außerhalb des Toleranzfensters");
  }

  const signed = `${id}.${ts}.${rawBody.toString("utf-8")}`;
  const expected = crypto.createHmac("sha256", secret).update(signed).digest("base64");

  for (const sig of sigHeader.split(" ")) {
    const [version, value] = sig.split(",");
    if (version === "v1" && crypto.timingSafeEqual(Buffer.from(value), Buffer.from(expected))) {
      return JSON.parse(rawBody.toString("utf-8"));
    }
  }
  throw new Error("Keine passende Signatur");
}

Rohe Bytes verwenden

Die meisten Web-Frameworks parsen JSON, bevor dein Handler läuft. Erfasse den rohen Request-Body vor dem Parsen — eine Reserialisierung des geparsten Objekts erzeugt andere Bytes, und die Signatur passt nicht mehr. Express benötigt express.raw({type: 'application/json'}); FastAPI benötigt die Coroutine request.body() vor request.json(); Django benötigt request.body vor request.POST.

Optionaler Auth-Header

Ein Endpoint kann optional ein Paar (auth_header_name, auth_header_value) tragen. Ist es gesetzt, fügt jeder ausgehende Request — inklusive Pings, Retries und Replays — den konfigurierten Header zusätzlich zu den Standard-Webhooks-Signaturheadern hinzu. Nützlich, wenn dein Empfänger hinter einem Reverse Proxy oder API Gateway sitzt, der Authorization: Bearer …, X-API-Key: … o. ä. verlangt.

Der Wert wird mit demselben Fernet-Schlüssel wie der Signatur-Schlüssel (webhook_signing_key) verschlüsselt und erst beim Versand entschlüsselt. Read-APIs geben den Header-Namen und einen Boolean auth_header_configured zurück; der Klartext-Wert wird nach der Anlage nie wieder zurückgegeben.

Der Header-Name muss ^[A-Za-z][A-Za-z0-9_-]{0,63}$ matchen und darf nicht mit Headern kollidieren, die das System bereits steuert (webhook-id, webhook-timestamp, webhook-signature, host, content-length, content-type, user-agent).

Der Auth-Header ergänzt die Signaturverifikation — er ersetzt sie nicht. Die Signatur authentifiziert weiterhin den Absender. Der Empfänger sollte sowohl die Signatur prüfen als auch (unabhängig) das, was sein Proxy verlangt.

Retries und Timeouts

Expecto wertet jede HTTP-Antwort außerhalb 2xx als Fehler (auch 5xx, 3xx-Redirects und Transportfehler). Bei Fehler plant Expecto einen Retry mit exponentiellem Backoff:

  • Basis-Delay: 30 s (webhook_backoff_base_seconds)
  • Cap: 1 h (webhook_backoff_cap_seconds)
  • Max. Versuche: 6 (webhook_max_attempts)
  • Jitter pro (Zustellung, Versuch) — Retries vieler Endpoints synchronisieren sich nicht.
  • Request-Timeout: 30 s (webhook_request_timeout_seconds).
  • Response-Excerpt: Expecto liest höchstens 4 KiB der Antwort und speichert sie zur Diagnose auf der Zustellung. Halte deine Antwort klein.

Nach dem 6. fehlgeschlagenen Versuch geht die Zustellung in Dead-Letter. Der consecutive_failures-Zähler des Endpoints erhöht sich; eine einzige erfolgreiche Zustellung setzt ihn auf null. Nach 10 aufeinanderfolgenden Dead-Letters wird der Endpoint automatisch deaktiviert und empfängt keine Ereignisse mehr, bis ein Admin ihn wieder aktiviert. Siehe Org-Admin-Leitfaden.

Idempotenz

Behandle webhook-id als Idempotency-Key auf deiner Seite. Retries verwenden dieselbe webhook-id; ein Replay aus der UI vergibt eine neue webhook-id (und signiert den Body neu, damit die Signatur weiter gilt) — das ist gewollt, damit du eine Zustellung erneut auslösen kannst, ohne dass der Empfänger sie stumm dedupliziert.

Wenn dein Konsument Exactly-Once erzwingen muss, speichere (endpoint_id, webhook-id) und lehne Duplikate ab. Expectos Worker ist intern bereits idempotent: eine Zustellung, deren Status schon succeeded ist, wird kurzgeschlossen und nicht erneut versendet.

Betriebsnotizen

  • Pings (webhook.ping) tragen keine Geschäftsdaten — nutze sie nur für Konnektivitäts- und Signatur-Tests.
  • Replays eines session.exported-Ereignisses senden dieselben Artefakt-Metadaten erneut; das Paket selbst ändert sich nicht. Hast du es bereits heruntergeladen und verarbeitet, ist kein erneuter Download nötig.
  • webhook-id vs. data.id — bei Nicht-Ping-Ereignissen ist die Envelope-id die Zustellungs-ID (ändert sich bei Replay); die inneren data.session_id / data.project_id sind die Domain-IDs und ändern sich nicht. Persistiere Zustellungen nach (endpoint_id, webhook-id); persistiere Geschäftszustand nach data.session_id.

Verifikation über die UI

Der schnellste Weg, deinen Empfänger zu prüfen, ist Send ping im Org-Admin-Webhooks-Tab. Expecto signiert ein webhook.ping-Envelope mit dem aktuellen Schlüssel des Endpoints, POSTet es und zeigt Antwort-Code und Antwort-Auszug innerhalb weniger Sekunden im Delivery-Log. Schlägt die Verifikation beim Konsumenten fehl, ist 400 mit kurzer Begründung im Body am nützlichsten — sie landet in last_response_excerpt und macht die Ursache in der UI sichtbar.

Ausgehende Bindungen (Schreiben in externe Plattformen)

Webhook-Endpunkte liefern einen signierten Envelope an Ihren HTTP-Empfänger. Ist das Ziel eine Drittplattform mit eigener API — heute Salesforce —, verwenden Sie stattdessen ein Outbound Binding des Projekts: Expecto authentifiziert sich bei der Plattform und schreibt einen Datensatz nach einem adapterspezifischen Mapping, statt den generischen Envelope zu POSTen. Binding-Zustellungen teilen sich diese Maschinerie — derselbe Retry/Backoff-Zyklus, Dead-Letter, Replay und Historie (GET /api/webhook-deliveries, filterbar nach connection_id).

Die vollständige Anleitung — Vorbereitung der Salesforce-Org, Anlegen der Connection, das Mapping mit required_fields und value_maps, welche Ereignisse run_id und verdict tragen, Fehlersemantik und Troubleshooting — steht unter Integrationen › Salesforce; das Platzhalter-Vokabular in der Platzhalter & Zustellungsreferenz.