Pular para o conteúdo

Eventos via webhook

O Turbo Notify notifica seu sistema sobre eventos (envio confirmado, entrega, leitura, mensagem recebida, reação, falha) via webhook (HTTP POST). O objeto data de cada evento é idêntico ao retornado pelo endpoint GET /events.

Se quem responde é um agente de IA, a combinação comum é: o webhook entrega o evento à sua aplicação, e o agente age pelas ferramentas do Servidor MCP (por exemplo send_text). Sem endpoint público, o agente pode ler os mesmos eventos com a ferramenta list_events.

Recebimento dos eventos

Para começar a receber eventos, cadastre a URL do seu webhook no dashboard (abre em nova aba) . A URL deve ser pública, acessível via HTTPS e responder com status 2xx dentro de 10 segundos.

Privacidade

Não ponha informações sensíveis, como chaves de API ou identificadores internos, na URL do seu webhook. Use caminhos genéricos e valide cada requisição pelo cabeçalho de assinatura.

Segurança

Toda requisição de webhook enviada pelo Turbo Notify inclui os cabeçalhos abaixo. Use a assinatura para confirmar que a requisição veio do Turbo Notify e não de terceiros.

CabeçalhoDescrição
X-Webhook-Signaturesha256=<hex>: HMAC-SHA256 de {X-Webhook-Timestamp}.{raw_body} com seu segredo.
X-Webhook-TimestampUnix epoch (segundos): utilize para rejeitar entregas com mais de ~5 minutos (proteção contra replay).
X-Webhook-AttemptNúmero da tentativa atual (começa em 1, incrementa a cada reentrega).
X-Webhook-Max-AttemptsTotal de tentativas que o Turbo Notify fará antes de desistir.
User-AgentTurboNotify-Webhook/1.0: útil para liberar as requisições no seu firewall de aplicação (WAF).

O segredo é definido por você, no dashboard (abre em nova aba) , no mesmo formulário em que você cadastra a URL. O Turbo Notify não gera um segredo: escolha um valor aleatório e longo, guarde-o na sua aplicação e informe o mesmo valor nos dois lados.

Exemplo de validação da assinatura:

