Documentación

Poné tu artefacto adentro de Chayana

Tu bot, tu app o tu proceso siguen corriendo donde ya corren, en tu código y tu infraestructura. Lo único que agregás son dos llamadas HTTP: una pregunta si el cliente está habilitado, la otra cuenta lo que hiciste. Nosotros cobramos, damos de baja y le mostramos al cliente para qué sirve lo que paga.

Quién hace qué

Todo lo que se hablan Chayana y un complemento pasa por los endpoints de esta página. No hay otro camino, y ningún complemento tiene un trato distinto: el mismo contrato para todos.

Chayana

  • Es dueña del canal del cliente: su WhatsApp y los demás.
  • Decide a quién le toca cada conversación: por anuncio, porque el agente la deriva o porque vos la devolvés.
  • Cobra y te dice si estás habilitado.
  • Guarda la conversación entera y pone topes.
  • Firma todo lo que te manda.

Tu complemento

  • Es dueño de su lógica y de su estado.
  • Decide qué contestar y cuándo soltar la conversación.
  • Comprueba la firma de lo que le llega.
  • Respeta puede_atender y reporta lo que hizo.
  • Pone su propio tope, además del nuestro.

Nosotros no ejecutamos tu complemento ni conocemos su lógica. Vos no tocás Meta ni guardás credenciales del cliente.

Quién llamaQuéPara qué
El complementoPOST /api/complementos/verificarSi el cliente está al día y podés trabajar.
El complementoPOST /api/complementos/reportarQué hiciste, para que el cliente vea para qué paga.
El complementoPOST /api/complementos/conversarPedirle una respuesta al agente sin soltar la conversación.
El complementoPOST /api/complementos/enviarMandar un mensaje por el canal del cliente.
El complementoPOST /api/complementos/asignarDevolverle la conversación al agente.
El complementoPOST /api/complementos/escribiendoRenovar el «escribiendo…» mientras trabajás.
Chayanaaviso mensajeAlguien escribió en una conversación que atendés vos.
Chayanaaviso derivacionEl agente te pasa una conversación.
Chayanaaviso leadEl agente registró a alguien interesado.

Las dos primeras son lo mínimo: con eso un complemento que tiene su propio canal ya está integrado. Los avisos son opcionales y te llegan sólo si nos das una dirección donde recibirlos.

La credencial

Cuando instalamos tu complemento para un cliente te damos un token que arranca con agc_. Es por instalación: el mismo complemento vendido a diez clientes son diez tokens distintos, y dar de baja a uno no toca a los demás.

Va en la cabecera Authorization de todas las llamadas. Guardalo como guardarías una clave de base de datos: en una variable de entorno, nunca en el repositorio. Si se filtra, avisanos y lo rotamos; el viejo deja de servir en el acto.

Ese token es además la clave con la que firmamos la respuesta, así que es lo único que compartimos y no viaja nunca en el cuerpo de nada.

Verificar

Preguntá al arrancar, y después sólo cuando la licencia que tenés esté por vencer. No en cada mensaje.

POST /api/complementos/verificar

curl -X POST https://www.chayana.co/api/complementos/verificar \
  -H "Authorization: Bearer agc_tu_token" \
  -H "content-type: application/json" \
  -d '{"version":"1.0.0"}'

El cuerpo es opcional. Si mandás version la registramos, y es lo que nos permite dejar de soportar versiones viejas sin romperle el día a nadie. Si no la mandás y el cliente tiene una versión mínima configurada, no te habilitamos.

Respuesta · 200

{
  "codigo": 1,
  "estado": "habilitado",
  "descripcion": "Habilitado.",
  "puede_atender": true,
  "modo": "completo",
  "mensaje_para_el_usuario": null,
  "vence": "2026-08-28T04:31:24.991Z",
  "emitida": "2026-08-27T04:31:24.991Z",
  "complemento": { "clave": "cotizador_piletas", "nombre": "Cotizador" },
  "cliente": { "nombre": "AquaFibra", "agente": "Jordan" },
  "uso": { "sesiones_del_mes": 12, "sesiones_incluidas": null },
  "reportar_en": "https://www.chayana.co/api/complementos/reportar",
  "firma": "K7x..."
}

Siempre devolvemos 200 cuando la credencial es válida, aunque el cliente no pueda atender. El resultado está en el cuerpo, no en el estado HTTP: muchos clientes de HTTP tiran excepción ante un 402 o un 403 antes de que llegues a leer el motivo y el mensaje para la persona. El 401 sí es un 401: significa que no te reconocemos.

CampoTipoPara qué
codigonúmero1 a 5. Para el registro y para vos, no para ramificar.
estadotextoSlug estable. Distingue dos situaciones con el mismo código.
descripciontextoEn castellano, para tus logs. No se la muestres a nadie.
puede_atenderbooleanoSobre esto ramificás. Es la única pregunta que importa.
modotextoHoy siempre "completo". Existe para que un modo nuevo no te obligue a redesplegar.
mensaje_para_el_usuariotexto o nullQué decirle a la persona cuando no podés atender. Lo escribimos nosotros.
vencefecha ISOHasta cuándo vale esta licencia. Guardala hasta entonces.
emitidafecha ISOCuándo se emitió. Entra en la firma.
complementoobjetoclave y nombre del complemento.
clienteobjetonombre del cliente y de su agente en Chayana.
usoobjetosesiones_del_mes y sesiones_incluidas. Informativo: no cortamos por consumo.
reportar_enURLA dónde mandar el reporte. No la hardcodees.
firmatextoHMAC de los campos de la decisión, con tu token.

