Webhooks

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:

json
{
  "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_id para 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:

json
{
  "id": 1,
  "url": "https://meuservidor.com/webhook",
  "secret": "a1b2c3d4e5f6...64_hex_chars",
  "events": "message.received,message.sent",
  "active": true
}

O secret e 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. O secret aparece somente na resposta de criação. GET, LIST e PATCH nunca o reexibem. A entrega e https only por padrão. Em runtime você pode afrouxar isso com WEBHOOK_ALLOW_INSECURE_HTTP=true ou restringir dominios com WEBHOOK_ALLOWED_DOMAINS / WEBHOOK_BLOCKED_DOMAINS.


GET/v1/webhooks#

Lista todos os webhooks da empresa.

Auth: Owner, Admin

Resposta 200:

json
[
  {
    "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:

json
{
  "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:

json
{
  "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:

json
{
  "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:

json
{
  "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=true indica que o evento bateu em SkipRetry por ser permanent_4xx após 3 tentativas — provavelmente um bug de configuração (URL errada, secret expirado, parser quebrado). has_payload_raw=false indica que o payload_raw foi 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:

json
{
  "log_ids": [123, 124, 125]
}

Modo 2 — por filtro:

json
{
  "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:

json
{
  "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):

text
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_id porque a entrega e at-least-once

Headers enviados pelo BiaZap:

text
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):

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

javascript
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:

json
{
  "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 (tipicamente event em vez de type). A correção e no consumer: leia body.type (não body.event). Cheque também se o consumer usa express.raw() (ou equivalente) para preservar os bytes exatos do body — JSON.parse + JSON.stringify muda 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.

json
{
  "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 (PENDINGAPPROVED / REJECTED).

json
{ "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.

json
{ "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).

json
{ "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.

json
{
  "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):

json
{
  "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_context sem URL Meta: quando a mensagem veio de um anúncio Click-to-WhatsApp (Instagram/Facebook), ela carrega ad_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. Use title/body para o conteúdo e ctwa_clid/ref para atribuição de campanha.

Reactions: Quando type=reaction, o campo content contem o emoji (ex: "❤️"). Um content vazio indica que a reaction foi removida. O campo reaction_target_id contem 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}.

json
{
  "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.sent com source: "external". Isso permite rastrear toda a comunicação outbound independente de onde foi originada. Mensagens enviadas pela própria BiaZap via fila tem source: "api". A persistencia no banco também distingue: a coluna source na tabela messages armazena "api" ou "external". Em mensagens externas, quoted_msg_id e reaction_target_id també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.

json
{
  "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.

json
{
  "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).

json
{
  "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.

json
{
  "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 atualizada
  • stream_error — erro no stream de comunicação com o servidor WhatsApp
  • keepalive_timeout — keepalive não recebeu resposta a tempo; conexão pode estar instável
  • keepalive_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.

json
{
  "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 action e picture_change, a BiaZap automaticamente baixa a nova foto do contato e armazena no S3/R2. Quando action e picture_remove, o cache e limpo. Clientes podem usar este evento para invalidar avatares em cache local e buscar a nova versão via GET /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.

json
{
  "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.update com status: "banned" também e emitido. Use instance.banned quando 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 tier degraded para offline).
  • instance.critical_offline — instância desconectada ha 6h ou mais (transicao do tier offline para critical_offline). Também dispara email para o owner da empresa.
  • instance.recovered — instância voltou a conectar após ter ficado em offline/critical_offline (limpa o offline_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.

json
{
  "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.

json
{ "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.

json
{ "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.

json
{ "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.

json
{ "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.

json
{ "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.

json
{ "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).

json
{ "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.

json
{ "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.

json
{ "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).

json
{ "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.

json
{ "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
json
{ "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:

text
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