Integration examples
Complete recipes for the real scenarios of an internet service provider (ISP): a reusable API client, a maintenance notice by region, a service request opened by the ERP for a blocked customer, a NOC that opens a service order and notifies the customer, a subscriber portal, sync with CRM and BI, monthly metrics, secure webhook reception, sending an invoice slip as PDF, and labeling and closing from another system.
Before you start
All examples use fictitious data and the same conventions. Copy them, replace the values in the table and adapt the functions marked as "from your system" (ERP lookup, CRM write).
| Item | Value in the examples | Replace with |
|---|---|---|
| API address | https://atendimento.seuprovedor.com.br/api_publica.php | The address shown in Integrations, on the ChatISP API card. |
| Key | environment variable CHATISP_CHAVE | The cisp_... key created for the integration. |
| Webhook secret | environment variable CHATISP_WEBHOOK_SEGREDO | The secret of the registered webhook. |
| Phone numbers | 44999990000, 5544999990001 | The real phone numbers. Without +, the number is treated as Brazilian (55 is added if missing); a mask such as (44) 99999-0000 is accepted. For another country, put + in front. |
| Ids | departamento_id 2 (Financeiro), 3 (Suporte técnico), etc. | The ids of your installation, looked up in Catalogs (departamentos/listar, etiquetas/listar and the others). |
Keep the key and the secret out of the code, in environment variables or in the server's secret vault. Create one key per integration, with only the permissions it uses, and give it a name the team recognizes (ERP, NOC, Portal): the name appears in the notes and in the timeline of conversations and service orders.
Collection for Postman and Insomnia
Before coding, try the routes with the ready-made collection (details in Collection for Postman and Insomnia): chatisp-api.postman_collection.json. It includes every route organized in folders (Catalogs, Messages, Conversations, Contacts, Tickets, Schedule, Metrics) with sample bodies. After importing, fill in the collection variables base_url (the API address) and chave. Insomnia imports the same file.
Permissions for each recipe
| Recipe | Key permissions | Webhook |
|---|---|---|
| Reusable client | depending on use | |
| Maintenance notice by region | Send messages | |
| Customer blocked in the ERP | Open service requests, Manage conversations, Send messages | |
| NOC opens a service order and notifies the customer | Send messages, Look up service orders, Manage service orders | |
| Subscriber portal | Open service requests, Send messages | Conversation assigned, Message sent, Conversation closed |
| Sync with CRM and BI | Look up conversations | Delivery and read (optional) |
| Monthly metrics | Metrics | |
| Receive webhooks | any event | |
| Invoice slip as PDF | Send messages | |
| Label and close | Look up conversations, Manage conversations |
Reusable API client
A small client that gathers what every integration needs and that the other recipes use:
Authorization: Bearerheader, route inr, JSON body for writes and query parameters for reads.- A response with
"ok": falsebecomes an exception with the stable error code (error), the HTTP status and the whole body (which carries extra fields such ascampo,aceitosanddetalhe). - HTTP 429 (the key's per-minute quota) waits for the time in the
Retry-Afterheader and retries, up to 3 times. - A generator that walks through every page of the numbered listings.
<?php // ChatIspApi.php: client for the ChatISP public API (PHP 8.1 or later, curl extension) final class ChatIspErro extends RuntimeException { public function __construct( public readonly string $codigo, // value of "error" (e.g. telefone_invalido) or "rede" public readonly int $http, // HTTP status; 0 when there was no response public readonly array $corpo = [], // whole response, with the error's extra fields ) { 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); } /** Walks through every page of a numbered listing (conversations, contacts, tickets, schedule). */ 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)); // per-minute quota: wait for the minute to turn and retry 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: client for the ChatISP public API (Python 3.9 or later, requests package) 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 # value of "error" or "rede" self.http = http # HTTP status; 0 when there was no response self.corpo = corpo or {} # whole response, with the error's extra fields 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): """Walks through every page of a numbered listing.""" 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: client for the ChatISP public API (Node.js 18 or later, native fetch) class ChatIspErro extends Error { constructor(codigo, http, corpo = {}) { super(`ChatISP: ${codigo} (HTTP ${http})`); this.codigo = codigo; // value of "error" or "rede" this.http = http; // HTTP status; 0 when there was no response this.corpo = corpo; // whole response, with the error's extra fields } } 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 };
When to retry a call
| Situation | What to do |
|---|---|
429 cota_excedida | The client already waits for Retry-After (seconds until the next minute) and retries. If it happens all the time, slow down or raise the key's quota. |
| Network error, timeout or HTTP 5xx on a read | You can retry with increasing waits (5 s, 30 s, 2 min). |
| Network error or timeout on a write | Do not retry blindly: the message may have gone out. Check first, for example with conversas/mensagens for the phone number, or log it for manual review. |
502 envio | The channel refused the send. The message is recorded with status equal to erro and the reason in detalhe. Retry only after understanding the reason. |
401, 403 (chave_invalida, permissao, plano), sem_conexao, conexao_desconectada | Configuration error: stop the batch and notify whoever manages the integration. Retrying does not help. |
Other 400 errors | Error in the data of that item (invalid phone number, empty title). Log it and move on to the next one. |
Maintenance notice by region
Scenario: the NOC will perform maintenance on an OLT overnight and needs to notify the subscribers in the region. The list of affected customers comes from the ERP or from the network system (phone number, name and neighborhood). The script sends the notice to each one at a controlled pace and records what failed.
Through the official API connection, a message to someone who has not talked to the ISP in the last 24 hours only goes out with a template approved by Meta. The script uses mensagens/enviar_modelo in that case and free text (mensagens/enviar) when the connection is by QR Code. Sample template, registered and approved at Meta with the name aviso_manutencao:
Hello, {{1}}! There will be scheduled maintenance on the network in your neighborhood ({{2}}) on {{3}}.
Your connection may fluctuate during this period. Thank you for your understanding!<?php // Usage: php aviso_manutencao.php afetados.csv "10/02, from 2 AM to 4 AM" // afetados.csv (semicolon-separated): phone;name;neighborhood require 'ChatIspApi.php'; $api = ChatIspApi::doAmbiente(); [, $arquivo, $quando] = $argv; $CONEXAO_ID = 1; // sending number (conexoes/listar); required with more than one connected number // is it an official API connection? then it needs an approved template $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] ?: 'customer'; 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' => "Hello, {$primeiroNome}! There will be scheduled maintenance on the network in your neighborhood " . "({$bairro}) on {$quando}. Your connection may fluctuate during this period. Thank you for your understanding!", ]); } $enviados++; echo "OK {$telefone} protocol {$r['protocolo']}\n"; } catch (ChatIspErro $e) { fputcsv($falhas, [$telefone, $nome, $e->codigo, $e->corpo['detalhe'] ?? ''], ';'); echo "FAIL {$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("Configuration error ({$e->codigo}): batch stopped.\n"); } } usleep(1_500_000); // pace: about 40 messages per minute } fclose($falhas); echo "{$enviados} notice(s) sent. Failures in falhas.csv\n";
# Usage: python aviso_manutencao.py afetados.csv "10/02, from 2 AM to 4 AM"
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 "customer"
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"Hello, {primeiro}! There will be scheduled maintenance on the network in your neighborhood "
f"({bairro}) on {quando}. Your connection may fluctuate during this period. "
"Thank you for your understanding!")
print("OK ", telefone, "protocol", r["protocolo"])
except ChatIspErro as e:
falhas.writerow([telefone, nome, e.codigo, e.corpo.get("detalhe", "")])
print("FAIL ", telefone, e.codigo)
if e.codigo in ERROS_DE_CONFIGURACAO:
sys.exit(f"Configuration error ({e.codigo}): batch stopped.")
time.sleep(1.5)What happens in ChatISP
- Customers without an open conversation receive the notice in a conversation that starts already closed (see Notice: a new conversation starts closed): the service queue does not fill up with the send. If the customer replies, a normal service request opens, with the AI or the team.
- Customers already being served receive the notice inside the ongoing conversation, and the agent sees it.
- A twin connection number (QR Code and official API on the same number) appears with
gemea_idfilled in; to send a template, the API uses the official leg on its own. - A QR Code connection has no templates: always use free text.
For large, recurring lists, with a dashboard of delivered, read and replied messages, prefer Sending automation, which already handles pacing and tracking. The API is the right choice when the list is born in your network system at the moment of the incident.
Customer blocked in the ERP
Scenario: the ERP blocks the contract for non-payment. Instead of waiting for the customer to complain, the ERP routine opens a service request in the Financeiro (billing) department with the billing details already in an internal note, labels the conversation "Bloqueado" (blocked) and sends the customer a message with the way to settle the account.
<?php // Called by the ERP routine at the moment of the block, with the contract data. 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( "Contract %s blocked for outstanding debt. Plan: %s. Outstanding: R$ %s in %d invoice(s), overdue since %s.", $contrato['numero'], $contrato['plano'], number_format($contrato['valor_aberto'], 2, ',', '.'), $contrato['faturas'], $contrato['vencimento'] ); // 1) opens (or reuses) the service request in the Financeiro queue, with the internal note $r = $api->post('atendimentos/abrir', [ 'telefone' => $contrato['celular'], 'nome' => $contrato['cliente'], 'departamento_id' => DEPARTAMENTO_FINANCEIRO, 'nota' => $nota, // up to 900 characters; only the team sees it ]); $conversaId = $r['conversa']['id']; // 2) label so the team can filter in the panel $api->post('conversas/etiquetas', ['conversa_id' => $conversaId, 'adicionar' => [ETIQUETA_BLOQUEADO]]); // 3) message to the customer inside the same service request $primeiroNome = explode(' ', $contrato['cliente'])[0]; try { $api->post('mensagens/enviar', [ 'conversa_id' => $conversaId, 'texto' => "Hello, {$primeiroNome}! We found pending items on your contract and your access was suspended. " . "Reply to this message and we will send you the Pix code or a copy of the invoice right away.", ]); } catch (ChatIspErro $e) { if ($e->codigo !== 'janela_fechada') throw $e; // official API outside the 24-hour window: approved template only $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', ]);
Behavior that matters here
atendimentos/abrirsends nothing to the customer: it only creates (or returns, withja_existiaequal totrue) the service request and records the note. If there was already an open service request with the AI or in a flow, it stays there; if it was in the queue, it moves to the queue of the requested department and the automatic distribution tries to hand it to someone.- Since the service request is already open,
mensagens/enviarwithconversa_idgoes into it (it does not become a closed notice). - The internal note appears in the timeline with the key's name (
API (ERP): ...); the customer never sees it. - When the customer replies, the AI or the Financeiro agent already has the context on screen. If your ERP is one of those integrated with ChatISP, the customer record and the invoices appear too.
NOC opens a service order and notifies the customer
Scenario: the monitoring system detects that a business customer's link went down. The script notifies the customer on WhatsApp, opens the repair service order linked to the same conversation and, when the alarm clears, adds a comment, moves the service order to the final stage and tells the customer the service is back.
# Hook it into your monitoring: "python noc_alarme.py problema <alarm_id>" and "python noc_alarme.py normalizado <alarm_id>" import json, sys from pathlib import Path from chatisp_api import ChatIspApi api = ChatIspApi.do_ambiente() ESTADO = Path("noc_tickets.json") # alarm id -> ticket_id and conversa_id TIPO_REPARO = 2 # tickets/tipos: "Reparo de rede" (network repair) DEPARTAMENTO_SUPORTE = 3 def cliente_do_alarme(alarme_id): """From your system: data of the customer tied to the equipment that raised the alarm.""" return {"nome": "Mercado Bom Preço", "telefone": "44999990003", "endereco": "Av. Brasil, 2100, Centro", "equipamento": "ONU ZTE F670L serial ZTEG00000001", "sinal": "LOS (no light)"} 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 # repeated alarm: ticket already open c = cliente_do_alarme(alarme_id) # 1) notice to the customer (with no open conversation, it starts as a closed notice) aviso = api.post("mensagens/enviar", telefone=c["telefone"], nome=c["nome"], texto="Hello! Our monitoring detected a failure on your link and the technical team " "has already been dispatched. We will let you know here as soon as it is back to normal.") # 2) repair ticket, linked to the notice's conversation t = api.post("tickets/criar", titulo=f"Link down: {c['nome']}", descricao=f"Alarm {alarme_id}. Equipment: {c['equipamento']}. Signal: {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"], "due", 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="Monitoring: alarm cleared.") api.post("tickets/mover", ticket_id=ticket_id, etapa_id=etapa_final_resolvido(), comentario="Closed automatically by the NOC.") api.post("mensagens/enviar", conversa_id=registro["conversa_id"], texto="Good news: your link is back to normal. If you notice any instability, just reply here.") ESTADO.write_text(json.dumps(estado)) if __name__ == "__main__": {"problema": problema, "normalizado": normalizado}[sys.argv[1]](sys.argv[2])
- The service order starts with the type's deadline (the factory Reparo de rede type has 8 hours) and the Support team receives the "New service order for the team" alert on WhatsApp. To send it straight to the on-call technician, pass
responsavel_id. - The link to the conversation makes the service order appear in the service request and lets the team see the notice the customer received.
- At closing, a message with the
conversa_idof an already closed conversation goes into the open service request of that number, if there is one (for example, the customer replied to the notice); otherwise, it opens a new protocol that also starts as a closed notice. - If a technician has already resolved it in the panel,
concluidocomes backtrueand the script only notifies the customer.
Through the official API connection, the initial notice to someone who has not talked to the ISP in the last 24 hours must be an approved template (mensagens/enviar_modelo); the back-to-normal message fits in the window if the customer has replied. See Official API 24-hour window and handle the janela_fechada error as in the blocked customer example.
Subscriber portal
Scenario: in the customer area of the website, the subscriber clicks "Talk to support", describes the problem and gets the protocol number on screen. The portal backend opens the service request in ChatISP and sends a confirmation on WhatsApp. Then a webhook receiver updates the request page: who took it, the replies and the closing.
1. Open the service request
<?php
require 'ChatIspApi.php';
$api = ChatIspApi::doAmbiente();
$assinante = portalAssinanteLogado(); // from your system: name, mobile, contract
$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 (technical support)
'nota' => "Opened from the portal (contract {$assinante['contrato']}): {$relato}",
]);
$conversa = $r['conversa'];
// confirmation to the customer inside the service request just opened
$api->post('mensagens/enviar', [
'conversa_id' => $conversa['id'],
'texto' => "We received your request through the portal (protocol {$conversa['protocolo']}). "
. "An agent will talk to you here.",
]);
} catch (ChatIspErro $e) {
if ($e->codigo !== 'janela_fechada') { // official API outside the window: the agent makes the first contact
http_response_code(502);
exit('We could not open the service request right now. Message us on WhatsApp.');
}
}
portalSalvarPedido($assinante['contrato'], $conversa['id'], $conversa['protocolo']); // from your system
echo "Request registered. Protocol {$conversa['protocolo']}.";2. Track it through the webhook
Register a webhook (in Integrations) with the events Conversation assigned (conversa_atribuida), Message sent (mensagem_enviada) and Conversation closed (conversa_encerrada), pointing to the portal. All of them carry conversa_id and protocolo in dados, which is the link to the saved request.
const express = require('express');
const crypto = require('crypto');
const { pedidoPorConversa, atualizarPedido } = require('./pedidos'); // from your system
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); // conversation that did not come from the portal
switch (evento) {
case 'conversa_atribuida':
await atualizarPedido(pedido.id, { situacao: 'In progress', 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: 'Completed', motivo: dados.motivo });
break;
}
res.sendStatus(200);
});
app.listen(3000);If the customer continues the conversation after it is closed, ChatISP opens a new protocol. To follow the subscriber's full history in the portal, match the requests by telefone, not only by conversa_id.
Sync conversations and messages with a CRM or BI
Scenario: the CRM (or the BI database) needs a copy of the conversations and messages for its own reports and to show the history in the customer record. A routine every 5 or 10 minutes fetches only what changed since the previous run:
chave/inforeturns the server time (servidor.agora): it becomes the checkpoint for the next run, without depending on your machine's clock.conversas/listarwithatualizadas_desdereturns the conversations that got a new message, opened or closed since the checkpoint.- For each conversation,
conversas/mensagenswithdepois_deequal to the last id already stored returns only the new messages, in order, 100 at a time whiletem_maisis true.
# Run every 5 or 10 minutes (cron, task scheduler, 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) # overlap between runs: nothing slips through, the write is idempotent def gravar_conversa(c): # from your system: INSERT ... ON DUPLICATE KEY UPDATE by c["id"] ... def gravar_mensagem(m): # from your system: upsert by m["id"] ... def ultimo_id_mensagem(conversa_id): # from your system: highest message id stored (0 if none) 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() # first run without a checkpoint: fetches the whole history, page by page 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} conversation(s) synced") if __name__ == "__main__": sincronizar()
Details that save you headaches
- Always write by id. The overlap between runs makes some conversations come back; with an upsert by
id, nothing gets duplicated. antigasorder. A conversation that receives a message during pagination moves to the end of the list and is picked up in the same run or the next one, never lost.- Internal notes are not included. The API delivers only what the customer and the ISP exchanged; the team's notes stay in the panel.
- Delivery and read status. A
statuschange (delivered, read) does not change the conversation and does not show up inatualizadas_desde. If the BI needs it, subscribe to the Delivery and read event (mensagem_status) and update the message byid. - An edited or deleted message comes with
editadaorapagadaset to true (a deleted one, without content). Since editing does not change the id, the next full read of the conversation brings the new value. - Files: the
arquivo.downloadfield is the route to download it with the same key (mensagens/arquivo?id=...). Download only what you need.
Export monthly metrics
Scenario: on the first day of each month, management wants a spreadsheet with the previous month's numbers, for the whole company and for each department. The script calls relatorios/resumo once without a filter and once per active department, converts the times to minutes and writes a CSV that opens directly in Excel.
<?php // Usage: php indicadores_mes.php (previous month) // php indicadores_mes.php 2026-09 (chosen month) 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)); // last day of the month (valid until 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' => 'All departments']]; 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 opens it with the right accents fputcsv($csv, ['Department', 'Opened', 'Closed', 'Resolved by AI (%)', 'Messages received', 'First response (min)', 'Queue wait (min)', 'Average duration (min)', 'Satisfaction', 'Response rate (%)'], ';'); 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 "Generated indicadores-{$mes}.csv\n";
For the per-agent spreadsheet, use the por_atendente list from the unfiltered call: it already includes handled and closed conversations, average duration, satisfaction and ratings for each person. The rules for each number are in Metrics.
Receive webhooks with verification and a queue
Every ChatISP webhook receiver follows four rules:
- Check the signature over the raw body: the
X-ChatISP-Assinaturaheader carriessha256=followed by the HMAC SHA-256 of the body with the webhook secret. Compare in constant time. - Discard repeats by the
X-ChatISP-Entregaheader (delivery id). The same delivery may arrive again if your response gets lost. - Store and respond quickly: ChatISP waits at most 10 seconds and considers any 2xx response as delivered. Without a 2xx, it retries after 1 minute, 5 minutes, 15 minutes, 1 hour and 6 hours, and then gives up.
- Process later, in your own routine or queue. The test event (Test button) arrives with
eventoequal totesteand delivery0.
<?php // Support table (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) signature over the raw body, in constant time $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'); // Test button } // 2) and 3) durable write; the primary key rejects the same delivery twice $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 = already received: go on to the 200 http_response_code(500); // real failure: ChatISP retries exit; } } echo 'ok'; // 4) a separate routine (every minute) reads chatisp_eventos WHERE processado = 0, // ORDER BY emitido_em, entrega_id, handles each event and sets processado = 1.
const express = require('express');
const crypto = require('crypto');
const { Pool } = require('pg'); // any database works; what matters is storing before the 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 on this route only: with express.json the body would be decoded and the signature would not match
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('failed to store the delivery', entrega, erro);
res.sendStatus(500); // ChatISP retries
}
});
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() # raw bytes, before any 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 discards the repeated delivery
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", 200The available events and the content of dados in each one are in Events; signature details in Signature.
Common signature mistakes: computing the HMAC over JSON that was already decoded and serialized again (spaces and accents change), using the secret with a trailing space, or comparing without the sha256= prefix.
Send an invoice slip as PDF
Scenario: the customer asked for a copy of the invoice during the service request, or the ERP routine sends the month's boleto (Brazilian bank slip). The PDF generated by the ERP goes out on WhatsApp as a document, with a caption. The mensagens/enviar_arquivo route accepts the file as base64 inside the JSON or as a multipart/form-data upload (field arquivo), up to 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", // name the customer sees
'legenda' => "Here is your invoice for {$competencia}, in the amount of R$ {$valor}. "
. "If you prefer Pix, just reply to this message.",
]);
}
try {
$r = enviarBoleto($api, '44999990004', '/tmp/boletos/10234-2026-10.pdf', 'outubro/2026', '119,90');
echo "Sent in protocol {$r['protocolo']}, message {$r['mensagem_id']}\n";
} catch (ChatIspErro $e) {
// janela_fechada: official API outside the 24 hours; send an approved template first
// tipo_nao_suportado (415): the content is not a real PDF
// arquivo_grande (413): over 45 MB
echo "Failed: {$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"Here is your invoice for {competencia}, in the amount of R$ {valor}. "
"If you prefer Pix, just reply to this message.")
try:
r = enviar_boleto("44999990004", "/tmp/boletos/10234-2026-10.pdf", "outubro/2026", "119,90")
print("Sent in protocol", r["protocolo"])
except ChatIspErro as e:
print("Failed:", e.codigo, e.corpo.get("detalhe", ""))# Alternative without base64: multipart upload ("arquivo" field)
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=Here is your invoice for outubro/2026." \
-F "nome_arquivo=boleto-outubro-2026.pdf" \
-F "arquivo=@/tmp/boletos/10234-2026-10.pdf;type=application/pdf"- The file type is identified by the content, not by the name: a PDF goes as a document; image, audio and video go as media of the right type. Executables are rejected.
- The caption accepts up to 1,024 characters.
- Same destination rule as sending text: with the
conversa_idof an open service request, the slip goes into it; for a number without an open conversation, it starts as a closed notice and the customer's reply opens a service request. - The PDF stays in the conversation history and can be downloaded later through the
mensagens/arquivoroute.
Label and close from another system
Scenario: the customer asked for a copy of the invoice and paid by Pix. When the ERP confirms the payment, the integration finds this customer's open service request, swaps the "Aguardando pagamento" (awaiting payment) label for "Pago" (paid), leaves a note for the team and closes the service request with the right reason, sending the customer the standard closing message and the satisfaction survey.
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}" does not exist in ${rota}`);
return item.id;
}
async function pagamentoConfirmado({ telefone, valor, pagoEm }) {
// this customer's open service request (the most recent)
const { conversas } = await api.get('conversas/listar', { telefone, abertas: 1, limite: 1 });
if (conversas.length === 0) return 'no open service request';
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: `Payment of R$ ${valor} confirmed by the ERP on ${pagoEm}. Automatic unblocking in progress.`,
});
try {
await api.post('conversas/encerrar', {
conversa_id: conversa.id,
motivo_id: motivo,
enviar_mensagem: true, // false = closes silently, with no closing message or survey
});
} catch (e) {
if (!(e instanceof ChatIspErro && e.codigo === 'conversa_encerrada')) throw e; // someone closed it first
}
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/etiquetasacceptsadicionarandremover(they touch only what is sent) oretiquetas(replaces the whole list; an empty list clears it).- With
enviar_mensagemset to true (the default), closing does the same as the panel button: it sends the configured closing message and the satisfaction survey, when they are turned on and when there was actual service. - The
conversa_encerradawebhook goes out with the reason name, for the other systems that track the service request. - Cache the label and reason ids: they only change when the ISP edits the records.