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).
| Elemento | Valor en los ejemplos | Reemplace por |
|---|---|---|
| Dirección de la API | https://atendimento.seuprovedor.com.br/api_publica.php | La dirección que se muestra en Integraciones, tarjeta API de ChatISP. |
| Clave | variable de entorno CHATISP_CHAVE | La clave cisp_... creada para la integración. |
| Secreto del webhook | variable de entorno CHATISP_WEBHOOK_SEGREDO | El secreto del webhook registrado. |
| Teléfonos | 44999990000, 5544999990001 | Los 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. |
| Ids | departamento_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
| Receta | Permisos de la clave | Webhook |
|---|---|---|
| Cliente reutilizable | según el uso | |
| Aviso de mantenimiento por región | Enviar mensajes | |
| Cliente bloqueado en el ERP | Abrir atenciones, Gestionar conversaciones, Enviar mensajes | |
| NOC abre una OS y avisa al cliente | Enviar mensajes, Consultar órdenes de servicio, Gestionar órdenes de servicio | |
| Portal del suscriptor | Abrir atenciones, Enviar mensajes | Conversación asignada, Mensaje enviado, Conversación cerrada |
| Sincronizar con CRM y BI | Consultar conversaciones | Entrega y lectura (opcional) |
| Indicadores mensuales | Indicadores | |
| Recibir webhooks | cualquier evento | |
| Boleto en PDF | Enviar mensajes | |
| Etiquetar y cerrar | Consultar 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 enr, cuerpo JSON en las escrituras y parámetros en la query en las lecturas. - Una respuesta con
"ok": falsese convierte en excepción con el código estable del error (error), el estado HTTP y el cuerpo completo (que trae campos extra comocampo,aceitosydetalhe). - HTTP 429 (cuota de la clave por minuto) espera el tiempo del encabezado
Retry-Aftery repite, hasta 3 veces. - Un generador que recorre todas las páginas de los listados numerados.
<?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: 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: 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ón | Qué hacer |
|---|---|
429 cota_excedida | El 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 lectura | Puede repetir con espera creciente (5 s, 30 s, 2 min). |
| Error de red o tiempo agotado en una escritura | No 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 envio | El 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_desconectada | Error de configuración: interrumpa el lote y avise a quien administra la integración. Repetir no lo resuelve. |
Demás 400 | Error 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:
¡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!<?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";
# 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_idcompletado; 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.
<?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/abrirno envía nada al cliente: solo crea (o devuelve, conja_existiaigual atrue) 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/enviarconconversa_identra 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ó.
# 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_idde 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,
concluidovienetruey 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
<?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.
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:
chave/infodevuelve 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.conversas/listarconatualizadas_desdetrae las conversaciones que tuvieron un mensaje nuevo, se abrieron o se cerraron desde el punto de control.- Para cada conversación,
conversas/mensagenscondepois_deigual al último id ya guardado trae solo los mensajes nuevos, en orden, de 100 en 100 mientrastem_maissea verdadero.
# 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 enatualizadas_desde. Si el BI lo necesita, suscríbase al evento Entrega y lectura (mensagem_status) y actualice el mensaje por elid. - Un mensaje editado o eliminado viene con
editadaoapagadaverdaderos (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.downloades 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.
<?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:
- Verifique la firma sobre el cuerpo crudo: el encabezado
X-ChatISP-Assinaturatraesha256=seguido del HMAC SHA-256 del cuerpo con el secreto del webhook. Compare en tiempo constante. - Descarte repeticiones por el encabezado
X-ChatISP-Entrega(id de la entrega). La misma entrega puede llegar de nuevo si su respuesta se pierde. - 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.
- Procese después, en una rutina o cola propia. El evento de prueba (botón Probar) llega con
eventoigual atestey entrega0.
<?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.
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);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", 200Los 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.
<?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";
}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", ""))# 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_idde 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.
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/etiquetasaceptaadicionaryremover(modifican solo lo que se envía) oetiquetas(reemplaza la lista completa; una lista vacía la limpia).- Con
enviar_mensagemverdadero (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_encerradacon 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.