Get notified when things happen in your VibeView project. Subscribe webhook endpoints or Slack channels to specific events — test results, visual regressions, warnings, platform actions, billing changes — and receive a signed HTTP POST or Slack message every time.
Quick Start
- Go to Settings → Integrations.
- Click Add webhook and enter a URL.
- Copy the signing secret from the banner that appears — you only see it once.
- Send a test event from the Events tab to confirm it arrives.
- Adjust the event subscriptions to receive only what you care about.
For Slack, add an Incoming Webhook URL under Slack integrations and pick which events should appear in the channel.
Event Catalog
VibeView events are grouped into four categories. Each event can be enabled or disabled per endpoint independently.
Tests
| Event | Triggers when | Slack | Default |
|---|---|---|---|
run.started | A group run or a single test run begins | — | off |
run.passed | Run finishes with all cases passing | ✓ | on |
run.not_checked | Run finishes without checking anything: no step was checked (for example a test of Verify steps only, run with No AI). Neither a pass nor a failure; never sent as run.passed or run.failed | ✓ | on |
run.failed | Run finishes with one or more failed tests | ✓ | on |
run.blocked | Run couldn’t finish for a reason that isn’t your app: the device or session was lost or ended mid-run (including a session timeout), the app couldn’t be installed, or the run was stopped (for example out of usage credit) | ✓ | on |
run.cancelled | Someone cancelled the run | — | off |
case.failed | Individual case within a group fails (one event per case) | — | off |
run.warning.flaky | A run turned a test flaky: its last 10 counted runs include both a pass and a failure (sent once until the test is stable again; not for quarantined tests) | ✓ | off |
run.warning.slow | Run took >2× the group median duration | — | off |
run.warning.ai_fallback_used | Replay failed mid-run, AI took over (signals stale replay). Steps set to Always AI don’t count | — | off |
run.warning.byok_quota_low | BYOK provider returning rate-limit warnings (not yet emitted — see note below) | ✓ | off |
Existing run.failed subscriptions were also subscribed to run.blocked, and existing run.passed subscriptions to run.not_checked.
Behavior change: a run that ends in an error or is stopped (for example out of usage credit) now sends
run.blocked, notrun.failed. If your integration only handlesrun.failed, subscribe torun.blockedtoo, or readrun.statusin the payload.
Visual
| Event | Triggers when | Slack | Default |
|---|---|---|---|
visual.regression_warning | Mid-run, similarity below warn threshold but above fail threshold | — | off |
visual.regression_detected | Mid-run, similarity below fail threshold (step fails) | ✓ | on |
visual.run_completed_with_regressions | Post-run summary, run had ≥1 regression | ✓ | on |
visual.baseline_updated | Baseline accepted, replaced, or deleted | — | off |
Platform
| Event | Triggers when | Slack | Default |
|---|---|---|---|
app.uploaded | New app build uploaded successfully | — | off |
app.upload_failed | App upload was rejected — file validation, or no storage left on your plan | — | off |
device.unavailable | Worker can’t allocate a requested device | ✓ | off |
session.created | New streaming/test session started | — | off |
session.ended | Session ended (with reason) | — | off |
Billing
| Event | Triggers when | Slack | Default |
|---|---|---|---|
quota.threshold_exceeded | Org crosses 80% or 100% of plan quota (not yet emitted — see note below) | ✓ | on |
plan.changed | Org plan upgraded or downgraded (not yet emitted — see note below) | — | off |
The Default column is what new endpoints subscribe to automatically. Existing endpoints are not affected when new events are added — they remain on their current set until you change it.
Heads up — some events are not yet emitted.
run.warning.byok_quota_low,quota.threshold_exceeded, andplan.changedappear in the subscription UI and their payload shapes are documented below, but the server does not fire them yet. Subscribing today will not deliver these events; we’ll remove this note as they go live. All other events in the catalog — includingvisual.baseline_updated— are live.
Payload Format
Every webhook delivery shares the same envelope. Only the data field varies by event.
Envelope
{
"event": "run.failed",
"event_id": "evt_01HXYZ...",
"timestamp": "2026-04-28T12:34:56Z",
"organization": {
"id": 1,
"public_id": "org_abc",
"name": "Acme"
},
"data": { ... }
}
event_id is unique per event and stable across redelivery — use it for idempotency on your side.
Test events
run.started, run.passed, run.not_checked, run.failed, run.blocked, run.cancelled:
{
"run": {
"id": "sr_...",
"kind": "suite",
"status": "failed",
"started_at": "2026-04-28T12:30:00",
"finished_at": "2026-04-28T12:30:12",
"duration_ms": 12345,
"url": "https://vibeview.io/tests/runs/sr_...",
"commit_sha": "abc123"
},
"suite": { "id": "ts_...", "name": "Login flow", "tags": ["prod", "smoke"] },
"test_case": null,
"app": { "id": "app_...", "name": "MyApp", "platform": "ios" },
"build": { "id": 42, "version": "1.2.3", "build_number": "100" },
"summary": { "total": 5, "passed": 4, "failed": 1, "cancelled": 0, "not_checked": 0 },
"failed_cases": [
{
"name": "Checkout",
"failing_step": "Tap 'Sign in'",
"error": "Element not found",
"error_kind": "element_not_found",
"error_kind_description": "The element the step needed was not present or could not be located on screen.",
"screenshots": {
"current": "https://vibeview.io/api/v1/public/screenshots/tr_.../step_2.jpg?exp=1746835200&sig=abc123"
}
}
]
}
run.kind is "suite" (a group run) or "standalone" (a single test run). The run.url always points to the run detail page regardless of kind.
The identity blocks tell you what was being tested:
suite({ id, name, tags }) is populated forkind: "suite",nullotherwise.tagsis the list of tag names attached to the group (preserving the casing the user typed). Empty array if the group has no tags. See Filtering by group tag.test_caseis populated forkind: "standalone"({ id, name }),nullotherwise.app({ id, name, platform }) is the app under test. Always populated when the run is associated with an app.build({ id, version, build_number }) is the specific app build. Populated for single test runs that targeted a known build;nullfor group runs (groups span multiple cases that may target different builds).
failed_cases is only present on run.failed and is capped at 10 unless the endpoint has the verbose flag enabled. When truncated, data.failed_cases_truncated: true is set. Each entry includes an error_kind string (see Error kind values) and a screenshots.current signed URL pointing at the screenshot captured at the failing step. Baselines are not resolved on run.failed to keep the aggregate event lightweight.
A single test run and a group send the same event for the same outcome: run.failed when the run failed (its run.status is failed), run.blocked when it ended in an error or was stopped (its run.status is then error or stopped_insufficient_credit), run.not_checked when no step was checked (its run.status is not_checked; a group sends it when every test was not checked, and run.passed when at least one passed and none failed, with summary.not_checked counting the rest), and run.cancelled when someone cancelled it. A run whose device session ended before it finished sends run.blocked, not run.cancelled. failed_cases lists only cases that failed a step, and a run.blocked event carries none.
case.failed is fired in addition to run.failed, once per failing case. Subscribe separately if you want per-case granularity.
{
"case": {
"id": "tc_...",
"name": "Checkout",
"failing_step": "Tap 'Sign in'",
"error": "Element not found",
"error_kind": "element_not_found",
"error_kind_description": "The element the step needed was not present or could not be located on screen.",
"duration_ms": 1234
},
"suite": { "id": "ts_...", "name": "Login flow", "tags": ["prod", "smoke"] },
"test_case": { "id": "tc_...", "name": "Checkout" },
"app": { "id": "app_...", "name": "MyApp", "platform": "ios" },
"run": { "id": "sr_...", "url": "https://vibeview.io/tests/runs/sr_..." }
}
case.failed carries no screenshots; use the visual.* events below for the images of a visual regression.
Warning events (run.warning.*) share a uniform shape:
{
"kind": "flaky",
"run": { "id": "sr_...", "url": "..." },
"suite": { "id": "ts_...", "name": "Login flow", "tags": ["prod"] },
"test_case": { "id": "tc_...", "name": "Sign in" },
"app": { "id": "app_...", "name": "MyApp", "platform": "ios" },
"details": { "recent_pass_rate": 0.4, "window": 10 }
}
kind matches the event suffix. details varies:
flaky:{ "recent_pass_rate": float, "window": int }— the share of passes among the counted runs considered, and how many runs that was;test_casenames the flaky testslow:{ "duration_ms": int, "median_ms": int, "ratio": float }ai_fallback_used:{ "fallback_at_step": int, "reason": string }— the first step where replay failed; steps set to Always AI are skippedbyok_quota_low:{ "provider": string, "rate_limit_remaining": int }
For warnings on single test runs, suite is null and test_case is populated. For group-level warnings (currently only slow), suite is populated and test_case is null.
Visual events
visual.regression_warning and visual.regression_detected (mid-run):
{
"run": { "id": "sr_...", "url": "..." },
"suite": { "id": "ts_...", "name": "Login flow", "tags": ["prod"] },
"test_case": { "id": "tc_...", "name": "Login" },
"app": { "id": "app_...", "name": "MyApp", "platform": "ios" },
"step": { "index": 4, "id": "s_9f2k1m4p8q3r", "name": "Login step 4" },
"baseline": { "id": 12, "name": "Login step 4" },
"reference_source": "baseline",
"similarity": 0.68,
"changed_pixel_pct": 32.0,
"thresholds": { "warn": 0.30, "fail": 0.50 },
"diff_url": "https://vibeview.io/tests/runs/sr_...#step-4",
"screenshots": {
"current": "https://vibeview.io/api/v1/public/visual-images/3f1c...e9.jpg?exp=1746835200&sig=abc123",
"diff": "https://vibeview.io/api/v1/public/visual-images/8a2d...41.jpg?exp=1746835200&sig=def456",
"baseline": "https://vibeview.io/api/v1/public/visual-images/c07b...5a.jpg?exp=1746835200&sig=ghi789"
}
}
step.index is the step’s position in that run. step.id identifies the step in its test case and stays the same when the test’s steps are reordered, so use it to match events for the same step across runs.
similarity is 1.0 - (changed_pixel_pct / 100). Thresholds are normalized the same way (0.30 means 30% diff triggers a warning). Both metrics are included so consumers can use whichever is more natural.
reference_source tells you what the live screenshot was compared against:
"baseline"— an accepted baseline (thebaselineblock points at the stored row)."recording"— the original recording’s screenshot, used as a first-run fallback before any baseline is accepted. In this casebaselineisnull.
Per-step events fire for both sources, so you’ll start seeing them on the very first run of a new group. Filter on data.reference_source == "baseline" if you only want to be alerted to drift against accepted baselines. When reference_source is "recording", screenshots.baseline is omitted — there is no stored baseline row to link to.
visual.run_completed_with_regressions (post-run summary):
{
"run": { "id": "sr_...", "url": "..." },
"suite": { "id": "ts_...", "name": "Login flow", "tags": ["prod"] },
"test_case": null,
"app": { "id": "app_...", "name": "MyApp", "platform": "ios" },
"regressions": { "warnings": 2, "failures": 1 },
"affected_steps": [
{
"index": 4,
"id": "s_9f2k1m4p8q3r",
"severity": "fail",
"similarity": 0.42,
"changed_pixel_pct": 58.1,
"screenshots": {
"current": "https://vibeview.io/api/v1/public/visual-images/3f1c...e9.jpg?exp=1746835200&sig=abc123",
"diff": "https://vibeview.io/api/v1/public/visual-images/8a2d...41.jpg?exp=1746835200&sig=def456",
"baseline": "https://vibeview.io/api/v1/public/visual-images/c07b...5a.jpg?exp=1746835200&sig=ghi789"
}
}
]
}
affected_steps lists every step in the run that crossed a threshold, with its position in the run (index), its step id, its severity ("warn" or "fail"), the SSIM-derived similarity, and the changed-pixel percent. Each step’s screenshots are the exact images that step was compared with; baseline is present when the comparison ran against an accepted baseline, and omitted when reference_source was "recording".
visual.baseline_updated:
{
"baseline": { "id": 12, "name": "Login step 4" },
"suite": { "id": "ts_...", "name": "Login flow", "tags": ["prod"] },
"test_case": { "id": "tc_...", "name": "Login" },
"app": { "id": "app_...", "name": "MyApp", "platform": "ios" },
"step_index": 4,
"step_id": "s_9f2k1m4p8q3r",
"device_name": "iPhone 16 Pro",
"actor": { "email": "user@example.com" },
"action": "replaced"
}
action is one of:
"replaced"— a baseline was accepted from a run (one event per step)."cleared"— a device’s baselines were cleared (one event for the whole device;baseline.id,step_indexandstep_idarenull).
step_id identifies the step and stays the same when steps are reordered; step_index is the step’s current position in its test case (null when it has none; baseline.name then names the step by its text, shortened, or by its step_id when it has no text).
Screenshot URLs
Failure and visual events include a screenshots object with signed, time-limited URLs to JPEG images. The URLs require no Authorization header — the sig query parameter authorizes access.
URL format:
https://vibeview.io/api/v1/public/visual-images/{image_id}.jpg?exp={unix_ts}&sig={hmac}
https://vibeview.io/api/v1/public/screenshots/{run_id}/step_{n}.jpg?exp={unix_ts}&sig={hmac}
Visual events link visual-images URLs: the exact images the step was compared with, which never change even if the baseline is replaced later. run.failed, and the steps of older runs (recorded before baselines followed their steps) in visual.run_completed_with_regressions, use the screenshots form; those older steps carry current and diff but no baseline.
Behaviour:
GETreturnsimage/jpegon success.403if the signature is invalid or the URL has expired.404if the image no longer exists on disk.- URLs expire 7 days after the webhook fires (
expis the Unix expiry timestamp). - The signing secret used for screenshot URLs is independent of the webhook signing secret.
Which keys appear where:
| Key | Present when |
|---|---|
current | Always present on visual.* events and run.failed entries when the failing/regressing step has a screenshot (case.failed carries no screenshots) |
baseline | Visual comparisons against an accepted baseline only (reference_source: "baseline") |
diff | visual.* events only (regression_warning, regression_detected, run_completed_with_regressions), when a diff image was produced |
run.failed entries only carry current — baselines are not resolved there to keep the aggregate payload lightweight.
Change (October 2026): visual.regression_warning and visual.regression_detected payloads, and the steps of visual.run_completed_with_regressions, omit screenshots.diff when no diff image was made for the step. Earlier payloads always included a diff link, which could point at an image that did not exist. Steps of older runs (recorded before baselines followed their steps) keep the earlier form.
Error kind values
error_kind is a coarse failure classification present on case.failed (in the case block) and on each entry of run.failed’s failed_cases array. Every payload that carries error_kind also carries error_kind_description — a human-readable sentence explaining the classification. The free-text error field still holds the specific failure detail or AI reasoning for that particular run.
AI-supplied — the AI agent picks these when it calls step_failed. A step that fails in a No AI run also uses element_not_found, wrong_state or app_unresponsive when that is why the recorded action could not be replayed:
| Value | Meaning |
|---|---|
element_not_found | The element the step needed was not present or could not be located on screen. |
app_unresponsive | The screen was frozen or no UI rendered, so the step could not proceed. A screen that shows normal UI but did not change after the agent’s taps is reported as wrong_state, not app_unresponsive. |
app_bug | The app crashed or behaved incorrectly (e.g. an error dialog or clearly wrong screen). |
wrong_state | The app was on a different screen than the step expected. |
assertion_mismatch | A check the AI agent made on the screen was not true. |
blocked | The step could not run because an external precondition was unmet (e.g. missing test data, login wall, paywall). |
needs_human | The failure was genuinely ambiguous and needs a person to judge. |
System-supplied — set in code when the cause is known:
| Value | Meaning |
|---|---|
assertion_failed | A check on the screen, such as a Verify step’s, did not hold. |
max_iterations | The step needed more AI turns than the run allows. Split it into smaller steps, or raise the limit in AI settings. |
aborted | The step was stopped before it could complete: a safety guard halted the run (e.g. token budget exceeded), or a recorded action could not be replayed and the step could not fall back to AI (a No AI run, or a device error). |
visual_regression | The screen differed from its visual baseline beyond the allowed threshold. |
Fallback:
| Value | Meaning |
|---|---|
unknown | The failure could not be classified (older run, or an unrecognized category). |
Platform events
app.uploaded:
{
"app": {
"id": "app_...",
"name": "MyApp",
"platform": "ios",
"size_bytes": 12345678
},
"build": { "id": 42, "version": "1.2.3", "build_number": "100" },
"is_new_app": false,
"actor": { "email": "user@example.com" }
}
is_new_app is true when the app row itself was just created (first build for this package), false when this is a new build of an existing app.
app.upload_failed shape is identical except build and is_new_app are absent (the build record was never created), and error is populated:
{
"app": {
"id": "-",
"name": "broken.ipa",
"platform": "ios",
"size_bytes": 12345678
},
"build": null,
"actor": { "email": "user@example.com" },
"error": "File content does not match expected type (.ipa)"
}
An app upload (dashboard, CLI, or CI) refused because your plan is out of
storage emits app.upload_failed too, with the storage message as the error
— so a pipeline watching this event learns why a build never appeared instead
of seeing silence. Cloud builds refused at creation for the same reason do not
emit this event; they fail at request time with the storage message.
device.unavailable:
{
"platform": "ios",
"requested": { "model": "iPhone 15", "os_version": "17.0" },
"reason": "no_workers_available",
"run_id": "sr_..."
}
session.created / session.ended (a session.ended shown):
{
"session": {
"id": "sess_...",
"platform": "ios",
"started_at": "2026-04-28T12:30:00",
"ended_at": "2026-04-28T13:05:00"
},
"ended_reason": "timeout",
"reason_code": "inactivity_timeout"
}
ended_reason is "user", "timeout", "error", or null (only set on session.ended; session.created has ended_at and ended_reason null). session.ended also carries reason_code, the same code the SDK’s session:ended event reports, which says more precisely why: stopped, stopped_by_admin (VibeView support stopped it), app_not_allowed (an app that isn’t allowed on VibeView was installed during the session), link_regenerated or link_revoked come with "user"; inactivity_timeout, no_client (no client was holding the session any more), session_length_limit, demo_session_limit, usage_credit_exhausted or overage_disabled with "timeout"; stuck_starting (the device never finished starting), worker_restarted, worker_lost (the device stopped responding), device_crashed (the device stopped unexpectedly during the session), device_turned_off or device_restarted (someone turned the device off or restarted it from its own menu; a simulator can’t power back on — usually reported this way, but as device_crashed when it couldn’t be confirmed) with "error". Other codes can appear; treat an unknown one by its ended_reason.
Billing events
quota.threshold_exceeded:
{
"metric": "streaming_minutes",
"threshold_pct": 80,
"current": 400,
"limit": 500,
"period_end": "2026-05-01T00:00:00Z"
}
threshold_pct is 80 or 100. Once live, each crossing fires once per period.
plan.changed:
{
"from": "starter",
"to": "professional",
"actor": { "email": "user@example.com" }
}
Filtering by group tag
Group tags travel with every test, warning, and visual event so receivers can decide which runs to act on without subscribing/unsubscribing each time the team adds a new group. Tag groups in Tests → Group → Tags (e.g. prod, smoke, nightly) and filter on the receiver side.
The same one-line rule works for every event that carries a suite block:
function shouldForward(payload) {
const suite = payload.data.suite;
if (!suite) return false; // standalone / suiteless event
const tags = (suite.tags || []).map((t) => t.toLowerCase());
return tags.includes("prod");
}
Notes:
- Casing. Tag values preserve the casing the user typed (e.g.
"Prod"). Comparisons should be case-insensitive — lowercase both sides before checking. - Single test runs. A test run outside any group has
data.suite: null. The filter above drops these automatically, which is usually the desired behavior for “only react to tagged production groups.” - Events without a
suiteblock.app.*,device.unavailable,session.*,quota.threshold_exceeded, andplan.changeddon’t carry a group — they’re org-level events. The filter above drops them; use the Events tab to unsubscribe from any you don’t want.
Subscriptions
Each webhook endpoint and each Slack integration has its own list of subscribed event types. New endpoints subscribe to the Default column from the catalog above.
In the UI, the Events tab on each endpoint shows the catalog as a category-grouped checkbox tree:
- A category checkbox is a tri-state shortcut (on / partial / off).
- Individual event checkboxes flip just that one event.
- Changes save automatically as you click.
Slack integrations only show events that Slack supports. Non-supported events are filtered out — there’s nothing to render for them in Block Kit.
Adding events to existing endpoints
When VibeView adds a new event type, existing endpoints stay on their current subscription set. New events are opt-in. To start receiving a new event, open the endpoint’s Events tab and check it.
Signing & Verification
Every webhook delivery is signed with HMAC-SHA256. Verify the signature on your side to confirm the request actually came from VibeView and wasn’t replayed.
Headers
Content-Type: application/json
User-Agent: VibeView-Webhook/2.0
X-VibeView-Event: run.failed
X-VibeView-Event-Id: evt_01HXYZ...
X-VibeView-Timestamp: 1714305296
X-VibeView-Signature: sha256=<hex>
X-VibeView-Delivery: dlv_01HXYZ...
How to verify
The signature is computed as:
HMAC-SHA256(secret, "<X-VibeView-Timestamp>.<raw_body>")
That is: the timestamp, then a literal period, then the raw request body bytes. The result is hex-encoded and prefixed with sha256=.
Reject the request if X-VibeView-Timestamp is older than 5 minutes from your current time — this prevents replay attacks if a delivery is intercepted and resent later.
Node.js
import crypto from 'crypto';
export function verify(secret, timestamp, body, signature) {
// body must be the raw bytes — read it before any JSON parsing middleware.
const ageSec = Math.abs(Date.now() / 1000 - Number(timestamp));
if (ageSec > 300) return false;
const expected =
'sha256=' +
crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${body}`)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
Python
import hashlib
import hmac
import time
def verify(secret: str, timestamp: str, body: bytes, signature: str) -> bool:
if abs(time.time() - int(timestamp)) > 300:
return False
expected = "sha256=" + hmac.new(
secret.encode(),
f"{timestamp}.".encode() + body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(signature, expected)
Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"time"
)
func Verify(secret, timestamp, body, signature string) bool {
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if abs(time.Now().Unix()-ts) > 300 {
return false
}
m := hmac.New(sha256.New, []byte(secret))
m.Write([]byte(timestamp + "." + body))
expected := "sha256=" + hex.EncodeToString(m.Sum(nil))
return hmac.Equal([]byte(signature), []byte(expected))
}
Rotating secrets
Click Regenerate secret on a webhook endpoint to produce a new secret. The old one stops working immediately. Update your verification code with the new secret before regenerating in production — there is no overlap window.
Delivery Log
Every delivery attempt is recorded for 30 days. Open the Deliveries tab on a webhook endpoint to see:
- Time, event type, status, response code, attempt count.
- Click a row to expand and inspect the exact payload sent.
- Click Redeliver on any failed delivery to re-send it. The new delivery has a new
dlv_…ID but reuses the originalevt_…so your idempotency check sees the same event.
Use Redeliver when:
- Your server was down briefly and you want the missed events.
- You’re debugging and want to replay a specific payload after fixing your handler.
Don’t use it for events more than a few hours old — the upstream state may have changed.
Slack Specifics
Slack integrations use the same subscription model as webhooks but render Block Kit messages instead of raw JSON. Only events with sensible Block Kit rendering are supported (the Slack column in each catalog table).
Slack messages always include:
- A summary line.
- A coloured sidebar (green for
passed, red forfailed/regression_detected, amber for warnings). - A “View Results” / “View Diff” / “View Run” button linking back to the authenticated VibeView UI.
Webhook JSON payloads for failure and visual events include signed, time-limited screenshot URLs (see Screenshot URLs). Slack messages do not embed those URLs directly — they link to the run detail page where the user can sign in and see everything in context. This keeps screenshots out of Slack message archives.
Troubleshooting
Webhook delivery says “failed” but my server logged a 200. Check the response body excerpt in the Deliveries tab — VibeView treats any 4xx or 5xx as failure. If your server returns a 2xx, the delivery is recorded as succeeded.
Signature verification keeps failing. The HMAC input is {timestamp}.{body} with the raw bytes — make sure your framework hasn’t already JSON-parsed the body. In Express, mount express.raw() on the webhook route.
I’m not getting an event I should be. Open the endpoint’s Events tab and confirm the event is checked. Also check that the endpoint is marked active and the is_active flag is true.