Webhooks: Ereignisse an eigene Systeme melden
Nach diesem Artikel hast du einen Webhook eingerichtet, der bei den von dir gewählten Ereignissen deine eigene Adresse aufruft — mit Signaturschlüssel, Probezustellung, Protokoll und automatischer Wiederholung bei Fehlern.
Ein Webhook dreht die Richtung um: Nicht dein System fragt bei Univents nach, sondern Univents meldet sich bei deinem System, sobald etwas passiert — eine Anfrage eingeht, ein Angebot angenommen oder eine Rechnung gestellt wird. Damit hängen sich eigene Programme, Skripte oder Automatisierungsdienste an deinen Workspace, ohne dass jemand regelmäßig nachfragen muss.
Eingerichtet wird das im Workspace selbst, unter Integrationen → Entwickler → Webhooks. Einrichtung, Schlüssel und Protokoll sind nur für Inhaber sichtbar — dort steht schliesslich, welche Adressen Univents aufruft.
Wann lohnt sich ein Webhook
Nimm einen Webhook, wenn dein eigenes System sofort reagieren soll: eine Anfrage ins eigene CRM spiegeln, den Versand an ein Lagerfeuer-System auslösen, eine Rechnung in deine Buchhaltung nachziehen, eine Anzeigetafel aktualisieren. Für alles, was innerhalb von Univents bleiben soll, sind stattdessen die Automatisierungen der richtige Ort.
Endpunkt anlegen
- Klicke im linken Hauptmenü auf Integrationen, dann links auf die Kategorie Entwickler, und öffne die Karte Webhooks.
- Klicke oben rechts auf Webhook anlegen.
- Trage unter Name etwas ein, das du später wiedererkennst, zum Beispiel
CRM-Sync. - Trage unter Adresse die vollständige
https://-Adresse deines Empfängers ein. Nurhttps— unverschlüsselte Adressen werden abgewiesen. Adressen in deinem lokalen Netz, auflocalhostoder in einem internen Netzwerk lehnt Univents ebenfalls ab. - Hakte die Ereignisse an, die dieser Empfänger bekommen soll.
- Speichere. Univents zeigt dir jetzt einmalig den Signaturschlüssel an.
Der Signaturschlüssel
Der Schlüssel beginnt mit whsec_. Er wird genau einmal angezeigt — beim Anlegen und beim Neu-Erzeugen. Danach siehst du nur noch den Anfang, damit im Workspace nachvollziehbar bleibt, welcher Schlüssel hinterlegt ist, ohne dass der Wert selbst herumliegt. Kopiere ihn also sofort in dein System (Umgebungsvariable, Secret-Store).
Über Signaturschlüssel neu erzeugen bekommst du jederzeit einen neuen. Danach ist der alte sofort ungültig — tausche ihn auf deiner Seite im selben Moment aus, sonst weist dein Empfänger die eingehenden Meldungen ab.
Die Signatur prüfen
Univents sendet zu jeder Zustellung drei Kopfzeilen:
| Kopfzeile | Inhalt |
|---|---|
X-Univents-Signature | t=<Zeitstempel>,v1=<Signatur> |
X-Univents-Event | der Auslöser-Schlüssel, zum Beispiel inquiry_created |
X-Univents-Delivery-Id | die Kennung dieser Zustellung |
Die Signatur ist ein HMAC-SHA256 über den Text ${t}.${Body} — also Zeitstempel, ein Punkt, und der rohe Body, byte-genau so, wie er ankommt. Wichtig: Prüfe den rohen Body, nicht einen neu serialisierten. Ein JSON.parse gefolgt von JSON.stringify ändert die Reihenfolge der Felder und die Escapes, und die Prüfung schlägt dann fehl, obwohl die Meldung in Ordnung ist.
So sieht die Prüfung in Node aus:
const crypto = require('node:crypto');
const express = require('express');
const app = express();
// Roher Body — NICHT express.json() davor.
app.post('/webhooks/dein-system', express.raw({ type: '*/*' }), (req, res) => {
const header = req.get('X-Univents-Signature') ?? '';
const parts = Object.fromEntries(
header.split(',').map((piece) => piece.trim().split('=')),
);
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(`${parts.t}.${req.body.toString('utf8')}`)
.digest('hex');
const received = parts.v1 ?? '';
// Länge zuerst prüfen: timingSafeEqual wirft bei ungleicher Länge.
const ok =
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!ok) return res.status(400).send('signature mismatch');
// Der Zeitstempel liegt unter der Signatur — so kann niemand eine alte
// Meldung erneut einspielen.
const ageSeconds = Math.abs(Date.now() / 1000 - Number(parts.t));
if (ageSeconds > 300) return res.status(400).send('stale');
const event = JSON.parse(req.body.toString('utf8'));
console.log(req.get('X-Univents-Event'), event);
res.sendStatus(200);
});
Gib mit 200 (oder einer anderen Antwort im 2xx-Bereich) zurück, sobald du die Meldung angenommen hast. Jede andere Antwort oder eine Antwort, die länger als zehn Sekunden braucht, gilt als Fehlversuch.
Zustellungen: was passiert bei Fehlern
Zugestellt wird mindestens einmal — nicht genau einmal. Ein Verbindungsabbruch auf dem Weg kann dazu führen, dass derselbe Vorgang ein zweites Mal bei dir ankommt. Baue deinen Empfänger deshalb so, dass ein zweimaliger Aufruf nichts kaputt macht: Prüfe die X-Univents-Delivery-Id gegen die zuletzt verarbeiteten Kennungen, bevor du etwas anlegst.
Antwortet dein Empfänger nicht oder mit einem Fehler, versucht Univents es erneut. Insgesamt sind acht Versuche vorgesehen, mit wachsendem Abstand:
| Versuch | Abstand zum vorigen |
|---|---|
| 1 | sofort |
| 2 | 1 Minute |
| 3 | 5 Minuten |
| 4 | 30 Minuten |
| 5 | 2 Stunden |
| 6 | 6 Stunden |
| 7 | 12 Stunden |
| 8 | 24 Stunden |
Danach gilt die Meldung als endgültig fehlgeschlagen und wird nicht mehr versucht. Läuft dein System planmässig Wartung, hast du so fast zwei Tage Zeit, um wieder erreichbar zu sein.
Probezustellung
Mit Test schickst du eine Meldung an einen Endpunkt, ohne auf ein echtes Ereignis zu warten. So prüfst du nach dem Einrichten, ob Adresse, Signaturprüfung und Antwortweg stimmen. Die Probezustellung steht anschliessend im Protokoll, damit du sie nicht mit einer echten Meldung verwechselst.
Ein pausierter Endpunkt bekommt keine Probezustellung — aktiviere ihn zuerst. Und weil die Probezustellung der einzige Weg ist, auf dem du unseren Server eine von dir bestimmte Adresse aufrufen lässt, gilt ein Deckel von zehn Probierzustellungen pro Workspace und Stunde. Danach meldet die Oberfläche, dass du es später noch einmal versuchen sollst.
Protokoll und erneutes Zustellen
Unter jedem Endpunkt stehen die letzten 50 Zustellungen. Je Zeile siehst du:
- den Zustand — wartet, läuft, zugestellt oder endgültig fehlgeschlagen,
- das Ereignis,
- die Anzahl der Versuche (zum Beispiel
3/8), - den Antwortstatus deines Empfängers.
Über Inhalt anzeigen siehst du die mitgeschickte Nachricht, über Details den letzten Fehler und den Zeitpunkt des nächsten Versuchs. Die Antwort deines Empfängers bewahrt Univents gekürzt als Diagnose-Material auf.
Erneut zustellen schickt eine zugestellte oder endgültig fehlgeschlagene Meldung noch einmal — sinnvoll, wenn dein System nur kurz weg war. Solange eine Zustellung gerade läuft oder noch wartet, ist der Knopf gesperrt, damit nicht zwei Aufrufe nebeneinander entstehen.
Pausieren und löschen
Der Schalter am Endpunkt pausiert ihn: Univents stellt dann sofort nichts mehr zu, die Einrichtung bleibt aber erhalten — ideal für ein System im Umbau. Univents liest den Zustand bei jeder Zustellung frisch, ein Pausieren wirkt also nicht erst beim nächsten Ereignis.
Löschen entfernt den Endpunkt samt seines Protokolls. Univents fragt vorher nach.
Gültige Ereignisse
Zur Auswahl stehen dieselben Ereignisse, die auch die Automatisierungen kennen. Die Liste im Dialog wächst mit: Sobald Univents an einer neuen Stelle ein Ereignis meldet, steht es auch für Webhooks zur Verfügung. Ein Ereignis ohne Haken wird nicht zugestellt.
Was in der Meldung steht
Jede Nachricht hat denselben Umschlag:
{
"id": "48213",
"type": "inquiry_created",
"created_at": "2026-09-28T10:12:33.000Z",
"data": { "…": "…" }
}
id— die Kennung dieser Zustellung, identisch mit dem KopfX-Univents-Delivery-Id. Nimm sie als Idempotenz-Schlüssel.type— der Auslöser-Schlüssel (inquiry_created,event_confirmed, …).created_at— wann der Umschlag gebaut wurde, nicht wann das Ereignis passiert ist.data— der fachliche Inhalt, je Ereignis nach einer festen Liste.
In data steht nur, was in dieser Liste steht. Interne Felder und alles, was Zugangsdaten oder Freitext enthalten könnte, fallen weg — unter anderem legacy_*, stripe_* (Zahlungs-Kennungen), *token*, *secret*, *_hash*, pdf_url, E-Mail- und Telefonfelder, Notizen/Beschreibungen sowie interne Schalter (is_*, created_by, deleted_at). Ein Feld ohne Wert wird weggelassen, nicht als null geschickt. Neue Spalten an unseren Tabellen tauchen hier nicht von selbst auf; wir nehmen sie bewusst einzeln auf.
Jede data-Fassung trägt entity_type und entity_id — damit ordnest du die Meldung auch dann zu, wenn das Ereignis keinen eigenen Datensatz hat.
Anfragen
| Ereignis | Felder in data |
|---|---|
inquiry_created | id, event_id, contact_id, location_id, booking_page_id, booking_request_state, event_name, event_date, event_end_date, guest_count, estimated_value, account_id, created_at, updated_at |
quote_accepted | die Felder von inquiry_created und die eines Angebots (siehe Finanzbelege) |
Events
| Ereignis | Felder in data |
|---|---|
event_confirmed, event_cancelled | id, name, guest_count, budget, start_date, end_date, date_is_confirmed, language, event_state, location_id, room_id, contact_id, account_id, created_at, updated_at |
event_t_minus | wie oben, zusätzlich days_before, weekday |
event_t_plus | wie oben, zusätzlich days_after |
Finanzbelege
Gilt für invoice_overdue, invoice_paid, quote_sent, quote_expired und quote_rejected; bei quote_rejected zusätzlich source.
id, event_id, contact_id, doc_type, document_number, title, version, quote_state, invoice_state, issue_date, due_date, sent_date, accepted_date, rejected_date, expires_at, net_total, gross_total, tax_total, discount_total, deposit_amount, currency, linked_order_id, prior_version_id, account_id, created_at, updated_at
Weitere Ereignisse
| Ereignis | Felder in data |
|---|---|
payment_received | id, booking_request_id, event_id, contact_id, booking_order_state, total_net, total_gross, total_tax, discount_total, currency, payment_method, paid_at, account_id, created_at, updated_at |
contract_signed | id, event_id, contact_id, title, contract_doc_type, contract_state, version, sent_at, expires_at, has_signed_pdf, account_id, created_at, updated_at |
email_received | contact_id, received_at, has_attachments, has_contact_match — kein Mailinhalt, kein Betreff |
schedule_monthly | day_of_month, target_date, period_start, period_end, period_label, account_id |
Die Probezustellung nutzt denselben Umschlag mit type: "webhook_test" und data: { test, endpoint_id, sent_at }.
Ein Ereignis, zu dem hier keine Liste steht, kommt mit leerem data an: lieber ein sichtbarer, leerer Umschlag als ein roher Datensatz.
Grenzen
- Nur https, keine Weiterleitungen: Antwortet dein Server mit einer Weiterleitung, gilt das als Fehlversuch. Trage die endgültige Adresse ein.
- Zehn Sekunden Zeit pro Versuch. Mehr Arbeit als das gehört hinter die Antwort: nimm die Meldung an, antworte mit 200 und verarbeite sie anschliessend.
- Der Inhalt der Meldung beschreibt den Datensatz, der das Ereignis ausgelöst hat — behandle ihn wie Kundendaten deines Workspace und protokolliere ihn nicht ungeprüft weiter.