Skip to Content

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: POST with a JSON body to your configured URL.
  • Signature: every request carries an X-Visz-Signature header (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 id to 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": { } }
FieldDescription
idUnique delivery id — use it to deduplicate.
topicOne of the topics below.
created_atWhen the event was generated (ISO 8601).
workspace_id / site_idPseudonymous identifiers of the source.
dataTopic-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 data examples above are representative shapes; field availability can vary per event. Always branch on topic, treat unknown fields as optional, and acknowledge with a 2xx quickly — do heavy work asynchronously.

Last updated:

Last updated on