Skip to content

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). Both v1= values validate during that time.
  • Slow handlers. Do the work after responding; the delivery times out at 10 s and will be retried.