FlashConnect Documentação Falar no WhatsApp

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?

  1. 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.
  2. Ou pelo MCPA ferramenta criar_webhook do servidor MCP cadastra a URL e devolve o secret uma única vez. Sem lista de eventos, assina message.received, message.status e conversation.handoff.
  3. 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?

EventoQuando dispara
message.receivedMensagem do contato gravada (texto, mídia, botão, localização e outros tipos). Reentrega da Meta não gera evento repetido
message.echoO dono respondeu pelo app WhatsApp Business no celular (coexistência, "from me")
message.statusMudou o status de uma mensagem enviada pela plataforma: sent, delivered, read, failed (ou played, áudio ouvido)
conversation.handoffO 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.resolvedA conversa virou resolvida pela API, painel, automação ou encerramento por inatividade
template.statusA Meta aprovou, reprovou, pausou ou desativou um template
appointment.createdAgendamento criado (API, painel ou automação)
appointment.updatedAgendamento remarcado, confirmado, concluído, com falta ou observação alterada
appointment.cancelledO status do agendamento virou cancelado
task.createdTarefa criada
task.completedTarefa entrou numa coluna de conclusão
automation.triggeredUma automação com a ação "webhook" rodou
ping"Testar webhook" no painel
connection.statusAceito 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": { ... }
}
HeaderConteúdo
X-Castilho-EventNome do evento, igual a evento no corpo
X-Castilho-DeliveryId da entrega, igual a id no corpo. Se repete nas novas tentativas
X-Castilho-Signaturet=<unix em segundos>,v1=<HMAC-SHA256 em hex>
User-AgentCastilhoIA-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 mudoumotivoOutros campos
API (conversation-update) ou painelo enviado, ou backend (chave) / painel (login)origem: api_key ou usuario; user_id; api_key_id
Atendente enviou mensagem pelo painel numa conversa da IAmensagem_enviada_por_usuariouser_id
Dono respondeu pelo celularmensagem_enviada_pelo_celularorigem: "app_celular", user_id: null
Automação "transferir para humano"automacaoautomacao_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
  1. Leia o corpo cruSem fazer parse antes. Reserializar o JSON muda os bytes e invalida a assinatura.
  2. Confira o tempoRejeite se |agora − t| passar de 300 segundos (5 minutos). Isso barra reenvio de entregas antigas capturadas.
  3. Compare em tempo constantecrypto.timingSafeEqual no Node, hmac.compare_digest no 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 2xx em até 10 segundos. Erro HTTP, timeout, falha de DNS ou destino recusado contam como falha.
  • Redirecionamento não é seguido: uma resposta 3xx conta 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 falhou e 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.received e message.echo saem 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 id do envelope (igual a X-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 id diferentes.
  • Do nosso lado, a mensagem recebida é gravada uma vez por wa_message_id: se a Meta reentregar a mesma mensagem, não sai um segundo message.received.
  • A ordem entre entregas não é garantida. Dentro de uma conversa, ordene por dados.message.timestamp.
  • Responda 200 antes 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