WEBHOOKS
Webhooks salientes
Recibe notificaciones HTTP en tiempo real cuando ocurren eventos en tu workspace. Firma HMAC-SHA256 para garantizar autenticidad.
Cómo funciona
- Registras una URL en
/dashboard/[slug]/settings/webhooks - Recibes un
secretautogenerado - Cuando ocurre un evento, Mati hace
POSTa tu URL - Verificas la firma HMAC con tu
secret - Respondes
2xxpara confirmar recepción
Headers enviados
X-Webhook-Event: booking.created
X-Webhook-Event-ID: evt_abc123
X-Webhook-Signature: t=1785460233,v1=8a7d...
Content-Type: application/json
User-Agent: Mati-Webhook/1.0
Verificar la firma
La firma es HMAC-SHA256 del body raw concatenado con el timestamp. Formato del header:t=<ts>,v1=<hex>.
Node.js — Express middleware
import crypto from "crypto";
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
const sig = req.headers["x-webhook-signature"] || "";
const secret = process.env.MATI_WEBHOOK_SECRET;
// 1. Parse "t=...,v1=..." format
const parts = Object.fromEntries(sig.split(",").map((p) => p.split("=")));
const timestamp = parts.t;
const signature = parts.v1;
if (!timestamp || !signature) return res.status(401).send("Invalid signature");
// 2. Compute expected HMAC
const body = req.body.toString(); // raw body, NOT parsed JSON
const expected = crypto
.createHmac("sha256", secret)
.update(timestamp + "." + body, "utf8")
.digest("hex");
// 3. Constant-time comparison
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
return res.status(401).send("Invalid signature");
}
// 4. Process the event
const event = JSON.parse(body);
console.log("Received:", event.type);
res.json({ received: true });
});Ver en Python (Flask), PHP y Ruby
Python — Flask
import hmac, hashlib
from flask import Flask, request, abort
app = Flask(__name__)
@app.route("/webhook", methods=["POST"])
def webhook():
sig = request.headers.get("X-Webhook-Signature", "")
parts = dict(p.split("=", 1) for p in sig.split(","))
timestamp, signature = parts.get("t"), parts.get("v1")
if not timestamp or not signature:
abort(401)
body = request.get_data(as_text=True)
expected = hmac.new(
key=app.config["MATI_WEBHOOK_SECRET"].encode(),
msg=f"{timestamp}.{body}".encode(),
digestmod=hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
abort(401)
event = request.get_json()
print("Received:", event["type"])
return {"received": True}PHP — Slim
<?php
$app->post("/webhook", function ($req, $res) {
$sigHeader = $req->getHeaderLine("X-Webhook-Signature");
$parts = [];
foreach (explode(",", $sigHeader) as $p) {
[$k, $v] = explode("=", $p, 2);
$parts[$k] = $v;
}
$timestamp = $parts["t"] ?? null;
$signature = $parts["v1"] ?? null;
if (!$timestamp || !$signature) {
return $res->withStatus(401);
}
$body = (string) $req->getBody();
$expected = hash_hmac("sha256", "{$timestamp}.{$body}", getenv("MATI_WEBHOOK_SECRET"));
if (!hash_equals($expected, $signature)) {
return $res->withStatus(401);
}
$event = json_decode($body, true);
error_log("Received: " . $event["type"]);
return $res->withJson(["received" => true]);
});Ruby — Sinatra
require "sinatra"
require "openssl"
require "json"
post "/webhook" do
sig = env["HTTP_X_WEBHOOK_SIGNATURE"] || ""
parts = sig.split(",").each_with_object({}) { |p, h| k, v = p.split("=", 2); h[k] = v }
timestamp = parts["t"]
signature = parts["v1"]
halt 401 unless timestamp && signature
body = request.body.read
expected = OpenSSL::HMAC.hexdigest("sha256", ENV["MATI_WEBHOOK_SECRET"], "#{timestamp}.#{body}")
halt 401 unless Rack::Utils.secure_compare(expected, signature)
event = JSON.parse(body)
puts "Received: #{event["type"]}"
{ received: true }.to_json
endCatálogo de eventos
booking.createdUna nueva reserva fue creada desde el widget o desde la API.
{
"type": "booking.created",
"eventId": "evt_abc123",
"workspaceId": "ws_xyz",
"data": {
"id": "bk_abc123",
"status": "PENDING",
"service": {
"name": "Consulta General",
"duration": 60
},
"customer": {
"name": "María García",
"email": "[email protected]"
},
"startAt": "2026-08-15T10:00:00Z"
}
}booking.confirmedLa reserva pasó a estado CONFIRMED (pago recibido o admin manual).
{
"type": "booking.confirmed",
"eventId": "evt_def456",
"data": {
"id": "bk_abc123",
"status": "CONFIRMED"
}
}booking.cancelledLa reserva fue cancelada por el cliente o por el profesional.
{
"type": "booking.cancelled",
"eventId": "evt_ghi789",
"data": {
"id": "bk_abc123",
"status": "CANCELLED",
"reason": "customer_request"
}
}payment.receivedStripe confirmó el pago de una reserva.
{
"type": "payment.received",
"eventId": "evt_pay001",
"data": {
"bookingId": "bk_abc123",
"amount": 5000,
"currency": "usd"
}
}Política de reintentos
Si tu endpoint no responde 2xx en menos de 10 segundos, Mati reintenta con backoff exponencial:
Intento 1: inmediato
Intento 2: +15 segundos
Intento 3: +1 minuto
Intento 4: +5 minutos
Intento 5: +30 minutos
Intento 6: +1 hora (final)
Después de 6 intentos fallidos, el webhook se marca como fallido y puedes reenviarlo manualmente desde el dashboard.
¿Listo para probar? Crea un endpoint de prueba con webhook.site y registra la URL en tu dashboard.