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.
Registrar tu webhook URL
Sección titulada «Registrar tu webhook URL»El webhook se cuelga de la API key: cada key tiene su propia webhook_url y su propio webhook_secret.
- La
webhook_urly elwebhook_secretse configuran al crear la API key, desde el panel del tenant → pestaña Canal API → Nueva API Key. Elwebhook_secretse muestra una sola vez; guardalo. - No hay endpoint del Canal API para editar la
webhook_urlde 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).
Suscripción de eventos
Sección titulada «Suscripción de eventos»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.
Headers de cada webhook
Sección titulada «Headers de cada webhook»X-Overtaker-Event: message.responseX-Overtaker-Signature: sha256=<hmac_hex>X-Overtaker-Version: 1Catálogo de eventos
Sección titulada «Catálogo de eventos»message.response — el bot respondió
Sección titulada «message.response — el bot respondió»{ "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"}changestrae 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
changesson 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_ides único por evento — usalo (o el headerX-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" } }Verificar la firma HMAC
Sección titulada «Verificar la firma HMAC»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, hashlibfrom 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 200Rotación de webhook secret (sin downtime)
Sección titulada «Rotación de webhook secret (sin downtime)»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ónPOST /api/v1/channel/webhook-secret/rotateAuthorization: Bearer sk_live_YOUR_KEYX-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é listoPOST /api/v1/channel/webhook-secret/confirm # el pending se promueve, el viejo se descarta
# O cancelar si algo salió malPOST /api/v1/channel/webhook-secret/cancel # descarta el pending, sigue el actualVerificador que acepta múltiples firmas separadas por coma:
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);});# Pythonimport hmac, hashlib, osfrom 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 "", 200Disparar un evento de prueba
Sección titulada «Disparar un evento de prueba»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/testAuthorization: Bearer sk_live_YOUR_KEYContent-Type: application/jsonX-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.