Integração

WhatsApp Cloud API: como integrar com webhook e API REST

Tutorial para desenvolvedores: como integrar a WhatsApp Cloud API com webhook assinado (X-Hub-Signature-256), envio por API REST em Node.js e status de entrega.

Código de programação exibido em uma tela
Foto ilustrativa · Pexels
Neste artigo

Integrar a WhatsApp Cloud API é montar três peças: um número autorizado na Meta com um token de acesso, um webhook que recebe mensagens e status de entrega, e chamadas REST para enviar. O webhook chega assinado com HMAC-SHA256 no header X-Hub-Signature-256, a Meta reenvia o que não recebeu 200 e, fora da janela de 24 horas, só sai template aprovado. O resto deste guia é o detalhe de cada peça, com código em Node.js, e a diferença entre fazer tudo direto na Meta ou por um Tech Provider.

O público aqui é quem escreve código. Se você quer entender o produto antes de entrar na técnica, comece pela visão geral da API oficial do WhatsApp.

As três peças de qualquer integração

Toda integração com a Cloud API, em qualquer linguagem e com qualquer provedor, se reduz a isto:

Peça O que é De quem é a regra
Número autorizado + token Um número registrado na WhatsApp Business Platform e um token com permissão para operá-lo Meta
Webhook (entrada) Um endpoint HTTPS seu que a Meta chama a cada mensagem recebida e a cada mudança de status Meta assina e reenvia; você valida e deduplica
API REST (saída) POST para enviar texto, mídia, botões ou template Meta define formato, janela e limites

A parte que mais engana é a primeira. Enviar e receber é código simples. Chegar a um número autorizado, com o app aprovado e o token certo, é onde a maioria dos tutoriais em português gasta 80% do texto e onde a maioria das integrações trava. Por isso vale separar os dois caminhos antes do código.

Dois caminhos: direto na Meta ou por um Tech Provider

O que você faz sozinho: app, App Review, verificação, token permanente e /register

Integrando direto, a sequência é sua. Você cria um app no Meta for Developers, adiciona o produto WhatsApp, verifica a empresa no Business Manager e pede App Review para as permissões whatsapp_business_management e whatsapp_business_messaging. Sem o review aprovado, o app só fala com números de teste. O token de desenvolvimento expira em horas; para produção você cria um usuário de sistema no Business Manager e gera um token permanente com essas permissões. Depois cadastra o webhook no App Dashboard, escolhe os campos assinados e registra o número com POST /{phone_number_id}/register.

Cada passo tem um jeito de falhar sem erro claro. O mais comum é esquecer o /register: o token funciona, o webhook responde, e todo envio devolve o código 133010, "número não registrado". O segundo é assinar só messages no App Dashboard e depois se perguntar por que aprovação de template e queda de qualidade nunca chegam.

O que o provedor já resolveu: onboarding, token, webhooks, dedup, retry

Por um Tech Provider verificado pela Meta, o número entra pelo Embedded Signup: uma janela da Meta, aberta dentro do site do provedor, onde o dono do número autoriza o app do provedor a operar aquele número. A Meta devolve os ids e um code trocável por token. O App Review é do provedor, o token fica guardado cifrado no provedor, os webhooks da Meta chegam no provedor, e o que chega até você é um evento já deduplicado, com retry e assinatura própria.

Isso não muda o que a Meta cobra. Como Tech Provider, o Frame Conexa não fatura mensagem e não cadastra pagamento na Meta pelo cliente: o cartão entra no Business Manager do dono do número, e a Meta cobra dele direto pela tabela por mensagem. O provedor cobra pelo software. A diferença entre os dois caminhos é quem carrega o App Review, a rotação de token e a fila de reentrega, não quanto custa cada mensagem.

Há uma diferença técnica importante entre as modalidades de número. Em coexistência, o número continua no app WhatsApp Business do celular e entra na API ao mesmo tempo; o app já registrou o número, e chamar /register nele derruba o app do celular. Num número novo, exclusivo da API, o /register é obrigatório. O provedor sabe qual é o caso e faz a chamada certa; integrando direto, você precisa saber.

Receber mensagens: o webhook

