← Blog

·

Cómo validar la firma del webhook de WhatsApp (X-Hub-Signature-256)

Actualizado el 28/10/2026

Para validar la firma del webhook de WhatsApp, calcula un HMAC SHA-256 del cuerpo de la solicitud usando como clave el app secret (clave secreta) de tu app de Meta y compáralo con el encabezado X-Hub-Signature-256, que llega con el formato sha256=<hash>. Si coinciden, el evento vino de Meta y no fue alterado en el camino. El error que más rompe la validación es calcular el hash sobre el JSON que tu framework ya interpretó, y no sobre los bytes exactos que llegaron.

Esta guía muestra el paso a paso, código listo para Node.js (Express), Python (FastAPI y Flask) y PHP, y los errores más comunes. Para el panorama general de la plataforma, consulta nuestra guía de WhatsApp Cloud API.

Por qué validar la firma

La URL de tu webhook es pública. Cualquiera que la descubra puede enviar un POST con un JSON con el formato de Meta, simulando el mensaje de un cliente, un botón que confirma un pago o un estado delivered que nunca ocurrió. Sin validación, tu sistema acepta todo.

La documentación de webhooks de la Graph API indica que Meta firma todas las notificaciones con SHA-256 y envía la firma en el encabezado X-Hub-Signature-256, precedida de sha256=. Validarla no es obligatorio, pero Meta lo recomienda. En la práctica, es lo que separa un webhook confiable de un endpoint abierto.

Las dos solicitudes que recibe tu endpoint

La misma URL recibe dos tipos de llamadas, cada una con su verificación:

SolicitudCuándo ocurreQué validarRespuesta
GET de verificaciónAl configurar o cambiar la URL en el panel de la apphub.mode igual a subscribe y hub.verify_token igual al token que definiste200 con el valor de hub.challenge
POST de eventoCada mensaje, estado o cambio en la cuentaX-Hub-Signature-256 contra el HMAC del cuerpo sin procesar200 rápido; procesamiento en cola

Según la página Create a webhook endpoint, si la verificación es válida respondes 200 con el hub.challenge; si no, respondes cualquier cosa distinta de 200 (un 403, por ejemplo). La misma página exige un certificado TLS válido en el endpoint: no se aceptan certificados autofirmados.

El verify token y el app secret son cosas distintas. El verify token es una cadena que tú inventas y registras en el panel; solo sirve para el GET. El app secret lo genera Meta, está en Configuración de la app > Básica y es el que firma los POST.

¿Quieres probar mientras lees? Crea tu cuenta en Vectalk y envía el primer mensaje por la API oficial. Crear cuenta gratis →

Paso a paso de la validación

  1. Lee el cuerpo de la solicitud como bytes, antes de cualquier parser de JSON.
  2. Calcula HMAC-SHA256(clave = app secret, mensaje = cuerpo sin procesar) y conviértelo a hexadecimal.
  3. Arma sha256= + hash y compáralo con el encabezado X-Hub-Signature-256 usando una comparación de tiempo constante.
  4. Si no coincide, responde 401 o 403 y descarta el evento.
  5. Si coincide, interpreta el JSON, pon el evento en una cola y responde 200.

La comparación de tiempo constante (crypto.timingSafeEqual, hmac.compare_digest, hash_equals) evita que un atacante adivine el hash byte por byte midiendo el tiempo de respuesta.

Ejemplo en Node.js con Express

La clave es usar express.raw() en la ruta del webhook, para que req.body sea un Buffer con los bytes originales:

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 verificación
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: cuerpo sin procesar solo en esta ruta
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const recibida = req.get('X-Hub-Signature-256') || '';
  const esperada = 'sha256=' + crypto
    .createHmac('sha256', APP_SECRET)
    .update(req.body)
    .digest('hex');

  const a = Buffer.from(recibida);
  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);
  colaDeEventos.push(evento); // procesa fuera del ciclo de la solicitud
});

app.use(express.json()); // las demás rutas pueden usar JSON normalmente
app.listen(3000);

Si tu app ya declara app.use(express.json()) antes de esta ruta, el cuerpo llega interpretado y el hash no coincide. Hay dos salidas: declarar la ruta del webhook antes del middleware global o guardar los bytes en el propio express.json:

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

Ejemplo en Python con FastAPI

