# Verificar la firma de los webhooks Fuente: /webhooks/webhooks-security/ Todos los webhooks que te envía Verificamex van **firmados** con el esquema de [Standard Webhooks](https://www.standardwebhooks.com). 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. > **Tu integración actual sigue funcionando** > > La firma viaja en headers HTTP adicionales; el cuerpo del webhook no cambia. Si todavía no verificas la firma, tu endpoint sigue recibiendo los mismos eventos. Te recomendamos agregar la verificación lo antes posible. *** ### Headers de cada envío | 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**. | *** ### Obtén tu clave de firma 1. Inicia sesión en el [panel de Verificamex Identidad](https://app.verificamex.com/identity/panel/login). 2. Entra a **Configuración > Desarrollo > Firma de webhooks**. 3. Copia la **Clave de firma** con el botón **Copiar**. En pantalla se muestra parcialmente oculta (usa **Mostrar** para verla completa), pero el botón copia la clave completa. Tiene el formato `whsec_` seguido de texto en Base64. 4. Guárdala en tu servidor como un secreto, por ejemplo en la variable de entorno `VERIFICAMEX_WEBHOOK_SECRET`. > **Trata la clave como una contraseña** > > No la incluyas en tu código fuente, en tu frontend ni en repositorios. Cada cuenta tiene su propia clave, y los webhooks se firman con la clave de la cuenta que creó la sesión o la validación. La clave se genera automáticamente la primera vez que entras a esa sección o que te enviamos un webhook. *** ### Cómo se verifica 1. **Lee el cuerpo crudo** de la petición, exactamente como llegó, antes de convertirlo a JSON. 2. **Revisa la hora**: rechaza el envío si `webhook-timestamp` está a más de 5 minutos de tu hora actual. 3. **Arma el contenido firmado** uniendo con puntos: `{webhook-id}.{webhook-timestamp}.{cuerpo crudo}`. 4. **Obtén la llave**: quita el prefijo `whsec_` a tu clave y decodifica el resto en Base64. La llave son esos bytes, no el texto de la clave. 5. **Calcula la firma**: HMAC-SHA256 del contenido del paso 3 con la llave del paso 4, codificado en Base64. 6. **Compara**: separa `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. 7. Si ninguna coincide, responde `401` y no proceses el evento. > **Verifica sobre el cuerpo crudo, no sobre el JSON ya interpretado** > > El cuerpo llega con caracteres escapados, por ejemplo `https:\/\/tusitio.com` y `Verificación`. Si conviertes el JSON a objeto y lo vuelves a serializar, los bytes cambian y la firma nunca va a coincidir. Lee el cuerpo como texto o bytes, verifica la firma y después conviértelo a JSON. *** ### Opción 1: librería oficial de Standard Webhooks (recomendada) 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](https://www.standardwebhooks.com). **Node.js** ```bash npm install standardwebhooks ``` ```js title="server.js" import 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); }); ``` **Python** ```bash pip install standardwebhooks ``` ```python title="app.py" import os from flask import Flask, abort, request from 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 "", 200 ``` *** ### Opción 2: implementación propia Si prefieres no agregar dependencias, estas funciones implementan los pasos de [Cómo se verifica](#cómo-se-verifica). Devuelven `true` solo si alguna firma coincide y el envío tiene menos de 5 minutos. **Node.js** ```js title="verificar-webhook.js" 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); }); } ``` ```js title="server.js" 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** ```php title="verificar_webhook.php" $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 title="webhook.php" $_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. **Python** ```python title="verificar_webhook.py" import base64 import hashlib import hmac import 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 ``` ```python title="app.py" @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 "", 200 ``` > **Consejo** > > Mantén sincronizado el reloj de tu servidor (NTP). Si tu hora se desfasa más de 5 minutos, rechazarás webhooks legítimos. *** ### Evita procesar dos veces el mismo evento Si 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. *** ### Cambiar (rotar) tu clave Puedes generar una clave nueva en cualquier momento desde el panel, con el botón **Regenerar clave** en **Configuración > Desarrollo > Firma de webhooks**. #### Antes de regenerarla Revisa estos tres puntos. Son las causas de casi todos los problemas al cambiar la clave: - **Tu verificación revisa todas las firmas del header.** Durante el cambio, `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. - **Puedes actualizar tu servidor en las próximas 24 horas.** Pasado ese plazo, la clave actual deja de funcionar. - **Sabes dónde está guardada la clave.** Todos los servidores y ambientes que reciben webhooks de tu cuenta usan la misma clave; actualízalos todos. #### Paso a paso 1. Presiona **Regenerar clave**. La ventana de confirmación te muestra la fecha y hora exactas en que dejará de funcionar tu clave actual. Marca la casilla de confirmación y presiona **Regenerar clave**. 2. La clave nueva se activa de inmediato y aparece en pantalla. Desde ese momento, y durante **24 horas**, cada webhook lleva dos firmas en `webhook-signature`: una con la clave nueva y otra con la anterior. El panel muestra hasta cuándo sigue funcionando la anterior. 3. Copia la clave nueva y actualízala en tu servidor (por ejemplo en `VERIFICAMEX_WEBHOOK_SECRET`). Como tu verificación acepta el webhook si cualquiera de las firmas coincide, no pierdes envíos mientras haces el cambio. 4. Pasadas las 24 horas, los webhooks se firman solo con la clave nueva. > **No regeneres dos veces seguidas** > > Durante las 24 horas de un cambio solo se conservan dos claves: la vigente y la anterior. Si regeneras otra vez antes de que termine el plazo, la clave más antigua deja de funcionar **de inmediato**. Si tu servidor todavía la usaba, empezará a rechazar los webhooks. Antes de volver a regenerar, confirma que tu servidor ya usa la clave que aparece en el panel. > **Si tu clave se filtró** > > Al regenerarla, marca **Revocar la clave actual de inmediato** en la ventana de confirmación. Tu clave actual deja de firmar al instante, sin el periodo de 24 horas. Los webhooks que lleguen antes de que actualices tu servidor no van a pasar la verificación. *** ### Prueba tu integración Usa [webhook.site](https://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. > **¿Y el campo optionals?** > > Puedes seguir enviando un token propio en `optionals` como capa adicional, como se describe en [Seguridad al usar Webhooks (V2)](/identity-verification/webhooks/webhooks-security/). La firma ya no depende de él: cubre todos los webhooks sin que tengas que enviar nada al crear la sesión.