O webhook é um endpoint HTTPS público, com certificado válido, que responde a dois verbos. GET é o handshake de cadastro. POST é o que a Meta chama a cada evento.

Handshake de verificação: hub.mode, hub.verify_token, hub.challenge

Ao salvar a URL no App Dashboard, a Meta faz um GET com três parâmetros: hub.mode=subscribe, hub.verify_token com o valor que você mesmo digitou no painel dela, e hub.challenge, um número. Se o token bate, você devolve o hub.challenge como corpo, em texto, com 200. Qualquer outra resposta e o cadastro falha.

javascript
app.get('/webhook', (req, res) => {
  const mode = req.query['hub.mode'];
  const token = req.query['hub.verify_token'];
  const challenge = req.query['hub.challenge'];
  if (mode === 'subscribe' && token === process.env.VERIFY_TOKEN) {
    return res.status(200).send(challenge);
  }
  return res.sendStatus(403);
});

O verify_token é um segredo que você inventa. Ele só serve para o handshake; não assina evento nenhum. Compare em tempo constante se quiser ser rigoroso, mas o valor importante é o próximo.

Validar a assinatura HMAC-SHA256 sobre o corpo bruto

Toda notificação vem com o header X-Hub-Signature-256, no formato sha256=<hex>. O hex é o HMAC-SHA256 do corpo da requisição, calculado com o App Secret do seu app. Recalcular e comparar é o que garante que o POST veio da Meta e não de qualquer um que descobriu a URL. A regra está na documentação de webhooks da Graph API.

O detalhe que quebra a maioria das implementações: o HMAC é sobre os bytes exatos do corpo. Se um parser JSON rodar antes e você reserializar o objeto, a ordem das chaves e os espaços mudam, e o hash nunca bate. Leia o corpo cru primeiro, valide, e só então faça o parse.

javascript
import crypto from 'node:crypto';
import express from 'express';

const app = express();

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const received = req.get('X-Hub-Signature-256') || '';
  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.APP_SECRET)
    .update(req.body)
    .digest('hex');

  const a = Buffer.from(received);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.sendStatus(401);
  }

  res.sendStatus(200);
  enqueue(JSON.parse(req.body.toString('utf8')));
});

Dois pontos nesse código são deliberados. timingSafeEqual em vez de ===, porque uma comparação que para no primeiro byte diferente vaza, pelo tempo de resposta, quantos bytes acertaram. E o 200 sai antes do processamento: a Meta mede o tempo de resposta e reenvia o que demora. Grave o evento numa fila ou numa tabela e responda; o processamento acontece depois.

O envelope: messages, statuses, errors

O corpo tem sempre a mesma casca: object, uma lista entry, cada entrada com uma lista changes, e cada mudança com um field e um value. Para o campo messages, o value traz messaging_product, metadata com o número da empresa, e então contacts e messages quando alguém escreveu, ou statuses quando uma mensagem sua mudou de estado. A referência completa está na visão geral dos webhooks do WhatsApp.

Uma mensagem recebida:

json
{
  "object": "whatsapp_business_account",
  "entry": [{
    "id": "WABA_ID",
    "changes": [{
      "field": "messages",
      "value": {
        "messaging_product": "whatsapp",
        "metadata": { "display_phone_number": "5585999990000", "phone_number_id": "PHONE_NUMBER_ID" },
        "contacts": [{ "profile": { "name": "Ana" }, "wa_id": "5585988880000" }],
        "messages": [{
          "from": "5585988880000",
          "id": "wamid.HBgNNTU4NT...",
          "timestamp": "1757340000",
          "type": "text",
          "text": { "body": "Oi, ainda tem vaga para sábado?" }
        }]
      }
    }]
  }]
}

Um status da sua mensagem:

json
{
  "field": "messages",
  "value": {
    "messaging_product": "whatsapp",
    "metadata": { "phone_number_id": "PHONE_NUMBER_ID" },
    "statuses": [{
      "id": "wamid.HBgNNTU4NT...",
      "status": "delivered",
      "timestamp": "1757340042",
      "recipient_id": "5585988880000",
      "conversation": { "id": "CONVERSATION_ID", "origin": { "type": "service" } },
      "pricing": { "billable": false, "pricing_model": "PMP", "category": "service" }
    }]
  }
}

