Preskočiť na hlavný obsah
← Hlavní stránka

Webhooky

Terminuj pošle HTTP POST s JSON na vaši adresu při událostech, které si vyberete (nová rezervace, zrušení, přesun…). Každý požadavek je podepsán HMAC SHA-256 — podpis ověřte před zpracováním.

1. Nastavení

Na nástěnce → Nastavení → Webhooks → + Nový webhook:

  • URL: vaše adresa, pouze https://
  • Události: jedna nebo více (viz část 4)
  • Tajný klíč: vygenerujeme ho (whsec_…) a ukážeme jednou — uložte si ho
  • Počet pokusů: 1 až 10, výchozí 5

2. Doručení a opakování

  • Odesílání běží každou minutu — událost přijde obvykle do minuty.
  • Na odpověď čekáme 10 s; úspěch = HTTP 2xx.
  • Při jakékoli jiné odpovědi, vypršení času nebo chybě sítě zkoušíme znovu: 1. pokus hned, potom po 1 min, 5 min, 30 min a 2 h (při více než 5 pokusech každý další po 2 h).
  • Po posledním pokusu je doručení „neúspěšné“ — v detailu webhooku ho pošlete znovu tlačítkem Odeslat znovu.
  • Hlavičky: X-Rezervacia-Event, X-Rezervacia-Delivery-Id, X-Rezervacia-Signature. Opakování téhož doručení mají stejné X-Rezervacia-Delivery-Id — podle něj zpracujte událost jen jednou.

3. Ověření podpisu (HMAC SHA-256)

Každý požadavek obsahuje hlavičku X-Rezervacia-Signature ve formátu sha256=<hex>. HMAC vypočítejte z nezpracovaného těla požadavku (ne z rozparsovaného JSON) a porovnejte ho v konstantním čase (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 raw body — NE parsovaný 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());
  // Opakované doručení má stejné X-Rezervacia-Delivery-Id — zpracujte ho jen jednou.
  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 — NEPARSOVAT JSON před ověřením!

    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', 200

PHP

<?php
$secret = getenv('REZERVACIA_WEBHOOK_SECRET');
$signature = $_SERVER['HTTP_X_REZERVACIA_SIGNATURE'] ?? '';
$body = file_get_contents('php://input'); // raw — NE $_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

⚠ NIKDY neověřujte podpis z rozparsovaného JSON. Knihovny serializují JSON různě (mezery, pořadí klíčů) — vždy použijte nezpracované tělo požadavku (raw body).

4. Události

  • BOOKING_CREATED

    Nová rezervace — z rezervačního formuláře, ručním zápisem na nástěnce nebo přes API

    Pole v data: id, publicReference, confirmationCode, startAt, source (web / embed / manual)

  • BOOKING_CANCELLED

    Zrušení — zákazníkem, provozovnou na nástěnce nebo přes API

    Pole v data: id; podle cesty publicReference, reason / cancellationReason, cancelledBy, refund, cancelledAt, via

  • BOOKING_RESCHEDULED

    Přesun termínu — zákazníkem, na nástěnce nebo přes API

    Pole v data: id; zákazník: oldStartAt, newStartAt · nástěnka a API: previousStartAt, startAt, endAt, via

  • BOOKING_COMPLETED

    Provozovna označí termín jako dokončený, nebo automaticky po termínu

    Pole v data: id, publicReference; při automatice a hromadné změně completedAt / markedAt, via

  • BOOKING_NO_SHOW

    Provozovna označí, že zákazník nepřišel

    Pole v data: id, publicReference

  • CUSTOMER_CREATED

    První rezervace nového zákazníka přes rezervační formulář

    Pole v data: customer { fullName, email, phone }, firstBookingId, source

  • BOOKING_PAID

    Zatím se neposílá — lze ji vybrat, ale Terminuj tuto událost dnes neodesílá

    Pole v data: —

5. Tvar zprávy

Každá událost má stejný obal: { event, occurredAt, tenantId, data }. Obsah data se liší podle události a podle toho, odkud změna přišla (část 4); vždy obsahuje id rezervace (u CUSTOMER_CREATED firstBookingId).

BOOKING_CREATED (rezervační formulář)

{
  "event": "BOOKING_CREATED",
  "occurredAt": "2026-06-10T10:30:00.000Z",
  "tenantId": "uuid provozovny",
  "data": {
    "id": "uuid rezervace",
    "publicReference": "BK4F2H8K9M3N7P",
    "confirmationCode": "K7M2QX",
    "startAt": "2026-06-15T14:00:00.000Z",
    "source": "embed"
  }
}

BOOKING_CANCELLED (zrušil zákazník)

{
  "event": "BOOKING_CANCELLED",
  "occurredAt": "2026-06-12T08:15:00.000Z",
  "tenantId": "uuid provozovny",
  "data": {
    "id": "uuid rezervace",
    "publicReference": "BK4F2H8K9M3N7P",
    "reason": "Nemoc",
    "cancelledBy": "customer",
    "refund": null
  }
}

6. Zkouška

V seznamu webhooků tlačítko Test pošle zkušební BOOKING_CREATED se správným podpisem a s "test": true v obalu — smyšlená rezervace, nic se nezapíše. Vhodné k ověření před ostrým provozem.