Recibir avisos de Lelemon y resolver escalamientos desde otra aplicación
Documento para el equipo que va a escribir la integración. Describe las dos mitades —los avisos que les mandamos por webhook y cómo nos cuentan que resolvieron un escalamiento— y alcanza para implementar el lado de ustedes sin preguntarnos nada.
Base de la API de Lelemon: https://api.lelemon.cl
Qué resuelve esto
Cuando el agente de Lelemon está atendiendo a una persona por WhatsApp y esa conversación necesita a un humano —porque lo pidió, porque el caso es complejo o porque es un reclamo—, Lelemon deja de responder y le pega a un webhook de ustedes con el contacto, el motivo y el historial de la conversación. De ahí en adelante manda n8n: ustedes deciden el request type, el issue type y la tipificación, y arman el ticket de Jira. Lelemon no sabe nada de Jira ni quiere saber; sólo emite el aviso. Cuando el ticket se cierra, n8n le pega a un endpoint de Lelemon con una credencial, la resolución queda escrita en la conversación y el control vuelve al agente para que siga atendiendo.
1. Recibir el aviso de escalamiento
La URL la eligen ustedes y nosotros la damos de alta del lado de Lelemon, junto con el secreto de firma. Condiciones del destino:
- Tiene que ser
httpohttps. Recomendamoshttps. - Tiene que resolver a una dirección pública. Validamos el dominio antes de cada salida y rechazamos loopback, redes privadas, link-local y sufijos como
.internalo.local. - Hay un destino por evento y por organización. El evento de este acuerdo es
conversation.escalated.
Detalles de la salida:
- Método
POST,Content-Type: application/json. - Esperamos hasta 10 segundos por la respuesta. Si no contestan en ese plazo, para nosotros es un fallo.
- No seguimos redirecciones. Un
301o302cuenta como fallo, no como redirección a seguir. La URL que nos den tiene que responder directo. - Tomamos como éxito cualquier código entre
200y299. Todo lo demás es fallo y dispara reintento.
Cabeceras que mandamos
| Cabecera | Valor |
|---|---|
Content-Type | application/json |
User-Agent | Lelemon-Webhooks/1.0 |
X-Lelemon-Event | conversation.escalated |
X-Lelemon-Timestamp | marca de tiempo Unix en segundos, como texto |
X-Lelemon-Signature | HMAC-SHA256 en hexadecimal (ver sección 2) |
El JSON que les llega
Ejemplo completo y realista de un contacto que pide hablar con una persona. Los datos son inventados a propósito.
Code
Qué significa cada campo
Nivel raíz
eventId— UUID único de este aviso. Es la llave de idempotencia; ver sección 3.event— siempreconversation.escalatedpara este acuerdo. Viene también en la cabeceraX-Lelemon-Event.occurredAt— cuándo armamos el aviso, en ISO 8601 UTC. No confundir conX-Lelemon-Timestamp, que va en segundos y es el que entra en la firma.
conversation
dealId— identificador de la conversación en Lelemon. Este es el id que después usan en la ruta para avisarnos que se resolvió. Guárdenlo en el ticket.pipelineId— el flujo al que pertenece la conversación.channel— por dónde venía conversando, por ejemplowhatsapp. Sale del contexto del agente y puede venirnull.
contact
id— identificador del contacto en Lelemon.name— nombre del contacto tal como lo tenemos guardado.phone— teléfono del contacto. Si el contacto no lo tiene guardado, usamos el teléfono del contexto de la conversación. Puede venirnull.email— puede venirnull.
escalation
reason— motivo que declaró el agente al escalar. Los valores que puede elegir sonuser_request,complex_issue,complaint,sales_opportunityytechnical_support. Si el agente no manda ninguno, quedauser_request. Puede venirnullsi el escalamiento se originó fuera de la herramienta del agente.priority—low,medium,highourgent. Si no viene, mandamosmedium.summary— resumen que escribió el agente para el humano que va a tomar el caso. Puede venirnull.
history
Los últimos 20 mensajes de la conversación, ordenados del más viejo al más nuevo. Si la conversación tuvo menos, llegan todos.
role—useres el contacto,assistantes el agente IA,humanes alguien del equipo que había tomado la conversación,systemson eventos internos.content— el texto del mensaje.at— cuándo se escribió, en ISO 8601 UTC.
Una aclaración de diseño que les conviene saber: mandamos el historial y no una clasificación nuestra a propósito. Quien tipifica es el flujo de ustedes, y tipificar sobre un resumen nuestro sería tipificar sobre nuestra interpretación.
2. Verificar que el aviso es nuestro
Cada envío va firmado con el secreto que compartimos al dar de alta el webhook. El algoritmo es este, sin variantes:
- Tomen el valor de
X-Lelemon-Timestamp(texto, segundos Unix). - Tomen el cuerpo crudo de la petición, exactamente los bytes que llegaron.
- Armen la cadena
<timestamp>.<cuerpo>— el timestamp, un punto, y el cuerpo. - Calculen
HMAC-SHA256de esa cadena con el secreto compartido. - Codifiquen en hexadecimal minúsculas y comparen contra
X-Lelemon-Signature.
Por qué la marca de tiempo entra en lo firmado y no viaja sólo como cabecera informativa: si firmáramos únicamente el cuerpo, cualquiera que haya visto un aviso podría reenviarlo mañana tal cual y la firma seguiría dando válida. Al meter el timestamp adentro del HMAC, el aviso queda atado al momento en que salió, y un reenvío viejo se detecta comparando ese timestamp contra la hora actual. Recomendación nuestra: rechacen todo aviso cuyo X-Lelemon-Timestamp esté a más de 5 minutos de la hora de ustedes. Lelemon no impone esa ventana; la validación de frescura corre por cuenta del receptor.
Y una advertencia que es la causa número uno de firmas que no calzan: firmen sobre el cuerpo crudo, nunca sobre el JSON re-serializado. Si el nodo de n8n ya parseó el JSON y ustedes hacen JSON.stringify() para volver a armarlo, el texto resultante puede diferir del nuestro en el orden de las claves, los espacios o el escapado de caracteres, y el HMAC va a dar distinto aunque el aviso sea legítimo. En el nodo Webhook de n8n hay que habilitar la opción que entrega el cuerpo sin parsear (según la versión aparece como "Raw Body") y firmar sobre ese texto.
Código para un nodo de n8n
Code
Los nombres de las cabeceras llegan en minúsculas cuando el runtime las normaliza; si su nodo las expone con la capitalización original, busquen X-Lelemon-Timestamp y X-Lelemon-Signature.
3. Reintentos e idempotencia
Cuando el envío no sale bien, reintentamos. Las reglas son fijas:
- Tres intentos en total: el primero, y hasta dos reintentos.
- Esperamos 1 segundo antes del segundo intento y 5 segundos antes del tercero.
- Cuenta como fallo cualquier respuesta fuera del rango
2xx, que no contesten dentro de los 10 segundos, o que no podamos alcanzar el destino. - Después del tercer intento fallido dejamos de insistir. El evento queda registrado de nuestro lado como fallido, con el código HTTP y la duración, para que podamos responder si les avisamos o no. No lo reencolamos solos: si el destino estuvo caído, el aviso se perdió y hay que reponerlo a mano.
Por eso conviene que el webhook de ustedes conteste rápido con un 200 y haga el trabajo pesado después. Si n8n arma el ticket de Jira antes de responder y eso demora más de 10 segundos, nosotros vamos a dar el envío por caído y a reintentar, aunque el ticket se haya creado bien.
Ahí entra la idempotencia. El eventId es el mismo en los tres intentos del mismo aviso y cambia con cada escalamiento nuevo. Guárdenlo y descarten todo aviso cuyo eventId ya hayan procesado; con eso, un reintento nuestro no les crea un segundo ticket para la misma conversación. No usen el dealId como llave de deduplicación: una misma conversación puede escalar más de una vez a lo largo del tiempo, y esas sí son dos atenciones distintas.
4. Avisarnos que el ticket se resolvió
La llamada
Code
Donde {id} es el conversation.dealId que les llegó en el aviso. Tiene que ser un UUID válido.
La credencial va en la cabecera Authorization:
Code
Es la única forma de mandarla: no hay una cabecera alternativa. Tuvimos una y la sacamos, porque nuestro logger sabe ocultar el valor de authorization pero no el de una cabecera inventada por nosotros, y en producción eso habría dejado la credencial entera y reutilizable en el registro de cada pedido.
El cuerpo
Todos los campos son opcionales. Un POST con {} es válido y sirve para el caso mínimo: cerrar el escalamiento y devolverle el control al agente.
| Campo | Tipo | Límite | Qué hace |
|---|---|---|---|
resolution | texto | 2.000 caracteres | Qué se resolvió, en palabras. Queda escrito en la conversación como mensaje de sistema. |
message | texto | 4.000 caracteres | Si viene, se le envía al contacto por su canal. Si se omite, la resolución sólo queda registrada. |
externalRef | texto | 120 caracteres | El identificador del ticket del lado de ustedes. Además de servir para cruzar, es lo que nos permite descartar un reintento: mándenlo siempre, ver abajo. |
returnToAgent | booleano | — | Por defecto true: el control vuelve al agente IA. Manden false si quieren seguir atendiendo a mano. |
Manden sólo estos campos: el endpoint valida contra esta lista y un campo de más no llega a nosotros.
Qué pasa de verdad cuando llaman
En orden:
- Buscamos la conversación dentro de la organización de la credencial. Si no existe, está borrada o es de otra organización,
404. - Si mandaron
externalRefy ya procesamos un aviso con esa misma referencia para esa conversación, cortamos ahí: no escribimos nada, no mandamos nada, no tocamos el control, y respondemos200conalreadyResolved: true. - Revisamos que haya algo que resolver. Si la conversación no está escalada ni la tiene tomada alguien del equipo, devolvemos
409y no escribimos nada. Esto es a propósito y conviene que lo tengan claro: sin esa puerta, una credencial con el alcanceconversations:resolvesería en la práctica un permiso para mandarle un WhatsApp a cualquier contacto de la organización, porque bastaría con un id de conversación —que nosotros mismos mandamos en cada aviso saliente— y unmessage. Sólo se puede escribir sobre una conversación que efectivamente pidió un humano. - Si mandaron
message, resolvemos el canal de salida antes de escribir nada. Si esa conversación no tiene por dónde responderle al contacto —sin WhatsApp vinculado y sin origen en el copiloto in-app—, devolvemos422y no registramos nada. Preferimos el error a dejar la resolución escrita con el contacto sin enterarse. - Escribimos un mensaje de sistema en la conversación con el texto
Resuelto en sistema externo (<externalRef>): <resolution>. Queda visible en el historial y marcado como resolución externa. - Si mandaron
message, lo guardamos como mensaje del equipo con remitentesistema externo—no el nombre de una persona, para que quien lea la conversación después sepa quién habló— y lo despachamos por el canal del contacto. - Salvo que hayan mandado
returnToAgent: false, devolvemos el control al agente IA y la conversación vuelve a la etapa de entrada de su flujo. Con una excepción, ver abajo.
Cuando la conversación la tomó una persona del equipo
Si alguien de Lelemon tomó la conversación a mano antes de que ustedes avisaran, el control no vuelve al agente aunque no manden returnToAgent: false. Lo van a ver en la respuesta: el controlStatus sigue en human en vez de pasar a agent. No es un error ni un pedido que se ignoró. Devolver el control en ese caso le sacaría la conversación de las manos a la persona que la está atendiendo, y además la movería de etapa, sin que se entere. La resolución y el mensaje al contacto sí se registran y se envían con normalidad; lo único que no pasa es la devolución del control.
Por qué conviene mandar siempre externalRef
Adentro, un resolve son tres escrituras: el mensaje de sistema, el mensaje al contacto y la
devolución del control. No están en una transacción. Si la última falla, ustedes reciben un 500 y
—como hace n8n por defecto— reintentan; sin externalRef no tenemos con qué darnos cuenta, y el
contacto recibe el mismo WhatsApp dos veces.
Con externalRef el segundo intento se corta al principio y responde 200 con
alreadyResolved: true. Usen la llave del ticket, que ya es única del lado de ustedes.
La respuesta
200 OK con este cuerpo:
Code
controlStatus— cómo quedó la conversación:agent(la maneja el agente IA),human(la tiene tomada alguien del equipo) oescalated(sigue esperando a un humano).messageQueued—truesi mandaronmessagey quedó encolado para salir por el canal del contacto. Se llama "encolado" y no "entregado" a propósito: en ese momento el mensaje está guardado y en camino, pero el envío por WhatsApp ocurre después. Si necesitan saber que llegó de verdad, mírenlo en la conversación, no en este campo.alreadyResolved—truecuando este aviso ya lo habíamos procesado antes, reconocido por suexternalRef. No es un error: significa que su reintento no hizo nada de más.
Códigos de error
Los errores llegan con este cuerpo:
Code
| Código | Qué pasó | Qué hacer |
|---|---|---|
400 | El {id} de la ruta no es un UUID válido, o el cuerpo no pasó la validación (por ejemplo, resolution de más de 2.000 caracteres). El mensaje de estos errores llega genérico, así que guíense por el campo que mandaron. | Arreglar el pedido. Reintentar tal cual no sirve. |
401 | Falta la credencial o no es válida. Falta la credencial. Mandala en el header Authorization: Bearer <clave>. significa que no mandaron ninguna; Credencial invalida. significa que la clave no existe, está revocada o no tiene la forma esperada. Es el mismo mensaje para los tres casos a propósito. | Revisar la cabecera y la clave. Si sospechan que se revocó, escríbannos. |
403 | La credencial es válida pero le falta el permiso. El mensaje dice cuál: A esta credencial le falta el permiso: conversations:resolve. | Escríbannos para que le agreguemos el alcance. |
404 | La conversación no existe, está borrada o pertenece a otra organización. | Verificar que el id sea el dealId del aviso. No reintentar en loop. |
409 | La conversación no está escalada ni la tiene tomada alguien del equipo, así que no hay nada que resolver. El mensaje es Esta conversación no está escalada: no hay nada que resolver. Pasa, por ejemplo, si el ticket se cierra dos veces, o si alguien ya resolvió el escalamiento por otra vía. | No reintentar: el estado no va a cambiar solo. Si les extraña, consulten el estado con el GET antes de decidir. |
422 | Mandaron message pero la conversación no tiene canal de salida. La resolución no se registró. | Reintentar sin el campo message, para al menos dejar la resolución escrita y devolver el control. |
429 | Pasaron un techo por minuto: 200 pedidos por dirección IP o 3.000 por credencial. El cuerpo dice Demasiadas solicitudes, intenta de nuevo en un momento. | Esperar y reintentar. Es seguro reintentar. |
500 | Error nuestro. | Reintentar con espera. Si persiste, escríbannos: los 500 nos llegan como alerta. |
Ejemplo con curl
Code
Ejemplo para un nodo HTTP Request de n8n
- Method:
POST - URL:
https://api.lelemon.cl/v1/external/conversations/{{ $json.dealId }}/resolve - Authentication: ninguna de las predefinidas; la credencial va como cabecera.
- Headers:
Authorization:Bearer {{ $env.LELEMON_API_KEY }}— la clave guardada como credencial o variable de entorno de n8n, nunca escrita adentro del nodo.Content-Type:application/json
- Body Content Type: JSON
- Body:
Code
Si no quieren mandarle nada al contacto, saquen message del cuerpo en vez de mandarlo vacío.
Consultar el estado de una conversación
Existe también, por si les sirve decidir sin adivinar si una conversación sigue escalada o ya la resolvieron:
Code
Misma credencial, pero exige el alcance conversations:read. Devuelve 200 con los datos de la conversación: id, name, email, phone, status, controlStatus, controlledBy, assignedTo, escalatedAt, escalationReason, pipelineId, pipelineName, currentStageId, currentStageName, lastMessageAt y createdAt. Los mismos códigos de error que el endpoint anterior.
Ver las conversaciones anteriores de la misma persona
Cuando alguien vuelve a escribir, la conversación nueva es otra, pero la persona es la misma. Para ver qué pasó antes:
Code
Exige conversations:read. Devuelve 200 con conversationId (la que preguntaron) y conversations: las otras conversaciones de esa persona en su organización, de la más nueva a la más vieja, hasta 50. Cada una trae conversationId, pipelineId, pipelineName, status, outcome (por qué terminó), summary (un resumen breve escrito al cerrarse, o null), firstMessage (lo primero que escribió el cliente, hasta 160 caracteres, o null), messageCount, createdAt y lastMessageAt. Si no hay anteriores, conversations viene vacío.
Los mensajes de una conversación anterior se leen con GET /v1/external/conversations/{conversationId}/messages, el mismo de lelemon-api-agentes.md. Los códigos de error son los mismos que el endpoint anterior: 404 si la conversación no existe o no es de su organización.
Con esa misma credencial existe también la superficie para hablarle a un agente desde una app de ustedes: abrir una conversación, mandar mensajes y leer el hilo. Está documentada aparte en lelemon-api-agentes.md y exige los alcances agents:converse y conversations:read. No hace falta para recibir avisos ni para resolver; queda ahí por si quieren que el agente atienda algo que no entró por WhatsApp.
Si lo que necesitan es escribirle a la persona desde su propio sistema sin que corra el agente —una encuesta al cerrar un caso, o la respuesta de su mesa de soporte sobre un caso escalado—, hay una ruta aparte con el alcance conversations:send, documentada en lelemon-api-mensajes.md.
5. La credencial
Formato: lel_<12 caracteres hexadecimales>_<64 caracteres hexadecimales>. El bloque del medio es un prefijo público con el que ubicamos la fila; el resto es el secreto.
Lo que tienen que saber:
- Se entrega una sola vez. De nuestro lado guardamos sólo un SHA-256 de la clave completa. Ni con la base de datos en la mano podemos volver a armarla: si la pierden, no la recuperamos, emitimos otra.
- Es por organización. De la clave sale la organización, y de ningún otro lado: nunca leemos el identificador de organización del cuerpo ni de la URL. Con la clave de su organización sólo pueden ver y resolver conversaciones de su organización, aunque conozcan el id de una conversación ajena.
- Se puede revocar en cualquier momento. Revocar es definitivo: esa clave no vuelve, se emite una nueva. La fila queda guardada para poder responder después qué clave hizo qué.
- Alcances de la clave de ustedes:
conversations:resolvepara avisar que un escalamiento se resolvió, yconversations:readpara consultar el estado de una conversación. No hay nada más habilitado. Un endpoint que no declare explícitamente su alcance se rechaza, así que la clave no se abre sola a superficie nueva. - Registramos la fecha del último uso, para saber cuándo una clave dejó de usarse.
No la manden por un canal que quede registrado en claro: nada de correo, chat corporativo, ticket de Jira, comentario de un issue ni archivo en un repositorio. Del lado de ustedes debería vivir como credencial o variable de entorno de n8n, no escrita adentro de un nodo. Si sospechan que quedó expuesta, avísennos y la revocamos: emitir una nueva toma un minuto.
Lo mismo vale para el secreto del webhook de la sección 2: es un secreto compartido, se maneja igual y también se puede reemplazar.
6. Si algo falla
No llega ningún aviso de escalamiento. Verifiquen primero que el destino responde desde afuera: no seguimos redirecciones, así que si la URL redirige a otra, para nosotros es un fallo. Después, que no esté demorando más de 10 segundos en contestar. Si de su lado no hay ni rastro del pedido, escríbannos: guardamos cada entrega, así que podemos ver si el envío salió, con qué código respondió el destino y cuántos intentos hicimos.
Llega el aviso pero la firma no calza. Casi siempre es el cuerpo re-serializado. Comprueben que están firmando el texto crudo y no el resultado de un JSON.stringify() sobre el objeto ya parseado. Después, que estén concatenando timestamp + . + cuerpo en ese orden, que el timestamp sea el de la cabecera y no la hora de ustedes, y que comparen en hexadecimal minúsculas.
Llegan tickets duplicados. Es un reintento nuestro sobre una respuesta que no fue 2xx o que demoró más de 10 segundos. Deduplicar por eventId lo resuelve; contestar rápido con 200 y hacer el trabajo después lo previene.
El POST de resolución devuelve 422. La conversación no tiene canal de salida y el message no se puede entregar. Reintenten sin message: la resolución queda escrita igual y el control vuelve al agente.
El POST de resolución devuelve 404 con un id que sí salió en el aviso. Revisen que estén usando conversation.dealId y no contact.id. Si es el correcto, escríbannos.
El POST de resolución devuelve 409. Esa conversación ya no está esperando a un humano. Lo más común es cerrar dos veces el mismo ticket, o que el escalamiento se haya resuelto antes por otra vía. Si necesitan confirmarlo, el GET les dice en qué estado quedó.
El POST devuelve 200 pero el controlStatus sigue en human. No es un bug: alguien del equipo tomó esa conversación a mano y por eso no le devolvimos el control al agente. La resolución quedó escrita igual.
El POST devuelve 401 de un día para el otro. La clave se revocó o se rotó. Escríbannos y emitimos una nueva.
Para cualquiera de estos casos podemos mirar de nuestro lado el registro de entregas —código HTTP, cantidad de intentos, duración y último error por webhook—, así que un mensaje con la fecha, la hora aproximada y el eventId o el dealId nos alcanza para responder qué pasó.
7. Lo que todavía no hacemos
Tres límites conocidos, para que no los descubran el día que los necesiten.
No hay rotación de credencial con dos llaves válidas a la vez. Para rotar hay que revocar la actual y emitir una nueva, en ese orden, así que entre una cosa y la otra hay un corte breve en el que los pedidos de ustedes van a recibir 401. Si tienen que rotar, coordinemos una ventana; son minutos, pero conviene que no los agarre en medio de un cierre de tickets.
No hay pantalla para que se emitan la credencial solos. Hoy la generamos nosotros y se la entregamos una vez. Lo mismo para revocarla o cambiar sus alcances: es un mensaje a nosotros.
No hay pantalla donde vean el registro de entregas. Guardamos cada intento con su código HTTP, cantidad de reintentos, duración y último error, pero por ahora eso lo miramos nosotros. Si necesitan saber si un aviso salió, escríbannos con la fecha y el eventId o el dealId.