# Hablarle a un agente de Lelemon desde otra aplicación

Documento para el equipo que va a escribir la integración. Describe los tres endpoints con los que una aplicación externa le delega una conversación a un agente de Lelemon y alcanza para implementar el lado de ustedes sin preguntarnos nada.

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

## Qué resuelve esto

Hasta ahora una app externa podía recibir avisos nuestros y contarnos que resolvió algo, pero no podía delegarle nada a un agente. Esto es lo contrario: ustedes abren una conversación contra un agente que ya existe en su organización, le mandan mensajes y reciben lo que el agente contesta. El caso típico es un ticket, un chat de ustedes o un formulario que quieren que atienda el agente antes de que lo toque una persona.

La conversación no vive en un lugar aparte. Entra por la etapa de entrada del flujo del agente, exactamente igual que una conversación de WhatsApp, y por eso aparece en la misma bandeja: no hay nada especial que hacer para lograrlo. Si alguien del equipo la toma a mano, el agente se calla; si el agente escala, sale el aviso `conversation.escalated` por el webhook de siempre.

Los tres endpoints van con la misma credencial de organización en la cabecera `Authorization`:

```
Authorization: Bearer lel_a1b2c3d4e5f6_<...>
```

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 prefijo es `/v1` desde el primer día. Del otro lado va a haber una integración escrita por otra empresa, y romperla sin dejarle una versión vieja a la que volver no es una opción.

---

## 1. Abrir una conversación

```
POST https://api.lelemon.cl/v1/external/agents/{slug}/conversations
```

Donde `{slug}` es el identificador del agente en su organización, el mismo que se ve en la pantalla del agente.

El agente tiene que tener habilitado el canal `api` en su configuración. Es una casilla más, al lado de WhatsApp y del chat web, en la pestaña Resumen del agente, y la marcan ustedes. Si no está, el `POST` devuelve `422` diciendo qué canales atiende hoy. Lo pedimos por adelantado en vez de dejar que falle a mitad de camino: sin la casilla, el motor rechaza el turno recién después de haberlo empezado, y del otro lado eso se ve como un error temporal que nunca se arregla solo.

Alcance que exige: `agents:converse`.

### El cuerpo

| Campo | Tipo | Límite | Obligatorio | Qué hace |
|---|---|---|---|---|
| `externalId` | texto | 120 caracteres | sí | El identificador del caso del lado de ustedes. Es la llave de idempotencia; ver sección 4. |
| `contactName` | texto | 255 caracteres | no | Quién es el contacto, para que en la bandeja no aparezca como un id. |
| `contactEmail` | texto | 255 caracteres, formato de correo | no | |
| `contactPhone` | texto | 50 caracteres | no | |

Manden sólo estos campos. Cualquier otro se descarta antes de llegar a nuestro código, en silencio y sin error, así que no cuenten con un `400` para detectar un campo mal escrito. Eso incluye cualquier intento de nombrar una organización: la organización sale de la credencial y de ningún otro lado, así que un `organizationId` en el cuerpo no hace nada. Un `externalId` mal escrito sí lo van a notar, porque el campo es obligatorio y su ausencia devuelve `400`.

Si no mandan `contactName`, el contacto queda con el `externalId` como nombre y no con un "Sin nombre". Es a propósito: en la bandeja, alguien que ve `TCK-4471` puede ir a buscarlo del lado de ustedes.

### La respuesta

`201 Created`:

```json
{
  "conversationId": "b81d3f77-2c4a-4f19-9a86-31ee6c0b7d52",
  "agentSlug": "sofia",
  "created": true
}
```

- `conversationId` — el id que van a usar en los otros dos endpoints. Guárdenlo junto al caso de ustedes.
- `agentSlug` — el agente que quedó atendiendo. Siempre es el que pidieron en la URL: si no fuera a ser él quien contesta, el pedido habría fallado con `422` en vez de sustituirlo en silencio.
- `created` — `true` si esta llamada abrió la conversación, `false` si ya existía una para ese `externalId`. Ver sección 4.

