Ejemplos de integración

Recetas completas para los escenarios reales de un proveedor de internet: cliente reutilizable de la API, aviso de mantenimiento por región, atención abierta por el ERP para el cliente bloqueado, NOC que abre una OS y avisa al cliente, portal del suscriptor, sincronización con CRM y BI, indicadores del mes, recepción segura de webhooks, envío de boleto en PDF y etiquetar y cerrar desde otro sistema.

Antes de comenzar

Todos los ejemplos usan datos ficticios y las mismas convenciones. Cópielos, reemplace los valores de la tabla y adapte las funciones marcadas como "de su sistema" (consulta al ERP, grabación en el CRM).

ElementoValor en los ejemplosReemplace por
Dirección de la APIhttps://atendimento.seuprovedor.com.br/api_publica.phpLa dirección que se muestra en Integraciones, tarjeta API de ChatISP.
Clavevariable de entorno CHATISP_CHAVELa clave cisp_... creada para la integración.
Secreto del webhookvariable de entorno CHATISP_WEBHOOK_SEGREDOEl secreto del webhook registrado.
Teléfonos44999990000, 5544999990001Los teléfonos reales. Sin +, el número se trata como brasileño (se agrega el 55 si falta); se acepta una máscara como (44) 99999-0000. Para otro país, con + adelante.
Idsdepartamento_id 2 (Financeiro), 3 (Suporte técnico), etc.Los ids de su instalación, consultados en Catálogos (departamentos/listar, etiquetas/listar y las demás).

Guarde la clave y el secreto fuera del código, en variables de entorno o en el almacén de secretos del servidor. Cree una clave por integración, solo con los permisos que usa, y dele un nombre que el equipo reconozca (ERP, NOC, Portal): el nombre aparece en las notas y en la línea de tiempo de las conversaciones y de las órdenes de servicio.

Colección para Postman e Insomnia

Antes de programar, pruebe las rutas con la colección lista (detalles en Colección para Postman e Insomnia): chatisp-api.postman_collection.json. Trae todas las rutas organizadas en carpetas (Catálogos, Mensajes, Conversaciones, Contactos, Tickets, Agenda, Indicadores) con ejemplos de cuerpo. Después de importarla, complete las variables de la colección base_url (la dirección de la API) y chave. Insomnia importa el mismo archivo.

Permisos de cada receta

RecetaPermisos de la claveWebhook
Cliente reutilizablesegún el uso
Aviso de mantenimiento por regiónEnviar mensajes
Cliente bloqueado en el ERPAbrir atenciones, Gestionar conversaciones, Enviar mensajes
NOC abre una OS y avisa al clienteEnviar mensajes, Consultar órdenes de servicio, Gestionar órdenes de servicio
Portal del suscriptorAbrir atenciones, Enviar mensajesConversación asignada, Mensaje enviado, Conversación cerrada
Sincronizar con CRM y BIConsultar conversacionesEntrega y lectura (opcional)
Indicadores mensualesIndicadores
Recibir webhookscualquier evento
Boleto en PDFEnviar mensajes
Etiquetar y cerrarConsultar conversaciones, Gestionar conversaciones

Cliente reutilizable de la API

Un cliente pequeño que concentra lo que toda integración necesita y que usan las demás recetas:

  • Encabezado Authorization: Bearer, ruta en r, cuerpo JSON en las escrituras y parámetros en la query en las lecturas.
  • Una respuesta con "ok": false se convierte en excepción con el código estable del error (error), el estado HTTP y el cuerpo completo (que trae campos extra como campo, aceitos y detalhe).
  • HTTP 429 (cuota de la clave por minuto) espera el tiempo del encabezado Retry-After y repite, hasta 3 veces.
  • Un generador que recorre todas las páginas de los listados numerados.
ChatIspApi.php
<?php
// ChatIspApi.php: cliente de la API pública de ChatISP (PHP 8.1 o superior, extensión curl)

final class ChatIspErro extends RuntimeException
{
    public function __construct(
        public readonly string $codigo,      // valor de "error" (ej.: telefone_invalido) o "rede"
        public readonly int $http,           // estado HTTP; 0 cuando no hubo respuesta
        public readonly array $corpo = [],   // respuesta completa, con los campos extra del error
    ) {
        parent::__construct("ChatISP: {$codigo} (HTTP {$http})");
    }
}

final class ChatIspApi
{
    public function __construct(
        private string $base,            // https://atendimento.seuprovedor.com.br/api_publica.php
        private string $chave,           // cisp_...
        private int $tentativas429 = 3,
        private int $timeout = 60,
    ) {}

    public static function doAmbiente(): self
    {
        return new self('https://atendimento.seuprovedor.com.br/api_publica.php', getenv('CHATISP_CHAVE'));
    }

    public function get(string $rota, array $parametros = []): array
    {
        return $this->chamar('GET', $rota, $parametros);
    }

    public function post(string $rota, array $dados = []): array
    {
        return $this->chamar('POST', $rota, $dados);
    }

    /** Recorre todas las páginas de un listado numerado (conversaciones, contactos, órdenes de servicio, agenda). */
    public function todos(string $rota, string $campo, array $filtros = []): Generator
    {
        $pagina = 1;
        do {
            $r = $this->get($rota, $filtros + ['pagina' => $pagina, 'limite' => 100]);
            yield from $r[$campo];
            $pagina++;
        } while ($pagina <= $r['paginacao']['paginas']);
    }

    private function chamar(string $metodo, string $rota, array $dados): array
    {
        for ($tentativa = 0; ; $tentativa++) {
            $url = $this->base . '?r=' . $rota;
            if ($metodo === 'GET' && $dados) {
                $url .= '&' . http_build_query($dados);
            }
            $retryAfter = 0;
            $cabecalhos = ['Authorization: Bearer ' . $this->chave];
            $ch = curl_init($url);
            if ($metodo === 'POST') {
                $cabecalhos[] = 'Content-Type: application/json';
                curl_setopt($ch, CURLOPT_POST, true);
                curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($dados, JSON_UNESCAPED_UNICODE));
            }
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_TIMEOUT => $this->timeout,
                CURLOPT_HTTPHEADER => $cabecalhos,
                CURLOPT_HEADERFUNCTION => function ($ch, string $linha) use (&$retryAfter): int {
                    if (stripos($linha, 'Retry-After:') === 0) {
                        $retryAfter = (int) trim(substr($linha, 12));
                    }
                    return strlen($linha);
                },
            ]);
            $bruto = curl_exec($ch);
            $http = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
            $falha = curl_error($ch);
            curl_close($ch);

            if ($bruto === false) {
                throw new ChatIspErro('rede', 0, ['detalhe' => $falha]);
            }
            if ($http === 429 && $tentativa < $this->tentativas429) {
                sleep(max(1, $retryAfter));   // cuota del minuto: espera a que cambie el minuto y repite
                continue;
            }
            $corpo = json_decode($bruto, true);
            if (!is_array($corpo)) {
                throw new ChatIspErro('resposta_invalida', $http);
            }
            if (empty($corpo['ok'])) {
                throw new ChatIspErro((string) ($corpo['error'] ?? 'desconhecido'), $http, $corpo);
            }
            return $corpo;
        }
    }
}
chatisp_api.py
# chatisp_api.py: cliente de la API pública de ChatISP (Python 3.9 o superior, paquete requests)
import os
import time
import requests