Os quatro valores de status contam uma história em ordem: sent significa que a Meta aceitou e encaminhou; delivered, que chegou ao aparelho; read, que a pessoa abriu, quando ela não desligou a confirmação de leitura; failed, que não foi, e aí statuses[].errors traz o código. É por esse failed que você fica sabendo de um 131047 ou de um 131026 depois que a API já tinha respondido 200 ao seu POST. O envio aceito não é o envio entregue.

O objeto pricing é o que diz se aquela mensagem custa. billable responde se a Meta cobra, category diz a categoria e pricing_model: "PMP" indica o modelo por mensagem, que substituiu o antigo modelo por conversa. Vale guardar: é a única fonte de verdade sobre custo que chega até o seu sistema.

Atenção: mensagens de serviço, as respostas livres dentro da janela de 24 horas, são gratuitas até 30/09/2026. A partir de 01/10/2026 a Meta passa a cobrá-las por mensagem, e o mesmo vale para templates de utilidade enviados dentro da janela. O pricing.billable desses status vai mudar de false para true nessa data. Detalhes na página oficial sobre mensagens não-template.

Erros aparecem em três lugares: value.errors para problemas do evento inteiro, messages[].type: "unsupported" para conteúdo que a API não representa, e statuses[].errors para falha de entrega. Um receptor que só lê messages[].text.body ignora os três.

Reentregas e duplicatas: por que deduplicar é obrigatório

Quando o seu endpoint não responde 200, a Meta reenvia. A documentação do WhatsApp fala em novas tentativas com frequência decrescente por até sete dias; a documentação genérica de webhooks fala em 36 horas. Nos dois casos, a consequência é a mesma: o mesmo evento pode chegar mais de uma vez, e também pode chegar fora de ordem, um delivered antes do sent.

A chave de deduplicação natural é messages[].id, o wamid, e para status o par statuses[].id mais status. Um INSERT com restrição única e ON CONFLICT DO NOTHING resolve; processar o evento e só depois responder 200 não resolve, porque o timeout da Meta é curto e um processamento lento vira reentrega de algo que você já tratou. Se o seu banco falhar no INSERT, responda 500 e deixe a Meta reenviar. A fila de retry externa é ela.

Enviar mensagens: API REST

O envio é um POST para https://graph.facebook.com/v25.0/{phone_number_id}/messages, com Authorization: Bearer e um corpo JSON que começa com messaging_product: "whatsapp". O número de destino vai em E.164 sem o sinal de mais.

Texto dentro da janela e template fora dela

O que pode sair depende de uma regra que é da Meta, não do provedor: mensagem livre só dentro da janela de 24 horas aberta pela última mensagem da pessoa. Fora dela, só template aprovado, nas categorias marketing, utilidade ou autenticação.

Texto, quando a janela está aberta:

bash
curl -X POST "https://graph.facebook.com/v25.0/$PHONE_NUMBER_ID/messages" \
  -H "Authorization: Bearer $META_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "messaging_product": "whatsapp",
    "to": "5585988880000",
    "type": "text",
    "text": { "body": "Tem sim. Sábado às 10h ou às 14h?" }
  }'

Template, quando a janela está fechada, em Node.js com fetch nativo:

javascript
const res = await fetch(`https://graph.facebook.com/v25.0/${PHONE_NUMBER_ID}/messages`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.META_ACCESS_TOKEN}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    messaging_product: 'whatsapp',
    to: '5585988880000',
    type: 'template',
    template: {
      name: 'lembrete_agendamento',
      language: { code: 'pt_BR' },
      components: [
        { type: 'body', parameters: [{ type: 'text', text: 'sábado, 10h' }] },
      ],
    },
  }),
});
const data = await res.json();
if (!res.ok) throw Object.assign(new Error(data.error?.message), { graph: data.error });
const wamid = data.messages[0].id;

