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:
— 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
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 more than 5 min 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.
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
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 with status `completed`. Includes full transcript, function-call summary, and from/to phone numbers.
Synthetic event fired by POST /v1/webhook_endpoints/{id}/test.
endpoint_id, emitted_at
webhook.endpoint_disabled_notice
Your 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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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"