validacao.py
import hmac
import hashlib
def verificar_assinatura(payload: bytes, timestamp: str, assinatura: str, segredo: str) -> bool:
dados_assinados = f"{timestamp}.".encode() + payload
esperado = "sha256=" + hmac.new(
segredo.encode(),
dados_assinados,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(esperado, assinatura)
# Uso:
# timestamp = request.headers["X-Webhook-Timestamp"]
# assinatura = request.headers["X-Webhook-Signature"]
# verificar_assinatura(request.body, timestamp, assinatura, segredo)
validacao.js
const crypto = require('crypto');
function verificarAssinatura(payload, timestamp, assinatura, segredo) {
const dadosAssinados = `${timestamp}.${payload}`;
const esperado = 'sha256=' + crypto
.createHmac('sha256', segredo)
.update(dadosAssinados)
.digest('hex');
const esperadoBuf = Buffer.from(esperado);
const assinaturaBuf = Buffer.from(assinatura ?? '');
if (esperadoBuf.length !== assinaturaBuf.length) return false;
return crypto.timingSafeEqual(esperadoBuf, assinaturaBuf);
}
// Uso:
// const timestamp = req.headers['x-webhook-timestamp'];
// const assinatura = req.headers['x-webhook-signature'];
// verificarAssinatura(req.body, timestamp, assinatura, segredo);

Estrutura do evento

Toda requisição de webhook segue a mesma estrutura base:

PropriedadeTipoDescrição
idstringIdentificador único do evento (evt_…). Use como chave de idempotência.
typestringTipo do evento (ex.: message.sent).
organization_idstringIdentificador público da sua organização (org_{hex32}). Sempre presente.
number_aliasstringIdentificador do número Turbo Notify ao qual o evento pertence: o próprio alias, "main" para o número principal ou o alias de um número extra. Presente em eventos ligados a um número específico: message.*, number.*, group.*, todos os contact.* (inclusive contact.quota.*) e message.quota.* (a cota de mensagens é por número). Omitido em storage.* e em media.unavailable, os eventos que não pertencem a um número único.
number_idstringIdentificador do número (num_…), logo depois do number_alias. Presente exatamente quando number_alias está presente, e nunca muda: é a chave para guardar. O number_alias do evento é o alias que o número tinha no momento em que o evento aconteceu, então depois de uma renomeação eventos antigos e novos do mesmo número trazem aliases diferentes e o mesmo number_id.
created_atstringMomento de emissão do evento pelo Turbo Notify, distinto de data.at (momento da ação no WhatsApp). ISO 8601 em UTC com sufixo Z e precisão de segundos (ex.: 2026-06-01T13:00:05Z).
dataobjectDados específicos do evento (veja Campos da mensagem).

Campos da mensagem

Todos os eventos message.* compartilham os campos abaixo em data:

CampoTipoDescrição
idstring | nullIdentificador público da mensagem (msg_{hex32}). null apenas em message.send.failed (registro não criado).
fromobjectQuem originou o evento. Pessoa (inbound): {contact_id?, name?, number}. Outbound: {contact_id?, name?, alias, number}.
toobjectDestinatário. 1:1 inbound: {contact_id?, name?, alias, number}, o alias é sempre presente e identifica o seu número que recebeu. 1:1 outbound: {contact_id?, name?, number}. Grupo: {group_id, name?}.
previewstringRecorte do conteúdo (até 50 caracteres). Veja Preview por tipo.
content_typestringtext, image, audio, video, document, sticker, location, calendar, contact, poll, interactive. Mensagens de tipo desconhecido chegam como unsupported. Vem como "" (string vazia) em message.deleted, message.edit.failed e message.delete.failed: essas operações não repetem o tipo do conteúdo original.
directionstring"outbound" (ação da sua organização, pela API ou pelo celular) ou "inbound" (ação do contato, vinda do WhatsApp).
channelstringSempre "whatsapp".
originstring"tn" (ação originada via API do Turbo Notify) ou "native" (originada no canal WhatsApp). Eventos inbound (message.received/delivered/read) são sempre "native". Sempre presente.
atstringISO 8601 em UTC com sufixo Z e precisão de segundos (ex.: 2026-06-01T13:00:05Z): momento da ação real no WhatsApp (envio, entrega, leitura, edição, etc.). Distinto de created_at (momento de emissão pelo Turbo Notify).
mentionsarrayOpcional: presente apenas quando a mensagem menciona alguém, e apenas nos eventos que carregam o corpo da mensagem: message.received, message.replied, message.sent, message.edited. Itens {contact_id?, name?, number} (mesmo formato do from inbound), na ordem dos tokens no texto, sem duplicatas. Um @<digitos> no preview/texto só é menção se o número constar aqui. Ausente em delivered/read/deleted/reacted e nos *.failed. Em message.edited, é o conjunto re-extraído do novo texto (ou da nova legenda). Veja Menções (@mentions).
canceledbooleanOpcional: true apenas em message.edited de um evento de calendário cancelado. Ausente em todos os outros casos. Veja Eventos de calendário.

Ordem dos campos de from/to: sempre contact_id, name, alias, number (alias onde existir; campos opcionais omitidos quando null). O to de grupo é a exceção: mantém {group_id, name?}. O seu número envolvido no evento é identificado pelo number_alias do envelope. created_at (envelope) é o momento de emissão do evento; at (em data) é o momento da ação no WhatsApp: valores distintos.

name em from/to é o push name (nome de exibição) configurado pelo contato no WhatsApp. O Turbo Notify nunca infere ou busca isso em uma agenda de contatos. Ele fica disponível quando o contato já enviou pelo menos uma mensagem inbound para a sua organização (é assim que o WhatsApp entrega o push name), ou quando o número de destino é ele mesmo um número Turbo Notify de outra organização com o nome já conhecido. Fora esses dois casos, em um message.sent para alguém que nunca apareceu em nenhuma dessas fontes, to.name vem omitido: não é um erro; trate a ausência como “nome ainda desconhecido”, não como falha de entrega.

Eventos message.*.failed adicionam um objeto error estruturado: o mesmo formato do status.error retornado por GET /v1/numbers/{ref}/messages/{id} e GET /v1/numbers/{ref}/messages/{id}/status, então o evento e a consulta dizem a mesma coisa:

CampoTipoDescrição
errorobjectErro da falha. Presente em todos os eventos *.failed.
error.codestringCódigo estável legível por máquina: faça branch neste valor (ex.: media_fetch_failed, unsupported_media_format).
error.messagestringDescrição legível da falha.
error.hintstringOrientação acionável para corrigir, derivada do code. Omitido (chave ausente, nunca null) quando não há orientação para o code.

Preview por tipo

content_typePreview
textTexto truncado
imageLegenda ou [Image]
sticker[Sticker]
videoLegenda ou [Video]
audioTranscrição (quando disponível) → legenda → [Audio]
documentLegenda → nome do arquivo → [Document]
locationTexto legível sem colchetes, ex.: Av. Paulista, Sao Paulo, SP
calendarSem colchetes: dia da semana e mês abreviados em inglês, horário em UTC. Ex.: Reunião — Tue, 2 Jun 2026 14:00
contact[Contact: nome] ou [Contact] quando o cartão não traz nome
poll"" (string vazia)
interactiveTexto do corpo (body) da mensagem interativa, ou [Interactive] quando o corpo está vazio
unsupported"" (string vazia): tipo de mensagem não reconhecido pelo Turbo Notify

Exemplo: message.sent (outbound)

webhook-payload.json
{
"id": "evt_b7adf529683940edcdc533081ae182c5",
"type": "message.sent",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-06-01T13:00:06Z",
"data": {
"id": "msg_01993c5a1b24d3e9f01a2b3c4d5e6f70",
"from": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" },
"to": { "name": "João Silva", "number": "5511977776666" },
"preview": "Sua mensagem...",
"content_type": "text",
"direction": "outbound",
"channel": "whatsapp",
"origin": "tn",
"at": "2026-06-01T13:00:05Z"
}
}

created_at (13:00:06, emissão pelo Turbo Notify) é distinto de data.at (13:00:05, confirmação de envio no WhatsApp). O evento foi emitido 1 s após a ação.

O number_alias do envelope identifica o seu número envolvido no evento. Em conversas 1:1 outbound esse número coincide com from.

origin discrimina a fonte do envio outbound:

  • "tn": enviado via API do Turbo Notify.
  • "native": detectado diretamente do app WhatsApp (quando a organização digita pelo celular).

content_type e preview espelham message.received: text, image, audio, video, document, sticker, location, calendar, contact, poll ou interactive. Para o corpo completo, faça GET /v1/numbers/{ref}/messages/{id}.

Exemplo: message.received (inbound)

webhook-payload.json
{
"id": "evt_2b09b39a44ffc0d3039251f995e4c284",
"type": "message.received",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-06-01T18:32:11Z",
"data": {
"id": "msg_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"from": { "name": "João Silva", "number": "5511977776666" },
"to": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" },
"preview": "Olá, gostaria de saber...",
"content_type": "text",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-01T18:32:11Z"
}
}

from.name traz o nome de exibição configurado no WhatsApp (push name); omitido quando não definido. O number_alias do envelope resolve para "main" ou o alias do número extra que recebeu.

Exemplo: message.received (grupo)

Quando uma mensagem chega de um grupo WhatsApp, to traz group_id e o nome do grupo no momento do evento (name) em vez de number. O seu número que participa do grupo é identificado pelo number_alias do envelope. from continua sendo o participante que enviou: quem envia é sempre uma pessoa, nunca o grupo. Para mensagens 1:1, to.group_id é omitido.

message.received (grupo)
{
"id": "evt_c00a44c3fc774698b0f130d40bb5f104",
"type": "message.received",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-06-02T01:55:14Z",
"data": {
"id": "msg_e95500cc03c1492e81e46a77c1f0d420",
"from": {
"contact_id": "ct_45f2ece8a66a4eeaa661aeb9b8e58c77",
"name": "Ana Souza",
"number": "5511988887777"
},
"to": {
"group_id": "grp_e442553a56b14e2695f6acfd7d651b1a",
"name": "TNT"
},
"preview": "Bom dia pessoal",
"content_type": "text",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-02T01:55:14Z"
}
}
  • to.group_id é o identificador público do grupo (grp_{hex32}), estável por organização. JIDs brutos do WhatsApp nunca são expostos.
  • O number_alias do envelope identifica o seu número que participa do grupo. Serve para saber qual número recebeu, quando a organização tem vários números extras.
  • Para detectar mensagens de grupo: verifique "group_id" in data.to. Conversas 1:1 nunca carregam esse campo.

Exemplo: message.received com menções

Quando o texto (ou a legenda) menciona alguém, o corpo chega com os tokens já normalizados para @<numero> (dígitos E.164 sem +) e data.mentions traz os contatos correspondentes, já resolvidos:

message.received (grupo, com menção)
{
"id": "evt_f11b55d4ed885709c1f241e51cc6f215",
"type": "message.received",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-07-03T12:15:02Z",
"data": {
"id": "msg_f06611dd14d2503f92f57b88d2f1e531",
"from": {
"contact_id": "ct_45f2ece8a66a4eeaa661aeb9b8e58c77",
"name": "Ana Souza",
"number": "5511988887777"
},
"to": {
"group_id": "grp_e442553a56b14e2695f6acfd7d651b1a",
"name": "TNT"
},
"mentions": [
{
"contact_id": "ct_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"name": "João Silva",
"number": "5511977776666"
}
],
"preview": "Oi @5511977776666, pode revisar o contrato?",
"content_type": "text",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-07-03T12:15:01Z"
}
}
  • mentions aparece somente nos eventos que carregam o corpo da mensagem (message.received, message.replied, message.sent, message.edited) e somente quando há menções.
  • Um @<digitos> no texto/preview só é menção se o número constar em mentions. Caso contrário é texto comum.
  • O mesmo token recebido pode ser colado de volta num envio com menção.

Exemplo: message.edited

message.edited (inbound: contato editou a mensagem)
{
"id": "evt_1f2e21dbffd042ed8913f047c40360e4",
"type": "message.edited",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-06-01T14:00:00Z",
"data": {
"id": "msg_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"from": { "name": "João Silva", "number": "5511977776666" },
"to": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" },
"preview": "Texto corrigido da mensagem",
"content_type": "text",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-01T14:00:00Z"
}
}
message.edited (outbound: você editou a mensagem via API)
{
"id": "evt_918e22c6067759c500da08b4b58d1e8e",
"type": "message.edited",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-06-01T14:05:01Z",
"data": {
"id": "msg_b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5",
"from": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" },
"to": { "name": "João Silva", "number": "5511977776666" },
"preview": "Texto atualizado da mensagem",
"content_type": "text",
"direction": "outbound",
"channel": "whatsapp",
"origin": "tn",
"at": "2026-06-01T14:05:00Z"
}
}

Um evento de calendário editado ou cancelado também chega como message.edited, com content_type: "calendar" e o nome do evento em preview. Os exemplos estão em Eventos de calendário.

Quando a legenda de uma imagem, vídeo ou documento é editada, message.edited traz o content_type do arquivo (image, video ou document) e a legenda nova em preview (até 50 caracteres, como no texto). Vale também para a legenda editada no app do WhatsApp (origin: "native"). Veja Editar legenda.

Exemplo: message.deleted

message.deleted (inbound: contato apagou a mensagem)
{
"id": "evt_5b285e5daa729a6dbbe721b7dbfb0572",
"type": "message.deleted",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-06-01T15:00:00Z",
"data": {
"id": "msg_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"from": { "name": "João Silva", "number": "5511977776666" },
"to": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" },
"preview": "",
"content_type": "",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-01T15:00:00Z"
}
}
message.deleted (outbound: você apagou a mensagem via API)
{
"id": "evt_aee054157f581b985d932b19cd360a07",
"type": "message.deleted",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-06-01T15:05:01Z",
"data": {
"id": "msg_b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5",
"from": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" },
"to": { "name": "João Silva", "number": "5511977776666" },
"preview": "",
"content_type": "",
"direction": "outbound",
"channel": "whatsapp",
"origin": "tn",
"at": "2026-06-01T15:05:00Z"
}
}

Em message.deleted, tanto preview quanto content_type vêm sempre como "" (string vazia). O evento informa apenas que a mensagem foi apagada, não o que ela continha. Não faça branch por content_type aqui; use o data.id para localizar a mensagem no seu lado.

Exemplo: message.reacted

Para message.reacted: content_type é sempre "text" e preview contém o emoji (ou "" para remoção). Use GET /v1/numbers/{ref}/messages/{id} para o detalhe completo da reação.

message.reacted (inbound: contato reagiu a uma mensagem sua)
{
"id": "evt_ba9178bb9becc1b3b37e4acc3aa31c93",
"type": "message.reacted",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-06-01T16:00:00Z",
"data": {
"id": "msg_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"from": { "name": "João Silva", "number": "5511977776666" },
"to": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" },
"preview": "👍",
"content_type": "text",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-01T16:00:00Z"
}
}
message.reacted (outbound: você reagiu a uma mensagem via API)
{
"id": "evt_4b34400f674df225f2f38dd79eba6227",
"type": "message.reacted",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-06-01T16:05:01Z",
"data": {
"id": "msg_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"from": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" },
"to": { "name": "João Silva", "number": "5511977776666" },
"preview": "❤️",
"content_type": "text",
"direction": "outbound",
"channel": "whatsapp",
"origin": "tn",
"at": "2026-06-01T16:05:00Z"
}
}