### Ejemplo con curl

```bash
curl -X POST "https://api.lelemon.cl/v1/external/agents/sofia/conversations" \
  -H "Authorization: Bearer lel_a1b2c3d4e5f6_<...>" \
  -H "Content-Type: application/json" \
  -d '{
    "externalId": "TCK-4471",
    "contactName": "Marcela Fuentes",
    "contactEmail": "marcela.fuentes@ejemplo.cl",
    "contactPhone": "+56912345678"
  }'
```

### Códigos de estado

| Código | Qué pasó | Qué hacer |
|---|---|---|
| `201` | La conversación quedó abierta, o ya estaba. Mirar `created`. | Guardar el `conversationId`. |
| `400` | Falta `externalId`, viene vacío o se pasó de los 120 caracteres; `contactEmail` no tiene forma de correo; o el cuerpo trae un campo que no está en la lista. El campo `errors` dice cuál falló. | Arreglar el pedido. Reintentar tal cual no sirve. |
| `401` | Falta la credencial o no es válida. Ver sección 6. | Revisar la cabecera y la clave. |
| `403` | Tres causas posibles, distinguibles por el mensaje: a la credencial le falta `agents:converse`; la organización de la credencial fue eliminada; o la suscripción está cancelada. | Escribirnos. Ninguna se arregla reintentando. |
| `403` | `El agente '<slug>' es interno: contesta al dueño de la organización y no se expone por API.` | Usar un agente de cara al público. Ver abajo. |
| `404` | `No existe el agente '<slug>'.` | Revisar el slug. Es el mismo `404` exista el agente en otra organización o no exista en ninguna: decir "existe pero no es tuyo" le confirmaría el slug a quien está probando agentes ajenos. |
| `422` | `El agente '<slug>' no está activo.` | Activarlo desde la pantalla del agente. |
| `422` | `El agente '<slug>' no tiene habilitado el canal 'api'...` | Habilitar el canal `api` en la configuración del agente. El mensaje dice qué canales atiende hoy. |
| `422` | `Ese agente no está conectado a ningún flujo...` | Asignarlo a una etapa desde la pantalla del flujo. Sin flujo no hay dónde dejar la conversación. |
| `422` | `El agente '<slug>' está en ese flujo pero no en su etapa de entrada...` | Ver abajo. |
| `429` | Se pasaron del techo por credencial. Ver sección 5. | Esperar y reintentar. Es seguro. |
| `500` | Error nuestro. | Reintentar con espera. Si persiste, escríbannos: los `500` nos llegan como alerta. |

### Por qué un agente interno da `403`

Un agente interno es el del CRM sin pantalla: le contesta al dueño de la organización y trae herramientas que leen contactos con su teléfono, tratos y líneas de cartola con el RUT de la contraparte. En WhatsApp lo protege una lista blanca de teléfonos; acá no hay teléfono que valga, así que la puerta es la audiencia declarada del agente. Sólo se puede conversar por API con un agente marcado como externo.

El mensaje sí dice cuál es el problema, a diferencia del `404` de más arriba, porque la credencial pertenece a la misma organización dueña del agente: no le estamos revelando nada que no sea suyo.

### Por qué la etapa de entrada da `422`

Un agente no tiene flujo propio. El vínculo vive del otro lado: en la etapa que lo tiene asignado. Nosotros buscamos el flujo que tenga alguna etapa con ese agente y metemos la conversación por la **etapa de entrada** de ese flujo, que es el mismo recorrido que hace una conversación de WhatsApp.

Si el agente que nombraron está en el flujo pero no en su etapa de entrada, quien contestaría sería el agente de la etapa de entrada —con otro prompt y otras herramientas— mientras la respuesta del `open` sigue diciendo el slug que pidieron. Antes que sustituirlo en silencio, se rechaza y se explica. La salida es asignarlo a la etapa de entrada, o abrir la conversación con el agente que sí está ahí.

