Verifying webhook signatures
4 min read Updated October 22, 2026
Why verify
The signature proves a request came from ComplianceGrid and was not altered. Always verify it in production.
How it is built
The X-ComplianceGrid-Signature header looks like t=1792670696,v1=. The v1 value is the HMAC-SHA256, in hex, of the string using your webhook secret. Reject requests whose timestamp is more than 5 minutes from your clock (replay protection).
Node.js
javascript
import crypto from 'crypto';
function verifyWebhook(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!Number.isFinite(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto.createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/webhooks/compliancegrid', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyWebhook(req.body.toString('utf8'), req.headers['x-compliancegrid-signature'], process.env.WEBHOOK_SECRET);
if (!ok) return res.status(401).end();
// handle the event
res.status(200).end();
});Python
python
import hmac, hashlib, time
def verify_webhook(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split('=', 1) for p in header.split(','))
t, v1 = parts.get('t'), parts.get('v1')
if not t or not v1 or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), t.encode() + b'.' + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)Notes
- Use a constant-time comparison
- Verify against the raw bytes, before parsing
- The secret is returned once when it is generated; if you supplied your own, keep it in a secrets manager
Was this article helpful?