# Webhooks reference

Rexa.ai POSTs JSON to your registered HTTPS endpoints whenever something
happens that you've subscribed to — a campaign starts, a contact
finishes, a recording finalizes. Every delivery is HMAC-signed so you
can trust it came from us; the headers below let you verify in three
lines of code.

---

## The envelope

Every event lands as the same JSON shape, regardless of `type`:

```json
{
  "id": "01J0Z0V7C5K8ZZB0EAFB6VBNYB",
  "type": "session.ended",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:09:42.103Z",
  "data": { "...": "event-specific shape, see catalog below" }
}
```

| Field        | Meaning                                                                                 |
| ------------ | --------------------------------------------------------------------------------------- |
| `id`         | Unique event id (UUIDv7). Use for idempotency on your side — replays carry the same id. |
| `type`       | Event type. See the catalog.                                                            |
| `tenant_id`  | Your tenant id. Useful when one receiver fans out to multiple Rexa.ai tenants.          |
| `created_at` | ISO-8601 UTC, when the platform emitted the event.                                      |
| `data`       | Event-specific payload — keys vary by `type`.                                           |

## Headers we send

| Header                | Example            | Purpose                                                          |
| --------------------- | ------------------ | ---------------------------------------------------------------- |
| `Content-Type`        | `application/json` | Always JSON.                                                     |
| `X-Webhook-Signature` | `sha256=a3f1...`   | HMAC-SHA256 hex of the canonical signing base.                   |
| `X-Webhook-Timestamp` | `1714817382`       | Unix seconds. Reject if it's more than 5 minutes off your clock. |
| `X-Webhook-Id`        | `01J0Z0V7...`      | Same as `body.id`. Read whichever is convenient.                 |
| `X-Webhook-Event`     | `session.ended`    | Same as `body.type`. Lets you route before parsing the body.     |

Default delivery timeout: **15 s**. We retry with exponential backoff
on 5xx + network errors (max 24 h, ~10 attempts) and stop on 2xx or
permanent 4xx. After ~10 consecutive permanent failures over 24 h,
we'll auto-suspend the endpoint and email you.

---

## Verifying the signature

The signing base is `<unix-seconds>.<raw-body>` and the secret is the
per-endpoint `whsec_…` returned **once** when you create the endpoint
(or rotate it via `POST /v1/webhook_endpoints/{id}/rotate_secret`).
Verify the **raw bytes** of the request body — do not JSON-parse and
re-stringify, since whitespace would change the signature.

### Node.js

```javascript
import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.REXA_WEBHOOK_SECRET; // whsec_...

export function verifyRexa.ai(rawBody, headers) {
  const sig = headers['x-webhook-signature']; // "sha256=<hex>"
  const ts = headers['x-webhook-timestamp']; // unix seconds
  if (!sig || !ts) return false;

  // Reject anything older than 5 min — replay defence.
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;

  const expected = `sha256=${createHmac('sha256', SECRET)
    .update(`${ts}.${rawBody}`)
    .digest('hex')}`;
  const a = Buffer.from(sig);
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

### Python

```python
import hmac, hashlib, time

SECRET = os.environ["REXA_WEBHOOK_SECRET"]  # whsec_...

def verify_rexa(raw_body: bytes, headers) -> bool:
    sig = headers.get("x-webhook-signature", "")  # "sha256=<hex>"
    ts  = headers.get("x-webhook-timestamp", "")
    if not sig or not ts:
        return False
    if abs(time.time() - int(ts)) > 300:           # 5 min drift
        return False
    base = f"{ts}.".encode() + raw_body
    expected = "sha256=" + hmac.new(SECRET.encode(), base, hashlib.sha256).hexdigest()
    return hmac.compare_digest(sig, expected)
