Webhook signature verification

Every outbound webhook delivery from GuardHound is signed with HMAC-SHA256 so your handler can prove the request actually came from us and was not tampered with in transit.

Headers we send

HeaderValue
X-GuardHound-Signaturesha256=<hex> — HMAC-SHA256 of the raw request body bytes, using your channel's signing secret as the key.
X-GuardHound-TimestampUnix epoch in milliseconds when we generated the signature. Use this to reject replays older than your tolerance window.
Content-Typeapplication/json
Important: verify the signature against the raw request body bytes, not against a re-serialized JSON object. Even a single whitespace difference will cause verification to fail.

Where do I get my signing secret?

The secret is shown once when you create a webhook channel in the portal (Account → Notifications). Save it somewhere safe — we cannot show it to you again. If you lose it, click Rotate signing secret on the channel to generate a new one.

Node.js (Express) example

const crypto = require('crypto');
const express = require('express');
const app = express();

const SECRET = process.env.GUARDHOUND_WEBHOOK_SECRET; // from your channel
const MAX_AGE_MS = 5 * 60 * 1000; // reject replays older than 5 min

// Capture the raw body bytes — re-serializing would break verification.
app.use('/guardhound', express.raw({ type: 'application/json' }));

app.post('/guardhound', (req, res) => {
  const sigHeader = req.header('X-GuardHound-Signature') || '';
  const ts = Number(req.header('X-GuardHound-Timestamp') || 0);

  if (!ts || Math.abs(Date.now() - ts) > MAX_AGE_MS) {
    return res.status(401).send('stale or missing timestamp');
  }

  const expected = 'sha256=' + crypto.createHmac('sha256', SECRET)
    .update(req.body) // raw Buffer
    .digest('hex');

  const a = Buffer.from(sigHeader);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).send('bad signature');
  }

  const payload = JSON.parse(req.body.toString('utf8'));
  // ... handle payload.subject, payload.body, payload.metadata
  res.status(200).send('ok');
});

Python (Flask) example

import hashlib
import hmac
import os
import time
from flask import Flask, request, abort

SECRET = os.environ['GUARDHOUND_WEBHOOK_SECRET'].encode('utf-8')
MAX_AGE_MS = 5 * 60 * 1000

app = Flask(__name__)

@app.route('/guardhound', methods=['POST'])
def guardhound():
    sig_header = request.headers.get('X-GuardHound-Signature', '')
    try:
        ts = int(request.headers.get('X-GuardHound-Timestamp', '0'))
    except ValueError:
        abort(401)
    if not ts or abs(int(time.time() * 1000) - ts) > MAX_AGE_MS:
        abort(401, 'stale or missing timestamp')

    body = request.get_data()  # raw bytes
    expected = 'sha256=' + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig_header, expected):
        abort(401, 'bad signature')

    # ... handle request.get_json()
    return ('ok', 200)

Why timing-safe comparison?

Both crypto.timingSafeEqual (Node) and hmac.compare_digest (Python) compare two byte strings in constant time, so an attacker can't infer the correct signature one byte at a time by measuring response latency. A naive == comparison short-circuits on the first mismatching byte and leaks that information. Always use the constant-time helper.

Migrating an existing webhook channel

Channels created before signing was added continue to deliver without a signature header so your integration does not break in flight. To start signing, click Rotate signing secret on the channel, store the returned secret, and add the verification snippet to your handler. After the rotation, every subsequent delivery to that channel will carry the headers above.