Skip to content

Webhook Security

Signature verification

Every webhook delivery includes a X-Govern-Signature-256 header containing an HMAC-SHA256 signature of the raw request body using your webhook’s signing secret.

Always verify signatures before processing webhook events. Without verification, an attacker could send forged events to your endpoint.

How to verify

TypeScript / Node.js

import { createHmac, timingSafeEqual } from 'crypto';
function verifyGovernWebhook(
payload: Buffer, // raw request body (not parsed JSON)
signature: string, // X-Govern-Signature-256 header value
secret: string // your webhook signing secret
): boolean {
const hmac = createHmac('sha256', secret);
hmac.update(payload);
const expected = 'sha256=' + hmac.digest('hex');
// Use timingSafeEqual to prevent timing attacks
return timingSafeEqual(
Buffer.from(signature, 'utf8'),
Buffer.from(expected, 'utf8')
);
}
// Express example
app.post('/webhooks/govern', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-govern-signature-256'] as string;
const secret = process.env.GOVERN_WEBHOOK_SECRET!;
if (!verifyGovernWebhook(req.body, sig, secret)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body.toString());
// Process event...
res.status(200).send('OK');
});

Python

import hmac
import hashlib
import secrets
def verify_govern_webhook(payload: bytes, signature: str, secret: str) -> bool:
expected = 'sha256=' + hmac.new(
secret.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
return secrets.compare_digest(signature, expected)
# FastAPI example
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
@app.post("/webhooks/govern")
async def govern_webhook(request: Request):
payload = await request.body()
signature = request.headers.get("x-govern-signature-256", "")
secret = os.environ["GOVERN_WEBHOOK_SECRET"]
if not verify_govern_webhook(payload, signature, secret):
raise HTTPException(status_code=401, detail="Invalid signature")
event = await request.json()
# Process event...
return {"status": "ok"}

Go

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"crypto/subtle"
)
func verifyGovernWebhook(payload []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return subtle.ConstantTimeCompare([]byte(signature), []byte(expected)) == 1
}

Using the SDK

All three SDKs include a built-in webhook verifier:

import { GovernWebhooks } from '@archetypal-ai/govern';
const webhooks = new GovernWebhooks({
secret: process.env.GOVERN_WEBHOOK_SECRET!,
});
app.post('/webhooks/govern', express.raw({ type: 'application/json' }), (req, res) => {
try {
const event = webhooks.constructEvent(req.body, req.headers['x-govern-signature-256']);
// event is typed and verified
handleEvent(event);
res.status(200).send('OK');
} catch (err) {
res.status(401).send('Webhook signature verification failed');
}
});

Replay attack prevention

The X-Govern-Timestamp header contains the Unix timestamp (seconds) when GOVERN sent the delivery. To prevent replay attacks, reject events older than 5 minutes:

function verifyWithTimestamp(payload: Buffer, signature: string, timestamp: string, secret: string): boolean {
const ts = parseInt(timestamp, 10);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - ts) > 300) { // 5 minute window
throw new Error('Webhook timestamp too old');
}
// Include timestamp in signature computation
const signedPayload = `${timestamp}.${payload.toString()}`;
return verifyGovernWebhook(Buffer.from(signedPayload), signature, secret);
}

IP allowlisting

GOVERN webhook deliveries originate from a fixed set of IP ranges. Allowlist these in your firewall for additional security:

Terminal window
# Fetch current IP ranges
curl https://api.govern.archetypal.ai/v1/webhook-ips
# ["34.120.0.0/16", "35.186.0.0/16", ...]

IP ranges are updated occasionally. Subscribe to system.ips_changed webhook events to receive notifications when the list changes.