---

## 2. Mandar un mensaje

```
POST https://api.lelemon.cl/v1/external/conversations/{id}/messages
```

Donde `{id}` es el `conversationId` que devolvió el paso anterior. Tiene que ser un UUID válido.

Alcance que exige: `agents:converse`.

### El cuerpo

| Campo | Tipo | Límite | Obligatorio |
|---|---|---|---|
| `message` | texto | 4.000 caracteres | sí |
| `externalRef` | texto | 120 caracteres | no, pero **mándenlo siempre** |

`externalRef` es el id de este mensaje en el sistema de ustedes, el mismo campo que usa `POST /conversations/{id}/send`. Con él, reintentar es seguro: un segundo `POST` con el mismo `externalRef` en la misma conversación no guarda el mensaje otra vez ni hace contestar al agente de nuevo, y la respuesta trae `"alreadySent": true`. Qué devuelve ese reintento:

- Si el agente ya contestó: `200` `answered`, con la misma respuesta.
- Si el turno sigue corriendo: `202` `pending`.
- Si alguien del equipo tomó la conversación: `200` `human`.
- Si el turno había fallado (el `503`): lo volvemos a intentar, una vez por fallo, y responde como un envío normal.
- Si el `externalRef` ya se usó en esa conversación con otro texto: `409`. Usen uno nuevo para cada mensaje.

Sin `externalRef` no tenemos con qué darnos cuenta de que es un reintento: cada `POST` guarda el mensaje y hace contestar al agente.

El tope son 4.000 y no los 1.000 del chat web porque del otro lado no hay una persona tecleando en una landing: hay un sistema que reenvía el cuerpo de un ticket, y 1.000 lo partiría al medio. Son los mismos 4.000 que ya acepta el `message` del endpoint de resolución, un solo criterio para toda la superficie externa, y quedan holgadamente bajo el tope del motor: el que corta somos nosotros, con un `400` claro, y no el motor con un error suyo.

### La respuesta

La llamada es sincrónica: se manda el mensaje y se espera a que el agente conteste. Hay tres desenlaces y el código de estado los distingue, para que del otro lado no haya que leer el cuerpo para saber si queda algo por hacer. El `status` viaja igual adentro, porque un cliente que sólo mira `res.ok` tiene que poder enterarse.

**`200 OK`, el agente contestó:**

```json
{
  "conversationId": "b81d3f77-2c4a-4f19-9a86-31ee6c0b7d52",
  "status": "answered",
  "response": "Hola Marcela. Veo la solicitud ingresada el viernes y sigue en revisión."
}
```

**`200 OK`, la tomó una persona:**

```json
{
  "conversationId": "b81d3f77-2c4a-4f19-9a86-31ee6c0b7d52",
  "status": "human"
}
```

No viene `response` y no es un error. Alguien del equipo tomó la conversación a mano, así que el agente se calla: el mensaje de ustedes **sí quedó guardado** y le aparece a esa persona en la bandeja. Contestar por encima le hablaría al contacto con dos voces. Ojo con la distinción: una conversación *escalada* —el agente pidió ayuda pero nadie la tomó todavía— no cae acá; ahí el agente sigue contestando.

**`202 Accepted`, se venció el techo de espera:**

```json
{
  "conversationId": "b81d3f77-2c4a-4f19-9a86-31ee6c0b7d52",
  "status": "pending"
}
```

Esperamos hasta **25 segundos** por la respuesta del agente. Un turno con herramientas puede tardar bastante, y una respuesta sin techo es una promesa que no se puede cumplir: del otro lado hay un cliente HTTP con su propio tiempo de espera, y cuando corta, nosotros seguimos gastando tokens para nadie. Los 25 segundos dejan margen bajo los 30 que traen por defecto casi todos los clientes, así que el que corta somos nosotros, con una respuesta que se entiende, y no la red con un socket caído.