Los códigos

Están para tus registros y para que un humano entienda qué pasó. No ramifiques sobre ellos: para eso está puede_atender.

CódigoQué es¿Atiende?Cuándo
1HabilitadoSíTodo en orden.
2Falta de pagoDurante una semanaVencido el pago hay siete días de gracia atendiendo. Después deja de atender.
3InactivoNoSuspendido o dado de baja desde el panel, contrato vencido, o suscripción en pausa.
4Sin conexiónLo decidís vosNo lo devolvemos nunca. Lo generás vos cuando no nos alcanzás.
5OtroNoCredencial desconocida, tu versión es anterior a la mínima soportada, o estás fuera del horario que el cliente definió para vos (estado fuera_de_horario). Fuera de horario no empieces sesiones nuevas; las que ya tenías, terminalas. Mostrale a la persona mensaje_para_el_usuario.

Los horarios del cliente

El cliente define sobre su agente qué días y a qué horas atiende, y esos horarios te gobiernan a vos también. No es una decisión tuya ni nuestra: es la del negocio que paga.

Fuera de horario no tomás conversaciones nuevas. La que ya tenías, terminala igual: cortarle a alguien en la mitad de una cotización porque dieron las siete es peor que atenderla diez minutos de más.

Te enterás por /verificar, que te devuelve puede_atender en false con estado en fuera_de_horario y el texto que escribió el cliente para ese momento.

Respuesta · 200

{
  "codigo": 5,
  "estado": "fuera_de_horario",
  "descripcion": "Fuera del horario de atención.",
  "puede_atender": false,
  "modo": "completo",
  "mensaje_para_el_usuario": "Hoy ya cerramos. Te respondemos mañana a las 9.",
  "vence": "2026-08-28T12:00:00.000Z",
  "emitida": "2026-08-27T21:14:02.318Z",
  "firma": "K7x..."
}

Mirá el vence: una licencia de fuera de horario vence cuando el negocio vuelve a abrir, no al día siguiente. Guardala como cualquier otra y vas a estar preguntando de nuevo justo cuando abren.

Mostrale a la persona mensaje_para_el_usuario tal cual: lo escribió el cliente y dice cuándo vuelve. No inventes el tuyo.

Es una razón más para las dos reglas de siempre. Una: no guardes un «sí» para siempre, porque el horario se cierra solo y tu licencia vence justo para que lo notes. Dos: ramificá sobre puede_atender y no sobre el código, porque fuera de horario llega con el mismo 5 que cualquier otro motivo y lo que te importa es que hoy no atendés.

Cuatro reglas

Las formas de los mensajes se entienden solas. Esto es lo que hay que hacer bien, y es todo.

1

Guardá la licencia hasta que venza

Vale un día. Si preguntás en cada mensaje, cualquier caída nuestra —o un pico de latencia— se convierte en una caída tuya, y vos corrés en una infraestructura donde nosotros no podemos hacer nada. Con la licencia en la mano, nuestra caída no la nota nadie.

2

Ramificá sobre puede_atender, no sobre el código

El día que agreguemos un código, tu artefacto ya desplegado —que quizá corre en un lugar al que ya no volvés— lo va a tratar bien sin que lo toques. Un código desconocido con puede_atender en true es un caso nuevo que igual puede trabajar.

3

Comprobá la firma

Sin eso, cualquiera que se meta en el medio le dice a tu bot que está habilitado, o peor, que dejó de estarlo. Si la firma no da, descartá la respuesta y quedate con la licencia anterior.

4

Usá ids de sesión estables

El id de cada sesión lo ponés vos y tiene que ser el mismo si reintentás. Es lo único que impide que un reporte reenviado duplique la actividad y le muestre al cliente un embudo falso.

Comprobar la firma

Firmamos una concatenación de seis campos, no el JSON entero: si firmáramos el cuerpo tal cual, los dos lados tendrían que ordenar las claves igual y eso se rompe solo. El separador es | y puede_atender entra como 1 o 0. El resultado es HMAC-SHA256 en base64url, con tu token como clave.

El mensaje que se firma

codigo|estado|puede_atender|modo|vence|emitida

1|habilitado|1|completo|2026-08-28T04:31:24.991Z|2026-08-27T04:31:24.991Z

JavaScript

import { createHmac, timingSafeEqual } from "node:crypto";

function firmaValida(l, token) {
  const mensaje = [
    l.codigo, l.estado, l.puede_atender ? 1 : 0, l.modo, l.vence, l.emitida,
  ].join("|");

  const esperada = createHmac("sha256", token).update(mensaje).digest("base64url");
  const a = Buffer.from(esperada);
  const b = Buffer.from(l.firma ?? "");

  return a.length === b.length && timingSafeEqual(a, b);
}

Python

import base64, hmac, hashlib

