# Verify Signatures

> Check X-Grout-Signature before trusting a delivery.

Canonical: https://grout.app/developer/documentation/webhooks/verify/

`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.

:::codegroup
```js title="Node"
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();
});
```
```python title="Python"
import hmac, hashlib, time

def verify_grout(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool:
    parts = dict(kv.split('=', 1) for kv in header.split(','))
    t = int(parts.get('t', 0))
    if not t or abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(kv[3:], expected) for kv in header.split(',') if kv.startswith('v1='))

# Flask
@app.post('/hooks/grout')
def hook():
    if not verify_grout(request.get_data(), request.headers.get('X-Grout-Signature', ''), os.environ['GROUT_WEBHOOK_SECRET']):
        abort(400)
    process_later(request.get_json())
    return '', 202
```
```php title="PHP"
function verifyGrout(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
  parse_str(str_replace(',', '&', $header), $p);
  $t = (int)($p['t'] ?? 0);
  if (!$t || abs(time() - $t) > $tolerance) return false;
  $expected = hash_hmac('sha256', "$t.$rawBody", $secret);
  foreach (explode(',', $header) as $kv) {
    if (str_starts_with($kv, 'v1=') && hash_equals($expected, substr($kv, 3))) return true;
  }
  return false;
}
```
:::

## 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.
