Exemplos de integração
Receitas completas para os cenários reais de um provedor: cliente reutilizável da API, aviso de manutenção por região, atendimento aberto pelo ERP para o cliente bloqueado, NOC que abre OS e avisa o cliente, portal do assinante, sincronização com CRM e BI, indicadores do mês, recepção segura de webhooks, envio de boleto em PDF e etiquetar e encerrar a partir de outro sistema.
Antes de começar
Todos os exemplos usam dados fictícios e as mesmas convenções. Copie, troque os valores da tabela e adapte as funções marcadas como "do seu sistema" (consulta ao ERP, gravação no CRM).
| Item | Valor nos exemplos | Troque por |
|---|---|---|
| Endereço da API | https://atendimento.seuprovedor.com.br/api_publica.php | O endereço mostrado em Integrações, card API do ChatISP. |
| Chave | variável de ambiente CHATISP_CHAVE | A chave cisp_... criada para a integração. |
| Segredo do webhook | variável de ambiente CHATISP_WEBHOOK_SEGREDO | O segredo do webhook cadastrado. |
| Telefones | 44999990000, 5544999990001 | Os telefones reais. Sem +, o número é tratado como brasileiro (o 55 é acrescentado se faltar); máscara como (44) 99999-0000 é aceita. Outro país, com + na frente. |
| Ids | departamento_id 2 (Financeiro), 3 (Suporte técnico), etc. | Os ids da sua instalação, consultados em Catálogos (departamentos/listar, etiquetas/listar e as demais). |
Guarde chave e segredo fora do código, em variáveis de ambiente ou no cofre de segredos do servidor. Crie uma chave por integração, só com as permissões que ela usa, e dê um nome que a equipe reconheça (ERP, NOC, Portal): o nome aparece nas notas e na linha do tempo das conversas e das ordens de serviço.
Coleção para Postman e Insomnia
Antes de programar, experimente as rotas com a coleção pronta (detalhes em Coleção para Postman e Insomnia): chatisp-api.postman_collection.json. Ela traz todas as rotas organizadas em pastas (Catálogos, Mensagens, Conversas, Contatos, Ordens de serviço, Agenda, Indicadores) com exemplos de corpo. Depois de importar, preencha as variáveis da coleção base_url (o endereço da API) e chave. O Insomnia importa o mesmo arquivo.
Permissões de cada receita
| Receita | Permissões da chave | Webhook |
|---|---|---|
| Cliente reutilizável | conforme o uso | |
| Aviso de manutenção por região | Enviar mensagens | |
| Cliente bloqueado no ERP | Abrir atendimentos, Gerenciar conversas, Enviar mensagens | |
| NOC abre OS e avisa o cliente | Enviar mensagens, Consultar ordens de serviço, Gerenciar ordens de serviço | |
| Portal do assinante | Abrir atendimentos, Enviar mensagens | Conversa atribuída, Mensagem enviada, Conversa encerrada |
| Sincronizar com CRM e BI | Consultar conversas | Entrega e leitura (opcional) |
| Indicadores mensais | Indicadores | |
| Receber webhooks | qualquer evento | |
| Boleto em PDF | Enviar mensagens | |
| Etiquetar e encerrar | Consultar conversas, Gerenciar conversas |
Cliente reutilizável da API
Um cliente pequeno que concentra o que toda integração precisa e que as demais receitas usam:
- Cabeçalho
Authorization: Bearer, rota emr, corpo JSON nas escritas e parâmetros na query nas leituras. - Resposta com
"ok": falsevira exceção com o código estável do erro (error), o status HTTP e o corpo inteiro (que traz campos extras comocampo,aceitosedetalhe). - HTTP 429 (cota da chave por minuto) espera o tempo do cabeçalho
Retry-Aftere repete, até 3 vezes. - Um gerador que percorre todas as páginas das listagens numeradas.
<?php // ChatIspApi.php: cliente da API pública do ChatISP (PHP 8.1 ou superior, extensão curl) final class ChatIspErro extends RuntimeException { public function __construct( public readonly string $codigo, // valor de "error" (ex.: telefone_invalido) ou "rede" public readonly int $http, // status HTTP; 0 quando não houve resposta public readonly array $corpo = [], // resposta inteira, com os campos extras do erro ) { 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); } /** Percorre todas as páginas de uma listagem numerada (conversas, contatos, tickets, 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)); // cota do minuto: espera o minuto virar e repete 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 da API pública do ChatISP (Python 3.9 ou superior, pacote 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" ou "rede" self.http = http # status HTTP; 0 quando não houve resposta self.corpo = corpo or {} # resposta inteira, com os campos extras do erro 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): """Percorre todas as páginas de uma listagem numerada.""" 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 da API pública do ChatISP (Node.js 18 ou superior, fetch nativo) class ChatIspErro extends Error { constructor(codigo, http, corpo = {}) { super(`ChatISP: ${codigo} (HTTP ${http})`); this.codigo = codigo; // valor de "error" ou "rede" this.http = http; // status HTTP; 0 quando não houve resposta this.corpo = corpo; // resposta inteira, com os campos extras do erro } } 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 };
Quando repetir uma chamada
| Situação | O que fazer |
|---|---|
429 cota_excedida | O cliente já espera o Retry-After (segundos até o próximo minuto) e repete. Se acontece sempre, diminua o ritmo ou aumente a cota da chave. |
| Erro de rede, tempo esgotado ou HTTP 5xx numa leitura | Pode repetir com espera crescente (5 s, 30 s, 2 min). |
| Erro de rede ou tempo esgotado numa escrita | Não repita às cegas: a mensagem pode ter saído. Confira antes, por exemplo com conversas/mensagens do telefone, ou registre para revisão manual. |
502 envio | O canal recusou o envio. A mensagem fica registrada com status igual a erro e o motivo em detalhe. Repetir só depois de entender o motivo. |
401, 403 (chave_invalida, permissao, plano), sem_conexao, conexao_desconectada | Erro de configuração: interrompa o lote e avise quem administra a integração. Repetir não resolve. |
Demais 400 | Erro no dado daquele item (telefone inválido, título vazio). Registre e siga para o próximo. |
Aviso de manutenção por região
Cenário: o NOC vai fazer manutenção numa OLT de madrugada e precisa avisar os assinantes da região. A lista de afetados sai do ERP ou do sistema de rede (telefone, nome e bairro). O script envia o aviso a cada um com ritmo controlado e grava o que falhou.
Pela conexão da API oficial, mensagem para quem não falou com o provedor nas últimas 24 horas só sai com modelo aprovado pela Meta. O script usa mensagens/enviar_modelo nesse caso e texto livre (mensagens/enviar) quando a conexão é por QR Code. Modelo de exemplo, cadastrado e aprovado na Meta com o nome aviso_manutencao:
Olá, {{1}}! Haverá manutenção programada na rede do seu bairro ({{2}}) em {{3}}.
A conexão pode oscilar durante o período. Obrigado pela compreensão!<?php // Uso: php aviso_manutencao.php afetados.csv "02/10, das 02h às 04h" // afetados.csv (separado por ponto e vírgula): telefone;nome;bairro require 'ChatIspApi.php'; $api = ChatIspApi::doAmbiente(); [, $arquivo, $quando] = $argv; $CONEXAO_ID = 1; // número que envia (conexoes/listar); obrigatório com mais de um número conectado // a conexão é da API oficial? então precisa de modelo aprovado $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' => "Olá, {$primeiroNome}! Haverá manutenção programada na rede do seu bairro " . "({$bairro}) em {$quando}. A conexão pode oscilar durante o período. Obrigado pela compreensão!", ]); } $enviados++; echo "OK {$telefone} protocolo {$r['protocolo']}\n"; } catch (ChatIspErro $e) { fputcsv($falhas, [$telefone, $nome, $e->codigo, $e->corpo['detalhe'] ?? ''], ';'); echo "FALHA {$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("Erro de configuração ({$e->codigo}): lote interrompido.\n"); } } usleep(1_500_000); // ritmo: cerca de 40 mensagens por minuto } fclose($falhas); echo "{$enviados} aviso(s) enviado(s). Falhas em falhas.csv\n";
# Uso: python aviso_manutencao.py afetados.csv "02/10, das 02h às 04h"
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"Olá, {primeiro}! Haverá manutenção programada na rede do seu bairro "
f"({bairro}) em {quando}. A conexão pode oscilar durante o período. "
"Obrigado pela compreensão!")
print("OK ", telefone, "protocolo", r["protocolo"])
except ChatIspErro as e:
falhas.writerow([telefone, nome, e.codigo, e.corpo.get("detalhe", "")])
print("FALHA", telefone, e.codigo)
if e.codigo in ERROS_DE_CONFIGURACAO:
sys.exit(f"Erro de configuração ({e.codigo}): lote interrompido.")
time.sleep(1.5)O que acontece no ChatISP
- Quem não tinha conversa aberta recebe o aviso numa conversa que já nasce encerrada (veja Aviso: conversa nova nasce encerrada): a fila do atendimento não enche com o disparo. Se o cliente responder, abre um atendimento normal, com a IA ou a equipe.
- Quem já estava em atendimento recebe o aviso dentro da conversa em andamento, e o atendente vê.
- O número de conexão gêmea (QR Code e API oficial no mesmo número) aparece com
gemea_idpreenchido; para mandar modelo, a API usa a perna oficial sozinha. - Conexão por QR Code não tem modelo: use sempre texto livre.
Para listas grandes e recorrentes, com painel de entregues, lidas e respondidas, prefira a Automação de envios, que já cuida do ritmo e do acompanhamento. A API é a escolha quando a lista nasce no seu sistema de rede no momento do incidente.
Cliente bloqueado no ERP
Cenário: o ERP bloqueia o contrato por falta de pagamento. Em vez de esperar o cliente reclamar, a rotina do ERP abre um atendimento no departamento Financeiro já com os dados da cobrança numa nota interna, marca a conversa com a etiqueta "Bloqueado" e manda ao cliente uma mensagem com o caminho para regularizar.
<?php // Chamado pela rotina do ERP no momento do bloqueio, com os dados do 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 débito. Plano: %s. Em aberto: R$ %s em %d fatura(s), vencida desde %s.", $contrato['numero'], $contrato['plano'], number_format($contrato['valor_aberto'], 2, ',', '.'), $contrato['faturas'], $contrato['vencimento'] ); // 1) abre (ou reaproveita) o atendimento na fila do Financeiro, com a nota interna $r = $api->post('atendimentos/abrir', [ 'telefone' => $contrato['celular'], 'nome' => $contrato['cliente'], 'departamento_id' => DEPARTAMENTO_FINANCEIRO, 'nota' => $nota, // até 900 caracteres; só a equipe vê ]); $conversaId = $r['conversa']['id']; // 2) etiqueta para a equipe filtrar no painel $api->post('conversas/etiquetas', ['conversa_id' => $conversaId, 'adicionar' => [ETIQUETA_BLOQUEADO]]); // 3) mensagem ao cliente dentro do mesmo atendimento $primeiroNome = explode(' ', $contrato['cliente'])[0]; try { $api->post('mensagens/enviar', [ 'conversa_id' => $conversaId, 'texto' => "Olá, {$primeiroNome}! Identificamos pendências no seu contrato e o acesso foi suspenso. " . "Responda esta mensagem que enviamos o Pix ou a segunda via na hora.", ]); } catch (ChatIspErro $e) { if ($e->codigo !== 'janela_fechada') throw $e; // API oficial fora da janela de 24 h: só modelo aprovado $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', ]);
Comportamento que importa aqui
atendimentos/abrirnão manda nada ao cliente: só cria (ou devolve, comja_existiaigual atrue) o atendimento e registra a nota. Se já existia um atendimento aberto com a IA ou num fluxo, ele continua lá; se estava na fila, vai para a fila do departamento pedido e a distribuição automática tenta entregar a alguém.- Como o atendimento já está aberto, o
mensagens/enviarcomconversa_identra nele (não vira aviso encerrado). - A nota interna aparece na linha do tempo com o nome da chave (
API (ERP): ...); o cliente nunca a vê. - Quando o cliente responde, a IA ou o atendente do Financeiro já tem o contexto na tela. Se o seu ERP é um dos integrados ao ChatISP, a ficha do cliente e as faturas aparecem também.
NOC abre OS e avisa o cliente
Cenário: o sistema de monitoramento detecta que o link de um cliente corporativo caiu. O script avisa o cliente pelo WhatsApp, abre a OS de reparo vinculado à mesma conversa e, quando o alarme normaliza, comenta, move a OS para a etapa final e avisa o cliente que voltou.
# Ligue no seu monitoramento: "python noc_alarme.py problema <id_alarme>" e "python noc_alarme.py normalizado <id_alarme>" import json, sys from pathlib import Path from chatisp_api import ChatIspApi api = ChatIspApi.do_ambiente() ESTADO = Path("noc_tickets.json") # id do alarme -> ticket_id e conversa_id TIPO_REPARO = 2 # tickets/tipos: "Reparo de rede" DEPARTAMENTO_SUPORTE = 3 def cliente_do_alarme(alarme_id): """Do seu sistema: dados do cliente ligado ao equipamento que alarmou.""" return {"nome": "Mercado Bom Preço", "telefone": "44999990003", "endereco": "Av. Brasil, 2100, Centro", "equipamento": "ONU ZTE F670L serial ZTEG00000001", "sinal": "LOS (sem 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 # alarme repetido: ticket já aberto c = cliente_do_alarme(alarme_id) # 1) aviso ao cliente (sem conversa aberta, nasce como aviso encerrado) aviso = api.post("mensagens/enviar", telefone=c["telefone"], nome=c["nome"], texto="Olá! Nosso monitoramento detectou uma falha no seu link e a equipe técnica " "já foi acionada. Avisaremos por aqui assim que normalizar.") # 2) ticket de reparo, vinculado à conversa do aviso t = api.post("tickets/criar", titulo=f"Link caído: {c['nome']}", descricao=f"Alarme {alarme_id}. Equipamento: {c['equipamento']}. Sinal: {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("Ticket", t["numero"], "prazo", 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="Monitoramento: alarme normalizado.") api.post("tickets/mover", ticket_id=ticket_id, etapa_id=etapa_final_resolvido(), comentario="Fechado automaticamente pelo NOC.") api.post("mensagens/enviar", conversa_id=registro["conversa_id"], texto="Boa notícia: o seu link foi normalizado. Se notar qualquer instabilidade, é só responder aqui.") ESTADO.write_text(json.dumps(estado)) if __name__ == "__main__": {"problema": problema, "normalizado": normalizado}[sys.argv[1]](sys.argv[2])
- A OS nasce com o prazo do tipo (o Reparo de rede de fábrica tem 8 horas) e a equipe do Suporte recebe o aviso "Nova OS para a equipe" pelo WhatsApp. Para mandar direto ao plantonista, informe
responsavel_id. - O vínculo com a conversa faz a OS aparecer no atendimento e permite que a equipe veja o aviso que o cliente recebeu.
- No fechamento, a mensagem com
conversa_idde uma conversa já encerrada entra no atendimento aberto desse número, se houver (por exemplo, o cliente respondeu ao aviso); senão, abre um novo protocolo que também nasce como aviso encerrado. - Se um técnico já resolveu pelo painel,
concluidovemtruee o script só avisa o cliente.
Pela conexão da API oficial, o aviso inicial a quem não falou com o provedor nas últimas 24 horas precisa ser um modelo aprovado (mensagens/enviar_modelo); a mensagem de normalização entra na janela se o cliente tiver respondido. Veja Janela de 24 horas da API oficial e trate o erro janela_fechada como no exemplo do cliente bloqueado.
Portal do assinante
Cenário: na área do cliente do site, o assinante clica em "Falar com o suporte", descreve o problema e recebe o protocolo na tela. O backend do portal abre o atendimento no ChatISP e manda uma confirmação pelo WhatsApp. Depois, um receptor de webhook atualiza a página do pedido: quem assumiu, as respostas e o encerramento.
1. Abrir o atendimento
<?php
require 'ChatIspApi.php';
$api = ChatIspApi::doAmbiente();
$assinante = portalAssinanteLogado(); // do seu sistema: nome, 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
'nota' => "Aberto pelo portal (contrato {$assinante['contrato']}): {$relato}",
]);
$conversa = $r['conversa'];
// confirmação ao cliente dentro do atendimento recém-aberto
$api->post('mensagens/enviar', [
'conversa_id' => $conversa['id'],
'texto' => "Recebemos o seu pedido pelo portal (protocolo {$conversa['protocolo']}). "
. "Um atendente vai falar com você por aqui.",
]);
} catch (ChatIspErro $e) {
if ($e->codigo !== 'janela_fechada') { // API oficial fora da janela: o atendente faz o primeiro contato
http_response_code(502);
exit('Não foi possível abrir o atendimento agora. Chame a gente no WhatsApp.');
}
}
portalSalvarPedido($assinante['contrato'], $conversa['id'], $conversa['protocolo']); // do seu sistema
echo "Pedido registrado. Protocolo {$conversa['protocolo']}.";2. Acompanhar pelo webhook
Cadastre um webhook (em Integrações) com os eventos Conversa atribuída (conversa_atribuida), Mensagem enviada (mensagem_enviada) e Conversa encerrada (conversa_encerrada), apontando para o portal. Todos trazem conversa_id e protocolo em dados, que é o elo com o pedido salvo.
const express = require('express');
const crypto = require('crypto');
const { pedidoPorConversa, atualizarPedido } = require('./pedidos'); // do seu 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); // conversa que não veio do portal
switch (evento) {
case 'conversa_atribuida':
await atualizarPedido(pedido.id, { situacao: 'Em atendimento', 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: 'Concluído', motivo: dados.motivo });
break;
}
res.sendStatus(200);
});
app.listen(3000);Se o cliente continuar a conversa depois do encerramento, o ChatISP abre um novo protocolo. Para seguir o histórico completo do assinante no portal, associe os pedidos pelo telefone, não só pelo conversa_id.
Sincronizar conversas e mensagens com CRM ou BI
Cenário: o CRM (ou o banco do BI) precisa de uma cópia das conversas e mensagens para relatórios próprios e para mostrar o histórico na ficha do cliente. Uma rotina a cada 5 ou 10 minutos busca só o que mudou desde a rodada anterior:
chave/infodevolve a hora do servidor (servidor.agora): ela vira o marco da próxima rodada, sem depender do relógio da sua máquina.conversas/listarcomatualizadas_desdetraz as conversas que tiveram mensagem nova, abriram ou encerraram desde o marco.- Para cada conversa,
conversas/mensagenscomdepois_deigual ao último id já gravado traz só as mensagens novas, em ordem, de 100 em 100 enquantotem_maisfor verdadeiro.
# Rode a cada 5 ou 10 minutos (cron, agendador de tarefas, 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) # sobreposição entre rodadas: nada escapa, a gravação é idempotente def gravar_conversa(c): # do seu sistema: INSERT ... ON DUPLICATE KEY UPDATE por c["id"] ... def gravar_mensagem(m): # do seu sistema: upsert por m["id"] ... def ultimo_id_mensagem(conversa_id): # do seu sistema: maior id de mensagem gravado (0 se nenhum) 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() # primeira rodada sem marco: traz o histórico inteiro, 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} conversa(s) sincronizada(s)") if __name__ == "__main__": sincronizar()
Detalhes que evitam dor de cabeça
- Grave por id, sempre. A folga entre rodadas faz algumas conversas voltarem; com upsert pelo
id, isso não duplica nada. - Ordem
antigas. Uma conversa que recebe mensagem durante a paginação vai para o fim da lista e é pega na própria rodada ou na seguinte, nunca perdida. - Notas internas não vêm. A API entrega só o que o cliente e o provedor trocaram; as notas da equipe ficam no painel.
- Status de entrega e leitura. A mudança de
status(entregue, lida) não altera a conversa e não aparece noatualizadas_desde. Se o BI precisa disso, assine o evento Entrega e leitura (mensagem_status) e atualize a mensagem peloid. - Mensagem editada ou apagada vem com
editadaouapagadaverdadeiros (a apagada, sem conteúdo). Como a edição não muda o id, a próxima leitura completa da conversa traz o valor novo. - Arquivos: o campo
arquivo.downloadé a rota para baixar com a mesma chave (mensagens/arquivo?id=...). Baixe só o que for necessário.
Exportar indicadores mensais
Cenário: no primeiro dia de cada mês, a gestão quer uma planilha com os números do mês anterior, da casa inteira e de cada departamento. O script chama relatorios/resumo uma vez sem filtro e uma vez por departamento ativo, converte os tempos para minutos e grava um CSV que abre direto no Excel.
<?php // Uso: php indicadores_mes.php (mês anterior) // php indicadores_mes.php 2026-09 (mês escolhido) 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 dia do mês (vale até 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 os 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: o Excel abre com acentos certos fputcsv($csv, ['Departamento', 'Abertas', 'Encerradas', 'Resolvidas pela IA (%)', 'Mensagens recebidas', 'Primeira resposta (min)', 'Espera na fila (min)', 'Duração média (min)', 'Satisfação', 'Taxa de resposta (%)'], ';'); 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 "Gerado indicadores-{$mes}.csv\n";
Para a planilha por atendente, use a lista por_atendente da chamada sem filtro: ela já traz atendidas, encerradas, duração média, satisfação e avaliações de cada pessoa. As regras de cada número estão em Indicadores.
Receber webhooks com verificação e fila
Todo receptor de webhook do ChatISP segue quatro regras:
- Confira a assinatura sobre o corpo cru: o cabeçalho
X-ChatISP-Assinaturatrazsha256=seguido do HMAC SHA-256 do corpo com o segredo do webhook. Compare em tempo constante. - Descarte repetições pelo cabeçalho
X-ChatISP-Entrega(id da entrega). A mesma entrega pode chegar de novo se a sua resposta se perder. - Grave e responda rápido: o ChatISP espera no máximo 10 segundos e considera entregue qualquer resposta 2xx. Sem 2xx, tenta de novo depois de 1 minuto, 5 minutos, 15 minutos, 1 hora e 6 horas, e então desiste.
- Processe depois, numa rotina ou fila sua. O evento de teste (botão Testar) chega com
eventoigual atestee entrega0.
<?php // Tabela de apoio (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) assinatura sobre o corpo cru, em tempo 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ão Testar } // 2) e 3) grava de forma durável; a chave primária recusa a mesma entrega duas vezes $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 = já recebida: segue para o 200 http_response_code(500); // falha real: o ChatISP tenta de novo exit; } } echo 'ok'; // 4) uma rotina separada (a cada minuto) lê chatisp_eventos WHERE processado = 0, // ORDER BY emitido_em, entrega_id, trata cada evento e marca processado = 1.
const express = require('express');
const crypto = require('crypto');
const { Pool } = require('pg'); // qualquer banco serve; o importante é gravar antes do 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 só nesta rota: com express.json o corpo seria decodificado e a assinatura não bateria
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('falha ao gravar a entrega', entrega, erro);
res.sendStatus(500); // o ChatISP tenta de novo
}
});
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 crus, antes de qualquer 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 a 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", 200Os eventos disponíveis e o conteúdo de dados em cada um estão em Eventos; detalhes da assinatura em Assinatura.
Erros comuns de assinatura: calcular o HMAC sobre o JSON já decodificado e serializado de novo (espaços e acentos mudam), usar o segredo com espaço no fim, ou comparar sem o prefixo sha256=.
Enviar boleto em PDF
Cenário: o cliente pediu a segunda via no atendimento, ou a rotina do ERP manda o boleto do mês. O PDF gerado pelo ERP vai pelo WhatsApp como documento, com legenda. A rota mensagens/enviar_arquivo aceita o arquivo em base64 dentro do JSON ou como upload multipart/form-data (campo arquivo), até 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", // nome que o cliente vê
'legenda' => "Segue o boleto de {$competencia}, no valor de R$ {$valor}. "
. "Se preferir Pix, é só responder esta mensagem.",
]);
}
try {
$r = enviarBoleto($api, '44999990004', '/tmp/boletos/10234-2026-10.pdf', 'outubro/2026', '119,90');
echo "Enviado no protocolo {$r['protocolo']}, mensagem {$r['mensagem_id']}\n";
} catch (ChatIspErro $e) {
// janela_fechada: API oficial fora das 24 h; mande antes um modelo aprovado
// tipo_nao_suportado (415): o conteúdo não é um PDF de verdade
// arquivo_grande (413): acima de 45 MB
echo "Falhou: {$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"Segue o boleto de {competencia}, no valor de R$ {valor}. "
"Se preferir Pix, é só responder esta mensagem.")
try:
r = enviar_boleto("44999990004", "/tmp/boletos/10234-2026-10.pdf", "outubro/2026", "119,90")
print("Enviado no protocolo", r["protocolo"])
except ChatIspErro as e:
print("Falhou:", e.codigo, e.corpo.get("detalhe", ""))# Alternativa sem base64: upload 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=Segue o boleto de outubro/2026." \
-F "nome_arquivo=boleto-outubro-2026.pdf" \
-F "arquivo=@/tmp/boletos/10234-2026-10.pdf;type=application/pdf"- O tipo do arquivo é identificado pelo conteúdo, não pelo nome: um PDF vai como documento; imagem, áudio e vídeo vão como mídia do tipo certo. Executáveis são recusados.
- A legenda aceita até 1.024 caracteres.
- Mesma regra de destino do envio de texto: com
conversa_idde um atendimento aberto, o boleto entra nele; para um número sem conversa aberta, nasce como aviso encerrado e a resposta do cliente abre um atendimento. - O PDF fica no histórico da conversa e pode ser baixado depois pela rota
mensagens/arquivo.
Etiquetar e encerrar a partir de outro sistema
Cenário: o cliente pediu a segunda via e pagou o Pix. Quando o ERP confirma o pagamento, a integração localiza o atendimento aberto desse cliente, troca a etiqueta "Aguardando pagamento" por "Pago", deixa uma nota para a equipe e encerra o atendimento com o motivo certo, mandando ao cliente a mensagem padrão de encerramento e a pesquisa de satisfação.
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}" não existe em ${rota}`);
return item.id;
}
async function pagamentoConfirmado({ telefone, valor, pagoEm }) {
// atendimento aberto deste cliente (o mais recente)
const { conversas } = await api.get('conversas/listar', { telefone, abertas: 1, limite: 1 });
if (conversas.length === 0) return 'sem atendimento aberto';
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: `Pagamento de R$ ${valor} confirmado pelo ERP em ${pagoEm}. Desbloqueio automático em andamento.`,
});
try {
await api.post('conversas/encerrar', {
conversa_id: conversa.id,
motivo_id: motivo,
enviar_mensagem: true, // false = encerra em silêncio, sem mensagem de encerramento nem pesquisa
});
} catch (e) {
if (!(e instanceof ChatIspErro && e.codigo === 'conversa_encerrada')) throw e; // alguém encerrou 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/etiquetasaceitaadicionareremover(mexem só no que vier) ouetiquetas(substitui a lista inteira; lista vazia limpa).- Com
enviar_mensagemverdadeiro (o padrão), o encerramento faz o mesmo que o botão do painel: manda a mensagem de encerramento configurada e a pesquisa de satisfação, quando ligadas e quando houve atendimento de fato. - Sai o webhook
conversa_encerradacom o nome do motivo, para os outros sistemas que acompanham o atendimento. - Guarde os ids de etiquetas e motivos em cache: eles só mudam quando o provedor edita os cadastros.