def firma_valida(l, token):
    mensaje = "|".join([
        str(l["codigo"]), l["estado"], "1" if l["puede_atender"] else "0",
        l["modo"], l["vence"], l["emitida"],
    ])

    esperada = base64.urlsafe_b64encode(
        hmac.new(token.encode(), mensaje.encode(), hashlib.sha256).digest()
    ).rstrip(b"=").decode()

    return hmac.compare_digest(esperada, l.get("firma", ""))

Ojo con el base64url: va sin relleno. En Python hay que sacar los = del final, que es donde se tropieza todo el mundo.

Reportar

Una sesión es una charla, una visita o una corrida, según lo que seas. Nos decís a qué paso llegó y cómo terminó. Eso es todo lo que interpretamos; lo que pongas en detalle se guarda tal cual y no lo tocamos.

Reportá a qué paso llegó, no por cuáles pasó: una sola variable que se pisa. Quien llegó al cuarto paso pasó por los tres anteriores, y el embudo lo armamos nosotros. Los nombres de los pasos y de los desenlaces los acordamos al dar de alta tu complemento, y el orden en que los declarás es el orden del embudo.

POST /api/complementos/reportar

curl -X POST https://www.chayana.co/api/complementos/reportar \
  -H "Authorization: Bearer agc_tu_token" \
  -H "content-type: application/json" \
  -d '{
    "version": "1.0.0",
    "sesiones": [
      {
        "id": "wa-5491122334455-1782",
        "inicio": "2026-08-27T10:00:00Z",
        "fin": "2026-08-27T10:06:31Z",
        "paso": "precio",
        "desenlace": "cotizado",
        "contacto": "Marta",
        "detalle": { "modelo": "8x4", "metros": 32 }
      }
    ]
  }'

Respuesta · 200

{ "recibidas": 1, "guardadas": 1, "descartadas": 0 }

Hasta 500 sesiones por lote. Podés mandarlas de a una al cerrar cada sesión, o juntar y mandar cada tanto: nos da igual. Una sesión sin fecha de inicio legible se descarta sola, sin tirar el lote entero, y te lo decimos en descartadas.

Una sesión sin fin se cuenta como abierta. Podés reportarla al empezar y volver a mandarla completa después, con el mismo id: la segunda pisa a la primera.

Pasarle una consulta al agente

Tu artefacto sabe hacer una cosa muy bien y se traba con todo lo demás. Cotiza una pileta sin equivocarse en la cuenta y no tiene idea de si la lona aguanta granizo. En vez de contestar cualquier cosa o cortar, le pasás la pregunta al agente del cliente, que sí tiene el conocimiento del negocio cargado.

Te devolvemos el texto y lo mandás vos: el dueño del canal seguís siendo vos. Mandá el mismo sesion_id que usás para reportar y la charla continúa donde iba, así que podés delegar un turno, tres, o el resto de la conversación.

POST /api/complementos/conversar

curl -X POST https://www.chayana.co/api/complementos/conversar \
  -H "Authorization: Bearer agc_tu_token" \
  -H "content-type: application/json" \
  -d '{
    "sesion_id": "5491122334455",
    "mensaje": "¿la lona de la 8x4 aguanta granizo?",
    "contacto": "Marta",
    "conversacion": [
      { "quien": "persona", "texto": "Hola, quiero una pileta" },
      { "quien": "complemento", "texto": "¡Hola! ¿Qué modelo tenés en mente?" },
      { "quien": "persona", "texto": "La 8x4" },
      { "quien": "complemento", "texto": "Son 32 m2. Te queda en $ 6.900.000." }
    ],
    "contexto": "Total cotizado 4200. Presupuesto interno #8812."
  }'

Respuesta · 200

{
  "atendido": true,
  "codigo": 1,
  "estado": "atendido",
  "respuesta": "Sí, la lona reforzada que usamos está probada para granizo...",
  "conversacion_id": "8f3c...",
  "derivada": false,
  "costo_usd": 0.0142
}

derivada viene en true cuando, en ese turno, el agente te devolvió la conversación porque lo que pide la persona es lo que vos sabés hacer. Retomá tu proceso: por acá no hay canal por donde tomar el hilo, así que es la forma de enterarte. Si tu contrato dice que abrís vos, respuesta viene nula: el agente no habla, igual que por un canal, donde su texto no sale.

conversacion es la charla que venías teniendo, tal cual ocurrió de tu lado. Es lo que hace que el agente entre sabiendo: si la persona ya eligió, dio medidas y recibió un precio, tiene que leer eso y no un resumen de dos líneas. Se siembra una sola vez, en el primer traspaso de esa sesión, así que podés mandarla completa en cada llamada —que es lo más fácil de programar— sin que se duplique nada.

contexto es otra cosa: lo que sabés y no se dijo en la charla —un total, un id interno, el nombre del vendedor—. Va a las instrucciones del turno y no al historial, así que no le ensucia al cliente la conversación que después lee. Mandalo en cada llamada si cambia.

Cuando no se pudo, atendido viene en false con el motivo en estado: agente_no_operativo si el cliente todavía no puso su agente en producción, sin_comportamiento si no tiene uno publicado, el_motor_fallo si falló de nuestro lado. Y si el complemento no está habilitado, contesta lo mismo que la verificación, con mensaje_para_el_usuario listo para relevar. Sigue siendo 200: el motivo lo tenés que poder leer.

