Seguridad de webhooks
Cada webhook que envía Verificamex Firma va firmado. Validar esa firma te asegura tres cosas:
- Lo envió Verificamex Firma, y no alguien que conoce la URL de tu webhook.
- Nadie modificó el contenido en el camino.
- No es un aviso viejo que alguien capturó y volvió a enviar.
No necesitas activar nada: la firma viene en todos los avisos. Validarla es opcional, pero te lo recomendamos en cualquier integración en producción, sobre todo si un aviso dispara acciones importantes en tu sistema (liberar un pago, activar una cuenta, entregar un producto).
Aplica a todos los webhooks: los de documentos y los de contenedores.
1. Obtén tu clave de webhooks
Sección titulada «1. Obtén tu clave de webhooks»- Inicia sesión en el panel de Verificamex Firma.
- Entra a Configuración → API desarrolladores.
- En la sección Clave de webhooks, presiona Copiar.
La clave empieza con whsec_ seguida de 64 caracteres. Se genera sola la primera vez; no hay que solicitarla.
¿Con qué clave se firma cada aviso?
Sección titulada «¿Con qué clave se firma cada aviso?»Cada cuenta tiene su propia clave, y es distinta de tu API key.
- Documentos: se firman con la clave de la cuenta que creó el documento, es decir, la dueña de la API key con la que hiciste la petición. Si trabajas en una carpeta compartida, usa tu propia clave, no la del dueño de la carpeta.
- Si el documento no tiene registrado quién lo creó, se usa la clave de la cuenta dueña de la carpeta de trabajo.
- Contenedores: se firman con la clave de la cuenta dueña del contenedor.
2. Qué trae cada aviso
Sección titulada «2. Qué trae cada aviso»Además del cuerpo JSON, cada aviso trae estos encabezados:
| Encabezado | Ejemplo | Qué es |
|---|---|---|
X-Verificamex-Firma-Signature | t=1760306642,v1=672644ed…01fe1097 | t es el momento del envío en segundos Unix. v1 es la firma: un HMAC-SHA256 en hexadecimal (64 caracteres en minúsculas). |
X-Verificamex-Firma-Event-Id | ac110005-a022-1c20-81a0-22ba06f60011 | Identificador del aviso. Es el mismo en todos los reintentos de un aviso, así que te sirve para detectar duplicados. |
Content-Type | application/json | Formato del cuerpo. |
3. Valida la firma
Sección titulada «3. Valida la firma»- Lee el cuerpo tal cual llegó, como texto o bytes, antes de convertirlo a JSON.
- Separa
tyv1del encabezadoX-Verificamex-Firma-Signature. - Arma el texto firmado: el valor de
t, un punto y el cuerpo. Por ejemplo:1760306642.{"id":"…","signed":3,…}. - Calcula un HMAC-SHA256 de ese texto usando tu clave completa, incluido el
whsec_, como llave. Exprésalo en hexadecimal. - Compara tu resultado con
v1usando una comparación de tiempo constante (las funciones de los ejemplos ya lo hacen). - Revisa la antigüedad: si
ttiene más de 5 minutos de diferencia con tu hora actual, descarta el aviso. - Si todo coincide, responde
2xxy procesa el aviso. Si no, responde401y descártalo.
Ejemplos
Sección titulada «Ejemplos»Cada ejemplo trae una función de validación que puedes copiar tal cual, y su uso en un servidor.
const crypto = require('crypto');
const TOLERANCE_SECONDS = 300; // 5 minutos
function verifyVerificamexFirmaSignature(rawBody, signatureHeader, secret) { if (!signatureHeader) return false;
const parts = Object.fromEntries( signatureHeader.split(',').map((part) => part.split('=')) ); const timestamp = Number(parts.t); if (!Number.isInteger(timestamp) || !parts.v1) return false;
// Rechaza avisos viejos que alguien haya capturado y reenviado. if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.`) .update(rawBody) // el cuerpo tal cual llegó (Buffer o string) .digest(); const received = Buffer.from(parts.v1, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);}Con Express, usa express.raw en la ruta del webhook para recibir el cuerpo sin convertir:
const express = require('express');
const WEBHOOK_SECRET = process.env.VERIFICAMEX_FIRMA_WEBHOOK_SECRET; // whsec_...const app = express();
app.post('/webhooks/verificamex-firma', express.raw({ type: 'application/json' }), (req, res) => { const signature = req.get('X-Verificamex-Firma-Signature'); if (!verifyVerificamexFirmaSignature(req.body, signature, WEBHOOK_SECRET)) { return res.sendStatus(401); }
const document = JSON.parse(req.body.toString('utf8')); const eventId = req.get('X-Verificamex-Firma-Event-Id'); res.sendStatus(200); // Procesa `document`. Usa `eventId` para ignorar avisos repetidos.});import hashlibimport hmacimport time
TOLERANCE_SECONDS = 300 # 5 minutos
def verify_verificamex_firma_signature(raw_body: bytes, signature_header: str | None, secret: str) -> bool: if not signature_header: return False
parts = dict(part.split("=", 1) for part in signature_header.split(",") if "=" in part) try: timestamp = int(parts["t"]) received = parts["v1"] except (KeyError, ValueError): return False
# Rechaza avisos viejos que alguien haya capturado y reenviado. if abs(time.time() - timestamp) > TOLERANCE_SECONDS: return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, received)Con Flask, request.get_data() te da el cuerpo sin convertir:
import osfrom flask import Flask, abort, request
WEBHOOK_SECRET = os.environ["VERIFICAMEX_FIRMA_WEBHOOK_SECRET"] # whsec_...app = Flask(__name__)
@app.post("/webhooks/verificamex-firma")def verificamex_firma_webhook(): signature = request.headers.get("X-Verificamex-Firma-Signature") if not verify_verificamex_firma_signature(request.get_data(), signature, WEBHOOK_SECRET): abort(401)
document = request.get_json() event_id = request.headers.get("X-Verificamex-Firma-Event-Id") # Procesa `document`. Usa `event_id` para ignorar avisos repetidos. return "", 200<?php
const TOLERANCE_SECONDS = 300; // 5 minutos
function verifyVerificamexFirmaSignature(string $rawBody, ?string $signatureHeader, string $secret): bool{ if (!$signatureHeader) { return false; }
$parts = []; foreach (explode(',', $signatureHeader) as $part) { [$key, $value] = array_pad(explode('=', $part, 2), 2, null); $parts[$key] = $value; } if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) { return false; }
// Rechaza avisos viejos que alguien haya capturado y reenviado. $timestamp = (int) $parts['t']; if (abs(time() - $timestamp) > TOLERANCE_SECONDS) { return false; }
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); return hash_equals($expected, $parts['v1']);}php://input te da el cuerpo sin convertir:
<?php
$rawBody = file_get_contents('php://input');$signature = $_SERVER['HTTP_X_VERIFICAMEX_FIRMA_SIGNATURE'] ?? null;$secret = getenv('VERIFICAMEX_FIRMA_WEBHOOK_SECRET'); // whsec_...
if (!verifyVerificamexFirmaSignature($rawBody, $signature, $secret)) { http_response_code(401); exit;}
$document = json_decode($rawBody, true);$eventId = $_SERVER['HTTP_X_VERIFICAMEX_FIRMA_EVENT_ID'] ?? null;// Procesa $document. Usa $eventId para ignorar avisos repetidos.http_response_code(200);import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;import java.nio.charset.StandardCharsets;import java.security.MessageDigest;import java.time.Instant;import java.util.HashMap;import java.util.HexFormat;import java.util.Map;
public final class VerificamexFirmaWebhookVerifier {
private static final long TOLERANCE_SECONDS = 300; // 5 minutos
public static boolean verify(byte[] rawBody, String signatureHeader, String secret) { if (signatureHeader == null) { return false; }
Map<String, String> parts = new HashMap<>(); for (String part : signatureHeader.split(",")) { String[] keyValue = part.split("=", 2); if (keyValue.length == 2) { parts.put(keyValue[0], keyValue[1]); } }
long timestamp; try { timestamp = Long.parseLong(parts.get("t")); } catch (NumberFormatException e) { return false; } String received = parts.get("v1");
// Rechaza avisos viejos que alguien haya capturado y reenviado. if (received == null || Math.abs(Instant.now().getEpochSecond() - timestamp) > TOLERANCE_SECONDS) { return false; }
try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8)); String expected = HexFormat.of().formatHex(mac.doFinal(rawBody)); return MessageDigest.isEqual( expected.getBytes(StandardCharsets.UTF_8), received.getBytes(StandardCharsets.UTF_8) ); } catch (Exception e) { return false; } }}Con Spring, recibe el cuerpo como byte[] para tenerlo sin convertir. Requiere Java 17 o superior por HexFormat.
@PostMapping("/webhooks/verificamex-firma")public ResponseEntity<Void> verificamexFirmaWebhook( @RequestBody byte[] rawBody, @RequestHeader(value = "X-Verificamex-Firma-Signature", required = false) String signature, @RequestHeader(value = "X-Verificamex-Firma-Event-Id", required = false) String eventId) {
if (!VerificamexFirmaWebhookVerifier.verify(rawBody, signature, webhookSecret)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); }
// Convierte rawBody a JSON con tu librería (por ejemplo, Jackson) y procésalo. // Usa eventId para ignorar avisos repetidos. return ResponseEntity.ok().build();}Prueba tu implementación
Sección titulada «Prueba tu implementación»Con estos datos, tu cálculo del paso 4 debe dar exactamente el valor de v1:
| Dato | Valor |
|---|---|
| Clave | whsec_test |
t | 1760306642 |
| Cuerpo | {"id":"doc-1","signed":3} |
v1 esperado | 672644edb5a94127822b5390b7714887895895d01e320718872dbb4c01fe1097 |
También puedes calcularlo desde la terminal:
printf '%s' '1760306642.{"id":"doc-1","signed":3}' | openssl dgst -sha256 -hmac 'whsec_test'Reintentos y avisos repetidos
Sección titulada «Reintentos y avisos repetidos»- Cada reintento se firma de nuevo, con un
tactualizado. Un reintento legítimo nunca se rechaza por antigüedad. X-Verificamex-Firma-Event-Idse repite en los reintentos de un mismo aviso. Guárdalo y, si ya lo procesaste, responde200sin volver a procesarlo.- Si respondes con un error, por ejemplo
401por firma inválida, el aviso se reintenta hasta completar 3 intentos en total.
Regenerar la clave
Sección titulada «Regenerar la clave»Si crees que tu clave se filtró, genera una nueva en Configuración → API desarrolladores → Regenerar clave de webhooks.