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

  1. Klicke im linken Hauptmenü auf Integrationen, dann links auf die Kategorie Entwickler, und öffne die Karte Webhooks.
  2. Klicke oben rechts auf Webhook anlegen.
  3. Trage unter Name etwas ein, das du später wiedererkennst, zum Beispiel CRM-Sync.
  4. Trage unter Adresse die vollständige https://-Adresse deines Empfängers ein. Nur https — unverschlüsselte Adressen werden abgewiesen. Adressen in deinem lokalen Netz, auf localhost oder in einem internen Netzwerk lehnt Univents ebenfalls ab.
  5. Hakte die Ereignisse an, die dieser Empfänger bekommen soll.
  6. 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:

KopfzeileInhalt
X-Univents-Signaturet=<Zeitstempel>,v1=<Signatur>
X-Univents-Eventder Auslöser-Schlüssel, zum Beispiel inquiry_created
X-Univents-Delivery-Iddie 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:

VersuchAbstand zum vorigen
1sofort
21 Minute
35 Minuten
430 Minuten
52 Stunden
66 Stunden
712 Stunden
824 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 Kopf X-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

EreignisFelder in data
inquiry_createdid, 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_accepteddie Felder von inquiry_created und die eines Angebots (siehe Finanzbelege)

Events

EreignisFelder in data
event_confirmed, event_cancelledid, 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_minuswie oben, zusätzlich days_before, weekday
event_t_pluswie 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

EreignisFelder in data
payment_receivedid, 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_signedid, 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_receivedcontact_id, received_at, has_attachments, has_contact_match — kein Mailinhalt, kein Betreff
schedule_monthlyday_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.

Univents-Newsletter

Produkt-Neuigkeiten und Praxistipps für Event- und Catering-Betriebe. Etwa einmal im Monat, nicht öfter.