Saltar al contenido principal

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​

  1. Toma el cuerpo exacto recibido (los bytes tal cual, sin volver a serializar el JSON).
  2. Arma el texto firmado: x-payment-event-id + . + x-payment-timestamp + . + cuerpo.
  3. La clave es tu secreto sin el prefijo whsec_, decodificado de Base64.
  4. Calcula HMAC-SHA256(clave, texto) y codifícalo en Base64.
  5. Compara en tiempo constante con la parte que sigue a v1, en x-payment-signature.
  6. Rechaza el evento si x-payment-timestamp se 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>
No reserialices el cuerpo

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​

examples/webhook-verification/node/verify.mjs
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​

examples/webhook-verification/python/verify.py
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#​

examples/webhook-verification/csharp/verify.cs
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:

DatoValor
Secretowhsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
x-payment-event-idevt_01HZX3K9
x-payment-timestamp1700000000
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-signaturev1,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.

Detalle de implementación

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.