← Blog

·

Como validar a assinatura do webhook do WhatsApp (X-Hub-Signature-256)

Atualizado em 28/10/2026

Para validar a assinatura do webhook do WhatsApp, calcule um HMAC SHA-256 do corpo da requisição usando o app secret do seu app da Meta como chave e compare o resultado com o valor do cabeçalho X-Hub-Signature-256, que vem no formato sha256=<hash>. Se os dois forem iguais, o evento veio da Meta e não foi alterado no caminho. O detalhe que mais derruba a validação é calcular o hash sobre o JSON já interpretado pelo framework, e não sobre os bytes exatos que chegaram.

Este guia mostra o passo a passo, código pronto para Node.js (Express), Python (FastAPI e Flask) e PHP, e os erros mais comuns. Se você ainda está montando o endpoint, comece por webhook da API do WhatsApp e volte aqui para a parte de segurança.

Por que validar a assinatura

A URL do seu webhook é pública. Qualquer pessoa que descobrir o endereço pode mandar um POST com um JSON no formato da Meta, simulando uma mensagem de cliente, um pagamento confirmado por botão ou um status delivered que nunca aconteceu. Sem validação, seu sistema aceita tudo.

A documentação de webhooks da Graph API diz que a Meta assina todas as notificações com SHA-256 e envia a assinatura no cabeçalho X-Hub-Signature-256, precedida de sha256=. A validação não é obrigatória, mas a própria Meta recomenda fazer. Na prática, é o que separa um webhook confiável de um endpoint aberto.

As duas requisições que o seu endpoint recebe

O mesmo endereço recebe dois tipos de chamada, e cada uma tem sua checagem:

RequisiçãoQuando aconteceO que validarResposta
GET de verificaçãoAo configurar ou alterar a URL no painel do apphub.mode igual a subscribe e hub.verify_token igual ao token que você definiu200 com o valor de hub.challenge
POST de eventoCada mensagem, status ou mudança na contaX-Hub-Signature-256 contra o HMAC do corpo bruto200 rápido; processamento em fila

Segundo a página Create a webhook endpoint, se a verificação for válida você responde 200 com o hub.challenge; se não for, responde qualquer coisa diferente de 200 (um 403, por exemplo). A mesma página exige certificado TLS válido no endpoint: certificados autoassinados não são aceitos.

Note que o verify token e o app secret são coisas diferentes. O verify token é uma string que você inventa e cadastra no painel; ele só serve para o GET. O app secret é gerado pela Meta e fica em Configurações do app > Básico; é ele que assina os POST.

Quer testar enquanto lê? Crie sua conta na Vectalk e envie a primeira mensagem pela API oficial. Criar conta grátis →

Passo a passo da validação

  1. Leia o corpo da requisição como bytes, antes de qualquer parser de JSON.
  2. Calcule HMAC-SHA256(chave = app secret, mensagem = corpo bruto) e converta para hexadecimal.
  3. Monte sha256= + hash e compare com o cabeçalho X-Hub-Signature-256 usando comparação em tempo constante.
  4. Se não bater, responda 401 ou 403 e descarte o evento.
  5. Se bater, faça o parse do JSON, coloque o evento numa fila e responda 200.

A comparação em tempo constante (crypto.timingSafeEqual, hmac.compare_digest, hash_equals) evita que um atacante descubra o hash byte a byte medindo o tempo de resposta.

Exemplo em Node.js com Express

O ponto central é usar express.raw() na rota do webhook, para que req.body seja um Buffer com os bytes originais:

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

const app = express();
const APP_SECRET = process.env.META_APP_SECRET;
const VERIFY_TOKEN = process.env.WEBHOOK_VERIFY_TOKEN;

// GET de verificação
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 === VERIFY_TOKEN) {
    return res.status(200).send(challenge);
  }
  return res.sendStatus(403);
});

// POST de eventos: corpo bruto só nesta rota
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const recebida = req.get('X-Hub-Signature-256') || '';
  const esperada = 'sha256=' + crypto
    .createHmac('sha256', APP_SECRET)
    .update(req.body)
    .digest('hex');

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

  const evento = JSON.parse(req.body.toString('utf8'));
  res.sendStatus(200);
  filaDeEventos.push(evento); // processe fora do ciclo da requisição
});

app.use(express.json()); // demais rotas podem usar JSON normalmente
app.listen(3000);

