Webhooks
Verify Signatures
Check X-Grout-Signature before trusting a delivery.
v1 = HMAC_SHA256(secret, "<t>.<raw body>") in hex, where t is the Unix timestamp in the header. Compare in constant time and reject if |now − t| > 300 s. During a secret rotation the header carries two v1= values — accept either.
import crypto from 'node:crypto';
export function verifyGrout(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return header.split(',').filter((kv) => kv.startsWith('v1=')).map((kv) => kv.slice(3))
.some((sig) => sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex')));
}
// Express: keep the raw body
app.post('/hooks/grout', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyGrout(req.body.toString(), req.get('X-Grout-Signature') || '', process.env.GROUT_WEBHOOK_SECRET)) return res.status(400).end();
const event = JSON.parse(req.body);
queue.push(event); // process later
res.status(202).end();
});Common mistakes#
- Re-serialising the body. Sign the bytes you received, not
JSON.stringify(JSON.parse(body)). - Clock drift. Keep your server on NTP; the 5-minute window is generous but finite.
- Rotations. After
rotate-secret, update your secret within the grace window (default 24 h). Bothv1=values validate during that time. - Slow handlers. Do the work after responding; the delivery times out at 10 s and will be retried.