16. Webhooks#
Webhooks permitem receber eventos em tempo real via HTTP POST no seu servidor.
Semantica de entrega: pelo menos uma vez (at-least-once). Retries do worker, falhas de rede ou retry manual podem gerar o mesmo event_id mais de uma vez. O endpoint do cliente deve ser idempotente e deduplicar por event_id (ou pelo par webhook_id + event_id).
Ordem em relação ao banco (tenant): para message.delivered, message.read e message.played, o BiaZap aplica primeiro o UPDATE na tabela messages do tenant e em seguida enfileira/dispara webhooks e streams em tempo real — o estado persistido não fica atrás do envio do evento HTTP.
POST/v1/webhooks#
Cria um novo webhook.
Auth: Owner, Admin
Request:
{
"url": "https://meuservidor.com/webhook",
"instance_id": "84c2e480-...",
"events": "message.received,message.sent",
"secret": "whsec_minha_chave_compartilhada"
}
| Campo | Tipo | Obrigatório | Descricao |
|---|---|---|---|
url |
string | sim | URL do seu endpoint (https:// por padrão) |
instance_id |
string | não | ID da instância para filtrar. Vazio/omitido = global (recebe de todas as instâncias da empresa) |
events |
string | sim | Eventos filtrados (separados por virgula). * = todos |
secret |
string | não | Secret customizado. Se omitido, a API gera um automaticamente |
Escopo do webhook — por instância vs global:
- Por instância: defina
instance_idpara receber eventos apenas daquela instância. Recomendado quando cada instância envia para um backend diferente.- Global: omita
instance_id(ou envie vazio) para receber eventos de todas as instâncias da empresa no mesmo endpoint.- Cuidado com duplicacao: se você criar um webhook global E um por instância apontando para o mesmo URL, os eventos daquela instância serao entregues duas vezes (uma por cada webhook). Prefira um ou outro.
Eventos disponíveis:
| Evento | Descrição | Persistido |
|---|---|---|
message.received |
Mensagem recebida de um contato | Sim (tabela messages) |
message.sent |
Mensagem que você enviou foi aceita pela Meta | Sim (tabela messages) |
message.delivered |
Mensagem entregue no aparelho do contato (double check) | Sim (atualiza delivered_at) |
message.read |
Mensagem lida pelo contato (blue check) | Sim (atualiza read_at) |
message.played |
Áudio (voice note) ouvido pela primeira vez no aparelho do contato | Sim (atualiza played_at) |
message.delivery_failed |
A Meta não conseguiu entregar a mensagem (motivo no payload) | Sim (atualiza status=failed) |
message.reaction_received |
Um contato reagiu a uma mensagem com emoji | Não cria linha; evento operacional |
message.interactive_reply_received |
O contato tocou num botão/item de lista ou submeteu um Flow (nfm) |
Não cria linha; evento operacional |
message.order_received |
O contato fez um pedido do catálogo (commerce) | Não cria linha; evento operacional |
call.received |
Chamada de voz recebida (Calling API) | Sim (tabela call_logs) |
call.ended |
Chamada de voz encerrada | Não cria linha; evento operacional |
official.template_status |
A Meta aprovou/rejeitou um template HSM | Não |
official.quality_update |
Mudou o quality rating do número ou o tier de limite | Não |
official.reauth_required |
O token da WABA expirou/foi revogado — reconecte | Não |
official.marketing_optout |
O contato optou por sair (ou voltar) das mensagens de marketing | Não |
official.account_alert |
Alerta da Meta sobre a conta WABA | Não |
official.account_review |
A conta WABA entrou em revisão da Meta | Não |
official.capability_update |
Mudança de capabilities/limites do número | Não |
official.template_quality |
Mudou o quality score de um template | Não |
official.template_components |
Componentes de um template foram atualizados pela Meta | Não |
official.template_category |
A Meta reclassificou a categoria de um template | Não |
official.phone_name_update |
Mudou o nome de exibição aprovado do número | Não |
official.security_update |
Aviso de segurança da conta/número | Não |
connection.update |
Mudança de status da conexão oficial | Sim (tabela instances) |
contact.update |
Mudança de perfil do contato (foto, nome) | Sim (tabela contact_events) |
contact.identity_changed |
O contato trocou de número de telefone | Não cria linha; evento operacional |
contact.temperature_changed |
O engajamento de um contato mudou de faixa (anti-spam) | Não |
anti_spam.blocked |
Um envio foi bloqueado pela proteção anti-spam | Não |
whatsapp.policy_warning |
A Meta emitiu um aviso de política/anti-abuso | Não |
instance.banned |
O número recebeu restrição da Meta | Sim (atualiza ban_expiry, ban_reason) |
instance.offline |
Conexão oficial fora do ar há 15min ou mais | Sim (campo offline_alert_level=warning) |
instance.critical_offline |
Conexão fora do ar há 6h ou mais; dispara email ao dono | Sim (campo offline_alert_level=critical) |
instance.recovered |
A conexão voltou após ficar offline | Sim (limpa offline_alert_level) |
instance.deleted |
A conexão foi removida | Sim (soft-delete da instância) |
* |
Todos os eventos | — |
Resposta 201:
{
"id": 1,
"url": "https://meuservidor.com/webhook",
"secret": "a1b2c3d4e5f6...64_hex_chars",
"events": "message.received,message.sent",
"active": true
}
O
secrete gerado automaticamente e usado para assinar os payloads via HMAC-SHA256. A URL e validada no cadastro/edicao e revalidada no momento da entrega; destinos internos ou resolvidos para rede privada são rejeitados. Osecretaparece somente na resposta de criação.GET,LISTePATCHnunca o reexibem. A entrega ehttpsonly por padrão. Em runtime você pode afrouxar isso comWEBHOOK_ALLOW_INSECURE_HTTP=trueou restringir dominios comWEBHOOK_ALLOWED_DOMAINS/WEBHOOK_BLOCKED_DOMAINS.
GET/v1/webhooks#
Lista todos os webhooks da empresa.
Auth: Owner, Admin
Resposta 200:
[
{
"id": 1,
"url": "https://meuservidor.com/webhook",
"events": "message.received,message.sent",
"active": true,
"created_at": "2026-03-07T10:00:00Z"
}
]
GET/v1/webhooks/{webhookId}#
Retorna detalhes de um webhook.
Auth: Owner, Admin
Resposta 200: Objeto webhook sem o campo secret.
PATCH/v1/webhooks/{webhookId}#
Atualiza um webhook.
Auth: Owner, Admin
Request:
{
"url": "https://novo-servidor.com/webhook",
"events": "*",
"active": false,
"secret": "whsec_rotacionado_manual"
}
Todos os campos são opcionais.
Resposta 200: Objeto webhook atualizado sem o campo secret.
DELETE/v1/webhooks/{webhookId}#
Remove um webhook.
Auth: Owner, Admin
Resposta 204: Sem corpo.
GET/v1/webhooks/{webhookId}/logs#
Lista logs de entrega do webhook.
Retencao e privacidade: registros de entrega com sucesso são removidos após 7 dias; falhas permanecem 14 dias (tarefa periodica webhook:cleanup_logs). Em cada execução, linhas com mais de 48 horas passam por redacao: o campo payload da API vira um JSON enxuto com event_type, event_id e redacted: true; copia completa do corpo enviado ao cliente e apagada no armazenamento interno; respostas HTTP muito longas são truncadas. Novas linhas já gravam em payload apenas metadados (tipo, id, tamanho do JSON). O retry manual depende dessa copia original: quando payload_raw já tiver sido limpo, o reenvio não e mais permitido.
Auth: Owner, Admin
Query Parameters:
| Parametro | Tipo | Padrão | Descricao |
|---|---|---|---|
page |
int | 1 | Página |
limit |
int | 50 | Itens por página (max 100) |
Resposta 200:
{
"data": [
{
"id": 1,
"webhook_id": 1,
"event_type": "message.received",
"payload": "{...}",
"status_code": 200,
"response": "OK",
"success": true,
"attempt": 1,
"created_at": "2026-03-07T12:00:00Z"
}
],
"total": 25,
"page": 1,
"limit": 50
}
POST/v1/webhooks/{webhookId}/logs/{logId}/retry#
Reenvia um evento que falhou.
Auth: Owner, Admin
Regras: so aceita logs com success=false e com payload_raw ainda preservado. Logs já entregues com sucesso ou já redatados retornam 409 Conflict.
Resposta 202:
{
"status": "retrying",
"event_id": "evt_xxxx"
}
GET/v1/webhooks/stuck#
Lista entregas webhook falhadas que precisam de atenção, agregadas por event_id (uma linha por evento, com a contagem total de tentativas e a classe do erro).
Auth: Owner, Admin Escopo: somente webhooks da company autenticada.
Query params (todos opcionais):
| Param | Tipo | Default | Descricao |
|---|---|---|---|
webhook_id |
int | — | Filtra por webhook especifico |
instance_id |
string | — | Estreita para webhooks dessa instância OU webhooks globais (sem instance_id, que disparam para qualquer instância). Usado pela aba Webhooks da página de detalhe da instância. |
event_type |
string | — | Filtra por tipo de evento (ex: message.received) |
class |
string | — | Filtra por classe do erro: permanent_4xx, transient_5xx, rate_limit, network, unknown |
since |
RFC3339 | now-24h |
Cutoff inferior |
page |
int | 1 | Página |
limit |
int | 50 | Itens por página (max 200) |
Resposta 200:
{
"data": [
{
"log_id": 123,
"webhook_id": 7,
"webhook_url": "https://app.exemplo.com/webhook",
"event_id": "evt-abc-123",
"event_type": "message.received",
"status_code": 503,
"response": "service unavailable",
"attempts": 25,
"error_class": "transient_5xx",
"is_hard_failed": false,
"has_payload_raw": true,
"created_at": "2026-04-08T15:30:45Z"
}
],
"total": 12,
"page": 1,
"limit": 50
}
is_hard_failed=trueindica que o evento bateu emSkipRetrypor serpermanent_4xxapós 3 tentativas — provavelmente um bug de configuração (URL errada, secret expirado, parser quebrado).has_payload_raw=falseindica que opayload_rawfoi redatado ou nunca foi salvo, e não da pra reenviar via API.
POST/v1/webhooks/retry-bulk#
Re-enfileira em batch um conjunto de entregas falhadas. Aceita dois modos:
Modo 1 — explicito por log IDs:
{
"log_ids": [123, 124, 125]
}
Modo 2 — por filtro:
{
"webhook_id": 7,
"event_type": "message.received",
"class": "transient_5xx",
"since": "2026-04-07T00:00:00Z"
}
Auth: Owner, Admin
Escopo: somente webhooks da company autenticada (logs de outros tenants são silenciosamente pulados).
Limites: máximo de 500 retries por chamada. Eventos com payload_raw vazio são pulados. Re-enfileiramento e deduplicado por event_id.
Resposta 202:
{
"requeued": 7,
"skipped": 1,
"errors": ["log 199: payload unavailable"]
}
Estrategia de retry e classificação de erros#
Quando uma entrega falha, o BiaZap classifica o erro e aplica a estrategia de retry adequada:
| Classe | Códigos | Estrategia |
|---|---|---|
permanent_4xx |
400, 401, 403, 404, 410, 422 | Hard-fail após 3 tentativas — vai pra DLQ. Quase sempre indica URL errada, secret expirado ou bug no parser do consumer. Aparece como "Eventos travados" na UI com badge vermelho. |
rate_limit |
408, 429 | Retry pelo schedule completo. |
transient_5xx |
500-599 | Retry pelo schedule completo. |
network |
sem status (DNS, refused, timeout) | Retry pelo schedule completo. Caso típico: consumer reiniciando. |
unknown |
qualquer outro | Retry pelo schedule completo. |
Schedule de retry (default WEBHOOK_MAX_RETRY=30, configuravel por env):
n=1 → 5s
n=2 → 15s
n=3 → 30s
n=4 → 1min
n=5 → 2min
n=6 → 5min
n=7 → 10min
n=8 → 15min
n=9 → 30min
n>=10 → 1h (capped)
Janela total: ~22 horas. Cobre restart de serviço, manutenção planejada, picos de carga e blips de infraestrutura sem perder eventos.
Wake-up retry on success: quando uma entrega chega com sucesso a um webhook, o BiaZap automaticamente re-enfileira todos os eventos falhados (excluindo permanent_4xx) desse mesmo webhook nas últimas 24h. Isso acelera a recuperacao quando o consumer volta de um restart — você não precisa esperar o backoff exponencial drainar. Um Redis lock por webhook (60s TTL) evita thundering herd.
Persistencia de payload_raw: entregas falhadas mantem o payload original não redatado pelo periodo de retencao completo (14 dias), de modo que retry manual via UI ou via POST /v1/webhooks/retry-bulk continue funcionando para eventos antigos.
Verificação de assinatura HMAC#
Cada requisição webhook inclui X-BiaZap-Timestamp e X-BiaZap-Signature. A assinatura HMAC-SHA256 cobre a string timestamp + "." + raw_body, usando o secret do webhook.
Validação recomendada no receiver:
- rejeite timestamps com skew maior que 5 minutos
- use comparacao constant-time (
hmac.compare_digest,crypto.timingSafeEqual,subtle.ConstantTimeCompare) - deduplicate por
event_idporque a entrega eat-least-once
Headers enviados pelo BiaZap:
Content-Type: application/json
User-Agent: BiaZap-Webhook/1.0
X-BiaZap-Timestamp: 1711035600
X-BiaZap-Signature: sha256=<hmac_hex>
Validação (exemplo em Python):
import hmac
import hashlib
import time
def verify_signature(payload_body, secret, timestamp, signature_header):
now = int(time.time())
ts = int(timestamp)
if abs(now - ts) > 300:
return False
signed = f"{timestamp}.".encode() + payload_body
expected = "sha256=" + hmac.new(
secret.encode(), signed, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header)
Validação (exemplo em Node.js):
const crypto = require('crypto');
function verifySignature(payloadBody, secret, timestamp, signatureHeader) {
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > 300) {
return false;
}
const signed = Buffer.concat([
Buffer.from(`${timestamp}.`),
Buffer.isBuffer(payloadBody) ? payloadBody : Buffer.from(payloadBody)
]);
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(signed)
.digest('hex');
if (expected.length !== signatureHeader.length) {
return false;
}
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader)
);
}
Payloads dos eventos#
Todos os eventos seguem a estrutura base:
{
"event_id": "550e8400-e29b-41d4-a716-446655440000",
"type": "message.received",
"instance_id": "84c2e480-...",
"timestamp": "2026-03-07T15:30:45.123Z",
"data": { ... }
}
| Campo do envelope | Tipo | Observacoes |
|---|---|---|
event_id |
string (UUID v4) | Identificador único da entrega. Use para deduplicacao idempotente no receiver. |
type |
string | Nome do evento (message.received, connection.update, etc.). Este e o nome do campo — não e event. |
instance_id |
string (UUID) | A instância que originou o evento. |
timestamp |
string (RFC3339) | Quando o BiaZap registrou o evento (não necessariamente quando o WhatsApp gerou). |
data |
object | Payload especifico do evento. Shape depende de type — veja os blocos abaixo. |
Troubleshooting — se o seu consumer responde
400 Bad Request: "missing event fields"ou"malformed envelope", o parser do receiver está esperando uma chave de envelope com nome diferente do que o BiaZap envia (tipicamenteeventem vez detype). A correção e no consumer: leiabody.type(nãobody.event). Cheque também se o consumer usaexpress.raw()(ou equivalente) para preservar os bytes exatos do body —JSON.parse+JSON.stringifymuda a ordem das chaves e quebra a verificação HMAC (veja seção anterior).
Eventos exclusivos do canal oficial#
Além de todos os eventos de mensagem abaixo, o canal oficial emite:
message.interactive_reply_received — o contato tocou em um botão ou item de
lista de um template/mensagem interativa. Traz a seleção estruturada para
automação, além de um message.received com o texto do botão como conteúdo.
{
"chat": "554199990000@s.whatsapp.net",
"phone": "554199990000",
"sender": "554199990000@s.whatsapp.net",
"reply_type": "button_reply",
"selected_id": "TRACK_ORDER",
"selected_display_text": "Acompanhar pedido",
"title": "",
"description": "",
"timestamp": 1784340265
}
reply_type é button_reply, list_reply ou nfm (resposta de um Flow).
Para list_reply, title e description trazem o item escolhido. Para nfm, o
payload traz native_flow_name + native_flow_params_json (o JSON do formulário
submetido pelo contato).
official.template_status — a Meta mudou o status de aprovação de um
template (PENDING → APPROVED / REJECTED).
{ "template_name": "confirmacao_pedido", "language": "pt_BR", "status": "APPROVED", "reason": "" }
official.quality_update — mudou o quality rating do número ou o tier de
limite de mensagens.
{ "display_phone_number": "+55 41 6349-0888", "quality_rating": "GREEN", "messaging_limit_tier": "TIER_1K" }
official.reauth_required — o token da WABA expirou ou foi revogado. A
conexão fica DISCONNECTED até você reconectar (Embedded Signup ou token novo).
{ "instance_id": "84c2e480-...", "reason": "token expired or revoked" }
Status de entrega das mensagens que você envia (message.sent,
message.delivered, message.read, message.delivery_failed) chegam
normalmente pelos eventos abaixo, com o message_id (UUID Catcher) para
correlação.
message.received#
Disparado quando uma mensagem e recebida no WhatsApp. Persistido na tabela messages.
Para mensagens de mídia (image, video, audio, document, sticker), o arquivo e baixado do WhatsApp, armazenado no S3 e um registro Media e criado. O campo media_id pode ser usado para baixar a mídia via GET /v1/instances/{instanceId}/media/{mediaId}.
Ecos das mensagens que você mesmo enviou não geram esse evento.
{
"phone": "554192464230",
"from": "554192464230@s.whatsapp.net",
"chat": "554192464230@s.whatsapp.net",
"message_ids": ["550e8400-e29b-41d4-a716-446655440000"],
"whatsapp_message_ids": ["3EB0ABC123DEF456"],
"type": "audio",
"content": "",
"media_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"mime_type": "audio/ogg; codecs=opus",
"file_size": 34567,
"duration": 15,
"quoted_msg_id": "d28b6749-0a5c-47bc-9c12-fd4537a5d149",
"timestamp": 1741360245,
"push_name": "Joao Silva"
}
Exemplo com contexto de anuncio (Instagram/Facebook click-to-WhatsApp):
{
"phone": "554188322497",
"from": "554188322497@s.whatsapp.net",
"chat": "554188322497@s.whatsapp.net",
"message_ids": ["ACDFD74C4368A916E0FE"],
"type": "text",
"content": "Ola! Tenho interesse no tratamento com o iModel",
"ad_context": {
"source_type": "instagram",
"source_app": "Instagram",
"source_id": "ad-123",
"title": "Tratamento iModel",
"body": "Agende sua consulta agora",
"media_type": "image",
"ctwa_clid": "ARA...",
"ref": "utm_campaign_xyz"
},
"is_forwarded": false,
"timestamp": 1741360245,
"push_name": "Patricia"
}
| Campo | Tipo | Descricao |
|---|---|---|
phone |
string | Número de telefone sem sufixo (ex: "554192464230") |
from |
string | JID do remetente (<phone>@s.whatsapp.net) |
chat |
string | JID do chat (igual a from em DM) |
message_ids |
[]string | Array com 1 UUID interno do Catcher (v4) — leia message_ids[0]. Identificador único e estável, nunca reciclado. |
whatsapp_message_ids |
[]string | ID(s) bruto(s) do WhatsApp que originaram o evento. Use apenas para auditoria/forense/correlação de baixo nível; para APIs e webhooks de negócio, prefira message_ids |
type |
string | Tipo: text, image, video, audio, document, sticker, location, contact, reaction |
content |
string | Texto, caption, ou emoji (para reactions) |
media_id |
string | ID da mídia para download via API (presente se mídia foi armazenada com sucesso) |
mime_type |
string | MIME type da mídia (ex: audio/ogg; codecs=opus, image/jpeg) |
file_name |
string | Nome do arquivo (presente para documentos) |
file_size |
uint64 | Tamanho do arquivo em bytes (do proto WhatsApp) |
duration |
uint32 | Duração em segundos (para audio e video) |
quoted_msg_id |
string | UUID BiaZap (v4) da mensagem sendo respondida. Presente apenas em respostas/reply threading. Vazio se a mensagem citada não estiver no tenant DB (ex: reply a mensagem antiga de antes da instância entrar no chat). Nunca e o hex do WhatsApp — use este campo para correlacionar com message_ids[0] de eventos anteriores. |
reaction_target_id |
string | UUID BiaZap (v4) da mensagem que recebeu a reaction (apenas para type=reaction). Vazio se o alvo não estiver no tenant DB. |
ad_context |
object | Contexto de anuncio do Instagram/Facebook (presente apenas quando a mensagem originou de um click-to-WhatsApp ad). Ver campos abaixo |
is_forwarded |
bool | true se a mensagem foi encaminhada. Omitido quando false |
latitude / longitude |
float64 | Coordenadas — presentes apenas quando type=location (localização compartilhada, ex.: resposta a um pedido de localização). O nome do lugar (quando houver) vem em content |
location_address |
string | Endereço da localização compartilhada (apenas type=location, quando informado) |
timestamp |
int64 | Unix timestamp |
push_name |
string | Nome do remetente no WhatsApp |
Campos de ad_context (todos opcionais, presentes conforme disponível):
| Campo | Tipo | Descricao |
|---|---|---|
source_type |
string | Plataforma de origem (ex: "instagram", "facebook") |
source_app |
string | Nome do app de origem (ex: "Instagram") |
source_id |
string | Identificador do anuncio na plataforma |
title |
string | Titulo/headline do anuncio |
body |
string | Texto descritivo do anuncio |
media_type |
string | Tipo de mídia do anuncio: "image", "video", ou vazio |
ctwa_clid |
string | Click-to-WhatsApp Client ID (tracking) |
ref |
string | Parametro de referência/tracking (ex: UTM campaign) |
ad_contextsem URL Meta: quando a mensagem veio de um anúncio Click-to-WhatsApp (Instagram/Facebook), ela carregaad_context(source_type,source_id,title,body,media_type,ctwa_clid) com apenas texto e tracking. As URLs de mídia do anúncio e o permalink do post são intencionalmente removidos — a Catcher nunca expõe uma URL de CDN da Meta. Usetitle/bodypara o conteúdo ectwa_clid/refpara atribuição de campanha.
Reactions: Quando
type=reaction, o campocontentcontem o emoji (ex:"❤️"). Umcontentvazio indica que a reaction foi removida. O camporeaction_target_idcontem o ID da mensagem que recebeu a reaction.
message.sent#
Disparado após envio bem-sucedido de qualquer mensagem via API. Persistido na tabela messages.
Para mensagens de mídia, inclui media_id para download via GET /v1/instances/{instanceId}/media/{mediaId}.
{
"phone": "554192464230",
"to": "554192464230@s.whatsapp.net",
"chat": "554192464230@s.whatsapp.net",
"message_ids": ["550e8400-e29b-41d4-a716-446655440001"],
"whatsapp_message_ids": ["3EB0DEF456ABC123"],
"type": "image",
"content": "Foto do produto",
"media_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"mime_type": "image/jpeg",
"file_name": "produto.jpg",
"file_size": 123456,
"source": "api",
"idempotency_key": "msg-2026-03-21-0001",
"quoted_msg_id": "3EB0AAA111BBB222",
"timestamp": 1741360300
}
| Campo | Tipo | Descricao |
|---|---|---|
phone |
string | Número de telefone sem sufixo (ex: "554192464230") |
to |
string | JID do destinatário (<phone>@s.whatsapp.net) |
chat |
string | JID do chat (igual a to em DM) |
message_ids |
[]string | Array com 1 UUID interno do Catcher (v4) — leia message_ids[0]. Identificador único e estável, nunca reciclado. message_ids[0] e o mesmo UUID retornado em message_id na resposta 202 do endpoint de envio — use este valor para correlacionar o webhook com a request original. |
whatsapp_message_ids |
[]string | ID(s) bruto(s) do WhatsApp retornados pelo envio. Útil para auditoria/correlação de baixo nível; não substitui o UUID Catcher em message_ids |
type |
string | Tipo da mensagem (text, image, video, audio, document, sticker, location, contact, reaction, template) |
content |
string | Conteúdo/caption da mensagem, ou emoji (para reactions) |
source |
string | Origem do envio: "api" (enviado pela BiaZap) ou "external" (enviado pelo WhatsApp Web, telefone ou outra API) |
idempotency_key |
string | Eco do header Idempotency-Key da request original. Presente apenas quando source="api". Util para casar o webhook com a request do cliente quando multiplas requests compartilham o mesmo destinatário no curto prazo. Ausente em outbound externo (WhatsApp Web/celular/outra API). |
media_id |
string | ID da mídia para download via API (presente para mensagens de mídia) |
mime_type |
string | MIME type da mídia |
file_name |
string | Nome do arquivo |
file_size |
int64 | Tamanho do arquivo em bytes |
quoted_msg_id |
string | UUID BiaZap (v4) da mensagem sendo respondida. Presente apenas em respostas/reply threading. Vazio se a mensagem citada não estiver no tenant DB (ex: reply a mensagem antiga de antes da instância entrar no chat). Nunca e o hex do WhatsApp — use este campo para correlacionar com message_ids[0] de eventos anteriores. |
reaction_target_id |
string | UUID BiaZap (v4) da mensagem que recebeu a reaction (apenas para type=reaction). Vazio se o alvo não estiver no tenant DB. |
timestamp |
int64 | Unix timestamp do envio |
Captura de outbound externo: Mensagens enviadas por WhatsApp Web, pelo celular ou por outra API conectada ao mesmo número são capturadas automaticamente como
message.sentcomsource: "external". Isso permite rastrear toda a comunicação outbound independente de onde foi originada. Mensagens enviadas pela própria BiaZap via fila temsource: "api". A persistencia no banco também distingue: a colunasourcena tabelamessagesarmazena"api"ou"external". Em mensagens externas,quoted_msg_idereaction_target_idtambém são preservados quando presentes.
message.delivered#
Disparado quando o destinatário recebe a mensagem (dois checks cinza). Atualiza status=delivered e delivered_at na tabela messages.
{
"phone": "554192464230",
"message_ids": ["550e8400-e29b-41d4-a716-446655440001", "550e8400-e29b-41d4-a716-446655440002"],
"chat": "554192464230@s.whatsapp.net",
"sender": "554192464230@s.whatsapp.net",
"whatsapp_message_ids": ["3EB0DEF456ABC123", "3EB0DEF456ABC124"],
"status": "delivered",
"timestamp": 1741360310
}
| Campo | Tipo | Descricao |
|---|---|---|
phone |
string | Número de telefone sem sufixo (ex: "554192464230") |
message_ids |
[]string | UUIDs internos do Catcher. Identificadores únicos e estáveis. |
whatsapp_message_ids |
[]string | ID(s) bruto(s) do WhatsApp recebidos no receipt. Pode ter múltiplos elementos quando o WhatsApp agrega receipts |
chat |
string | JID do chat (<phone>@s.whatsapp.net) |
sender |
string | JID de quem recebeu (<phone>@s.whatsapp.net) |
status |
string | Sempre "delivered" |
timestamp |
int64 | Unix timestamp da entrega |
message.read#
Disparado quando o destinatário le a mensagem (dois checks azuis). Atualiza status=read e read_at na tabela messages.
{
"phone": "554192464230",
"message_ids": ["550e8400-e29b-41d4-a716-446655440001"],
"chat": "554192464230@s.whatsapp.net",
"sender": "554192464230@s.whatsapp.net",
"whatsapp_message_ids": ["3EB0DEF456ABC123"],
"status": "read",
"timestamp": 1741360350
}
| Campo | Tipo | Descricao |
|---|---|---|
phone |
string | Número de telefone sem sufixo (ex: "554192464230") |
message_ids |
[]string | UUIDs internos do Catcher. Identificadores únicos e estáveis. |
whatsapp_message_ids |
[]string | ID(s) bruto(s) do WhatsApp recebidos no receipt |
chat |
string | JID do chat (<phone>@s.whatsapp.net) |
sender |
string | JID de quem leu (<phone>@s.whatsapp.net) |
status |
string | Sempre "read" |
timestamp |
int64 | Unix timestamp da leitura |
message.played#
Disparado quando o contato ouve pela primeira vez um áudio (voice note) que você enviou. Atualiza status=played e played_at na tabela messages. É a prova de que a mídia foi consumida (o salto delivered → played).
{
"phone": "554192464230",
"message_ids": ["550e8400-e29b-41d4-a716-446655440001"],
"chat": "554192464230@s.whatsapp.net",
"status": "played",
"timestamp": 1741360400
}
| Campo | Tipo | Descricao |
|---|---|---|
phone |
string | Número de telefone sem sufixo |
message_ids |
[]string | UUIDs internos do Catcher. Identificadores únicos e estáveis. |
chat |
string | JID do chat (<phone>@s.whatsapp.net) |
status |
string | Sempre "played" |
timestamp |
int64 | Unix timestamp do play |
connection.update#
Disparado quando o status da conexão WhatsApp muda. Persistido na tabela instances.
{
"status": "connected",
"reason": "",
"ban_expiry": null
}
| Campo | Tipo | Descricao |
|---|---|---|
status |
string | "connected", "disconnected", "banned", "logged_out", "replaced", "connect_failure", "client_outdated", "stream_error", "keepalive_timeout", "keepalive_restored" |
reason |
string | Motivo do ban ou da desconexao (quando disponível) |
ban_expiry |
int64/null | Unix timestamp de quando o ban expira (apenas para "banned") |
Novos status:
connect_failure— falha ao conectar ao servidor WhatsApp (ex: erro de rede, autenticação falhou)client_outdated— versão do cliente WhatsApp está desatualizada e precisa ser atualizadastream_error— erro no stream de comunicação com o servidor WhatsAppkeepalive_timeout— keepalive não recebeu resposta a tempo; conexão pode estar instávelkeepalive_restored— keepalive voltou a funcionar normalmente após um timeout
contact.update#
Disparado quando a foto de perfil, nome ou status de um contato muda. Persistido na tabela contact_events.
{
"phone": "554192464230",
"jid": "554192464230@s.whatsapp.net",
"action": "picture_change",
"value": "j+rE",
"timestamp": 1741360700
}
| Campo | Tipo | Descricao |
|---|---|---|
phone |
string | Número de telefone sem sufixo |
jid |
string | JID do contato (<phone>@s.whatsapp.net) |
action |
string | Tipo da mudanca (veja tabela abaixo) |
value |
string | Valor novo (ID/hash da foto, novo nome, etc.) |
old_value |
string | Valor anterior (presente apenas para push_name_change e business_name_change) |
timestamp |
int64 | Unix timestamp |
Ações possíveis:
| Action | Descricao | value |
old_value |
|---|---|---|---|
picture_change |
Foto de perfil alterada | Picture ID ou hash | - |
picture_remove |
Foto de perfil removida | - | - |
about_change |
Status/about alterado | Novo status | - |
push_name_change |
Nome de exibição alterado | Novo nome | Nome anterior |
business_name_change |
Nome comercial alterado | Novo nome | Nome anterior |
Avatar cache: Quando
actionepicture_change, a BiaZap automaticamente baixa a nova foto do contato e armazena no S3/R2. Quandoactionepicture_remove, o cache e limpo. Clientes podem usar este evento para invalidar avatares em cache local e buscar a nova versão viaGET /v1/instances/{instanceId}/contacts/{phone}/avatar.
instance.banned#
Disparado quando a instância recebe um ban temporario ou permanente do WhatsApp. Persistido nos campos ban_expiry e ban_reason da instância. Diferente de connection.update com status: "banned", este evento traz informações detalhadas do ban.
{
"instance_id": "84c2e480-...",
"permanent": false,
"reason": "spam",
"ban_expiry": 1741446000
}
| Campo | Tipo | Descricao |
|---|---|---|
instance_id |
string | ID da instância banida |
permanent |
bool | true = ban permanente, false = temporario |
reason |
string | Motivo do ban (quando disponível) |
ban_expiry |
int64/null | Unix timestamp de quando o ban expira (null se permanente) |
Nota: O evento
connection.updatecomstatus: "banned"também e emitido. Useinstance.bannedquando precisar de detalhes do ban (permanencia, motivo, expiracao).
instance.offline / instance.critical_offline / instance.recovered#
Eventos do monitor de saúde (internal/health/monitor.go). Disparados quando uma instância cruza um dos thresholds de tempo offline:
instance.offline— instância desconectada ha 15min ou mais (transicao única do tierdegradedparaoffline).instance.critical_offline— instância desconectada ha 6h ou mais (transicao do tierofflineparacritical_offline). Também dispara email para o owner da empresa.instance.recovered— instância voltou a conectar após ter ficado emoffline/critical_offline(limpa ooffline_alert_level).
Cada evento e disparado exatamente uma vez por transicao. O monitor armazena o nivel atual em offline_alert_level na linha da instância para evitar re-emissoes a cada scan (60s). Instâncias com desired_state=DISCONNECTED (pausadas manualmente) nunca disparam estes eventos.
{
"instance_id": "84c2e480-...",
"name": "EJ Whats",
"phone": "554192464230",
"tier": "critical_offline",
"previous_tier": "offline",
"status": "DISCONNECTED",
"desired_state": "CONNECTED",
"disconnected_at": "2026-04-06T21:44:00Z",
"last_activity_at": "2026-04-06T21:42:18Z",
"offline_duration_seconds": 158400,
"offline_duration_human": "1d 20h",
"reason": "",
"timestamp": 1744156800
}
| Campo | Tipo | Descricao |
|---|---|---|
instance_id |
string | ID da instância |
name |
string | Nome configurado da instância |
phone |
string | Telefone E.164 da instância (vazio se nunca conectou) |
tier |
string | Tier no momento da emissao: offline, critical_offline, healthy, stale, etc. |
previous_tier |
string | Tier antes da transicao (util para correlacionar) |
status |
string | Status WhatsApp bruto: CONNECTED, DISCONNECTED, etc. |
desired_state |
string | CONNECTED (queremos restaurar) ou DISCONNECTED (pausada) |
disconnected_at |
string/null | Timestamp ISO-8601 da primeira deteccao do drop. Preserva o tempo original em flapping. |
last_activity_at |
string/null | Último heartbeat (mensagem inbound, send outbound, ou receipt processado) |
offline_duration_seconds |
int64 | Duração calculada no momento da emissao |
offline_duration_human |
string | Versão formatada ("2d 4h", "30m") para display direto |
reason |
string | Conteúdo de last_error se houver |
timestamp |
int64 | Unix seconds da emissao |
Configuração: Os thresholds (15min e 6h) são constantes no código para garantir visibilidade em code review.
Payloads adicionais de eventos#
Os eventos abaixo também fazem parte do conjunto de eventos. Os exemplos usam o envelope real {type,data}.
instance.deleted#
Campos: instance_id, phone, timestamp.
{ "type": "instance.deleted", "data": { "instance_id": "84c2e480-...", "phone": "554137984905", "timestamp": 1741361650 } }
message.reaction_received#
Campos: chat, phone, sender, envelope_whatsapp_message_id, target_whatsapp_message_id, target_from_me, emoji, timestamp.
{ "type": "message.reaction_received", "data": { "chat": "554137984905@s.whatsapp.net", "phone": "554137984905", "sender": "554137984905@s.whatsapp.net", "envelope_whatsapp_message_id": "3EB0REACTION", "target_whatsapp_message_id": "3EB0TARGET", "target_from_me": true, "emoji": "👍", "timestamp": 1741361650 } }
message.delivery_failed#
Campos: chat, phone, message_id, whatsapp_message_id, idempotency_key, task_id, message_type, reason, last_error, attempt_count, timestamp.
{ "type": "message.delivery_failed", "data": { "chat": "554137984905@s.whatsapp.net", "phone": "554137984905", "message_id": "550e8400-e29b-41d4-a716-446655440000", "idempotency_key": "send-001", "task_id": "asynq:task:abc", "message_type": "text", "reason": "retry_exhausted", "last_error": "timeout", "attempt_count": 5, "timestamp": 1741361650 } }
contact.temperature_changed#
Campos: remote_jid, phone, old_tier, new_tier, old_score, new_score, direction, trigger, timestamp.
{ "type": "contact.temperature_changed", "data": { "remote_jid": "554137984905@s.whatsapp.net", "phone": "554137984905", "old_tier": "cold", "new_tier": "warm", "old_score": 25, "new_score": 42, "direction": "inbound", "trigger": "message.received", "timestamp": 1741361650 } }
anti_spam.blocked#
Campos: remote_jid, phone, rule, detail, message_type, bypass_available, bypass_quota_left, content_hash, timestamp.
{ "type": "anti_spam.blocked", "data": { "remote_jid": "554137984905@s.whatsapp.net", "phone": "554137984905", "rule": "duplicate_content", "detail": "same content sent in rolling 24h", "message_type": "text", "bypass_available": true, "bypass_quota_left": 2, "content_hash": "sha256:abc", "timestamp": 1741361650 } }
whatsapp.policy_warning#
Campos: warning_code, category, message, expires_at, severity_hint, recommended_action, timestamp.
{ "type": "whatsapp.policy_warning", "data": { "warning_code": "rate_limit", "category": "anti_abuse", "message": "rate limit warning", "expires_at": "2026-05-21T13:00:00Z", "severity_hint": "warn", "recommended_action": "slow_down", "timestamp": 1741361650 } }
message.order_received#
Campos: chat, phone, sender, order_id, item_count, total_amount_1000, currency, timestamp. Evento operacional (não cria linha) — o contato fez um pedido do catálogo (commerce). total_amount_1000 é o valor total em milésimos da moeda (ex.: 45990 = 45,99 BRL).
{ "type": "message.order_received", "data": { "chat": "554199990000@s.whatsapp.net", "phone": "554199990000", "sender": "554199990000@s.whatsapp.net", "order_id": "SMB123456", "item_count": 3, "total_amount_1000": 45990, "currency": "BRL", "timestamp": 1741361650 } }
contact.identity_changed#
Campos: phone, jid, timestamp. O contato trocou de número de telefone (system message customer_changed_number). Use para reconciliar o contato com o novo jid.
{ "type": "contact.identity_changed", "data": { "phone": "554199991111", "jid": "554199991111@s.whatsapp.net", "timestamp": 1741361650 } }
call.received#
Campos: phone, from, call_id, state, timestamp. Chamada de voz recebida (Calling API, evento connect). Persistida em call_logs. O SDP offer nunca é exposto no payload — só o sinal.
{ "type": "call.received", "data": { "phone": "554199990000", "from": "554199990000@s.whatsapp.net", "call_id": "wacid.ABCD1234", "state": "ringing", "timestamp": 1741361650 } }
call.ended#
Campos: phone, from, call_id, reason, duration, timestamp. Chamada de voz encerrada (evento terminate). duration em segundos (0 se não foi atendida).
{ "type": "call.ended", "data": { "phone": "554199990000", "from": "554199990000@s.whatsapp.net", "call_id": "wacid.ABCD1234", "reason": "completed", "duration": 42, "timestamp": 1741361650 } }
official.marketing_optout#
Campos: phone, from, category, opted_out, value, timestamp. O contato mudou a preferência de marketing (webhook user_preferences). value é stop (opt-out, opted_out=true) ou resume (opt-in de volta, opted_out=false). Envios de template category:"marketing" para um contato com opted_out=true são bloqueados com 409 BLOCKED_MARKETING_OPTOUT.
{ "type": "official.marketing_optout", "data": { "phone": "554199990000", "from": "554199990000@s.whatsapp.net", "category": "marketing_messages", "opted_out": true, "value": "stop", "timestamp": 1741361650 } }
Eventos de observabilidade da conta (official.*)#
Oito eventos de saúde da conta/número/template usam o payload genérico { field, detail, timestamp }, onde field é o campo bruto do webhook da Meta e detail traz o valor decodificado. Todos carregam apenas estado da conta, nunca mídia/URL — use para dashboards de saúde da WABA e alertas operacionais.
field do webhook Meta |
Evento |
|---|---|
account_alerts |
official.account_alert |
account_review_update |
official.account_review |
business_capability_update |
official.capability_update |
message_template_quality_update |
official.template_quality |
message_template_components_update |
official.template_components |
template_category_update |
official.template_category |
phone_number_name_update |
official.phone_name_update |
security |
official.security_update |
{ "type": "official.template_quality", "data": { "field": "message_template_quality_update", "detail": { "message_template_name": "confirmacao_pedido", "message_template_language": "pt_BR", "new_quality_score": "GREEN" }, "timestamp": 1741361650 } }
Persistencia de eventos#
Eventos são persistidos no histórico operacional do tenant quando passam pelo pipeline de realtime/webhooks. Eventos operacionais (connection.update, contact.update, official.template_status, official.quality_update, contact.temperature_changed, whatsapp.policy_warning) não criam/alteram linhas em messages — são entregues via webhook/SSE/WebSocket e consultáveis pelo histórico de eventos quando habilitado.
Ciclo de vida da mensagem na tabela messages:
queued → sent → delivered → read
| Campo | Preenchido quando |
|---|---|
queued_at |
Mensagem entra na fila |
sent_at |
Enviada com sucesso ao WhatsApp |
delivered_at |
Destinatário recebeu (receipt type=delivered) |
read_at |
Destinatário leu (receipt type=read) |
played_at |
Destinatário tocou a mídia (áudio/vídeo) |
failed_at |
Falha no envio |
Tabelas de eventos:
| Tabela | Evento | Descricao |
|---|---|---|
messages |
message.received, message.sent, message.delivered, message.read, message.played | Todas as mensagens inbound/outbound com tracking completo |
contact_events |
contact.update | Mudancas em contatos |