Webhooks
Terminuj sendet bei den von Ihnen gewählten Ereignissen (neue Buchung, Stornierung, Verschiebung…) einen HTTP-POST mit JSON an Ihre Adresse. Jede Anfrage ist mit HMAC SHA-256 signiert — prüfen Sie die Signatur vor der Verarbeitung.
1. Einrichtung
In der Übersicht → Einstellungen → Webhooks → + Neuer Webhook:
- URL: Ihre Adresse, nur
https:// - Ereignisse: eines oder mehrere (siehe Abschnitt 4)
- Geheimer Schlüssel: wir erzeugen ihn (
whsec_…) und zeigen ihn einmal an — speichern Sie ihn - Anzahl der Versuche: 1 bis 10, Standard 5
2. Zustellung und Wiederholung
- Der Versand läuft jede Minute — ein Ereignis kommt in der Regel innerhalb einer Minute an.
- Wir warten 10 s auf eine Antwort; Erfolg = HTTP
2xx. - Bei jeder anderen Antwort, Zeitüberschreitung oder einem Netzwerkfehler versuchen wir es erneut: 1. Versuch sofort, dann nach 1 Min., 5 Min., 30 Min. und 2 Std. (bei mehr als 5 Versuchen jeder weitere nach 2 Std.).
- Nach dem letzten Versuch gilt die Zustellung als „fehlgeschlagen“ — in den Details des Webhooks senden Sie sie mit der Schaltfläche Erneut senden noch einmal.
- Header:
X-Rezervacia-Event,X-Rezervacia-Delivery-Id,X-Rezervacia-Signature. Wiederholungen derselben Zustellung haben dieselbeX-Rezervacia-Delivery-Id— verarbeiten Sie das Ereignis anhand dieser nur einmal.
3. Signatur prüfen (HMAC SHA-256)
Jede Anfrage enthält den Header X-Rezervacia-Signature im Format sha256=<hex>. Berechnen Sie den HMAC aus dem unverarbeiteten Anfragetext (nicht aus geparstem JSON) und vergleichen Sie ihn in konstanter Zeit (timing-safe).
Node.js (Express)
const crypto = require('crypto');
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-rezervacia-signature']; // "sha256=<hex>"
const secret = process.env.REZERVACIA_WEBHOOK_SECRET;
const computed = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(req.body) // Buffer mit Rohdaten — NICHT geparstes JSON!
.digest('hex');
const a = Buffer.from(signature || ''), b = Buffer.from(computed);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(req.body.toString());
// Eine wiederholte Zustellung hat dieselbe X-Rezervacia-Delivery-Id — nur einmal verarbeiten.
console.log('Event:', event.event, 'Delivery:', req.headers['x-rezervacia-delivery-id'], 'Data:', event.data);
res.status(200).send('ok');
});Python (Flask)
import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ['REZERVACIA_WEBHOOK_SECRET'].encode()
@app.post('/webhook')
def webhook():
signature = request.headers.get('X-Rezervacia-Signature', '')
body = request.get_data() # Rohbytes — JSON NICHT vor der Prüfung parsen!
computed = 'sha256=' + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, computed):
abort(401)
event = request.get_json()
print('Event:', event['event'], 'Data:', event['data'])
return 'ok', 200PHP
<?php
$secret = getenv('REZERVACIA_WEBHOOK_SECRET');
$signature = $_SERVER['HTTP_X_REZERVACIA_SIGNATURE'] ?? '';
$body = file_get_contents('php://input'); // Rohdaten — NICHT $_POST!
$computed = 'sha256=' . hash_hmac('sha256', $body, $secret);
if (!hash_equals($signature, $computed)) {
http_response_code(401);
exit('invalid signature');
}
$event = json_decode($body, true);
error_log('Event: ' . $event['event']);
http_response_code(200);
echo 'ok';Ruby (Sinatra)
require 'sinatra'
require 'openssl'
SECRET = ENV.fetch('REZERVACIA_WEBHOOK_SECRET')
post '/webhook' do
signature = request.env['HTTP_X_REZERVACIA_SIGNATURE'] || ''
body = request.body.read
computed = 'sha256=' + OpenSSL::HMAC.hexdigest('sha256', SECRET, body)
halt 401 unless Rack::Utils.secure_compare(signature, computed)
event = JSON.parse(body)
puts "Event: #{event['event']}"
status 200
body 'ok'
end⚠ Prüfen Sie die Signatur NIEMALS anhand von geparstem JSON. Bibliotheken serialisieren JSON unterschiedlich (Leerzeichen, Reihenfolge der Schlüssel) — verwenden Sie immer den unverarbeiteten Anfragetext (raw body).
4. Ereignisse
BOOKING_CREATEDNeue Buchung — aus dem Buchungsformular, manuell in der Übersicht eingetragen oder über die API
Felder in
data: id, publicReference, confirmationCode, startAt, source (web / embed / manual)BOOKING_CANCELLEDStornierung — durch den Kunden, durch den Betrieb in der Übersicht oder über die API
Felder in
data: id; je nach Weg publicReference, reason / cancellationReason, cancelledBy, refund, cancelledAt, viaBOOKING_RESCHEDULEDTerminverschiebung — durch den Kunden, in der Übersicht oder über die API
Felder in
data: id; Kunde: oldStartAt, newStartAt · Übersicht und API: previousStartAt, startAt, endAt, viaBOOKING_COMPLETEDDer Betrieb markiert den Termin als erledigt, oder automatisch nach dem Termin
Felder in
data: id, publicReference; bei Automatik und Sammeländerung completedAt / markedAt, viaBOOKING_NO_SHOWDer Betrieb markiert, dass der Kunde nicht erschienen ist
Felder in
data: id, publicReferenceCUSTOMER_CREATEDErste Buchung eines neuen Kunden über das Buchungsformular
Felder in
data: customer { fullName, email, phone }, firstBookingId, sourceBOOKING_PAIDWird noch nicht gesendet — auswählbar, aber Terminuj sendet dieses Ereignis derzeit nicht
Felder in
data: —
5. Aufbau der Nachricht
Jedes Ereignis hat dieselbe Hülle: { event, occurredAt, tenantId, data }. Der Inhalt von data unterscheidet sich je nach Ereignis und danach, woher die Änderung kam (Abschnitt 4); er enthält immer die id der Buchung (bei CUSTOMER_CREATED firstBookingId).
BOOKING_CREATED (Buchungsformular)
{
"event": "BOOKING_CREATED",
"occurredAt": "2026-06-10T10:30:00.000Z",
"tenantId": "UUID des Betriebs",
"data": {
"id": "UUID der Buchung",
"publicReference": "BK4F2H8K9M3N7P",
"confirmationCode": "K7M2QX",
"startAt": "2026-06-15T14:00:00.000Z",
"source": "embed"
}
}BOOKING_CANCELLED (vom Kunden storniert)
{
"event": "BOOKING_CANCELLED",
"occurredAt": "2026-06-12T08:15:00.000Z",
"tenantId": "UUID des Betriebs",
"data": {
"id": "UUID der Buchung",
"publicReference": "BK4F2H8K9M3N7P",
"reason": "Krankheit",
"cancelledBy": "customer",
"refund": null
}
}6. Test
In der Liste der Webhooks sendet die Schaltfläche Testen ein Test-BOOKING_CREATED mit gültiger Signatur und "test": true in der Hülle — eine erfundene Buchung, es wird nichts gespeichert. Geeignet zur Prüfung vor dem Livebetrieb.