Webhook events
Lifecycle events fired to your agent's webhook URL — exact payload shapes, signature scheme, and delivery semantics.
When an agent has webhook.url set, zoxaAI POSTs lifecycle events to that URL. Five events exist: call.started, call.ended, call.completed, and — only for calls that transfer to a recorded destination leg — one of call.transfer_recording.completed or call.transfer_recording.failed. The event name is in the body — there is no event header.
Request shape
POST https://your.app/webhook
Content-Type: application/json
X-Zoxa-Signature: 8f1c2a... (hex HMAC-SHA256)Plus any custom headers from the agent's webhook.headers config (signature is applied AFTER custom headers, so a custom X-Zoxa-Signature would be overwritten — don't do that).
Signature verification
X-Zoxa-Signature = hex( HMAC_SHA256( webhookSecret, raw_body_bytes ) )You supply the signing secret yourself. Send a webhookSecret field when you create or update the agent (POST/PATCH /agents) — it sits alongside webhook in the request body. The field is write-only: it's stored server-side on the agent and never returned in any API response (the agent envelope, AgentEnvelope, omits it), so keep your own copy when you set it. zoxaAI signs every event with it.
No secret means no signature
X-Zoxa-Signature is only added when the agent has a webhookSecret. If you never set one, events are delivered unsigned — treat the receiving URL itself as the security boundary (use HTTPS and embed an unguessable token in the path, e.g. https://your.app/webhook/9f1c2a...). To rotate the secret, PATCH the agent with a new webhookSecret.
Verify on the raw body
Compute the HMAC over the raw POST body bytes — not over a re-serialized JSON. Re-serialization changes whitespace and your HMAC won't match.
import crypto from "node:crypto";
function verify(rawBody, header, secret) {
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(header, "hex"),
);
}import hashlib, hmac
def verify(raw_body: bytes, header: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)Events
call.started
Fired when the agent pipeline starts — the call has been answered (telephony) or the WS/WebRTC session is established.
{
"event": "call.started",
"callId": "call_4e4e571f8c9b3cf8e99d",
"agentId": "550e8400-e29b-41d4-a716-446655440000",
"transport": "outbound",
"startedAt": "2026-06-16T10:30:12.341Z"
}| Field | Type | Notes |
|---|---|---|
event | string | Always "call.started". |
callId | string | zoxaAI internal call id. |
agentId | string | null | Agent UUID. null if the call was placed with a transient (inline) agent. |
transport | string | One of outbound (telephony dial-out), inbound (telephony binding), webrtc (browser WebRTC), websocket (raw WS), web (WebRTC alias). |
startedAt | ISO 8601 | Pipeline start timestamp. |
call.ended
Fired as soon as the pipeline finalizes — terminal state reached. Cheap and fast — sent before any post-processing.
{
"event": "call.ended",
"callId": "call_4e4e571f8c9b3cf8e99d",
"agentId": "550e8400-e29b-41d4-a716-446655440000",
"endedReason": "user_hangup",
"connectionStatus": "completed",
"duration": 105.27,
"endedAt": "2026-06-16T10:32:01.611Z"
}| Field | Type | Notes |
|---|---|---|
event | string | Always "call.ended". |
callId | string | |
agentId | string | null | |
endedReason | string | null | Why the connected call ended. null means the call never connected — read connectionStatus for the reason. See values below. |
connectionStatus | string | null | "completed" when the call connected; otherwise why it never did (no_answer, busy, missing_credentials, ...). null only if the outcome couldn't be determined at emit time. |
duration | float (seconds) | Rounded to 2 decimals. |
endedAt | ISO 8601 |
endedReason and connectionStatus values
endedReason is one of exactly nine values — the same vocabulary as the REST API: user_hangup, agent_hangup, silence_timeout, max_duration, voicemail, transferred, pipeline_error, cancelled, unknown. Full table: endedReason values.
connectionStatus uses the connection vocabulary: connectionStatus values.
A never-connected call (e.g. missing_credentials — telephony credentials missing or unusable) fires call.ended directly with no call.started, endedReason: null, and the reason in connectionStatus.
Treat `endedReason` as nullable
endedReason is null whenever the call never connected. Branch on connectionStatus === "completed" first, then on endedReason.
call.completed
Fired after post-call processing finishes — transcript written, recording uploaded, usage metrics aggregated. Arrives seconds to a minute after call.ended.
{
"event": "call.completed",
"callId": "call_4e4e571f8c9b3cf8e99d",
"agentId": "550e8400-e29b-41d4-a716-446655440000",
"endedReason": "user_hangup",
"connectionStatus": "completed",
"duration": 105.27,
"recordingUrl": "https://cdn.webrexstudio.com/zoxaAI/recordings/call_4e4e571f8c9b3cf8e99d.wav",
"transcript": [
{ "role": "assistant", "content": "Hi, this is Aria from Acme..." },
{ "role": "user", "content": "Sure, go ahead." }
],
"transcriptUrl": "https://cdn.webrexstudio.com/zoxaAI/transcripts/call_4e4e571f8c9b3cf8e99d.json",
"summary": "User confirmed appointment for next Tuesday at 3pm.",
"usage": {
"llm": { "provider": "openai", "model": "gpt-5.4-mini", "prompt_tokens": 482, "completion_tokens": 153 },
"tts": { "provider": "elevenlabs", "model": "eleven_flash_v2_5", "characters": 1247 },
"stt": { "provider": "soniox", "model": "stt-rt-v5", "seconds": 47.3 }
}
}| Field | Type | Notes |
|---|---|---|
event | string | Always "call.completed". |
callId | string | |
agentId | string | null | |
endedReason | string | null | Same semantics as call.ended — null when the call never connected. |
connectionStatus | string | null | Same semantics as call.ended. |
duration | float (seconds) | |
recordingUrl | string | null | Download URL for the WAV recording (https://cdn.webrexstudio.com/zoxaAI/recordings/{callId}.wav). Also available later via GET /calls/{callId}. |
transcript | array | null | Ordered list of message turns and tool calls. role is assistant, user, or tool — see the webhooks guide for the entry shapes. |
transcriptUrl | string | null | Download URL for the transcript as JSON (https://cdn.webrexstudio.com/zoxaAI/transcripts/{callId}.json, shape {"messages": [...]}). Same content as the inline transcript. |
summary | string | null | LLM-generated summary. Set only if the agent's enableSummarization is true. |
usage | object | null | Cleaned-up usage metrics per service. See below. |
usage shape
Per-service breakdown — only present if the underlying pipecat service emitted metrics.
| Service | Fields |
|---|---|
llm | provider, model, plus any metrics the LLM service emitted (prompt_tokens, completion_tokens, cache_read_tokens, cache_creation_tokens, etc.). |
tts | provider, model, characters. |
stt | provider, model, seconds. May be {provider, model} only if STT didn't emit usage. |
If multiple LLM services ran during one call (rare — only for multi-agent setups), llm is an array instead of a single object.
call.transfer_recording.completed
Fires only when the call transferred to a destination whose leg was recorded carrier-side, and after call.completed — potentially minutes later, since the transferred conversation outlives the agent's leg and the carrier only produces the file once it ends. The callId is the same id as the earlier lifecycle events — that's how you correlate this late artifact back to the call.
{
"event": "call.transfer_recording.completed",
"callId": "call_4e4e571f8c9b3cf8e99d",
"agentId": "550e8400-e29b-41d4-a716-446655440000",
"recordingUrl": "https://cdn.webrexstudio.com/zoxaAI/recordings/transfer_call_4e4e571f8c9b3cf8e99d.wav",
"durationSeconds": 73.4,
"telephonyProvider": "twilio",
"transfer": {
"transferId": "6f1c2a9e-4b7d-4e0a-9c3f-2d8e5b1a7c40",
"destination": "+15551234567",
"carrierCallId": "CA9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c"
}
}| Field | Type | Notes |
|---|---|---|
event | string | Always "call.transfer_recording.completed". |
callId | string | Same id as the call's other lifecycle events. |
agentId | string | null | null for transient (inline) agents. |
recordingUrl | string | URL for the transfer-leg recording, re-hosted in zoxaAI storage (CDN link or presigned URL) — never a carrier URL. |
durationSeconds | float | null | Length of the transfer-leg recording, rounded to 2 decimals. null if the carrier didn't report it. |
telephonyProvider | string | null | Which carrier produced the recording (twilio, vobiz, plivo, telnyx, vonage, cloudonix, ari, smartflo). |
transfer | object | The transfer this event belongs to — see transfer object. |
call.transfer_recording.failed
Fires instead of call.transfer_recording.completed when the transfer leg was recorded but no file could be obtained. Exactly one of the two fires per recorded transfer, after call.completed, with the same callId.
{
"event": "call.transfer_recording.failed",
"callId": "call_4e4e571f8c9b3cf8e99d",
"agentId": "550e8400-e29b-41d4-a716-446655440000",
"reason": "download_failed",
"telephonyProvider": "vobiz",
"transfer": {
"transferId": "6f1c2a9e-4b7d-4e0a-9c3f-2d8e5b1a7c40",
"destination": "+919876543210",
"carrierCallId": "b1a7c40e-2d8e-4e0a-9c3f-6f1c2a9e4b7d"
}
}| Field | Type | Notes |
|---|---|---|
event | string | Always "call.transfer_recording.failed". |
callId | string | Same id as the call's other lifecycle events. |
agentId | string | null | null for transient (inline) agents. |
reason | string | no_recording (carrier produced no file — e.g. too short), download_failed (carrier file could not be downloaded after all retries, ~45 min), or storage_failed (downloaded but could not be stored). |
telephonyProvider | string | null | The carrier that handled the transfer. |
transfer | object | See transfer object. |
transfer object
| Field | Type | Notes |
|---|---|---|
transferId | string | null | zoxaAI's id for the transfer. |
destination | string | null | Number or SIP address the call was transferred to. |
carrierCallId | string | null | The transferred leg's id at the carrier (Twilio CallSid, Vobiz/Plivo B-leg UUID, Asterisk channel id, …). null when the carrier doesn't report one. |
Delivery semantics
| Property | Value |
|---|---|
| Retry on failure | None — fire-and-forget. Single 10-second timeout. |
| Delivery guarantee | At-most-once. |
| Ordering | Best-effort. call.started → call.ended → call.completed is the typical order but not strictly serialized. call.transfer_recording.completed / call.transfer_recording.failed, when one fires at all, always arrives last (after call.completed). |
| 4xx response | Logged as a warning. Not retried. |
| 5xx response | Logged as a warning. Not retried. |
| Timeout | 10 seconds. Logged as a warning. Not retried. |
No retries means make your handler reliable
Webhook delivery is fire-and-forget. If your handler is down, the event is lost. For state you must not miss, treat webhooks as a notification ("look at this call now") and fall back to GET /calls/{callId} as the source of truth.
Per-call webhook override
To route one call's events to a different URL, send the agent inline (agent field) with a different webhook.url — there is no per-call override field. Note that webhookSecret is not part of the inline agent config (it lives only on saved agents), so events for an inline transient agent are delivered unsigned. When you need signed webhooks, use a saved agent (agentId) that has a webhookSecret set.
Related
- Outbound flow — webhook handler — runnable handler in Node + Python
- Agent config — webhook — full
webhooksub-schema GET /calls/{callId}— authoritative source for everything incall.completed