class ChatIspErro(Exception):
    def __init__(self, codigo, http, corpo=None):
        super().__init__(f"ChatISP: {codigo} (HTTP {http})")
        self.codigo = codigo      # valor de "error" o "rede"
        self.http = http          # estado HTTP; 0 cuando no hubo respuesta
        self.corpo = corpo or {}  # respuesta completa, con los campos extra del error


class ChatIspApi:
    def __init__(self, base, chave, tentativas_429=3, timeout=60):
        self.base = base
        self.tentativas_429 = tentativas_429
        self.timeout = timeout
        self.sessao = requests.Session()
        self.sessao.headers["Authorization"] = "Bearer " + chave

    @classmethod
    def do_ambiente(cls):
        return cls("https://atendimento.seuprovedor.com.br/api_publica.php", os.environ["CHATISP_CHAVE"])

    def get(self, rota, **parametros):
        return self._chamar("GET", rota, parametros)

    def post(self, rota, **dados):
        return self._chamar("POST", rota, dados)

    def todos(self, rota, campo, **filtros):
        """Recorre todas las páginas de un listado numerado."""
        pagina = 1
        while True:
            r = self.get(rota, **filtros, pagina=pagina, limite=100)
            yield from r[campo]
            if pagina >= r["paginacao"]["paginas"]:
                return
            pagina += 1

    def _chamar(self, metodo, rota, dados):
        for tentativa in range(self.tentativas_429 + 1):
            try:
                if metodo == "GET":
                    resp = self.sessao.get(self.base, params={"r": rota, **dados}, timeout=self.timeout)
                else:
                    resp = self.sessao.post(self.base, params={"r": rota}, json=dados, timeout=self.timeout)
            except requests.RequestException as e:
                raise ChatIspErro("rede", 0, {"detalhe": str(e)})
            if resp.status_code == 429 and tentativa < self.tentativas_429:
                time.sleep(max(1, int(resp.headers.get("Retry-After", "5"))))
                continue
            try:
                corpo = resp.json()
            except ValueError:
                raise ChatIspErro("resposta_invalida", resp.status_code)
            if not corpo.get("ok"):
                raise ChatIspErro(corpo.get("error", "desconhecido"), resp.status_code, corpo)
            return corpo
chatisp-api.js
// chatisp-api.js: cliente de la API pública de ChatISP (Node.js 18 o superior, fetch nativo)

class ChatIspErro extends Error {
  constructor(codigo, http, corpo = {}) {
    super(`ChatISP: ${codigo} (HTTP ${http})`);
    this.codigo = codigo;   // valor de "error" o "rede"
    this.http = http;       // estado HTTP; 0 cuando no hubo respuesta
    this.corpo = corpo;     // respuesta completa, con los campos extra del error
  }
}

class ChatIspApi {
  constructor(base, chave, { tentativas429 = 3, timeoutMs = 60000 } = {}) {
    Object.assign(this, { base, chave, tentativas429, timeoutMs });
  }

  static doAmbiente() {
    return new ChatIspApi('https://atendimento.seuprovedor.com.br/api_publica.php', process.env.CHATISP_CHAVE);
  }