O wamid que volta é o mesmo id que vai aparecer nos status do webhook. Guarde-o na hora: é ele que liga o envio ao delivered e ao failed que chegam minutos depois. Como criar um template e o que a Meta olha na aprovação está em template do WhatsApp aprovado.

Idempotência, retry e os códigos 130429, 131047, 133010 e 190

A API responde erro em dois momentos: síncrono, no POST, e assíncrono, num status failed. Os que importam para o código de envio:

Código O que significa O que fazer De quem é
130429 Ritmo de envio acima do que o número suporta Espere e reenvie com espaçamento; backoff exponencial curto Meta
131047 Mais de 24 horas desde a última mensagem da pessoa Não reenvie o texto; envie um template aprovado Meta
131026 Destinatário não é usuário do WhatsApp, não aceitou os termos ou usa app antigo Tire da lista; reenviar dá o mesmo resultado Meta
133010 Número não registrado na plataforma Chame /register no número novo; nunca em coexistência Meta
190 Token expirado ou revogado Gere token novo, ou refaça a autorização; nada mais vai sair até lá Meta

A lista completa, com o que o painel mostra para cada um, está em erros da API do WhatsApp e na referência oficial de códigos.

Sobre ritmo: um número integrado direto começa em 80 mensagens por segundo, segundo a documentação de throughput da Meta; um número em coexistência fica em 20. Acima disso vem o 130429, e a resposta certa é espaçar, não insistir em loop.

Sobre retry: a Cloud API não tem chave de idempotência nativa. Se o seu POST estourar o timeout sem resposta, você não sabe se a mensagem saiu. Reenviar às cegas duplica; não reenviar pode perder. A saída é registrar a intenção de envio antes do POST, com um id seu, e só marcar como enviada quando o wamid voltar. Um envio sem wamid fica como incerto até um status chegar pelo webhook, ou até você conferir por outro caminho. É trabalhoso, e é exatamente o tipo de coisa que um provedor faz por você.

Como fica com o Frame Conexa

Com o número conectado pelo Embedded Signup, as três peças ficam assim: o token é um fc_ gerado em Configurações, no painel, e enviado em Authorization: Bearer; o webhook é uma URL sua cadastrada por API ou pelo painel; o envio é o mesmo formato da Cloud API, numa rota que já conhece a instância. A documentação tem o quickstart e a referência de cada rota; abaixo vai o que muda em relação à integração direta.

Webhook assinado com instanceId, customerRef e payload cru da Meta

Você cadastra a URL com PUT /api/v1/webhook-config e o corpo { "url": "https://..." }. A resposta traz o webhookSecret, gerado na primeira gravação; POST /api/v1/webhook-config/secret gira o segredo quando você precisar, e o anterior morre na hora.

O que chega no seu endpoint é um envelope assinado no header X-Frame-Signature-256, no mesmo esquema da Meta: sha256= mais o HMAC-SHA256 do corpo bruto, calculado com esse segredo. O código de validação é o mesmo da seção anterior, trocando o nome do header e o segredo:

javascript
app.post('/webhooks/frame-conexa', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get('X-Frame-Signature-256') || '';
  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.FRAME_WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');
  const a = Buffer.from(received);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
  res.sendStatus(200);
  enqueue(JSON.parse(req.body.toString('utf8')));
});

O envelope tem seis chaves de topo, e payload é o value cru da Meta, sem nenhum campo nosso dentro:

json
{
  "type": "messages",
  "clientId": "8",
  "instanceId": "6ed76b84-4d65-4f3d-88c1-8d4174bdce73",
  "customerId": null,
  "customerRef": null,
  "payload": { "messaging_product": "whatsapp", "metadata": {}, "messages": [] }
}

Três tipos de evento chegam por aí. messages é o campo de mesmo nome da Meta, com mensagens recebidas e status de entrega juntos, como ela manda. smb_message_echoes só existe em coexistência: é a mensagem que alguém enviou pelo app do celular, para o seu sistema saber que a conversa foi respondida por ali. instance_status é nosso: connected ou disconnected, emitido uma vez por transição, para o seu sistema descobrir que o número caiu antes do primeiro envio que falha com 409.