Para remover uma reação previamente enviada, envie a propriedade reaction como null em POST /v1/numbers/{ref}/messages/{id}/react. Veja Enviar reação. O evento message.reacted é emitido com preview: "".

Exemplo: message.replied (inbound/outbound)

Respostas (mensagens que citam outra) são entregues como evento dedicado. preview contém o preview da nova mensagem (a resposta, não a mensagem citada). quoted_message_id identifica a mensagem citada no formato msg_<hex32>; omitido quando o alvo não pôde ser resolvido (mensagem anterior ao uso do Turbo Notify ou removida pela retenção: use GET /v1/numbers/{ref}/messages/{id} para acessar replied.message_id e replied.preview nesses casos).

message.replied (inbound)
{
"id": "evt_cad607ebaa2f414ac955d3c726bb7e8b",
"type": "message.replied",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-06-01T13:00:00Z",
"data": {
"id": "msg_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"from": { "name": "João Silva", "number": "5511977776666" },
"to": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" },
"preview": "Sim, é esse pedido mesmo!",
"content_type": "text",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"quoted_message_id": "msg_c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6",
"at": "2026-06-01T13:00:00Z"
}
}
message.replied (outbound: enviado via API)
{
"id": "evt_debdd6a4dc6642778f08bc4de99798c2",
"type": "message.replied",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-06-01T13:01:01Z",
"data": {
"id": "msg_b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5",
"from": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" },
"to": { "name": "João Silva", "number": "5511977776666" },
"preview": "Sem problemas, vou conferir.",
"content_type": "text",
"direction": "outbound",
"channel": "whatsapp",
"origin": "tn",
"quoted_message_id": "msg_c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6",
"at": "2026-06-01T13:01:00Z"
}
}