  get(rota, parametros = {}) { return this.#chamar('GET', rota, parametros); }
  post(rota, dados = {}) { return this.#chamar('POST', rota, dados); }

  async *todos(rota, campo, filtros = {}) {
    for (let pagina = 1; ; pagina++) {
      const r = await this.get(rota, { ...filtros, pagina, limite: 100 });
      yield* r[campo];
      if (pagina >= r.paginacao.paginas) return;
    }
  }

  async #chamar(metodo, rota, dados) {
    for (let tentativa = 0; ; tentativa++) {
      const url = new URL(this.base);
      url.searchParams.set('r', rota);
      const opcoes = { method: metodo, headers: { Authorization: `Bearer ${this.chave}` },
                       signal: AbortSignal.timeout(this.timeoutMs) };
      if (metodo === 'GET') {
        for (const [k, v] of Object.entries(dados)) url.searchParams.set(k, String(v));
      } else {
        opcoes.headers['Content-Type'] = 'application/json';
        opcoes.body = JSON.stringify(dados);
      }
      let resp;
      try {
        resp = await fetch(url, opcoes);
      } catch (e) {
        throw new ChatIspErro('rede', 0, { detalhe: String(e) });
      }
      if (resp.status === 429 && tentativa < this.tentativas429) {
        const espera = Math.max(1, Number(resp.headers.get('Retry-After') || 5));
        await new Promise((ok) => setTimeout(ok, espera * 1000));
        continue;
      }
      const corpo = await resp.json().catch(() => null);
      if (!corpo) throw new ChatIspErro('resposta_invalida', resp.status);
      if (!corpo.ok) throw new ChatIspErro(corpo.error || 'desconhecido', resp.status, corpo);
      return corpo;
    }
  }
}

module.exports = { ChatIspApi, ChatIspErro };

Cuándo repetir una llamada

SituaciónQué hacer
429 cota_excedidaEl cliente ya espera el Retry-After (segundos hasta el próximo minuto) y repite. Si ocurre siempre, reduzca el ritmo o aumente la cuota de la clave.
Error de red, tiempo agotado o HTTP 5xx en una lecturaPuede repetir con espera creciente (5 s, 30 s, 2 min).
Error de red o tiempo agotado en una escrituraNo repita a ciegas: el mensaje puede haber salido. Verifique antes, por ejemplo con conversas/mensagens del teléfono, o regístrelo para revisión manual.
502 envioEl canal rechazó el envío. El mensaje queda registrado con status igual a erro y el motivo en detalhe. Repita solo después de entender el motivo.
401, 403 (chave_invalida, permissao, plano), sem_conexao, conexao_desconectadaError de configuración: interrumpa el lote y avise a quien administra la integración. Repetir no lo resuelve.
Demás 400Error en el dato de ese elemento (teléfono inválido, título vacío). Regístrelo y siga con el siguiente.

Aviso de mantenimiento por región

Escenario: el NOC va a hacer mantenimiento en una OLT de madrugada y necesita avisar a los suscriptores de la región. La lista de afectados sale del ERP o del sistema de red (teléfono, nombre y barrio). El script envía el aviso a cada uno con ritmo controlado y registra lo que falló.

Por la conexión de la API oficial, un mensaje para quien no habló con el proveedor en las últimas 24 horas solo sale con una plantilla aprobada por Meta. El script usa mensagens/enviar_modelo en ese caso y texto libre (mensagens/enviar) cuando la conexión es por código QR. Plantilla de ejemplo, registrada y aprobada en Meta con el nombre aviso_manutencao:

Plantilla aviso_manutencao (cuerpo con 3 variables)
¡Hola, {{1}}! Habrá mantenimiento programado en la red de su barrio ({{2}}) el {{3}}.
La conexión puede oscilar durante el período. ¡Gracias por su comprensión!
aviso_manutencao.php
<?php
// Uso: php aviso_manutencao.php afetados.csv "02/10, de 02 h a 04 h"
// afetados.csv (separado por punto y coma): teléfono;nombre;barrio
require 'ChatIspApi.php';
$api = ChatIspApi::doAmbiente();

[, $arquivo, $quando] = $argv;
$CONEXAO_ID = 1;    // número que envía (conexoes/listar); obligatorio con más de un número conectado

// ¿la conexión es de la API oficial? entonces necesita una plantilla aprobada
$conexao = null;
foreach ($api->get('conexoes/listar')['conexoes'] as $c) {
    if ($c['id'] === $CONEXAO_ID) $conexao = $c;
}
$oficial = $conexao !== null && ($conexao['tipo'] === 'api_oficial' || $conexao['gemea_id'] !== null);

$falhas = fopen('falhas.csv', 'w');
$enviados = 0;
foreach (file($arquivo, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $linha) {
    [$telefone, $nome, $bairro] = array_map('trim', str_getcsv($linha, ';'));
    $primeiroNome = explode(' ', $nome)[0] ?: 'cliente';
    try {
        if ($oficial) {
            $r = $api->post('mensagens/enviar_modelo', [
                'conexao_id' => $CONEXAO_ID,
                'telefone' => $telefone,
                'nome' => $nome,
                'modelo' => 'aviso_manutencao',
                'idioma' => 'pt_BR',
                'variaveis' => [$primeiroNome, $bairro, $quando],
            ]);
        } else {
            $r = $api->post('mensagens/enviar', [
                'conexao_id' => $CONEXAO_ID,
                'telefone' => $telefone,
                'nome' => $nome,
                'texto' => "¡Hola, {$primeiroNome}! Habrá mantenimiento programado en la red de su barrio "
                         . "({$bairro}) el {$quando}. La conexión puede oscilar durante el período. ¡Gracias por su comprensión!",
            ]);
        }
        $enviados++;
        echo "OK    {$telefone}  protocolo {$r['protocolo']}\n";
    } catch (ChatIspErro $e) {
        fputcsv($falhas, [$telefone, $nome, $e->codigo, $e->corpo['detalhe'] ?? ''], ';');
        echo "FALLA {$telefone}  {$e->codigo}\n";
        if (in_array($e->codigo, ['chave_invalida', 'permissao', 'plano', 'sem_conexao', 'conexao_desconectada',
                                  'conexao_id_obrigatorio', 'modelo_invalido', 'variaveis_invalidas'], true)) {
            exit("Error de configuración ({$e->codigo}): lote interrumpido.\n");
        }
    }
    usleep(1_500_000);   // ritmo: cerca de 40 mensajes por minuto
}
fclose($falhas);
echo "{$enviados} aviso(s) enviado(s). Fallas en falhas.csv\n";
aviso_manutencao.py
# Uso: python aviso_manutencao.py afetados.csv "02/10, de 02 h a 04 h"
import csv, sys, time
from chatisp_api import ChatIspApi, ChatIspErro

api = ChatIspApi.do_ambiente()
arquivo, quando = sys.argv[1], sys.argv[2]
CONEXAO_ID = 1
ERROS_DE_CONFIGURACAO = {"chave_invalida", "permissao", "plano", "sem_conexao", "conexao_desconectada",
                         "conexao_id_obrigatorio", "modelo_invalido", "variaveis_invalidas"}

conexao = next(c for c in api.get("conexoes/listar")["conexoes"] if c["id"] == CONEXAO_ID)
oficial = conexao["tipo"] == "api_oficial" or conexao["gemea_id"] is not None

with open(arquivo, encoding="utf-8") as entrada, open("falhas.csv", "w", newline="", encoding="utf-8") as saida:
    falhas = csv.writer(saida, delimiter=";")
    for telefone, nome, bairro in csv.reader(entrada, delimiter=";"):
        primeiro = nome.split()[0] if nome.strip() else "cliente"
        try:
            if oficial:
                r = api.post("mensagens/enviar_modelo", conexao_id=CONEXAO_ID, telefone=telefone, nome=nome,
                             modelo="aviso_manutencao", idioma="pt_BR", variaveis=[primeiro, bairro, quando])
            else:
                r = api.post("mensagens/enviar", conexao_id=CONEXAO_ID, telefone=telefone, nome=nome,
                             texto=f"¡Hola, {primeiro}! Habrá mantenimiento programado en la red de su barrio "
                                   f"({bairro}) el {quando}. La conexión puede oscilar durante el período. "
                                   "¡Gracias por su comprensión!")
            print("OK   ", telefone, "protocolo", r["protocolo"])
        except ChatIspErro as e:
            falhas.writerow([telefone, nome, e.codigo, e.corpo.get("detalhe", "")])
            print("FALLA", telefone, e.codigo)
            if e.codigo in ERROS_DE_CONFIGURACAO:
                sys.exit(f"Error de configuración ({e.codigo}): lote interrumpido.")
        time.sleep(1.5)

Qué sucede en ChatISP

  • Quien no tenía una conversación abierta recibe el aviso en una conversación que ya nace cerrada (vea Aviso: la conversación nueva nace cerrada): la cola de atención no se llena con el envío. Si el cliente responde, se abre una atención normal, con la IA o con el equipo.
  • Quien ya estaba en atención recibe el aviso dentro de la conversación en curso, y el agente lo ve.
  • El número de una conexión gemela (código QR y API oficial en el mismo número) aparece con gemea_id completado; para enviar una plantilla, la API usa el lado oficial por sí sola.
  • La conexión por código QR no tiene plantillas: use siempre texto libre.

Para listas grandes y recurrentes, con panel de entregados, leídos y respondidos, prefiera la Automatización de envíos, que ya se encarga del ritmo y del seguimiento. La API es la elección cuando la lista nace en su sistema de red en el momento del incidente.


Cliente bloqueado en el ERP

Escenario: el ERP bloquea el contrato por falta de pago. En lugar de esperar a que el cliente reclame, la rutina del ERP abre una atención en el departamento Financeiro (finanzas) ya con los datos del cobro en una nota interna, marca la conversación con la etiqueta "Bloqueado" y envía al cliente un mensaje con el camino para regularizar.

erp_cliente_bloqueado.php
<?php
// Llamado por la rutina del ERP en el momento del bloqueo, con los datos del contrato.
require 'ChatIspApi.php';
$api = ChatIspApi::doAmbiente();

const DEPARTAMENTO_FINANCEIRO = 2;
const ETIQUETA_BLOQUEADO = 5;          // etiquetas/listar

function avisarBloqueio(ChatIspApi $api, array $contrato): void
{
    $nota = sprintf(
        "Contrato %s bloqueado por deuda. Plan: %s. Pendiente: R$ %s en %d factura(s), vencida desde %s.",
        $contrato['numero'], $contrato['plano'],
        number_format($contrato['valor_aberto'], 2, ',', '.'), $contrato['faturas'], $contrato['vencimento']
    );

    // 1) abre (o reutiliza) la atención en la cola de Financeiro, con la nota interna
    $r = $api->post('atendimentos/abrir', [
        'telefone' => $contrato['celular'],
        'nome' => $contrato['cliente'],
        'departamento_id' => DEPARTAMENTO_FINANCEIRO,
        'nota' => $nota,    // hasta 900 caracteres; solo el equipo la ve
    ]);
    $conversaId = $r['conversa']['id'];

    // 2) etiqueta para que el equipo filtre en el panel
    $api->post('conversas/etiquetas', ['conversa_id' => $conversaId, 'adicionar' => [ETIQUETA_BLOQUEADO]]);

    // 3) mensaje al cliente dentro de la misma atención
    $primeiroNome = explode(' ', $contrato['cliente'])[0];
    try {
        $api->post('mensagens/enviar', [
            'conversa_id' => $conversaId,
            'texto' => "¡Hola, {$primeiroNome}! Identificamos pendientes en su contrato y el acceso fue suspendido. "
                     . "Responda este mensaje y le enviamos el Pix o la segunda vía al instante.",
        ]);
    } catch (ChatIspErro $e) {
        if ($e->codigo !== 'janela_fechada') throw $e;
        // API oficial fuera de la ventana de 24 h: solo plantilla aprobada
        $api->post('mensagens/enviar_modelo', [
            'conversa_id' => $conversaId,
            'modelo' => 'aviso_suspensao',
            'variaveis' => [$primeiroNome],
        ]);
    }
}

avisarBloqueio($api, [
    'numero' => '10234', 'cliente' => 'João Pereira', 'celular' => '44999990002', 'plano' => 'Fibra 500 Mega',
    'valor_aberto' => 239.80, 'faturas' => 2, 'vencimento' => '10/09/2026',
]);

Comportamiento que importa aquí

  • atendimentos/abrir no envía nada al cliente: solo crea (o devuelve, con ja_existia igual a true) la atención y registra la nota. Si ya existía una atención abierta con la IA o en un flujo, sigue allí; si estaba en la cola, pasa a la cola del departamento solicitado y la distribución automática intenta entregarla a alguien.
  • Como la atención ya está abierta, el mensagens/enviar con conversa_id entra en ella (no se convierte en aviso cerrado).
  • La nota interna aparece en la línea de tiempo con el nombre de la clave (API (ERP): ...); el cliente nunca la ve.
  • Cuando el cliente responde, la IA o el agente de Financeiro ya tiene el contexto en pantalla. Si su ERP es uno de los integrados a ChatISP, también aparecen la ficha del cliente y las facturas.

NOC abre una OS y avisa al cliente

Escenario: el sistema de monitoreo detecta que se cayó el enlace de un cliente corporativo. El script avisa al cliente por WhatsApp, abre la OS de reparación vinculada a la misma conversación y, cuando la alarma se normaliza, comenta, mueve la OS a la etapa final y avisa al cliente que el servicio volvió.

noc_alarme.py
# Conéctelo a su monitoreo: "python noc_alarme.py problema <id_alarma>" y "python noc_alarme.py normalizado <id_alarma>"
import json, sys
from pathlib import Path
from chatisp_api import ChatIspApi

api = ChatIspApi.do_ambiente()
ESTADO = Path("noc_tickets.json")          # id de la alarma -> ticket_id y conversa_id
TIPO_REPARO = 2                            # tickets/tipos: "Reparo de rede" (reparación de red)
DEPARTAMENTO_SUPORTE = 3


def cliente_do_alarme(alarme_id):
    """De su sistema: datos del cliente vinculado al equipo que generó la alarma."""
    return {"nome": "Mercado Bom Preço", "telefone": "44999990003",
            "endereco": "Av. Brasil, 2100, Centro", "equipamento": "ONU ZTE F670L serial ZTEG00000001",
            "sinal": "LOS (sin luz)"}


def etapa_final_resolvido():
    etapas = api.get("tickets/etapas")["etapas"]
    return next(e["id"] for e in etapas if e["natureza"] == "resolvido" and e["ativa"])


def problema(alarme_id):
    estado = json.loads(ESTADO.read_text()) if ESTADO.exists() else {}
    if alarme_id in estado:
        return                                   # alarma repetida: OS ya abierta
    c = cliente_do_alarme(alarme_id)

    # 1) aviso al cliente (sin conversación abierta, nace como aviso cerrado)
    aviso = api.post("mensagens/enviar", telefone=c["telefone"], nome=c["nome"],
                     texto="¡Hola! Nuestro monitoreo detectó una falla en su enlace y el equipo técnico "
                           "ya fue convocado. Le avisaremos por aquí en cuanto se normalice.")

    # 2) OS de reparación, vinculada a la conversación del aviso
    t = api.post("tickets/criar",
                 titulo=f"Enlace caído: {c['nome']}",
                 descricao=f"Alarma {alarme_id}. Equipo: {c['equipamento']}. Señal: {c['sinal']}.",
                 tipo_id=TIPO_REPARO, prioridade="urgente", departamento_id=DEPARTAMENTO_SUPORTE,
                 conversa_id=aviso["conversa_id"], telefone=c["telefone"],
                 nome_cliente=c["nome"], endereco=c["endereco"])["ticket"]

    estado[alarme_id] = {"ticket_id": t["id"], "conversa_id": aviso["conversa_id"]}
    ESTADO.write_text(json.dumps(estado))
    print("OS", t["numero"], "plazo", t["prazo_em"])


def normalizado(alarme_id):
    estado = json.loads(ESTADO.read_text()) if ESTADO.exists() else {}
    registro = estado.pop(alarme_id, None)
    if not registro:
        return
    ticket_id = registro["ticket_id"]
    atual = api.get("tickets/consultar", ticket_id=ticket_id)["ticket"]
    if not atual["concluido"]:
        api.post("tickets/comentar", ticket_id=ticket_id, texto="Monitoreo: alarma normalizada.")
        api.post("tickets/mover", ticket_id=ticket_id, etapa_id=etapa_final_resolvido(),
                 comentario="Cerrado automáticamente por el NOC.")
    api.post("mensagens/enviar", conversa_id=registro["conversa_id"],
             texto="Buena noticia: su enlace se normalizó. Si nota cualquier inestabilidad, solo responda aquí.")
    ESTADO.write_text(json.dumps(estado))


if __name__ == "__main__":
    {"problema": problema, "normalizado": normalizado}[sys.argv[1]](sys.argv[2])
  • La OS nace con el plazo del tipo (el Reparo de rede de fábrica tiene 8 horas) y el equipo de Soporte recibe el aviso "Nueva OS para el equipo" por WhatsApp. Para enviarlo directo al técnico de guardia, informe responsavel_id.
  • El vínculo con la conversación hace que la OS aparezca en la atención y permite que el equipo vea el aviso que recibió el cliente.
  • En el cierre, el mensaje con conversa_id de una conversación ya cerrada entra en la atención abierta de ese número, si la hay (por ejemplo, el cliente respondió al aviso); si no, abre un nuevo protocolo que también nace como aviso cerrado.
  • Si un técnico ya lo resolvió desde el panel, concluido viene true y el script solo avisa al cliente.

Por la conexión de la API oficial, el aviso inicial a quien no habló con el proveedor en las últimas 24 horas debe ser una plantilla aprobada (mensagens/enviar_modelo); el mensaje de normalización entra en la ventana si el cliente respondió. Vea Ventana de 24 horas de la API oficial y trate el error janela_fechada como en el ejemplo del cliente bloqueado.


Portal del suscriptor

Escenario: en el área del cliente del sitio web, el suscriptor hace clic en "Hablar con soporte", describe el problema y recibe el protocolo en pantalla. El backend del portal abre la atención en ChatISP y envía una confirmación por WhatsApp. Después, un receptor de webhook actualiza la página del pedido: quién lo tomó, las respuestas y el cierre.

1. Abrir la atención

portal/abrir_suporte.php (recibe el formulario)
<?php
require 'ChatIspApi.php';
$api = ChatIspApi::doAmbiente();

$assinante = portalAssinanteLogado();          // de su sistema: nombre, celular, contrato
$relato = mb_substr(trim($_POST['relato'] ?? ''), 0, 600);

try {
    $r = $api->post('atendimentos/abrir', [
        'telefone' => $assinante['celular'],
        'nome' => $assinante['nome'],
        'departamento_id' => 3,                  // Suporte técnico (soporte técnico)
        'nota' => "Abierto desde el portal (contrato {$assinante['contrato']}): {$relato}",
    ]);
    $conversa = $r['conversa'];

    // confirmación al cliente dentro de la atención recién abierta
    $api->post('mensagens/enviar', [
        'conversa_id' => $conversa['id'],
        'texto' => "Recibimos su pedido por el portal (protocolo {$conversa['protocolo']}). "
                 . "Un agente hablará con usted por aquí.",
    ]);
} catch (ChatIspErro $e) {
    if ($e->codigo !== 'janela_fechada') {   // API oficial fuera de la ventana: el agente hace el primer contacto
        http_response_code(502);
        exit('No fue posible abrir la atención ahora. Escríbanos por WhatsApp.');
    }
}

portalSalvarPedido($assinante['contrato'], $conversa['id'], $conversa['protocolo']);   // de su sistema
echo "Pedido registrado. Protocolo {$conversa['protocolo']}.";

2. Seguir por el webhook

Registre un webhook (en Integraciones) con los eventos Conversación asignada (conversa_atribuida), Mensaje enviado (mensagem_enviada) y Conversación cerrada (conversa_encerrada), apuntando al portal. Todos traen conversa_id y protocolo en dados, que es el vínculo con el pedido guardado.

portal/webhook.js (Node.js con Express)
const express = require('express');
const crypto = require('crypto');
const { pedidoPorConversa, atualizarPedido } = require('./pedidos');   // de su sistema

const app = express();
const SEGREDO = process.env.CHATISP_WEBHOOK_SEGREDO;

function assinaturaValida(corpo, cabecalho) {
  const esperada = Buffer.from('sha256=' + crypto.createHmac('sha256', SEGREDO).update(corpo).digest('hex'));
  const recebida = Buffer.from(cabecalho || '');
  return esperada.length === recebida.length && crypto.timingSafeEqual(esperada, recebida);
}

app.post('/chatisp/webhook', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
  if (!assinaturaValida(req.body, req.get('X-ChatISP-Assinatura'))) return res.sendStatus(401);
  const { evento, dados } = JSON.parse(req.body.toString('utf8'));
  if (evento === 'teste') return res.sendStatus(200);

  const pedido = await pedidoPorConversa(dados.conversa_id);
  if (!pedido) return res.sendStatus(200);       // conversación que no vino del portal

  switch (evento) {
    case 'conversa_atribuida':
      await atualizarPedido(pedido.id, { situacao: 'En atención', atendente: dados.atendente });
      break;
    case 'mensagem_enviada':
      if (dados.mensagem.autor === 'atendente' || dados.mensagem.autor === 'ia') {
        await atualizarPedido(pedido.id, { ultimaResposta: dados.mensagem.conteudo, em: dados.mensagem.enviada_em });
      }
      break;
    case 'conversa_encerrada':
      await atualizarPedido(pedido.id, { situacao: 'Concluido', motivo: dados.motivo });
      break;
  }
  res.sendStatus(200);
});

app.listen(3000);

Si el cliente continúa la conversación después del cierre, ChatISP abre un nuevo protocolo. Para seguir el historial completo del suscriptor en el portal, asocie los pedidos por el telefone, no solo por el conversa_id.


Sincronizar conversaciones y mensajes con CRM o BI

Escenario: el CRM (o la base de datos del BI) necesita una copia de las conversaciones y mensajes para informes propios y para mostrar el historial en la ficha del cliente. Una rutina cada 5 o 10 minutos busca solo lo que cambió desde la ejecución anterior:

  1. chave/info devuelve la hora del servidor (servidor.agora): se convierte en el punto de control de la próxima ejecución, sin depender del reloj de su máquina.
  2. conversas/listar con atualizadas_desde trae las conversaciones que tuvieron un mensaje nuevo, se abrieron o se cerraron desde el punto de control.
  3. Para cada conversación, conversas/mensagens con depois_de igual al último id ya guardado trae solo los mensajes nuevos, en orden, de 100 en 100 mientras tem_mais sea verdadero.
sincronizar_chatisp.py
# Ejecute cada 5 o 10 minutos (cron, programador de tareas, systemd timer).
import json
from datetime import datetime, timedelta
from pathlib import Path
from chatisp_api import ChatIspApi

api = ChatIspApi.do_ambiente()
MARCO = Path("chatisp_marco.json")
FOLGA = timedelta(minutes=5)       # superposición entre ejecuciones: nada se escapa, la grabación es idempotente


def gravar_conversa(c):             # de su sistema: INSERT ... ON DUPLICATE KEY UPDATE por c["id"]
    ...

def gravar_mensagem(m):             # de su sistema: upsert por m["id"]
    ...

def ultimo_id_mensagem(conversa_id):   # de su sistema: mayor id de mensaje guardado (0 si no hay ninguno)
    return 0


def sincronizar():
    inicio = api.get("chave/info")["servidor"]["agora"]
    estado = json.loads(MARCO.read_text()) if MARCO.exists() else {}
    filtros = {"ordem": "antigas"}
    if "marco" in estado:
        desde = datetime.fromisoformat(estado["marco"]) - FOLGA
        filtros["atualizadas_desde"] = desde.isoformat()
    # primera ejecución sin punto de control: trae el historial completo, página por página

