Essentials
Webhooks
Events, signatures, retries and the rules your endpoint has to meet.
Webhooks tell your systems when something lands, so you don’t poll for it. They are included on the Custom plan; create them with Create a webhook.
The events
| Event | Fires when | Payload |
|---|---|---|
run.completed | A sweep finishes and new results are readable. | project_id, run_id, status, answers |
draft.ready | A draft you generated has finished writing. | project_id, asset_id, version |
Events carry ids and counts, never the data itself. That keeps the payload small, keeps measurements out of logs and inboxes they don’t belong in, and means a receiver reads the detail with its own key — so what it sees is bounded by that key’s plan and access.
What a delivery looks like
POST to your endpoint
POST /vidrys HTTP/1.1
Content-Type: application/json
User-Agent: Vidrys-Webhooks/1
X-Vidrys-Event: run.completed
X-Vidrys-Delivery: 7b2f9a41-3c6d-4e58-9a1b-2c3d4e5f6a70
X-Vidrys-Signature: t=1790000000,v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
{"id":"7b2f9a41-3c6d-4e58-9a1b-2c3d4e5f6a70","type":"run.completed","created_at":"2026-09-22T04:00:11Z","data":{"project_id":"3c90c3cc-0d44-4b50-8888-8dd25736052a","run_id":"b41d8e7f-2a95-4c03-8e6d-1f2a3b4c5d6e","status":"completed","answers":120}}X-Vidrys-Delivery is the event id. A delivery that timed out is retried with the same id, so de-duplicate on it before you act.
Verifying the signature
X-Vidrys-Signature is t=<unix seconds>,v1=<hex>, where the hex is HMAC-SHA256 of "<t>.<raw body>" using the secret shown once when you created the webhook. Check the signature against the raw body, before any JSON parsing, and check that tis recent so an old delivery can’t be replayed at you.
Node (Express)
import crypto from "node:crypto";
// Keep the raw body: express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } })
function verify(req, secret, toleranceSeconds = 300) {
const header = req.get("X-Vidrys-Signature") ?? "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.`)
.update(req.rawBody)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1 ?? "", "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Python (Flask)
import hashlib
import hmac
import time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp = int(parts.get("t", "0"))
if not timestamp or abs(time.time() - timestamp) > tolerance:
return False
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Retries and switch-off
5xx,429and network failures are retried after 30s, 1m, 2m, 4m and 8m.- Every other non-
2xxanswer counts as a failure straight away and is not retried — a404during a deploy loses that delivery. Redirects are never followed, so a3xxfails too. - After 15 consecutive failures the webhook is switched off (
is_active: false). Fix the endpoint and create it again. - List webhooks shows the last status and when it was last attempted.
Rules for your endpoint
- HTTPS, publicly resolvable. Private, internal and loopback addresses are refused when the webhook is created and checked again on every delivery, so a DNS record that later points inward stops being delivered to.
- Answer fast. Deliveries time out after 10 seconds. Acknowledge, then do the work.
- Expect duplicates and out-of-order arrivals. Key on
X-Vidrys-Delivery. - Store the secret. It is shown once, at creation. To rotate it, create a new webhook and delete the old one.
- Up to 10 webhooks per workspace.