V
Vibeview
Pricing

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

  1. Go to Settings → Integrations.
  2. Click Add webhook and enter a URL.
  3. Copy the signing secret from the banner that appears — you only see it once.
  4. Send a test event from the Events tab to confirm it arrives.
  5. 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

EventTriggers whenSlackDefault
run.startedSuite or standalone run beginsoff
run.passedRun finishes with all cases passingon
run.failedRun finishes with one or more failureson
run.cancelledUser cancels mid-run, or session timed outoff
case.failedIndividual case within a suite fails (one event per case)off
run.warning.flakySame test passed/failed inconsistently across recent runs (not yet emitted — see note below)off
run.warning.slowRun took >2× the suite median durationoff
run.warning.ai_fallback_usedReplay failed mid-run, AI took over (signals stale replay)off
run.warning.byok_quota_lowBYOK provider returning rate-limit warnings (not yet emitted — see note below)off

Visual

EventTriggers whenSlackDefault
visual.regression_warningMid-run, similarity below warn threshold but above fail thresholdoff
visual.regression_detectedMid-run, similarity below fail threshold (step fails)on
visual.run_completed_with_regressionsPost-run summary, run had ≥1 regressionon
visual.baseline_updatedBaseline accepted, replaced, or deletedoff

Platform

EventTriggers whenSlackDefault
app.uploadedNew app build uploaded successfullyoff
app.upload_failedApp upload failed magic-byte / size validationoff
device.unavailableWorker can’t allocate a requested deviceoff
session.createdNew streaming/test session startedoff
session.endedSession ended (with reason)off

Billing

EventTriggers whenSlackDefault
quota.threshold_exceededOrg crosses 80% or 100% of plan quota (not yet emitted — see note below)on
plan.changedOrg 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, and plan.changed appear 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 — including visual.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 for kind: "suite", null otherwise. tags is 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_case is populated for kind: "standalone" ({ id, name }), null otherwise.
  • 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; null for 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 (the baseline block points at the stored row).
  • "recording" — the original recording’s screenshot, used as a first-run fallback before any baseline is accepted. In this case baseline is null.

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:

  • GET returns image/jpeg on success.
  • 403 if the signature is invalid or the URL has expired.
  • 404 if the image no longer exists on disk.
  • URLs expire 7 days after the webhook fires (exp is the Unix expiry timestamp).
  • The signing secret used for screenshot URLs is independent of the webhook signing secret.

Which keys appear where:

KeyPresent when
currentAlways present when a failing/regressing step index is known
baselineVisual comparisons against an accepted baseline only (reference_source: "baseline")
diffvisual.* 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:

ValueMeaning
element_not_foundThe element the step needed was not present or could not be located on screen.
app_unresponsiveThe screen was frozen or no UI rendered, so the step could not proceed.
app_bugThe app crashed or behaved incorrectly (e.g. an error dialog or clearly wrong screen).
wrong_stateThe app was on a different screen than the step expected.
assertion_mismatchA condition the agent verified was not true.
blockedThe step could not run because an external precondition was unmet (e.g. missing test data, login wall, paywall).
needs_humanThe failure was genuinely ambiguous and needs a person to judge.

System-supplied — set in code when the cause is known:

ValueMeaning
assertion_failedAn explicit assertion in the test did not hold.
max_iterationsThe agent could not complete the step within the allowed number of attempts; the step may be too complex — try breaking it into smaller steps.
abortedThe run was halted by a safety guard (e.g. token budget exceeded, or a replay step permanently failed).
visual_regressionThe screen differed from its visual baseline beyond the allowed threshold.

Fallback:

ValueMeaning
unknownThe 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 suite block. app.*, device.unavailable, session.*, quota.threshold_exceeded, and plan.changed don’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 original evt_… 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 for failed/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.