La charla queda del lado de Chayana, en la misma pantalla de conversaciones que el resto y marcada como llegada por un complemento. Ese es el punto: el cliente tiene un solo lugar donde mirar. El consumo de tokens va a su cuenta, no a la tuya, porque es su agente el que trabaja.

Tarda lo que tarda un modelo en contestar, así que no la llames con un timeout de dos segundos. Y si te contesta que no atendió, seguí vos: es tu conversación.

El contrato

Lo que tu complemento puede hacer no es lo mismo en todos lados: sale de tres capas apiladas, una por cada canal de cada agente. Manda la más específica, y un campo vacío hereda de la de abajo.

CapaQué diceCuándo
Chayana–complementoLo que tu complemento declara para todosSe acuerda al dar de alta el complemento y vale para cualquier cliente que lo contrate.
Agente–complementoLo que cambia esa instalaciónEl agente de un cliente puede pedir algo distinto de lo declarado. Pisa a la capa de arriba.
Excepción por canalLo que cambia en un canal puntualLo más específico que hay: el mismo complemento puede tomar el control en WhatsApp y no en el chat del sitio.

Encima de las tres va la compatibilidad del canal, que no se configura: si necesitás algo que el canal no sabe mostrar, ahí no tomás el control y listo.

Los campos

CampoQué decide
toma_controlSi en ese canal podés quedarte con la conversación. En false seguís existiendo, pero el hilo no es tuyo.
abre_al_tomarQuién habla primero cuando la conversación pasa a ser tuya: el complemento o el agente. Si abrís vos, el agente no dice nada en ese turno.
avisosCuáles de mensaje, derivacion y lead te llegan. El que no recibe mensaje no toma el control de nada: nunca se entera de lo que la persona escribe después.
requisitos_del_canalQué necesitás poder mostrar: opciones, documentos o imagenes. Si el canal no puede, ahí no tomás el control aunque todo lo demás diga que sí.
anunciosDe qué conversaciones nuevas te hacés cargo. Vacío y sin «tomar todas» es integración perfecta sin un solo mensaje.
relacionLo que el agente lee sobre tu complemento para saber cuándo pasártela y cuándo no.

Nada de esto lo configurás vos: lo configura Chayana en el panel, junto con el cliente. Vos no tenés una llamada para pedirlo ni una clave que te dé un trato distinto.

Lo que sí tenés que hacer es no asumir. Que hoy te llegue una derivacion no quiere decir que te llegue en todos los canales ni en todos los clientes; que en una instalación abras vos no quiere decir que abras en la siguiente. Escribí el complemento para que funcione con lo que le llega, no con lo que viste una vez: si no te llega lead, no te quedes esperando uno, y si no tomás el control del canal, no llames a /enviar.

Recibir una conversación

Es la vuelta, y es opcional. El agente del cliente puede devolverte una conversación —o pasarte una que empezó de su lado— cuando lo que la persona pide es justo lo que vos sabés hacer. Para eso necesitamos una dirección donde alcanzarte: se la das a Chayana al instalar y tiene que ser https.

Acá no te mandamos la charla: arrancás tu proceso desde donde corresponda. Si la conversación vino por un canal del cliente —su WhatsApp, por ejemplo— el campo canal lo dice y el hilo pasa a ser tuyo en ese momento: lo que la persona escriba después te llega como mensaje, igual que si hubiera entrado por un anuncio, y contestás con /enviar o soltás con /asignar. Si canal viene nulo, seguís como siempre por /conversar.

Lo que te llega, por POST

{
  "evento": "derivacion",
  "sesion_id": "wa-5491122334455-1782",
  "conversacion_id": "8f3c...",
  "contacto": "Marta",
  "canal": "whatsapp",
  "motivo": "Quiere cotizar una pileta",
  "resumen": "Preguntó por modelos y precios de piletas de fibra.",
  "datos": {
    "nombre": "Marta Gómez",
    "localidad": "Villa Allende",
    "modelo": "AF03"
  },
  "emitida": "2026-08-27T21:14:02.318Z"
}

datos es lo que el agente ya averiguó, suelto: pares de nombre y valor, cortos, con lo que la persona dijo. Sirve para no volver a preguntarle lo que ya contestó. El resumen es prosa para leer; esto es para tu programa.

Todos los campos son opcionales y pueden no venir nunca: el agente manda lo que le dijeron, no lo que a vos te conviene. No los des por seguros y no te apoyes en que exista uno. Un valor que no reconozcas —una localidad mal escrita, un modelo que no vendés— se descarta y se pregunta: lo que no puede pasar es que un dato dudoso arruine lo que sigue.

El sesion_id te dice de quién se trata. Si la conversación había empezado de tu lado, es el id que ya usás para reportar y para consultarnos. Si empezó del nuestro, es el identificador de la persona en el canal por donde escribió: en WhatsApp, su número. No es un id para reportar. Cuando la derivación trae canal, la clave del hilo es conversacion_id: reportá y consultá esa sesión con un id tuyo derivado de él, el mismo en /reportar y en /conversar.

