CRPRO HubEntrar no painel

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.

Criar o endpoint
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.

Valide a assinatura antes de olhar o corpo. No formato nativo (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.
verificar-assinatura.js
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.

message.received
{
  "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 — o wamid da mensagem do cliente. É a chave natural para deduplicar do seu lado e para responder com reaction.
  • text — atalho para o texto útil: corpo da mensagem de texto ou legenda de imagem, vídeo e documento. Vem null quando não há nenhum dos dois.
  • content — a leitura já resolvida do conteúdo: available, type, text, media, location, interactive, button e order. Trate content como 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 caso content.available vem false: 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 de uma imagem
"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.

Baixar o arquivo
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.

O evento só chega para canais da sua organização. Se você tentar consultar um canal ou um webhook que não é seu, a resposta é 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.

Responder
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.

handler.js
// 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.

Grave o delivery id antes de processar, não depois, e responda 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:

Listar e reenviar
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.received para atendimento, bot e CRM. É o caminho normal.
  • Use meta.raw quando 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.
Para testar o caminho sem depender de um cliente escrevendo, 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.