Developer docs

Webhooks

Signed POSTs to a URL you control whenever something happens in your workspace: a lead is captured or changed by API, a goal is reached, a call is booked or canceled, an outcome is recorded, a video finishes processing or fails, or a viewer starts, completes, passes a gate or picks a branch in a video. Available on every plan.

Create an endpoint

On the Integrations page, scroll to Webhooks and choose Add a webhook. Give it a name and a public URL, then pick the events it should receive. Local and private network addresses are rejected when the endpoint is saved. The same can be done by code with an Admin key: see Provisioning on the REST API page.

The endpoint's signing secret is shown once, when it is created. Keep it; it never comes back. If it is lost, delete the endpoint and add it again.

Every endpoint has Send test event, a pause toggle, and a delivery history filtered by success, failed, retrying and pending. A paused endpoint receives nothing until resumed.

Events

12 events can be subscribed to, in two groups. Recommended events fire once per meaningful moment; the high-volume group fires per play or per choice and is off by default.

EventLabelFiresDefault
`lead.created`Lead capturedFires when a viewer submits the gate form.On
`lead.updated`Lead updatedFires when a lead is changed through the API.Off
`goal.reached`Goal reachedFires when a viewer hits a goal step in a route — the conversion signal, with lead context when known.On
`booking.created`Call bookedFires when a viewer books a call through the scheduler.On
`booking.canceled`Call canceledFires when a booked call is canceled — by the invitee or by you.On
`outcome.recorded`Outcome recordedFires when a purchase, booking, or custom conversion lands in the outcomes ledger — with value, lead, and the path that produced it.On
`video.ready`Video readyFires when a new or replaced video finishes processing and can play.On
`video.failed`Video failedFires when a video cannot be processed.On
`video.completed`Video completedFires when a viewer reaches 95% watch depth.On
`gate.completed`Gate passedFires when the gate form is submitted with valid data.Off
`choice_point.selected`Choice selectedFires every time a viewer picks a branch in a route.Off
`video.started`Video startedFires on every play. High volume on busy videos.Off

An endpoint subscribed to * receives every event, including ones added later.

The request

Each delivery is one POST with a JSON body of three keys: event, the ISO timestamp it fired, and data, the event payload documented below.

{
  "event": "lead.created",
  "timestamp": "2026-09-07T14:03:11.412Z",
  "data": {
    "…": "…"
  }
}
HeaderValue
Content-Typeapplication/json
User-AgentStreamAgent-Webhook/1.0
X-StreamAgent-EventThe event name, for routing before you parse the body.
X-StreamAgent-Idempotency-KeyStable across retries of the same delivery. Store it and ignore repeats.
X-StreamAgent-Signaturet=<unix-ms>,v1=<hex HMAC-SHA256 of "<unix-ms>.<raw body>">

Respond with any 2xx within 10 seconds. Do the work after you respond; a slow handler is retried as a timeout. Response bodies are kept for the delivery log, truncated to 4,096 bytes.

Verify the signature

Recompute the HMAC over <timestamp>.<raw body> with your endpoint secret and compare it to v1 in constant time. Use the raw request bytes, not a re-serialized object. Reject anything older than 5 minutes to shut out replays.

const crypto = require('crypto');

