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
Verificar la firma
La firma es HMAC-SHA256 con tu secret sobre el texto <timestamp>.<body raw>, en hexadecimal. Cada intento se firma de nuevo con su propio timestamp, así que puedes rechazar firmas de hace más de 5 minutos sin perder reintentos legítimos. Formato del header:t=<ts>,v1=<hex>.
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
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
$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]);
});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.createdSe creó una reserva: desde el widget, el carrito, la API v1, el panel del negocio, el panel de WordPress o una serie recurrente.
{
"type": "booking.created",
"eventId": "evt_abc123",
"workspaceId": "ws_xyz",
"data": {
"id": "cmabc123",
"status": "PENDING",
"startAt": "2026-08-15T10:00:00.000Z",
"endAt": "2026-08-15T11:00:00.000Z",
"amount": 50,
"currency": "USD",
"paid": false,
"cartId": null,
"service": {
"id": "svc_1",
"name": "Consulta General",
"duration": 60
},
"customer": {
"name": "María García",
"email": "[email protected]"
},
"provider": {
"name": "Dra. López"
},
"source": "widget"
},
"sentAt": "2026-08-14T09:00:00.000Z"
}booking.confirmedLa reserva pasó a CONFIRMED: pago recibido, confirmación manual desde el panel o membresía. Salvo cuando viene de un pago de Stripe, `data` es la reserva completa; en ese caso solo trae `{ id, status, reason: "payment_received" }`.
{
"type": "booking.confirmed",
"eventId": "evt_def456",
"workspaceId": "ws_xyz",
"data": {
"id": "cmabc123",
"status": "CONFIRMED",
"reason": "payment_received"
},
"sentAt": "2026-08-14T09:01:00.000Z"
}booking.cancelledLa reserva pasó a CANCELLED: la canceló el cliente, el negocio o la API. Una cancelación repetida NO vuelve a emitir.
{
"type": "booking.cancelled",
"eventId": "evt_ghi789",
"workspaceId": "ws_xyz",
"data": {
"id": "cmabc123",
"status": "CANCELLED",
"startAt": "2026-08-15T10:00:00.000Z",
"endAt": "2026-08-15T11:00:00.000Z",
"amount": 50,
"currency": "USD",
"paid": false,
"cartId": null,
"service": {
"id": "svc_1",
"name": "Consulta General",
"duration": 60
},
"customer": {
"name": "María García",
"email": "[email protected]"
},
"provider": {
"name": "Dra. López"
},
"source": "widget",
"reason": "customer_request"
},
"sentAt": "2026-08-14T10:00:00.000Z"
}booking.completedLa reserva pasó a COMPLETED (cita atendida), marcada desde el panel del negocio o el panel de WordPress.
{
"type": "booking.completed",
"eventId": "evt_comp_001",
"workspaceId": "ws_xyz",
"data": {
"id": "cmabc123",
"status": "COMPLETED",
"startAt": "2026-08-15T10:00:00.000Z",
"endAt": "2026-08-15T11:00:00.000Z",
"amount": 50,
"currency": "USD",
"paid": false,
"cartId": null,
"service": {
"id": "svc_1",
"name": "Consulta General",
"duration": 60
},
"customer": {
"name": "María García",
"email": "[email protected]"
},
"provider": {
"name": "Dra. López"
},
"source": "widget"
},
"sentAt": "2026-08-15T11:05:00.000Z"
}booking.rescheduledLa reserva cambió de hora (la movió el cliente, el negocio, la API o la sesión a la que pertenece). `data.startAt`/`endAt` son los NUEVOS; los anteriores van en `previousStartAt`/`previousEndAt`. Si la mueve el cliente desde su portal, la reserva vuelve a PENDING (el negocio tiene que confirmarla otra vez), `data.status` lo refleja y `data.source` es `customer_portal`.
{
"type": "booking.rescheduled",
"eventId": "evt_resch_001",
"workspaceId": "ws_xyz",
"data": {
"id": "cmabc123",
"status": "CONFIRMED",
"startAt": "2026-08-16T10:00:00.000Z",
"endAt": "2026-08-16T11:00:00.000Z",
"amount": 50,
"currency": "USD",
"paid": false,
"cartId": null,
"service": {
"id": "svc_1",
"name": "Consulta General",
"duration": 60
},
"customer": {
"name": "María García",
"email": "[email protected]"
},
"provider": {
"name": "Dra. López"
},
"source": "api_v1",
"previousStartAt": "2026-08-15T10:00:00.000Z",
"previousEndAt": "2026-08-15T11:00:00.000Z"
},
"sentAt": "2026-08-14T12:00:00.000Z"
}payment.receivedUn CLIENTE del negocio pagó: dinero que ENTRA. `data` trae `bookingId` (una cita), `cartId` + `bookingIds` (varias citas de una misma visita) o `membershipId` (una membresía), y siempre `amount`, `currency` y `stripePaymentIntentId`. La factura de la suscripción del negocio a Mati NO viaja por aquí: eso es `subscription.invoice_paid`.
{
"type": "payment.received",
"eventId": "evt_pay_001",
"workspaceId": "ws_xyz",
"data": {
"bookingId": "cmabc123",
"amount": 50,
"currency": "USD",
"stripePaymentIntentId": "pi_xxx"
},
"sentAt": "2026-08-14T09:01:00.000Z"
}subscription.invoice_paidMati cobró la factura de la suscripción del negocio a Mati: dinero que SALE. No es un cobro a un cliente final (eso es `payment.received`) y el importe es lo que el negocio paga por su plan y sus módulos, no una venta suya.
{
"type": "subscription.invoice_paid",
"eventId": "evt_sub_001",
"workspaceId": "ws_xyz",
"data": {
"invoiceId": "in_1OaBcDeFgHiJkL",
"subscriptionId": "sub_1OaBcDeFgHiJkL",
"amount": 27,
"currency": "USD",
"hostedInvoiceUrl": "https://invoice.stripe.com/i/acct_1/live_abc123"
},
"sentAt": "2026-08-14T09:01:00.000Z"
}pingEvento de prueba enviado desde el dashboard para verificar la firma.
{
"type": "ping",
"eventId": "evt_ping_001",
"data": {
"sentAt": "2026-07-31T12:00:00Z",
"message": "Hello from Mati"
}
}Qué podrías hacer con cada evento
No es una API aparte: es la misma descripción del catálogo, en el formato que usarías para enrutar cada evento en tu propio código.
const ACCIONES_POR_EVENTO = [
{ evento: "booking.created", cuando: "Se creó una reserva: desde el widget, el carrito, la API v1, el panel del negocio, el panel de WordPress o una serie recurrente." },
{ evento: "booking.confirmed", cuando: "La reserva pasó a CONFIRMED: pago recibido, confirmación manual desde el panel o membresía. Salvo cuando viene de un pago de Stripe, `data` es la reserva completa; en ese caso solo trae `{ id, status, reason: \"payment_received\" }`." },
{ evento: "booking.cancelled", cuando: "La reserva pasó a CANCELLED: la canceló el cliente, el negocio o la API. Una cancelación repetida NO vuelve a emitir." },
{ evento: "booking.completed", cuando: "La reserva pasó a COMPLETED (cita atendida), marcada desde el panel del negocio o el panel de WordPress." },
{ evento: "booking.rescheduled", cuando: "La reserva cambió de hora (la movió el cliente, el negocio, la API o la sesión a la que pertenece). `data.startAt`/`endAt` son los NUEVOS; los anteriores van en `previousStartAt`/`previousEndAt`. Si la mueve el cliente desde su portal, la reserva vuelve a PENDING (el negocio tiene que confirmarla otra vez), `data.status` lo refleja y `data.source` es `customer_portal`." },
{ evento: "payment.received", cuando: "Un CLIENTE del negocio pagó: dinero que ENTRA. `data` trae `bookingId` (una cita), `cartId` + `bookingIds` (varias citas de una misma visita) o `membershipId` (una membresía), y siempre `amount`, `currency` y `stripePaymentIntentId`. La factura de la suscripción del negocio a Mati NO viaja por aquí: eso es `subscription.invoice_paid`." },
{ evento: "subscription.invoice_paid", cuando: "Mati cobró la factura de la suscripción del negocio a Mati: dinero que SALE. No es un cobro a un cliente final (eso es `payment.received`) y el importe es lo que el negocio paga por su plan y sus módulos, no una venta suya." },
];Política de reintentos
Si tu endpoint no responde 2xx en menos de 30 segundos, Mati lo considera fallido y reintenta con backoff exponencial. Son 5 intentos en total.
Entre intento e intento esperamos al menos 15 segundos, luego al menos 1 minuto, luego al menos 4 minutos, luego al menos 16 minutos. Si el último también falla, el webhook se marca como fallido en el panel.
Entrega, duplicados y orden
Los reintentos los recoge un proceso que corre cada 10 minutos: las esperas de arriba son mínimas, y cada reintento sale en el primer ciclo posterior a su espera.
Mati entrega cada evento al menos una vez. Si tu endpoint tarda en responder, o el intento en segundo plano coincide con un reintento, puedes recibir el mismo evento dos veces: guarda el X-Webhook-Event-ID (es el mismo eventId del cuerpo) de lo que ya procesaste y descarta los repetidos.
Tampoco hay garantía de orden: un reintento puede llegar después de un evento más nuevo. Ante la duda, consulta el estado actual con GET /bookings/{id} en vez de fiarte del orden de llegada.
Campos de data en los eventos booking.*
Las fechas (startAt, endAt, previousStartAt) son instantes UTC en ISO 8601. amount y currency son los del servicio, y paid dice si ya se cobró. reason aparece solo cuando hay un motivo (por ejemplo, el que mandaste al cancelar por la API). source dice por dónde entró el cambio:
widget— el widget embebido en la web del negociocart— el carrito del widget (varias citas de una misma visita)api_v1— esta API: POST /bookings, PATCH /bookings/{id} o POST .../cancelapi_legacy— la cancelación que hace el cliente desde el widgetdashboard— el panel del negociocustomer_portal— el portal del cliente (al reprogramar, la reserva vuelve a PENDING)wp_panel— el panel de WordPressmanual— una reserva anotada a mano por el negocioseries— una serie recurrentesession— una sesión programada (taller, clase)