Ir al contenido

Webhooks y eventos

El Canal API entrega los resultados de cada conversación por webhook: tu endpoint HTTPS recibe un POST por cada evento.

Tu endpoint debe responder HTTP 200 dentro de 10 segundos. Si no, OvertakerAI reintenta hasta 4 veces con espera creciente (10s, 30s, 2min, 10min). Pasados los 4 intentos el evento se marca como fallido y se notifica en el panel.

El webhook se cuelga de la API key: cada key tiene su propia webhook_url y su propio webhook_secret.

  • La webhook_url y el webhook_secret se configuran al crear la API key, desde el panel del tenant → pestaña Canal APINueva API Key. El webhook_secret se muestra una sola vez; guardalo.
  • No hay endpoint del Canal API para editar la webhook_url de una key existente. Para cambiarla, rotá la key (que genera credenciales nuevas) o creá una key nueva con la URL correcta desde el panel.
  • Para rotar solo el secret sin perder eventos, usá la rotación de webhook secret (no cambia la URL).

Una key puede declarar a qué eventos se suscribe. La regla:

  • Sin suscripción declarada → recibís todos los eventos del catálogo salvo los marcados como opt-in.
  • Con suscripción declarada → recibís solo los eventos que listaste.

Hoy el único evento opt-in es contact.updated (transporta datos arbitrarios del contacto, así que requiere consentimiento explícito). Tu key debe estar suscrita a contact.updated para recibirlo.

X-Overtaker-Event: message.response
X-Overtaker-Signature: sha256=<hmac_hex>
X-Overtaker-Version: 1
{
"event": "message.response",
"session_id": "user-123",
"user_email": "cliente@empresa.com",
"message_id": "uuid-del-mensaje-original",
"conversation_id": "uuid-de-la-conversacion",
"response": {
"text": "Hola, con gusto te ayudo. ¿En qué puedo asistirte?",
"quick_replies": ["Ver planes", "Hablar con un asesor"]
},
"timestamp": "2026-05-27T12:00:00Z"
}

quick_replies son sugerencias para mostrar como botones en tu UI. El usuario puede ignorarlas y escribir libremente. Pueden venir 0, 1 o más opciones (lista vacía [] si no hay).

lead.captured — el bot capturó datos del usuario

Sección titulada «lead.captured — el bot capturó datos del usuario»
{
"event": "lead.captured",
"session_id": "user-123",
"user_email": "cliente@empresa.com",
"fields": {
"name": "María González",
"email": "maria@empresa.com",
"phone": "+56912345678"
},
"timestamp": "2026-05-27T12:01:30Z"
}

contact.updated — se creó o cambió un dato del contacto

Sección titulada «contact.updated — se creó o cambió un dato del contacto»

Evento genérico de contacto. Se dispara cada vez que el agente crea o actualiza datos del contacto durante la conversación, y trae solo lo que cambió en esa interacción. Es el evento indicado para mantener sincronizados, en tu sistema, los datos que el agente va capturando (formularios o encuestas conversacionales, perfiles que se completan de a poco, etc.).

{
"event": "contact.updated",
"event_id": "evt_8f3a2b9c...",
"session_id": "user-123",
"contact_id": "uuid-del-contacto",
"changes": {
"empresa": "Acme S.A.",
"presupuesto": "50k-100k"
},
"timestamp": "2026-05-27T12:02:00Z"
}
  • changes trae solo los campos nuevos o corregidos en esa interacción. La primera vez que aparecen datos del contacto, trae todo el estado inicial.
  • Las claves de changes son arbitrarias: las define la configuración del agente (qué datos captura). Pueden ser datos de identidad (full_name, email) o campos propios de tu vertical (empresa, presupuesto, …).
  • Si el usuario corrige un dato, el evento se re-emite con el valor actualizado.
  • event_id es único por evento — usalo (o el header X-Overtaker-Delivery) para descartar duplicados de reintentos.
  • Es opt-in: tu key debe estar suscrita a contact.updated (ver Suscripción de eventos).
  • ¿Cómo se eligen las claves de changes? Las define la configuración del agente. Para nombres estables y controlados, ver Completar datos estructurados.

handoff.requested — el bot escala a un humano