// signatureHeader is the X-StreamAgent-Signature header: "t=<ms>,v1=<hex>"
function verifyStreamAgentSignature(rawBody, signatureHeader, secret) {
  const parts = Object.fromEntries(signatureHeader.split(',').map((p) => p.split('=')));
  const ts = parseInt(parts.t, 10);
  const sig = parts.v1;
  if (!ts || !sig) return false;

  // Reject replays older than 5 minutes.
  if (Math.abs(Date.now() - ts) > 300000) return false;

  const expected = crypto.createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
  const a = Buffer.from(sig, 'hex');
  const b = Buffer.from(expected, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Node
import hmac
import hashlib
import time

# signature_header is the X-StreamAgent-Signature header: "t=<ms>,v1=<hex>"
def verify_streamagent_signature(raw_body: str, signature_header: str, secret: str) -> bool:
    parts = dict(p.split('=', 1) for p in signature_header.split(','))
    ts = int(parts.get('t', 0))
    sig = parts.get('v1', '')
    if not ts or not sig:
        return False

    # Reject replays older than 5 minutes.
    if abs(int(time.time() * 1000) - ts) > 300000:
        return False

    expected = hmac.new(secret.encode('utf-8'), f'{ts}.{raw_body}'.encode('utf-8'), hashlib.sha256).hexdigest()
    return hmac.compare_digest(sig, expected)
Python

Retries and idempotency

A delivery is retried when your endpoint times out, fails to connect, returns a 5xx, or returns 408 or 429. Any other 4xx is treated as your decision and is not retried.

AttemptAfter
230 seconds
35 minutes
430 minutes
52 hours

5 attempts in total, then the delivery is marked failed and stays in the history. Every attempt of one delivery carries the same X-StreamAgent-Idempotency-Key; a handler that has already processed it should return 2xx without acting again.

The test event

When you press Send test event. The body carries _test: true and a synthetic lead.

{
  "event": "webhook.test",
  "timestamp": "2026-09-07T14:03:11.412Z",
  "data": {
    "_test": true,
    "email": "test@streamagent.io",
    "name": "Test Lead",
    "first_name": "Test",
    "last_name": "Lead",
    "lead_score": 0,
    "source": "test_event",
    "campaign": null,
    "watch_depth_pct": 0,
    "video_watched": null
  }
}
A test delivery

`lead.created` · Lead captured

Once per new lead, the moment the gate form (or a booking, a comment with contact details, or an API create) creates the lead record. Updates to an existing lead fire lead.updated instead.

FieldTypeNotes
idstringLead id. Stable across every later event about this lead.
emailstringLowercased.
first_name / last_name / namestring | nullAs submitted or inferred.
phonestring | null
scoreintegerThe intent score at capture time.
watch_depth_pctintegerFurthest depth reached before submitting.
video_idstring | nullThe video the gate was on.
landing_page_idstring | nullSet when the lead came through a landing page.
source / campaign / utm_*string | nullAttribution as captured.
branch_pathstring | nullThe choices taken in a route before capture.
responsesobject | nullAnswers to in-video questions.
geo_city / geo_region / geo_postal_code / geo_countrystring | nullFrom the request, when present.
created_atISO 8601
{
  "event": "lead.created",
  "timestamp": "2026-09-07T14:03:11.412Z",
  "data": {
    "id": "9f1c2e6a-7d4b-4c1e-9a2f-0b3d5e7f9a1c",
    "email": "maria@example.com",
    "first_name": "Maria",
    "last_name": "Lopez",
    "name": "Maria Lopez",
    "phone": null,
    "score": 42,
    "watch_depth_pct": 75,
    "video_id": "4b8e1d3c-2a6f-4e9b-8c1d-7f2a9e4b6c3d",
    "landing_page_id": null,
    "source": "gate",
    "campaign": null,
    "utm_source": "meta",
    "utm_medium": "paid",
    "utm_campaign": "spring-offer",
    "branch_path": "pricing > annual",
    "responses": {
      "budget": "over 5k"
    },
    "geo_city": "Austin",
    "geo_region": "TX",
    "geo_postal_code": "78701",
    "geo_country": "US",
    "created_at": "2026-09-07T14:03:11.412Z"
  }
}
A lead.created delivery

`lead.updated` · Lead updated

Once per API write to an existing lead: an upsert that matched by email, or an update of status, tags, notes or contact details. Edits made by hand in the dashboard do not fire it.

FieldTypeNotes
idstringLead id.
emailstringLowercased.
first_name / last_name / namestring | null
phonestring | null
statusstring
scoreintegerUnchanged by API writes.
tagsstring[]
notesstring | null
custom_fieldsobject
sourcestring | null
updated_fieldsstring[]The columns this write changed.
viastringapi.
created_at / updated_at / occurred_atISO 8601
{
  "event": "lead.updated",
  "timestamp": "2026-09-07T14:03:11.412Z",
  "data": {
    "id": "9f1c2e6a-7d4b-4c1e-9a2f-0b3d5e7f9a1c",
    "email": "maria@example.com",
    "first_name": "Maria",
    "last_name": "Lopez",
    "name": "Maria Lopez",
    "phone": "+1 512 555 0134",
    "status": "qualified",
    "score": 42,
    "tags": [
      "webinar",
      "enterprise"
    ],
    "notes": "[2026-09-07] Booked a demo from the CRM.",
    "custom_fields": {
      "crm_id": "0031x000"
    },
    "source": "crm",
    "updated_fields": [
      "notes",
      "phone",
      "status"
    ],
    "via": "api",
    "created_at": "2026-09-01T09:12:00.000Z",
    "updated_at": "2026-09-07T14:03:11.412Z",
    "occurred_at": "2026-09-07T14:03:11.412Z"
  }
}
A lead.updated delivery

`goal.reached` · Goal reached

Once per viewer per goal step, when the route reaches a step marked as a goal.

FieldTypeNotes
goal_typestringlead, registration, contact, purchase or custom.
goal_labelstring | nullThe label set on the goal step.
value_centsinteger | nullThe value authored on the goal, when any.
route_id / route_slugstringThe route that produced it.
node_idstringThe goal step.
session_idstring | nullThe viewing session.
leadobject | null{ id, email, score, watch_depth_pct } when the viewer is a known lead.
{
  "event": "goal.reached",
  "timestamp": "2026-09-07T14:03:11.412Z",
  "data": {
    "goal_type": "purchase",
    "goal_label": "Annual plan",
    "value_cents": 49900,
    "route_id": "c2d4f6a8-1b3e-4d5f-a7c9-e1f3a5b7c9d1",
    "route_slug": "pricing-walkthrough",
    "node_id": "n_goal_1",
    "session_id": "s_7c1e",
    "lead": {
      "id": "9f1c2e6a-7d4b-4c1e-9a2f-0b3d5e7f9a1c",
      "email": "maria@example.com",
      "score": 42,
      "watch_depth_pct": 75
    }
  }
}
A goal.reached delivery

`booking.created` · Call booked

Once per confirmed booking, after the confirmation email is queued.

FieldTypeNotes
booking_idstring
event_namestring | nullThe event type booked.
start_atISO 8601 | nullStart time in UTC.
invitee_timezonestring | null
invitee_name / invitee_email / invitee_phonestring | null
lead_idstring | nullThe lead the booking resolved to.
join_urlstring | nullMeeting link when one exists.
statusstring | nullconfirmed.
created_atISO 8601 | null
{
  "event": "booking.created",
  "timestamp": "2026-09-07T14:03:11.412Z",
  "data": {
    "booking_id": "b_2f9d",
    "event_name": "Strategy call",
    "start_at": "2026-09-09T21:00:00.000Z",
    "invitee_timezone": "America/Los_Angeles",
    "invitee_name": "Maria Lopez",
    "invitee_email": "maria@example.com",
    "invitee_phone": null,
    "lead_id": "9f1c2e6a-7d4b-4c1e-9a2f-0b3d5e7f9a1c",
    "join_url": "https://meet.example.com/abc",
    "status": "confirmed",
    "created_at": "2026-09-07T14:03:11.412Z"
  }
}
A booking.created delivery

`booking.canceled` · Call canceled

Once per cancellation, by either side.

FieldTypeNotes
booking_idstring
start_atISO 8601 | null
invitee_name / invitee_email / invitee_phonestring | null
lead_idstring | null
statusstringcanceled.
canceled_bystring | nullhost or invitee.
{
  "event": "booking.canceled",
  "timestamp": "2026-09-07T14:03:11.412Z",
  "data": {
    "booking_id": "b_2f9d",
    "start_at": "2026-09-09T21:00:00.000Z",
    "invitee_name": "Maria Lopez",
    "invitee_email": "maria@example.com",
    "invitee_phone": null,
    "lead_id": "9f1c2e6a-7d4b-4c1e-9a2f-0b3d5e7f9a1c",
    "status": "canceled",
    "canceled_by": "invitee"
  }
}
A booking.canceled delivery

`outcome.recorded` · Outcome recorded

Once per recorded outcome: a purchase from a connected revenue source or a valued goal step.

FieldTypeNotes
outcome_idstring
kindstringpurchase, booking or custom.
sourcestringWhere it was recorded from.
statusstring
value_cents / currencyinteger | null, string | null
labelstring | null
occurred_atISO 8601
lead_id / video_id / route_idstring | nullThe lead and the path that produced it.
branch_pathstring | null
external_idstring | nullThe id in the source system.
{
  "event": "outcome.recorded",
  "timestamp": "2026-09-07T14:03:11.412Z",
  "data": {
    "outcome_id": "o_51ab",
    "kind": "purchase",
    "source": "stripe",
    "status": "confirmed",
    "value_cents": 49900,
    "currency": "usd",
    "label": "Annual plan",
    "occurred_at": "2026-09-07T14:03:11.412Z",
    "lead_id": "9f1c2e6a-7d4b-4c1e-9a2f-0b3d5e7f9a1c",
    "video_id": "4b8e1d3c-2a6f-4e9b-8c1d-7f2a9e4b6c3d",
    "route_id": "c2d4f6a8-1b3e-4d5f-a7c9-e1f3a5b7c9d1",
    "branch_path": "pricing > annual",
    "external_id": "pi_3Nz…"
  }
}
A outcome.recorded delivery

`video.ready` · Video ready

Once per video that finishes processing: an upload, a URL ingest, a Studio take, a duplicate, or a replacement. The natural follow-up to creating a video by API.

FieldTypeNotes
idstringVideo id, the same one create_video_* returned.
titlestring
statusstringready or error.
ingest_sourcestringupload, url, recorder or duplicate: how the video arrived.
source_urlstring | nullThe URL it was fetched from, for URL ingests.
duration_secondsnumber | nullSet when ready.
file_size_bytesinteger | null
resolution / aspect_ratiostring | nullSet when ready.
tagsstring[]
folder_idstring | null
errorobject | nullOn failure: code, title, detail and action, the same words the library shows.
created_at / occurred_atISO 8601When the video was created, and when this event fired.
{
  "event": "video.ready",
  "timestamp": "2026-09-07T14:03:11.412Z",
  "data": {
    "id": "4b8e1d3c-2a6f-4e9b-8c1d-7f2a9e4b6c3d",
    "title": "Spring offer walkthrough",
    "status": "ready",
    "ingest_source": "url",
    "source_url": "https://cdn.example.com/spring-offer.mp4",
    "duration_seconds": 184.2,
    "file_size_bytes": 58204113,
    "resolution": "1080p",
    "aspect_ratio": "16:9",
    "tags": [
      "spring",
      "offer"
    ],
    "folder_id": null,
    "error": null,
    "created_at": "2026-09-07T13:58:40.000Z",
    "occurred_at": "2026-09-07T14:03:11.412Z"
  }
}
A video.ready delivery

`video.failed` · Video failed

Once per failed ingest: the file could not be fetched, was not a video, or the upload never completed. error says why and what to do.

FieldTypeNotes
idstringVideo id, the same one create_video_* returned.
titlestring
statusstringready or error.
ingest_sourcestringupload, url, recorder or duplicate: how the video arrived.
source_urlstring | nullThe URL it was fetched from, for URL ingests.
duration_secondsnumber | nullSet when ready.
file_size_bytesinteger | null
resolution / aspect_ratiostring | nullSet when ready.
tagsstring[]
folder_idstring | null
errorobject | nullOn failure: code, title, detail and action, the same words the library shows.
created_at / occurred_atISO 8601When the video was created, and when this event fired.
{
  "event": "video.failed",
  "timestamp": "2026-09-07T14:03:11.412Z",
  "data": {
    "id": "4b8e1d3c-2a6f-4e9b-8c1d-7f2a9e4b6c3d",
    "title": "Spring offer walkthrough",
    "status": "error",
    "ingest_source": "url",
    "source_url": "https://cdn.example.com/spring-offer.mp4",
    "duration_seconds": null,
    "file_size_bytes": 58204113,
    "resolution": null,
    "aspect_ratio": null,
    "tags": [
      "spring",
      "offer"
    ],
    "folder_id": null,
    "error": {
      "code": "invalid_url",
      "title": "The link did not point at a video",
      "detail": "The address returned a web page rather than a media file.",
      "action": "Use a direct download link to the file and try again."
    },
    "created_at": "2026-09-07T13:58:40.000Z",
    "occurred_at": "2026-09-07T14:03:11.412Z"
  }
}
A video.failed delivery

`video.completed` · Video completed

Once per session when playback passes 95%.

FieldTypeNotes
idstring | nullThe recorded event id.
video_idstring
workspace_idstring
event_typestringThe player event name.
internal_namestringThe signal name the event maps from.
session_idstring | null
visitor_fingerprintstring | nullCross-session viewer identity, when known.
timestampISO 8601
watch_depth_pctinteger | null
branch_labelstring | nullFor choice events, the branch picked.
fbp / fbc / gclid / ttclid / ttp / ga_client_idstring | nullClick ids and cookies when the player received them.
{
  "event": "video.completed",
  "timestamp": "2026-09-07T14:03:11.412Z",
  "data": {
    "id": "e_8a2c",
    "video_id": "4b8e1d3c-2a6f-4e9b-8c1d-7f2a9e4b6c3d",
    "workspace_id": "w_1",
    "event_type": "watch_95",
    "internal_name": "VideoWatch95",
    "session_id": "s_7c1e",
    "visitor_fingerprint": "fp_3e9a",
    "timestamp": "2026-09-07T14:03:11.412Z",
    "watch_depth_pct": 95,
    "branch_label": null,
    "fbp": null,
    "fbc": null,
    "gclid": "Cj0KCQ…",
    "ttclid": null,
    "ttp": null,
    "ga_client_id": null
  }
}
A video.completed delivery

`gate.completed` · Gate passed

Once per session when a gate is passed. Pairs with a lead.created when the submission created a new lead.

FieldTypeNotes
idstring | nullThe recorded event id.
video_idstring
workspace_idstring
event_typestringThe player event name.
internal_namestringThe signal name the event maps from.
session_idstring | null
visitor_fingerprintstring | nullCross-session viewer identity, when known.
timestampISO 8601
watch_depth_pctinteger | null
branch_labelstring | nullFor choice events, the branch picked.
fbp / fbc / gclid / ttclid / ttp / ga_client_idstring | nullClick ids and cookies when the player received them.
{
  "event": "gate.completed",
  "timestamp": "2026-09-07T14:03:11.412Z",
  "data": {
    "id": "e_8a2c",
    "video_id": "4b8e1d3c-2a6f-4e9b-8c1d-7f2a9e4b6c3d",
    "workspace_id": "w_1",
    "event_type": "gate_passed",
    "internal_name": "GatePassed",
    "session_id": "s_7c1e",
    "visitor_fingerprint": "fp_3e9a",
    "timestamp": "2026-09-07T14:03:11.412Z",
    "watch_depth_pct": 50,
    "branch_label": null,
    "fbp": null,
    "fbc": null,
    "gclid": "Cj0KCQ…",
    "ttclid": null,
    "ttp": null,
    "ga_client_id": null
  }
}
A gate.completed delivery

`choice_point.selected` · Choice selected

Every branch pick, so several per session in a branching route.

FieldTypeNotes
idstring | nullThe recorded event id.
video_idstring
workspace_idstring
event_typestringThe player event name.
internal_namestringThe signal name the event maps from.
session_idstring | null
visitor_fingerprintstring | nullCross-session viewer identity, when known.
timestampISO 8601
watch_depth_pctinteger | null
branch_labelstring | nullFor choice events, the branch picked.
fbp / fbc / gclid / ttclid / ttp / ga_client_idstring | nullClick ids and cookies when the player received them.
{
  "event": "choice_point.selected",
  "timestamp": "2026-09-07T14:03:11.412Z",
  "data": {
    "id": "e_8a2c",
    "video_id": "4b8e1d3c-2a6f-4e9b-8c1d-7f2a9e4b6c3d",
    "workspace_id": "w_1",
    "event_type": "branch_selected",
    "internal_name": "BranchSelected",
    "session_id": "s_7c1e",
    "visitor_fingerprint": "fp_3e9a",
    "timestamp": "2026-09-07T14:03:11.412Z",
    "watch_depth_pct": null,
    "branch_label": "Annual",
    "fbp": null,
    "fbc": null,
    "gclid": "Cj0KCQ…",
    "ttclid": null,
    "ttp": null,
    "ga_client_id": null
  }
}
A choice_point.selected delivery

`video.started` · Video started

Every play, including replays.

FieldTypeNotes
idstring | nullThe recorded event id.
video_idstring
workspace_idstring
event_typestringThe player event name.
internal_namestringThe signal name the event maps from.
session_idstring | null
visitor_fingerprintstring | nullCross-session viewer identity, when known.
timestampISO 8601
watch_depth_pctinteger | null
branch_labelstring | nullFor choice events, the branch picked.
fbp / fbc / gclid / ttclid / ttp / ga_client_idstring | nullClick ids and cookies when the player received them.
{
  "event": "video.started",
  "timestamp": "2026-09-07T14:03:11.412Z",
  "data": {
    "id": "e_8a2c",
    "video_id": "4b8e1d3c-2a6f-4e9b-8c1d-7f2a9e4b6c3d",
    "workspace_id": "w_1",
    "event_type": "play",
    "internal_name": "VideoStart",
    "session_id": "s_7c1e",
    "visitor_fingerprint": "fp_3e9a",
    "timestamp": "2026-09-07T14:03:11.412Z",
    "watch_depth_pct": 0,
    "branch_label": null,
    "fbp": null,
    "fbc": null,
    "gclid": "Cj0KCQ…",
    "ttclid": null,
    "ttp": null,
    "ga_client_id": null
  }
}
A video.started delivery