Skip to main content
Version: 1.0

Verifying Webhook Signatures

When Require signature (requiresSignature: true) is enabled for a webhook, Hub Chatt2.me sends an X-hub-Signature header on each delivery. Your endpoint should verify it with the signing secret returned when you enable signing (create or update webhook) or when you call redefine signature. See Creating your first webhook for API details.

Header format

X-hub-Signature: t=<unixSeconds>,v1=<hexDigest>
  • t — Unix timestamp in seconds when Hub built the signature.
  • v1 — Lowercase hexadecimal HMAC-SHA256 of the signed material (see below).

Signing material

Hub computes:

signedPayload = `${t}.${rawJsonBody}`
hexDigest = HMAC_SHA256(signingSecret, signedPayload) as lowercase hex

rawJsonBody is the exact JSON body bytes Hub sends (equivalent to JSON.stringify of the payload object). Your server must verify using the raw body string as received over the wire. Parsing JSON and calling JSON.stringify again can change key order or spacing and will break verification.

Node.js (Express)

Use a raw body parser on the webhook route so req.body is the untouched payload before JSON.parse:

Node.js / Express
const crypto = require('crypto');
const express = require('express');

const secret = process.env.WEBHOOK_SIGNING_SECRET;
const MAX_SKEW_SEC = 300;

function parseHubSignature(headerValue) {
if (!headerValue || typeof headerValue !== 'string') return null;
let t;
let v1;
for (const part of headerValue.split(',')) {
const p = part.trim();
if (p.startsWith('t=')) t = p.slice(2);
if (p.startsWith('v1=')) v1 = p.slice(3);
}
if (!t || !v1) return null;
return { t, v1 };
}

function verifyHubSignature(rawBody, headerValue) {
const parsed = parseHubSignature(headerValue);
if (!parsed) return false;
const ts = Number(parsed.t);
if (!Number.isFinite(ts)) return false;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - ts) > MAX_SKEW_SEC) return false;
const signedPayload = `${parsed.t}.${rawBody}`;
const expectedHex = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
let a;
let b;
try {
a = Buffer.from(expectedHex, 'hex');
b = Buffer.from(parsed.v1, 'hex');
} catch {
return false;
}
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}

const app = express();

app.post(
'/webhooks/chatt2me',
express.raw({ type: 'application/json' }),
(req, res) => {
const rawBody =
req.body instanceof Buffer ? req.body.toString('utf8') : String(req.body);
const headerValue = req.headers['x-hub-signature'];
if (!verifyHubSignature(rawBody, headerValue)) {
return res.status(401).send('Unauthorized');
}
const payload = JSON.parse(rawBody);
res.status(200).send('OK');
},
);

The timestamp check rejects requests outside MAX_SKEW_SEC seconds to limit replay windows; adjust or remove if your environment requires it.

Next steps