    total = 0
    for conversa in api.todos("conversas/listar", "conversas", **filtros):
        gravar_conversa(conversa)
        cursor = ultimo_id_mensagem(conversa["id"])
        while True:
            r = api.get("conversas/mensagens", conversa_id=conversa["id"], depois_de=cursor, limite=100)
            for m in r["mensagens"]:
                gravar_mensagem(m)
            cursor = r["cursor"]["depois_de"] or cursor
            if not r["tem_mais"]:
                break
        total += 1

    MARCO.write_text(json.dumps({"marco": inicio}))
    print(f"{total} conversación(es) sincronizada(s)")


if __name__ == "__main__":
    sincronizar()

Detalles que evitan dolores de cabeza

  • Grabe siempre por id. La superposición entre ejecuciones hace que algunas conversaciones vuelvan; con un upsert por id, nada se duplica.
  • Orden antigas. Una conversación que recibe un mensaje durante la paginación pasa al final de la lista y se toma en la misma ejecución o en la siguiente, nunca se pierde.
  • Las notas internas no vienen. La API entrega solo lo que el cliente y el proveedor intercambiaron; las notas del equipo quedan en el panel.
  • Estado de entrega y lectura. El cambio de status (entregado, leído) no altera la conversación y no aparece en atualizadas_desde. Si el BI lo necesita, suscríbase al evento Entrega y lectura (mensagem_status) y actualice el mensaje por el id.
  • Un mensaje editado o eliminado viene con editada o apagada verdaderos (el eliminado, sin contenido). Como la edición no cambia el id, la próxima lectura completa de la conversación trae el valor nuevo.
  • Archivos: el campo arquivo.download es la ruta para descargarlo con la misma clave (mensagens/arquivo?id=...). Descargue solo lo necesario.

Exportar indicadores mensuales

Escenario: el primer día de cada mes, la gerencia quiere una planilla con los números del mes anterior, de toda la empresa y de cada departamento. El script llama a relatorios/resumo una vez sin filtro y una vez por departamento activo, convierte los tiempos a minutos y graba un CSV que se abre directamente en Excel.

indicadores_mes.php
<?php
// Uso: php indicadores_mes.php            (mes anterior)
//      php indicadores_mes.php 2026-09    (mes elegido)
require 'ChatIspApi.php';
$api = ChatIspApi::doAmbiente();

$mes = $argv[1] ?? date('Y-m', strtotime('first day of last month'));
$de = "{$mes}-01";
$ate = date('Y-m-t', strtotime($de));      // último día del mes (vale hasta las 23:59:59)

$minutos = fn(?float $seg): string => $seg === null ? '' : number_format($seg / 60, 1, ',', '');
$pct = fn(int $parte, int $todo): string => $todo > 0 ? number_format(100 * $parte / $todo, 1, ',', '') : '';

$recortes = [['id' => 0, 'nome' => 'Todos los departamentos']];
foreach ($api->get('departamentos/listar')['departamentos'] as $d) {
    if ($d['ativo']) $recortes[] = $d;
}

$csv = fopen("indicadores-{$mes}.csv", 'w');
fwrite($csv, "\xEF\xBB\xBF");               // BOM: Excel lo abre con los acentos correctos
fputcsv($csv, ['Departamento', 'Abiertas', 'Cerradas', 'Resueltas por la IA (%)', 'Mensajes recibidos',
    'Primera respuesta (min)', 'Espera en la cola (min)', 'Duración media (min)', 'Satisfacción', 'Tasa de respuesta (%)'], ';');

foreach ($recortes as $d) {
    $r = $api->get('relatorios/resumo', ['de' => $de, 'ate' => $ate] + ($d['id'] ? ['departamento_id' => $d['id']] : []));
    fputcsv($csv, [
        $d['nome'],
        $r['conversas']['abertas'],
        $r['conversas']['encerradas'],
        $pct($r['conversas']['resolvidas_pela_ia'], $r['conversas']['encerradas']),
        $r['mensagens']['recebidas'],
        $minutos($r['tempos']['primeira_resposta_media_seg']),
        $minutos($r['tempos']['espera_fila_media_seg']),
        $minutos($r['tempos']['duracao_media_seg']),
        $r['satisfacao']['media'] === null ? '' : number_format($r['satisfacao']['media'], 2, ',', ''),
        $pct($r['satisfacao']['respostas'], $r['satisfacao']['pesquisas_enviadas']),
    ], ';');
    sleep(1);
}
fclose($csv);
echo "Generado indicadores-{$mes}.csv\n";

Para la planilla por agente, use la lista por_atendente de la llamada sin filtro: ya trae atendidas, cerradas, duración media, satisfacción y evaluaciones de cada persona. Las reglas de cada número están en Indicadores.


Recibir webhooks con verificación y cola

Todo receptor de webhook de ChatISP sigue cuatro reglas:

