Concepts
Webhooks
A policy can call your server when a decision comes out a certain way, or when a reviewer rules on something. Each call is signed with a secret only you and Vindex know, and retried if your server doesn't answer.
Subscribe
Webhooks live in the policy document. Add them in a new version, then publish it (Policies):
"webhooks": [
{
"url": "https://example.com/hooks/vindex",
"events": ["block", "review"],
"secret": "use-a-long-random-string-here"
}
]url:https://on a public domain name. Plainhttp://, IP addresses,localhost,.localand.internalnames,*.workers.devand Vindex's own addresses are refused.events: any ofblock,review,allow,tag(any tag was applied) andreview.verdict(a reviewer ruled).secret: at least 16 characters. It's write-only. Reads return"redacted", and sending"redacted"back in a new version keeps the stored one.
Each webhook gets one delivery per matching event. A webhook on ["block", "tag"] gets two for a blocked decision that was also tagged.
When they're sent
Only for decisions that are enforced. Nothing is sent for:
- a check made with a test key;
- a dry run;
- a check made in shadow mode, except a block by
minors_sexual, which is always enforced.
When a policy's webhooks would have fired but didn't, the decision's actions.webhooks_suppressed says why: test_mode, dry_run or shadow_mode.
The request
A POST to your URL with a JSON body and these headers:
| Header | Value |
|---|---|
X-Vindex-Signature | Hex HMAC-SHA256 of the raw body, keyed with the webhook's secret |
X-Vindex-Event | block, review, allow, tag or review.verdict |
X-Vindex-Delivery | The delivery id. The same on every retry, so use it to ignore repeats |
Content-Type | application/json |
Payloads
A decision event names the decision, not the prompt. Fetch the rest with GET /v1/decisions/{id} if you need it.
{
"id": "5f0c6a3e-8a7b-4d2e-9c1f-3b6a2d4e8f10",
"event": "block",
"decision": {
"id": "dec_60b500b8ab8d4b5882530f2ea8115f14",
"tenantId": "ten_71b89c26cdab463d84aeddf03cd796cb",
"outcome": "block",
"tags": [],
"policyId": "pol_69ea6e25ab524a98944b8e83407df74a",
"policyVersion": 1,
"createdAt": "2026-09-17T09:02:31.004Z"
}
}{
"id": "0b9e2d71-4c3a-4f5e-8d6b-7a1c2e3f4d50",
"event": "review.verdict",
"review": {
"id": "rev_b518334420eb45edb2508efe2f9a697d",
"verdict": "allow",
"note": "historical photo, fine",
"verdictAt": "2026-09-17T09:07:13.073Z"
},
"decision": {
"id": "dec_f29c8d657b7b453baf27e2fa54497b3a",
"tenantId": "ten_71b89c26cdab463d84aeddf03cd796cb",
"outcome": "review",
"policyId": "pol_cb805d8999bd453f8f2b0ffd04f05b59",
"policyVersion": 1
}
}Payload keys are camelCase, unlike the API's snake_case responses.
Verify the signature
Compute the HMAC over the raw bytes of the body, before parsing it, and compare in constant time. Both servers below listen on port 3000 and answer 204 to a good signature, 401 to anything else.
// webhook.mjs · Node 18+ · VINDEX_WEBHOOK_SECRET=... node webhook.mjs
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
const secret = process.env.VINDEX_WEBHOOK_SECRET;
function verify(rawBody, signature) {
const expected = Buffer.from(createHmac("sha256", secret).update(rawBody).digest("hex"));
const given = Buffer.from(String(signature ?? ""));
return expected.length === given.length && timingSafeEqual(expected, given);
}
createServer(async (req, res) => {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const raw = Buffer.concat(chunks);
if (!verify(raw, req.headers["x-vindex-signature"])) return res.writeHead(401).end();
const delivery = JSON.parse(raw.toString("utf8"));
console.log(req.headers["x-vindex-event"], req.headers["x-vindex-delivery"], delivery.decision.id);
res.writeHead(204).end();
}).listen(3000);# webhook.py · Python 3.10+ · VINDEX_WEBHOOK_SECRET=... python webhook.py
import hashlib
import hmac
import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["VINDEX_WEBHOOK_SECRET"].encode()
def verify(raw_body: bytes, signature: str | None) -> bool:
expected = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature or "")
class Webhook(BaseHTTPRequestHandler):
def do_POST(self):
raw = self.rfile.read(int(self.headers.get("Content-Length", 0)))
if not verify(raw, self.headers.get("X-Vindex-Signature")):
self.send_response(401)
self.end_headers()
return
delivery = json.loads(raw)
print(self.headers.get("X-Vindex-Event"), self.headers.get("X-Vindex-Delivery"), delivery["decision"]["id"])
self.send_response(204)
self.end_headers()
HTTPServer(("", 3000), Webhook).serve_forever()Send it a signed delivery from a second terminal to check:
body='{"id":"5f0c6a3e-8a7b-4d2e-9c1f-3b6a2d4e8f10","event":"block","decision":{"id":"dec_60b500b8ab8d4b5882530f2ea8115f14","outcome":"block","tags":[]}}'
signature=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$VINDEX_WEBHOOK_SECRET" | sed 's/^.* //')
curl -i http://localhost:3000 \
-H "X-Vindex-Signature: $signature" \
-H "X-Vindex-Event: block" \
-H "X-Vindex-Delivery: 5f0c6a3e-8a7b-4d2e-9c1f-3b6a2d4e8f10" \
-H "Content-Type: application/json" \
-d "$body"Retries
Answer with any 2xx status within 10 seconds and the delivery is done. Any other status (a redirect too: Vindex doesn't follow them), or no answer in time, is a failure, and Vindex tries again:
| Attempt | When |
|---|---|
| 1 | As soon as the decision is made |
| 2 | 1 minute after attempt 1 fails |
| 3 | 5 minutes after attempt 2 fails |
| 4 | 30 minutes after attempt 3 fails |
After the fourth failure, that delivery stops. Every attempt is recorded with its status code.
Deliveries can arrive out of order, and occasionally twice. Use X-Vindex-Delivery to skip one you've already handled, and answer quickly: do the work after you've responded.