Verificación de firma de webhook
Cómo verificar la autenticidad de los webhooks de Bre-B participant usando HMAC-SHA256
Cada webhook entregado por Mono está firmado para que puedas confirmar que viene de nosotros y que no fue alterado en tránsito. Esta guía recorre el algoritmo de firma, una implementación de referencia en TypeScript y cómo conectarlo en Express.js.
Algoritmo de firma
Los webhooks se firman con HMAC-SHA256. La firma se calcula sobre la concatenación del timestamp de entrega y el body crudo del request:
signed_payload = "<unix_timestamp>.<raw_body>"
signature = HMAC-SHA256(secret, signed_payload)Header de firma
El header Mono-Signature lleva tanto el timestamp como la firma:
Mono-Signature: t=<unix_timestamp>,v1=<signature_hex>Ejemplo:
Mono-Signature: t=1766002441,v1=62afda2079925823b390e1199060d793aa50d64ec9d7bf184f5b7e96c8bf411cImplementación de referencia (TypeScript)
import { createHmac, timingSafeEqual } from 'crypto';
interface WebhookVerificationResult {
valid: boolean;
error?: string;
}
function parseSignatureHeader(header: string): {
timestamp: string | null;
signatures: string[];
} {
const parts = header.split(',');
let timestamp: string | null = null;
const signatures: string[] = [];
for (const part of parts) {
const [key, value] = part.split('=');
if (key === 't') timestamp = value;
else if (key === 'v1') signatures.push(value);
}
return { timestamp, signatures };
}
function computeSignature(timestamp: string, payload: string, secret: string): string {
const signedPayload = `${timestamp}.${payload}`;
return createHmac('sha256', secret).update(signedPayload).digest('hex');
}
/**
* @param signatureHeader El valor del header "Mono-Signature"
* @param rawBody El body crudo del request (deben ser los bytes exactos recibidos)
* @param secret El secret de firma del webhook (e.g., "whsec_xxx")
* @param toleranceSeconds Edad máxima del webhook en segundos (default: 300)
*/
export function verifyWebhookSignature(
signatureHeader: string,
rawBody: string,
secret: string,
toleranceSeconds: number = 300
): WebhookVerificationResult {
const { timestamp, signatures } = parseSignatureHeader(signatureHeader);
if (!timestamp) return { valid: false, error: 'Missing timestamp in signature header' };
if (signatures.length === 0) return { valid: false, error: 'Missing signature in header' };
const timestampNum = parseInt(timestamp, 10);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestampNum) > toleranceSeconds) {
return { valid: false, error: 'Timestamp outside tolerance window' };
}
const expectedSignature = computeSignature(timestamp, rawBody, secret);
const expectedBuffer = Buffer.from(expectedSignature, 'hex');
for (const signature of signatures) {
const signatureBuffer = Buffer.from(signature, 'hex');
if (
expectedBuffer.length === signatureBuffer.length &&
timingSafeEqual(expectedBuffer, signatureBuffer)
) {
return { valid: true };
}
}
return { valid: false, error: 'Signature mismatch' };
}Usándolo en Express.js
Captura el body crudo
Tienes que verificar contra los bytes exactos del request entrante. Si Express parsea y
re-serializa el JSON, el string resultante va a diferir (orden de keys, espacios) y la firma
ya no coincidirá. Usa express.raw() específicamente para la ruta del webhook.
import express from 'express';
import { verifyWebhookSignature } from './webhook_verification';
const app = express();
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString();
const signatureHeader = req.headers['mono-signature'] as string;
const secret = process.env.MONO_WEBHOOK_SECRET!;
const result = verifyWebhookSignature(signatureHeader, rawBody, secret);
if (!result.valid) {
return res.status(401).send(result.error);
}
const payload = JSON.parse(rawBody);
// Procesa payload...
res.status(200).send();
});Ejemplo trabajado
Entradas
- Header:
t=1766002441,v1=62afda2079925823b390e1199060d793aa50d64ec9d7bf184f5b7e96c8bf411c - Secret:
whsec_1w5dFdWSaGV7qiTpf0VGqRk62rG2FSknb - Body crudo:
{
"timestamp": "2025-12-17T20:14:01.947520Z",
"event": {
"data": {
"id": "bbot_031uOJ6qb0sVclNseQfItQ",
"state": "failed",
"description": "Comfama",
"query": { "value": "@SBV567", "format": "plain_key" },
"target": {
"id": "bbtgt_031uOJ6uISEH2QbuvZlW3M",
"key_value": "@SBV567",
"key_type": "alphanumeric",
"transaction_amount": null,
"spbvi": "CRB",
"resolution_type": "plain_key",
"payment_id": null,
"creditor_account": {
"type": "savings_account",
"number": "892893",
"currency_code": "COP"
},
"creditor": {
"type": "natural",
"full_name": "Juan Carlos Perez Gomez",
"document_type": "CC",
"document_number": "123143455"
},
"participant_nit": "1431701149"
},
"amount": { "currency": "COP", "amount": 100 },
"external_id": "21752385-357e-450f-94a3-d9a0984da291::5f283bc8-9ee7-404b-8952-5082e5c181a7",
"inserted_at": "2025-12-17T20:13:57.545628Z",
"updated_at": "2025-12-17T20:13:57.589888Z",
"payment_id": null,
"state_reason": "target_creditor_mismatch",
"resolution_request_id": "bbtgr_031uOJ6qOnaxydvH1CZB3q",
"timemarks": {},
"expected_creditor": { "document_type": "CC", "document_number": "21482961" }
},
"type": "outgoing_transfer.created"
}
}Pasos
- Extrae
timestamp = "1766002441"ysignature = "62afda20...c8bf411c"del header. - Construye
signed_payload = "1766002441.{raw_body}". - Calcula
HMAC-SHA256(secret, signed_payload). - Compara con la firma del header usando una comparación timing-safe.
La firma calculada coincide byte a byte con la firma del header, así que el request es válido.
Buenas prácticas
- Usa el body crudo. Nunca re-serialices JSON antes de verificar — el orden de keys y los espacios no están garantizados.
- Aplica una tolerancia de timestamp. El default de 5 minutos alcanza para absorber el clock skew y rechazar webhooks replay. Ajústalo más arriba o más abajo según tu modelo de amenazas.
- Compara con
timingSafeEqual. Las comparaciones de igualdad estándar filtran información por canales laterales de timing. - Protege tu secret. El secret de firma (prefijo
whsec_) es el secret compartido entre Mono y tu servicio. Mantenlo fuera de logs y del control de versiones, y rótalo si llega a quedar expuesto.