# Mandarle un mensaje a una persona desde tu sistema

Tu sistema escribe en una conversación que ya existe, por el mismo canal en que la persona habla con nosotros, sin que corra el agente. Sirve para una encuesta de satisfacción cuando el caso llega a su etapa de cierre, o para responderle desde tu mesa de soporte a alguien cuyo caso ya está escalado, sin copiar y pegar en la bandeja.

Base de la API de Lelemon: `https://api.lelemon.cl`

La idea de fondo: **el mensaje sale con el texto que tú mandas, firmado con el nombre de tu credencial, y no pasa por el modelo.** No gasta tokens, el agente no contesta por encima y la persona ve un mensaje de tu organización, no uno suyo.

## Antes de empezar

Necesitas una credencial con el alcance `conversations:send`. Se emite en Integraciones API del dashboard, y al elegir ese alcance la pantalla te pide el **nombre con el que firma**. Ese nombre queda en cada mensaje que mande la credencial. Es lo que ve tu equipo en la bandeja y lo que llega en el aviso `conversation.human_message`, y en un widget embebido que lee el historial llega hasta la pantalla de la persona. Elige uno que diga qué es, como `Encuesta de satisfacción` o `Mesa de soporte`.

- **El nombre lo fija la credencial, no el pedido.** Un campo de autor en el cuerpo se rechaza con `400` en vez de ignorarse, para que nadie crea que firmó con otro nombre. Así una integración no puede firmar con el nombre de una persona.
- Una credencial con `conversations:send` sin nombre de autor no se puede emitir, y un nombre de autor sin ese alcance tampoco.
- Rotar la credencial conserva el nombre.

El alcance va separado de `agents:converse` a propósito. Conversar escribe como si lo mandara la persona y hace correr al modelo; esto escribe como tu organización y no corre nada.

## El pedido

```
POST https://api.lelemon.cl/v1/external/conversations/{id}/send
Authorization: Bearer <tu credencial>
Content-Type: application/json

{
  "message": "Hola Marcela, ¿cómo calificas la atención que recibiste? Responde de 1 a 5.",
  "externalRef": "encuesta-PROC-4821"
}
```

`{id}` es el id de la conversación, el mismo `conversation.dealId` que viaja en los avisos y el que devuelven las otras rutas.

| Campo | Tipo | Límite | Qué hace |
|---|---|---|---|
| `message` | texto | 4.000 caracteres | El mensaje, tal cual sale. No puede venir vacío ni de solo espacios. |
| `externalRef` | texto | 120 caracteres | Tu referencia para este envío. **Es obligatoria**: es lo que impide que un reintento mande el mensaje dos veces. Ver más abajo. |

Manda sólo estos campos. Un campo de autor (`senderName`, `authorName`, `author`, `senderId` o `userId`) se rechaza con `400` y un mensaje que explica que el autor lo fija la credencial. Cualquier otro campo de más se ignora.

```json
{
  "id": "b81d3f77-2c4a-4f19-9a86-31ee6c0b7d52",
  "messageId": "7f0c5e1a-39d2-4b8e-9f64-2d1a8c3b5e90",
  "alreadySent": false
}
```

El mensaje queda **guardado y encolado** en el canal. Encolado no es entregado: el transporte lo despacha después, como con cualquier mensaje que escribe una persona de tu equipo.

## Cuándo se puede mandar

La regla no es la etapa del caso sino **quién lo está atendiendo**. Si el agente está al mando de un caso abierto, tu mensaje llegaría por encima de lo que el agente está diciendo, y por eso se rechaza.

| Estado de la conversación | Resultado |
|---|---|
| El agente la atiende y el caso está abierto | `409`, código `agent_in_control` |
| Escalada, esperando que alguien la tome | Sale |
| La tomó una persona del equipo | Sale |
| El caso está cerrado, aunque siga marcado como atendido por el agente | Sale |

