Webhooks

Get an HTTPS POST when something changes, such as a system becoming high-risk or an incident being logged, instead of polling.

Events

EventWhen
system.createdAn AI system was added to the inventory.
system.tier_changedAn AI system's EU AI Act risk tier changed.
evidence.addedAn evidence file was uploaded.
finding.createdA finding was opened.
incident.createdAn incident was logged.
proof.sealedA Proof Seal was created and timestamped.
passport.issuedAn AI Conformity Passport was issued.
passport.revokedAn AI Conformity Passport was revoked.
discovery.new_assetDiscovery found an AI service or model that wasn't seen before.

Create an endpoint

In the app (Settings → Webhooks) or with an admin key. The signing secret is returned once.

curl https://v77trust.com/api/v1/webhook-endpoints \
  -H "Authorization: Bearer $V77_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://grc.example.eu/hooks/v77", "events": ["system.tier_changed", "incident.created", "finding.created"] }'

The request you receive

POST /hooks/v77 HTTP/1.1
Content-Type: application/json
User-Agent: V77-Trust-Webhooks/1
X-V77-Event: system.tier_changed
X-V77-Delivery: 3f6c0d1e-…
X-V77-Signature: t=1762594200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

{
  "id": "3f6c0d1e-…",
  "event": "system.tier_changed",
  "createdAt": "2026-11-08T09:30:00.000Z",
  "orgId": "…",
  "data": { "systemId": "…", "name": "Retail credit scoring", "previousTier": "limited", "riskTier": "high_risk" }
}

Verify the signature

X-V77-Signature is t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of <t>.<raw body> keyed with your endpoint secret. Check it against the raw body (before JSON parsing), compare in constant time, and reject timestamps older than 5 minutes to stop replays.

import crypto from "node:crypto";

export function verifyV77(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.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");
  const a = Buffer.from(expected), b = Buffer.from(parts.v1 || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express: use the raw body
app.post("/hooks/v77", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyV77(req.body.toString("utf8"), req.get("X-V77-Signature"), process.env.V77_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body);
  res.sendStatus(204); // acknowledge fast, process async
  queue.push(event);
});

Delivery

  • Respond with any 2xx within 5 seconds. Do heavy work after responding.
  • Deliveries are attempted once and aren't retried automatically. Each attempt and its status is logged in Settings → Webhooks. If you can't miss an event, also reconcile periodically with the list endpoints or GET /audit-log.
  • The same event may occasionally arrive twice; deduplicate on id.
  • Endpoints must be public HTTPS URLs; private and local addresses are refused.
Treat webhooks as a signal: for critical decisions, fetch the current object from the API by id rather than trusting the payload alone.