# 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](/docs/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.

| Event | Label | Fires | Default |
| --- | --- | --- | --- |
| [`lead.created`](#lead-created) | Lead captured | Fires when a viewer submits the gate form. | On |
| [`lead.updated`](#lead-updated) | Lead updated | Fires when a lead is changed through the API. | Off |
| [`goal.reached`](#goal-reached) | Goal reached | Fires when a viewer hits a goal step in a route — the conversion signal, with lead context when known. | On |
| [`booking.created`](#booking-created) | Call booked | Fires when a viewer books a call through the scheduler. | On |
| [`booking.canceled`](#booking-canceled) | Call canceled | Fires when a booked call is canceled — by the invitee or by you. | On |
| [`outcome.recorded`](#outcome-recorded) | Outcome recorded | Fires 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-ready) | Video ready | Fires when a new or replaced video finishes processing and can play. | On |
| [`video.failed`](#video-failed) | Video failed | Fires when a video cannot be processed. | On |
| [`video.completed`](#video-completed) | Video completed | Fires when a viewer reaches 95% watch depth. | On |
| [`gate.completed`](#gate-completed) | Gate passed | Fires when the gate form is submitted with valid data. | Off |
| [`choice_point.selected`](#choice-point-selected) | Choice selected | Fires every time a viewer picks a branch in a route. | Off |
| [`video.started`](#video-started) | Video started | Fires 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.

```json
{
  "event": "lead.created",
  "timestamp": "2026-09-07T14:03:11.412Z",
  "data": {
    "…": "…"
  }
}
```

| Header | Value |
| --- | --- |
| `Content-Type` | `application/json` |
| `User-Agent` | `StreamAgent-Webhook/1.0` |
| `X-StreamAgent-Event` | The event name, for routing before you parse the body. |
| `X-StreamAgent-Idempotency-Key` | Stable across retries of the same delivery. Store it and ignore repeats. |
| `X-StreamAgent-Signature` | `t=<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.

```js
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_

```python
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_

> These two samples are run in our test suite against a delivery signed by the production sender, so they cannot drift from what you receive.

## 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.

| Attempt | After |
| --- | --- |
| 2 | 30 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 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.

```json
{
  "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.

| Field | Type | Notes |
| --- | --- | --- |
| id | string | Lead id. Stable across every later event about this lead. |
| email | string | Lowercased. |
| first_name / last_name / name | string \| null | As submitted or inferred. |
| phone | string \| null |  |
| score | integer | The intent score at capture time. |
| watch_depth_pct | integer | Furthest depth reached before submitting. |
| video_id | string \| null | The video the gate was on. |
| landing_page_id | string \| null | Set when the lead came through a landing page. |
| source / campaign / utm_* | string \| null | Attribution as captured. |
| branch_path | string \| null | The choices taken in a route before capture. |
| responses | object \| null | Answers to in-video questions. |
| geo_city / geo_region / geo_postal_code / geo_country | string \| null | From the request, when present. |
| created_at | ISO 8601 |  |

> The lead record as captured, plus edge geo when the request carried it. Fields not listed here can appear and may change; build on the ones below.

```json
{
  "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.

| Field | Type | Notes |
| --- | --- | --- |
| id | string | Lead id. |
| email | string | Lowercased. |
| first_name / last_name / name | string \| null |  |
| phone | string \| null |  |
| status | string |  |
| score | integer | Unchanged by API writes. |
| tags | string[] |  |
| notes | string \| null |  |
| custom_fields | object |  |
| source | string \| null |  |
| updated_fields | string[] | The columns this write changed. |
| via | string | `api`. |
| created_at / updated_at / occurred_at | ISO 8601 |  |

> The lead record after the change, plus which fields changed. Fields not listed here can appear and may change; build on the ones below.

```json
{
  "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.

| Field | Type | Notes |
| --- | --- | --- |
| goal_type | string | lead, registration, contact, purchase or custom. |
| goal_label | string \| null | The label set on the goal step. |
| value_cents | integer \| null | The value authored on the goal, when any. |
| route_id / route_slug | string | The route that produced it. |
| node_id | string | The goal step. |
| session_id | string \| null | The viewing session. |
| lead | object \| null | { id, email, score, watch_depth_pct } when the viewer is a known lead. |

```json
{
  "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.

| Field | Type | Notes |
| --- | --- | --- |
| booking_id | string |  |
| event_name | string \| null | The event type booked. |
| start_at | ISO 8601 \| null | Start time in UTC. |
| invitee_timezone | string \| null |  |
| invitee_name / invitee_email / invitee_phone | string \| null |  |
| lead_id | string \| null | The lead the booking resolved to. |
| join_url | string \| null | Meeting link when one exists. |
| status | string \| null | confirmed. |
| created_at | ISO 8601 \| null |  |

```json
{
  "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.

| Field | Type | Notes |
| --- | --- | --- |
| booking_id | string |  |
| start_at | ISO 8601 \| null |  |
| invitee_name / invitee_email / invitee_phone | string \| null |  |
| lead_id | string \| null |  |
| status | string | canceled. |
| canceled_by | string \| null | host or invitee. |

```json
{
  "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.

| Field | Type | Notes |
| --- | --- | --- |
| outcome_id | string |  |
| kind | string | purchase, booking or custom. |
| source | string | Where it was recorded from. |
| status | string |  |
| value_cents / currency | integer \| null, string \| null |  |
| label | string \| null |  |
| occurred_at | ISO 8601 |  |
| lead_id / video_id / route_id | string \| null | The lead and the path that produced it. |
| branch_path | string \| null |  |
| external_id | string \| null | The id in the source system. |

```json
{
  "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.

| Field | Type | Notes |
| --- | --- | --- |
| id | string | Video id, the same one create_video_* returned. |
| title | string |  |
| status | string | `ready` or `error`. |
| ingest_source | string | `upload`, `url`, `recorder` or `duplicate`: how the video arrived. |
| source_url | string \| null | The URL it was fetched from, for URL ingests. |
| duration_seconds | number \| null | Set when ready. |
| file_size_bytes | integer \| null |  |
| resolution / aspect_ratio | string \| null | Set when ready. |
| tags | string[] |  |
| folder_id | string \| null |  |
| error | object \| null | On failure: `code`, `title`, `detail` and `action`, the same words the library shows. |
| created_at / occurred_at | ISO 8601 | When the video was created, and when this event fired. |

```json
{
  "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.

| Field | Type | Notes |
| --- | --- | --- |
| id | string | Video id, the same one create_video_* returned. |
| title | string |  |
| status | string | `ready` or `error`. |
| ingest_source | string | `upload`, `url`, `recorder` or `duplicate`: how the video arrived. |
| source_url | string \| null | The URL it was fetched from, for URL ingests. |
| duration_seconds | number \| null | Set when ready. |
| file_size_bytes | integer \| null |  |
| resolution / aspect_ratio | string \| null | Set when ready. |
| tags | string[] |  |
| folder_id | string \| null |  |
| error | object \| null | On failure: `code`, `title`, `detail` and `action`, the same words the library shows. |
| created_at / occurred_at | ISO 8601 | When the video was created, and when this event fired. |

```json
{
  "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%.

| Field | Type | Notes |
| --- | --- | --- |
| id | string \| null | The recorded event id. |
| video_id | string |  |
| workspace_id | string |  |
| event_type | string | The player event name. |
| internal_name | string | The signal name the event maps from. |
| session_id | string \| null |  |
| visitor_fingerprint | string \| null | Cross-session viewer identity, when known. |
| timestamp | ISO 8601 |  |
| watch_depth_pct | integer \| null |  |
| branch_label | string \| null | For choice events, the branch picked. |
| fbp / fbc / gclid / ttclid / ttp / ga_client_id | string \| null | Click ids and cookies when the player received them. |

```json
{
  "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.

| Field | Type | Notes |
| --- | --- | --- |
| id | string \| null | The recorded event id. |
| video_id | string |  |
| workspace_id | string |  |
| event_type | string | The player event name. |
| internal_name | string | The signal name the event maps from. |
| session_id | string \| null |  |
| visitor_fingerprint | string \| null | Cross-session viewer identity, when known. |
| timestamp | ISO 8601 |  |
| watch_depth_pct | integer \| null |  |
| branch_label | string \| null | For choice events, the branch picked. |
| fbp / fbc / gclid / ttclid / ttp / ga_client_id | string \| null | Click ids and cookies when the player received them. |

```json
{
  "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.

| Field | Type | Notes |
| --- | --- | --- |
| id | string \| null | The recorded event id. |
| video_id | string |  |
| workspace_id | string |  |
| event_type | string | The player event name. |
| internal_name | string | The signal name the event maps from. |
| session_id | string \| null |  |
| visitor_fingerprint | string \| null | Cross-session viewer identity, when known. |
| timestamp | ISO 8601 |  |
| watch_depth_pct | integer \| null |  |
| branch_label | string \| null | For choice events, the branch picked. |
| fbp / fbc / gclid / ttclid / ttp / ga_client_id | string \| null | Click ids and cookies when the player received them. |

```json
{
  "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.

| Field | Type | Notes |
| --- | --- | --- |
| id | string \| null | The recorded event id. |
| video_id | string |  |
| workspace_id | string |  |
| event_type | string | The player event name. |
| internal_name | string | The signal name the event maps from. |
| session_id | string \| null |  |
| visitor_fingerprint | string \| null | Cross-session viewer identity, when known. |
| timestamp | ISO 8601 |  |
| watch_depth_pct | integer \| null |  |
| branch_label | string \| null | For choice events, the branch picked. |
| fbp / fbc / gclid / ttclid / ttp / ga_client_id | string \| null | Click ids and cookies when the player received them. |

```json
{
  "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_

---
Source: https://app.streamagent.io/docs/webhooks