```

Common pitfalls: parsing the body with a framework that re-serialises
before you see it (Express's `body-parser` is fine if you keep
`req.rawBody`), forgetting the `sha256=` prefix in `expected`, or
using a non-constant-time string compare (susceptible to timing
attacks).

---

## Event catalog

Subscribe to any subset when you create or update an endpoint. You'll
only receive events whose type is in your endpoint's `events` array.

### Sessions (one outbound call)

| `type`                                    | When                                                                                                  | Key `data` fields                                                                                                                                                                                                          |
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `session.created`                         | The call is queued for dispatch.                                                                      | `session_id`, `to_number`, `from_number`, `campaign_id?`                                                                                                                                                                   |
| `session.started`                         | The agent picked up and started the conversation.                                                     | `session_id`, `started_at`                                                                                                                                                                                                 |
| `session.ended`                           | Call finished cleanly (any reason).                                                                   | `session_id`, `ended_at`, `duration_seconds`, `end_reason`, `actual_cost_cents`, `disposition_code?`, `disposition_note?`                                                                                                  |
| `session.failed`                          | Call failed before or during connection.                                                              | `session_id`, `error_code`, `error_message`                                                                                                                                                                                |
| `session.disposition_set`                 | Async LLM-derived disposition for an agent-launched campaign call. Fires AFTER `session.ended`.       | `session_id`, `campaign_id`, `contact_id`, `disposition_code`, `disposition_note`, `evaluated_at`, `evaluator`, `model?`                                                                                                   |
| `session.recording_completed`             | Recording finalized + uploaded. Single `recording_url` string mirrors `voice_sessions.recording_url`. | `session_id`, `campaign_id?`, `contact_id?`, `from_number`, `to_number`, `phone_number`, `email?`, `first_name?`, `last_name?`, `recording_id`, `status`, `started_at?`, `ended_at?`, `duration_seconds?`, `recording_url` |
| `session.opt_out_detected`                | Caller said "stop calling me". DNC list updated.                                                      | `session_id`, `phone_number`, `detected_at`                                                                                                                                                                                |
| `session.opt_out_recording_pending_purge` | Recording flagged for purge after opt-out.                                                            | `session_id`, `recording_id`, `purge_after`                                                                                                                                                                                |
| `session.opt_out_recording_purged`        | Recording purged.                                                                                     | `session_id`, `recording_id`, `purged_at`                                                                                                                                                                                  |
| `session.consent_refused`                 | Caller refused recording consent. Call ended.                                                         | `session_id`, `refused_at`                                                                                                                                                                                                 |

### Campaigns (bulk outreach lifecycle)

| `type`                                                   | When                                                                                                                                                       |
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `campaign.created`                                       | Campaign row created (still draft until started).                                                                                                          |
| `campaign.started`                                       | Campaign moved to `running`.                                                                                                                               |
| `campaign.paused` / `campaign.paused_quiet_hours`        | Manual pause / auto-pause for quiet hours.                                                                                                                 |
| `campaign.completed`                                     | Every contact reached a terminal state.                                                                                                                    |
| `campaign.contact_dispatched`                            | One contact handed to the dispatcher. `data: { contact_id, campaign_id, phone_number, … }`                                                                 |
| `campaign.contact_completed` / `campaign.contact_failed` | Per-contact terminal outcome. Carries cost + duration.                                                                                                     |
| `campaign.contact_skipped`                               | Contact skipped before dispatch. `data.skip_reason ∈ { invalid, dnc_blocked, daily_cap_reached, quiet_hours_expired, concurrency_full, tenant_suspended }` |

### Credits / billing

| `type`                    | When                                          |
| ------------------------- | --------------------------------------------- |
| `credits.low_balance`     | Balance dropped below your warning threshold. |
| `credits.depleted`        | Balance hit zero — dispatch paused.           |
| `credits.topup_succeeded` | Stripe top-up settled.                        |

### Voice cloning, rooms, operational

| `type`                                                               | When                                                             |
| -------------------------------------------------------------------- | ---------------------------------------------------------------- |
| `voice_clone.training_{started, succeeded, failed}`                  | Lifecycle of a voice-clone training job.                         |
| `room.{created, landing_viewed, joined, completed, expired, failed}` | WebRTC room lifecycle.                                           |
| `number.answer_rate_degraded`                                        | An inbound number's answer rate dropped below 80% in 24 h.       |
| `function.timeout`                                                   | An agent function call exceeded its timeout.                     |
| `webhook.test`                                                       | Synthetic event fired by `POST /v1/webhook_endpoints/{id}/test`. |
| `webhook.endpoint_disabled_notice`                                   | Your endpoint was auto-suspended after sustained failures.       |

---

## Sample payloads — every event

The session, campaign, and contact ids across the session + campaign
families below belong to one fictional call so you can trace a single
lifecycle through the catalog. Every payload is the **full** body of
the request; nothing is truncated.

### `session.created`

Fired the moment a voice session row is inserted — call is queued for dispatch.

```json
{
  "id": "01J0Z0RA3D7K3M1B2X9HTPYW00",
  "type": "session.created",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:08:51.402Z",
  "data": {
    "session_id": "019df26d-6435-7e4e-9cb9-fffc3d8661e2",
    "campaign_id": "019df26d-1a2b-7000-9cb9-cccccccccccc",
    "contact_id": "019df26d-1a2b-7001-9cb9-dddddddddddd",
    "to_number": "+15513487977",
    "from_number": "+12013796233",
    "voice_id": "leah",
    "status": "queued"
  }
}
```

### `session.started`

The agent picked up and started speaking. Recording begins around this time if enabled.

```json
{
  "id": "01J0Z0RC8H1Y9F0R3Q2VDPYW01",
  "type": "session.started",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:09:06.118Z",
  "data": {
    "session_id": "019df26d-6435-7e4e-9cb9-fffc3d8661e2",
    "started_at": "2026-05-04T11:09:06Z"
  }
}
```

### `session.ended`

Call finished with status `completed`. Carries the **full transcript inline** (agent's `{role, content, t?}` shape), function-call summary, `from_number` / `to_number` as the natural CRM join key, the metered cost, and — when the campaign was launched with an Agent (Phase 1, 2026-05) — the agent's chosen `disposition_code` + free-form `disposition_note`. `phone_number` mirrors `to_number` so a single column maps cleanly across `session.*` and `campaign.contact_*` events.

```json
{
  "id": "01J0Z0RD4K2Z8N0Q4M3HTPYW02",
  "type": "session.ended",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:11:29.918Z",
  "data": {
    "session_id": "019df26d-6435-7e4e-9cb9-fffc3d8661e2",
    "campaign_id": "019df26d-1a2b-7000-9cb9-cccccccccccc",
    "contact_id": "019df26d-1a2b-7001-9cb9-dddddddddddd",
    "from_number": "+12013796233",
    "to_number": "+15513487977",
    "phone_number": "+15513487977",
    "email": null,
    "first_name": "Smoke",
    "last_name": null,
    "status": "completed",
    "end_reason": "completed",
    "started_at": "2026-05-04T11:09:06Z",
    "ended_at": "2026-05-04T11:11:28Z",
    "duration_seconds": 142,
    "actual_cost_cents": 18,
    "transcript": [
      { "role": "agent", "content": "Hello, this is Rexa on behalf of JPMorgan…", "t": 0 },
      { "role": "user", "content": "Hi, yes I have a moment.", "t": 4200 },
      { "role": "agent", "content": "Great. The role is a Senior Python AI Engineer…", "t": 7100 },
      { "role": "user", "content": "Sounds interesting, what's the salary?", "t": 28400 },
      { "role": "agent", "content": "Around $100,000 annually. Want to schedule…", "t": 31000 }
    ],
    "transcript_url": "https://api.rexa.ai/v1/sessions/019df26d-…/transcript.json",
    "function_invocations": [
      { "name": "lookup_candidate_profile", "status": "succeeded", "duration_ms": 412 }
    ],
    "disposition_code": "booked_appointment",
    "disposition_note": "Caller asked us to call back after the 15th."
  }
}
```

#### Disposition fields (Agents Phase 1)

`disposition_code` and `disposition_note` are **optional** — they only
appear when the campaign was launched with an Agent attached. Either
field may be `null` (or absent) for campaigns predating the Agents
feature.

| Field              | Type             | Meaning                                                                                                                                                                                                                                                                                      |
| ------------------ | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `disposition_code` | `string \| null` | One of the codes the Agent declared on the campaign (e.g. `booked_appointment`, `not_interested`), or the reserved literal `"other"` when the agent picked an outcome that didn't match any declared code. Always lowercase `snake_case`, max 40 chars. `null` for legacy / non-Agent calls. |
| `disposition_note` | `string \| null` | Free-form one-liner from the agent capturing what the enum can't (e.g. "said to call back after the 15th"). Max 2000 chars. `null` when the agent didn't provide a note.                                                                                                                     |

The `disposition_code` value is validated against the **agent snapshot
frozen on the campaign at launch time** — not the live agent. Editing
the source agent's dispositions does not change the values that arrive
on this webhook for already-launched campaigns. See the [Agents
guide](https://docs.rexa.ai.com/agents) for the full lifecycle.

#### Forward compatibility

Treat all webhook payloads as open / non-strict — **receivers MUST
accept and ignore unknown fields**. Adding optional fields like
`disposition_code` and `disposition_note` is **additive** under the
platform's deprecation policy and never bumps the webhook version.
Removals or shape-breaking changes go through the documented 12-month
deprecation window with explicit version headers.

If you parse with a strict schema validator (e.g. Zod's `.strict()`,
Pydantic's `extra='forbid'`), switch to `.passthrough()` /
`extra='ignore'` for webhook payloads — otherwise a future additive
field will start rejecting otherwise-valid events.

### `session.failed`

Call ended with a non-`completed` status (`failed`, `no_answer`, `voicemail`, `busy`). Same payload shape as `session.ended` — your handler can branch on `data.status`.

```json
{
  "id": "01J0Z0RH7P3T5V0K4N5HTPYW03",
  "type": "session.failed",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:09:42.013Z",
  "data": {
    "session_id": "019df26d-6435-7e4e-9cb9-fffc3d8661e2",
    "campaign_id": "019df26d-1a2b-7000-9cb9-cccccccccccc",
    "contact_id": "019df26d-1a2b-7001-9cb9-dddddddddddd",
    "from_number": "+12013796233",
    "to_number": "+15513487977",
    "phone_number": "+15513487977",
    "email": null,
    "first_name": "Smoke",
    "last_name": null,
    "status": "no_answer",
    "end_reason": "no_answer",
    "started_at": "2026-05-04T11:09:06Z",
    "ended_at": "2026-05-04T11:09:42Z",
    "duration_seconds": 0,
    "actual_cost_cents": 0,
    "transcript": [],
    "transcript_url": null,
    "function_invocations": []
  }
}
```

### `session.disposition_set`

Asynchronous LLM-derived disposition for a call launched by an agent-attached campaign (Agents Phase 2.5, 2026-05). Fires _after_ `session.ended` once a worker job classifies the call against the agent's frozen `dispositions[]` list.

Skipped when:

- The campaign has no agent (`campaign_id` exists, but the campaign was created without picking an agent), or
- The agent's frozen snapshot has no dispositions configured, or
- The call agent already supplied a disposition inline on `session.ended` (the inline value wins; this event would be a duplicate), or
- The platform has the LLM-eval feature disabled.

`evaluator` is `"llm"` for now; future paths (manual review, agent-reported async) may add new values, so guard your handler against unknown enum members.

```json
{
  "id": "01J0Z14B1Q3R5T0V4M3HTPYW04",
  "type": "session.disposition_set",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-08T22:00:14.918Z",
  "data": {
    "session_id": "019df26d-6435-7e4e-9cb9-fffc3d8661e2",
    "campaign_id": "019df26d-1a2b-7000-9cb9-cccccccccccc",
    "contact_id": "019df26d-1a2b-7001-9cb9-dddddddddddd",
    "disposition_code": "callback",
    "disposition_note": "Caller asked to be called back next Tuesday after vacation.",
    "evaluated_at": "2026-05-08T22:00:14.821Z",
    "evaluator": "llm",
    "model": "openai/gpt-4o-mini"
  }
}
```

### `session.recording_completed`

```json
{
  "id": "01J0Z14P3W6F1K0AB7QXE7TMVQ",
  "type": "session.recording_completed",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:11:43.508Z",
  "data": {
    "session_id": "019df26d-6435-7e4e-9cb9-fffc3d8661e2",
    "campaign_id": "019df26d-1a2b-7000-9cb9-cccccccccccc",
    "contact_id": "019df26d-1a2b-7001-9cb9-dddddddddddd",
    "from_number": "+12013796233",
    "to_number": "+15513487977",
    "phone_number": "+15513487977",
    "email": null,
    "first_name": "Smoke",
    "last_name": null,
    "recording_id": "rec_abc123",
    "status": "completed",
    "channels": "dual",
    "started_at": "2026-05-04T11:09:06Z",
    "ended_at": "2026-05-04T11:11:42Z",
    "duration_seconds": 156,
    "recording_url": "https://recordings.rexa.ai/rec_abc123.mp3"
  }
}
```

### `session.opt_out_detected`

Caller said "stop calling me" (or a recognized variant). The phone number is automatically appended to your tenant DNC list — future campaigns will skip it with `skip_reason=dnc_blocked`.

```json
{
  "id": "01J0Z0RJ9R4U6X0M5P6HTPYW04",
  "type": "session.opt_out_detected",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:10:14.522Z",
  "data": {
    "session_id": "019df26d-6435-7e4e-9cb9-fffc3d8661e2",
    "phone_number": "+15513487977",
    "detected_at": "2026-05-04T11:10:14Z"
  }
}
```

### `session.opt_out_recording_pending_purge`

Recording flagged for purge after an opt-out. The platform will purge it after the configured retention grace window (default 7 days).

```json
{
  "id": "01J0Z0RK1S5V7Y0N6Q7HTPYW05",
  "type": "session.opt_out_recording_pending_purge",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:10:18.711Z",
  "data": {
    "session_id": "019df26d-6435-7e4e-9cb9-fffc3d8661e2",
    "recording_id": "rec_abc123",
    "purge_after": "2026-05-11T11:10:18Z"
  }
}
```

### `session.opt_out_recording_purged`

Recording was actually purged — `recording_url` returns 404 from this point.

```json
{
  "id": "01J0Z0RM2T6W8Z0O7R8HTPYW06",
  "type": "session.opt_out_recording_purged",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-11T11:10:21.001Z",
  "data": {
    "session_id": "019df26d-6435-7e4e-9cb9-fffc3d8661e2",
    "recording_id": "rec_abc123",
    "purged_at": "2026-05-11T11:10:21Z"
  }
}
```

### `session.consent_refused`

Caller refused recording consent. Call ended without a recording row.

```json
{
  "id": "01J0Z0RN3U7X9A0P8S9HTPYW07",
  "type": "session.consent_refused",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:09:11.044Z",
  "data": {
    "session_id": "019df26d-6435-7e4e-9cb9-fffc3d8661e2",
    "refused_at": "2026-05-04T11:09:10Z"
  }
}
```

### `campaign.created`

Campaign row inserted. Status is `draft` until you `POST /v1/campaigns/{id}/start` (or pass contacts inline via `/v1/campaigns/run`, which both creates AND starts).

```json
{
  "id": "01J0Z0SC1A0J1A0E0F0HTPYW10",
  "type": "campaign.created",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:08:00.001Z",
  "data": {
    "campaign_id": "019df26d-1a2b-7000-9cb9-cccccccccccc",
    "name": "May outreach — APAC",
    "channel": "phone_call",
    "session_type": "phone_call"
  }
}
```

### `campaign.started`

Campaign moved from `draft` to `running`. Pacer begins ticking.

```json
{
  "id": "01J0Z0SD2B1K2B0F0G0HTPYW11",
  "type": "campaign.started",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:08:02.118Z",
  "data": {
    "campaign_id": "019df26d-1a2b-7000-9cb9-cccccccccccc",
    "started_at": "2026-05-04T11:08:02Z"
  }
}
```

### `campaign.paused`

Manual pause via the dashboard or API. Pacer stops dispatching new contacts; in-flight calls run to completion.

```json
{
  "id": "01J0Z0SE3C2L3C0G0H0HTPYW12",
  "type": "campaign.paused",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:08:31.422Z",
  "data": {
    "campaign_id": "019df26d-1a2b-7000-9cb9-cccccccccccc",
    "paused_at": "2026-05-04T11:08:31Z",
    "reason": "manual"
  }
}
```

### `campaign.paused_quiet_hours`

Auto-pause triggered when every remaining contact is in their local quiet-hours window. Resumes automatically when the window opens.

```json
{
  "id": "01J0Z0SF4D3M4D0H0I0HTPYW13",
  "type": "campaign.paused_quiet_hours",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:08:42.012Z",
  "data": {
    "campaign_id": "019df26d-1a2b-7000-9cb9-cccccccccccc",
    "paused_at": "2026-05-04T11:08:42Z",
    "next_open_at": "2026-05-05T03:30:00Z"
  }
}
```

### `campaign.completed`

Every contact reached a terminal state.

```json
{
  "id": "01J0Z0SG5E4N5E0I0J0HTPYW14",
  "type": "campaign.completed",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:14:08.004Z",
  "data": {
    "campaign_id": "019df26d-1a2b-7000-9cb9-cccccccccccc",
    "completed_at": "2026-05-04T11:14:08Z",
    "total_contacts": 250,
    "completed_sessions": 243,
    "failed_sessions": 7
  }
}
```

### `campaign.contact_dispatched`

One contact handed to the dispatcher. The voice session is created and a `delivery_row_id` (the session id for phone, sms_message_id / email_message_id for the other channels) is returned.

```json
{
  "id": "01J0Z0SH6F5O6F0J0K0HTPYW15",
  "type": "campaign.contact_dispatched",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:08:51.402Z",
  "data": {
    "contact_id": "019df26d-1a2b-7001-9cb9-dddddddddddd",
    "campaign_id": "019df26d-1a2b-7000-9cb9-cccccccccccc",
    "phone_number": "+15513487977",
    "email": null,
    "first_name": "Smoke",
    "last_name": null,
    "delivery_row_id": "019df26d-6435-7e4e-9cb9-fffc3d8661e2"
  }
}
```

### `campaign.contact_completed`

Per-contact terminal success. Carries the session id, duration, and metered cost — most tenants pivot on this for per-row CRM updates.

```json
{
  "id": "01J0Z150K2T6X4N0BG8QXE7TMVQ",
  "type": "campaign.contact_completed",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:12:01.118Z",
  "data": {
    "contact_id": "019df26d-1a2b-7001-9cb9-dddddddddddd",
    "campaign_id": "019df26d-1a2b-7000-9cb9-cccccccccccc",
    "phone_number": "+15513487977",
    "first_name": "Smoke",
    "last_name": null,
    "email": null,
    "session_id": "019df26d-6435-7e4e-9cb9-fffc3d8661e2",
    "duration_seconds": 142,
    "actual_cost_cents": 18
  }
}
```

### `campaign.contact_failed`

Per-contact terminal failure. Same payload shape as `contact_completed`; check the linked `session.failed` event for the underlying `error_code`.

```json
{
  "id": "01J0Z0SK8H7Q8H0L0M0HTPYW17",
  "type": "campaign.contact_failed",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:09:43.118Z",
  "data": {
    "contact_id": "019df26d-1a2b-7001-9cb9-dddddddddddd",
    "campaign_id": "019df26d-1a2b-7000-9cb9-cccccccccccc",
    "phone_number": "+15513487977",
    "email": null,
    "first_name": "Smoke",
    "last_name": null,
    "session_id": "019df26d-6435-7e4e-9cb9-fffc3d8661e2",
    "duration_seconds": 0,
    "actual_cost_cents": 0
  }
}
```

### `campaign.contact_skipped`

Contact skipped before dispatch — never had a voice session. `skip_reason` ∈ `{invalid, dnc_blocked, daily_cap_reached, quiet_hours_expired, concurrency_full, tenant_suspended}`.

```json
{
  "id": "01J0Z0SL9J8R9I0M0N0HTPYW18",
  "type": "campaign.contact_skipped",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:08:52.118Z",
  "data": {
    "contact_id": "019df26d-1a2b-7001-9cb9-dddddddddddd",
    "campaign_id": "019df26d-1a2b-7000-9cb9-cccccccccccc",
    "phone_number": "+15513487977",
    "email": null,
    "first_name": "Smoke",
    "last_name": null,
    "skip_reason": "quiet_hours_expired"
  }
}
```

### `credits.low_balance`

Your balance dropped below the warning threshold configured in `/settings/billing`. Fired once per drop, not on every call.

```json
{
  "id": "01J0Z0T01K9SAJ0N0O0HTPYW20",
  "type": "credits.low_balance",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:09:00.001Z",
  "data": {
    "balance_cents": 2500,
    "threshold_cents": 5000,
    "currency": "USD"
  }
}
```

### `credits.depleted`

Balance hit zero. New dispatches are paused until a top-up settles.

```json
{
  "id": "01J0Z0T12LAJSK0O0P0HTPYW21",
  "type": "credits.depleted",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:11:38.401Z",
  "data": {
    "balance_cents": 0,
    "depleted_at": "2026-05-04T11:11:38Z",
    "currency": "USD"
  }
}
```

### `credits.topup_succeeded`

Stripe top-up settled.

```json
{
  "id": "01J0Z0T23MBKTL0P0Q0HTPYW22",
  "type": "credits.topup_succeeded",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:12:14.118Z",
  "data": {
    "amount_cents": 5000,
    "new_balance_cents": 5000,
    "currency": "USD",
    "stripe_payment_intent_id": "pi_3PZ8aXP9z4Y2Qc1H1aB2dE3F"
  }
}
```

### `voice_clone.training_started`

You uploaded samples and the training job picked them up.

```json
{
  "id": "01J0Z0U01N0L0V0Q0R0HTPYW30",
  "type": "voice_clone.training_started",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:00:00.001Z",
  "data": { "voice_id": "vc_alex_2026_05", "name": "Alex (UK)", "samples": 3 }
}
```

### `voice_clone.training_succeeded`

Training job finished. The voice is now usable in campaigns + sessions.

```json
{
  "id": "01J0Z0U12P1M1W0R0S0HTPYW31",
  "type": "voice_clone.training_succeeded",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:08:14.118Z",
  "data": { "voice_id": "vc_alex_2026_05", "name": "Alex (UK)", "ready_at": "2026-05-04T11:08:14Z" }
}
```

### `voice_clone.training_failed`

Common error codes: `insufficient_samples`, `sample_too_short`, `sample_too_quiet`, `voice_clone_disabled_on_plan`.

```json
{
  "id": "01J0Z0U23Q2N2X0S0T0HTPYW32",
  "type": "voice_clone.training_failed",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:01:42.001Z",
  "data": {
    "voice_id": "vc_alex_2026_05",
    "name": "Alex (UK)",
    "error_code": "insufficient_samples",
    "error_message": "Need at least 3 samples of 30 s each; received 2"
  }
}
```

### `room.created`

You called `POST /v1/rooms` — a signed URL was minted for the participant.

```json
{
  "id": "01J0Z0V01R3O3Y0T0U0HTPYW40",
  "type": "room.created",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:00:00.001Z",
  "data": {
    "room_id": "room_2K9aB1cDe2Fg3Hh4Ij5",
    "room_url": "https://rooms.rexa.ai/r/2K9aB1cDe2Fg3Hh4Ij5?sig=…",
    "expires_at": "2026-05-04T12:00:00Z"
  }
}
```

### `room.landing_viewed`

Participant opened the room URL but has not yet clicked "Join".

```json
{
  "id": "01J0Z0V12S4P4Z0U0V0HTPYW41",
  "type": "room.landing_viewed",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:00:14.118Z",
  "data": { "room_id": "room_2K9aB1cDe2Fg3Hh4Ij5", "viewed_at": "2026-05-04T11:00:14Z" }
}
```

### `room.joined`

Participant accepted permissions and joined. The agent is connected at this point.

```json
{
  "id": "01J0Z0V23T5Q5A0V0W0HTPYW42",
  "type": "room.joined",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:00:21.404Z",
  "data": {
    "room_id": "room_2K9aB1cDe2Fg3Hh4Ij5",
    "participant_id": "p_7M2nO3pQrStUvWxY",
    "joined_at": "2026-05-04T11:00:21Z"
  }
}
```

### `room.completed`

Both sides hung up cleanly. `duration_seconds` is `end - join`.

```json
{
  "id": "01J0Z0V34U6R6B0W0X0HTPYW43",
  "type": "room.completed",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:04:08.118Z",
  "data": {
    "room_id": "room_2K9aB1cDe2Fg3Hh4Ij5",
    "completed_at": "2026-05-04T11:04:08Z",
    "duration_seconds": 227
  }
}
```

### `room.expired`

`expires_at` elapsed before anyone joined.

```json
{
  "id": "01J0Z0V45V7S7C0X0Y0HTPYW44",
  "type": "room.expired",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T12:00:01.118Z",
  "data": { "room_id": "room_2K9aB1cDe2Fg3Hh4Ij5", "expired_at": "2026-05-04T12:00:00Z" }
}
```

### `room.failed`

Room couldn't be established (TURN auth fail, agent unreachable, etc).

```json
{
  "id": "01J0Z0V56W8T8D0Y0Z0HTPYW45",
  "type": "room.failed",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:00:22.001Z",
  "data": {
    "room_id": "room_2K9aB1cDe2Fg3Hh4Ij5",
    "error_code": "agent_unreachable",
    "error_message": "No agent URL passed health check at room create time"
  }
}
```

### `number.answer_rate_degraded`

Inbound number's answer rate dropped below 80% over a rolling 24h window. Often a sign your number got marked as Spam Likely by carriers — rotate it via `/inbound-numbers`.

```json
{
  "id": "01J0Z0W01X9U9E0Z0A0HTPYW50",
  "type": "number.answer_rate_degraded",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T12:00:00.001Z",
  "data": {
    "phone_number": "+12013796233",
    "answer_rate": 0.42,
    "window_start": "2026-05-03T12:00:00Z",
    "window_end": "2026-05-04T12:00:00Z"
  }
}
```

### `function.timeout`

An agent function call exceeded its `timeout_ms`. The agent gracefully tells the caller "I am still working on that" and the conversation continues.

```json
{
  "id": "01J0Z0W12Y0V0F0A0B0HTPYW51",
  "type": "function.timeout",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:09:14.118Z",
  "data": {
    "session_id": "019df26d-6435-7e4e-9cb9-fffc3d8661e2",
    "function_id": "fn_lookup_order",
    "name": "lookup_order",
    "timeout_ms": 3000
  }
}
```

### `webhook.test`

Synthetic event fired by `POST /v1/webhook_endpoints/{id}/test`. Use this to confirm your verification code path with a real signature.

```json
{
  "id": "01J0Z0W23Z1W1G0B0C0HTPYW52",
  "type": "webhook.test",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:00:00.001Z",
  "data": {
    "endpoint_id": "019dd49a-8e9f-7a07-9a23-680a2bf737bb",
    "emitted_at": "2026-05-04T11:00:00Z"
  }
}
```

### `webhook.endpoint_disabled_notice`

Your endpoint was auto-suspended after sustained delivery failures. Re-enable it from the dashboard once your receiver is back.

```json
{
  "id": "01J0Z0W34A2X2H0C0D0HTPYW53",
  "type": "webhook.endpoint_disabled_notice",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-04T11:00:00.001Z",
  "data": {
    "endpoint_id": "019dd49a-8e9f-7a07-9a23-680a2bf737bb",
    "url": "https://example.com/rexa",
    "disabled_at": "2026-05-04T11:00:00Z",
    "failure_streak": 10,
    "last_status": 503
  }
}
```

---

## Local testing

Use the `webhook.test` endpoint to fire a synthetic event at one of
your endpoints any time:

```bash
curl -X POST https://voice-api-bpnl.onrender.com/v1/webhook_endpoints/<ID>/test \
  -H "Authorization: Bearer $REXA_API_KEY"
```

You'll receive a `webhook.test` event delivered with a real signature
so you can validate your verification code path before relying on it
in production.