  1. Verifique la firma sobre el cuerpo crudo: el encabezado X-ChatISP-Assinatura trae sha256= seguido del HMAC SHA-256 del cuerpo con el secreto del webhook. Compare en tiempo constante.
  2. Descarte repeticiones por el encabezado X-ChatISP-Entrega (id de la entrega). La misma entrega puede llegar de nuevo si su respuesta se pierde.
  3. Grabe y responda rápido: ChatISP espera como máximo 10 segundos y considera entregada cualquier respuesta 2xx. Sin 2xx, intenta de nuevo después de 1 minuto, 5 minutos, 15 minutos, 1 hora y 6 horas, y luego desiste.
  4. Procese después, en una rutina o cola propia. El evento de prueba (botón Probar) llega con evento igual a teste y entrega 0.
webhook_chatisp.php
<?php
// Tabla de apoyo (MySQL/MariaDB):
// CREATE TABLE chatisp_eventos (
//   entrega_id BIGINT PRIMARY KEY, evento VARCHAR(40), emitido_em VARCHAR(40),
//   corpo JSON, processado TINYINT NOT NULL DEFAULT 0, recebido_em DATETIME DEFAULT CURRENT_TIMESTAMP);

$segredo = getenv('CHATISP_WEBHOOK_SEGREDO');
$corpo = file_get_contents('php://input');

// 1) firma sobre el cuerpo crudo, en tiempo constante
$esperada = 'sha256=' . hash_hmac('sha256', $corpo, $segredo);
if (!hash_equals($esperada, $_SERVER['HTTP_X_CHATISP_ASSINATURA'] ?? '')) {
    http_response_code(401);
    exit;
}

$evento = json_decode($corpo, true);
$entregaId = (int) ($_SERVER['HTTP_X_CHATISP_ENTREGA'] ?? 0);
if ($evento['evento'] === 'teste' || $entregaId === 0) {
    exit('ok');                              // botón Probar
}

// 2) y 3) graba de forma durable; la clave primaria rechaza la misma entrega dos veces
$pdo = new PDO(getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS'), [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
try {
    $pdo->prepare('INSERT INTO chatisp_eventos (entrega_id, evento, emitido_em, corpo) VALUES (?, ?, ?, ?)')
        ->execute([$entregaId, $evento['evento'], $evento['emitido_em'], $corpo]);
} catch (PDOException $e) {
    if ($e->getCode() !== '23000') {         // 23000 = ya recibida: sigue al 200
        http_response_code(500);             // falla real: ChatISP intenta de nuevo
        exit;
    }
}
echo 'ok';

// 4) una rutina separada (cada minuto) lee chatisp_eventos WHERE processado = 0,
//    ORDER BY emitido_em, entrega_id, trata cada evento y marca processado = 1.
webhook.js
const express = require('express');
const crypto = require('crypto');
const { Pool } = require('pg');              // cualquier base de datos sirve; lo importante es grabar antes del 200

const SEGREDO = process.env.CHATISP_WEBHOOK_SEGREDO;
const banco = new Pool();
const app = express();

function assinaturaValida(corpo, cabecalho) {
  const esperada = Buffer.from('sha256=' + crypto.createHmac('sha256', SEGREDO).update(corpo).digest('hex'));
  const recebida = Buffer.from(cabecalho || '');
  return esperada.length === recebida.length && crypto.timingSafeEqual(esperada, recebida);
}

// express.raw solo en esta ruta: con express.json el cuerpo se decodificaría y la firma no coincidiría
app.post('/hooks/chatisp', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
  if (!assinaturaValida(req.body, req.get('X-ChatISP-Assinatura'))) return res.sendStatus(401);
  const entrega = Number(req.get('X-ChatISP-Entrega') || 0);
  const evento = JSON.parse(req.body.toString('utf8'));
  if (evento.evento === 'teste' || entrega === 0) return res.sendStatus(200);
  try {
    await banco.query(
      `INSERT INTO chatisp_eventos (entrega_id, evento, emitido_em, corpo)
       VALUES ($1, $2, $3, $4) ON CONFLICT (entrega_id) DO NOTHING`,
      [entrega, evento.evento, evento.emitido_em, req.body.toString('utf8')]);
    res.sendStatus(200);
  } catch (erro) {
    console.error('falla al grabar la entrega', entrega, erro);
    res.sendStatus(500);                     // ChatISP intenta de nuevo
  }
});

app.listen(3000);
webhook.py
import hashlib, hmac, json, os, sqlite3
from flask import Flask, request, abort

SEGREDO = os.environ["CHATISP_WEBHOOK_SEGREDO"].encode()
app = Flask(__name__)


def banco():
    con = sqlite3.connect("chatisp_eventos.db")
    con.execute("""CREATE TABLE IF NOT EXISTS chatisp_eventos (
                     entrega_id INTEGER PRIMARY KEY, evento TEXT, emitido_em TEXT,
                     corpo TEXT, processado INTEGER NOT NULL DEFAULT 0)""")
    return con


@app.post("/hooks/chatisp")
def receber():
    corpo = request.get_data()               # bytes crudos, antes de cualquier request.json
    esperada = "sha256=" + hmac.new(SEGREDO, corpo, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(esperada, request.headers.get("X-ChatISP-Assinatura", "")):
        abort(401)
    entrega = int(request.headers.get("X-ChatISP-Entrega", "0"))
    evento = json.loads(corpo)
    if evento["evento"] == "teste" or entrega == 0:
        return "ok", 200
    with banco() as con:                     # INSERT OR IGNORE descarta la entrega repetida
        con.execute("INSERT OR IGNORE INTO chatisp_eventos (entrega_id, evento, emitido_em, corpo) VALUES (?, ?, ?, ?)",
                    (entrega, evento["evento"], evento["emitido_em"], corpo.decode()))
    return "ok", 200

Los eventos disponibles y el contenido de dados en cada uno están en Eventos; los detalles de la firma, en Firma.

Errores comunes de firma: calcular el HMAC sobre el JSON ya decodificado y serializado de nuevo (cambian los espacios y los acentos), usar el secreto con un espacio al final, o comparar sin el prefijo sha256=.


Enviar boleto en PDF

Escenario: el cliente pidió la segunda vía durante la atención, o la rutina del ERP envía el boleto (comprobante bancario brasileño) del mes. El PDF generado por el ERP sale por WhatsApp como documento, con leyenda. La ruta mensagens/enviar_arquivo acepta el archivo en base64 dentro del JSON o como carga multipart/form-data (campo arquivo), hasta 45 MB.

enviar_boleto.php
<?php
require 'ChatIspApi.php';
$api = ChatIspApi::doAmbiente();

function enviarBoleto(ChatIspApi $api, string $telefone, string $pdf, string $competencia, string $valor): array
{
    return $api->post('mensagens/enviar_arquivo', [
        'telefone' => $telefone,
        'arquivo_base64' => base64_encode(file_get_contents($pdf)),
        'nome_arquivo' => "boleto-{$competencia}.pdf",        // nombre que ve el cliente
        'legenda' => "Le enviamos el boleto de {$competencia}, por un valor de R$ {$valor}. "
                   . "Si prefiere Pix, solo responda este mensaje.",
    ]);
}

try {
    $r = enviarBoleto($api, '44999990004', '/tmp/boletos/10234-2026-10.pdf', 'outubro/2026', '119,90');
    echo "Enviado en el protocolo {$r['protocolo']}, mensaje {$r['mensagem_id']}\n";
} catch (ChatIspErro $e) {
    // janela_fechada: API oficial fuera de las 24 h; envíe antes una plantilla aprobada
    // tipo_nao_suportado (415): el contenido no es un PDF de verdad
    // arquivo_grande (413): más de 45 MB
    echo "Falló: {$e->codigo}\n";
}
enviar_boleto.py
import base64
from chatisp_api import ChatIspApi, ChatIspErro

api = ChatIspApi.do_ambiente()


def enviar_boleto(telefone, caminho_pdf, competencia, valor):
    with open(caminho_pdf, "rb") as f:
        conteudo = base64.b64encode(f.read()).decode()
    return api.post("mensagens/enviar_arquivo",
                    telefone=telefone,
                    arquivo_base64=conteudo,
                    nome_arquivo=f"boleto-{competencia.replace('/', '-')}.pdf",
                    legenda=f"Le enviamos el boleto de {competencia}, por un valor de R$ {valor}. "
                            "Si prefiere Pix, solo responda este mensaje.")


try:
    r = enviar_boleto("44999990004", "/tmp/boletos/10234-2026-10.pdf", "outubro/2026", "119,90")
    print("Enviado en el protocolo", r["protocolo"])
except ChatIspErro as e:
    print("Falló:", e.codigo, e.corpo.get("detalhe", ""))
multipart
# Alternativa sin base64: carga multipart (campo "arquivo")
curl -s -X POST "https://atendimento.seuprovedor.com.br/api_publica.php?r=mensagens/enviar_arquivo" \
  -H "Authorization: Bearer $CHATISP_CHAVE" \
  -F "telefone=44999990004" \
  -F "legenda=Le enviamos el boleto de outubro/2026." \
  -F "nome_arquivo=boleto-outubro-2026.pdf" \
  -F "arquivo=@/tmp/boletos/10234-2026-10.pdf;type=application/pdf"
  • El tipo del archivo se identifica por el contenido, no por el nombre: un PDF va como documento; imagen, audio y video van como medios del tipo correcto. Los ejecutables se rechazan.
  • La leyenda acepta hasta 1.024 caracteres.
  • Misma regla de destino que el envío de texto: con el conversa_id de una atención abierta, el boleto entra en ella; para un número sin conversación abierta, nace como aviso cerrado y la respuesta del cliente abre una atención.
  • El PDF queda en el historial de la conversación y puede descargarse después mediante la ruta mensagens/arquivo.

Etiquetar y cerrar desde otro sistema

Escenario: el cliente pidió la segunda vía y pagó el Pix. Cuando el ERP confirma el pago, la integración localiza la atención abierta de ese cliente, cambia la etiqueta "Aguardando pagamento" (esperando pago) por "Pago", deja una nota para el equipo y cierra la atención con el motivo correcto, enviando al cliente el mensaje estándar de cierre y la encuesta de satisfacción.

pagamento_confirmado.js (Node.js)
const { ChatIspApi, ChatIspErro } = require('./chatisp-api');
const api = ChatIspApi.doAmbiente();

async function idPorNome(rota, campo, nome) {
  const lista = (await api.get(rota))[campo];
  const item = lista.find((x) => x.nome.toLowerCase() === nome.toLowerCase());
  if (!item) throw new Error(`"${nome}" no existe en ${rota}`);
  return item.id;
}

async function pagamentoConfirmado({ telefone, valor, pagoEm }) {
  // atención abierta de este cliente (la más reciente)
  const { conversas } = await api.get('conversas/listar', { telefone, abertas: 1, limite: 1 });
  if (conversas.length === 0) return 'sin atención abierta';
  const conversa = conversas[0];

  const [pago, aguardando, motivo] = await Promise.all([
    idPorNome('etiquetas/listar', 'etiquetas', 'Pago'),
    idPorNome('etiquetas/listar', 'etiquetas', 'Aguardando pagamento'),
    idPorNome('motivos/listar', 'motivos', 'Pagamento confirmado'),
  ]);

  await api.post('conversas/etiquetas', { conversa_id: conversa.id, adicionar: [pago], remover: [aguardando] });
  await api.post('conversas/nota', {
    conversa_id: conversa.id,
    texto: `Pago de R$ ${valor} confirmado por el ERP el ${pagoEm}. Desbloqueo automático en curso.`,
  });

  try {
    await api.post('conversas/encerrar', {
      conversa_id: conversa.id,
      motivo_id: motivo,
      enviar_mensagem: true,   // false = cierra en silencio, sin mensaje de cierre ni encuesta
    });
  } catch (e) {
    if (!(e instanceof ChatIspErro && e.codigo === 'conversa_encerrada')) throw e;   // alguien la cerró antes
  }
  return conversa.protocolo;
}

pagamentoConfirmado({ telefone: '44999990002', valor: '239,80', pagoEm: '01/10/2026 15:42' })
  .then((r) => console.log('ok:', r))
  .catch((e) => console.error(e));
  • conversas/etiquetas acepta adicionar y remover (modifican solo lo que se envía) o etiquetas (reemplaza la lista completa; una lista vacía la limpia).
  • Con enviar_mensagem verdadero (el valor predeterminado), el cierre hace lo mismo que el botón del panel: envía el mensaje de cierre configurado y la encuesta de satisfacción, cuando están activados y cuando hubo atención de hecho.
  • Sale el webhook conversa_encerrada con el nombre del motivo, para los demás sistemas que siguen la atención.
  • Guarde en caché los ids de etiquetas y motivos: solo cambian cuando el proveedor edita los registros.