Viene firmado en la cabecera x-chayana-firma: es el HMAC-SHA256 del cuerpo entero, tal cual lo recibiste, con tu token como clave y en base64url. Comprobalo antes de hacer nada. Sin eso, cualquiera que descubra tu dirección puede empujarte conversaciones inventadas. Por un tiempo, la misma firma viaja también en x-agentia-firma, que es el nombre que usábamos antes de llamarnos Chayana: si tu complemento todavía lee esa, pasalo a x-chayana-firma, porque la vieja va a desaparecer.

Comprobarlo

const firma = req.headers["x-chayana-firma"];
const esperada = createHmac("sha256", TOKEN)
  .update(cuerpoCrudo)          // el texto sin parsear, no el objeto
  .digest("base64url");

if (firma !== esperada) return res.status(401).end();

Contestá 2xx si la tomás. Si contestás un error, o si tardás más de ocho segundos, el agente se queda atendiendo él y le avisa a la persona: preferimos eso antes que dejarla sin nadie del otro lado.

Con canal, podés contestar la derivación con { "responder": "..." } para arrancar: sale por el canal en lugar del mensaje del agente, así la persona lee directo tu primera pregunta y no dos mensajes que dicen lo mismo. Si tu contrato dice que abrís vos, el agente no habla en ese turno: tenés que arrancar vos. Es mejor que un /enviar inmediato, que puede cruzarse con el mensaje del agente.

Si tu primera pregunta necesita más de un mensaje —fotos y después la pregunta con botones—, contestá con mensajes en lugar de responder:

Contestás con varios mensajes

{
  "mensajes": [
    { "mensaje": "Rectangular: la que mejor aprovecha patios angostos",
      "imagen": { "url": "https://tu-dominio/img/rectangular.png" } },
    { "mensaje": "Con solarium: escalón amplio para tomar sol",
      "imagen": { "url": "https://tu-dominio/img/solarium.png" } },
    { "mensaje": "¿Qué tipo de pileta estás buscando?",
      "opciones": { "tipo": "botones", "items": [
        { "id": "rect", "titulo": "Rectangular" },
        { "id": "sol", "titulo": "Con solarium" } ] } }
  ]
}

Salen en orden: cada uno recién cuando el canal aceptó el anterior, así la persona ve las fotos antes de la pregunta. Cada elemento tiene la forma del cuerpo de /enviar sin conversacion_id. Cada uno cuenta para el tope por hora. Las reglas exactas son cuatro:

  • Van hasta cinco. Si mandás más, salen los cuatro primeros y el último: el último suele ser la pregunta, y la persona nunca se tiene que quedar sin ella.
  • Cada elemento lleva mensaje y, como mucho, uno de opciones, imagen o documento. Si trae más de uno, sale el texto solo.
  • Un elemento sin mensaje se descarta. Una imagen sin texto no sale.
  • Si mandás responder y mensajes en la misma respuesta, gana mensajes y responder se ignora entero.

Ojo con esto: en la respuesta a un aviso no tenemos cómo avisarte un error —vos ya cerraste el POST y nadie va a leer lo que contestemos—, así que lo que no cumple se degrada en silencio. Nada corta la tanda: una imagen o un documento que no baja, o que el canal no muestra, sale como su texto solo; unas opciones que no cumplen salen numeradas; un elemento que el canal no acepta se saltea y siguen los demás. Por /enviar es al revés: ahí lo que no cumple lo rechazamos diciéndote el campo y el motivo. Si algo no te sale como esperabas en una respuesta a un aviso, probá el mismo cuerpo por /enviar y vas a leer qué está mal.

Los 8 segundos corren para contestar el aviso, no para que salgan los mensajes.

Recibir un interesado

También es opcional. Si el cliente eligió que su registro comercial lo lleves vos —una tarjeta por persona en su tablero, por ejemplo—, cada vez que su agente registra o actualiza a alguien interesado te llega un aviso lead, a la misma dirección y con la misma firma que las derivaciones.

No cambia quién atiende la conversación y no se le contesta nada a la persona. Puede llegar varias veces por la misma conversación, a medida que el agente sabe más: usá conversacion_id para actualizar lo que ya tenés en vez de duplicarlo.

Lo que te llega, por POST

{
  "evento": "lead",
  "conversacion_id": "8f3c...",
  "sesion_id": "web-3f2a...",
  "canal": "whatsapp",
  "contacto": { "id": "5493515550144", "nombre": "Martín" },
  "lead": {
    "nombre": "Martín Ríos",
    "telefono": "351 555-0144",
    "email": null,
    "interes": "Pileta de 6 metros en Villa Allende",
    "notas": "Preguntó por la fecha de instalación.",
    "calor": "caliente"
  },
  "emitida": "2026-09-18T19:02:11.004Z"
}

sesion_id es tu id de sesión para esa persona, tal cual lo mandaste en /conversar o en /reportar: con eso unís el interesado con lo que ya tenés de ella cuando la charla empezó de tu lado, donde nuestro conversacion_id no te dice nada. Si consultaste con más de uno, va el último. Viene nulo si la persona solo habló con el agente.

calor es frio, tibio o caliente, según los criterios que el cliente le dio a su agente, y nulo si no definió ninguno. Qué hacer con él —una etiqueta, un orden, un aviso— lo decidís vos.