Para citar uma mensagem anterior via API, use o endpoint dedicado POST /v1/numbers/{ref}/messages/{message_id}/reply (corpo plano). Para responder à mensagem que acabou de chegar, envie uma mensagem normal. Veja Envio de mensagens.

Exemplo: message.delivered / message.read (recibo inbound)

Recibos são sempre inbound. from é quem disparou o recibo; to identifica o lado da sua organização (1:1: o seu número; grupo: o grupo). O seu número é dado pelo number_alias do envelope.

message.delivered (1:1)
{
"id": "evt_f4702fbb477a28939969781b0a3f5964",
"type": "message.delivered",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-06-01T13:00:30Z",
"data": {
"id": "msg_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"from": { "name": "João Silva", "number": "5511977776666" },
"to": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" },
"preview": "Olá, gostaria de saber...",
"content_type": "text",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-01T13:00:30Z"
}
}
message.read (1:1)
{
"id": "evt_38059003a68e27e22391e62d7c8b94ac",
"type": "message.read",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-06-01T13:01:15Z",
"data": {
"id": "msg_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"from": { "name": "João Silva", "number": "5511977776666" },
"to": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" },
"preview": "Olá, gostaria de saber...",
"content_type": "text",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-01T13:01:15Z"
}
}

preview segue o mesmo prazo de retenção de conteúdo do plano que o corpo completo da mensagem: não é permanente. Uma entrega que ainda não foi concluída guarda o conteúdo até ser entregue ou até acabarem as novas tentativas (no máximo 72 horas). Depois que a entrega é concluída e o prazo do plano passa, preview e transcription (quando presentes) vêm com um marcador de redação em vez do conteúdo original numa reentrega manual e nos eventos de GET /v1/events, que continua devolvendo o evento. A mensagem continua consultável por id em GET /v1/numbers/{ref}/messages/{id}, com content_expired: true e preview vazio: só o conteúdo expira. Veja Retenção das mensagens para o modelo completo (registro da mensagem: 12 meses em todo plano; conteúdo, incluindo preview: 1 hora por padrão, até 7 horas no Lobo Solitário e até 72 horas no Empresarial e no Personalizado).

Recibos em grupo: um por participante

Quando você envia uma mensagem a um grupo, cada outro participante gera o seu próprio delivered (ao receber) e read (ao ler). Em cada recibo: from = aquele participante, to = o grupo. O seu número que observou o recibo é dado pelo number_alias do envelope.

message.read (grupo): recibo de um participante
{
"id": "evt_7857525b786570587e8170fdc73a60aa",
"type": "message.read",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-06-01T13:01:15Z",
"data": {
"id": "msg_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"from": { "name": "João Silva", "number": "5511977776666" },
"to": { "group_id": "grp_e442553a56b14e2695f6acfd7d651b1a", "name": "TNT" },
"preview": "Olá, time!",
"content_type": "text",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-01T13:01:15Z"
}
}
  • from.number muda a cada participante que confirma; to (o grupo) e o number_alias do envelope (o seu número) permanecem os mesmos para todos os recibos da mesma mensagem.
  • Para detectar recibos de grupo: verifique "group_id" in data.to.

Exemplos por content_type (inbound)

O envelope do webhook é idêntico em estrutura para todos os tipos inbound. Apenas data.content_type e data.preview mudam. Os trechos abaixo mostram só o objeto data para reduzir ruído visual.

Para obter o corpo completo (texto, vCard, coordenadas, descrição do evento, link da mídia, etc.), faça GET /v1/numbers/{ref}/messages/{id}.

text: texto simples

data: message.received (text)
{
"id": "msg_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"from": { "name": "João Silva", "number": "5511977776666" },
"to": { "alias": "main", "number": "5511973117056" },
"preview": "Olá, gostaria de saber...",
"content_type": "text",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-01T18:32:11Z"
}

Respostas (mensagens que citam outra) são entregues como o evento dedicado message.replied, nunca como message.received com content_type=reply.

image

data: message.received (image)
{
"id": "msg_a1b2c3d4e5f60718293a4b5c6d7e0010",
"from": { "name": "João Silva", "number": "5511977776666" },
"to": { "alias": "main", "number": "5511973117056" },
"preview": "Foto do produto",
"content_type": "image",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-01T18:34:01Z"
}

Eventos não incluem o bloco media. Faça GET /v1/numbers/{ref}/messages/{id} para obter o bloco image com a URL de download.

video

data: message.received (video)
{
"id": "msg_a1b2c3d4e5f60718293a4b5c6d7e0011",
"from": { "name": "João Silva", "number": "5511977776666" },
"to": { "alias": "main", "number": "5511973117056" },
"preview": "[Video]",
"content_type": "video",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-01T18:35:12Z"
}

audio: mensagem de voz ou música