Sección titulada «handoff.requested — el bot escala a un humano»
{
"event": "handoff.requested",
"session_id": "user-123",
"user_email": "cliente@empresa.com",
"reason": "El usuario solicitó hablar con un agente humano",
"conversation_url": "https://app.overtaker.online/inbox?conversation=uuid",
"timestamp": "2026-05-27T12:05:00Z"
}

ticket.created · ticket.state_changed · ticket.resolved — tickets de soporte

Sección titulada «ticket.created · ticket.state_changed · ticket.resolved — tickets de soporte»
// ticket.created
{ "event": "ticket.created", "tenant_id": "...", "timestamp": "...",
"data": { "ticket_number": "SUP-00042", "lead_id": "uuid",
"status": "ABIERTO", "category": "técnico", "urgency": "alta",
"description": "Error al exportar PDF..." } }
// ticket.state_changed
{ "event": "ticket.state_changed", "tenant_id": "...", "timestamp": "...",
"data": { "ticket_number": "SUP-00042",
"old_status": "ABIERTO", "new_status": "EN_ESPERA" } }
// ticket.resolved
{ "event": "ticket.resolved", "tenant_id": "...", "timestamp": "...",
"data": { "ticket_number": "SUP-00042",
"resolution": "Parche aplicado. Problema resuelto." } }

Estados posibles: NUEVO · ABIERTO · PENDIENTE · EN_ESPERA · RESUELTO · CERRADO.

tenant.limit_warning · tenant.limit_reached — solo Partners OEM

Sección titulada «tenant.limit_warning · tenant.limit_reached — solo Partners OEM»
// tenant.limit_warning — 80% de la cuota mensual
{ "event": "tenant.limit_warning", "tenant_id": "tenant-hijo-uuid",
"timestamp": "2026-05-27T12:00:00Z",
"data": { "usage_percent": 80, "tenant_name": "Cliente Ejemplo S.A." } }
// tenant.limit_reached — 100% de la cuota mensual
{ "event": "tenant.limit_reached", "tenant_id": "tenant-hijo-uuid",
"timestamp": "2026-05-27T12:00:00Z",
"data": { "usage_percent": 100, "tenant_name": "Cliente Ejemplo S.A." } }

message.received · message.sent · lead.created — solo Partners OEM

Sección titulada «message.received · message.sent · lead.created — solo Partners OEM»

Eventos de ciclo de vida de la conversación de un tenant hijo, enviados al webhook del Partner OEM (la cuenta padre), no al webhook del Canal API del tenant.

// message.received — llegó un mensaje entrante (se dispara antes de procesarlo)
{ "event": "message.received", "tenant_id": "tenant-hijo-uuid", "timestamp": "2026-05-27T12:00:00Z",
"data": { "conversation_id": "uuid", "message_preview": "primeros 120 caracteres…", "channel": "WHATSAPP" } }
// message.sent — el bot respondió
{ "event": "message.sent", "tenant_id": "tenant-hijo-uuid", "timestamp": "2026-05-27T12:00:05Z",
"data": { "conversation_id": "uuid", "response_preview": "primeros 120 caracteres…" } }
// lead.created — se creó un lead nuevo en el tenant hijo
{ "event": "lead.created", "tenant_id": "tenant-hijo-uuid", "timestamp": "2026-05-27T12:00:06Z",
"data": { "lead_id": "uuid", "phone": "+569…", "funnel_stage": "NEW" } }

Cada webhook incluye X-Overtaker-Signature: sha256=<hmac>. Verificá siempre esta firma antes de procesar el evento — descartá cualquier POST que no la tenga o no coincida.

// Node.js (Express) — firmá sobre el BODY CRUDO, no el JSON parseado.
const crypto = require("crypto");
const secret = "tu_webhook_secret";
// express.raw deja req.body como Buffer (los bytes exactos recibidos).
// NO uses express.json en esta ruta: re-serializar el JSON cambia el orden/espacios
// y la firma deja de coincidir.
app.post("/webhook", express.raw({ type: "*/*" }), (req, res) => {
const signature = req.headers["x-overtaker-signature"] || "";
const computed = "sha256=" + crypto.createHmac("sha256", secret).update(req.body).digest("hex");
// El header puede traer 1 o 2 firmas (rotación): "sha256=a" o "sha256=a,sha256=b".
const valid = signature.split(",").some((sig) => {
sig = sig.trim();
return sig.length === computed.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(computed));
});
if (!valid) return res.status(401).send("Invalid signature");
const event = JSON.parse(req.body.toString("utf8")); // parseá DESPUÉS de verificar
// procesar evento...
res.sendStatus(200); // ← siempre responder 200
});
# Python (Flask)
import hmac, hashlib
from flask import request, abort
WEBHOOK_SECRET = b"tu_webhook_secret"
@app.route("/webhook", methods=["POST"])
def webhook():
signature = request.headers.get("X-Overtaker-Signature", "")
body = request.get_data()
computed = "sha256=" + hmac.new(WEBHOOK_SECRET, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, computed):
abort(401)
event = request.json
# procesar evento...
return "", 200 # ← siempre responder 200

