Skip to main content
Version: 1.0

Verifying Webhook Signatures

When requiresSignature: true, event deliveries include:

X-hub-Signature: t=<unixSeconds>,v1=<64-lowercase-hex-characters>

Hub computes HMAC-SHA256 with the returned signing secret over the exact bytes of t + "." + rawJsonBody. Verify the raw received body, not JSON parsed and serialized again. The decimal timestamp must be an integer and the digest exactly 64 lowercase hexadecimal characters before decoding. The example allows five minutes of clock skew. Keep receiver clocks synchronized; old replayed payloads receive a newly generated signature timestamp.

Combined receiver (Node.js, no dependencies)​

Save this as receiver.cjs. Initially set WEBHOOK_RECEIVER_SECRET to the configured X-Webhook-Secret and WEBHOOK_INBOX to a directory on persistent storage. Register the webhook to obtain its signing secret, then set WEBHOOK_SIGNING_SECRET and restart the receiver before subscribing the channel. Without a signing secret only authenticated empty-body verification can succeed; ordinary events fail closed. Run node receiver.cjs behind your HTTPS reverse proxy. Do not place a JSON parser before this route.

receiver.cjs
const http = require('node:http');
const crypto = require('node:crypto');
const fs = require('node:fs/promises');
const path = require('node:path');

function equalSecret(received, expected) {
if (typeof received !== 'string' || !expected) return false;
const a = Buffer.from(received);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

function verifyHubSignature(raw, header, secret, now = Math.floor(Date.now() / 1000)) {
if (typeof header !== 'string' || !secret) return false;
const match = /^t=(0|[1-9]\d*),v1=([a-f0-9]{64})$/.exec(header);
if (!match || match[0] !== header) return false;
const timestamp = Number(match[1]);
if (!Number.isSafeInteger(timestamp) || Math.abs(now - timestamp) > 300) return false;
const expected = crypto.createHmac('sha256', secret)
.update(match[1] + '.').update(raw).digest();
return crypto.timingSafeEqual(expected, Buffer.from(match[2], 'hex'));
}

function authenticateRequest(headers, raw, receiverSecret, signingSecret) {
if (!equalSecret(headers['x-webhook-secret'], receiverSecret)) return { status: 401 };
const token = headers['x-validation-token'];
if (token !== undefined) {
// Handshakes are unsigned and empty; never accept event bodies through this branch.
if (typeof token !== 'string' || token.length !== 32 || !/^[a-f0-9]{32}$/.test(token) || raw.length !== 0)
return { status: 400 };
return { status: 200, token };
}
if (!verifyHubSignature(raw, headers['x-hub-signature'], signingSecret))
return { status: 401 };
try {
const event = JSON.parse(raw.toString('utf8'));
if (!event || Array.isArray(event) || typeof event.eventType !== 'string')
return { status: 400 };
return { status: 200, event };
} catch {
return { status: 400 };
}
}

async function persistEvent(raw, inbox) {
const id = crypto.randomUUID();
const pending = path.join(inbox, id + '.pending');
const handle = await fs.open(pending, 'wx', 0o600);
try { await handle.writeFile(raw); await handle.sync(); }
finally { await handle.close(); }
await fs.rename(pending, path.join(inbox, id + '.json'));
const directory = await fs.open(inbox, 'r');
try { await directory.sync(); } finally { await directory.close(); }
}

async function main() {
const receiverSecret = process.env.WEBHOOK_RECEIVER_SECRET;
const signingSecret = process.env.WEBHOOK_SIGNING_SECRET;
const inbox = process.env.WEBHOOK_INBOX;
if (!receiverSecret || !inbox) throw new Error('Set receiver secret and persistent inbox');
await fs.mkdir(inbox, { recursive: true, mode: 0o700 });
http.createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== '/webhooks/chatt2me') {
res.writeHead(404).end(); return;
}
try {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > 1024 * 1024) { res.writeHead(413).end(); return; }
chunks.push(chunk);
}
const raw = Buffer.concat(chunks);
const result = authenticateRequest(req.headers, raw, receiverSecret, signingSecret);
if (result.token) { res.writeHead(200, { 'Content-Type': 'text/plain' }).end(result.token); return; }
if (!result.event) { res.writeHead(result.status).end(); return; }
await persistEvent(raw, inbox);
res.writeHead(202).end('Accepted');
} catch {
// No successful acknowledgement if authentication/read/storage failed.
if (!res.headersSent) res.writeHead(503).end('Acceptance failed');
}
}).listen(3000);
}

module.exports = { verifyHubSignature, authenticateRequest, persistEvent };
if (require.main === module) main().catch(() => { process.exitCode = 1; });

The file and directory are synchronized before acknowledgement; this example assumes a filesystem that supports those operations. A worker must consume only .json files, deduplicate events, retry processing, and retain accepted files until processing succeeds. .pending files are incomplete acceptance attempts and require recovery/cleanup. A process crash after persistence but before acknowledgement can produce duplicates. Use a committed database transaction or a durable queue in your deployed integration if that is your existing storage pattern; an in-memory queue is not durable acceptance. Set body/storage limits appropriate for your actual payloads.

Once Hub receives 2xx, later worker failures are your responsibility and cannot be recovered by retrying a successful Hub log. General live-event automatic retries are not guaranteed. See retry semantics.

Local signature/handshake check​

Save this as receiver-check.cjs beside the receiver and run node receiver-check.cjs. It makes no HTTP calls.

receiver-check.cjs
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const { verifyHubSignature, authenticateRequest } = require('./receiver.cjs');
const raw = Buffer.from('{"eventType":"message","payload":{}}');
const secret = 'test-signing-secret';
const t = Math.floor(Date.now() / 1000);
const digest = crypto.createHmac('sha256', secret).update(t + '.').update(raw).digest('hex');
const header = `t=${t},v1=${digest}`;
assert.equal(verifyHubSignature(raw, header, secret, t), true);
for (const invalid of [header + 'zz', header + '\n', `t=${t}.0,v1=${digest}`, `t=+${t},v1=${digest}`, `t=${t},v1=${digest.slice(2)}`])
assert.equal(verifyHubSignature(raw, invalid, secret, t), false);
assert.equal(verifyHubSignature(Buffer.from('{}'), header, secret, t), false);
assert.equal(verifyHubSignature(raw, header, secret, t + 301), false);
const auth = { 'x-webhook-secret': 'receiver', 'x-hub-signature': header };
assert.ok(authenticateRequest(auth, raw, 'receiver', secret).event);
const handshake = { 'x-webhook-secret': 'receiver', 'x-validation-token': 'a'.repeat(32) };
assert.equal(authenticateRequest(handshake, Buffer.alloc(0), 'receiver', secret).token, 'a'.repeat(32));
assert.equal(authenticateRequest(handshake, Buffer.alloc(0), 'receiver', undefined).token, 'a'.repeat(32));
assert.equal(authenticateRequest(auth, raw, 'receiver', undefined).status, 401);
assert.equal(authenticateRequest(handshake, raw, 'receiver', secret).status, 400);
assert.equal(authenticateRequest(handshake, Buffer.alloc(0), 'wrong-secret', secret).status, 401);
assert.equal(authenticateRequest({ 'x-webhook-secret': 'receiver' }, raw, 'receiver', secret).status, 401);
console.log('Signature and handshake checks passed');

Changing custom headers or protocol requires revalidation. Every update containing requiresSignature: true rotates the secret; omission preserves it. Coordinate rotation for requests already in flight, and see registration and rotation.