Webhooks Available (v1)
Webhooks let Visz push events to your own systems. When something
noteworthy happens in your workspace — a frustration spike, a JavaScript error
spike, a conversion drop — Visz sends a signed POST to a URL you configure.
You set up webhooks under Settings → Integrations: add an endpoint URL, choose which topics it should receive, and you’re done. GA4, GTM, and Slack are configured there too, but those are OAuth-driven and need no code on your side — this page covers the signed outbound webhook only.
Delivery model
- Method:
POSTwith a JSON body to your configured URL. - Signature: every request carries an
X-Visz-Signatureheader (HMAC) — verify it before trusting the payload. - Retries: failed deliveries (non-2xx or timeout) are retried with backoff. Each attempt is logged; you can inspect the delivery log and replay a delivery from the dashboard.
- Idempotency: because of retries, the same event may arrive more than
once. Use the event’s
idto deduplicate on your side. - Data minimization: payloads carry only pseudonymous identifiers (e.g. a
session_id), never raw PII.
Verifying the signature
Visz signs webhooks Stripe-style for replay resistance. The
X-Visz-Signature header carries a timestamp and the signature:
X-Visz-Signature: t=1781804392,v1=3f8a…<hex>v1 is HMAC-SHA256(secret, "{t}.{rawBody}") — the signed payload is the
unix-second timestamp t, a literal dot, then the raw request body. To
verify: parse t and v1, recompute the HMAC over `${t}.${rawBody}`,
compare in constant time, and reject the request if it doesn’t match (optionally
also reject timestamps older than ~5 minutes to stop replays).
import { createHmac, timingSafeEqual } from "node:crypto";
// Express example — make sure you have the RAW body, not the parsed JSON.
function verifyViszWebhook(rawBody, header, signingSecret, toleranceSec = 300) {
// header looks like: t=1781804392,v1=<hex>
const parts = Object.fromEntries(
String(header ?? "")
.split(",")
.map((p) => p.split("=").map((s) => s.trim()))
);
const t = parts.t;
const v1 = parts.v1;
if (!/^\d+$/.test(t ?? "") || !v1) return false;
// Optional replay guard: reject signatures older than the tolerance window.
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranceSec) return false;
const expected = createHmac("sha256", signingSecret)
.update(`${t}.${rawBody}`, "utf8") // signed payload = "{timestamp}.{rawBody}"
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(v1, "utf8");
return a.length === b.length && timingSafeEqual(a, b);
}
app.post("/visz-webhook", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyViszWebhook(
req.body.toString("utf8"), // raw body as received
req.header("X-Visz-Signature"),
process.env.VISZ_WEBHOOK_SECRET
);
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body.toString("utf8"));
// … handle event.topic … (dedupe on event.id)
res.status(200).end(); // ack quickly; do the work async
});Sign
t.rawBody, not the body alone. The timestamp is part of the HMAC input, so it can’t be tampered with. Re-serializing the parsed JSON can change whitespace/key order and break the signature — always use the exact bytes you received.
Payload envelope
Every webhook shares the same outer shape; topic tells you what happened and
data carries the topic-specific fields.
{
"id": "whd_3f2504e0",
"topic": "frustration_spike",
"created_at": "2026-06-05T10:00:00.000Z",
"workspace_id": "ws_1a2b",
"site_id": "site_9z8y",
"data": { }
}| Field | Description |
|---|---|
id | Unique delivery id — use it to deduplicate. |
topic | One of the topics below. |
created_at | When the event was generated (ISO 8601). |
workspace_id / site_id | Pseudonymous identifiers of the source. |
data | Topic-specific payload. |
Topics
You choose which topics an endpoint subscribes to.
frustration_spike
A surge of frustration signals (rage clicks, dead clicks, thrashing) on a page.
{
"topic": "frustration_spike",
"data": {
"page_url": "https://shop.example.com/checkout",
"signal": "rage_click",
"count": 42,
"window_minutes": 15,
"session_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
}
}error_spike
An above-baseline burst of JavaScript errors.
{
"topic": "error_spike",
"data": {
"page_url": "https://shop.example.com/checkout",
"message": "TypeError: Cannot read properties of undefined",
"count": 88,
"window_minutes": 15
}
}conversion_drop
A funnel’s conversion rate dropped meaningfully versus its baseline.
{
"topic": "conversion_drop",
"data": {
"funnel_id": "fn_7c4a",
"funnel_name": "Checkout",
"previous_rate": 0.041,
"current_rate": 0.018,
"window_minutes": 60
}
}blocked_purchase
A visitor hit an error or hard block during a critical purchase action.
{
"topic": "blocked_purchase",
"data": {
"page_url": "https://shop.example.com/checkout",
"reason": "js_error_during_critical_action",
"session_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
}
}mention
You (or a teammate) were @-mentioned on a recording, heatmap, or note.
{
"topic": "mention",
"data": {
"subject_type": "recording",
"subject_id": "rec_2f5a",
"mentioned_by": "member_4d7e",
"comment_excerpt": "take a look at this drop-off"
}
}weekly_summary
A scheduled digest of the past week’s key metrics.
{
"topic": "weekly_summary",
"data": {
"period_start": "2026-05-29",
"period_end": "2026-06-05",
"sessions": 12840,
"recordings": 9210,
"top_frustration_page": "https://shop.example.com/checkout"
}
}The
dataexamples above are representative shapes; field availability can vary per event. Always branch ontopic, treat unknown fields as optional, and acknowledge with a2xxquickly — do heavy work asynchronously.
Last updated: