Webhooks & Slack
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 | Suite or standalone run begins | — | off |
run.passed | Run finishes with all cases passing | ✓ | on |
run.failed | Run finishes with one or more failures | ✓ | on |
run.cancelled | User cancels mid-run, or session timed out | — | off |
case.failed | Individual case within a suite fails (one event per case) | — | off |
run.warning.flaky | Same test passed/failed inconsistently across recent runs (not yet emitted — see note below) | ✓ | off |
run.warning.slow | Run took >2× the suite median duration | — | off |
run.warning.ai_fallback_used | Replay failed mid-run, AI took over (signals stale replay) | — | off |
run.warning.byok_quota_low | BYOK provider returning rate-limit warnings (not yet emitted — see note below) | ✓ | off |
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 failed magic-byte / size validation | — | 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.flaky,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.failed, 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 },
"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" or "standalone". 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 suite (preserving the casing the user typed). Empty array if the suite has no tags. See Filtering by suite 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 standalone runs that targeted a known build;nullfor suite runs (suites 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.
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,
"screenshots": {
"current": "https://vibeview.io/api/v1/public/screenshots/tr_.../step_2.jpg?exp=1746835200&sig=abc123"
}
},
"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_..." }
}
For visual-regression failures (error_kind: "visual_regression"), case.screenshots also includes a baseline key with the signed URL of the accepted baseline image.
Warning events (run.warning.*) share a uniform shape:
{
"kind": "flaky",
"run": { "id": "sr_...", "url": "..." },
"suite": { "id": "ts_...", "name": "Login flow", "tags": ["prod"] },
"test_case": null,
"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 }slow:{ "duration_ms": int, "median_ms": int, "ratio": float }ai_fallback_used:{ "fallback_at_step": int, "reason": string }byok_quota_low:{ "provider": string, "rate_limit_remaining": int }
For warnings on standalone runs, suite is null and test_case is populated. For suite-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, "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/screenshots/tr_.../step_4.jpg?exp=1746835200&sig=abc123",
"diff": "https://vibeview.io/api/v1/public/screenshots/tr_.../diff_step_4.jpg?exp=1746835200&sig=def456",
"baseline": "https://vibeview.io/api/v1/public/baselines/baseline_12.jpg?exp=1746835200&sig=ghi789"
}
}
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 suite. 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,
"severity": "fail",
"similarity": 0.42,
"changed_pixel_pct": 58.1,
"screenshots": {
"current": "https://vibeview.io/api/v1/public/screenshots/tr_.../step_4.jpg?exp=1746835200&sig=abc123",
"diff": "https://vibeview.io/api/v1/public/screenshots/tr_.../diff_step_4.jpg?exp=1746835200&sig=def456",
"baseline": "https://vibeview.io/api/v1/public/baselines/baseline_12.jpg?exp=1746835200&sig=ghi789"
}
}
]
}
affected_steps lists every step in the run that crossed a threshold, with its severity ("warn" or "fail"), the SSIM-derived similarity, and the changed-pixel percent. Each step’s screenshots point at the individual test run that produced them; 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,
"actor": { "email": "user@example.com" },
"action": "accepted"
}
action is one of "accepted", "replaced", "deleted".
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/screenshots/{run_id}/step_{n}.jpg?exp={unix_ts}&sig={hmac}
https://vibeview.io/api/v1/public/baselines/{path}?exp={unix_ts}&sig={hmac}
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 when a failing/regressing step index is known |
baseline | Visual comparisons against an accepted baseline only (reference_source: "baseline") |
diff | visual.* events only (regression_warning, regression_detected, run_completed_with_regressions) |
run.failed entries only carry current — baselines are not resolved there to keep the aggregate payload lightweight.
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:
| 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. |
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 condition the agent verified 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 | An explicit assertion in the test did not hold. |
max_iterations | The agent could not complete the step within the allowed number of attempts; the step may be too complex — try breaking it into smaller steps. |
aborted | The run was halted by a safety guard (e.g. token budget exceeded, or a replay step permanently failed). |
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)"
}
device.unavailable:
{
"platform": "ios",
"requested": { "model": "iPhone 15", "os_version": "17.0" },
"reason": "no_workers_available",
"run_id": "sr_..."
}
session.created / session.ended:
{
"session": {
"id": "sess_...",
"platform": "ios",
"started_at": "2026-04-28T12:30:00",
"ended_at": null
},
"ended_reason": null
}
ended_reason is "user", "timeout", "error", or null (only set on session.ended).
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 suite tag
Suite 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 suite. Tag suites in Tests → Suite → 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. - Standalone runs. A standalone test run (one-off, not part of a suite) has
data.suite: null. The filter above drops these automatically, which is usually the desired behavior for “only react to tagged production suites.” - Events without a
suiteblock.app.*,device.unavailable,session.*,quota.threshold_exceeded, andplan.changeddon’t carry a suite — 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.