Lo importante del `202`: **el turno no se cancela.** Sigue corriendo y su respuesta se escribe igual en la conversación. Cortarlo dejaría al contacto sin respuesta después de haber pagado el modelo. Lo que hay que hacer entonces es ir a buscarla con el `GET` de la sección 3, esperando un poco y reintentando. No hay evento que les avise cuando llegó; ver "Lo que todavía no hacemos".

### Ejemplo con curl

```bash
curl -X POST "https://api.lelemon.cl/v1/external/conversations/b81d3f77-2c4a-4f19-9a86-31ee6c0b7d52/messages" \
  -H "Authorization: Bearer lel_a1b2c3d4e5f6_<...>" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Hola, subi los documentos el viernes y todavia no me aparece nada.",
    "externalRef": "TCK-42-msg-3"
  }'
```

### Códigos de estado

| Código | Qué pasó | Qué hacer |
|---|---|---|
| `200` | El agente contestó (`answered`) o la conversación la tiene tomada una persona (`human`). | Leer `status` antes de leer `response`. |
| `202` | Se vencieron los 25 segundos. El mensaje quedó guardado y la respuesta se va a escribir. | Ir a buscarla con el `GET`, o reintentar el `POST` con el mismo `externalRef`. Sin `externalRef`, no reintentar: mandaría el mismo mensaje dos veces. |
| `400` | Falta `message`, viene vacío, pasa los 4.000 caracteres, el cuerpo trae un campo de más, o el `{id}` de la ruta no es un UUID válido. | Arreglar el pedido. |
| `401` | Falta la credencial o no es válida. | Ver sección 6. |
| `403` | A la credencial le falta `agents:converse`, la organización fue eliminada, o la suscripción está cancelada. | Escribirnos. |
| `403` | `Esta conversación no se abrió por la API: sólo se puede conversar en las que abriste tú.` | Ver abajo. |
| `404` | `No existe la conversación <id>.` La conversación no existe, está borrada o es de otra organización. | Revisar que sea el `conversationId` que devolvió el `open`. No reintentar en loop. |
| `409` | `Esta conversación llegó a 200 mensajes. Abre una nueva con otro externalId.` | Ver abajo. |
| `409` | `Ese externalRef ya se usó en esta conversación con otro mensaje. Usa uno nuevo para cada mensaje.` | Generar un `externalRef` distinto por mensaje. |
| `422` | `La conversación está en una etapa sin agente: no hay quién conteste.` La etapa donde quedó parada la conversación no tiene ningún agente asignado. | Revisar el flujo desde la pantalla. Reintentar no cambia nada hasta que se asigne un agente a esa etapa. |
| `422` | `El agente '<slug>' no tiene habilitado el canal 'api'...` Le sacaron el canal después de que la conversación ya existía, o la conversación se movió a una etapa cuyo agente no lo tiene. | Habilitar el canal `api` en ese agente. Reintentar antes de eso no sirve. |
| `429` | Se pasaron del techo por credencial. | Esperar y reintentar. |
| `503` | El turno falló antes de los 25 segundos. El mensaje **ya quedó guardado**. Con `externalRef`: `El agente no pudo responder. Reintenta con el mismo externalRef: lo volvemos a intentar sin guardar el mensaje dos veces.` Sin él: `El agente no pudo responder y el mensaje ya quedó registrado: no lo mandes de nuevo, porque quedaría dos veces. Lee el hilo con GET /conversations/{id}/messages.` | Con `externalRef`, reintentar con el mismo. Sin él, no reintentar: esperar y mirar el hilo con el `GET`. |
| `500` | Error nuestro. | Reintentar con espera. |

### Por qué una conversación ajena da `403`