Contestá 2xx apenas lo guardes; el cuerpo se ignora. Si contestás un error o tardás más de ocho segundos, el agente se entera de que no quedó registrado y sigue atendiendo. No hay segundo intento.

Atender por el WhatsApp del cliente

Hasta acá el dueño del canal seguías siendo vos: te devolvíamos texto y lo mandabas. Con esto podés no tener ninguno. El cliente ya conectó su WhatsApp en Chayana, y vos atendés por ahí sin hacer nada con Meta: ni número, ni webhook, ni verificación.

Sirve para el caso más común que hay: una persona toca un anuncio, se le abre WhatsApp, escribe, y la atendés vos.

Te llega el mensaje

A la misma dirección donde recibís las derivaciones, con la misma firma. Si la conversación entró por un anuncio del que te hacés cargo, la tomás vos y todo el hilo sigue viniendo acá hasta que la sueltes.

Lo que te llega, por POST

{
  "evento": "mensaje",
  "conversacion_id": "8f3c...",
  "canal": "whatsapp",
  "contacto": { "id": "5493875550000", "nombre": "Marta" },
  "mensaje": "Hola, quiero cotizar una pileta",
  "origen": {
    "anuncioId": "120226305854810726",
    "tipo": "ad",
    "titular": "Cotizá tu pileta",
    "clicId": "Aff-n8ZTODiE79d22KtAwQKj9e"
  }
}

origen viene sólo en el primer mensaje, que es el que dispara el anuncio. Los siguientes llegan sin eso, así que si lo necesitás, guardalo.

Contestás en el momento

{ "responder": "Dale, ¿de qué medida la querés?" }

Lo mandamos nosotros por el número del cliente. Tenés 8 segundos: si vas a tardar más —cotizar, consultar un sistema— contestá { "recibido": true } y usá la llamada de abajo cuando tengas la respuesta.

También podés contestar con varios mensajes en orden, con mensajes en lugar de responder, igual que al recibir una conversación: salen uno detrás del otro, fotos antes que la pregunta, con las mismas reglas.

Mandás vos

POST /api/complementos/enviar

curl -X POST https://www.chayana.co/api/complementos/enviar \
  -H "Authorization: Bearer agc_tu_token" \
  -H "content-type: application/json" \
  -d '{
    "conversacion_id": "8f3c...",
    "mensaje": "Te queda $ 20.400.000, incluye instalación."
  }'

Sólo en conversaciones que tengas tomadas. El mensaje sale a nombre del negocio del cliente, así que sin esa comprobación tu token serviría para escribirle a cualquier contacto suyo. Hay tope por hora: un bucle mal escrito son miles de mensajes desde un número que no es tuyo, y al que le bajan la calificación es al cliente.

Se la pasás al agente

POST /api/complementos/asignar

curl -X POST https://www.chayana.co/api/complementos/asignar \
  -H "Authorization: Bearer agc_tu_token" \
  -H "content-type: application/json" \
  -d '{
    "conversacion_id": "8f3c...",
    "contexto": "Cotizó 8x4, quiere hablar de financiación"
  }'

Desde ahí contesta el agente, con toda la charla adelante: lo que contestaste vos ya quedó guardado, no hace falta que mandes el historial. En contexto va lo que sabés y no está dicho en la charla.

El agente no espera a que la persona escriba: apenas soltás, retoma él con un mensaje desde donde quedó, usando tu contexto. Por eso conviene mandarlo siempre, y no despedirte vos antes de soltar: el cierre lo hace el agente.

Es una sola dirección. Después de esto, /enviar sobre esa conversación devuelve 409. Si el agente necesita volver a pasártela, tiene su propia herramienta para derivar.

Quién tiene el control

En cada conversación hay uno solo que atiende, y siempre se sabe cuál. Cuando el agente deriva, el control pasa a ser tuyo. Cuando llamás a /asignar, vuelve a ser del agente. No hay medias tintas ni un estado compartido.

Mientras el control es tuyo, nosotros somos un caño: no corregimos, no agregamos y no reemplazamos lo que decís. Si mandás un precio equivocado sale el precio equivocado. Eso es lo que hace que valga la pena poner tu artefacto acá, y también lo que te deja a vos la responsabilidad de lo que sale.

/conversar no cambia el control

«Me quedo yo, pero necesito que el agente conteste esto». Te devolvemos el texto y lo mandás vos. La conversación sigue siendo tuya en el turno siguiente.

/asignar sí lo cambia

«Yo terminé, quedátela vos». Después de eso, /enviar sobre esa conversación devuelve 409. No es un error a reintentar: es «ya no es mía».

Dos cosas que se hacen mal

Mandar el historial en contexto

No hace falta: todo lo que contestaste ya está guardado en la conversación y el agente lo lee entero. Si además se lo pegás en contexto, le estás duplicando la charla adentro de las instrucciones del turno. En contexto va sólo lo que sabés vos y no está dicho en la charla: por qué la soltás y en qué quedó.

Despedirte antes de asignar

El agente no espera a que la persona vuelva a escribir: apenas soltás, retoma él con un mensaje propio. Si te despediste un segundo antes, la persona lee dos cierres seguidos y se da cuenta de que del otro lado hay dos cosas distintas. Soltá y callate.

Escribiendo…