data: message.received (audio, sem BYO STT)
{
"id": "msg_a1b2c3d4e5f60718293a4b5c6d7e0012",
"from": { "name": "João Silva", "number": "5511977776666" },
"to": { "alias": "main", "number": "5511973117056" },
"preview": "[Audio]",
"content_type": "audio",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-01T18:36:08Z"
}
data: message.received (audio, com BYO STT ativo: sucesso)
{
"id": "msg_a1b2c3d4e5f60718293a4b5c6d7e0012",
"from": { "name": "João Silva", "number": "5511977776666" },
"to": { "alias": "main", "number": "5511973117056" },
"preview": "Oi, pode confirmar o pedido?",
"content_type": "audio",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"transcription": {
"kind": "available",
"text": "Oi, pode confirmar o pedido?",
"language": "pt"
},
"at": "2026-06-01T18:36:08Z"
}
data: message.received (audio, com BYO STT ativo: falha)
{
"id": "msg_a1b2c3d4e5f60718293a4b5c6d7e0012",
"from": { "name": "João Silva", "number": "5511977776666" },
"to": { "alias": "main", "number": "5511973117056" },
"preview": "[Audio]",
"content_type": "audio",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"transcription": {
"kind": "unavailable",
"reason": "timeout"
},
"at": "2026-06-01T18:36:08Z"
}

No GET /v1/numbers/{ref}/messages/{id}, o mime_type típico do bloco audio é audio/ogg (mensagem de voz) ou audio/mpeg (música).

Quando BYO Speech-to-Text está ligado na sua organização (veja Transcrição de áudio), mensagens de áudio inbound ganham o campo transcription: uma união discriminada por kind (omitida quando não é mensagem de áudio):

  • {"kind": "available", "text": "...", "language"?: "pt"}: transcrição concluída; language é o código BCP-47 do idioma detectado/configurado quando disponível.
  • {"kind": "unavailable", "reason": "...", "hint"?: "..."}: transcrição não disponível. reason é um destes 6 valores: not_configured, provider_error, file_too_large (áudio acima de 24 MB), timeout, provider_not_supported (o provedor de transcrição configurado não é suportado no momento) ou internal_error. hint é uma orientação opcional, escrita para quem lê o evento (com o link da tela Transcrição do dashboard quando há algo a ajustar lá).

O fluxo da mensagem nunca é bloqueado por falha de transcrição.

document

data: message.received (document)
{
"id": "msg_a1b2c3d4e5f60718293a4b5c6d7e0013",
"from": { "name": "João Silva", "number": "5511977776666" },
"to": { "alias": "main", "number": "5511973117056" },
"preview": "fatura-abril.pdf",
"content_type": "document",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-01T18:37:00Z"
}

sticker

data: message.received (sticker)
{
"id": "msg_a1b2c3d4e5f60718293a4b5c6d7e0014",
"from": { "name": "João Silva", "number": "5511977776666" },
"to": { "alias": "main", "number": "5511973117056" },
"preview": "[Sticker]",
"content_type": "sticker",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-01T18:37:33Z"
}

location

data: message.received (location)
{
"id": "msg_a1b2c3d4e5f60718293a4b5c6d7e0001",
"from": { "name": "João Silva", "number": "5511977776666" },
"to": { "alias": "main", "number": "5511973117056" },
"preview": "Av. Paulista, Sao Paulo, SP",
"content_type": "location",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-01T18:38:21Z"
}

preview é o texto legível sem colchetes. Corpo completo via GET /v1/numbers/{ref}/messages/{id} no campo location com latitude, longitude, name, address, etc.

calendar: evento de calendário

data: message.received (calendar)
{
"id": "msg_a1b2c3d4e5f60718293a4b5c6d7e0003",
"from": { "name": "João Silva", "number": "5511977776666" },
"to": { "alias": "main", "number": "5511973117056" },
"preview": "Sprint Planning — Mon, 1 Jun 2026 17:00",
"content_type": "calendar",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-01T18:40:00Z"
}

Corpo completo via GET /v1/numbers/{ref}/messages/{id} no campo calendar com name, description, start_at, end_at, etc.

Tipos de evento

O seu webhook recebe exatamente os 43 tipos de evento documentados aqui, e nenhum outro. Esta página cobre os 25 eventos das famílias message.*, number.*, storage.* e media.*. Os 18 restantes vivem em três páginas dedicadas: 6 eventos contact.* (inclui contact.quota.*) em Eventos de contato, 10 eventos group.* em Eventos de ciclo de vida de grupo, e 2 eventos message.quota.* em Eventos de cota de mensagem. Todos chegam no mesmo endpoint configurado, sem cadastro separado.

Mensagens: ciclo de envio

TipoQuando é emitido
message.sentMensagem enviada com sucesso ao WhatsApp (sem citação).
message.send.failedFalha no envio de uma mensagem regular (sem citação).
message.deliveredRecibo: o destinatário recebeu uma mensagem que você enviou.
message.readRecibo: o destinatário leu uma mensagem que você enviou.

Mensagens: respostas (citações)

TipoQuando é emitido
message.repliedResposta enviada ou recebida (qualquer content_type, nas duas direções).
message.reply.failedFalha ao enviar uma resposta (via POST /v1/numbers/{ref}/messages/{message_id}/reply).

Mensagens: recebimento e operações

TipoQuando é emitido
message.receivedMensagem recebida de um contato (texto, mídia, sem citação).
message.editedMensagem foi editada (sua ou do contato), uma legenda foi editada, ou um evento de calendário foi editado ou cancelado.
message.edit.failedFalha ao editar uma mensagem, uma legenda ou um evento de calendário.
message.deletedMensagem foi apagada (sua ou do contato).
message.delete.failedFalha ao apagar uma mensagem.
message.reactedUma reação foi adicionada ou removida (preview = emoji ou "").
message.react.failedFalha ao reagir a uma mensagem.
message.calendar_respondedUm convidado respondeu a um evento de calendário (response = going, not_going ou maybe). Veja Eventos de calendário.

Números: ciclo de vida

TipoQuando é emitido
number.createdNúmero extra criado.
number.connectedNúmero principal ou extra conectado ao WhatsApp.
number.disconnectedNúmero ficou desconectado além da janela de reconexão automática.
number.connect.failedFalha no pareamento/conexão.
number.revokedDispositivo desvinculado pelo WhatsApp.
number.remove.scheduledExclusão de número extra agendada (ele segue ativo até o fim do período já pago).
number.remove.canceledExclusão agendada foi cancelada.
number.removedNúmero extra foi removido.

