Documentación

Poné tu artefacto adentro de agentia

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 agentia 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.

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 agentia.
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
1HabilitadoTodo 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, o tu versión es anterior a la mínima soportada.

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 USD 4.200." }
    ],
    "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...",
  "costo_usd": 0.0142
}

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 agentia, 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.

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.",
  "emitida": "2026-08-27T21:14:02.318Z"
}

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-agentia-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.

Comprobarlo

const firma = req.headers["x-agentia-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 saludar: sale por el canal justo después del mensaje del agente que le avisa a la persona que la pasa. Es mejor que un /enviar inmediato, que puede llegar antes que ese aviso.

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.

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 USD 12.400, 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.

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.

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 motivodireccion_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.

agentia.js

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

const AGENTIA = "https://www.chayana.co";
const TOKEN = process.env.AGENTIA_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 agentia no sea una caída tuya.
  if (licencia && new Date(licencia.vence) > new Date()) return licencia;

  try {
    const r = await fetch(AGENTIA + "/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("agentia: credencial rechazada");
      return licencia;
    }

    const nueva = await r.json();
    if (!firmaValida(nueva)) {
      console.error("agentia: 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 ?? AGENTIA + "/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);
}

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

¿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.