Webhooks do WhatsApp na FlashConnect
A FlashConnect avisa o seu sistema com um POST JSON assinado com HMAC-SHA256 (header X-Castilho-Signature) a cada evento do WhatsApp: 13 eventos, de mensagem recebida a tarefa concluída. Responda 2xx em até 10 segundos; se falhar, a entrega é repetida após 1, 5, 15 e 60 minutos, num total de 5 tentativas, sempre com o mesmo id para você descartar duplicatas.
Atualizado em
Como cadastrar um webhook de saída?
- Pelo painelEm Integrações → Webhooks: informe a URL
https://pública, escolha os eventos e guarde o secret gerado. É com ele que você confere a assinatura. - Ou pelo MCPA ferramenta
criar_webhookdo servidor MCP cadastra a URL e devolve o secret uma única vez. Sem lista de eventos, assinamessage.received,message.statuseconversation.handoff. - TesteO botão "Testar webhook" no painel enfileira um evento
ping, entregue no próximo ciclo de até 1 minuto.
Use * na lista de eventos para receber todos. A URL precisa ser https e pública: endereços internos ou privados são recusados na entrega.
Quais eventos o webhook do WhatsApp envia?
| Evento | Quando dispara |
|---|---|
message.received | Mensagem do contato gravada (texto, mídia, botão, localização e outros tipos). Reentrega da Meta não gera evento repetido |
message.echo | O dono respondeu pelo app WhatsApp Business no celular (coexistência, "from me") |
message.status | Mudou o status de uma mensagem enviada pela plataforma: sent, delivered, read, failed (ou played, áudio ouvido) |
conversation.handoff | O modo da conversa mudou (IA → humano ou humano → IA) pela API ou pelo painel; ou foi para humano por automação, por envio de um atendente pelo painel ou por resposta do dono pelo celular |
conversation.resolved | A conversa virou resolvida pela API, painel, automação ou encerramento por inatividade |
template.status | A Meta aprovou, reprovou, pausou ou desativou um template |
appointment.created | Agendamento criado (API, painel ou automação) |
appointment.updated | Agendamento remarcado, confirmado, concluído, com falta ou observação alterada |
appointment.cancelled | O status do agendamento virou cancelado |
task.created | Tarefa criada |
task.completed | Tarefa entrou numa coluna de conclusão |
automation.triggered | Uma automação com a ação "webhook" rodou |
ping | "Testar webhook" no painel |
connection.status | Aceito na inscrição, mas ainda não é emitido |
Os eventos de agenda, tarefas e automação nascem no banco de dados, então valem para qualquer origem da mudança (API, painel ou automação).
Qual é o formato da entrega (envelope e headers)?
POST https://seu-sistema.com/webhooks/flashconnect
Content-Type: application/json
User-Agent: CastilhoIA-Webhooks/1.0
X-Castilho-Event: message.received
X-Castilho-Delivery: 5f0c7a52-8e3b-4d1f-a6c9-2b7e4d9f1a03
X-Castilho-Signature: t=1761224700,v1=8f3a...64 caracteres hex...
{
"id": "5f0c7a52-8e3b-4d1f-a6c9-2b7e4d9f1a03",
"evento": "message.received",
"tenant_id": "uuid-da-sua-conta",
"criado_em": "2026-10-23T13:05:00.000Z",
"dados": { ... }
}
| Header | Conteúdo |
|---|---|
X-Castilho-Event | Nome do evento, igual a evento no corpo |
X-Castilho-Delivery | Id da entrega, igual a id no corpo. Se repete nas novas tentativas |
X-Castilho-Signature | t=<unix em segundos>,v1=<HMAC-SHA256 em hex> |
User-Agent | CastilhoIA-Webhooks/1.0 |
Exemplos de payload de cada evento
Os exemplos mostram o campo dados do envelope. Ids são ilustrativos. numero.id e phone_number_id de conversa são ids internos da FlashConnect; numero.phone_number_id é o id da Meta, o que o envio pede.
message.received
{
"conversation": {
"id": "4e7a2b91-3c5d-4f6e-9a8b-1c2d3e4f5a6b",
"modo": "ia",
"estado": "aberta",
"etiquetas": [],
"atribuido_a": null,
"janela_expira_em": "2026-10-24T13:05:00.000Z"
},
"contact": { "id": "7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f", "wa_id": "5511999990000", "nome": "Ana Souza" },
"message": {
"id": "0f1e2d3c-4b5a-4968-8776-655443322110",
"wa_message_id": "wamid.HBgN...",
"tipo": "audio",
"texto": null,
"midia": { "media_id": "1234567890123456", "mime_type": "audio/ogg; codecs=opus", "nome_arquivo": null, "voz": true },
"interativo": null,
"contexto": null,
"timestamp": "2026-10-23T13:05:00.000Z"
},
"numero": { "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "phone_number_id": "123456789012345" }
}
midia é null em texto. interativo.id traz o id do botão ou item de lista clicado. contexto.wa_message_id aparece quando o contato responde citando outra mensagem. Para o arquivo, chame POST /media-download com message.id (o media_id sozinho não abre sem o token da Meta).
message.echo
{
"conversation": { "id": "4e7a2b91-3c5d-4f6e-9a8b-1c2d3e4f5a6b", "modo": "ia", "estado": "aberta" },
"contact": { "wa_id": "5511999990000" },
"message": {
"id": "3a4b5c6d-7e8f-4a9b-8c1d-2e3f4a5b6c7d",
"wa_message_id": "wamid.HBgN...",
"tipo": "text",
"texto": "Oi Ana, aqui é o João. Sábado abrimos até 13h.",
"midia": null,
"origem": "app_celular",
"timestamp": "2026-10-23T13:07:12.000Z"
},
"numero": { "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "phone_number_id": "123456789012345" }
}
Se a conversa estava em modo ia, a resposta pelo celular faz o dono assumir: logo depois chega um conversation.handoff com motivo: "mensagem_enviada_pelo_celular". No echo, midia traz também os campos crus da Meta.
message.status
{
"message": { "id": "9b2f1c7e-5d0a-4c1e-8f3b-2a6d4e8c1f90", "wa_message_id": "wamid.HBgN...", "autor": "ia" },
"conversation_id": "4e7a2b91-3c5d-4f6e-9a8b-1c2d3e4f5a6b",
"status": "failed",
"erro": { "codigo": 131047, "titulo": "Re-engagement message", "detalhe": "Message failed to send because more than 24 hours have passed..." },
"timestamp": "2026-10-23T13:10:02.000Z",
"numero": { "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "phone_number_id": "123456789012345" }
}
erro é null quando não houve falha. Só mensagens enviadas pela plataforma geram este evento.
conversation.handoff
{
"conversation": {
"id": "4e7a2b91-3c5d-4f6e-9a8b-1c2d3e4f5a6b",
"contact_id": "7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f",
"phone_number_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"modo": "humano",
"modo_anterior": "ia",
"estado": "aberta",
"etiquetas": [],
"atribuido_a": null
},
"motivo": "cliente pediu atendente",
"origem": "api_key",
"user_id": null,
"api_key_id": "2c4e6a8b-0d1f-4e3a-9b5c-7d9f1b3d5f7a"
}
| Quem mudou | motivo | Outros campos |
|---|---|---|
API (conversation-update) ou painel | o enviado, ou backend (chave) / painel (login) | origem: api_key ou usuario; user_id; api_key_id |
| Atendente enviou mensagem pelo painel numa conversa da IA | mensagem_enviada_por_usuario | user_id |
| Dono respondeu pelo celular | mensagem_enviada_pelo_celular | origem: "app_celular", user_id: null |
| Automação "transferir para humano" | automacao | automacao_id |
Nos casos sem a API, conversation traz só id, modo, modo_anterior e, às vezes, estado.
conversation.resolved
{
"conversation": {
"id": "4e7a2b91-3c5d-4f6e-9a8b-1c2d3e4f5a6b",
"contact_id": "7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f",
"phone_number_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"estado": "resolvida"
},
"origem": "inatividade"
}
origem: api_key ou usuario (com user_id, api_key_id e a conversa completa), automacao ou inatividade (encerramento automático configurado nas regras de atendimento).
template.status
{
"template": {
"id": "5d7f9b1c-3e5a-4c7e-9f1b-3d5f7b9d1f3b",
"nome": "lembrete_consulta",
"idioma": "pt_BR",
"categoria": "UTILITY",
"status": "rejected",
"motivo_rejeicao": "INVALID_FORMAT",
"meta_template_id": "987654321098765"
},
"evento_meta": "REJECTED"
}
status: approved, rejected, paused, disabled ou pending. evento_meta é o valor cru enviado pela Meta. Mais em templates.
appointment.created, appointment.updated, appointment.cancelled
{
"agendamento": {
"id": "6b8d0f2a-4c6e-4a1b-9d3f-5e7a9c1b3d5f",
"tenant_id": "uuid-da-sua-conta",
"agenda_id": "2d4f6a8c-0e1b-4c3d-9e5f-7a9b1c3d5e7f",
"contact_id": "7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f",
"conversation_id": "4e7a2b91-3c5d-4f6e-9a8b-1c2d3e4f5a6b",
"product_id": null,
"titulo": "Avaliação",
"observacoes": null,
"inicio": "2026-10-26T12:00:00+00:00",
"fim": "2026-10-26T12:30:00+00:00",
"status": "marcado",
"origem": "ia",
"criado_por": null,
"criado_em": "2026-10-23T13:12:00+00:00",
"atualizado_em": "2026-10-23T13:12:00+00:00"
}
}
É a linha do agendamento já gravada. Os três eventos têm o mesmo formato; no cancelled, status é cancelado.
task.created e task.completed
{
"tarefa": {
"id": "8a0c2e4f-6b8d-4f1a-9c3e-5b7d9f1a3c5e",
"tenant_id": "uuid-da-sua-conta",
"coluna_id": "1b3d5f7a-9c1e-4b3d-8f5a-7c9e1b3d5f7a",
"titulo": "Ligar para a Ana sobre o orçamento",
"descricao": null,
"prioridade": "alta",
"prazo": "2026-10-27T21:00:00+00:00",
"responsavel_id": null,
"contact_id": "7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f",
"conversation_id": "4e7a2b91-3c5d-4f6e-9a8b-1c2d3e4f5a6b",
"agendamento_id": null,
"etiquetas": [],
"checklist": [],
"ordem": 0,
"origem": "ia",
"criado_por": null,
"concluida_em": null,
"criado_em": "2026-10-23T13:15:00+00:00",
"atualizado_em": "2026-10-23T13:15:00+00:00"
}
}
No task.completed, concluida_em vem preenchida.
automation.triggered
{
"automacao": { "id": "0e2a4c6e-8a0c-4e2a-9c4e-6a8c0e2a4c6e", "nome": "Avisar CRM de novo lead" },
"gatilho": "mensagem_recebida",
"contexto": {
"conversation_id": "4e7a2b91-3c5d-4f6e-9a8b-1c2d3e4f5a6b",
"contact_id": "7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f",
"modo": "ia",
"estado": "aberta",
"etiquetas": [],
"nome": "Ana Souza",
"telefone": "5511999990000",
"tags_contato": ["lead"],
"timezone": "America/Sao_Paulo",
"texto": "Quero um orçamento",
"tipo": "text"
}
}
gatilho: mensagem_recebida, conversa_criada, conversa_transferida, conversa_resolvida, agendamento_criado, agendamento_cancelado ou tarefa_concluida. Nos gatilhos de agenda, contexto traz também agendamento_id, data e hora. Veja automações.
ping
{ "tenant_id": "uuid-da-sua-conta", "enviado_em": "2026-10-23T13:20:00.000Z" }
Como verificar a assinatura HMAC-SHA256 do webhook?
O header X-Castilho-Signature tem o formato t=<timestamp>,v1=<assinatura>, onde:
v1 = hex( HMAC-SHA256( secret, "<t>.<corpo cru>" ) ) // hex minúsculo, 64 caracteres
- Leia o corpo cruSem fazer parse antes. Reserializar o JSON muda os bytes e invalida a assinatura.
- Confira o tempoRejeite se
|agora − t|passar de 300 segundos (5 minutos). Isso barra reenvio de entregas antigas capturadas. - Compare em tempo constante
crypto.timingSafeEqualno Node,hmac.compare_digestno Python.
Vetor de teste para validar a sua implementação: secret whsec_teste, t = 1700000000, corpo {"id":"d1","evento":"ping"} → v1 = 144ed65255014b00c5de7faf72d1a7d29015b86b7e78bc32f0dd21bd5ad2da8f.
Node.js (Express)
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.FLASHCONNECT_WEBHOOK_SECRET;
const app = express();
app.post("/webhooks/flashconnect", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("x-castilho-signature") ?? "";
const partes = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(partes.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return res.sendStatus(401);
const esperado = crypto.createHmac("sha256", SECRET).update(`${t}.${req.body}`).digest("hex");
const recebido = Buffer.from(partes.v1 ?? "", "hex");
if (recebido.length !== 32 || !crypto.timingSafeEqual(Buffer.from(esperado, "hex"), recebido)) {
return res.sendStatus(401);
}
const evento = JSON.parse(req.body);
res.sendStatus(200); // responda antes de processar
processar(evento).catch(console.error);
});
Python (FastAPI)
import hmac, hashlib, json, time, os
from fastapi import FastAPI, Request, Response
SECRET = os.environ["FLASHCONNECT_WEBHOOK_SECRET"].encode()
app = FastAPI()
@app.post("/webhooks/flashconnect")
async def webhook(req: Request):
corpo = await req.body() # bytes crus
header = req.headers.get("x-castilho-signature", "")
partes = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
t = int(partes["t"])
except (KeyError, ValueError):
return Response(status_code=401)
if abs(time.time() - t) > 300:
return Response(status_code=401)
esperado = hmac.new(SECRET, f"{t}.".encode() + corpo, hashlib.sha256).hexdigest()
if not hmac.compare_digest(esperado, partes.get("v1", "")):
return Response(status_code=401)
evento = json.loads(corpo)
# enfileire o evento e responda 200 rápido
return {"ok": True}
Como funcionam o retry e o backoff?
- Sucesso é qualquer resposta
2xxem até 10 segundos. Erro HTTP, timeout, falha de DNS ou destino recusado contam como falha. - Redirecionamento não é seguido: uma resposta
3xxconta como falha. Cadastre a URL final. - Novas tentativas: 1 min depois da 1ª falha, 5 min depois da 2ª, 15 min depois da 3ª e 60 min depois da 4ª. A 5ª tentativa é a última: se falhar, a entrega vai para
falhoue fica visível no painel em Integrações, na lista de entregas, com o último status HTTP e o erro. - Tempo de saída:
message.receivedemessage.echosaem na hora, poucos segundos depois de a Meta entregar a mensagem. Os demais eventos e as novas tentativas saem no ciclo do despachante, que roda a cada 1 minuto. - Assinatura da conta pausada: com a assinatura pausada, cancelada ou aguardando cartão, as entregas ficam retidas na fila, sem gastar tentativa, e são reavaliadas a cada 10 minutos. Saem quando a conta é regularizada.
Como garantir idempotência no recebimento?
- O
iddo envelope (igual aX-Castilho-Delivery) identifica a entrega e se repete em toda nova tentativa. Grave os ids processados e ignore repetidos. - Cada webhook recebe a sua própria entrega: com dois webhooks no mesmo evento, são dois
iddiferentes. - Do nosso lado, a mensagem recebida é gravada uma vez por
wa_message_id: se a Meta reentregar a mesma mensagem, não sai um segundomessage.received. - A ordem entre entregas não é garantida. Dentro de uma conversa, ordene por
dados.message.timestamp. - Responda
200antes de processar (enfileire o trabalho). Processamento demorado estoura os 10 segundos e gera nova tentativa.
Para agir sobre o evento, use a API REST: um backend de IA normalmente responde a message.received só quando conversation.modo é ia. Os erros possíveis dessas chamadas estão em códigos de erro.
Perguntas frequentes
O que acontece se o meu servidor estiver fora do ar?
A entrega fica pendente e é repetida após 1, 5, 15 e 60 minutos, num total de 5 tentativas. Depois disso ela fica marcada como falha no painel, em Integrações, na lista de entregas. Nada é descartado sem registro.
Como evito processar o mesmo evento duas vezes?
Use o campo id do envelope, que é igual ao header X-Castilho-Delivery. Numa nova tentativa a mesma entrega chega com o mesmo id: guarde os ids já processados e ignore os repetidos.
Por que a assinatura não confere no meu código?
Quase sempre porque o corpo foi reserializado. O HMAC é calculado sobre os bytes crus do corpo, então leia o corpo sem fazer parse (express.raw no Node, await request.body() no Python) e só depois converta para JSON. Confira também se está usando o secret do webhook certo.
Recebo eventos de mensagens antigas importadas na coexistência?
Não. As mensagens do histórico importado na sincronização inicial da coexistência ficam fora das automações e não disparam message.received.
Posso ter mais de um webhook na mesma conta?
Sim. Cada webhook ativo inscrito no evento recebe a sua própria entrega, com id próprio e assinada com o secret dele. Para receber todos os eventos, assine o curinga *.
O evento connection.status já funciona?
Ainda não. Ele é aceito na inscrição do webhook, mas a plataforma não o emite hoje. Para saber se a conexão está saudável, use a ferramenta status_setup ou listar_numeros do servidor MCP.
Quer usar a FlashConnect?
O acesso está sendo liberado por convite. Fale com a gente no WhatsApp.
Pedir convite no WhatsApp