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çalho | Descrição |
|---|---|
X-Webhook-Signature | sha256=<hex>: HMAC-SHA256 de {X-Webhook-Timestamp}.{raw_body} com seu segredo. |
X-Webhook-Timestamp | Unix epoch (segundos): utilize para rejeitar entregas com mais de ~5 minutos (proteção contra replay). |
X-Webhook-Attempt | Número da tentativa atual (começa em 1, incrementa a cada reentrega). |
X-Webhook-Max-Attempts | Total de tentativas que o Turbo Notify fará antes de desistir. |
User-Agent | TurboNotify-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:
import hmacimport 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)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:
| Propriedade | Tipo | Descrição |
|---|---|---|
id | string | Identificador único do evento (evt_…). Use como chave de idempotência. |
type | string | Tipo do evento (ex.: message.sent). |
organization_id | string | Identificador público da sua organização (org_{hex32}). Sempre presente. |
number_alias | string | Identificador 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_id | string | Identificador 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_at | string | Momento 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). |
data | object | Dados específicos do evento (veja Campos da mensagem). |
Campos da mensagem
Todos os eventos message.* compartilham os campos abaixo em data:
| Campo | Tipo | Descrição |
|---|---|---|
id | string | null | Identificador público da mensagem (msg_{hex32}). null apenas em message.send.failed (registro não criado). |
from | object | Quem originou o evento. Pessoa (inbound): {contact_id?, name?, number}. Outbound: {contact_id?, name?, alias, number}. |
to | object | Destinatá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?}. |
preview | string | Recorte do conteúdo (até 50 caracteres). Veja Preview por tipo. |
content_type | string | text, 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. |
direction | string | "outbound" (ação da sua organização, pela API ou pelo celular) ou "inbound" (ação do contato, vinda do WhatsApp). |
channel | string | Sempre "whatsapp". |
origin | string | "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. |
at | string | ISO 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). |
mentions | array | Opcional: 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). |
canceled | boolean | Opcional: 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: semprecontact_id, name, alias, number(alias onde existir; campos opcionais omitidos quandonull). Otode grupo é a exceção: mantém{group_id, name?}. O seu número envolvido no evento é identificado pelonumber_aliasdo envelope.created_at(envelope) é o momento de emissão do evento;at(emdata) é o momento da ação no WhatsApp: valores distintos.
nameemfrom/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 ummessage.sentpara alguém que nunca apareceu em nenhuma dessas fontes,to.namevem 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:
| Campo | Tipo | Descrição |
|---|---|---|
error | object | Erro da falha. Presente em todos os eventos *.failed. |
error.code | string | Código estável legível por máquina: faça branch neste valor (ex.: media_fetch_failed, unsupported_media_format). |
error.message | string | Descrição legível da falha. |
error.hint | string | Orientaçã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_type | Preview |
|---|---|
text | Texto truncado |
image | Legenda ou [Image] |
sticker | [Sticker] |
video | Legenda ou [Video] |
audio | Transcrição (quando disponível) → legenda → [Audio] |
document | Legenda → nome do arquivo → [Document] |
location | Texto legível sem colchetes, ex.: Av. Paulista, Sao Paulo, SP |
calendar | Sem 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) |
interactive | Texto 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)
{ "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)
{ "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.
{ "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_aliasdo 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:
{ "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" }}mentionsaparece 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/previewsó é menção se o número constar emmentions. Caso contrário é texto comum. - O mesmo token recebido pode ser colado de volta num envio com menção.
Exemplo: message.edited
{ "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" }}{ "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
{ "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" }}{ "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.
{ "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" }}{ "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).
{ "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" }}{ "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.
{ "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" }}{ "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.
{ "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.numbermuda a cada participante que confirma;to(o grupo) e onumber_aliasdo 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
{ "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
{ "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
{ "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
{ "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"}{ "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"}{ "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) ouinternal_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
{ "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
{ "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
{ "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
{ "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
| Tipo | Quando é emitido |
|---|---|
message.sent | Mensagem enviada com sucesso ao WhatsApp (sem citação). |
message.send.failed | Falha no envio de uma mensagem regular (sem citação). |
message.delivered | Recibo: o destinatário recebeu uma mensagem que você enviou. |
message.read | Recibo: o destinatário leu uma mensagem que você enviou. |
Mensagens: respostas (citações)
| Tipo | Quando é emitido |
|---|---|
message.replied | Resposta enviada ou recebida (qualquer content_type, nas duas direções). |
message.reply.failed | Falha ao enviar uma resposta (via POST /v1/numbers/{ref}/messages/{message_id}/reply). |
Mensagens: recebimento e operações
| Tipo | Quando é emitido |
|---|---|
message.received | Mensagem recebida de um contato (texto, mídia, sem citação). |
message.edited | Mensagem foi editada (sua ou do contato), uma legenda foi editada, ou um evento de calendário foi editado ou cancelado. |
message.edit.failed | Falha ao editar uma mensagem, uma legenda ou um evento de calendário. |
message.deleted | Mensagem foi apagada (sua ou do contato). |
message.delete.failed | Falha ao apagar uma mensagem. |
message.reacted | Uma reação foi adicionada ou removida (preview = emoji ou ""). |
message.react.failed | Falha ao reagir a uma mensagem. |
message.calendar_responded | Um convidado respondeu a um evento de calendário (response = going, not_going ou maybe). Veja Eventos de calendário. |
Números: ciclo de vida
| Tipo | Quando é emitido |
|---|---|
number.created | Número extra criado. |
number.connected | Número principal ou extra conectado ao WhatsApp. |
number.disconnected | Número ficou desconectado além da janela de reconexão automática. |
number.connect.failed | Falha no pareamento/conexão. |
number.revoked | Dispositivo desvinculado pelo WhatsApp. |
number.remove.scheduled | Exclusão de número extra agendada (ele segue ativo até o fim do período já pago). |
number.remove.canceled | Exclusão agendada foi cancelada. |
number.removed | Nú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.
| Evento | reason | O que aconteceu e o que fazer |
|---|---|---|
number.disconnected | connection_lost | A 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.disconnected | service_interruption | Uma 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.disconnected | replaced_by_another_device | Outra conexão assumiu o dispositivo vinculado deste número. Enviado na hora. |
number.disconnected | disconnected_by_request | Alguém da sua conta desconectou ou removeu o número. Enviado na hora. |
number.revoked | logged_out_on_phone | O dispositivo foi desvinculado no celular (WhatsApp, Dispositivos conectados). Pareie o número de novo. |
number.revoked | replaced_by_another_device | Outra 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.
{ "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).
{ "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.
{ "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
reason | error |
|---|---|
pairing_timeout | Pairing was not completed in time. Request a new code or QR code and try again. |
paired_phone_mismatch | A different WhatsApp account completed the pairing. Pair again using the phone of the registered number. |
phone_already_bound | This phone number is already linked to another number in the account. Use a different phone number or remove the other number. |
main_number_not_connected | The main number must be connected before you pair extra numbers. Reconnect the main number and try again. |
code_generation_failed | WhatsApp did not provide a pairing code or QR code. Try again in a moment. |
connection_lost | The number lost its connection to WhatsApp. Pair it again. |
pairing_failed | Pairing 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
| Tipo | Quando é emitido |
|---|---|
storage.degraded | As 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.recovered | Enviado 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
| Tipo | Quando é emitido |
|---|---|
media.unavailable | A mídia não pôde ser salva no seu bucket (reason: no_storage, storage_circuit_open, upload_failed). |
Exemplo: storage.degraded
{ "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:
reason | O que aconteceu |
|---|---|
healthcheck_failed | As 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_code | O que aconteceu e o que fazer |
|---|---|
access_denied | As 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_unreachable | O bucket não existe para essa região ou endpoint. Confira o nome do bucket, a região e o endpoint. |
region_mismatch | A região salva na integração não é a região do bucket. Corrija a região. |
endpoint_unreachable | Não foi possível alcançar o endpoint. Confira o endereço e se o provedor está no ar. |
endpoint_url_blocked | O 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_error | A conexão com o provedor falhou ou demorou demais. Se continuar, confira o status do provedor. |
quota_exceeded | O 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_found | O 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_unavailable | O provedor respondeu com uma indisponibilidade, por exemplo projeto pausado ou em manutenção. Confira o status da conta no painel do provedor. |
unknown_error | Qualquer 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
{ "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
{ "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
{ "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.
{ "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:
{ "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úblicogrp_{hex32}. Mesmo valor fornecido noPOST /v1/numbers/{ref}/messages.- O
number_aliasdo 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ção | Evento de sucesso | Evento de falha |
|---|---|---|
| Enviar mensagem | message.sent | message.send.failed |
| Responder mensagem | message.replied | message.reply.failed |
| Editar mensagem | message.edited | message.edit.failed |
| Apagar mensagem | message.deleted | message.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:
| Tentativa | Atraso antes desta tentativa |
|---|---|
| 1 | Nenhum: entrega inicial |
| 2 | ~0 s (próximo ciclo de reentrega, ≤15 s) |
| 3 | até 5 segundos |
| 4 | até 5 minutos |
| 5 | até 30 minutos |
| 6 | até 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:
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-Timestampreflete 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.