Reference

Webhooks

Rexa.ai POSTs JSON to your registered HTTPS endpoints whenever something happens that you've subscribed to. Every delivery is HMAC-signed; the snippets below let you verify in three lines of code. The same reference is available as raw markdown at /webhooks-reference.md.

The envelope

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

{
  "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" }
}
id
— Unique event id (UUIDv7). Use for idempotency on your side; replays carry the same id.
type
— Event type. See catalog below.
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

headerexamplepurpose
Content-Typeapplication/jsonAlways JSON.
X-Webhook-Signaturesha256=a3f1…HMAC-SHA256 hex of the canonical signing base.
X-Webhook-Timestamp1714817382Unix seconds. Reject if more than 5 min off your clock.
X-Webhook-Id01J0Z0V7…Same as body.id. Read whichever is convenient.
X-Webhook-Eventsession.endedSame as body.type. Lets you route before parsing.

Default delivery timeout: 15s. We retry with exponential backoff on 5xx + network errors (max 24h, ~10 attempts). After ~10 consecutive permanent failures the endpoint auto-suspends and you receive a webhook.endpoint_disabled_notice email.

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

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

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

export function verifyRexaSignature(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

import hmac, hashlib, time, os

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-serializes before you see it (Express's body-parser is fine if you keep req.rawBody); forgetting the sha256= prefix in expected; using a non-constant-time string compare (susceptible to timing attacks).

Event catalog — sessions

typewhenkey data fields
session.createdThe call is queued for dispatch.session_id, to_number, from_number, campaign_id?
session.startedThe agent picked up and started the conversation.session_id, started_at
session.endedCall finished with status `completed`. Includes full transcript, function-call summary, and from/to phone numbers.session_id, campaign_id?, contact_id?, from_number, to_number, phone_number, email?, first_name?, last_name?, status, end_reason, started_at, ended_at, duration_seconds, actual_cost_cents, transcript[], transcript_url?, function_invocations[]
session.failedCall ended with status not `completed` (failed, no_answer, voicemail, busy). Same shape as session.ended.session_id, campaign_id?, contact_id?, from_number, to_number, phone_number, email?, first_name?, last_name?, status, end_reason, started_at?, ended_at, duration_seconds, actual_cost_cents, transcript[], transcript_url?, function_invocations[]
session.disposition_setAsync LLM-derived disposition for an agent-launched campaign call (Phase 2.5). Fires AFTER session.ended.session_id, campaign_id, contact_id, disposition_code, disposition_note, evaluated_at, evaluator, model?
session.recording_completedRecording 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.transfer_initiatedAgent transferred the live call to a human operator mid-call.session_id, to_number, from_number, transfer_number, transferred_at
session.opt_out_detectedCaller said "stop calling me". DNC list updated automatically.session_id, phone_number, detected_at
session.opt_out_recording_pending_purgeRecording flagged for purge after opt-out.session_id, recording_id, purge_after
session.opt_out_recording_purgedRecording purged.session_id, recording_id, purged_at
session.consent_refusedCaller refused recording consent. Call ended.session_id, refused_at

Event catalog — campaigns

typewhenkey data fields
campaign.createdCampaign row created (still draft until started).campaign_id
campaign.startedCampaign moved to running.campaign_id
campaign.pausedManual pause.campaign_id
campaign.paused_quiet_hoursAuto-pause for quiet hours.campaign_id
campaign.completedEvery contact reached a terminal state.campaign_id
campaign.contact_dispatchedOne contact handed to the dispatcher.contact_id, campaign_id, phone_number, email, first_name, last_name, delivery_row_id
campaign.contact_completedPer-contact terminal success. Carries cost + duration.contact_id, campaign_id, phone_number, email, first_name, last_name, session_id, duration_seconds, actual_cost_cents
campaign.contact_failedPer-contact terminal failure.contact_id, campaign_id, phone_number, email, first_name, last_name, session_id, duration_seconds, actual_cost_cents
campaign.contact_skippedContact skipped before dispatch.contact_id, campaign_id, phone_number, email, first_name, last_name, skip_reason ∈ {invalid, dnc_blocked, daily_cap_reached, quiet_hours_expired, concurrency_full, tenant_suspended}

Event catalog — credits, voice clones, rooms, ops

typewhenkey data fields
credits.low_balanceBalance dropped below your warning threshold.balance_cents, threshold_cents
credits.depletedBalance hit zero — dispatch paused.balance_cents
credits.topup_succeededStripe top-up settled.amount_cents, new_balance_cents
voice_clone.training_started / training_succeeded / training_failedLifecycle of a voice-clone training job.voice_id, status, error?
room.created / landing_viewed / joined / completed / expired / failedWebRTC room lifecycle.room_id, participant_id?, …
number.answer_rate_degradedAn inbound number answer rate dropped below 80% in 24h.phone_number, answer_rate, window_start, window_end
function.timeoutAn agent function call exceeded its timeout.session_id, function_id, timeout_ms
webhook.testSynthetic event fired by POST /v1/webhook_endpoints/{id}/test.endpoint_id, emitted_at
webhook.endpoint_disabled_noticeYour endpoint was auto-suspended after sustained failures.endpoint_id, disabled_at, failure_streak

Sample payloads

Every event we emit, with a full example body. The session, campaign, and contact ids across the session/campaign families belong to one fictional call so you can trace a single lifecycle through the catalog.

session.created

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

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

{
  "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

Session finished with status `completed`. Carries the full transcript (agent's {role, content, t?} shape — turns that are pure tool calls have no `content`), function-call summary, and the metered cost. For an outbound phone call `from_number` / `to_number` / `phone_number` / `campaign_id` / `contact_id` are the join keys. For a WebRTC room (created via `POST /v1/rooms`) there is no phone leg — those five fields are all `null`; join on `session_id`, or on the `client_reference_id` you passed when you created the room.

{
  "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",
    "client_reference_id": null,
    "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-6435-7e4e-9cb9-fffc3d8661e2/transcript.json",
    "function_invocations": [
      { "name": "lookup_candidate_profile", "status": "succeeded", "duration_ms": 412 }
    ]
  }
}

session.failed

Call ended with a non-`completed` status (`failed`, `no_answer`, `voicemail`, `busy`). Same payload shape as session.ended; treat the difference as a status-driven branch in your handler.

{
  "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

Async LLM-derived disposition for an agent-launched campaign call (Phase 2.5, 2026-05). Fires AFTER session.ended once a worker classifies the call against the agent's frozen dispositions[]. Skipped when the campaign has no agent, the snapshot has no dispositions, or the call agent already supplied a disposition inline.

{
  "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

Recording finalized + uploaded to a public CDN. Lags session.ended by tens of seconds (transcoding + upload). Identity payload mirrors campaign.contact_completed so per-destination receivers can join without an extra API call.

{
  "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.transfer_initiated

Agent transferred the live call to a human operator. Fires mid-call when the agent initiates the transfer — the session may still be in_progress at this point. Use transfer_number as the key for routing in your CRM.

{
  "id": "01J0Z14C2R4S6U1W5N4HTPYW05",
  "type": "session.transfer_initiated",
  "tenant_id": "0192b9c2-3ea4-7d3c-9ab2-2e8a4ff62a99",
  "created_at": "2026-05-21T14:32:10.123Z",
  "data": {
    "session_id":      "019df26d-6435-7e4e-9cb9-fffc3d8661e2",
    "to_number":       "+15513487977",
    "from_number":     "+14155551234",
    "transfer_number": "+15559998888",
    "transferred_at":  "2026-05-21T14:32:10Z"
  }
}

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.

{
  "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) so it is not part of any analytics export taken during that window.

{
  "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 — the recording_url returns 404 from this point.

{
  "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"
  }
}

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

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

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

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

{
  "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. Campaign row flipped to `completed` with completed_at set.

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

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

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

{
  "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. Use skip_reason to drive your CRM logic. Possible values: invalid, dnc_blocked, daily_cap_reached, quiet_hours_expired, concurrency_full, tenant_suspended.

{
  "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 after the threshold's been crossed.

{
  "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 — already-running calls finish at your last positive balance and may charge against the next top-up's grace window.

{
  "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. Balance is updated in the same transaction; this event fires after the row is committed.

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

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

{
  "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

Training job failed. Common error codes: insufficient_samples, sample_too_short, sample_too_quiet, voice_clone_disabled_on_plan.

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

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

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

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

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

{
  "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).

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

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

{
  "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, no live traffic needed.

{
  "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 (~10 consecutive permanent failures over 24h). Re-enable it from the dashboard once your receiver is back.

{
  "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. You'll receive a real signature so you can validate your verification code path before relying on it in production.

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