Mono Colombia

Webhook Signature Verification

How to verify the authenticity of Bre-B participant webhooks using HMAC-SHA256

Every webhook delivered by Mono is signed so you can confirm it came from us and hasn't been tampered with in transit. This guide walks through the signing algorithm, a reference TypeScript implementation, and how to wire it up in Express.js.

Signing algorithm

Webhooks are signed with HMAC-SHA256. The signature is computed over a concatenation of the delivery timestamp and the raw request body:

signed_payload = "<unix_timestamp>.<raw_body>"
signature      = HMAC-SHA256(secret, signed_payload)

Signature header

The Mono-Signature header carries both the timestamp and the signature:

Mono-Signature: t=<unix_timestamp>,v1=<signature_hex>

Example:

Mono-Signature: t=1766002441,v1=62afda2079925823b390e1199060d793aa50d64ec9d7bf184f5b7e96c8bf411c

Reference implementation (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  The "Mono-Signature" header value
 * @param rawBody          The raw request body (must be the exact bytes received)
 * @param secret           The webhook signing secret (e.g., "whsec_xxx")
 * @param toleranceSeconds Max age of the webhook in seconds (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' };
}

Using it in Express.js

Capture the raw body

You must verify against the exact bytes of the incoming request. If Express parses and re-serializes the JSON, the resulting string will differ (key order, whitespace) and the signature will no longer match. Use express.raw() for the webhook route specifically.

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);
  // Process payload...
  res.status(200).send();
});

Worked example

Inputs

  • Header: t=1766002441,v1=62afda2079925823b390e1199060d793aa50d64ec9d7bf184f5b7e96c8bf411c
  • Secret: whsec_1w5dFdWSaGV7qiTpf0VGqRk62rG2FSknb
  • Raw body:
{
  "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"
  }
}

Steps

  1. Extract timestamp = "1766002441" and signature = "62afda20...c8bf411c" from the header.
  2. Build signed_payload = "1766002441.{raw_body}".
  3. Compute HMAC-SHA256(secret, signed_payload).
  4. Compare with the header signature using a timing-safe comparison.

The computed signature matches the header signature byte-for-byte, so the request is valid.

Best practices

  1. Use the raw body. Never re-serialize JSON before verifying — key order and whitespace are not guaranteed to be preserved.
  2. Enforce a timestamp tolerance. The default of 5 minutes is enough to absorb clock skew while rejecting replayed webhooks. Tighten or loosen based on your threat model.
  3. Compare with timingSafeEqual. Standard equality checks leak information through timing side-channels.
  4. Guard your secret. The signing secret (prefixed whsec_) is the shared secret between Mono and your service. Keep it out of logs and source control, and rotate it if it's ever exposed.

On this page