Cada vez que te entregamos un mensaje, a la persona le mostramos que le están escribiendo. En WhatsApp además le aparece el mensaje como leído. Dura hasta que sale la respuesta o 25 segundos, lo que pase primero, y no tenés que hacer nada.

Si vas a tardar más —cotizar, armar un PDF—, renovalo cada tanto mientras trabajás. Así la persona no mira una pantalla quieta.

POST /api/complementos/escribiendo

{ "conversacion_id": "8f3c..." }

→ 200 { "escribiendo": true }

Mismas reglas que /enviar: solo en conversaciones que atendés (409 si no), 403 si la licencia no te habilita. Si el canal no tiene indicador —el correo, por ejemplo—, contesta 200 con escribiendo: false: no es un error. Hay un tope de 600 por hora por instalación. No lo llames si no vas a contestar.

Botones, listas, documentos e imágenes

Además de texto, por el canal del cliente podés mandar opciones para tocar, un PDF y una imagen. Todo es opcional: si no lo usás, nada cambia.

Opciones, por /enviar o junto con responder

{
  "conversacion_id": "8f3c...",
  "mensaje": "¿Cómo podemos ayudarte?",
  "opciones": {
    "items": [
      { "id": "pileta", "titulo": "Quiero una pileta" },
      { "id": "ayuda",  "titulo": "Necesito ayuda" }
    ]
  }
}
  • tipo es botones o lista. Si falta, hasta 3 van como botones y de 4 a 10 como lista.
  • Botones: de 1 a 3, título hasta 20 caracteres, mensaje hasta 1024. No llevan descripción.
  • Lista: de 1 a 10, título hasta 24, descripción hasta 72, boton hasta 20 (por defecto "Ver opciones"), mensaje hasta 4096.
  • El id va hasta 200 caracteres. Ni los ids ni los títulos se repiten.
  • Solo con botones, una imagen arriba: "encabezado": { "imagen": "https://..." }. Con lista es un 400. Si la imagen no baja o el canal no la muestra, los botones salen sin ella; en /enviar te contestamos "encabezado": false.

No recortamos nada: dos títulos recortados pueden quedar iguales. En /enviar, lo que no cumple es un 400 que dice qué campo y por qué. En la respuesta de un aviso no tenés cómo enterarte de un error, así que ahí —y también si el canal no muestra botones o si WhatsApp los rechaza— mandamos el texto con las opciones numeradas desde 1, en el orden de items. La persona nunca se queda sin la pregunta.

Cuando la persona toca, te llega

{
  "evento": "mensaje",
  "conversacion_id": "8f3c...",
  "mensaje": "Quiero una pileta",
  "respuesta": { "id": "pileta", "titulo": "Quiero una pileta" }
}

mensaje sigue trayendo el título, para el que no lee respuesta. respuesta es nulo cuando la persona escribió en vez de tocar, y si toca un botón de un mensaje viejo te llega igual con su id.

Un PDF, solo por /enviar

{
  "conversacion_id": "8f3c...",
  "mensaje": "Tu presupuesto AF-20260910-ABC123. Válido por 15 días.",
  "documento": {
    "url": "https://tu-servidor/presupuestos/AF-20260910-ABC123.pdf?firma=...",
    "nombre": "Presupuesto AF-20260910-ABC123.pdf"
  }
}

Lo bajamos al recibir el pedido, lo validamos y lo guardamos: a WhatsApp le pasamos un link nuestro de corta vida, nunca el tuyo, y el cliente lo puede abrir desde su pantalla. La dirección tiene que ser https y no puede apuntar a una red privada; no seguimos redirecciones; tiene que responder en diez segundos, pesar hasta 5 MB, ser application/pdf y empezar con %PDF. El mensaje va como pie, hasta 1024. No va junto con opciones.

Una imagen, solo por /enviar

{
  "conversacion_id": "8f3c...",
  "mensaje": "Rectangular: la que mejor aprovecha patios angostos.",
  "imagen": { "url": "https://tu-servidor/modelos/rectangular.png" }
}

Las mismas reglas que el PDF, pero tiene que ser image/png o image/jpeg y empezar como tal. El mensaje va como pie, hasta 1024. No va junto con opciones ni con documento.

Si el documento o la imagen no se pudo, contestamos 422 con el motivo —direccion_no_permitida, no_se_pudo_bajar, no_es_pdf, no_es_imagen, muy_grande, el_canal_no_admite_documentos o el_canal_no_admite_imagenes— y no sale nada: mandá el texto solo.

Cada mensaje con opciones, documento o imagen cuenta como uno para el tope por hora. Lo que se mostró, lo que se eligió, el documento y la imagen quedan en la conversación: el cliente los ve en su pantalla, y el agente los tiene en su historial cuando le devolvés la charla.

Ejemplo completo

Esto es todo lo que hay que agregarle a un artefacto que ya funciona.

chayana.js

import { createHmac, timingSafeEqual } from "node:crypto";

const CHAYANA = "https://www.chayana.co";
const TOKEN = process.env.CHAYANA_TOKEN;
const VERSION = "1.0.0";

let licencia = null;

