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 respuestaQué hacemos
2xxEntregado. No hay más intentos.
5xx, 408, 429, timeout o error de redReintentamos con el backoff de arriba.
Cualquier otro 4xxAbandonamos 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:

ServicioDónde se configuraQué eventos manda
WhatsAppTus negociosLos de WhatsApp: message.*, template.*, tenant.* (con channel: "whatsapp").
InstagramInstagram y MessengerLos message.* con channel: "instagram".
MessengerInstagram y MessengerLos message.* con channel: "messenger".
Mercado PagoMercado Pagomp.payment.*.

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": "..."
}

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:

eventQué pasó
tenant.updateCambios 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_changedCambió la calidad o el límite de mensajería del número.
tenant.name_updateMeta aprobó o rechazó el nombre para mostrar del número.
tenant.capability_updateCambió alguna capacidad del negocio (límites, features habilitadas).
tenant.alertAlertas de la cuenta.
template.quality_changedCambió 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):

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).