Verificar la firma de un webhook
Cada envío lleva una firma HMAC para que compruebes que viene de la pasarela y que nadie alteró el cuerpo.
Algoritmo
- Toma el cuerpo exacto recibido (los bytes tal cual, sin volver a serializar el JSON).
- Arma el texto firmado:
x-payment-event-id+.+x-payment-timestamp+.+ cuerpo. - La clave es tu secreto sin el prefijo
whsec_, decodificado de Base64. - Calcula
HMAC-SHA256(clave, texto)y codifícalo en Base64. - Compara en tiempo constante con la parte que sigue a
v1,enx-payment-signature. - Rechaza el evento si
x-payment-timestampse aparta más de 5 minutos de tu reloj (protege contra repetición).
firma = base64( HMAC_SHA256( base64_decode(secreto sin "whsec_"), "{event_id}.{timestamp}.{cuerpo}" ) )
x-payment-signature: v1,<firma>
Verifica sobre el texto recibido. Si parseas y vuelves a generar el JSON, el orden de claves o los espacios cambian y la firma deja de coincidir.
Ejemplos
Los tres verificadores se prueban contra los mismos vectores de prueba (examples/webhook-verification/vectors.json), generados con una implementación independiente y comprobados contra el firmador del propio motor.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
/**
* Verifica un webhook del motor de pagos.
*
* @param {object} p
* @param {string} p.secret Secreto del webhook (`whsec_…`), el de `meta.secret` al registrarlo.
* @param {string} p.eventId Cabecera `x-payment-event-id`.
* @param {string} p.timestamp Cabecera `x-payment-timestamp` (epoch en segundos).
* @param {string} p.body Cuerpo EXACTO recibido (texto, sin reserializar el JSON).
* @param {string} p.signatureHeader Cabecera `x-payment-signature` (`v1,<base64>`).
* @param {number} [p.now] Epoch en segundos (para pruebas).
* @param {number} [p.toleranceSeconds=300]
*/
export function verifyWebhook({ secret, eventId, timestamp, body, signatureHeader, now, toleranceSeconds = 300 }) {
if (!/^\d+$/.test(timestamp ?? '')) return false;
const current = now ?? Math.floor(Date.now() / 1000);
if (Math.abs(current - Number(timestamp)) > toleranceSeconds) return false;
const encoded = secret.startsWith('whsec_') ? secret.slice('whsec_'.length) : secret;
const key = Buffer.from(encoded, 'base64');
const expected = createHmac('sha256', key).update(`${eventId}.${timestamp}.${body}`, 'utf8').digest();
// La cabecera puede traer varias firmas separadas por espacio ("v1,AAA v1,BBB"): basta con que una coincida.
return (signatureHeader ?? '')
.split(' ')
.filter((part) => part.startsWith('v1,'))
.some((part) => {
const candidate = Buffer.from(part.slice(3), 'base64');
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
});
}
Uso en Express (necesitas el cuerpo crudo):
import express from 'express';
import { verifyWebhook } from './verify.mjs';
const app = express();
app.post('/webhooks/pagos', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyWebhook({
secret: process.env.WEBHOOK_SECRET,
eventId: req.get('x-payment-event-id'),
timestamp: req.get('x-payment-timestamp'),
body: req.body.toString('utf8'),
signatureHeader: req.get('x-payment-signature'),
});
if (!ok) return res.sendStatus(401);
// …deduplica por el id del evento, guarda y responde rápido
res.sendStatus(200);
});
Python
import base64
import hashlib
import hmac
import time
def verify_webhook(secret: str, event_id: str, timestamp: str, body: str, signature_header: str,
now: float | None = None, tolerance_seconds: int = 300) -> bool:
"""Verifica un webhook del motor de pagos.
secret: el `whsec_…` de `meta.secret`. event_id: cabecera `x-payment-event-id`. timestamp: cabecera
`x-payment-timestamp`. body: el cuerpo EXACTO recibido (texto, sin reserializar). signature_header:
cabecera `x-payment-signature` (`v1,<base64>`).
"""
if not (timestamp or "").isdigit():
return False
current = time.time() if now is None else now
if abs(current - int(timestamp)) > tolerance_seconds:
return False
encoded = secret[len("whsec_"):] if secret.startswith("whsec_") else secret
key = base64.b64decode(encoded)
expected = hmac.new(key, f"{event_id}.{timestamp}.{body}".encode("utf-8"), hashlib.sha256).digest()
# La cabecera puede traer varias firmas separadas por espacio: basta con que una coincida.
for part in (signature_header or "").split(" "):
if not part.startswith("v1,"):
continue
try:
candidate = base64.b64decode(part[3:], validate=True)
except ValueError:
continue
if hmac.compare_digest(candidate, expected):
return True
return False
C#
static bool VerifyWebhook(string secret, string eventId, string timestamp, string body, string signatureHeader,
DateTimeOffset now, int toleranceSeconds = 300)
{
// `timestamp` es la cabecera x-payment-timestamp (epoch en segundos); `body` es el cuerpo EXACTO recibido.
if (!long.TryParse(timestamp, out var ts)) return false;
if (Math.Abs(now.ToUnixTimeSeconds() - ts) > toleranceSeconds) return false;
var encoded = secret.StartsWith("whsec_", StringComparison.Ordinal) ? secret["whsec_".Length..] : secret;
var key = Convert.FromBase64String(encoded);
var expected = HMACSHA256.HashData(key, Encoding.UTF8.GetBytes($"{eventId}.{timestamp}.{body}"));
// La cabecera puede traer varias firmas separadas por espacio: basta con que una coincida.
foreach (var part in (signatureHeader ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
if (!part.StartsWith("v1,", StringComparison.Ordinal)) continue;
try
{
var candidate = Convert.FromBase64String(part[3..]);
if (CryptographicOperations.FixedTimeEquals(candidate, expected)) return true;
}
catch (FormatException) { }
}
return false;
}
Vector de prueba
Úsalo para comprobar tu implementación antes de recibir tráfico real:
| Dato | Valor |
|---|---|
| Secreto | whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= |
x-payment-event-id | evt_01HZX3K9 |
x-payment-timestamp | 1700000000 |
| Cuerpo | {"id":"evt_01HZX3K9","type":"payment.approved","version":"1.0","source":"payment_engine","time":"2023-11-14T22:13:20.0000000Z","data":{"payment":{"id":"2f1b0c2e-9d4a-4c1e-8f0a-5a6c7d8e9f01","amount":"50000","currency":"COP","status":"paid"},"metadata":{"nota":"cobro de prueba ñandú"}}} |
x-payment-signature | v1,6uVQAY2ZQhld1/s4l4y7mps0p3ClPPvRw+Sg6qSNpCM= |
El archivo vectors.json trae además casos que deben fallar: cuerpo alterado, firma incorrecta, secreto equivocado, otro event_id, timestamp viejo o futuro, timestamp no numérico y cabecera sin v1.
Rotación del secreto
Al rotar (PATCH /api/merchant-webhooks/{id}/rotate-secret) el secreto anterior deja de servir de inmediato: los envíos siguientes se firman con el nuevo. Actualiza tu configuración antes de rotar o acepta que haya un breve intervalo con firmas inválidas.
La clave se obtiene decodificando de Base64 el secreto sin whsec_. Si un secreto no fuera Base64 válido, el motor usa el texto del secreto como clave; un verificador estándar no coincidiría. Los secretos que genera la pasarela siempre son Base64 válido, así que no debería ocurrir. Si te pasa, rota el secreto.