Webhooks
Terminuj sends an HTTP POST with JSON to your address on the events you choose (new booking, cancellation, rescheduling…). Every request is signed with HMAC SHA-256 — verify the signature before processing.
1. Setup
In the dashboard → Settings → Webhooks → + New webhook:
- URL: your address,
https://only - Events: one or more (see section 4)
- Secret: we generate it (
whsec_…) and show it once — store it safely - Number of attempts: 1 to 10, default 5
2. Delivery and retries
- Sending runs every minute — an event usually arrives within a minute.
- We wait 10 s for a response; success = HTTP
2xx. - On any other response, timeout or network error we try again: 1st attempt immediately, then after 1 min, 5 min, 30 min and 2 h (with more than 5 attempts, each further one after 2 h).
- After the last attempt the delivery is marked “failed” — in the webhook detail you can send it again with the Resend button.
- Headers:
X-Rezervacia-Event,X-Rezervacia-Delivery-Id,X-Rezervacia-Signature. Retries of the same delivery have the sameX-Rezervacia-Delivery-Id— use it to process the event only once.
3. Verifying the signature (HMAC SHA-256)
Every request contains the X-Rezervacia-Signature header in the format sha256=<hex>. Compute the HMAC from the raw request body (not from parsed JSON) and compare it in constant time (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) // raw body Buffer — NOT parsed 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());
// A retried delivery has the same X-Rezervacia-Delivery-Id — process it only once.
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() # raw bytes — do NOT parse JSON before verification!
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'); // raw — NOT $_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⚠ NEVER verify the signature from parsed JSON. Libraries serialise JSON differently (whitespace, key order) — always use the raw request body.
4. Events
BOOKING_CREATEDNew booking — from the booking form, manual entry in the dashboard or via the API
Fields in
data: id, publicReference, confirmationCode, startAt, source (web / embed / manual)BOOKING_CANCELLEDCancellation — by the customer, by the business in the dashboard or via the API
Fields in
data: id; depending on the path publicReference, reason / cancellationReason, cancelledBy, refund, cancelledAt, viaBOOKING_RESCHEDULEDRescheduling — by the customer, in the dashboard or via the API
Fields in
data: id; customer: oldStartAt, newStartAt · dashboard and API: previousStartAt, startAt, endAt, viaBOOKING_COMPLETEDThe business marks the appointment as completed, or automatically after the appointment
Fields in
data: id, publicReference; for automatic and bulk changes completedAt / markedAt, viaBOOKING_NO_SHOWThe business marks the customer as a no-show
Fields in
data: id, publicReferenceCUSTOMER_CREATEDA new customer’s first booking via the booking form
Fields in
data: customer { fullName, email, phone }, firstBookingId, sourceBOOKING_PAIDNot sent yet — it can be selected, but Terminuj does not send this event today
Fields in
data: —
5. Message format
Every event has the same envelope: { event, occurredAt, tenantId, data }. The content of data differs by event and by where the change came from (section 4); it always contains the booking id (for CUSTOMER_CREATED, firstBookingId).
BOOKING_CREATED (booking form)
{
"event": "BOOKING_CREATED",
"occurredAt": "2026-06-10T10:30:00.000Z",
"tenantId": "business uuid",
"data": {
"id": "booking uuid",
"publicReference": "BK4F2H8K9M3N7P",
"confirmationCode": "K7M2QX",
"startAt": "2026-06-15T14:00:00.000Z",
"source": "embed"
}
}BOOKING_CANCELLED (cancelled by the customer)
{
"event": "BOOKING_CANCELLED",
"occurredAt": "2026-06-12T08:15:00.000Z",
"tenantId": "business uuid",
"data": {
"id": "booking uuid",
"publicReference": "BK4F2H8K9M3N7P",
"reason": "Illness",
"cancelledBy": "customer",
"refund": null
}
}6. Testing
In the list of webhooks, the Test button sends a test BOOKING_CREATED with a valid signature and "test": true in the envelope — a made-up booking, nothing is saved. Useful for checking before going live.