Outgoing webhooks
Receive an HTTP notification in your system when something happens in customer service: a message received, sent, delivered or read; a conversation created, assigned, transferred or closed; a service order opened or updated. Each delivery is a POST with a JSON body, signed with HMAC SHA-256, and ChatISP retries on its own when your system is down.
Overview
Webhooks are the reverse path of the API: instead of your system asking ChatISP what changed, ChatISP notifies your system when it happens. Typical uses at an internet service provider (ISP):
- Record each closed conversation in the CRM, with protocol number and reason, and fetch the history through the API.
- Know when the customer replied to a notice sent by the ERP and trigger an automation.
- Track whether the bank slip sent over WhatsApp was delivered and read.
- Mirror service orders in the work order system and know when they change stage or owner.
- Feed your own dashboard with the queue and the distribution of conversations in real time.
How it works
- You register a webhook with the address of your system and the events of interest.
- When an event happens, ChatISP writes the delivery to a queue. Customer service never waits for your system: if it is slow or down, nothing changes for the team or for the customer.
- The ChatISP background service goes through the queue roughly every 20 seconds and makes the POSTs in parallel, up to 100 deliveries per pass. The delivery usually arrives a few seconds after the event.
- Your system verifies the signature, responds with HTTP 2xx and processes the event.
- If the response is not 2xx, or the connection fails, ChatISP tries again later, with increasing waits. See Retries and timing.
Requirements
- A plan with Public API and webhooks (PREMIUM, GOLD, ENTERPRISE or trial) Plan feature. On other plans, the Webhooks card appears locked.
- The Integrations permission in the access profile of the person registering it.
- An HTTP or HTTPS address that the ChatISP server can reach. In a self-hosted installation, the server needs outbound access to that address; in ChatISP Cloud, the address must be public on the internet.
There is no limit on the number of webhooks. The same event is delivered to all active webhooks subscribed to it, each with its own queue and its own attempts.
Register a webhook
- In the side menu, open Integrations.
- On the Webhooks card ("HTTP notifications to external systems when something happens in the service."), click New webhook.
- Fill in the Name (up to 120 characters), for example "CRM: conversations".
- In Address, enter the full URL that will receive the POSTs, starting with
https://(orhttp://), up to 500 characters. - Check the events. On a new webhook, Message received, Conversation created and Conversation closed come checked; the high-volume ones (Message sent and Delivery and read) stay unchecked until you need them. At least one is required.
- Click Save. The Webhook created window shows the Secret, with the Copy button. Store it in your system: you use it to verify the signature of each delivery.
whsec_4b1e9a07c3d2f68e5a90b1c2d3e4f5a6
The secret starts with whsec_, followed by 32 hexadecimal characters, and is generated by ChatISP.
The webhook list
| Column | What it shows |
|---|---|
| Name | The name and, right below it, the address (shortened; the full one appears on hover). |
| Events | One tag for each subscribed event. |
| Last delivery | Date and time of the last delivery attempt. On hover, the result appears: http 200 on success; the HTTP code or the connection error on failure. When deliveries are waiting, a highlighted number appears ("3 delivery(ies) queued"). |
| Status | Active or Inactive. |
| Actions | Edit, Test, Secret (shows the secret again) and Delete. |
Edit, pause and delete
- Edit changes name, address and events, and shows the Active switch. The secret does not change.
- Turning Active off pauses the webhook: new events are no longer queued for it, and the deliveries already in the queue are kept, without using up attempts. When you turn it back on, they go out again.
- Delete asks for confirmation ("Delete this webhook? Pending deliveries will be discarded.") and removes the webhook along with its queue.
The secret cannot be changed. To replace it (for example, if it leaked), create a new webhook with the same address and the same events, update the secret in your system and delete the old one. During the switch, your system can accept both secrets.
Events
There are 9 events, in the order shown on screen. The code is the value that arrives in the evento field and in the X-ChatISP-Evento header.
| Event | Label on screen | When it fires | Volume |
|---|---|---|---|
mensagem_recebida | Message received | For every message that arrives from the customer, on any connection and in any conversation state (queue, flow, AI or agent), of any type. | high |
mensagem_enviada | Message sent | For every message the ISP sends to the customer: agent in the panel or directly on the phone paired by QR Code, AI, flow, automatic messages, campaigns, collection schedule and API. Also when the channel rejects the send (with status erro). | high |
mensagem_status | Delivery and read | When a sent message changes to entregue, lida or erro, according to the WhatsApp receipt. | very high |
conversa_criada | Conversation created | When a new conversation is born, with a new protocol number, by any path: the customer wrote with no open conversation, the team started a conversation from the panel, a send or opening through the API, a campaign or the collection schedule. | medium |
conversa_atribuida | Conversation assigned | When an agent becomes the owner of the conversation without a transfer: took it from the queue or from the AI, started the conversation from the panel, pulled the conversation from a colleague, answered a voice call, or automatic distribution handed the conversation to them. | medium |
conversa_transferida | Conversation transferred | When the conversation is transferred to a department or agent: from the panel, by the AI, by a flow step, through the API (conversas/transferir and atendimentos/abrir), by the copilot, or when an agent replies in a conversation that was with the AI. | medium |
conversa_encerrada | Conversation closed | When a conversation is closed: by the agent, by the AI, by a flow, due to inactivity, by the copilot or through the API. | medium |
ticket_criado | Service order opened | When a service order is opened: in the panel (standalone or from a conversation), by the AI, by the copilot or through the API. | low |
ticket_atualizado | Service order updated | When a service order changes stage, is reopened, changes owner or receives a comment, by any path. | low |
Besides these, the Test button delivers the special teste event (see Test).
What does not generate an event
- The team's internal notes and the notes written through the API (
conversas/nota). - A transfer to an agent generates only
conversa_transferida, notconversa_atribuida. A transfer to a department with automatic distribution turned on, however, can generate both: firstconversa_transferida(to the queue) and thenconversa_atribuidawith"como": "roleta", when distribution hands the conversation to someone. - On service orders, editing the title, description, priority, due date or type, and attachments, do not generate
ticket_atualizado. Opening a service order that already has an owner generates onlyticket_criado. - The automatic closing of conversations opened by campaigns and by the collection schedule (the new conversation stays closed until the customer replies) does not generate
conversa_encerrada. The same closing done through the API does, with"motivo": "aviso".
Twin number
On a number connected by QR and by the official API at the same time, the same customer message can arrive through both channels. ChatISP records only one and emits only one mensagem_recebida. Events carry the conexao_id of the conversation, usually that of the main leg (see Connections).
Delivery format
Each delivery is a POST to the registered address, with a UTF-8 JSON body:
POST /chatisp/webhook HTTP/1.1
Host: crm.yourisp.com
Content-Type: application/json; charset=utf-8
X-ChatISP-Evento: conversa_encerrada
X-ChatISP-Entrega: 90412
X-ChatISP-Assinatura: sha256=5d2c0f1e8b7a69c4e3d21f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c
{"evento":"conversa_encerrada","emitido_em":"2026-10-01T10:05:12-03:00","dados":{...}}Headers
| Header | Content |
|---|---|
Content-Type | application/json; charset=utf-8 |
X-ChatISP-Evento | The event code, the same as the evento field in the body. Lets you route before decoding the JSON. |
X-ChatISP-Entrega | Unique delivery number, the same on every attempt. Use it to discard duplicates (see Idempotency). In the teste event, it is 0. |
X-ChatISP-Assinatura | sha256= followed by the HMAC SHA-256 of the body, in lowercase hexadecimal, computed with the webhook secret. See Signature. |
Body envelope
| Field | Type | Description |
|---|---|---|
evento | string | Event code. |
emitido_em | string | When the event happened, in ISO 8601 with time zone (2026-10-01T10:05:12-03:00). It does not change on retries: the resent body is identical to the original, byte for byte. |
dados | object | The event data, described in the following sections. |
Expected response from your system
- Any HTTP 2xx (200, 201, 202, 204) confirms the delivery. The response body is ignored.
- Any other code (including 3xx redirects, which are not followed), connection error, DNS error or a delay over 10 seconds counts as a failure, and the delivery goes back to the queue.
- ChatISP does not reject a self-signed certificate at your address. Even so, use a valid certificate: the body carries phone numbers, names and message content of your customers.
Respond quickly. The deliveries of each pass go out in parallel, so a slow address does not delay the others, but your own system loses the delivery if it takes more than 10 seconds, and it goes back for another attempt. Write the event to a queue on your side, respond 200 right away and process it later.
Message event data
Conversation summary (common to all message and conversation events)
| Field | Type | Description |
|---|---|---|
conversa_id | integer | Conversation identifier, for use in conversas/consultar, conversas/mensagens and the management routes. |
protocolo | string | Conversation protocol number (YYYYMMDD-NNNN). |
conexao_id | integer | Connection (WhatsApp number) of the conversation. |
telefone | string | Customer phone number, digits only, with country code. |
nome | string | Customer name in the conversation; empty when unknown. |
estado | string | Conversation state at the time of the event: fila, auto, ia, humano or encerrada. |
mensagem_recebida
The conversation summary plus the mensagem object:
| Field | Type | Description |
|---|---|---|
mensagem.id | integer | Message identifier. It is the same id as in conversas/mensagens and mensagens/arquivo. |
mensagem.tipo | string | texto, imagem, audio, video, documento, localizacao or contato. |
mensagem.conteudo | string | The text; in media, the caption (may be empty). The file does not come in the webhook: download it with mensagens/arquivo?id=. Audio arrives without the transcription, which is made afterwards and appears in conversas/mensagens. |
mensagem.recebida_em | string | Message date and time reported by WhatsApp, in ISO 8601. |
{
"evento": "mensagem_recebida",
"emitido_em": "2026-10-01T09:12:41-03:00",
"dados": {
"conversa_id": 18342,
"protocolo": "20261001-0042",
"conexao_id": 1,
"telefone": "5544999990000",
"nome": "Maria Souza",
"estado": "fila",
"mensagem": {
"id": 402850,
"tipo": "texto",
"conteudo": "My internet has been down since this morning",
"recebida_em": "2026-10-01T09:12:40-03:00"
}
}
}mensagem_enviada
The conversation summary plus the mensagem object:
| Field | Type | Description |
|---|---|---|
mensagem.id | integer | Message identifier. The subsequent mensagem_status events carry the same id. |
mensagem.autor | string | atendente (sent from the panel or typed directly on the phone paired by QR Code), ia (AI assistant) or sistema (flows, automatic messages such as closing, survey and inactivity, campaigns, collection schedule and sends through the API). |
mensagem.autor_nome | string | Agent name when the message was sent from the panel; empty in all other cases, including when autor is atendente but the message was typed directly on the phone paired by QR Code. |
mensagem.tipo | string | The same types as mensagem_recebida. |
mensagem.conteudo | string | The text as recorded in the conversation (without the agent signature); in media, the caption; in templates, the body with the variables filled in. |
mensagem.nome_arquivo | string | File name for media; empty for text. |
mensagem.status | string | enviada when the channel accepted it; erro when it rejected it immediately; pendente in the rare cases where the channel accepted it without returning an identifier. |
mensagem.erro | string | Rejection reason returned by the channel when status is erro; empty otherwise. |
mensagem.enviada_em | string | Date and time the send was recorded, in ISO 8601. |
{
"evento": "mensagem_enviada",
"emitido_em": "2026-10-01T09:14:02-03:00",
"dados": {
"conversa_id": 18342,
"protocolo": "20261001-0042",
"conexao_id": 1,
"telefone": "5544999990000",
"nome": "Maria Souza",
"estado": "humano",
"mensagem": {
"id": 402853,
"autor": "atendente",
"autor_nome": "Ana Ribeiro",
"tipo": "texto",
"conteudo": "Good morning, Maria! I am already checking your signal.",
"nome_arquivo": "",
"status": "enviada",
"erro": "",
"enviada_em": "2026-10-01T09:14:02-03:00"
}
}
}mensagem_status
The conversation summary plus a short mensagem object. It is only sent when the status advances (enviada to entregue, entregue to lida) or when the message changes to erro; repeated or late receipts that change nothing do not generate an event.
| Field | Type | Description |
|---|---|---|
mensagem.id | integer | The sent message the receipt refers to (the same id as in mensagem_enviada and in the response of the send API). |
mensagem.status | string | entregue, lida or erro. |
mensagem.em | string | When ChatISP received the receipt, in ISO 8601. |
{
"evento": "mensagem_status",
"emitido_em": "2026-10-01T09:15:30-03:00",
"dados": {
"conversa_id": 18342,
"protocolo": "20261001-0042",
"conexao_id": 1,
"telefone": "5544999990000",
"nome": "Maria Souza",
"estado": "humano",
"mensagem": {
"id": 402853,
"status": "lida",
"em": "2026-10-01T09:15:30-03:00"
}
}
}When the customer reads a message, WhatsApp also considers the previous ones in that conversation as read. ChatISP marks them all as read in the conversation, but the mensagem_status event is sent only for the message in the receipt. If you track reads per message, treat a lida as a read of everything sent earlier in the same conversation. Some people turn off read receipts in WhatsApp: in that case the status stops at entregue.
Conversation event data
All of them start with the conversation summary described in Message event data.
conversa_criada
Only the conversation summary. The estado is always fila: the event is sent at the moment of creation, before flow, AI or distribution act. When the customer opens it, the mensagem_recebida of the first message follows.
{
"evento": "conversa_criada",
"emitido_em": "2026-10-01T09:12:41-03:00",
"dados": {
"conversa_id": 18342,
"protocolo": "20261001-0042",
"conexao_id": 1,
"telefone": "5544999990000",
"nome": "Maria Souza",
"estado": "fila"
}
}conversa_atribuida and conversa_transferida
Both carry the conversation summary and who has it after the change:
| Field | Type | Description |
|---|---|---|
departamento_id | integer or null | Conversation department. |
departamento | string | Department name; empty when there is no department. |
atendente_id | integer or null | Responsible agent; null when the conversation went to the queue. |
atendente | string | Agent name; empty when in the queue. |
como | string | Only in conversa_atribuida: roleta when automatic distribution handed over the conversation; assumida in all other cases (the agent took, started or pulled the conversation). |
detalhe | string | Only in conversa_transferida: a description of the transfer for human reading, for example "Department queue: Billing", "Agent: Ana Ribeiro (Technical support)", "flow: Main menu" or "API: Billing ERP (customer asked for a copy of the bill)". The text may change; do not use it for automated decisions. |
{
"evento": "conversa_transferida",
"emitido_em": "2026-10-01T09:20:15-03:00",
"dados": {
"conversa_id": 18342,
"protocolo": "20261001-0042",
"conexao_id": 1,
"telefone": "5544999990000",
"nome": "Maria Souza",
"estado": "fila",
"departamento_id": 2,
"departamento": "Billing",
"atendente_id": null,
"atendente": "",
"detalhe": "Department queue: Billing"
}
}{
"evento": "conversa_atribuida",
"emitido_em": "2026-10-01T09:20:16-03:00",
"dados": {
"conversa_id": 18342,
"protocolo": "20261001-0042",
"conexao_id": 1,
"telefone": "5544999990000",
"nome": "Maria Souza",
"estado": "humano",
"departamento_id": 2,
"departamento": "Billing",
"atendente_id": 9,
"atendente": "Bruno Lima",
"como": "roleta"
}
}conversa_encerrada
The conversation summary (with estado encerrada) plus the motivo field:
Value of motivo | When |
|---|---|
| Name of a closing reason | Closed with a chosen reason (panel, AI or API with motivo_id). The names come from motivos/listar. |
| empty | Closed in the panel or by the AI without a reason. |
inatividade | Closed automatically because the customer did not reply. |
fluxo: <flow name> | Closed by a flow step. |
API: <key name> | Closed by conversas/encerrar without motivo_id. |
aviso | A send through the API to a number with no open conversation: the conversation is born and stays closed until the customer replies. |
| other texts | Descriptions of automatic closings (for example, by the copilot or when the AI closes the continuation of a conversation that was just closed). |
{
"evento": "conversa_encerrada",
"emitido_em": "2026-10-01T10:05:12-03:00",
"dados": {
"conversa_id": 18342,
"protocolo": "20261001-0042",
"conexao_id": 1,
"telefone": "5544999990000",
"nome": "Maria Souza",
"estado": "encerrada",
"motivo": "Problem solved"
}
}To get the history and the summary of the conversation, call conversas/consultar or conversas/mensagens with the conversa_id. The AI summary is generated a few moments after closing; if your system needs it, query with a small delay.
Service order event data
ticket_criado and ticket_atualizado carry the same service order summary, already with the values after the change:
| Field | Type | Description |
|---|---|---|
ticket_id | integer | Service order identifier, for tickets/consultar and the management routes. |
numero | string | Service order number as shown in the panel, for example OS-000123 (older service orders keep their T- number). |
titulo | string | Title. |
etapa_id | integer | Current stage (ids from tickets/etapas). |
etapa | string | Name of the current stage. |
natureza | string | Nature of the stage: inicial, andamento, aguardando, resolvido or cancelado. The last two close the service order. Use the nature, not the stage name, in your system's rules. |
prioridade | string | baixa, normal, alta or urgente. |
origem | string | How it was opened: manual (panel), chat (from a conversation), ia, copiloto or api. |
conversa_id | integer or null | Source conversation, when there is one. |
telefone | string | Customer phone number; empty when not provided. |
nome_cliente | string | Customer name; empty when not provided. |
departamento_id | integer or null | Service order department. |
responsavel_id | integer or null | Owner; null when unassigned. |
responsavel | string | Owner name; empty when unassigned. |
prazo_em | string or null | Due date in ISO 8601, when set. |
ticket_atualizado adds:
| Field | Description |
|---|---|
mudanca | What changed: etapa (moved to another stage), reaberto (left a final stage for a working one), responsavel (assigned, changed or removed) or comentario. |
comentario | Only when mudanca is comentario: the text, up to 2000 characters. Comments made through the API start with [API: key name]. |
{
"evento": "ticket_criado",
"emitido_em": "2026-10-01T09:25:40-03:00",
"dados": {
"ticket_id": 123,
"numero": "OS-000123",
"titulo": "No signal on the ONU: technical visit",
"etapa_id": 1,
"etapa": "New",
"natureza": "inicial",
"prioridade": "alta",
"origem": "ia",
"conversa_id": 18342,
"telefone": "5544999990000",
"nome_cliente": "Maria Souza",
"departamento_id": 1,
"responsavel_id": null,
"responsavel": "",
"prazo_em": "2026-10-02T18:00:00-03:00"
}
}{
"evento": "ticket_atualizado",
"emitido_em": "2026-10-01T14:02:10-03:00",
"dados": {
"ticket_id": 123,
"numero": "OS-000123",
"titulo": "No signal on the ONU: technical visit",
"etapa_id": 3,
"etapa": "In the field",
"natureza": "andamento",
"prioridade": "alta",
"origem": "ia",
"conversa_id": 18342,
"telefone": "5544999990000",
"nome_cliente": "Maria Souza",
"departamento_id": 1,
"responsavel_id": 12,
"responsavel": "Diego Technician",
"prazo_em": "2026-10-02T18:00:00-03:00",
"mudanca": "comentario",
"comentario": "[API: Work order system] Work order 55821 opened, technician on the way."
}
}When a panel user moves an unassigned service order to a working stage, they automatically become its owner. In that case a single ticket_atualizado is sent with mudanca etapa, already with responsavel filled in.
HMAC signature
Anyone who discovers your webhook address can try to send fake POSTs. The signature guarantees that the delivery came from your ChatISP and that the body was not altered:
X-ChatISP-Assinatura = "sha256=" + hex( HMAC_SHA256( key = secret, message = raw_body ) )
- Secret: the entire value shown on screen, including the
whsec_prefix. - Raw body: the exact bytes received, before any decoding. Do not decode and re-encode the JSON to compute it: the new serialization changes spaces, order or escapes (ChatISP sends accents and slashes unescaped) and the signature no longer matches.
- Compare in constant time (
hash_equalsin PHP,crypto.timingSafeEqualin Node.js,hmac.compare_digestin Python). - Missing or different signature: respond
401and discard.
<?php $segredo = getenv('CHATISP_WEBHOOK_SEGREDO'); // whsec_... $corpo = file_get_contents('php://input'); // RAW body $recebida = $_SERVER['HTTP_X_CHATISP_ASSINATURA'] ?? ''; $esperada = 'sha256=' . hash_hmac('sha256', $corpo, $segredo); if (!hash_equals($esperada, $recebida)) { http_response_code(401); exit; } $entrega = (int) ($_SERVER['HTTP_X_CHATISP_ENTREGA'] ?? 0); $evento = json_decode($corpo, true); if ($evento['evento'] !== 'teste') { $pdo = new PDO(getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS')); // unique key on "entrega": a repeat of an already stored delivery is ignored $pdo->prepare('INSERT IGNORE INTO chatisp_eventos (entrega, evento, corpo) VALUES (?, ?, ?)') ->execute([$entrega, $evento['evento'], $corpo]); } http_response_code(200); // respond now; a separate process handles the table
const express = require('express'); const crypto = require('crypto'); const SEGREDO = process.env.CHATISP_WEBHOOK_SEGREDO; // whsec_... const app = express(); function assinaturaValida(corpo, cabecalho) { const esperada = 'sha256=' + crypto.createHmac('sha256', SEGREDO).update(corpo).digest('hex'); const a = Buffer.from(esperada); const b = Buffer.from(cabecalho || ''); return a.length === b.length && crypto.timingSafeEqual(a, b); } // express.raw keeps the body as a Buffer, without decoding app.post('/chatisp/webhook', express.raw({ type: 'application/json' }), (req, res) => { if (!assinaturaValida(req.body, req.get('X-ChatISP-Assinatura'))) { return res.sendStatus(401); } const entrega = req.get('X-ChatISP-Entrega'); const evento = JSON.parse(req.body.toString('utf8')); res.sendStatus(200); // respond before processing setImmediate(() => tratar(entrega, evento)); }); app.listen(3000);
import hmac, hashlib, json, os from flask import Flask, request SEGREDO = os.environ["CHATISP_WEBHOOK_SEGREDO"].encode() # whsec_... app = Flask(__name__) def assinatura_valida(corpo: bytes, cabecalho: str) -> bool: esperada = "sha256=" + hmac.new(SEGREDO, corpo, hashlib.sha256).hexdigest() return hmac.compare_digest(esperada, cabecalho or "") @app.post("/chatisp/webhook") def receber(): corpo = request.get_data() # raw bytes, without decoding if not assinatura_valida(corpo, request.headers.get("X-ChatISP-Assinatura")): return "", 401 entrega = request.headers.get("X-ChatISP-Entrega") evento = json.loads(corpo) enfileirar(entrega, evento) # your queue (Redis, RQ, Celery, a table...) return "", 200
More complete receivers are in Practical examples.
Other recommended protections
- Use HTTPS in the webhook address.
- Use a hard-to-guess path in the URL (for example
/hooks/chatisp-8f2k1q) as an extra layer, never as a substitute for the signature. - To discard old deliveries replayed by third parties, compare
emitido_emwith your server clock, keeping in mind that legitimate retries can arrive up to about 7 and a half hours after the event. - If your firewall only allows known sources, allow the outbound IP address of the ChatISP server (in a self-hosted installation, your own server; in ChatISP Cloud, ask Ravi Systems support).
Retries and timing
When a delivery fails, ChatISP schedules a new attempt with an increasing wait. There are up to 6 attempts in total:
| Attempt | Wait after the previous failure | Approximate time since the event |
|---|---|---|
| 1st | (first pass of the queue) | seconds |
| 2nd | 1 minute | 1 minute |
| 3rd | 5 minutes | 6 minutes |
| 4th | 15 minutes | 21 minutes |
| 5th | 1 hour | 1 hour and 21 minutes |
| 6th and last | 6 hours | 7 hours and 21 minutes |
The times may stretch a little, because the queue is checked roughly every 20 seconds and each pass delivers up to 100 events. If the 6th attempt also fails, the delivery is abandoned and does not come back.
- Long outage = lost events. If your system is down for more than about 7 hours, the deliveries from that period run out. After a long outage, reconcile through the API:
conversas/listarwithatualizadas_desdeandtickets/listarreturn what changed. - Planned maintenance: pause the webhook (Active switch off). Pending deliveries are frozen without using up attempts and resume when you reactivate it. Events that happen during the pause are not queued; reconcile that interval through the API.
- Watch the queue: the highlighted number in the Last delivery column shows how many deliveries are waiting. A growing number is a sign that your system is rejecting them or is down.
Idempotency and order
Duplicates
A delivery can arrive more than once. This happens when your system processed the event but the response did not arrive in time (for example, it responded after 10 seconds, or the connection dropped before the response): ChatISP considers it a failure and tries again.
Use the X-ChatISP-Entrega header as the idempotency key:
- It is the same on every attempt of the same delivery, and the body is identical too.
- It is unique per webhook: the same event delivered to two webhooks has different delivery numbers.
- Store the numbers already processed (a table with a unique key is enough, as in the PHP example above) and ignore repeats, responding 200.
As a second line of defense, use the identifiers of the event itself: mensagem.id for messages, conversa_id together with the event type for conversations, ticket_id for service orders.
Arrival order
Under normal conditions, deliveries go out roughly in the order of the events (for example, conversa_criada before the mensagem_recebida of the first message). But there is no guarantee: events in the same second can go out in a different order, and a delivery that fails and goes to a new attempt is overtaken by the following ones. Practical rules:
- Sort by
emitido_emand by the ids, never by arrival order. - For the current state of a conversation or service order, do not accumulate events: use the event as a trigger and query the state through the API (
conversas/consultar,tickets/consultar). - On a
mensagem_status, only advance the status on your side (enviada,entregue,lida); never go back fromlidatoentreguebecause of a late event.
Test and monitor
Test button
In the webhook list, Test makes an immediate delivery of the teste event, outside the queue and without retries, with the same signature and the same headers as real deliveries (X-ChatISP-Entrega is 0):
{"evento":"teste","emitido_em":"2026-10-01T08:30:00-03:00","dados":{"webhook_id":2}}The result appears on screen:
- "Test delivery accepted (http 200)." when your system responded 2xx.
- "Test delivery failed (...)." with the HTTP code or the connection error, for example
http 401(the signature did not match on your side) or a timeout or name-not-found message.
The test works even with the webhook inactive and updates the Last delivery column. Your system should respond 2xx to the teste event and do nothing with it.
Test with real events
- Register the webhook with the desired events.
- Send a message from your own phone to the ISP's number:
conversa_criada(if there was no open conversation) andmensagem_recebidaarrive. - Reply from the panel:
conversa_atribuida(when taking the conversation),mensagem_enviadaand thenmensagem_statuswithentregueandlidaarrive. - Transfer and close the conversation to see
conversa_transferidaandconversa_encerrada. Open and move a service order to see the service order events.
No receiver ready yet
To see the real format before writing code, temporarily register the address of a request inspection service (a "request bin" type) and click Test. Then replace it with the final address. Do not leave a third-party service registered in production: real deliveries carry your customers' data.
High-volume events
mensagem_enviada and mensagem_status follow every message sent by the ISP, including those from the AI, flows, campaigns and the collection schedule. Each sent message generates one mensagem_enviada and usually one or two more mensagem_status (entregue and lida). That is why they come unchecked on a new webhook.
Delivery capacity
ChatISP delivers up to 100 events per queue pass, in parallel, and the queue is processed roughly every 20 seconds: something on the order of 300 deliveries per minute, across all webhooks. Peaks above that are not lost, but they build up a queue and arrive late. Example: a campaign of 1,000 messages with both high-volume events turned on generates about 3,000 deliveries, which take around 10 minutes to drain; during that interval, conversation events also wait in the same queue.
Recommendations
- Turn on
mensagem_enviadaandmensagem_statusonly if your system really uses this information (for example, to prove delivery of a collection notice). - Split the high-volume events and the conversation and service order events into different webhooks, pointing to different addresses. Each webhook has its own attempts, and the system that handles conversations and service orders does not need to receive and discard thousands of receipts.
- To confirm delivery of a send made through the API itself, you can also check the message
statuswithconversas/mensagensinstead of subscribing to all receipts. - Respond in milliseconds: with high volume, a slow receiver piles up timeout failures and its deliveries go back for new attempts, arriving late.
Best practices and common problems
Receiver checklist
- Validate the signature with the raw body, in constant time.
- Respond 2xx quickly, well under 10 seconds; process in your own queue, in the background.
- Discard duplicates by
X-ChatISP-Entrega. - Handle the
testeevent with 2xx and no effect. - Ignore unknown events and fields, so you keep working if ChatISP adds information.
- Do not depend on arrival order; query the API when you need the current state.
- Subscribe only to the events you need and separate the high-volume ones.
- Use HTTPS with a valid certificate.
Common problems
| Symptom | Likely cause and solution |
|---|---|
Test fails with http 401 or http 403 | Your receiver rejected it. Usually the signature was computed over re-serialized JSON, or the secret was copied without the whsec_ prefix. |
Test fails with http 301 or http 302 | The address redirects (from http:// to https://, or because of a trailing slash). Redirects are not followed: register the final address. |
| Test fails with a timeout | Your system took more than 10 seconds, or the ChatISP server cannot reach the address (firewall, internal network, DNS). |
| Test passes, but real events do not arrive | Webhook inactive, event not checked, or the tested case does not generate the event (see What does not generate an event). Remember that deliveries arrive a few seconds later. |
| The same event arrived twice | Expected behavior after a failure or slow response. Discard by X-ChatISP-Entrega. |
| Events arriving minutes late | Queue built up by high-volume events turned on during a campaign, or deliveries on retry because the receiver took more than 10 seconds. See High-volume events. |
| The number of queued deliveries keeps growing | Your system is rejecting them or is down. Check the result in Last delivery, fix it, and the next attempts deliver the backlog. |