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=62afda2079925823b390e1199060d793aa50d64ec9d7bf184f5b7e96c8bf411cReference 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
- Extract
timestamp = "1766002441"andsignature = "62afda20...c8bf411c"from the header. - Build
signed_payload = "1766002441.{raw_body}". - Compute
HMAC-SHA256(secret, signed_payload). - 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
- Use the raw body. Never re-serialize JSON before verifying — key order and whitespace are not guaranteed to be preserved.
- 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.
- Compare with
timingSafeEqual. Standard equality checks leak information through timing side-channels. - 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.