Una credencial con `agents:converse` no puede escribir dentro de una conversación que entró por otro canal. El mensaje se persiste con rol `user`, o sea "esto lo dijo el contacto": en un hilo de WhatsApp eso sería ponerle palabras en la boca a una persona. Y el id de conversación no es un secreto —lo mandamos nosotros en cada aviso saliente de escalamiento—, así que sin esta puerta bastaría con haber recibido un webhook para escribir en el WhatsApp de cualquier contacto de la organización. Sólo se puede conversar en las conversaciones que abrió la propia API.

### Por qué hay un tope de 200 mensajes

Cada mensaje reenvía el historial al modelo, así que una conversación que no termina nunca se va poniendo más cara con cada turno. Al llegar a 200 se corta con el `409` y la salida es abrir una conversación nueva con otro `externalId`, que además es lo correcto: 200 mensajes ya no son el mismo caso.

El tope se mira **antes de escribir**. Pasado el límite, el mensaje ni siquiera se guarda. Guardarlo y no contestarlo sería peor: el contacto quedaría con un mensaje colgado que nadie va a responder.

---

## 3. Leer el hilo

```
GET https://api.lelemon.cl/v1/external/conversations/{id}/messages
```

Alcance que exige: `conversations:read`, **no** `agents:converse`. Leer y hacer gastar tokens son dos permisos distintos, así que una integración que sólo consulta el hilo no necesita poder correr al modelo.

Es la contraparte del `202`: sin esto, una respuesta que se pasó del techo quedaría escrita y sin forma de leerla desde afuera.

### La respuesta

`200 OK`:

```json
{
  "conversationId": "b81d3f77-2c4a-4f19-9a86-31ee6c0b7d52",
  "messages": [
    {
      "role": "user",
      "content": "Hola, subi los documentos el viernes y todavia no me aparece nada.",
      "createdAt": "2026-08-19T14:28:55.102Z"
    },
    {
      "role": "assistant",
      "content": "Hola Marcela. Veo la solicitud ingresada el viernes y sigue en revisión.",
      "createdAt": "2026-08-19T14:29:31.774Z"
    },
    {
      "role": "human",
      "content": "Marcela, soy Pablo del equipo. Ya lo estoy revisando.",
      "createdAt": "2026-08-19T14:35:02.410Z"
    }
  ]
}
```

- `role` — `user` es lo que mandaron ustedes por la API, `assistant` es el agente IA y `human` es alguien del equipo de Lelemon que tomó la conversación. Van separados a propósito: del otro lado importa saber si contestó el agente o una persona.
- `content` — el texto del mensaje.
- `createdAt` — cuándo se escribió, en ISO 8601 UTC.

Los mensajes vienen ordenados del más viejo al más nuevo, hasta 200 —el mismo tope de la conversación, así que en la práctica salen todos—. Los mensajes de rol `system` quedan afuera: son notas internas nuestras, como resoluciones o evidencia de escalamientos, y no conversación con el contacto.

### Ejemplo con curl

```bash
curl "https://api.lelemon.cl/v1/external/conversations/b81d3f77-2c4a-4f19-9a86-31ee6c0b7d52/messages" \
  -H "Authorization: Bearer lel_a1b2c3d4e5f6_<...>"
```

### Códigos de estado

| Código | Qué pasó | Qué hacer |
|---|---|---|
| `200` | El hilo. | — |
| `400` | El `{id}` de la ruta no es un UUID válido. | Arreglar el pedido. |
| `401` | Falta la credencial o no es válida. | Ver sección 6. |
| `403` | A la credencial le falta `conversations:read`, la organización fue eliminada, o la suscripción está cancelada. | Escribirnos. |
| `404` | La conversación no existe, está borrada o es de otra organización. | Revisar el id. |
| `429` | Se pasaron de un techo por minuto (200 por IP o 3.000 por credencial). Ojo con esto si están sondeando un `pending`. | Esperar lo que diga `Retry-After`. |
| `500` | Error nuestro. | Reintentar con espera. |

### Leer no mira el origen

