Jede fachlich relevante Aktion emittiert ein Domain-Event. Tenants abonnieren diese Events pro Webhook-Endpoint; die Plattform übernimmt Zustellung, Signatur und Retries. Dein Tool-Code kennt genau einen Einstieg: emitEventBestEffort.
Event emittieren
import { emitEventBestEffort } from "@/lib/webhooks";
await emitEventBestEffort(ctx, "homestaging.generated", {
generationId: generation.id,
propertyId,
});Was der Aufruf macht:
- Er schreibt in einer Transaktion das Event in den append-only Event-Stream und je eine Outbox-Zeile pro passendem aktiven Endpoint des Tenants.
- Die eigentliche HTTP-Zustellung läuft nach der Antwort (deferred Kick plus Cron-Worker); deine Server-Action wartet nie auf fremde Endpoints.
- Fail-soft: Ein Emit-Fehler wird geloggt, nie geworfen. Ein Webhook-Problem darf die Nutzer-Aktion nicht scheitern lassen.
Payload-Regeln: nur Daten des eigenen Tenants, IDs statt Inhalte, keine Geheimnisse und keine PII-Payloads. Der Payload ist für den Tenant im Zustell-Inspektor sichtbar und geht wortgleich an dessen Endpoints.
Katalog-Pflicht: neuer Event-Typ, neuer Eintrag
Der einzige Ort, der die abonnierbaren Event-Typen kennt, ist WEBHOOK_EVENT_CATALOG in @reosa/config. Wer einen emitEventBestEffort-Aufruf mit neuem Typ hinzufügt, muss denselben Typ dort registrieren:
// packages/config/src/webhook-events.ts · in der passenden Domain-Gruppe
{
type: "homestaging.generated", // WORTGLEICH zum Emit-Aufruf
label: "Homestaging erstellt", // deutsches Label für den Picker
description: "Ein Homestaging-Bild wurde generiert.",
examplePayload: { generationId: "..." }, // optional: speist den Per-Typ-Test-Event
}- Wortgleich heißt wortgleich: Der
type-String im Katalog muss exakt dem Emit entsprechen. Ein unregistrierter Typ ist zwar zustellbar, aber im UI-Picker unter Einstellungen > Webhooks nicht auswählbar. - Die Konvention für Typen ist
<domäne>.<verb>bzw.<domäne>.<entity>.<verb>in Vergangenheitsform (property.created,db-lead.scan.completed). - Kein hardcodiertes Event-Array in Komponenten. Anzeige-Labels kommen überall aus
webhookEventLabel(type). - Der Test
packages/config/tests/webhook-events.test.tsbewacht Eindeutigkeit und Format; er bricht, wenn zwei Einträge kollidieren oder ein Typ-String aus der Reihe tanzt.
Zustellung im Kurzüberblick
Die Zustellung läuft über das Outbox-Pattern, at-least-once:
- Signierte POSTs nach Standard-Webhooks: Header
webhook-id,webhook-timestamp,webhook-signature(HMAC-SHA256 mit dem Endpoint-Secretwhsec_..., das genau einmal angezeigt wird). - Fehlversuche wiederholen mit Backoff 1m → 5m → 30m → 2h → 12h, danach gilt die Zustellung als
dead. - Ein Endpoint, der 15-mal in Folge ohne Zwischenerfolg fehlschlägt, wird automatisch pausiert; der Owner wird benachrichtigt.
- Endpoint-URLs sind tenant-kontrolliert und werden SSRF-geprüft; nur
https://ist erlaubt. - Verwaltung, Zustell-Log, Test-Events, Secret-Rotation und Neu-Zustellung liegen für Tenants unter Einstellungen > Webhooks.
Das komplette Wire-Format (Body-Aufbau, Signatur-Verifikation, Empfänger-Sicht) dokumentiert die Webhook-Referenz in der API-Sektion.
Events vs. Audit vs. Notifications
Drei Systeme, drei Zwecke, oft alle drei in derselben Aktion:
| System | Zweck | Empfänger |
|---|---|---|
| Events/Webhooks | externe Systeme reagieren lassen | Endpoints des Tenants |
| Audit | unveränderlicher Nachweis "wer hat was getan" | Tenant-Admins |
| Notifications | Menschen in der App informieren | einzelne Nutzer:innen/Rollen |
Weiterführend
Zurück zum Modul-Guide; der Runs-Harness emittiert tool.run.completed und tool.run.failed bereits für dich, eigene Events brauchst du nur für fachliche Ereignisse deines Tools.