Se a aplicação já tem app.use(express.json()) declarado antes dessa rota, o corpo chega interpretado e o hash não bate. Duas saídas: declarar a rota do webhook antes do middleware global, ou guardar os bytes no próprio express.json:

app.use(express.json({
  verify: (req, _res, buf) => { req.rawBody = buf; },
}));
// depois, use req.rawBody no createHmac

Exemplo em Python com FastAPI

No FastAPI, await request.body() devolve os bytes exatos. Não use o modelo Pydantic do corpo para assinar:

import hashlib
import hmac
import json
import os

from fastapi import FastAPI, HTTPException, Request, Response

app = FastAPI()
APP_SECRET = os.environ["META_APP_SECRET"].encode()
VERIFY_TOKEN = os.environ["WEBHOOK_VERIFY_TOKEN"]


@app.get("/webhook")
async def verificar(request: Request):
    p = request.query_params
    if p.get("hub.mode") == "subscribe" and p.get("hub.verify_token") == VERIFY_TOKEN:
        return Response(content=p.get("hub.challenge", ""), media_type="text/plain")
    raise HTTPException(status_code=403)


@app.post("/webhook")
async def receber(request: Request):
    corpo = await request.body()
    recebida = request.headers.get("x-hub-signature-256", "")
    esperada = "sha256=" + hmac.new(APP_SECRET, corpo, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(recebida, esperada):
        raise HTTPException(status_code=401)

    evento = json.loads(corpo)
    # enfileire o evento e responda rápido
    return {"ok": True}

Exemplo em Python com Flask

No Flask, use request.get_data(), que devolve o corpo bruto:

import hashlib
import hmac
import os

from flask import Flask, abort, request

app = Flask(__name__)
APP_SECRET = os.environ["META_APP_SECRET"].encode()


@app.post("/webhook")
def receber():
    corpo = request.get_data()
    recebida = request.headers.get("X-Hub-Signature-256", "")
    esperada = "sha256=" + hmac.new(APP_SECRET, corpo, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(recebida, esperada):
        abort(401)
    evento = request.get_json()
    return "", 200

Exemplo em PHP

Em PHP, o corpo bruto vem de php://input. Atenção ao GET: o PHP troca pontos por sublinhados nos nomes de parâmetros, então hub.verify_token vira $_GET['hub_verify_token'].

<?php
$appSecret = getenv('META_APP_SECRET');
$verifyToken = getenv('WEBHOOK_VERIFY_TOKEN');

if ($_SERVER['REQUEST_METHOD'] === 'GET') {
    if (($_GET['hub_mode'] ?? '') === 'subscribe'
        && hash_equals($verifyToken, $_GET['hub_verify_token'] ?? '')) {
        echo $_GET['hub_challenge'] ?? '';
        exit;
    }
    http_response_code(403);
    exit;
}

$corpo = file_get_contents('php://input');
$recebida = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
$esperada = 'sha256=' . hash_hmac('sha256', $corpo, $appSecret);

if (!hash_equals($esperada, $recebida)) {
    http_response_code(401);
    exit;
}

$evento = json_decode($corpo, true);
http_response_code(200);

Erros comuns que fazem a assinatura não bater

Hash calculado sobre o JSON interpretado. É o erro número um. Ao fazer JSON.stringify(req.body) ou json.dumps(payload), você gera um texto diferente do original: espaços, ordem de chaves e escape de caracteres mudam. Um único byte diferente muda o hash inteiro. Assine sempre os bytes que chegaram.

Encoding trocado. Converter o corpo para string com um charset errado (latin-1 em vez de UTF-8, por exemplo) altera nomes com acento e emojis. Trabalhe com bytes até a validação terminar.

Secret errado. O valor certo é o app secret do app que está inscrito no webhook. Não é o token de acesso, não é o verify token e não é o secret de outro app do mesmo portfólio. Se você tem apps separados para teste e produção, cada ambiente precisa do seu.

Prefixo ignorado. O cabeçalho traz sha256= antes do hash. Ou você compara a string completa (como nos exemplos), ou remove o prefixo dos dois lados.

Middleware ou gateway alterando o corpo. Proxies, gateways de API e funções serverless às vezes reformatam ou codificam o corpo antes de entregar ao seu código. Se a validação falha só em produção, registre o tamanho do corpo recebido e compare com o Content-Length.

Secret trocado sem atualizar o servidor. Ao redefinir o app secret no painel, os eventos passam a chegar assinados com o novo valor. Se o servidor recusar tudo com 401, a Meta tenta reenviar: pela visão geral de webhooks, as entregas que não recebem 200 são repetidas com frequência decrescente por até 7 dias, e isso pode gerar eventos duplicados. Monitore a taxa de 401 do endpoint para pegar esse cenário cedo.

Boas práticas depois da validação

  • Responda rápido. Valide, enfileire e devolva 200. Processamento pesado (IA, CRM, banco) vai para um worker.
  • Trate duplicatas. Use o id da mensagem (wamid) ou do status como chave de idempotência, já que os reenvios podem repetir eventos.
  • Aceite payloads grandes. A visão geral de webhooks informa que um payload pode ter até 3 MB; confira o limite de corpo do seu servidor.
  • Não dependa de lista de IPs. A Meta muda seus endereços periodicamente e sugere mTLS no lugar de liberar IPs fixos.
  • Teste localmente com um túnel antes de subir; veja como testar o webhook do WhatsApp com ngrok.

Para gerar um evento assinado no seu próprio ambiente de teste e conferir o código, calcule o hash com o mesmo secret:

corpo='{"object":"whatsapp_business_account","entry":[]}'
assinatura=$(printf '%s' "$corpo" | openssl dgst -sha256 -hmac "$META_APP_SECRET" | sed 's/^.* //')
curl -X POST http://localhost:3000/webhook \
  -H "Content-Type: application/json" \
  -H "X-Hub-Signature-256: sha256=$assinatura" \
  -d "$corpo"

Como fazer isso com a Vectalk

A Vectalk é um proxy da Cloud API oficial: o Hub recebe os eventos da Meta e repassa às URLs que você cadastra o mesmo payload da documentação da Meta, sem formato próprio. Você registra quantas URLs quiser por instância com POST https://apis.vectalk.com.br/api/webhooks, e o endpoint deve responder 200 em menos de 20 segundos, conforme a documentação de webhooks da Vectalk. O cabeçalho X-Hub-Signature-256 descrito aqui é o que a Meta envia a quem recebe direto do app; para saber como autenticar a entrega feita pelo Hub no seu caso, consulte a documentação ou fale com o time pela página de contato. O parser do payload, a fila e a idempotência continuam iguais. Para começar, crie sua conta em apps.vectalk.com.br/register.

Com o webhook seguro, os próximos passos naturais são baixar a mídia que o cliente envia e controlar o ritmo de resposta para não esbarrar no throughput e rate limit da API. Para a visão geral da plataforma, veja o guia da API oficial do WhatsApp.

Perguntas frequentes

Onde encontro o app secret?

No painel de desenvolvedores da Meta, em Configurações do app > Básico, no campo “Chave secreta do app”. Guarde em variável de ambiente ou num gerenciador de segredos, nunca no repositório.

O verify token precisa ser igual ao app secret?

Não, e não deveria. O verify token é uma string que você escolhe e só serve para o GET de verificação. O app secret é gerado pela Meta e assina os eventos POST.

Por que a verificação GET passa e a assinatura POST falha?

Porque são checagens independentes. O GET só compara o verify token. Se o POST falha, quase sempre o hash está sendo calculado sobre o JSON interpretado ou com o secret de outro app.

Que código devo devolver quando a assinatura é inválida?

Qualquer código diferente de 200, como 401 ou 403. Lembre que entregas legítimas recusadas são reenviadas, então um secret desatualizado aparece como uma enxurrada de 401 e de eventos atrasados.

Preciso validar a assinatura em ambiente de teste?

Sim. Validar desde o primeiro dia evita que um código sem checagem vá para produção. Para testar sem a Meta, gere a assinatura com openssl como no exemplo acima. Os detalhes de payload estão em status de mensagem via webhook.


Fontes oficiais consultadas em 28/10/2026: Create a webhook endpoint (Meta), WhatsApp webhooks: visão geral (Meta), Webhooks da Graph API: getting started (Meta), Webhooks na documentação da Vectalk.

Teste a API oficial do WhatsApp hoje

Crie sua conta, gere o token e envie a primeira mensagem seguindo o guia da documentação. Mesmos payloads e webhooks da Cloud API da Meta.