A diferencia de escribir, leer no mira por dónde entró la conversación: con `conversations:read` se lee cualquier conversación de la organización, también las que llegaron por WhatsApp, con lo que escribió el equipo humano. Traten una credencial con ese alcance como acceso a todas las conversaciones, y no la compartan con un sistema que sólo necesita las suyas.

---

## 4. Idempotencia: el `externalId`

El `externalId` es obligatorio y es la llave de idempotencia. Sin él, el reintento de una llamada que ya escribió abriría una segunda conversación para el mismo caso, y del lado de ustedes quedarían dos hilos donde había uno.

Lo que garantiza: **dentro de una organización, un `externalId` tiene a lo más una conversación abierta con ese agente.** Usen la llave del caso del lado de ustedes, que ya es única ahí: el número del ticket, el id del chat, lo que corresponda.

Cómo se resuelve, en dos redes:

1. Antes de escribir nada, buscamos si ya hay una conversación viva para ese `externalId` en el flujo de ese agente. Si la hay, la devolvemos con `created: false` y no tocamos nada más.
2. Aun así, dos reintentos pueden entrar a la vez y las escrituras no están en una transacción. Por eso las dos inserciones se apoyan en índices únicos que ya existen en la base, y el reintento que pierde la carrera resuelve a la fila del que ganó en vez de reventar.

Por eso **reintentar el `POST` de apertura es seguro**: el peor caso es que reciban un `201` con `created: false` y el mismo `conversationId` que ya tenían.

`created` es justamente lo que un reintento necesita saber para no duplicar su propio trabajo. Si viene `false` y ustedes iban a mandar el primer mensaje de bienvenida, ya lo mandaron antes.

Un detalle sobre los datos del contacto: en una llamada repetida, `contactName`, `contactEmail` y `contactPhone` se refrescan si vienen, pero **nunca se pisan con nulos**. Un segundo pedido más pobre que el primero no le borra el nombre a un contacto que ya lo tenía.

Y una aclaración que conviene tener a mano: el `externalId` vive en un espacio de nombres fijo, no atado a la credencial. Rotar la clave —que es algo que se espera que pase— no deja huérfanas las conversaciones abiertas.

**El `open` es idempotente por `externalId`; el `messages`, por `externalRef`.** Sin `externalRef`, mandar el mensaje dos veces lo manda dos veces: si reciben un `202` o un `503`, el mensaje ya quedó escrito y la salida es el `GET`, no repetir el `POST`.

---

## 5. El techo de pedidos

Hay dos techos por minuto, en ventana deslizante:

- **200 pedidos desde una misma dirección IP.** Es el que topa primero una integración, porque todo su tráfico sale de la misma IP. Pasado, `429` con el cuerpo `Demasiadas solicitudes, intenta de nuevo en un momento.`
- **3.000 pedidos por credencial.** Pasado, `429` con el cuerpo `Demasiadas solicitudes con esta credencial, intenta de nuevo en un momento.`

Los dos traen `Retry-After` con los segundos que faltan.

Dos cosas prácticas:

- **El rechazo no cuenta como pedido.** Si contara, la ventana se correría sola con cada reintento y quien insiste quedaría castigado para siempre.
- **Los tres endpoints comparten el mismo balde.** Si van a sondear el `GET` esperando una respuesta que quedó `pending`, espacien los reintentos: un sondeo cada segundo se come el cupo del minuto entero y deja sin lugar a los mensajes.

El de la IP es el límite general de toda la API, no sólo de esta superficie.

---

## 6. 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.

Para entrar tienen que darse cuatro cosas, en este orden: la clave existe y no está revocada, el secreto coincide, la organización sigue viva y no cancelada, y la credencial tiene el alcance que el endpoint exige.

Los `401` que pueden ver:

