Mono Colombia
Bre-B Participant

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=62afda2079925823b390e1199060d793aa50d64ec9d7bf184f5b7e96c8bf411c

Implementació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

  1. Extrae timestamp = "1766002441" y signature = "62afda20...c8bf411c" del header.
  2. Construye signed_payload = "1766002441.{raw_body}".
  3. Calcula HMAC-SHA256(secret, signed_payload).
  4. 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

  1. Usa el body crudo. Nunca re-serialices JSON antes de verificar — el orden de keys y los espacios no están garantizados.
  2. 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.
  3. Compara con timingSafeEqual. Las comparaciones de igualdad estándar filtran información por canales laterales de timing.
  4. 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.

En esta página