Skip to content
NewiPhone Duo, live in your browser. Try it →

CI and integrations Webhooks and Slack

Webhooks & Slack

Subscribe endpoints or Slack channels to test results, visual regressions, warnings and billing events, and verify the signed HTTP POST you receive.

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.startedA group run or a single test run begins—off
run.passedRun finishes with all cases passing✓on
run.not_checkedRun 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.failedRun finishes with one or more failed tests✓on
run.blockedRun 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.cancelledSomeone cancelled the run—off
case.failedIndividual case within a group fails (one event per case)—off
run.warning.flakyA 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.slowRun took >2× the group median duration—off
run.warning.ai_fallback_usedReplay failed mid-run, AI took over (signals stale replay). Steps set to Always AI don’t count—off
run.warning.byok_quota_lowBYOK 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, not run.failed. If your integration only handles run.failed, subscribe to run.blocked too, or read run.status in the payload.

Visual

EventTriggers whenSlackDefault
visual.regression_warningMid-run, similarity below warn threshold but above fail threshold—off
visual.regression_detectedMid-run, similarity below fail threshold (step fails)✓on
visual.run_completed_with_regressionsPost-run summary, run had ≥1 regression✓on
visual.baseline_updatedBaseline accepted, replaced, or deleted—off

Platform

EventTriggers whenSlackDefault
app.uploadedNew app build uploaded successfully—off
app.upload_failedApp upload was rejected — file validation, or no storage left on your plan—off
device.unavailableWorker can’t allocate a requested device✓off
session.createdNew streaming/test session started—off
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.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.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 for kind: "suite", null otherwise. tags is 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_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 single test runs that targeted a known build; null for 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_case names the flaky test
  • slow: { "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 skipped
  • byok_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 (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 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_index and step_id are null).

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:

  • 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 on visual.* events and run.failed entries when the failing/regressing step has a screenshot (case.failed carries no screenshots)
baselineVisual comparisons against an accepted baseline only (reference_source: "baseline")
diffvisual.* 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:

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. A screen that shows normal UI but did not change after the agent’s taps is reported as wrong_state, not app_unresponsive.
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 check the AI agent made on the screen 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_failedA check on the screen, such as a Verify step’s, did not hold.
max_iterationsThe step needed more AI turns than the run allows. Split it into smaller steps, or raise the limit in AI settings.
abortedThe 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_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)"
}

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 suite block. app.*, device.unavailable, session.*, quota.threshold_exceeded, and plan.changed don’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 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.

Last updated 13 Oct 2026 Something wrong on this page? Tell us

Search the docs