export async function licenciaVigente() {
  // Mientras esté vigente no se pregunta nada. Esta línea es la que hace que
  // una caída de Chayana no sea una caída tuya.
  if (licencia && new Date(licencia.vence) > new Date()) return licencia;

  try {
    const r = await fetch(CHAYANA + "/api/complementos/verificar", {
      method: "POST",
      headers: {
        authorization: "Bearer " + TOKEN,
        "content-type": "application/json",
      },
      body: JSON.stringify({ version: VERSION }),
    });

    if (r.status === 401) {
      // Nuestra credencial no vale. Reintentar no la va a arreglar.
      console.error("chayana: credencial rechazada");
      return licencia;
    }

    const nueva = await r.json();
    if (!firmaValida(nueva)) {
      console.error("chayana: firma inválida, alguien está en el medio");
      return licencia;
    }

    licencia = nueva;
  } catch (error) {
    // Código 4: no se pudo verificar. Con licencia vieja pero de hoy seguimos;
    // sin nada, decidimos nosotros. Acá elegimos atender.
    if (!licencia) {
      return { codigo: 4, puede_atender: true, modo: "completo" };
    }
  }

  return licencia;
}

/** Poner donde el artefacto está por contestar. */
export async function atender(responder) {
  const l = await licenciaVigente();
  if (!l.puede_atender) return responder(l.mensaje_para_el_usuario);
  return null;
}

export async function reportar(sesiones) {
  const l = await licenciaVigente();
  const url = l.reportar_en ?? CHAYANA + "/api/complementos/reportar";

  await fetch(url, {
    method: "POST",
    headers: {
      authorization: "Bearer " + TOKEN,
      "content-type": "application/json",
    },
    body: JSON.stringify({ version: VERSION, sesiones }),
  });
}

function firmaValida(l) {
  const mensaje = [
    l.codigo, l.estado, l.puede_atender ? 1 : 0, l.modo, l.vence, l.emitida,
  ].join("|");

  const esperada = createHmac("sha256", TOKEN).update(mensaje).digest("base64url");
  const a = Buffer.from(esperada);
  const b = Buffer.from(l.firma ?? "");

  return a.length === b.length && timingSafeEqual(a, b);
}

Antes de salir

Ocho pruebas. Ninguna necesita un cliente real y todas fallan fuerte en producción si no las hiciste, porque son los casos que no aparecen mientras probás a mano con todo andando.

  1. 1Apagá la red mientras corre y comprobá que sigue atendiendo con la licencia que ya tenía.
  2. 2Pedinos que suspendamos la instalación y comprobá que le mostrás a la persona el texto que mandamos nosotros, no uno tuyo.
  3. 3Reenviá el mismo lote de reportes dos veces y comprobá que no se duplica nada.
  4. 4Mandale un evento desconocido a tu webhook y comprobá que no rompe nada.
  5. 5Mandale un aviso con la firma cambiada y comprobá que lo rechaza.
  6. 6Hacé algo que tarde 20 segundos: el webhook tiene que contestar al instante y el mensaje tiene que llegar igual por /enviar.
  7. 7Después de /asignar, mandá un /enviar sobre esa conversación: tiene que dar 409 y tu código tiene que tratarlo como «ya no es mía», no como algo a reintentar.
  8. 8Reenviá el mismo aviso dos veces y comprobá que no se le contesta dos veces a la persona.

Y dos condiciones que son nuestras, sin las cuales no te llega nada por más que tu complemento esté impecable: el agente del cliente tiene que estar publicado, y la licencia te tiene que habilitar. Si probás y no pasa nada, empezá por ahí antes de revisar tu código.

Cuando algo falla

Qué pasóQué hacer
No nos alcanzás y tenés licencia vigenteSeguí trabajando. Ni la mires: para eso vence recién mañana.
No nos alcanzás y la licencia vencióEs tuya la decisión. Nuestra recomendación: seguí atendiendo y reintentá cada pocos minutos. Un cliente que paga no tiene por qué quedarse sin servicio por un problema nuestro.
Contestamos 401Tu credencial no existe o fue rotada. No reintentes en loop: avisá y pará.
La firma no daDescartá la respuesta y quedate con la licencia anterior. Alguien está en el medio.
El reporte devuelve 400Hay un error en tu carga, no de conexión. El cuerpo trae el campo que falla.
El reporte devuelve 500Fue nuestro. Reintentá el mismo lote más tarde: con los mismos ids no duplica.

OpenAPI

Si preferís generarte el cliente en vez de escribirlo, ahí está la especificación. Tené presente que describe las formas de los mensajes y no los comportamientos: lo de guardar la licencia, ramificar sobre puede_atender y usar ids estables no hay esquema que lo exprese, y es la parte que importa.

complementos.openapi.yaml

Si lo programás con un asistente

Este mismo contrato está escrito otra vez, más corto y en el formato que entienden los asistentes de código, en el repositorio del proyecto: .claude/skills/complemento-de-chayana/SKILL.md. Pegáselo al asistente antes de que escriba la primera línea y arranca sabiendo lo que en esta página tardás un rato en leer: los 8 segundos, el 409, la firma, los ids estables. Si no lo tenés a mano, pedínoslo.

¿Todavía no tenés un complemento dado de alta? Empezá por la página de complementos.

Contanos qué tenés andando

Si encaja, lo damos de alta y empezás a cobrarlo con el primer cliente que lo contrate.