Duas decisões desse envelope são de compatibilidade. instanceId é o UUID que aparece como id em GET /api/v1/instances, e é por ele que você casa o evento com uma instância; clientId é uma chave numérica antiga que segue existindo porque trocá-la quebrou consumidores em produção. E customerId/customerRef vêm como null, nunca ausentes: são o cliente final numa parceria de software, e um consumidor estrito trata chave nula e chave ausente como coisas diferentes.

A entrega é durável do nosso lado. O evento entra numa fila antes de sair, o POST tem timeout de 10 segundos, e uma resposta que não seja 2xx agenda nova tentativa com espaçamento crescente, até esgotar; só então o evento vai para uma fila de descarte, visível no painel. Um destino lento ou fora do ar não perde evento nem trava a ingestão dos outros. Do seu lado, continue deduplicando por messages[].id: a Meta pode reentregar para nós, e nós reentregamos para você.

POST /api/v1/instances/:id/messages com token fc_ e Idempotency-Key

O envio é o mesmo corpo da Cloud API, sem messaging_product e sem phone_number_id, porque a instância na URL já diz qual número é:

bash
curl -X POST "https://frameconexa.com/api/v1/instances/$FRAME_CONEXA_INSTANCE_ID/messages" \
  -H "Authorization: Bearer fc_SEU_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 9f8d4a11-5e52-4c83-a72c-45d48f4ea6d5" \
  -d '{"to":"5585988880000","type":"text","text":{"body":"Tem sim. Sábado às 10h ou às 14h?"}}'

O Idempotency-Key é o que resolve o problema do retry descrito acima. Use um UUID por mensagem. Se a resposta se perder, repita a mesma chave com o mesmo corpo: você recebe o resultado anterior em vez de um envio novo. Chave repetida com corpo diferente responde 409 IDEMPOTENCY_CONFLICT; envio ainda em processamento responde 409 SEND_IN_PROGRESS com ambiguous: true e não é repetido.

Por dentro, o envio é DB-primeiro: a intenção vira linha antes do POST à Meta. Aceite vira accepted, erro explícito vira failed, timeout sem wamid vira unknown, e nesse último caso a resposta traz ambiguous: true. Significa "pode ter saído": não reenvie sem conferir. Quem promove accepted para entregue ou lido é o status do webhook, nunca a resposta do POST.

Template segue o formato da Cloud API: "type": "template" com name, language e components. Duas regras do lado do provedor valem aqui: template de marketing e a criação de templates exigem número do tipo marketing, e a categoria do template é conferida antes do envio. Um número de atendimento envia utilidade e autenticação sem restrição. Os limites de chamada estão em erros e limites: 120 requisições por minuto por token, com Retry-After no 429.

Baixar mídia recebida pelo proxy

Quando alguém manda uma foto ou um documento, o webhook entrega só image.id ou document.id. O binário fica na Meta e exige o token do número para ser baixado, em dois passos: um GET para o metadado, que devolve uma URL de CDN válida por minutos, e um segundo GET nessa URL, também com o token. Integrando direto, você faz os dois. Pelo Conexa, um GET resolve:

bash
curl -L "https://frameconexa.com/api/v1/instances/$FRAME_CONEXA_INSTANCE_ID/media/$MEDIA_ID" \
  -H "Authorization: Bearer fc_SEU_TOKEN" \
  -o recebido.jpg

O proxy faz stream, com teto de 100 MB, e nunca devolve nem cacheia a URL de CDN. Baixe assim que o evento chegar: a retenção do lado da Meta é limitada.

Se o seu caso é um fluxo de automação em vez de código próprio, as mesmas rotas alimentam o nó HTTP do n8n e do Make. Se é um agente de IA que opera o número por linguagem natural, há um servidor MCP sobre a mesma API, com confirmação dupla antes de qualquer envio.

Checklist de produção

