Signed webhooks
VIGIL calls your URL when a gate holds, alerts or collapses, and when a reviewer decides. Every delivery is HMAC-signed over the exact bytes sent.
Register a receiver
POST /webhooks (scope admin):
{ "url": "https://example.com/vigil", "events": ["gate.hold", "gate.alert", "gate.collapse", "review.released"], "format": "json" }
Events: gate.hold, gate.alert, gate.collapse, review.released, review.rejected, review.escalated, ping. Formats: json, splunk_hec, datadog. The signing secret (whsec_...) is returned once at creation; secret_hint is its last 8 characters. DELETE /webhooks/{id} deactivates; nothing is deleted.
The envelope
{ "id": "<uuid>", "event": "gate.hold", "org": "acme", "created_at": "...",
"data": { "agent_id": "...", "action_id": 42, "event_id": 42, "gate_status": "hold", "bii": 0.71,
"reason": "...", "policy_digest": "...", "ledger_seq": 1412 } }
Review events add outcome and reviewer; reason is the reviewer's note. splunk_hec posts { "event": <envelope>, "sourcetype": "vigil", "time" }. datadog posts a one-item Logs intake list with ddsource: vigil, ddtags: event:<event>,org:<slug>, message, and the envelope under vigil.
Headers and retries
| Header | Value |
|---|---|
Content-Type | application/json |
X-VIGIL-Event | the event name |
X-VIGIL-Delivery | the envelope id, stable across retries |
X-VIGIL-Signature | t=<unix>,v1=<hex hmac-sha256(secret, "{t}.{body}")> |
Non-2xx or a transport error schedules the next attempt at 1m, 5m, 30m, 2h, 12h; the sixth failure marks the delivery dead. GET /webhooks/{id}/deliveries shows the queue with last_error. POST /webhooks/{id}/test sends a signed ping now and returns { delivered, status_code, error }.
Verify the signature
Recompute the HMAC over the raw body, compare in constant time, and reject when |now - t| > 300 seconds. Parse the JSON only after both checks pass.
Python:
import hmac, hashlib, time
def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = int(parts["t"]), parts["v1"]
if abs(int(time.time()) - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
TypeScript (Node):
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret: string, header: string, body: Buffer, tolerance = 300): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2) as [string, string]));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Math.floor(Date.now() / 1000) - t) > tolerance) return false;
const expected = createHmac("sha256", secret).update(`${t}.`).update(body).digest("hex");
const given = Buffer.from(parts.v1 ?? "", "hex");
return given.length === 32 && timingSafeEqual(Buffer.from(expected, "hex"), given);
}
Use the raw request body. A body that has been parsed and re-serialized will not match.
Prove both verifiers agree
This signs a body with a throwaway secret in Python and verifies it in Node, then the other way round. No network, no key needed; it runs even when VIGIL_URL is unset.
SECRET=whsec_docs_example; BODY='{"id":"abc","event":"ping","org":"docs","data":{}}'; T=$(date +%s)
SIG=$(python3 -c "
import hmac, hashlib, sys
s, t, body = sys.argv[1], sys.argv[2], sys.argv[3].encode()
print('t=' + t + ',v1=' + hmac.new(s.encode(), t.encode() + b'.' + body, hashlib.sha256).hexdigest())" "$SECRET" "$T" "$BODY")
node -e '
const { createHmac, timingSafeEqual } = require("node:crypto");
const [secret, header, body] = process.argv.slice(1);
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) throw new Error("stale");
const expected = createHmac("sha256", secret).update(`${t}.`).update(Buffer.from(body)).digest("hex");
if (!timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(parts.v1, "hex"))) throw new Error("bad signature");
console.log("node verified the python signature");' "$SECRET" "$SIG" "$BODY"