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

ItemValue in the examplesReplace with
API addresshttps://atendimento.seuprovedor.com.br/api_publica.phpThe address shown in Integrations, on the ChatISP API card.
Keyenvironment variable CHATISP_CHAVEThe cisp_... key created for the integration.
Webhook secretenvironment variable CHATISP_WEBHOOK_SEGREDOThe secret of the registered webhook.
Phone numbers44999990000, 5544999990001The 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.
Idsdepartamento_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

RecipeKey permissionsWebhook
Reusable clientdepending on use
Maintenance notice by regionSend messages
Customer blocked in the ERPOpen service requests, Manage conversations, Send messages
NOC opens a service order and notifies the customerSend messages, Look up service orders, Manage service orders
Subscriber portalOpen service requests, Send messagesConversation assigned, Message sent, Conversation closed
Sync with CRM and BILook up conversationsDelivery and read (optional)
Monthly metricsMetrics
Receive webhooksany event
Invoice slip as PDFSend messages
Label and closeLook up conversations, Manage conversations

Reusable API client

A small client that gathers what every integration needs and that the other recipes use:

  • Authorization: Bearer header, route in r, JSON body for writes and query parameters for reads.
  • A response with "ok": false becomes an exception with the stable error code (error), the HTTP status and the whole body (which carries extra fields such as campo, aceitos and detalhe).
  • HTTP 429 (the key's per-minute quota) waits for the time in the Retry-After header and retries, up to 3 times.
  • A generator that walks through every page of the numbered listings.
ChatIspApi.php
<?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
# 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
// 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

SituationWhat to do
429 cota_excedidaThe 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 readYou can retry with increasing waits (5 s, 30 s, 2 min).
Network error or timeout on a writeDo 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 envioThe 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_desconectadaConfiguration error: stop the batch and notify whoever manages the integration. Retrying does not help.
Other 400 errorsError 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:

aviso_manutencao template (body with 3 variables)
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!
aviso_manutencao.php
<?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";
aviso_manutencao.py
# 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_id filled 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.

erp_cliente_bloqueado.php
<?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/abrir sends nothing to the customer: it only creates (or returns, with ja_existia equal to true) 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/enviar with conversa_id goes 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.

noc_alarme.py
# 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_id of 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, concluido comes back true and 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

portal/abrir_suporte.php (receives the form)
<?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.

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

  1. chave/info returns the server time (servidor.agora): it becomes the checkpoint for the next run, without depending on your machine's clock.
  2. conversas/listar with atualizadas_desde returns the conversations that got a new message, opened or closed since the checkpoint.
  3. For each conversation, conversas/mensagens with depois_de equal to the last id already stored returns only the new messages, in order, 100 at a time while tem_mais is true.
sincronizar_chatisp.py
# 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.
  • antigas order. 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 status change (delivered, read) does not change the conversation and does not show up in atualizadas_desde. If the BI needs it, subscribe to the Delivery and read event (mensagem_status) and update the message by id.
  • An edited or deleted message comes with editada or apagada set 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.download field 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.

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

  1. Check the signature over the raw body: the X-ChatISP-Assinatura header carries sha256= followed by the HMAC SHA-256 of the body with the webhook secret. Compare in constant time.
  2. Discard repeats by the X-ChatISP-Entrega header (delivery id). The same delivery may arrive again if your response gets lost.
  3. 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.
  4. Process later, in your own routine or queue. The test event (Test button) arrives with evento equal to teste and delivery 0.
webhook_chatisp.php
<?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.
webhook.js
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);
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()               # 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", 200

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

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

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.

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}" 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/etiquetas accepts adicionar and remover (they touch only what is sent) or etiquetas (replaces the whole list; an empty list clears it).
  • With enviar_mensagem set 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_encerrada webhook 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.