Antes de apontar tráfego real para a integração, confira:

  • Assinatura validada sobre o corpo bruto, com comparação em tempo constante, e 401 para o que não bate. Um webhook sem validação aceita qualquer POST como se fosse da Meta.
  • 200 rápido e processamento em fila. Guarde primeiro, processe depois. Se não conseguir guardar, responda 500 e deixe reenviar.
  • Deduplicação por wamid para mensagens e por wamid mais status para status. Eventos repetidos e fora de ordem são normais.
  • wamid guardado no envio, ligando o POST aos status que chegam depois. Sem isso, failed não tem a quem pertencer.
  • Idempotência no envio: chave por mensagem, e nunca reenviar um resultado incerto às cegas.
  • Janela de 24 horas no código, não só na cabeça: o texto livre só sai quando a última mensagem da pessoa tem menos de 24 horas; do contrário, template. Contas com anúncio Click-to-WhatsApp têm uma janela grátis de 72 horas que o painel sinaliza, e o artigo sobre anúncios Click-to-WhatsApp explica a condição para ela existir.
  • Tratamento do 190: token revogado significa que nada mais sai até reautorizar. Alerta para o time, não retry.
  • Versão da Graph API fixada na URL. Chamada a versão expirada cai para a mais antiga disponível, silenciosamente.
  • Custo previsto para outubro de 2026: se a sua integração responde muito dentro da janela, o pricing.billable dessas mensagens vai virar true em 01/10/2026. A tabela por mensagem é a publicada pela Meta; confira o rate card oficial. O que um provedor cobra, e o que a Meta cobra, está separado em quanto custa a WhatsApp Business API.

Perguntas frequentes

Preciso criar um app na Meta para integrar a Cloud API?

Integrando direto, sim: app no Meta for Developers, produto WhatsApp, verificação da empresa e App Review para as duas permissões do WhatsApp. Por um Tech Provider verificado, o app é do provedor e o número entra pelo Embedded Signup; você recebe um token do provedor e nunca toca no App Dashboard da Meta. O que não muda em nenhum dos caminhos é o pagamento: o cartão entra no Business Manager do dono do número, e a Meta cobra dele direto.

Como validar a assinatura do webhook do WhatsApp?

Leia o corpo da requisição como bytes, antes de qualquer parser JSON. Calcule o HMAC-SHA256 desses bytes com o App Secret do app, prefixe com sha256= e compare com o header X-Hub-Signature-256 em tempo constante. Se bater, responda 200 e processe; se não, 401. No webhook do Frame Conexa o header é X-Frame-Signature-256 e o segredo é o webhookSecret da sua conta; o código é o mesmo.

Por que recebo o mesmo evento duas vezes?

Porque a Meta reenvia tudo que não recebeu 200 a tempo, com tentativas em frequência decrescente por até sete dias segundo a documentação do WhatsApp. Um processamento lento antes da resposta, um deploy no meio, um timeout de rede: qualquer um gera reentrega. Deduplique por messages[].id e, para status, pelo par id mais status. Se você recebe pelo Conexa, vale a mesma regra: nós reentregamos o que o seu endpoint não confirmou.

Como enviar mensagem fora da janela de 24h pela API?

Só com template aprovado pela Meta, enviado com "type": "template", o nome, o idioma e os parâmetros dos componentes. Texto livre fora da janela responde 131047 e não sai. A categoria do template define o que pode ser dito e quanto custa: marketing para promoção, utilidade para pedido e agendamento, autenticação para código de uso único.

Que versão da Graph API devo usar em 2026?

A mais recente estável que você tiver testado. Em setembro de 2026, a v25.0 foi lançada em 18/02/2026 e vale até 29/07/2028; a v26.0 saiu em 29/07/2026. Cada versão vive cerca de dois anos depois da seguinte. A v20.0 é removida em 24/09/2026, e chamadas a uma versão expirada caem para a mais antiga disponível. A tabela oficial está na lista de versões da Graph API. Pelo Conexa a versão é do provedor, e você não a escreve na URL.

Próximo passo

O quickstart leva do token à primeira mensagem em poucos minutos, e o guia de webhooks tem o envelope completo com o schema de cada tipo de evento. Para conectar um número e gerar o primeiro token, veja os planos por número.

Leia também

Conecte o número que você já usa à API oficial do WhatsApp.

Sem trocar de número, sem perder o aplicativo e sem cobrança por mensagem da nossa parte. Você paga por número; a Meta, você paga direto.