Todos os eventos number.* carregam os campos comuns id (o identificador do número, num_…, igual ao number_id do envelope), alias e number (quando disponível). O id é o primeiro campo de data e nunca muda, mesmo que o alias seja renomeado: uma plataforma que revende números usa o id para encaminhar o evento ao cliente certo sem consultar nada. Os eventos message.* e os demais eventos de um número identificam o número pelo number_alias e pelo number_id do envelope. O campo name (nome de exibição WhatsApp do número, capturado na conexão) é opcional em todos os eventos e omitido quando nulo. O alias "main" identifica o número principal; qualquer outro valor identifica um número extra. number.connected, number.disconnected, number.connect.failed e number.revoked também podem trazer contact_id (ct_…, o contato que representa o próprio número), omitido quando ainda não existe. number.disconnected e number.revoked sempre trazem reason, um código estável que diz o que aconteceu (veja Motivos de uma queda).

Todos os eventos number.* também carregam channel (sempre "whatsapp") e origin (sempre "tn": quem informa o ciclo de vida do número é o Turbo Notify). Eventos number.* não carregam direction, porque o ciclo de vida do número (criação, conexão, desconexão, revogação, remoção) não é uma troca de mensagens no WhatsApp.

Reconexão automática. Quando a conexão de um número cai e o Turbo Notify consegue trazê-lo de volta sozinho (a conexão com o WhatsApp caiu, ou o Turbo Notify moveu a conexão durante uma manutenção, uma redistribuição de carga ou a recuperação de uma falha do lado dele), nada é enviado na hora. Se o número volta dentro da janela de reconexão automática (cerca de um minuto), nenhum number.disconnected e nenhum number.connected são enviados: para você, o número não caiu. Se não volta a tempo, um único number.disconnected é enviado ao fim da janela, com at igual ao momento em que o número ficou offline; por isso esse at pode ser cerca de um a dois minutos anterior ao created_at do envelope. O Turbo Notify continua tentando, e o number.connected chega quando o número volta. Uma queda que precisa de você é enviada na hora (veja os motivos abaixo).

Motivos de uma queda

Use reason para decidir o que mostrar ao seu cliente. Os valores são estáveis e escritos do ponto de vista dele.

EventoreasonO que aconteceu e o que fazer
number.disconnectedconnection_lostA conexão com o WhatsApp caiu e não voltou dentro da janela de reconexão automática. O Turbo Notify continua reconectando: você não precisa fazer nada.
number.disconnectedservice_interruptionUma interrupção do lado do Turbo Notify tirou o número do ar. Ele volta sozinho; se continuar desconectado, conecte de novo (POST /v1/numbers/{ref}/connect).
number.disconnectedreplaced_by_another_deviceOutra conexão assumiu o dispositivo vinculado deste número. Enviado na hora.
number.disconnecteddisconnected_by_requestAlguém da sua conta desconectou ou removeu o número. Enviado na hora.
number.revokedlogged_out_on_phoneO dispositivo foi desvinculado no celular (WhatsApp, Dispositivos conectados). Pareie o número de novo.
number.revokedreplaced_by_another_deviceOutra conexão assumiu o dispositivo vinculado deste número e, por segurança, ele foi desvinculado. Pareie o número de novo.

O mesmo código tem o mesmo significado em todos os eventos number.*: o tipo do evento diz a consequência (number.disconnected: o número continua pareado e pode voltar; number.revoked e number.connect.failed: é preciso parear de novo). Novos motivos podem ser adicionados. Trate um valor que você não conhece em number.disconnected como connection_lost e em number.revoked como logged_out_on_phone.

number.disconnected (exemplo)
{
"id": "evt_6f0e1d2c3b4a59687766554433221100",
"type": "number.disconnected",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-06-01T13:06:21Z",
"data": {
"id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"name": "Turbo Notify",
"alias": "main",
"number": "5511973117056",
"reason": "connection_lost",
"channel": "whatsapp",
"origin": "tn",
"at": "2026-06-01T13:05:12Z"
}
}

O campo at (em data) carrega o momento da ação real no WhatsApp, e é um valor distinto do created_at do envelope (momento de emissão pelo Turbo Notify). Os eventos de ação real number.connected, number.disconnected, number.revoked e number.connect.failed carregam at com esse momento genuíno do WhatsApp (tipicamente anterior ao created_at); os eventos gerados pelo próprio Turbo Notify (number.created, number.remove.scheduled, number.remove.canceled e number.removed) não carregam at, apenas o created_at do envelope (number.remove.scheduled traz scheduled_at, o momento em que a exclusão vai acontecer).

number.connected (exemplo)
{
"id": "evt_2dd89f2fad140cf3ba32f2ed9f289a22",
"type": "number.connected",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-06-01T13:00:01Z",
"data": {
"id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"name": "Turbo Notify",
"alias": "main",
"number": "5511973117056",
"channel": "whatsapp",
"origin": "tn",
"at": "2026-06-01T13:00:00Z"
}
}

number.connect.failed traz dois campos sobre a falha: reason, um código estável para decidir o que mostrar ao seu cliente, e error, uma frase em inglês que é sempre a mesma para o mesmo reason. Decida pelo reason e use o error só nos seus logs. Os valores de reason são os mesmos do campo reason do status, listados em Motivos de falha. Trate um valor desconhecido como pairing_failed.

number.connect.failed (exemplo)
{
"id": "evt_4bcdecc36d67daab8f863f45a288c624",
"type": "number.connect.failed",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "vendas_sp",
"number_id": "num_4e5f60718293a4b5c6d7e8f90a1b2c3d",
"created_at": "2026-06-01T13:03:01Z",
"data": {
"id": "num_4e5f60718293a4b5c6d7e8f90a1b2c3d",
"alias": "vendas_sp",
"number": "5511988887777",
"error": "Pairing was not completed in time. Request a new code or QR code and try again.",
"reason": "pairing_timeout",
"channel": "whatsapp",
"origin": "tn",
"at": "2026-06-01T13:03:00Z"
}
}

Frase de erro por motivo