- `Falta la credencial. Mandala en el header Authorization: Bearer <clave>.` — no mandaron ninguna, o no viene con el prefijo `Bearer `.
- `Credencial invalida.` — la clave no existe, está revocada, o no tiene la forma esperada. Es el mismo mensaje para los tres casos a propósito: distinguirlos le diría a quien está probando si acertó la forma.

Los `403` que no son de alcance:

- `La organizacion de esta credencial fue eliminada.`
- `La suscripcion de esta organizacion esta cancelada: la API queda cerrada hasta reactivarla.`

Los dos aparecen recién después de haber probado que se tiene la clave. Antes de eso, contar que una organización está borrada le regalaría información a quien sólo está probando prefijos al azar. Un atraso en el pago no cierra la API: sólo la cancelación.

Alcances de esta superficie:

- `agents:converse` — abrir una conversación con un agente y mandarle mensajes.
- `conversations:read` — leer el hilo de una conversación.

`agents:converse` va separado de `conversations:resolve` porque son dos poderes distintos: resolver es cerrar algo que nosotros escalamos, mientras que conversar hace correr al modelo, gasta tokens y deja al agente ejecutando herramientas. Una integración que sólo cierra tickets no tiene por qué poder hacer eso. Un endpoint que no declare explícitamente su alcance se rechaza, así que la clave no se abre sola a superficie nueva.

Lo demás de la credencial —que se entrega una sola vez, que de nuestro lado sólo vive su hash, que se revoca pero no se recupera— vale igual acá.

**No la manden por un canal que quede registrado en claro**: nada de correo, chat corporativo, ticket ni comentario de un issue. Si sospechan que quedó expuesta, avísennos y la revocamos.

---

## 7. El cuerpo de los errores

Todos los errores llegan con esta forma:

```json
{
  "statusCode": 403,
  "code": "scope_missing",
  "message": "A esta credencial le falta el permiso: agents:converse.",
  "timestamp": "2026-08-19T15:02:11.883Z",
  "path": "/v1/external/agents/sofia/conversations"
}
```

### Códigos de error

El campo `code` es lo que su integración debe leer para decidir qué hacer. Es un texto en inglés, en minúsculas, y **no cambia**: el `message` puede reescribirse, el `code` no. Un código nuevo puede aparecer más adelante, así que su integración debe tratar uno que no conoce según su `statusCode`.

| `code` | Status | Cuándo pasa | Qué hacer |
|---|---|---|---|
| `validation_failed` | 400 | El cuerpo no cumple lo que pide la ruta. | Corregir el pedido. No reintentar igual. |
| `api_key_invalid` | 401 | Falta la credencial, está mal escrita, fue revocada o venció. No distinguimos entre esos casos a propósito. | Revisar la clave; si estaba bien, pedir una nueva. |
| `scope_missing` | 403 | La credencial no tiene el permiso que pide la ruta. | Pedir que le agreguen ese permiso. |
| `organization_deleted` | 403 | La organización de la credencial fue eliminada. | Escribirnos. |
| `subscription_canceled` | 403 | La suscripción está cancelada y la API queda cerrada. | Reactivar la suscripción. |
| `agent_not_exposed` | 403 | El agente es interno y no se habla con él por API. | Usar otro agente. |
| `conversation_not_from_api` | 403 | La conversación no se abrió por la API. | Abrir una con `open`. |
| `agent_without_api_channel` | 422 | El agente no tiene el canal `api` activo. | Activar el canal en el agente. |
| `conversation_limit_reached` | 409 | La conversación llegó al tope de mensajes. | Abrir otra con otro `externalId`. |
| `external_ref_conflict` | 409 | Ese `externalRef` ya se usó con otro mensaje. | Usar un `externalRef` nuevo por mensaje. |
| `agent_turn_failed` | 503 | El agente no pudo responder. | Reintentar con el mismo `externalRef`; sin él, mirar el hilo antes. |
| `action_origin_not_allowed` | 403 | La credencial no puede crear acciones hacia ese sitio. | Que un administrador agregue el sitio a la credencial. |
| `action_destination_blocked` | 400 | La dirección de la acción no es pública o no es http o https. | Usar una URL pública. |
| `rate_limited` | 429 | Pasaron el límite de pedidos. | Esperar lo que diga `Retry-After`. |

