Origen
El webhook lo envió Verificamex: solo quien tiene tu clave puede generar una firma válida.
Todos los webhooks que te envía Verificamex van firmados con el esquema de Standard Webhooks. Al verificar la firma confirmas tres cosas antes de procesar el evento:
Origen
El webhook lo envió Verificamex: solo quien tiene tu clave puede generar una firma válida.
Integridad
El cuerpo no se modificó en el camino: cambiar un solo byte invalida la firma.
Vigencia
El envío es reciente: la hora del envío forma parte de lo firmado, así que puedes rechazar reenvíos viejos (replay).
Aplica a todos los webhooks: sesiones de verificación (V2, V3 y pasaporte), validación de INE asíncrona, constancia CURP, recibos CFE, Penny Test y procesos por lote.
| Header | Ejemplo | Descripción |
|---|---|---|
webhook-id | 9db97292-be25-4917-b046-ad3ed1bfd083_e2a1b4b396ef9927 | Identificador de la notificación. Es el mismo en todos los reintentos de esa notificación; úsalo para descartar duplicados. |
webhook-timestamp | 1792085386 | Momento del envío en segundos Unix (UTC). Cambia en cada intento. |
webhook-signature | v1,SEsPX3qWvq080fEnSEF4B1kyTTWalVxOFZSk/zhaXvk= | Firma del envío con el prefijo de versión v1,. Durante un cambio de clave puede traer dos firmas separadas por un espacio. |
whsec_ seguido de texto en Base64.VERIFICAMEX_WEBHOOK_SECRET.webhook-timestamp está a más de 5 minutos de tu hora actual.{webhook-id}.{webhook-timestamp}.{cuerpo crudo}.whsec_ a tu clave y decodifica el resto en Base64. La llave son esos bytes, no el texto de la clave.webhook-signature por espacios y quita el prefijo v1, a cada entrada. El webhook es válido si cualquiera coincide con tu firma. Usa una comparación de tiempo constante.401 y no proceses el evento.Las librerías del estándar hacen todos los pasos anteriores por ti, incluida la tolerancia de 5 minutos y la comparación con varias firmas. Están disponibles para varios lenguajes en standardwebhooks.com.
npm install standardwebhooksimport express from 'express';import { Webhook } from 'standardwebhooks';
const app = express();const wh = new Webhook(process.env.VERIFICAMEX_WEBHOOK_SECRET);
// express.raw conserva el cuerpo crudo; no uses express.json() en esta ruta.app.post('/webhooks/verificamex', express.raw({ type: 'application/json' }), (req, res) => { let evento; try { evento = wh.verify(req.body.toString('utf8'), req.headers); } catch (err) { return res.status(401).send('Firma inválida'); }
// evento ya es el JSON del webhook, verificado. res.sendStatus(200);});pip install standardwebhooksimport os
from flask import Flask, abort, requestfrom standardwebhooks.webhooks import Webhook, WebhookVerificationError
app = Flask(__name__)wh = Webhook(os.environ["VERIFICAMEX_WEBHOOK_SECRET"])
@app.post("/webhooks/verificamex")def webhook(): try: evento = wh.verify(request.get_data(), request.headers) except WebhookVerificationError: abort(401)
# evento ya es el JSON del webhook, verificado. return "", 200Si prefieres no agregar dependencias, estas funciones implementan los pasos de Cómo se verifica. Devuelven true solo si alguna firma coincide y el envío tiene menos de 5 minutos.
import crypto from 'node:crypto';
const TOLERANCIA_SEGUNDOS = 5 * 60;
export function verificarWebhook(rawBody, headers, secret) { const id = headers['webhook-id']; const timestamp = headers['webhook-timestamp']; const firmas = headers['webhook-signature']; if (!id || !timestamp || !firmas) return false;
const ahora = Math.floor(Date.now() / 1000); if (Math.abs(ahora - Number(timestamp)) > TOLERANCIA_SEGUNDOS) return false;
const llave = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const esperada = crypto .createHmac('sha256', llave) .update(`${id}.${timestamp}.`) .update(rawBody) .digest();
return firmas.split(' ').some((entrada) => { const [version, valor] = entrada.split(','); if (version !== 'v1' || !valor) return false; const recibida = Buffer.from(valor, 'base64'); return recibida.length === esperada.length && crypto.timingSafeEqual(recibida, esperada); });}app.post('/webhooks/verificamex', express.raw({ type: 'application/json' }), (req, res) => { // req.body es un Buffer con el cuerpo crudo. if (!verificarWebhook(req.body, req.headers, process.env.VERIFICAMEX_WEBHOOK_SECRET)) { return res.status(401).send('Firma inválida'); }
const evento = JSON.parse(req.body.toString('utf8')); res.sendStatus(200);});<?php
function verificarWebhook(string $payload, array $headers, string $secret, int $tolerancia = 300): bool{ $id = $headers['webhook-id'] ?? null; $timestamp = $headers['webhook-timestamp'] ?? null; $firmas = $headers['webhook-signature'] ?? null; if (!$id || !$timestamp || !$firmas) { return false; }
if (abs(time() - (int) $timestamp) > $tolerancia) { return false; }
$llave = base64_decode(substr($secret, strlen('whsec_'))); $esperada = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$payload}", $llave, true));
foreach (explode(' ', $firmas) as $entrada) { [$version, $valor] = array_pad(explode(',', $entrada, 2), 2, ''); if ($version === 'v1' && hash_equals($esperada, $valor)) { return true; } }
return false;}<?php
require 'verificar_webhook.php';
$payload = file_get_contents('php://input');$headers = [ 'webhook-id' => $_SERVER['HTTP_WEBHOOK_ID'] ?? null, 'webhook-timestamp' => $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? null, 'webhook-signature' => $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? null,];
if (!verificarWebhook($payload, $headers, getenv('VERIFICAMEX_WEBHOOK_SECRET'))) { http_response_code(401); exit;}
$evento = json_decode($payload, true);http_response_code(200);En Laravel, usa $request->getContent() como cuerpo crudo y $request->header('webhook-id') (y los otros dos) para los headers.
import base64import hashlibimport hmacimport time
TOLERANCIA_SEGUNDOS = 5 * 60
def verificar_webhook(payload: bytes, headers, secret: str) -> bool: msg_id = headers.get("webhook-id") timestamp = headers.get("webhook-timestamp") firmas = headers.get("webhook-signature") if not msg_id or not timestamp or not firmas or not timestamp.isdigit(): return False
if abs(time.time() - int(timestamp)) > TOLERANCIA_SEGUNDOS: return False
llave = base64.b64decode(secret.removeprefix("whsec_")) contenido = f"{msg_id}.{timestamp}.".encode() + payload esperada = base64.b64encode(hmac.new(llave, contenido, hashlib.sha256).digest()).decode()
for entrada in firmas.split(" "): version, _, valor = entrada.partition(",") if version == "v1" and hmac.compare_digest(esperada, valor): return True return False@app.post("/webhooks/verificamex")def webhook(): # request.get_data() devuelve el cuerpo crudo en bytes. if not verificar_webhook(request.get_data(), request.headers, os.environ["VERIFICAMEX_WEBHOOK_SECRET"]): abort(401)
evento = request.get_json() return "", 200Si tu servidor no responde 200, Verificamex reintenta el envío. Un reintento trae el mismo webhook-id, con un webhook-timestamp y una firma nuevos. Guarda los webhook-id que ya procesaste: si te llega uno repetido, responde 200 y no vuelvas a ejecutar la acción.
Cada notificación nueva, por ejemplo cuando una sesión cambia de status, trae un webhook-id distinto.
Puedes generar una clave nueva en cualquier momento desde el panel, con el botón Regenerar clave en Configuración > Desarrollo > Firma de webhooks.
Revisa estos tres puntos. Son las causas de casi todos los problemas al cambiar la clave:
webhook-signature trae dos firmas y la primera es la de la clave nueva. Si tu código solo compara la primera, rechazará los webhooks en el momento en que regeneres la clave, aunque todavía tengas 24 horas para cambiarla. Las librerías de Standard Webhooks y los ejemplos de esta página ya revisan todas.webhook-signature: una con la clave nueva y otra con la anterior. El panel muestra hasta cuándo sigue funcionando la anterior.VERIFICAMEX_WEBHOOK_SECRET). Como tu verificación acepta el webhook si cualquiera de las firmas coincide, no pierdes envíos mientras haces el cambio.Usa webhook.site como URL de webhook en una sesión de prueba para ver los headers webhook-id, webhook-timestamp y webhook-signature junto con el cuerpo exacto que envía Verificamex. Con esos valores y tu clave puedes probar tu función de verificación localmente.