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: es el webhook por defecto de tu cuenta. Si querés que un servicio mande sus eventos a otro sistema, dale su propio webhook. Cada evento llega como:
POST https://tu-dominio.com/sabado-webhook
Content-Type: application/json
X-Galgo-Signature: sha256=a3f5...
X-Galgo-Event-Id: 01a08e82-ee11-71ee-84d4-f81329deaa50
X-Galgo-Event-Type: message.received
X-Galgo-Attempt: 1
{
"event": "message.received",
"tenant_ref": "pizzeria-don-mario",
"channel": "whatsapp",
"data": {
/* payload específico del evento */
},
"timestamp": "2026-05-07T15:30:00Z"
}
El envelope (campos event,
tenant_ref, channel, 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.
channel dice por dónde llegó (whatsapp,
instagram o messenger), y viene en
null en los eventos que no son de mensajería (los de Mercado
Pago). Si solo integrás WhatsApp, en los mensajes siempre te va a llegar en
whatsapp.
Los headers llevan el prefijo X-Galgo-, no
X-Sabado-: son el contrato original de la plataforma y no los
renombramos con la marca para no romper las integraciones que ya están
andando. Son cuatro: X-Galgo-Signature (la firma),
X-Galgo-Event-Id (id del evento, UUID estable entre reintentos),
X-Galgo-Event-Type (repite el event del body) y
X-Galgo-Attempt (qué número de intento es este, arranca en 1).
Reintentos
Hasta 6 intentos (el primero + 5 reintentos), con backoff de 30 segundos, 2 minutos, 10 minutos, 1 hora y 6 horas — una ventana total de unas 7 horas y media. Cada intento espera tu respuesta 15 segundos.
| Tu respuesta | Qué hacemos |
|---|---|
2xx | Entregado. No hay más intentos. |
5xx, 408, 429, timeout o error de red | Reintentamos con el backoff de arriba. |
Cualquier otro 4xx | Abandonamos en el primer intento. Un 400/401/404 es tu sistema diciendo "esto está mal": reintentar no lo arregla. |
Si tu endpoint se cae, contestá 503 — nunca
4xx. Un 4xx quema el evento al primer
intento. Y validá la firma antes de decidir el código: si devolvés
401 por una firma mal validada, perdés el mensaje sin
reintento.
Agotados los 6 intentos la entrega queda abandonada y no se reenvía
sola: hoy no hay replay self-service. El evento queda
guardado de nuestro lado (lo vemos en el panel de administración), así que si
estuviste caído escribinos y lo recuperamos. En números con coexistencia, los
mensajes que mandó el negocio desde el celular y el historial previo se leen
por API con GET /v1/history.
Idempotencia del lado tuyo
El header X-Galgo-Event-Id es único por evento y estable entre
reintentos — el body no trae ese id. Si lo recibís dos veces
es porque tu lado falló la primera y reintentamos: no procesés dos veces,
respondé 200 y listo.
Un webhook por servicio
Por defecto, todos los eventos de tu cuenta salen por el webhook de Inicio. Si distintos sistemas atienden distintos servicios — por ejemplo, un bot que conversa por WhatsApp y un sistema de ventas que cobra con Mercado Pago — cada servicio puede tener su propio webhook. Se configura en la página de ese servicio del panel:
| Servicio | Dónde se configura | Qué eventos manda |
|---|---|---|
| Tus negocios | Los de WhatsApp: message.*, template.*, tenant.* (con channel: "whatsapp"). | |
| Instagram y Messenger | Los message.* con channel: "instagram". | |
| Messenger | Instagram y Messenger | Los message.* con channel: "messenger". |
| Mercado Pago | Mercado Pago | mp.payment.*. |
- Cada evento va a un solo lugar: al webhook de su servicio si existe, si no al de por defecto. No se duplica.
- Cada webhook tiene su propio secret. Validá la firma de cada uno con el suyo; rotar uno no afecta a los demás.
- Mismo formato, mismos headers y mismos reintentos que el de por defecto.
- Quitar el webhook propio lo devuelve al de por defecto, incluidos los eventos que estaban esperando un reintento.
- Si tu cuenta no tiene webhook por defecto, solo te llegan los eventos de los servicios que tengan uno propio.
Validar la firma
Cada POST viene con el header X-Galgo-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-Galgo-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_GALGO_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-Galgo-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.
{
"event": "message.received",
"tenant_ref": "pizzeria-don-mario",
"channel": "whatsapp",
"data": {
"message": {
"id": "wamid.HBg...",
"from": "5491155555555",
"timestamp": "1789520701",
"type": "text",
"text": { "body": "Hola, ¿está abierto?" }
},
"contacts": [
{ "wa_id": "5491155555555", "profile": { "name": "Mariana" } }
]
},
"timestamp": "2026-05-07T15:30:00Z"
}
data.message es el objeto del mensaje tal cual lo manda
Meta, sin normalizar ni renombrar: el contenido vive en la clave que
dice type (text, image,
audio, video, document,
sticker, location, contacts,
interactive, button, reaction,
order, system, unsupported…).
data.contacts trae el perfil del remitente cuando Meta lo manda.
Si el mensaje responde a otro, viene message.context con el
id del mensaje original — es lo que usás para correlacionar.
Cuando tocan un botón o eligen de una lista
Llega como un message.received más, con
type: "interactive". El id que vos pusiste
al mandar el botón o la fila vuelve tal cual — es la clave para saber
qué eligieron sin depender del texto visible.
// Botón (data.message)
{
"id": "wamid.HBg...",
"from": "5491155555555",
"timestamp": "1787677703",
"type": "interactive",
"context": { "id": "wamid.HBg... (tu mensaje con los botones)", "from": "5493364552906" },
"interactive": {
"type": "button_reply",
"button_reply": { "id": "confirmar_pedido_42", "title": "Confirmar" }
}
}
// Fila de una lista (data.message)
{
"id": "wamid.HBg...",
"from": "5491155555555",
"timestamp": "1787677912",
"type": "interactive",
"context": { "id": "wamid.HBg... (tu mensaje con la lista)", "from": "5493364552906" },
"interactive": {
"type": "list_reply",
"list_reply": { "id": "muzzarella_grande", "title": "Muzzarella grande", "description": "8 porciones" }
}
}
El botón de una plantilla es otra forma. El quick reply de
un template NO llega como interactive: llega con
type: "button" y
"button": { "text": "Sí", "payload": "Sí" }, donde
payload es lo que definiste en el botón de la plantilla.
Si tu integración manda plantillas con botones, manejá los dos casos.
Cuando mandan una foto, un audio o un archivo
// data.message
{
"id": "wamid.HBg...",
"from": "5491155555555",
"timestamp": "1789520701",
"type": "image",
"image": {
"id": "2377147539477971",
"url": "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=...&ext=1789521002&hash=...",
"mime_type": "image/jpeg",
"sha256": "zzYBA4RS67hwt7mPue4c0Js38xYmWSbRTaRJCq/I+1s=",
"caption": "¿Llega así?"
}
}
Mismo esquema para audio, video,
document y sticker (cambia la clave y los campos
propios del tipo: filename en documentos, voice en
audios, etc.).
No uses esa url. Es de Meta: vive
5 minutos y hay que pedirla con el token de Meta, que no
exponemos. Bajá el binario con
GET /v1/media/{id}
usando data.message.image.id: pedimos una URL fresca en el
momento, así que del lado tuyo no hay ventana de 5 minutos.
Cuando reaccionan a un mensaje
También es un message.received, con type: "reaction".
message_id es el wamid del mensaje al que
reaccionaron (puede ser uno tuyo o uno de ellos).
// data.message
{
"id": "wamid.HBg...",
"from": "5491155555555",
"timestamp": "1789493889",
"type": "reaction",
"reaction": { "emoji": "👍", "message_id": "wamid.HBg... (el mensaje reaccionado)" }
}
Cuando la persona quita la reacción, Meta manda el mismo
evento con el emoji vacío.
message.status
Cambió el estado de un mensaje que enviaste vía
POST /v1/messages.
{
"event": "message.status",
"tenant_ref": "pizzeria-don-mario",
"channel": "whatsapp",
"data": {
"status": {
"id": "wamid.HBg...",
"status": "delivered",
"timestamp": "1789097337",
"recipient_id": "5491155555555",
"conversation": { "id": "...", "origin": { "type": "utility" } },
"pricing": { "billable": true, "category": "utility", "pricing_model": "PMP" }
}
},
"timestamp": "..."
}
data.status es el objeto de estado crudo de Meta.
data.status.status: sent, delivered,
read, failed. Si es failed viene
data.status.errors (una lista, con code y
title del error de Meta). data.status.id es el
waba_message_id que te devolvió
POST /v1/messages.
Solo te llegan estados de mensajes que salieron por la API. Lo que el negocio
manda desde el celular no genera message.status sino
message.echo.
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.
Los ecos —y los chats previos que Meta sincroniza al onboardear— también
quedan guardados y se pueden leer por API con
GET /v1/history
(minado del historial, backfill si tu endpoint estuvo caído, etc.).
template.status_changed
Meta aprobó / rechazó / actualizó una plantilla del negocio.
{
"event": "template.status_changed",
"tenant_ref": "pizzeria-don-mario",
"channel": "whatsapp",
"data": {
"event": "APPROVED",
"reason": "NONE",
"message_template_id": "27686102984401620",
"message_template_name": "pedido_listo",
"message_template_category": "UTILITY",
"message_template_language": "es_AR"
},
"timestamp": "..."
}
Ojo con los nombres: data es el payload crudo de Meta, así que el
estado nuevo viene en data.event (APPROVED,
REJECTED, …) y el motivo en data.reason
(NONE cuando no hay). Es el evento que cierra el ciclo de
POST /v1/templates: creás
la plantilla en PENDING y Meta resuelve minutos u horas después.
Si preferís preguntar en vez de esperar, está
GET /v1/templates/{name}.
tenant.review_update
Cambio de estado de la revisión de la cuenta de WhatsApp del negocio (Meta revisa periódicamente).
{
"event": "tenant.review_update",
"tenant_ref": "pizzeria-don-mario",
"channel": "whatsapp",
"data": {
"decision": "APPROVED" /* o RESTRICTED, BANNED, etc. */
},
"timestamp": "..."
}
Otros eventos de la cuenta
Todo lo que Meta manda sobre la cuenta y el número se te reenvía con el
payload crudo en data:
| event | Qué pasó |
|---|---|
tenant.update | Cambios en el vínculo con la cuenta de WhatsApp del negocio: PARTNER_ADDED, PARTNER_APP_INSTALLED, PARTNER_REMOVED, PARTNER_APP_UNINSTALLED (el evento concreto viene en data.event). |
tenant.quality_changed | Cambió la calidad o el límite de mensajería del número. |
tenant.name_update | Meta aprobó o rechazó el nombre para mostrar del número. |
tenant.capability_update | Cambió alguna capacidad del negocio (límites, features habilitadas). |
tenant.alert | Alertas de la cuenta. |
template.quality_changed | Cambió la calidad de una plantilla. |
Mirá el tenant.update con PARTNER_REMOVED.
Significa que el negocio dejó de estar vinculado a la plataforma y
ese número deja de recibir y de enviar. No siempre lo
decide una persona: Meta desinstala la app sola cuando el número pasa
demasiado tiempo sin actividad, y ahí data.disconnection_info
trae reason: "PRIMARY_INACTIVITY" e
initiated_by: "SYSTEM". Se recupera re-conectando el número
por Embedded Signup.
Si Meta empieza a mandar un tipo de evento que todavía no mapeamos, te llega
como meta.<nombre_del_field> con el payload crudo:
nunca descartamos un evento.
Eventos de Instagram y Messenger
Los canales sociales usan los mismos eventos que
WhatsApp; lo que cambia es el channel del envelope
(instagram o messenger) y que los ids de las
personas son PSID/IGSID en vez de teléfonos. Ver
Instagram y Messenger.
message.received
Alguien le escribió un mensaje directo al negocio.
{
"event": "message.received",
"tenant_ref": "pizzeria-don-mario",
"channel": "instagram",
"data": {
"message": {
"mid": "m_AbCdEf...",
"text": "Hola, ¿hacen delivery?"
},
"from": "6789012345678901",
"to": "17841400000000000"
},
"timestamp": "2026-09-07T15:30:00Z"
}
El from es el id que tenés que usar como to al
responder. Si el mensaje trae adjuntos, vienen en
data.message.attachments con su URL — en estos canales las
descargás directamente, no hace falta el proxy de media.
message.status
Cambió el estado de un mensaje que enviaste. En estos canales hay
delivered y read; el read aplica a
todo lo enviado a esa persona hasta ese momento, así que puede venir con
varios message_ids.
{
"event": "message.status",
"tenant_ref": "pizzeria-don-mario",
"channel": "messenger",
"data": {
"status": "read",
"watermark": 1757254200000,
"recipient_id": "7654321098765432",
"message_ids": ["m_AbCdEf...", "m_GhIjKl..."]
},
"timestamp": "..."
}
message.echo
Alguien del negocio contestó a mano desde la app de Messenger o de Instagram. Es la señal para que frenes al bot en esa conversación: un humano la tomó. (Es el equivalente al eco de la coexistencia de WhatsApp.) Los mensajes que enviaste vos por la API no generan este evento.
{
"event": "message.echo",
"tenant_ref": "pizzeria-don-mario",
"channel": "messenger",
"data": {
"message": {
"mid": "m_MnOpQr...",
"is_echo": true,
"text": "Sí, hasta las 23 hacemos delivery"
},
"peer": "7654321098765432"
},
"timestamp": "..."
}
message.postback
La persona tocó uno de los botones que mandaste (quick replies o botones
de plantilla de Messenger). El payload es el que definiste
vos.
{
"event": "message.postback",
"tenant_ref": "pizzeria-don-mario",
"channel": "messenger",
"data": {
"postback": {
"mid": "m_StUvWx...",
"title": "Delivery",
"payload": "DELIVERY"
},
"from": "7654321098765432"
},
"timestamp": "..."
}
message.reaction
La persona reaccionó con un emoji a un mensaje del negocio.
{
"event": "message.reaction",
"tenant_ref": "pizzeria-don-mario",
"channel": "instagram",
"data": {
"reaction": {
"mid": "m_AbCdEf...",
"action": "react",
"emoji": "❤️"
},
"from": "6789012345678901"
},
"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",
"channel": null,
"data": {
"payment_id": "123456789",
"external_reference": "ticket-42",
"status": "approved",
"status_detail": "accredited",
"payment_type_id": "credit_card",
"transaction_amount": 46000,
"amount_refunded": 0,
"marketplace_fee": 690,
"mp_fee": 1380,
"net_received": 43930,
"currency": "ARS",
"date_created": "2026-05-28T16:59:58.000-04:00",
"date_approved": "2026-05-28T17:00:00.000-04:00",
"refunds": []
},
"timestamp": "2026-05-28T21:00:03+00:00"
}
data tiene la misma forma que la
consulta de pagos, así que lo podés
procesar con el mismo código venga por donde venga.
date_approved es cuándo lo aprobó Mercado Pago (no cuándo te
llegó el webhook): usalo para decidir si el pago entró dentro de una reserva.
channel viene en null: un pago no es un canal de
mensajería.
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.
Y los de estado y tiempo: status_detail (el motivo fino de
Mercado Pago, ej. accredited), payment_type_id
(credit_card, debit_card, account_money,
ticket…), date_created, date_approved y
refunds (las devoluciones hechas).
Los webhooks de Mercado Pago no tienen techo de demora. Normalmente llegan en segundos, pero si algo falla Mercado Pago reintenta cada 15 minutos y después cada vez más espaciado. No midas el margen de una reserva contra el webhook: al vencerla, consultá el pago.
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).