VindexDocs

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):

JSON
"webhooks": [
  {
    "url": "https://example.com/hooks/vindex",
    "events": ["block", "review"],
    "secret": "use-a-long-random-string-here"
  }
]
  • url: https:// on a public domain name. Plain http://, IP addresses, localhost, .local and .internal names, *.workers.dev and Vindex's own addresses are refused.
  • events: any of block, review, allow, tag (any tag was applied) and review.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:

HeaderValue
X-Vindex-SignatureHex HMAC-SHA256 of the raw body, keyed with the webhook's secret
X-Vindex-Eventblock, review, allow, tag or review.verdict
X-Vindex-DeliveryThe delivery id. The same on every retry, so use it to ignore repeats
Content-Typeapplication/json

Payloads

A decision event names the decision, not the prompt. Fetch the rest with GET /v1/decisions/{id} if you need it.

Decision event
{
  "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"
  }
}
review.verdict
{
  "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);

Send it a signed delivery from a second terminal to check:

Shell
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:

AttemptWhen
1As soon as the decision is made
21 minute after attempt 1 fails
35 minutes after attempt 2 fails
430 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.