Webhooks
Cuando pasa algo relevante en cualquiera de los servicios conectados (entra un mensaje, cambia el estado de un envío, un pago se aprueba o se reembolsa, una plantilla se aprueba…) te mandamos un POST firmado a tu URL. Una sola URL, un solo formato de envelope, un solo secret para todos los eventos.
Recibir eventos
Configurá tu URL en Inicio del panel. Cada evento llega como:
POST https://tu-dominio.com/sabado-webhook
Content-Type: application/json
X-Sabado-Signature: sha256=a3f5...
X-Sabado-Event: message.received
{
"id": "evt_019dcf...",
"event": "message.received",
"tenant_ref": "pizzeria-don-mario",
"data": {
/* payload específico del evento */
},
"timestamp": "2026-05-07T15:30:00Z"
}
El envelope (campos id, event,
tenant_ref, data, timestamp) es
el mismo para todos los eventos — solo cambia el contenido de
data. Tu integración puede tener un único handler que
rutee por event al sub-handler que corresponda.
Reintentos
Si tu URL devuelve algo distinto de 2xx, reintentamos con
backoff: 30 segundos, 2 minutos, 10 minutos, 1 hora, 6 horas. Si
después de eso seguimos fallando, marcamos la entrega como
abandonada — podés reintentar manualmente desde el panel
cuando arregles tu sistema.
Idempotencia del lado tuyo
El campo id del envelope es único por evento y estable
entre retries. Si lo recibís dos veces con el mismo id,
es porque tu lado falló la primera y reintentamos — no procesés dos
veces; respondé 200 y listo.
Validar la firma
Cada POST viene con el header X-Sabado-Signature que es el
HMAC-SHA256 del body crudo, usando tu secret.
Validalo siempre antes de procesar.
Node.js
const crypto = require('crypto');
function validarFirma(rawBody, headerFirma, secret) {
const esperado = 'sha256=' +
crypto.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(esperado),
Buffer.from(headerFirma)
);
}
// En tu handler de Express:
app.post('/sabado-webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const firma = req.header('X-Sabado-Signature');
if (!validarFirma(req.body, firma, process.env.SABADO_SECRET)) {
return res.status(401).end();
}
const evento = JSON.parse(req.body);
// procesar evento...
res.status(200).end();
});
PHP
function validarFirma(string $body, string $headerFirma, string $secret): bool {
$esperado = 'sha256=' . hash_hmac('sha256', $body, $secret);
return hash_equals($esperado, $headerFirma);
}
// En tu controller:
$body = file_get_contents('php://input');
$firma = $_SERVER['HTTP_X_SABADO_SIGNATURE'] ?? '';
if (!validarFirma($body, $firma, env('SABADO_SECRET'))) {
http_response_code(401);
exit;
}
$evento = json_decode($body, true);
// procesar evento...
Python
import hmac
import hashlib
def validar_firma(body: bytes, header_firma: str, secret: str) -> bool:
esperado = 'sha256=' + hmac.new(
secret.encode(),
body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(esperado, header_firma)
# En Flask:
@app.post('/sabado-webhook')
def webhook():
body = request.get_data()
firma = request.headers.get('X-Sabado-Signature', '')
if not validar_firma(body, firma, os.environ['SABADO_SECRET']):
return '', 401
evento = json_loads(body)
# procesar evento...
return '', 200
Importante. Validá siempre sobre el body crudo (los bytes exactos que llegaron), no sobre el JSON parseado. Cualquier diferencia de espacios o orden de campos rompe la firma.
Eventos de WhatsApp
message.received
Entró un mensaje del cliente final al negocio.
{
"id": "evt_019dcf...",
"event": "message.received",
"tenant_ref": "pizzeria-don-mario",
"data": {
"from": "5491155555555",
"type": "text",
"text": { "body": "Hola, ¿está abierto?" },
"waba_message_id": "wamid.HBg...",
"received_at": "2026-05-07T15:30:00Z"
},
"timestamp": "2026-05-07T15:30:00Z"
}
El data trae el tipo de mensaje (text,
image, audio, video,
document, location, contacts,
interactive, etc.) con el payload específico. Para
bajar media adjunta usá
GET /v1/media/{id}.
message.status
Cambió el estado de un mensaje que enviaste vía
POST /v1/messages.
{
"event": "message.status",
"tenant_ref": "pizzeria-don-mario",
"data": {
"id": "019dcf...",
"waba_message_id": "wamid.HBg...",
"status": "delivered",
"to": "5491155555555",
"updated_at": "2026-05-07T15:30:12Z"
},
"timestamp": "..."
}
data.status: sent, delivered,
read, failed. Si es failed,
viene también error_code y error_message.
message.echo
Solo en números con coexistencia. El negocio respondió a un cliente desde la app de WhatsApp Business en su celular (no por la API). Te lo reenviamos como eco para que sepas que una persona entró en la conversación.
Usalo para frenar el bot. Si tenés un bot respondiendo
automáticamente, al recibir message.echo conviene
pausarlo para ese contacto: el dueño tomó la charla a
mano. Es la señal de "human takeover".
{
"event": "message.echo",
"tenant_ref": "pizzeria-don-mario",
"data": {
"peer": "5491155555555",
"message": {
"from": "5493412004406",
"to": "5491155555555",
"id": "wamid.HBg...",
"type": "text",
"text": { "body": "Sí, hasta las 23 🍕" },
"timestamp": "1730002000"
}
},
"timestamp": "..."
}
peer: el cliente final (el otro lado de la conversación).message.fromes el número del negocio — el eco es siempre saliente (del negocio hacia el cliente).- Tus propios envíos por
POST /v1/messagesNO generan eco (eso ya lo sabés pormessage.status); el eco es solo lo que se manda desde el celular.
template.status_changed
Meta aprobó / rechazó / actualizó una plantilla del negocio.
{
"event": "template.status_changed",
"tenant_ref": "pizzeria-don-mario",
"data": {
"template_id": "TPL123",
"name": "pedido_listo",
"language": "es",
"status": "APPROVED",
"rejected_reason": null
},
"timestamp": "..."
}
account.review
Cambio de estado de la cuenta de WhatsApp del negocio (Meta revisa periódicamente).
{
"event": "account.review",
"tenant_ref": "pizzeria-don-mario",
"data": {
"decision": "APPROVED" /* o RESTRICTED, BANNED, etc. */
},
"timestamp": "..."
}
Eventos de MercadoPago
Cuando ocurre algo en un cobro hecho con
POST /v1/mercadopago/preferences,
te lo reenviamos firmado. Antes de despachártelo, la plataforma
verifica el pago contra la API de Mercado Pago — no confiamos solo
en la notificación cruda de MP.
mp.payment.approved
Un pago de un cobro del comercio quedó aprobado. Acá confirmás la venta (emitís la entrada/QR, sumás cupo, etc.).
{
"event": "mp.payment.approved",
"tenant_ref": "bruno-traslados",
"data": {
"external_reference": "ticket-42",
"payment_id": "123456789",
"status": "approved",
"transaction_amount": 46000,
"amount_refunded": 0,
"marketplace_fee": 690,
"mp_fee": 1380,
"net_received": 43930
},
"timestamp": "2026-05-28T20:00:00+00:00"
}
external_reference es el que mandaste al crear la
preferencia — usalo para cruzar con tu venta. Campos numéricos
(todos en la moneda de la preferencia, típicamente ARS):
transaction_amount— Bruto que pagó el comprador.marketplace_fee— Comisión de Sabado (1,5%).mp_fee— Comisión de Mercado Pago, extraída delfee_detailsdel pago. Puede ser0si MP todavía no la informa.net_received— Lo que efectivamente recibe el vendedor (transaction_amount − mp_fee − marketplace_fee).amount_refunded— Monto reembolsado acumulado del pago (0 si no hubo devolución). Vermp.payment.partial_refund.
mp.payment.rejected
El pago fue rechazado o cancelado
(rejected / cancelled). Mismo shape que
approved (incluido mp_fee y
net_received, que serán 0 si MP no cobró nada);
data.status trae el estado real.
mp.payment.refunded
El pago fue reembolsado o sufrió un contracargo
(refunded / charged_back). Acá
invalidás la entrada/venta. Te llega tanto si el
refund lo disparaste vos con
POST /v1/mercadopago/refunds
como si lo hizo el comercio o el comprador desde Mercado Pago.
Mismo shape que approved; data.status trae
refunded o charged_back.
mp.payment.partial_refund
El pago tuvo una devolución parcial: sigue
approved pero se reembolsó una parte del total. Acá
ajustás la venta en proporción (no la invalidás del todo). Mismo shape
que approved; mirá amount_refunded (monto
reembolsado acumulado) contra
transaction_amount.
{
"event": "mp.payment.partial_refund",
"tenant_ref": "bruno-traslados",
"data": {
"external_reference": "ticket-42",
"payment_id": "123456789",
"status": "approved",
"transaction_amount": 46000,
"amount_refunded": 15000,
"marketplace_fee": 690,
"mp_fee": 1380,
"net_received": 43930
},
"timestamp": "2026-06-03T20:00:00+00:00"
}
Si después se reembolsa el resto (o el acumulado llega al total),
recibís mp.payment.refunded. net_received es
el neto del cobro original (sin descontar la devolución); restá
amount_refunded para el neto efectivo.
Dedup interno. Sabado deduplica por
payment_id + evento. Si MP notifica el mismo cambio
dos veces te llega una sola — pero un refunded
posterior al approved del mismo pago sí te llega
(son eventos distintos). Cada partial_refund de monto
acumulado distinto también se reenvía (varias devoluciones parciales
sobre el mismo pago).