Como receber mensagens do WhatsApp na sua aplicação
Receber é o lado difícil da integração. Não é você que escolhe a hora: o evento chega quando o cliente escreve, chega mais de uma vez quando algo falha, e chega de novo quando o seu servidor volta. Este guia cobre o evento message.received do começo ao fim — payload, resposta dentro da janela, deduplicação e recuperação.
Assinar o evento
Mensagem recebida chega por webhook. Crie um endpoint com o escopo webhooks:write e assine message.received — o campo event_types nasce vazio, e endpoint sem evento assinado recebe todos os eventos, não nenhum.
curl -X POST https://crprohub.com/api/v1/webhooks \
-H "Authorization: Bearer hub_pk_EXEMPLO_NAO_REAL" \
-H "Content-Type: application/json" \
-d '{"name":"Recebimento","url":"https://exemplo.com/webhooks/crprohub","event_types":["message.received"]}'O secret (whsec_...) vem só nesta resposta. O passo a passo completo — validação, teste e rotação — está em configurar webhook.
X-Hub-Signature-Version: v2) o HMAC não cobre só o corpo: cobre `${timestamp}.${delivery_id}.${corpo}`. Assinar só o corpo é o erro que reprova 100% das entregas — e é o padrão que a maioria das outras APIs usa, por isso quase todo mundo tenta assim primeiro.import { createHmac, timingSafeEqual } from 'node:crypto'
const timestamp = request.headers['x-hub-timestamp']
const deliveryId = request.headers['x-hub-delivery-id']
const signed = `${timestamp}.${deliveryId}.${rawBody}`
const expected = createHmac('sha256', process.env.WEBHOOK_SECRET).update(signed).digest('hex')
const received = request.headers['x-hub-signature-256']?.replace('sha256=', '')
const valid = !!received &&
/^[0-9a-f]{64}$/.test(received) &&
timingSafeEqual(Buffer.from(received, 'hex'), Buffer.from(expected, 'hex'))O que chega em message.received
Num endpoint normalized — o padrão — o corpo é sempre o mesmo envelope, seja qual for o evento: id, event, channel_id, occurred_at e data. O que muda entre eventos é só o conteúdo de data.
{
"id": "6f1f0f6a-3b2c-4d1e-9a8b-0c1d2e3f4a5b",
"event": "message.received",
"channel_id": "9f6a9c1e-2f3d-4a5b-8c7d-1e2f3a4b5c6d",
"occurred_at": "2026-08-21T12:00:00.000Z",
"data": {
"message_id": "wamid.EXEMPLO123",
"from": "5521999999999",
"type": "text",
"text": "Oi, ainda tem em estoque?",
"contact": {
"wa_id": "5521999999999",
"name": "Maria",
"user_id": null
},
"contacts": [
{ "profile": { "name": "Maria" }, "wa_id": "5521999999999" }
],
"content": {
"available": true,
"type": "text",
"text": "Oi, ainda tem em estoque?",
"media": null,
"location": null,
"interactive": null,
"button": null,
"order": null
},
"errors": [],
"message": {
"id": "wamid.EXEMPLO123",
"from": "5521999999999",
"timestamp": "1787313600",
"type": "text",
"text": { "body": "Oi, ainda tem em estoque?" }
}
}
}message_id— owamidda mensagem do cliente. É a chave natural para deduplicar do seu lado e para responder comreaction.text— atalho para o texto útil: corpo da mensagem de texto ou legenda de imagem, vídeo e documento. Vemnullquando não há nenhum dos dois.content— a leitura já resolvida do conteúdo:available,type,text,media,location,interactive,buttoneorder. Tratecontentcomo a sua fonte principal.message— o objeto cru da Meta, do jeito que veio. Está aí para o caso que a normalização ainda não cobre; se você depende dele todo dia, o seu handler está lendo no lugar errado.errors— não é vazio quando a Meta entregou o evento mas não o conteúdo. Nesse casocontent.availablevemfalse: você sabe que o cliente escreveu, mas não o quê.
Em mídia, content.media traz o tipo e o id do arquivo na Meta:
"content": {
"available": true,
"type": "image",
"text": "Chegou assim",
"media": {
"type": "image",
"id": "1234567890123456",
"mime_type": "image/jpeg",
"sha256": "EXEMPLO",
"caption": "Chegou assim"
},
"location": null,
"interactive": null,
"button": null,
"order": null
}Esse id é o que GET /api/v1/channels/{id}/media/{mediaId} baixa — a resposta é o arquivo binário, sem envelope { "data": ... }. Baixe cedo: a mídia some da Meta em poucos dias, e guardar o id não adianta.
curl https://crprohub.com/api/v1/channels/CHANNEL_UUID/media/1234567890123456 \ -H "Authorization: Bearer hub_pk_EXEMPLO_NAO_REAL" \ -o arquivo-recebido
Canal e remetente
channel_id fica no envelope, não em data: é o UUID do canal que recebeu a mensagem — o seu número. Um endpoint de webhook pode servir vários canais, então é esse campo que diz por qual número responder. Ignorá-lo e responder pelo canal errado é falha silenciosa: a mensagem sai, só que de outro número.
data.from é o remetente, em dígitos com código do país — exatamente o formato que o campo to do envio espera. data.contact.name é o nome do perfil do WhatsApp: o cliente escolhe, muda quando quiser e pode ser qualquer coisa. Use para exibir, nunca para identificar.
404 — nunca 403. É proposital: 403 confirmaria que o recurso existe.A janela de 24 horas
Uma mensagem recebida abre — ou renova — a janela de atendimento daquele contato por 24 horas. Dentro dela você responde com texto livre e qualquer type da API: text, image, audio, video, document, sticker, location, contacts, reaction, interactive.
Fora dela, só template aprovado. A conta é por contato e por canal, e o relógio começa no occurred_at da última mensagem dele — a sua resposta não estende nada. Guarde esse instante junto com o contato quando processar o evento: é a única forma barata de saber, na hora de responder, se ainda dá para mandar texto livre ou se o caminho é enviar template.
Como responder
Responder é um envio comum: POST /api/v1/channels/{id}/messages, com channel_id do evento no caminho e data.from em to. Não existe rota de “responder” separada.
curl -X POST https://crprohub.com/api/v1/channels/CHANNEL_UUID/messages \
-H "Authorization: Bearer hub_pk_EXEMPLO_NAO_REAL" \
-H "Idempotency-Key: 1f2e3d4c-5b6a-4798-8a9b-0c1d2e3f4a5b" \
-H "Content-Type: application/json" \
-d '{"to":"5521999999999","type":"text","text":{"body":"Tem sim, quantas unidades?"}}'A resposta é 202: aceita e enfileirada, não entregue. O detalhe do envio — obrigatoriedade da Idempotency-Key, códigos de erro, como acompanhar a entrega — está em enviar mensagem.
Deduplicar por delivery id
O Hub garante entrega ao menos uma vez, não exatamente uma vez. Se o seu endpoint responder fora de 2xx, estourar o tempo, ou cair depois de processar mas antes de responder, a mesma entrega volta. Toda entrega traz X-Hub-Delivery-Id, e a retentativa reenvia o mesmo id — é por ele que se deduplica.
// 0. Assinatura ANTES de olhar o corpo. Um corpo nao verificado e um
// corpo de origem desconhecida -- fazer JSON.parse nele ja e confiar cedo
// demais. O trecho de verificacao esta em /docs/guias/configurar-webhook.
if (!assinaturaValida(request, rawBody)) return responder(401)
const evento = JSON.parse(rawBody)
const deliveryId = request.headers['x-hub-delivery-id']
// 1. Ja processamos esta entrega? Retentativa reenvia o MESMO delivery id.
if (await jaProcessado(deliveryId)) return responder(200)
await marcarProcessado(deliveryId)
// 2. Enfileire e responda rapido: o Hub espera 2xx em ate 10 segundos.
await fila.publicar({
chaveDeEnvio: `resposta:${evento.data.message_id}`,
canal: evento.channel_id,
para: evento.data.from,
texto: evento.data.text,
})
return responder(200)Dedupe por delivery id resolve a repetição da entrega. Ele não resolve o seu lado: se o processamento cria um pedido, cobra alguém ou dispara uma resposta, essa ação também precisa ser idempotente. Derive a chave de algo estável do evento — data.message_id serve bem, e é ele que deve virar a Idempotency-Key da resposta. Chave aleatória por tentativa transforma retentativa em mensagem duplicada para o cliente.
2xx em até 10 segundos. Handler que faz o trabalho pesado antes de responder é a causa mais comum de endpoint pausado: 20 falhas consecutivas pausam o endpoint, e aí você para de receber tudo.Quando o seu endpoint cai
Uma entrega que falha é reenviada até nove vezes, somando dez tentativas. Se as dez tentativas falharem, ela termina como dead e para de tentar — não fica esperando você voltar. Depois de 20 falhas consecutivas o endpoint inteiro é pausado, e enquanto ele estiver pausado nada é entregue.
A recuperação tem duas etapas. Primeiro, reative o endpoint em /painel/webhooks, depois de corrigir o que quebrou. Depois, liste as entregas e reenvie o que perdeu:
curl https://crprohub.com/api/v1/webhooks/WEBHOOK_UUID/deliveries \ -H "Authorization: Bearer hub_pk_EXEMPLO_NAO_REAL" curl -X POST https://crprohub.com/api/v1/webhooks/WEBHOOK_UUID/replay/DELIVERY_UUID \ -H "Authorization: Bearer hub_pk_EXEMPLO_NAO_REAL"
GET /api/v1/webhooks/{id}/deliveries traz as 50 entregas mais recentes, mais nova primeiro, sem paginação — procure as de status dead e canceled. Só entrega já finalizada (succeeded, dead ou canceled) pode ser reenviada; uma que ainda está pending, processing ou retry responde 404, porque já está na fila.
O replay devolve 202 e volta a entrega para pending — o resultado aparece depois em deliveries. O delivery id é o mesmo do envio original, então o seu dedupe descarta o que já entrou sem você fazer nada. Só 50 entregas ficam visíveis: para uma janela de indisponibilidade maior, reconcilie por GET /api/v1/channels/{id}/messages com direction=inbound, que pagina por cursor e não tem esse teto.
message.received e meta.raw
message.received é o evento normalizado do Hub: uma mensagem recebida por evento, campos estáveis, o mesmo envelope de sempre. meta.raw é o outro extremo — o callback da Meta repassado como chegou, sem interpretação. Um entry da Meta pode carregar várias mensagens e vários status de uma vez; em meta.raw eles chegam juntos, e desmontar isso é problema seu.
Os dois não convivem no mesmo endpoint, e isso pega muita gente: meta.raw só é entregue a endpoint com payload_mode: "meta_raw", e um endpoint meta_raw não recebe message.received nem message.status. Assinar meta.raw num endpoint normalized não entrega nada — e não dá erro, o que torna o sintoma “meu webhook parou de chegar” em vez de uma mensagem clara.
- Use
message.receivedpara atendimento, bot e CRM. É o caminho normal. - Use
meta.rawquando você precisa de um campo que a normalização ainda não expõe, ou está espelhando o payload da Meta para um sistema que já o entende. - Precisa dos dois? Crie dois endpoints, um em cada
payload_mode.
POST /api/v1/webhooks/{id}/test enfileira um evento sintético webhook.test, que respeita o payload_mode do endpoint e serve para validar assinatura e conectividade. Ele não simula message.received: a forma de data é outra. A lista completa de eventos e o contrato dos endpoints estão em webhooks.