reasonerror
pairing_timeoutPairing was not completed in time. Request a new code or QR code and try again.
paired_phone_mismatchA different WhatsApp account completed the pairing. Pair again using the phone of the registered number.
phone_already_boundThis phone number is already linked to another number in the account. Use a different phone number or remove the other number.
main_number_not_connectedThe main number must be connected before you pair extra numbers. Reconnect the main number and try again.
code_generation_failedWhatsApp did not provide a pairing code or QR code. Try again in a moment.
connection_lostThe number lost its connection to WhatsApp. Pair it again.
pairing_failedPairing could not be completed. Pair the number again.

Novos motivos podem ser adicionados, cada um com a sua frase.

Armazenamento e mídia

Eventos relacionados à integração BYO Object Storage (AWS S3, Cloudflare R2, Supabase Storage) e à disponibilidade de mídia recebida.

Saúde do armazenamento

TipoQuando é emitido
storage.degradedAs verificações periódicas do seu bucket falharam várias vezes seguidas. Enviado uma vez por interrupção, não a cada verificação que continua falhando. Enquanto isso, a mídia recebida não é salva no bucket.
storage.recoveredEnviado uma vez, na primeira verificação bem-sucedida depois de um storage.degraded. A mídia recebida volta a ser salva no seu bucket.

Disponibilidade de mídia

TipoQuando é emitido
media.unavailableA mídia não pôde ser salva no seu bucket (reason: no_storage, storage_circuit_open, upload_failed).

Exemplo: storage.degraded

webhook-payload.json
{
"id": "evt_85c989abd492e9905d8f038b981a4d6d",
"type": "storage.degraded",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"created_at": "2026-06-01T13:00:00Z",
"data": {
"reason": "healthcheck_failed",
"consecutive_failures": 5,
"error_code": "access_denied",
"affected_messages_count": 0,
"channel": "whatsapp",
"origin": "tn"
}
}

Motivos de uma degradação do armazenamento

storage.degraded traz dois campos que dizem o que aconteceu, reason e error_code. Os dois têm uma lista fechada de valores. Novos valores podem ser adicionados: trate um error_code que você não conhece como unknown_error.

reason diz qual verificação falhou:

reasonO que aconteceu
healthcheck_failedAs verificações periódicas do seu bucket falharam várias vezes seguidas. O error_code diz como o provedor respondeu.

error_code diz como o provedor de armazenamento respondeu à verificação que falhou:

error_codeO que aconteceu e o que fazer
access_deniedAs credenciais estão erradas ou não têm permissão no bucket. Confira a chave de acesso e as permissões de leitura, escrita e exclusão.
bucket_unreachableO bucket não existe para essa região ou endpoint. Confira o nome do bucket, a região e o endpoint.
region_mismatchA região salva na integração não é a região do bucket. Corrija a região.
endpoint_unreachableNão foi possível alcançar o endpoint. Confira o endereço e se o provedor está no ar.
endpoint_url_blockedO endereço de endpoint salvo é recusado porque não aponta para um serviço público de armazenamento. Informe o endereço que o provedor publica.
network_errorA conexão com o provedor falhou ou demorou demais. Se continuar, confira o status do provedor.
quota_exceededO provedor diz que a conta passou da cota de armazenamento ou está limitando as requisições. Libere espaço ou aumente o limite no provedor.
object_not_foundO provedor não encontrou o bucket nem o arquivo de teste que a verificação tinha acabado de gravar. Confira o nome e a região do bucket.
provider_unavailableO provedor respondeu com uma indisponibilidade, por exemplo projeto pausado ou em manutenção. Confira o status da conta no painel do provedor.
unknown_errorQualquer outra falha. Revise as credenciais e o status da conta no provedor.

Quando o armazenamento volta a responder, você recebe storage.recovered.

Exemplo: storage.recovered

webhook-payload.json
{
"id": "evt_57b5936ddcfaa5c4a714c25321e62bb7",
"type": "storage.recovered",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"created_at": "2026-06-01T13:08:00Z",
"data": {
"degraded_since": "2026-06-01T13:00:00Z",
"duration_seconds": 480,
"channel": "whatsapp",
"origin": "tn"
}
}

Exemplo: media.unavailable

webhook-payload.json
{
"id": "evt_42a7e38a04bef3cfe4f6a2cb6b387f06",
"type": "media.unavailable",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"created_at": "2026-06-01T14:15:30Z",
"data": {
"id": "msg_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"content_type": "image",
"direction": "inbound",
"reason": "no_storage",
"channel": "whatsapp",
"origin": "tn"
}
}

Eventos de falha

Os eventos de falha compartilham um envelope comum e diferem entre si pelo conjunto de campos extras. id pode ser null apenas em message.send.failed (quando o registro não foi criado).

message.send.failed / message.reply.failed

message.send.failed
{
"id": "evt_e80780d9147341cab5a0791102f44c02",
"type": "message.send.failed",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-06-01T13:00:01Z",
"data": {
"id": "msg_01993c5a1b24d3e9f01a2b3c4d5e6f70",
"from": { "alias": "main", "number": "5511973117056" },
"to": { "number": "5511988887777" },
"preview": "Mensagem que falhou",
"content_type": "text",
"direction": "outbound",
"channel": "whatsapp",
"origin": "tn",
"error": {
"code": "number_not_connected",
"message": "The number is not connected to WhatsApp.",
"hint": "The number was not connected to WhatsApp. Reconnect it, then send the message again."
},
"at": "2026-06-01T13:00:00Z"
}
}

from e to estão sempre presentes em todo evento message.*.failed, mesmo quando nenhum registro de mensagem chegou a ser criado (id: null). Se a falha acontece antes de o destinatário ser identificado, from traz o número e o alias que tentaram enviar. O campo error.hint traz a orientação para o código, como no exemplo acima (number_not_connected: o número não estava conectado, reconecte e envie de novo), e é omitido (não null) quando não há orientação para o código.

message.edit.failed / message.delete.failed / message.react.failed

Diferentemente de message.send.failed, essas operações falham sobre uma mensagem que já existe: id está sempre presente.

message.edit.failed
{
"id": "evt_833a2144b32918475ce853581a753a5f",
"type": "message.edit.failed",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-06-01T13:05:01Z",
"data": {
"id": "msg_01993c5a1b24d3e9f01a2b3c4d5e6f70",
"from": { "alias": "main", "number": "5511973117056" },
"to": { "number": "5511988887777" },
"preview": "",
"content_type": "",
"direction": "outbound",
"channel": "whatsapp",
"origin": "tn",
"error": {
"code": "edit_failed",
"message": "We could not edit this message on WhatsApp. Try again in a moment."
},
"at": "2026-06-01T13:05:00Z"
}
}

O WhatsApp aceita a edição de um texto ou de uma legenda só até 15 minutos depois do envio: depois disso o próprio POST .../edit responde 400 edit_window_expired, sem enviar nada. Quando o WhatsApp recusa uma edição que a API aceitou (um evento de calendário, por exemplo), o Turbo Notify emite message.edit.failed com error.code: "edit_failed". O POST .../edit também rejeita mensagens que não estão em estado editável, com 400 message_not_editable. Veja Editar e apagar.

Na edição de um evento de calendário, error.code pode ser message_secret_unavailable (este número não pode mais alterar o evento, por exemplo porque foi vinculado de novo depois do envio) ou calendar_edit_unavailable (a plataforma não consegue alterar eventos no momento). Nos dois casos o error.hint orienta a enviar um novo evento.

message.delete.failed segue exatamente a mesma forma: apenas o valor de type muda; preview e content_type também vêm "".

message.react.failed difere em um ponto além do type: seu content_type é sempre o literal fixo "text" (uma reação é sempre texto), enquanto edit.failed e delete.failed trazem "".

Mensagens de grupo nos webhooks outbound

Quando você envia uma mensagem para um grupo via POST /v1/numbers/{ref}/messages com to.group_id, os webhooks subsequentes (message.sent, message.delivered, message.read, message.send.failed) usam o mesmo formato: to traz group_id e o nome do grupo (name), e o number_alias do envelope identifica o seu número que enviou:

message.sent (outbound para grupo)
{
"id": "evt_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"type": "message.sent",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-06-01T13:00:06Z",
"data": {
"id": "msg_01993c5a1b24d3e9f01a2b3c4d5e6f70",
"from": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" },
"to": { "group_id": "grp_e442553a56b14e2695f6acfd7d651b1a", "name": "TNT" },
"preview": "Bom dia, equipe",
"content_type": "text",
"direction": "outbound",
"channel": "whatsapp",
"origin": "tn",
"at": "2026-06-01T13:00:05Z"
}
}
  • to.group_id é o identificador público grp_{hex32}. Mesmo valor fornecido no POST /v1/numbers/{ref}/messages.
  • O number_alias do envelope identifica o seu número que enviou a mensagem ao grupo.
  • Para detectar mensagens de grupo: verifique "group_id" in data.to.

Mapeamento de operações assíncronas

Operações da API que disparam processamento assíncrono expõem o X-Request-ID no cabeçalho da resposta. Use GET /v1/events?request_id=req_... para consultar os eventos da operação e identificar o resultado pelo id da mensagem (data.id):

OperaçãoEvento de sucessoEvento de falha
Enviar mensagemmessage.sentmessage.send.failed
Responder mensagemmessage.repliedmessage.reply.failed
Editar mensagemmessage.editedmessage.edit.failed
Apagar mensagemmessage.deletedmessage.delete.failed

Reações e operações de número não entram nesse filtro. Para uma reação, acompanhe message.reacted/message.react.failed pelo data.id da mensagem-alvo. Para números, acompanhe number.connected, number.connect.failed, number.remove.scheduled e number.remove.canceled pelo número (por exemplo GET /v1/events?number_id=num_4e5f60718293a4b5c6d7e8f90a1b2c3d ou GET /v1/events?number_alias=vendas_sp).

Tentativas de entrega

Se seu endpoint não responder com status 2xx dentro de 10 segundos, o Turbo Notify agenda outra tentativa com atraso aleatório: cada espera é um valor aleatório entre 0 e o máximo indicado, nunca um valor fixo:

TentativaAtraso antes desta tentativa
1Nenhum: entrega inicial
2~0 s (próximo ciclo de reentrega, ≤15 s)
3até 5 segundos
4até 5 minutos
5até 30 minutos
6até 2 horas

Após a 6ª tentativa falhar, o evento é marcado como dead_letter e nenhuma nova tentativa é feita.

Garantia de entrega e idempotência

O Turbo Notify entrega cada evento pelo menos uma vez. Em situações excepcionais, o mesmo evento pode ser entregue mais de uma vez. Use o campo id para implementar idempotência e evitar processamento duplicado.

O campo id como chave de idempotência

O campo id (ex.: evt_3f1c9a7e2b8d4c6fa0e15b7d9c2a4e68) identifica o evento de forma única e é estável entre tentativas de entrega. Se o mesmo id chegar duas vezes no seu endpoint, trata-se de uma reentrega automática pelo Turbo Notify. O evento já foi processado na primeira entrega.

Armazene os id recebidos e ignore eventos cujo id já esteja na sua base:

dedup.js
const seen = new Set();
function handleEvent(event) {
if (seen.has(event.id)) {
return; // reentrega: já processado
}
seen.add(event.id);
// ... processar
}

Em produção, guarde o id numa tabela ou num cache que o mantenha por pelo menos três dias.

Timestamps em reentregas

O campo created_at (envelope) reflete o momento de emissão do evento pelo Turbo Notify: quando o registro do evento é criado e publicado, nunca a hora em que o webhook foi enviado ao seu endpoint. Quando presente, at (em data) reflete o momento da ação real no WhatsApp. Estes são valores distintos: um evento pode ser emitido algum tempo depois da ação que descreve (eventos gerados pelo próprio Turbo Notify não têm at). Em reentregas, tanto created_at quanto at permanecem inalterados: são os mesmos valores da tentativa original.

Ao receber uma reentrega:

  • created_at é o momento de emissão; at (quando presente) é o momento da ação no WhatsApp, ambos fixos entre tentativas.
  • X-Webhook-Attempt é incrementado a cada tentativa: use-o para diagnóstico, não para lógica de negócio.
  • X-Webhook-Timestamp reflete a hora do envio HTTP atual e é usado para validação de assinatura e proteção anti-replay; não é o timestamp do evento.

Alternativa: polling

Se preferir consumir eventos sob demanda em vez de expor um endpoint público, utilize o polling via GET /events. O objeto data é o mesmo do webhook.