Visual Uptime

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

Headers

HeaderMeaning
X-Visual-EventEvent type, for example incident.opened
X-Visual-DeliveryStable delivery id, the same for every retry
X-Visual-Signaturesha256=<hex>, HMAC-SHA256 of the raw request body using your signing secret
Content-Typeapplication/json

Events

EventWhenAlerts
incident.openedA new problem is detected. Sent once per incident.Email and webhook
incident.resolvedThe problem went away on its own, or someone approved it as the new baseline.Webhook only
run.completedA run finished. Includes counts and links to everything still unresolved, so you do not get the same incident re-announced every day.Webhook only
testYou 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:

FieldTypeDescription
schemastringAlways visual-uptime.webhook.v1
eventstringincident.opened, incident.resolved, run.completed or test
deliveryIdstringStable id of this delivery
sentAtstringISO 8601 UTC time the payload was created
projectobjectid, name, rootUrl, url (link to the project)
incidentobjectPresent for incident.opened and incident.resolved
runobjectPresent for run.completed

incident object

FieldTypeDescription
idstringStable incident id
urlstringDeep link to the incident (sign-in required)
kindstringvisual (visual regression), unreachable (page did not load) or system (monitoring system error)
statusstringopen or resolved
resolutionstring or nullfixed (page matches the baseline again), approved (accepted as the new baseline) or null while open
titlestringShort plain-language problem statement
explanationstringOne or two sentences describing what looks wrong
pageUrlstring or nullThe affected page
templatestring or nullName of the page type the page belongs to
viewportobject or nullwidth in pixels and a label such as "Mobile (390px)"
regionobject or nullAffected area in screenshot pixels: x, y, w, h
scopestring or nullisolated, multiple, template_wide, insufficient or single_page
firstDetectedAt, lastDetectedAtstringISO 8601 UTC times
evidenceobjectLinks to current, baseline and diff images (each may be null). The links require a signed-in project member.

run object

FieldTypeDescription
id, urlstringRun id and deep link
statusstringpassed, failed (issues found) or error (could not complete)
triggerstringscheduled, manual or setup
targetstringThe site that was checked
baselinestring or nullName of the baseline used
startedAt, endedAtstring or nullISO 8601 UTC times
countsobjectchecked, passed, newIncidents, stillOpen, resolved, operationalFailures
unresolvedIncidentsarrayid, 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"