El caso cerrado cubre la encuesta de cierre: un caso que llegó a su etapa final queda cerrado y nadie lo atiende, así que se le puede escribir.

Si te llega el `409`, toma el control de la conversación desde la bandeja o cierra el caso, y vuelve a mandar con la misma `externalRef`: un rechazo no gasta la referencia.

Mandar un mensaje **no cambia nada más**. No mueve el caso de etapa, no cambia quién lo atiende y no reabre un caso cerrado.

## La ventana de 24 horas de WhatsApp

Por WhatsApp, Meta sólo deja escribirle con texto libre dentro de las 24 horas siguientes al último mensaje de la persona. Pasado ese plazo sólo acepta plantillas aprobadas.

**Esta ruta no envía plantillas.** Si la ventana está cerrada, o si la persona nunca escribió, responde `422` con el código `channel_window_closed` y **no manda nada**:

```json
{
  "statusCode": 422,
  "code": "channel_window_closed",
  "message": "Pasaron más de 24 horas desde el último mensaje de la persona y WhatsApp sólo deja escribirle con una plantilla aprobada, no con texto libre. Esta ruta todavía no envía plantillas: el mensaje no se mandó."
}
```

Tómalo como un error de verdad, no como un aviso: el mensaje no salió. Para una encuesta de cierre, mándala dentro de las 24 horas del último mensaje de la persona.

La ventana es una regla del canal de WhatsApp, no de la ruta. Las conversaciones que entraron por la API o por el copiloto in-app no tienen ventana.

## La referencia: reintentar sin duplicar

`externalRef` identifica **un envío tuyo en una conversación**. Dos llamadas a la misma conversación con la misma referencia mandan un solo mensaje:

- La primera responde `alreadySent: false` y el `messageId` del mensaje guardado.
- Las siguientes responden `200` con `alreadySent: true` y **el mismo `messageId`**, y no escriben nada.

Funciona también cuando los reintentos llegan a la vez. Si tu flujo se cae después de mandar y vuelve a intentar, o dos ejecuciones corren en paralelo, la persona recibe un solo mensaje.

- La referencia vale **por conversación**: la misma referencia en otra conversación es otro envío.
- Un reintento responde igual aunque el caso haya cambiado de manos desde el primer envío: lo ya enviado no se vuelve a evaluar.
- Una referencia distinta es un mensaje distinto, aunque el texto sea el mismo.
- Arma la referencia con el hecho que origina el mensaje, como `encuesta-<id del ticket>`, y no con un valor que cambie en cada intento, como la hora.

## Errores

Llegan con el cuerpo habitual (`statusCode`, `message`, `path`). Los de esta ruta traen además `code`.

| Código | Qué pasó | Qué hacer |
|---|---|---|
| `400` | Falta `message` o `externalRef`, vienen vacíos, muy largos o no son texto, el cuerpo trae un campo de autor, o el `{id}` no es un UUID. | Arreglar el pedido. Reintentar igual no sirve. |
| `401` | Falta la credencial o no es válida. | Revisar la cabecera y la clave. |
| `403` | A la credencial le falta `conversations:send`, o la tiene pero no tiene nombre de autor. | Emitir una credencial con el alcance y su nombre. |
| `404` | La conversación no existe o es de otra organización. | Verificar el id. |
| `409` `agent_in_control` | El agente atiende un caso abierto. | Tomar el control o cerrar el caso, y reintentar con la misma `externalRef`. |
| `422` `channel_window_closed` | Pasaron más de 24 horas desde el último mensaje de la persona y sólo se puede con plantilla. | No reintentar igual: no va a cambiar solo. |
| `422` `no_outbound_channel` | La conversación no tiene por dónde escribirle a la persona. | Escribirle por otro medio. |
| `429` | Pasaste un techo por minuto: 3.000 pedidos por credencial o 200 por dirección IP, compartidos por toda la API externa. | Esperar y reintentar. Es seguro. Si mandas muchos mensajes seguidos, espácialos: la ruta no tiene envío en lote. |
| `500` | Error nuestro. | Reintentar con espera y la misma `externalRef`: es seguro. |

## Ejemplo con curl

```bash
curl -X POST "https://api.lelemon.cl/v1/external/conversations/b81d3f77-2c4a-4f19-9a86-31ee6c0b7d52/send" \
  -H "Authorization: Bearer lel_a1b2c3d4e5f6_<...>" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Hola Marcela, ¿cómo calificas la atención que recibiste? Responde de 1 a 5.",
    "externalRef": "encuesta-PROC-4821"
  }'
```

## Cómo ver lo que se mandó

- `GET /v1/external/conversations/{id}/messages`, con `conversations:read`, devuelve el hilo. El mensaje sale con rol `human`.
- Si tienes configurado el aviso `conversation.human_message`, te llega también cuando manda tu propia credencial. El aviso no trae el texto: trae dónde encontrarlo y el nombre del autor.

## Bajar los archivos de una conversación

Para traer a tu sistema lo que la persona mandó por el chat y lo que quedó en la ficha del caso:

```
GET https://api.lelemon.cl/v1/external/conversations/{id}/adjuntos
Authorization: Bearer <tu credencial>
```

Pide `conversations:read`, el mismo alcance que leer la conversación. Acepta `limit` y `offset`, como las demás listas.

```json
{
  "data": [
    {
      "id": "3b1f6c0e-5d0a-4a52-9a43-0c7c0f6a2e11",
      "nombre": "Contrato firmado.pdf",
      "mime": "application/pdf",
      "tamano": 48213,
      "fecha": "2026-10-21T14:05:00.000Z",
      "origen": "ficha",
      "subidoPor": "cliente",
      "url": "https://...",
      "venceEn": "2026-10-21T14:15:00.000Z"
    }
  ],
  "limit": 50,
  "offset": 0,
  "hasMore": false
}
```

| Campo | Qué es |
|---|---|
| `id` | El id del archivo en la ficha, o el id del mensaje que lo trajo si vino por el chat. |
| `nombre` | El nombre original del archivo. Una imagen, un audio o un video del chat no guardan el nombre que tenían en el teléfono: salen con el nombre con que se guardaron. |
| `mime` | El tipo del archivo. |
| `tamano` | Bytes. `null` en lo que vino por el chat, que no guarda el peso. |
| `fecha` | Cuándo llegó al chat o se adjuntó a la ficha. |
| `origen` | `chat` o `ficha`. |
| `subidoPor` | `cliente` o `equipo`. Todo lo de `chat` es del cliente; en `ficha`, lo que entró por WhatsApp es del cliente y lo que cargó alguien del equipo en la ficha es del equipo. |
| `url` | Dirección firmada para descargar, sin credencial. |
| `venceEn` | Hasta cuándo sirve la `url`. |

Qué trae cada origen:

- `chat`: las imágenes, los audios y los videos que mandó la persona. No trae los archivos que tu equipo manda por el chat.
- `ficha`: **todo** lo que está en la ficha del caso: los documentos que la persona mandó por el chat, que quedan guardados ahí con su nombre original, y los archivos que subió tu equipo. Un documento no sale dos veces. Para quedarte sólo con lo del cliente, filtra por `subidoPor: "cliente"`.

El más nuevo viene primero, mezclando los dos orígenes.

**La `url` vive 10 minutos.** Descárgala apenas pidas la lista; si venció, pide la lista de nuevo. Baja con su nombre en `Content-Disposition`. Sólo los pdf, las imágenes, el audio y el video se abren en línea; cualquier otro tipo, y en particular un html o un svg, baja siempre como archivo.

**Una imagen que el filtro de seguridad bloqueó no se ofrece.** Tampoco sale lo que se borró.

Un `404` significa que la conversación no existe o es de otra organización. El resto de los errores son los habituales de la API: `401`, `403` si falta `conversations:read`, `429`.

## Lo que esta ruta no hace

- **No envía plantillas de WhatsApp.** Fuera de las 24 horas no hay forma de usarla.
- **No escribe sobre un caso que el agente está atendiendo.** No hay una opción para forzarlo.
- **No manda archivos.** Sólo texto.
- **No elige el autor por pedido.** Lo fija la credencial.
- **No crea conversaciones.** Para empezar una con un agente, ver [`lelemon-api-agentes.md`](./lelemon-api-agentes.md), y para que el agente escriba primero, [`lelemon-api-triggers.md`](./lelemon-api-triggers.md).

## Notas internas: avisarle al equipo sin escribirle a la persona

Una nota interna es un mensaje que queda en el chat de la conversación y que ve solo tu equipo, con el rótulo "Nota interna", su autor y su hora. Sirve para dejar constancia desde tu sistema, por ejemplo que el ticket no se pudo crear al escalar un caso.

**Una nota nunca sale a la persona por ningún canal, no la lee el agente ni entra a sus resúmenes, y no viaja en los avisos hacia tu sistema.** Tampoco mueve la espera ni la última actividad del caso, no reinicia el reloj del cierre por inactividad ni cuenta para el tope de mensajes de la conversación, y se puede dejar con el agente al mando, escalada o con el caso cerrado.

Necesitas una credencial con el alcance `conversations:annotate`, aparte de `conversations:send`: una integración que solo avisa de sus fallos no necesita poder escribirle a la persona. La nota se firma con el nombre de autor de la credencial si lo tiene y, si no, con el nombre de la credencial.

```
POST https://api.lelemon.cl/v1/external/conversations/{id}/notas
Authorization: Bearer <tu credencial>
Content-Type: application/json

{
  "texto": "No se pudo crear el ticket en Jira: respondió 503.",
  "externalRef": "fallo-escalamiento-PROC-4821"
}
```

| Campo | Tipo | Límite | Qué hace |
|---|---|---|---|
| `texto` | texto | 4.000 caracteres | La nota. No puede venir vacía ni de solo espacios. |
| `externalRef` | texto, opcional | 120 caracteres | Tu referencia para esta nota. Con la misma referencia en la misma conversación, un reintento no deja la nota dos veces. |

Un campo de autor (`senderName`, `authorName`, `author`, `senderId` o `userId`) se rechaza con `400`: el autor lo fija la credencial.

```json
{
  "id": "b81d3f77-2c4a-4f19-9a86-31ee6c0b7d52",
  "messageId": "7f0c5e1a-39d2-4b8e-9f64-2d1a8c3b5e90",
  "createdAt": "2026-10-21T15:30:00.000Z",
  "alreadyCreated": false
}
```

Un reintento con la misma `externalRef` responde `200` con `alreadyCreated: true` y el mismo `messageId`. Los errores son los de la ruta de enviar, salvo `409` y `422`, que acá no existen. Un `404` es una conversación que no existe o es de otra organización, y un `403` es una credencial sin `conversations:annotate`.

### Leer las notas

`GET /v1/external/conversations/{id}/messages` no las devuelve, para no cambiar el hilo a quien ya lo lee. Con `?incluirNotas=true` las trae en su lugar, con `role: "internal_note"` y el nombre del autor en `author`:

```json
{
  "role": "internal_note",
  "content": "No se pudo crear el ticket en Jira: respondió 503.",
  "createdAt": "2026-10-21T15:30:00.000Z",
  "author": "n8n de la mesa de soporte"
}
```

`incluirNotas=true` lo puede usar cualquier credencial con `conversations:read`: no hay un alcance aparte para leer notas. Dale ese alcance solo a sistemas que pueden ver lo que anota tu equipo.

Si muestras ese hilo a la persona, descarta los mensajes con rol `internal_note`.

### Desde el dashboard

En el chat, el compositor tiene el modo "Nota interna". Lo puede usar quien puede responder conversaciones, sin tomar el control, y la nota queda firmada con su nombre.
