Visual Uptime webhooks
Visual Uptime can send a signed JSON POST request to a URL you choose whenever something happens in a project. Use it to forward alerts into Slack, a chat bot, a ticketing system or your own automation.
Add a webhook in Project settings → Alerts. The signing secret is shown once, right after you add the URL. Store it somewhere safe; if you lose it, remove the webhook and add it again.
Delivery
- Method:
POST, bodyapplication/json; charset=utf-8 - Your endpoint must answer with a
2xxstatus to confirm receipt. Any other status, or no answer within 15 seconds, counts as a failed attempt. - Failed deliveries are retried automatically: first attempt immediately, then after 30 seconds, 2 minutes and 10 minutes. The Alerts page shows each delivery as pending, retrying, succeeded or failed.
- A delivery can arrive more than once if a retry races with a slow answer. Use the
X-Visual-Deliveryheader (ordeliveryIdin the body) to deduplicate: it is identical for every attempt of the same delivery. - Webhook failures never change incident state. Incidents stay visible in the app regardless.
- The payload schema is versioned through the
schemafield (visual-uptime.webhook.v1). New optional fields may be added to v1; existing fields will not change meaning.
Headers
| Header | Meaning |
|---|---|
X-Visual-Event | Event type, for example incident.opened |
X-Visual-Delivery | Stable delivery id, the same for every retry |
X-Visual-Signature | sha256=<hex>, HMAC-SHA256 of the raw request body using your signing secret |
Content-Type | application/json |
Events
| Event | When | Alerts |
|---|---|---|
incident.opened | A new problem is detected. Sent once per incident. | Email and webhook |
incident.resolved | The problem went away on its own, or someone approved it as the new baseline. | Webhook only |
run.completed | A run finished. Includes counts and links to everything still unresolved, so you do not get the same incident re-announced every day. | Webhook only |
test | You pressed "Send test webhook". | Webhook only |
New incidents alert once. Unresolved incidents stay visible in the app and are referenced in later run.completed payloads.
Payload
Every payload has these top-level fields:
| Field | Type | Description |
|---|---|---|
schema | string | Always visual-uptime.webhook.v1 |
event | string | incident.opened, incident.resolved, run.completed or test |
deliveryId | string | Stable id of this delivery |
sentAt | string | ISO 8601 UTC time the payload was created |
project | object | id, name, rootUrl, url (link to the project) |
incident | object | Present for incident.opened and incident.resolved |
run | object | Present for run.completed |
incident object
| Field | Type | Description |
|---|---|---|
id | string | Stable incident id |
url | string | Deep link to the incident (sign-in required) |
kind | string | visual (visual regression), unreachable (page did not load) or system (monitoring system error) |
status | string | open or resolved |
resolution | string or null | fixed (page matches the baseline again), approved (accepted as the new baseline) or null while open |
title | string | Short plain-language problem statement |
explanation | string | One or two sentences describing what looks wrong |
pageUrl | string or null | The affected page |
template | string or null | Name of the page type the page belongs to |
viewport | object or null | width in pixels and a label such as "Mobile (390px)" |
region | object or null | Affected area in screenshot pixels: x, y, w, h |
scope | string or null | isolated, multiple, template_wide, insufficient or single_page |
firstDetectedAt, lastDetectedAt | string | ISO 8601 UTC times |
evidence | object | Links to current, baseline and diff images (each may be null). The links require a signed-in project member. |
run object
| Field | Type | Description |
|---|---|---|
id, url | string | Run id and deep link |
status | string | passed, failed (issues found) or error (could not complete) |
trigger | string | scheduled, manual or setup |
target | string | The site that was checked |
baseline | string or null | Name of the baseline used |
startedAt, endedAt | string or null | ISO 8601 UTC times |
counts | object | checked, passed, newIncidents, stillOpen, resolved, operationalFailures |
unresolvedIncidents | array | id, title, url of every incident still open after the run |
Examples
incident.opened
{
"schema": "visual-uptime.webhook.v1",
"event": "incident.opened",
"deliveryId": "dlv_0example",
"sentAt": "2026-10-03T09:14:07.000Z",
"project": {
"id": "prj_0example",
"name": "Acme marketing site",
"rootUrl": "https://acme.example/",
"url": "https://vis.clanqi.org/p/prj_0example"
},
"incident": {
"id": "inc_0example",
"url": "https://vis.clanqi.org/p/prj_0example/incidents/inc_0example",
"kind": "visual",
"status": "open",
"resolution": null,
"title": "Pricing cards overflow the page on mobile",
"explanation": "The third pricing card extends beyond the right edge of its container on mobile.",
"pageUrl": "https://acme.example/pricing",
"template": "Marketing pages",
"viewport": {
"width": 390,
"label": "Mobile (390px)"
},
"region": {
"x": 24,
"y": 1180,
"w": 420,
"h": 360
},
"scope": "isolated",
"firstDetectedAt": "2026-10-03T09:14:05.000Z",
"lastDetectedAt": "2026-10-03T09:14:05.000Z",
"evidence": {
"current": "https://vis.clanqi.org/p/prj_0example/artifacts/p/prj_0example/runs/run_0example/chk_current.jpg",
"baseline": "https://vis.clanqi.org/p/prj_0example/artifacts/p/prj_0example/baselines/bl_0example/pricing-390.jpg",
"diff": "https://vis.clanqi.org/p/prj_0example/artifacts/p/prj_0example/runs/run_0example/chk_diff.png"
}
}
}
incident.resolved
{
"schema": "visual-uptime.webhook.v1",
"event": "incident.resolved",
"deliveryId": "dlv_1example",
"sentAt": "2026-10-04T09:12:41.000Z",
"project": {
"id": "prj_0example",
"name": "Acme marketing site",
"rootUrl": "https://acme.example/",
"url": "https://vis.clanqi.org/p/prj_0example"
},
"incident": {
"id": "inc_0example",
"url": "https://vis.clanqi.org/p/prj_0example/incidents/inc_0example",
"kind": "visual",
"status": "resolved",
"resolution": "fixed",
"title": "Pricing cards overflow the page on mobile",
"explanation": "The third pricing card extends beyond the right edge of its container on mobile.",
"pageUrl": "https://acme.example/pricing",
"template": "Marketing pages",
"viewport": {
"width": 390,
"label": "Mobile (390px)"
},
"region": {
"x": 24,
"y": 1180,
"w": 420,
"h": 360
},
"scope": "isolated",
"firstDetectedAt": "2026-10-03T09:14:05.000Z",
"lastDetectedAt": "2026-10-03T09:14:05.000Z",
"evidence": {
"current": "https://vis.clanqi.org/p/prj_0example/artifacts/p/prj_0example/runs/run_0example/chk_current.jpg",
"baseline": "https://vis.clanqi.org/p/prj_0example/artifacts/p/prj_0example/baselines/bl_0example/pricing-390.jpg",
"diff": "https://vis.clanqi.org/p/prj_0example/artifacts/p/prj_0example/runs/run_0example/chk_diff.png"
}
}
}
run.completed
{
"schema": "visual-uptime.webhook.v1",
"event": "run.completed",
"deliveryId": "dlv_2example",
"sentAt": "2026-10-03T09:16:30.000Z",
"project": {
"id": "prj_0example",
"name": "Acme marketing site",
"rootUrl": "https://acme.example/",
"url": "https://vis.clanqi.org/p/prj_0example"
},
"run": {
"id": "run_0example",
"url": "https://vis.clanqi.org/p/prj_0example/runs/run_0example",
"status": "failed",
"trigger": "scheduled",
"target": "https://acme.example/",
"baseline": "Production",
"startedAt": "2026-10-03T09:13:02.000Z",
"endedAt": "2026-10-03T09:16:29.000Z",
"counts": {
"checked": 14,
"passed": 13,
"newIncidents": 1,
"stillOpen": 1,
"resolved": 0,
"operationalFailures": 0
},
"unresolvedIncidents": [
{
"id": "inc_0example",
"title": "Pricing cards overflow the page on mobile",
"url": "https://vis.clanqi.org/p/prj_0example/incidents/inc_0example"
},
{
"id": "inc_1example",
"title": "Footer links overlap on tablet",
"url": "https://vis.clanqi.org/p/prj_0example/incidents/inc_1example"
}
]
}
}
test
{
"schema": "visual-uptime.webhook.v1",
"event": "test",
"deliveryId": "dlv_3example",
"sentAt": "2026-10-03T09:20:00.000Z",
"project": {
"id": "prj_0example",
"name": "Acme marketing site",
"rootUrl": "https://acme.example/",
"url": "https://vis.clanqi.org/p/prj_0example"
}
}
Verifying the signature
Compute the HMAC-SHA256 of the raw request body (the exact bytes you received, before any JSON parsing) with your signing secret as the key, hex-encode it, and compare it to the value after sha256= in X-Visual-Signature using a constant-time comparison. Reject the request if they differ.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody, signatureHeader, secret) {
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const given = (signatureHeader || "").replace(/^sha256=/, "");
const a = Buffer.from(expected), b = Buffer.from(given);
return a.length === b.length && timingSafeEqual(a, b);
}
Python
import hmac, hashlib
def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
given = signature_header.removeprefix("sha256=")
return hmac.compare_digest(expected, given)
Quick check from a shell
printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET"