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

ItemValor nos exemplosTroque por
Endereço da APIhttps://atendimento.seuprovedor.com.br/api_publica.phpO endereço mostrado em Integrações, card API do ChatISP.
Chavevariável de ambiente CHATISP_CHAVEA chave cisp_... criada para a integração.
Segredo do webhookvariável de ambiente CHATISP_WEBHOOK_SEGREDOO segredo do webhook cadastrado.
Telefones44999990000, 5544999990001Os 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.
Idsdepartamento_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

ReceitaPermissões da chaveWebhook
Cliente reutilizávelconforme o uso
Aviso de manutenção por regiãoEnviar mensagens
Cliente bloqueado no ERPAbrir atendimentos, Gerenciar conversas, Enviar mensagens
NOC abre OS e avisa o clienteEnviar mensagens, Consultar ordens de serviço, Gerenciar ordens de serviço
Portal do assinanteAbrir atendimentos, Enviar mensagensConversa atribuída, Mensagem enviada, Conversa encerrada
Sincronizar com CRM e BIConsultar conversasEntrega e leitura (opcional)
Indicadores mensaisIndicadores
Receber webhooksqualquer evento
Boleto em PDFEnviar mensagens
Etiquetar e encerrarConsultar 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 em r, corpo JSON nas escritas e parâmetros na query nas leituras.
  • Resposta com "ok": false vira exceção com o código estável do erro (error), o status HTTP e o corpo inteiro (que traz campos extras como campo, aceitos e detalhe).
  • HTTP 429 (cota da chave por minuto) espera o tempo do cabeçalho Retry-After e repete, até 3 vezes.
  • Um gerador que percorre todas as páginas das listagens numeradas.
ChatIspApi.php
<?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
# 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
// 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çãoO que fazer
429 cota_excedidaO 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 leituraPode repetir com espera crescente (5 s, 30 s, 2 min).
Erro de rede ou tempo esgotado numa escritaNã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 envioO 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_desconectadaErro de configuração: interrompa o lote e avise quem administra a integração. Repetir não resolve.
Demais 400Erro 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:

Modelo aviso_manutencao (corpo com 3 variáveis)
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!
aviso_manutencao.php
<?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";
aviso_manutencao.py
# 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_id preenchido; 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.

erp_cliente_bloqueado.php
<?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/abrir não manda nada ao cliente: só cria (ou devolve, com ja_existia igual a true) 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/enviar com conversa_id entra 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.

noc_alarme.py
# 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_id de 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, concluido vem true e 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

portal/abrir_suporte.php (recebe o formulário)
<?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.

portal/webhook.js (Node.js com Express)
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:

  1. chave/info devolve a hora do servidor (servidor.agora): ela vira o marco da próxima rodada, sem depender do relógio da sua máquina.
  2. conversas/listar com atualizadas_desde traz as conversas que tiveram mensagem nova, abriram ou encerraram desde o marco.
  3. Para cada conversa, conversas/mensagens com depois_de igual ao último id já gravado traz só as mensagens novas, em ordem, de 100 em 100 enquanto tem_mais for verdadeiro.
sincronizar_chatisp.py
# 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 no atualizadas_desde. Se o BI precisa disso, assine o evento Entrega e leitura (mensagem_status) e atualize a mensagem pelo id.
  • Mensagem editada ou apagada vem com editada ou apagada verdadeiros (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.

indicadores_mes.php
<?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:

  1. Confira a assinatura sobre o corpo cru: o cabeçalho X-ChatISP-Assinatura traz sha256= seguido do HMAC SHA-256 do corpo com o segredo do webhook. Compare em tempo constante.
  2. 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.
  3. 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.
  4. Processe depois, numa rotina ou fila sua. O evento de teste (botão Testar) chega com evento igual a teste e entrega 0.
webhook_chatisp.php
<?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.
webhook.js
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);
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 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", 200

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

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",        // 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";
}
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"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", ""))
multipart
# 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_id de 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.

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}" 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/etiquetas aceita adicionar e remover (mexem só no que vier) ou etiquetas (substitui a lista inteira; lista vazia limpa).
  • Com enviar_mensagem verdadeiro (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_encerrada com 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.