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). Entsprichtdata.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,failedodernull, 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 daherverdictbevorzugen.phase— welche Hälfte des zweiphasigen Ablaufs das Ereignis meldet:technical(Parsen —session.parsed,session.failedmitstage = parse),context(das Kontext-Gate —session.failedmitstage = context_fetch/context_validation),content(alles nach dem Gate —session.transformed,session.checked,session.failedmitstage = transform/validation_gating/validate/check),nullbei 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 dentechnical-Ereignissen: Parse-Befunde der Schwereerrorüber alle Dateien (eine gar nicht lesbare Datei zählt als einer) und Befunde der Schwerewarning;nullbei 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-idvs.data.id— bei Nicht-Ping-Ereignissen ist die Envelope-iddie Zustellungs-ID (ändert sich bei Replay); die innerendata.session_id/data.project_idsind die Domain-IDs und ändern sich nicht. Persistiere Zustellungen nach(endpoint_id, webhook-id); persistiere Geschäftszustand nachdata.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.