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

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

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