En FastAPI, await request.body() devuelve los bytes exactos. No firmes el modelo Pydantic del cuerpo:

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 recibir(request: Request):
    cuerpo = await request.body()
    recibida = request.headers.get("x-hub-signature-256", "")
    esperada = "sha256=" + hmac.new(APP_SECRET, cuerpo, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(recibida, esperada):
        raise HTTPException(status_code=401)

    evento = json.loads(cuerpo)
    # encola el evento y responde rápido
    return {"ok": True}

Ejemplo en Python con Flask

En Flask, usa request.get_data(), que devuelve el cuerpo sin procesar:

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 recibir():
    cuerpo = request.get_data()
    recibida = request.headers.get("X-Hub-Signature-256", "")
    esperada = "sha256=" + hmac.new(APP_SECRET, cuerpo, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(recibida, esperada):
        abort(401)
    evento = request.get_json()
    return "", 200

Ejemplo en PHP

En PHP, el cuerpo sin procesar viene de php://input. Cuidado con el GET: PHP reemplaza los puntos por guiones bajos en los nombres de parámetros, así que hub.verify_token se convierte en $_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;
}

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

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

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

Errores comunes que hacen que la firma no coincida

Hash calculado sobre el JSON interpretado. Es el error número uno. JSON.stringify(req.body) o json.dumps(payload) generan un texto distinto del original: cambian espacios, orden de claves y escape de caracteres. Un solo byte distinto cambia todo el hash. Firma siempre los bytes que llegaron.

Codificación equivocada. Convertir el cuerpo a texto con un charset incorrecto (latin-1 en vez de UTF-8, por ejemplo) altera nombres con tilde y emojis. Trabaja con bytes hasta terminar la validación.

Secret equivocado. El valor correcto es el app secret de la app suscrita al webhook. No es el token de acceso, ni el verify token, ni el secret de otra app del mismo portafolio. Si tienes apps separadas para pruebas y producción, cada entorno necesita el suyo.

Prefijo ignorado. El encabezado trae sha256= antes del hash. O comparas la cadena completa (como en los ejemplos) o quitas el prefijo de ambos lados.

Middleware o gateway que altera el cuerpo. Proxies, gateways de API y funciones serverless a veces reformatean o codifican el cuerpo antes de entregarlo a tu código. Si la validación falla solo en producción, registra el tamaño del cuerpo recibido y compáralo con el Content-Length.

Secret cambiado sin actualizar el servidor. Al restablecer el app secret en el panel, los eventos pasan a llegar firmados con el nuevo valor. Si tu servidor rechaza todo con 401, Meta reintenta: según la descripción general de webhooks, las entregas que no reciben 200 se repiten con frecuencia decreciente durante hasta 7 días, lo que puede generar eventos duplicados. Monitorea la tasa de 401 del endpoint para detectarlo a tiempo.

Buenas prácticas después de validar

  • Responde rápido. Valida, encola y devuelve 200. El trabajo pesado (IA, CRM, base de datos) va a un worker.
  • Maneja duplicados. Usa el id del mensaje (wamid) o del estado como clave de idempotencia, ya que los reintentos pueden repetir eventos.
  • Acepta payloads grandes. La descripción general de webhooks indica que un payload puede tener hasta 3 MB; revisa el límite de cuerpo de tu servidor.
  • No dependas de listas de IP. Meta cambia sus direcciones periódicamente y sugiere mTLS en su lugar.
  • Prueba localmente con un túnel antes de desplegar; mira cómo probar el webhook de WhatsApp con ngrok.

Para enviar un evento firmado a tu propio entorno de prueba, calcula el hash con el mismo secret:

cuerpo='{"object":"whatsapp_business_account","entry":[]}'
firma=$(printf '%s' "$cuerpo" | 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=$firma" \
  -d "$cuerpo"

Cómo hacerlo con Vectalk

Vectalk es un proxy de la Cloud API oficial: el Hub recibe los eventos de Meta y reenvía a las URL que registras el mismo payload de la documentación de Meta, sin formato propio. Puedes registrar las URL que necesites por instancia con POST https://apis.vectalk.com.br/api/webhooks, y tu endpoint debe responder 200 en menos de 20 segundos, según la documentación de webhooks de Vectalk. El encabezado X-Hub-Signature-256 descrito aquí es el que Meta envía a quien recibe directamente desde la app; para saber cómo autenticar la entrega que hace el Hub en tu caso, consulta la documentación o habla con el equipo en la página de contacto. El parser del payload, la cola y la idempotencia no cambian. Para empezar, crea tu cuenta en apps.vectalk.com.br/register.

Con el webhook protegido, los siguientes pasos naturales son descargar los archivos que envía el cliente y controlar el ritmo de respuesta para no chocar con el throughput y el rate limit de la API.

Preguntas frecuentes

¿Dónde encuentro el app secret?

En el panel de desarrolladores de Meta, en Configuración de la app > Básica, campo «Clave secreta de la app». Guárdalo en una variable de entorno o en un gestor de secretos, nunca en el repositorio.

¿El verify token debe ser igual al app secret?

No, y no debería. El verify token es una cadena que eliges y solo sirve para el GET de verificación. El app secret lo genera Meta y firma los eventos POST.

¿Por qué pasa la verificación GET y falla la firma del POST?

Porque son verificaciones independientes. El GET solo compara el verify token. Cuando el POST falla, casi siempre el hash se calcula sobre el JSON interpretado o con el secret de otra app.

¿Qué código debo devolver si la firma es inválida?

Cualquier código distinto de 200, como 401 o 403. Recuerda que las entregas legítimas rechazadas se reintentan, así que un secret desactualizado aparece como una avalancha de 401 y de eventos atrasados.

¿Necesito validar la firma en el entorno de pruebas?

Sí. Validar desde el primer día evita que llegue a producción código sin verificación. Para probar sin Meta, genera la firma con openssl como en el ejemplo anterior. Los detalles del payload están en estado de mensajes vía webhook.


Fuentes oficiales consultadas el 28/10/2026: Create a webhook endpoint (Meta), WhatsApp webhooks: descripción general (Meta), Webhooks de la Graph API: getting started (Meta), Webhooks en la documentación de Vectalk.

Prueba hoy la API oficial de WhatsApp

Crea tu cuenta, genera el token y envía el primer mensaje con la guía de la documentación. Los mismos payloads y webhooks de la Cloud API de Meta.