Permite cambiar el webhook_secret sin perder eventos. Durante la ventana de rotación (72h) los webhooks se firman con ambos secrets separados por coma en X-Overtaker-Signature: sha256=<a>,sha256=<b>. Tu verificador acepta el evento si cualquiera de las dos firmas coincide.

# Paso 1: iniciar rotación
POST /api/v1/channel/webhook-secret/rotate
Authorization: Bearer sk_live_YOUR_KEY
X-Overtaker-Version: 1
# 200 OK — el pending secret se muestra UNA SOLA VEZ
{
"api_key_id": "uuid",
"pending_webhook_secret": "wsk_nuevo_xxxxxxxxxxxx",
"expires_at": "2026-05-30T12:00:00Z",
"message": "Rotación iniciada. Agregá este secret a tu verificación..."
}
# Paso 2: agregás el nuevo secret a tu verificador y deployás.
# Durante 72h tu endpoint recibe dos firmas:
# X-Overtaker-Signature: sha256=<firma_actual>,sha256=<firma_pending>
# Paso 3: confirmar cuando tu verificador esté listo
POST /api/v1/channel/webhook-secret/confirm # el pending se promueve, el viejo se descarta
# O cancelar si algo salió mal
POST /api/v1/channel/webhook-secret/cancel # descarta el pending, sigue el actual

Verificador que acepta múltiples firmas separadas por coma:

Node.js
const crypto = require("crypto");
const SECRETS = [
process.env.WEBHOOK_SECRET_CURRENT, // tu secret de hoy
process.env.WEBHOOK_SECRET_PENDING, // el nuevo (durante la ventana de 72h)
].filter(Boolean);
// express.raw → req.body es el Buffer crudo. No uses express.json en esta ruta.
app.post("/webhook", express.raw({ type: "*/*" }), (req, res) => {
const sigs = (req.headers["x-overtaker-signature"] || "").split(",").map((s) => s.trim());
const raw = req.body; // bytes exactos recibidos
const valid = SECRETS.some((secret) => {
const computed = "sha256=" + crypto.createHmac("sha256", secret).update(raw).digest("hex");
return sigs.some((sig) => sig.length === computed.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(computed)));
});
if (!valid) return res.status(401).send("Invalid signature");
res.sendStatus(200);
});
# Python
import hmac, hashlib, os
from flask import request, abort
SECRETS = [s.encode() for s in [
os.environ.get("WEBHOOK_SECRET_CURRENT"),
os.environ.get("WEBHOOK_SECRET_PENDING"),
] if s]
@app.route("/webhook", methods=["POST"])
def webhook():
sigs = [s.strip() for s in request.headers.get("X-Overtaker-Signature", "").split(",")]
body = request.get_data()
def matches(secret):
computed = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(sig, computed) for sig in sigs)
if not any(matches(s) for s in SECRETS):
abort(401)
return "", 200

POST /channel/webhooks/test envía un evento de prueba al webhook_url configurado de la API key, para validar tu handler sin orquestar una conversación real. El payload incluye "test": true y se firma normalmente (incluyendo rotación dual si está activa). Rate limit: 30/min.

POST /api/v1/channel/webhooks/test
Authorization: Bearer sk_live_YOUR_KEY
Content-Type: application/json
X-Overtaker-Version: 1
{
"event_type": "message.response", // requerido — uno del catálogo
"sample_payload": null // opcional — override del fixture canned
}
// 200 OK
{
"delivered": true,
"event_type": "message.response",
"webhook_url": "https://mi-app.com/webhook",
"message": "Evento de prueba enviado"
}

Devuelve 400 si event_type no está en el catálogo o si la API key no tiene webhook_url configurada.