Webhooks
Recibe eventos en tu sistema, HMAC + retries.
Cuando algo pasa en Replai — un mensaje entra, una sesión se conecta, un envío falla — firmamos un payload con HMAC-SHA256 (estilo Stripe) y lo POSTeamos a tu endpoint. Si tu endpoint falla, reintentamos con backoff exponencial.
Eventos disponibles
| Evento | Cuándo dispara |
|---|---|
message.received | Llegó un mensaje del cliente. |
message.sent | El worker envió un outbound (bot o API). |
message.delivered | WhatsApp confirmó entrega (2 checks). |
message.read | El destinatario abrió el chat (2 checks azules). |
message.failed | Envío falló tras todos los reintentos. |
session.connected | Sesión WhatsApp viva. |
session.disconnected | Sesión cayó (con reason). |
session.qr_generated | QR listo para escanear. |
session.logged_out | WhatsApp invalidó las credenciales. |
Dos maneras de recibir webhooks
Modo gateway (Bot externo). Si quieres que Replai actúe solo como pasarela de WhatsApp y tu propio bot maneje la conversación, activa Bot externo en el detalle de la sesión (Dashboard → Sesiones → tu sesión → tarjeta "Quién responde"). Replai deja de responder con su IA y registra automáticamente un endpoint webhook con la URL + secret que indiques. Te entrega los 4 valores que tu bot necesita:
REPLAI_TENANT_ID=cmp... REPLAI_SESSION_ID=cmp... REPLAI_WEBHOOK_SECRET=whsec_... REPLAI_API_KEY=rk_live_... # para responder vía POST /v1/messages
El endpoint queda atado a esa sesión (no recibe eventos de otras sesiones del mismo tenant). Si rotás el secret o cambiás la URL desde el toggle, el endpoint se actualiza en vivo.
Modo subscripción manual. Si querés recibir eventos a nivel tenant-wide (todas las sesiones), o múltiples endpoints con filtros distintos de eventos, registralos manualmente desde el dashboard → Configuración → Webhooks → + Nuevo, o vía API:
curl -X POST https://app.replai.tech/api/v1/webhooks \
-H "authorization: Bearer rk_live_..." \
-H "content-type: application/json" \
-d '{
"name": "Mi sistema",
"url": "https://mi-app.com/replai/webhook",
"events": ["message.received", "message.failed"]
}'La respuesta contiene un secret — guárdalo ahí mismo, nunca te lo volveremos a mostrar. events: [] = subscribirse a todo.
Endpoints relacionados
| Método | Ruta | Para qué |
|---|---|---|
GET | /v1/sessions/:id/external-config | Lee el estado del toggle: botMode, webhookUrl, webhookSecret. |
PUT | /v1/sessions/:id/external-config | Activa/desactiva Bot externo, setea URL, rota secret, genera API key. Body: { enabled, webhookUrl?, rotateSecret?, mintApiKey? }. |
POST | /v1/webhooks | Registra un endpoint tenant-wide. |
Forma del POST
Cada delivery te llega así:
POST https://tu-app.com/replai/webhook
Content-Type: application/json
User-Agent: Replai-Webhook/1
X-Replai-Event: message.received
X-Replai-Delivery-Id: 5f4d0c12-7c9e-4a8d-b04f-...
X-Replai-Timestamp: 1779916523123
X-Replai-Signature: t=1779916523123,v1=4a8d1c9b7e0f...
# Aliases para receivers genéricos (mismo valor, sin firmar de nuevo):
X-Webhook-Event: message.received
X-Webhook-Delivery-Id: 5f4d0c12-...
X-Webhook-Timestamp: 1779916523123
X-Webhook-Signature: t=1779916523123,v1=4a8d1c9b7e0f...
{
"event": "message.received",
"deliveryId": "5f4d0c12-7c9e-4a8d-b04f-...",
"payload": {
"sessionId": "cmp...",
"messageId": "cmq...",
"remoteJid": "584141234567@s.whatsapp.net",
"keyId": "3EB0A1B2C3D4E5F6...",
"body": { "type": "text", "text": "hola" },
"pushName": "Juan Pérez"
}
}Tipos del campo payload.body
Es una unión discriminada por type. No asumas que payload.body.text siempre existe — chequeá el discriminator primero.
type MessageBody =
| { type: 'text'; text: string }
| { type: 'image'; caption?: string; mimeType?: string }
| { type: 'video'; caption?: string; mimeType?: string; seconds?: number; isPtv?: boolean }
| { type: 'audio'; mimeType?: string; seconds?: number; isPtt?: boolean; transcript?: string }
| { type: 'document'; filename?: string; mimeType?: string; pageCount?: number; caption?: string }
| { type: 'sticker'; mimeType?: string; isAnimated?: boolean }
| { type: 'location'; latitude?: number; longitude?: number; name?: string; address?: string }
| { type: 'contact'; displayName?: string; count?: number }
| { type: 'poll'; name?: string; options?: string[] }
| { type: 'reaction'; emoji?: string; targetKeyId?: string }
| { type: 'view_once'; inner: MessageBody }
| { type: 'unsupported'; original: string };Para audio, la transcripción vive en body.transcript (la corre Replai si el tenant tiene una OpenAI key configurada).
Verificar la firma
La firma viene en un solo header X-Replai-Signature con dos campos separados por coma:
X-Replai-Signature: t=<unix_ms>,v1=<hex> donde: v1 = hex( hmac_sha256( secret, "<t>.<rawBody>" ) )
Importante: el timestamp está en milisegundos, no en segundos. El X-Replai-Timestamp echo es informativo — todo lo que necesitás está dentro de X-Replai-Signature. Verificá sobre el body crudo recibido (Buffer), no sobre un JSON.stringify(parsed) que cambia el orden de las keys y rompe el HMAC.
Node con el SDK:
import { verifyWebhookSignature } from '@replai/sdk';
app.post('/replai/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyWebhookSignature({
rawBody: req.body, // ← Buffer, NOT parsed JSON
signatureHeader: req.header('x-replai-signature') ?? '',
secret: process.env.REPLAI_WEBHOOK_SECRET!,
});
if (!ok) return res.status(401).send('bad signature');
const event = JSON.parse(req.body.toString());
// event = { event, deliveryId, payload }
res.send({ ok: true });
});Node sin SDK (referencia, ~20 líneas):
import crypto from 'node:crypto';
function verifyReplai(secret, rawBody, signatureHeader, toleranceMs = 5 * 60_000) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => p.split('=', 2).map((s) => s.trim())),
);
const t = parts.t, sig = parts.v1;
if (!t || !sig) return false;
if (Math.abs(Date.now() - Number(t)) > toleranceMs) return false;
const expected = crypto.createHmac('sha256', secret)
.update(`${t}.${rawBody.toString('utf8')}`).digest('hex');
const a = Buffer.from(sig, 'hex');
const b = Buffer.from(expected, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Python:
import hmac, hashlib, time
def verify_replai(secret: str, raw_body: bytes, signature_header: str,
tolerance_ms: int = 5 * 60 * 1000) -> bool:
parts = dict(p.strip().split("=", 1) for p in signature_header.split(",") if "=" in p)
t, sig = parts.get("t"), parts.get("v1")
if not t or not sig:
return False
if abs(int(time.time() * 1000) - int(t)) > tolerance_ms:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)Responder al visitante
Cuando tu bot quiera contestar, llamá POST /v1/messages con tu REPLAI_API_KEY como Bearer:
curl -X POST https://app.replai.tech/api/v1/messages \
-H "authorization: Bearer $REPLAI_API_KEY" \
-H "content-type: application/json" \
-d '{
"sessionId": "cmp...",
"to": "584141234567@s.whatsapp.net",
"text": "¡Hola! Gracias por escribir."
}'to acepta el JID completo o el msisdn pelado (lo normalizamos). Para media, reemplazá text por media: { kind: 'image' | 'audio' | 'document', ... }. Respuestas: 200 { status: 'QUEUED' } en éxito, 400 SESSION_NOT_CONNECTED si el slot está caído, 429 RATE_LIMITED si pegaste el cap de tokens del plan.
Reintentos
Replai considera la entrega exitosa con cualquier 2xx. Cualquier otra cosa (timeout, 4xx, 5xx) entra en cola de reintentos con backoff exponencial:
intento 1 → inmediato intento 2 → +30s intento 3 → +2min intento 4 → +10min intento 5 → +1h después → DLQ (dead-letter, sin más reintentos)
Si tu endpoint da 4xx permanente (404, 410), pausamos automáticamente el webhook a las 5 fallas seguidas para no spammearte. Lo reactivás desde el dashboard.
Idempotencia
X-Replai-Delivery-Id es estable por intento de entrega. Si reintentamos por 5xx o timeout, llega el mismo deliveryId. Guardá los últimos N en una cache (Redis SET con TTL de ~1h) y devolvé 200 sin reprocesar si ya lo viste.
Protección contra replays
El verificador del SDK rechaza por defecto deliveries con timestamp fuera del rango de ±5 min (tolerancia configurable vía toleranceMs). Esto previene replays si alguien intercepta una request vieja.