Webhooks
Under Settings, API & webhooks an admin adds an HTTPS endpoint and picks the events it should receive. The signing secret is shown once, when the endpoint is created. Up to ten endpoints per workspace.
Events
Section titled “Events”| Event | When | data |
|---|---|---|
run.completed |
a full or change run completed | readership (the run’s headline audit figures) and change (counts of new, resolved, regressed and still-open findings; pages compared, changed, gone, newly blocked or served; AI access before and after), change is null on a site’s first run |
run.failed |
a run failed or paused on its budget | status, error |
finding.new_severe |
a run found new critical or high audit findings | findings: up to 50 with key, title, severity, category, scope |
fix.verified |
a run checked recorded fixes | confirmed and refuted: key, title, severity and the effect sentence |
access.changed |
the daily access watch found a change | checkId, changes (each with its kind: refused, served, gone, robots, llms, and the identity and URLs it concerns) and sentences, the same lines the workspace shows. run is null: a check is not a run |
citation.changed |
a run’s own engine check found the site dropped out of, or entered, an answer it was in before | against (the previous run with an engine check), dropped and gained: each with query, engine, label, instead (the domains cited in its place) and url (the page of the site that was cited) |
traffic.changed |
the weekly traffic check found the sessions AI assistants sent in the last complete week more than 30% below the average of the four weeks before (Google Analytics); or the daily access watch found, in the crawlers’ own traffic, an AI crawler newly refused or newly served at the edge, or a crawler gone quiet | kind tells the two shapes apart, and both carry source (analytics or crawler_traffic), sentence (one line) and sentences (the lines, one for the weekly check), so a receiver can read either the same way and switch on kind for the rest. kind: "ai_sessions_drop": week (its Monday), sessions, mean (of the four weeks before) and dropPct. kind: "crawler_observed": checkId and changes (each with kind observed, with crawler, label, what, hits, before and after as refused shares, or quiet, with crawler, label, weeklyBefore, lastSeen). run is null |
prompt.changed |
an engine’s answers to a question the workspace tracks word for word started or stopped naming or citing the site (two answers in a row) | question (id, text, url), prompt (null: the tracked-prompt list is retired) and engines: each with engine, label, changes and sentences. run is null |
engine.drift |
Vercatus Intelligence confirmed a drift that touches the site | noticeId, headline, drift (the engine, the page feature, the direction, the window, the size of the change and the medians of the pages that entered and were replaced) and exposure (losing of of observed pages, siteMedian). run is null |
competitor.changed |
a competitor’s page score from the audit passed the site’s, overall or on one engine | noticeId, competitor, why (named or cocited), scope, day, before and now. run is null |
crawl.slowing |
the daily check found a search crawler refetching the site’s money pages less often, or less successfully, than its usual range, before any citation loss shows | crawler, label, template, week, sentence, rate and success (the checks with their figures), days, panel (whether the run’s engine check already shows a loss), noticeId |
disclosure.missing |
a disclosure check (after a run, or a live check) found a required disclosure the workspace defined gone from a page, or from one of its views (JavaScript off, rendered, a crawler’s), where the previous check found it | noticeId, checkId, trigger (schedule, manual or live), workflowId (the run whose pages were read; null for a live check), lost (each with ruleId, rule, url and view) and sentences. run is null |
agent_task.failed |
an agent task trial that passed under an identity in its previous batch failed under it in this one | noticeId, taskId, task, kind, batchId and failed (each with identity, blocker, detail, finalUrl and steps). run is null |
journey.checkpoint |
a site reached a checkpoint against its first audit’s starting point (day 14, 30, 60, 90, 180, 270, then each year), one delivery per checkpoint; kept for integrations built on it | day, label, claim, headline, indexBefore, indexNow, fixed, total |
answer.stale |
a scan of the site’s latest answers found an engine quoting a fact about the site (a price, a plan, a figure, a date) that the site’s page no longer states, one delivery per new stale answer | noticeId, staleId, engine, question, kind, answerValue, siteValue, pageUrl, siteSince (the first snapshot of the page stating the current value), cause (not_refetched, refused, refetched, never_fetched or no_crawl_data), crawl (per crawler of the engine: lastServedAt, served, refused, and which version of the page it saw), severity and sentence. run is null |
verdict.read |
a play’s verdict landed: a read of Won or Lost ground (once for each state; a read before day 45 is provisional), or the final day-45 read, whatever it says; one delivery per read, with the same delivery id if it is ever sent again. A Too early read, and a flat or unclear read before day 45, are not sent | noticeId, verdictId, day (14, 30 or 45), state (won, lost_ground, no_effect, inconclusive) and label (Won, Lost ground, No clear change, Inconclusive), grade (A to D), final and provisional, play (id, title; null for plays measured together) and plays, topics (id, name), engines, effect (pp, lo, hi, in percentage points of Answer Share against the topics left alone, with unit), mde, multiplicity (q and compared when several plays were read the same day, or null), sentence and url. The wording says answers moved, with the range and the grade: never that the play proved or caused it. run is null |
visibility.changed |
once a day at most, when the site’s Answer Share, a topic’s or a competitor’s moved clearly over the last 7 days against the 7 before (its range excludes zero on enough answers); a day with no clear move sends nothing | noticeId, days (7), window (from, to, days), moves (each with kind: you, topic or competitor, key, name, now (the share in percent), delta, lo, hi in percentage points). run is null |
Delivery
Section titled “Delivery”One POST per event per endpoint, Content-Type: application/json, from user agent Vercatus-Webhooks/1.
{ "event": "fix.verified", "deliveryId": "8f2c1d0a9b3e4f61", "at": "2026-09-16T17:09:44.901Z", "org": { "id": "…", "name": "Acme" }, "site": { "id": "…", "host": "www.acme.com" }, "run": { "id": "…", "kind": "light", "status": "completed", "url": "https://app.vercatus.com/runs/…", "startedAt": "…", "finishedAt": "…" }, "data": { "confirmed": [ { "key": "b3a71480fbf3d00a", "title": "No /llms.txt", "severity": "low", "effect": "…" } ], "refuted": [] }}Headers:
| Header | Value |
|---|---|
x-vercatus-event |
the event name |
x-vercatus-delivery |
the delivery id; the same id is kept on retries and when a delivery is sent again from the log |
vercatus-signature |
t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, keyed with the endpoint’s secret. While a secret is being rotated there are two v1 values, one per secret |
x-vercatus-signature |
deprecated: sha256= and the hex HMAC-SHA256 of the raw body alone. Still sent for one release; move to vercatus-signature, which also protects against replays |
Any 2xx counts as delivered. A 5xx, 408, 425, 429, a timeout or a network error is retried: after half a minute, a minute, five minutes, then with growing gaps up to six hours, for about a day in all. Any other 4xx is taken as the endpoint’s answer and not retried. Redirects are not followed. Deliveries go out from a queue, so a restart on our side delays a delivery but does not lose it.
Endpoints must be HTTPS addresses on the public internet; addresses that resolve to a private, loopback or link-local network are refused.
Verifying a delivery
Section titled “Verifying a delivery”Compute the HMAC over the timestamp, a dot and the exact bytes received, before parsing; compare with a
constant-time comparison against each v1 value; and refuse a timestamp more than five minutes old, so a
captured delivery cannot be replayed:
import { createHmac, timingSafeEqual } from "node:crypto";export function verify(rawBody, header, secret, toleranceSeconds = 300) { const parts = header.split(",").map((p) => p.trim().split("=")); const t = Number(parts.find(([k]) => k === "t")?.[1]); if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")); return parts.some(([k, v]) => k === "v1" && v.length === expected.length && timingSafeEqual(Buffer.from(v), expected));}Use deliveryId to ignore a delivery you already processed. Each delivery also carries sequence
in the body and in an x-vercatus-sequence header: a number that only grows (microseconds since the
epoch, taken when the event was queued). Retries can bring two deliveries of one subject out of order;
keep the one with the higher sequence. Send a test on the settings page posts a
run.completed with data.test: true, signed like any other delivery; the answer shows the status your
endpoint returned, never its body.
When an endpoint keeps failing
Section titled “When an endpoint keeps failing”An endpoint that fails 25 attempts in a row (any attempt that is not a 2xx, counting retries) is paused: it
stops receiving, the endpoint list shows why and when, and the deliveries waiting for it are kept, not
dropped. Resuming it (Settings, API & webhooks, or PATCH /api/org/webhooks/:id with { "active": true })
sends what was held at once. One delivered attempt resets the count.
Delivery log and secret rotation
Section titled “Delivery log and secret rotation”Settings, API & webhooks lists the deliveries of the last 30 days with their result, the status your endpoint answered and the next attempt, and Send again queues one again with the same payload and id.
Rotate signing secret makes a new secret and shows it once. For 24 hours every delivery carries two v1
signatures, one per secret, so the endpoint can switch to the new secret without missing a delivery.
POST /api/org/webhooks with { url, events } returns the endpoint including its secret, once.
PATCH /api/org/webhooks/:id with { active?, events?, url? } (unknown event names are refused),
DELETE /api/org/webhooks/:id, POST /api/org/webhooks/:id/test, POST /api/org/webhooks/:id/rotate with
{ graceHours? } (0 to 168, default 24) returning the new secret once, GET /api/org/webhooks/deliveries
(?hook=, ?status=pending|delivered|failed, ?limit=) and POST /api/org/webhooks/deliveries/:id/redeliver.
Admin role required.