Si el error no tiene un código propio, `code` toma uno según el `statusCode`:

| Status | `code` |
|---|---|
| 400 | `invalid_request` |
| 401 | `unauthorized` |
| 403 | `forbidden` |
| 404 | `not_found` |
| 409 | `conflict` |
| 413 | `payload_too_large` |
| 422 | `unprocessable` |
| 429 | `rate_limited` |
| 500 | `internal_error` |
| 503 | `service_unavailable` |

Los `400` de validación traen `code: "validation_failed"`, el `message` genérico `Validation failed` y un campo `errors` con un elemento por campo que falló: `field` (el nombre), `constraints` (qué regla no cumplió) y `received` (la forma de lo que llegó, por ejemplo `string(130)`, nunca el valor). Los demás errores de este documento traen el texto que aparece en las tablas.

---

## 8. Si algo falla

**Todos los mensajes vuelven `202`.** El agente se está pasando de los 25 segundos, muy probablemente porque el turno usa herramientas. Las respuestas están quedando escritas igual: confírmenlo con el `GET`. Si el hilo tiene las respuestas y sólo llegan tarde, no hay nada roto del lado de ustedes; escríbannos y miramos qué está demorando el turno.

**Un `409` a la primera.** Están reusando un `externalId` de un caso viejo que llegó a 200 mensajes. Abran uno nuevo con otro `externalId`.

**Un `403` diciendo que la conversación no se abrió por la API.** Están usando un `conversationId` que salió de un aviso de escalamiento de WhatsApp, no del `open`. Esta superficie sólo alcanza las conversaciones que abrió la propia API.

**Un `422` de etapa sin agente en una conversación que venía funcionando.** La conversación se movió de etapa —lo más común es un escalamiento del agente— y quedó parada en una etapa que no tiene ningún agente asignado. No es algo que se arregle desde afuera.

**Un `404` con un id que sí devolvió el `open`.** Revisen que estén mandando el `conversationId` y no el `externalId` de ustedes. Si es el correcto, escríbannos.

**Un `401` de un día para el otro.** La clave se revocó o se rotó. Escríbannos y emitimos una nueva.

**Un `503`.** El turno del agente falló y el mensaje ya quedó registrado. Si lo mandaron con `externalRef`, reintenten con el mismo y lo volvemos a intentar. Si no, no lo reenvíen: esperen unos minutos y miren el hilo.

---

## 9. Lo que todavía no hacemos

Cuatro límites conocidos, para que no los descubran el día que los necesiten.

**Los dos techos cuentan en memoria de cada instancia del servidor.** Con varias instancias corriendo, el techo efectivo se multiplica por la cantidad de instancias. No diseñen contra los números exactos como si fueran una garantía en ninguna de las dos direcciones.

**No hay ningún aviso saliente de "el agente contestó".** El único evento que sale por webhook hoy es `conversation.escalated`. Cuando un mensaje devuelve `202`, la única forma de enterarse de la respuesta es sondear el `GET` del hilo. Si sondean, espacien: el techo de 200 por minuto por IP es el mismo para toda la API.

**No se puede cerrar una conversación desde afuera.** Los únicos endpoints externos que existen son estos tres, más los del acuerdo de escalamiento —avisar que un escalamiento se resolvió y consultar el estado de una conversación—. No hay forma de decirnos "este caso terminó, archívalo". Lo que sí pueden hacer es dejar de mandarle mensajes y abrir una conversación nueva con otro `externalId` cuando arranque el caso siguiente.

**No hay adjuntos.** El único contenido que acepta el `POST` de mensaje es texto en el campo `message`. No hay forma de mandar un archivo, una imagen ni un audio por esta vía.
