Webhooks
Terminuj envía un HTTP POST con JSON a su dirección en los eventos que usted elija (reserva nueva, cancelación, cambio de hora…). Cada solicitud va firmada con HMAC SHA-256 — verifique la firma antes de procesarla.
1. Configuración
En el panel → Ajustes → Webhooks → + Nuevo webhook:
- URL: su dirección, solo
https:// - Eventos: uno o varios (véase la sección 4)
- Clave secreta: la generamos nosotros (
whsec_…) y la mostramos una sola vez — guárdela - Número de intentos: de 1 a 10, por defecto 5
2. Entrega y reintentos
- El envío se ejecuta cada minuto — el evento suele llegar en menos de un minuto.
- Esperamos la respuesta 10 s; éxito = HTTP
2xx. - Ante cualquier otra respuesta, tiempo de espera agotado o error de red volvemos a intentarlo: 1.er intento inmediato, después tras 1 min, 5 min, 30 min y 2 h (con más de 5 intentos, cada uno de los siguientes tras 2 h).
- Tras el último intento, la entrega queda como «fallida» — en el detalle del webhook puede volver a enviarla con el botón Reenviar.
- Cabeceras:
X-Rezervacia-Event,X-Rezervacia-Delivery-Id,X-Rezervacia-Signature. Los reintentos de la misma entrega llevan el mismoX-Rezervacia-Delivery-Id— úselo para procesar el evento una sola vez.
3. Verificación de la firma (HMAC SHA-256)
Cada solicitud contiene la cabecera X-Rezervacia-Signature con el formato sha256=<hex>. Calcule el HMAC a partir del cuerpo sin procesar de la solicitud (no del JSON ya analizado) y compárelo en tiempo constante (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 con el cuerpo sin procesar — ¡NO el JSON analizado!
.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());
// Una entrega reintentada tiene el mismo X-Rezervacia-Delivery-Id — procésela una sola vez.
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() # bytes sin procesar — ¡NO analizar el JSON antes de verificar!
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'); // sin procesar — ¡NO $_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⚠ NUNCA verifique la firma a partir del JSON analizado. Cada biblioteca serializa el JSON de forma distinta (espacios, orden de las claves) — use siempre el cuerpo sin procesar de la solicitud (raw body).
4. Eventos
BOOKING_CREATEDReserva nueva — desde el formulario de reserva, registro manual en el panel o por API
Campos en
data: id, publicReference, confirmationCode, startAt, source (web / embed / manual)BOOKING_CANCELLEDCancelación — por el cliente, por el negocio en el panel o por API
Campos en
data: id; según la ruta publicReference, reason / cancellationReason, cancelledBy, refund, cancelledAt, viaBOOKING_RESCHEDULEDCambio de hora — por el cliente, en el panel o por API
Campos en
data: id; cliente: oldStartAt, newStartAt · panel y API: previousStartAt, startAt, endAt, viaBOOKING_COMPLETEDEl negocio marca la cita como completada, o automáticamente después de la cita
Campos en
data: id, publicReference; en cambios automáticos y masivos completedAt / markedAt, viaBOOKING_NO_SHOWEl negocio marca que el cliente no se presentó
Campos en
data: id, publicReferenceCUSTOMER_CREATEDPrimera reserva de un cliente nuevo a través del formulario de reserva
Campos en
data: customer { fullName, email, phone }, firstBookingId, sourceBOOKING_PAIDTodavía no se envía — se puede seleccionar, pero Terminuj no envía este evento por ahora
Campos en
data: —
5. Formato del mensaje
Todos los eventos tienen el mismo sobre: { event, occurredAt, tenantId, data }. El contenido de data varía según el evento y según de dónde venga el cambio (sección 4); siempre incluye el id de la reserva (en CUSTOMER_CREATED, firstBookingId).
BOOKING_CREATED (formulario de reserva)
{
"event": "BOOKING_CREATED",
"occurredAt": "2026-06-10T10:30:00.000Z",
"tenantId": "uuid del negocio",
"data": {
"id": "uuid de la reserva",
"publicReference": "BK4F2H8K9M3N7P",
"confirmationCode": "K7M2QX",
"startAt": "2026-06-15T14:00:00.000Z",
"source": "embed"
}
}BOOKING_CANCELLED (cancelada por el cliente)
{
"event": "BOOKING_CANCELLED",
"occurredAt": "2026-06-12T08:15:00.000Z",
"tenantId": "uuid del negocio",
"data": {
"id": "uuid de la reserva",
"publicReference": "BK4F2H8K9M3N7P",
"reason": "Enfermedad",
"cancelledBy": "customer",
"refund": null
}
}6. Prueba
En la lista de webhooks, el botón Probar envía un BOOKING_CREATED de prueba con una firma válida y "test": true en el sobre — una reserva ficticia, no se guarda nada. Útil para comprobarlo antes de ponerlo en marcha.