zoxaAI
Homepage
API ReferenceSchemas

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"
}
FieldTypeNotes
eventstringAlways "call.started".
callIdstringzoxaAI internal call id.
agentIdstring | nullAgent UUID. null if the call was placed with a transient (inline) agent.
transportstringOne of outbound (telephony dial-out), inbound (telephony binding), webrtc (browser WebRTC), websocket (raw WS), web (WebRTC alias).
startedAtISO 8601Pipeline 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"
}
FieldTypeNotes
eventstringAlways "call.ended".
callIdstring
agentIdstring | null
endedReasonstring | nullWhy the connected call ended. null means the call never connected — read connectionStatus for the reason. See values below.
connectionStatusstring | 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.
durationfloat (seconds)Rounded to 2 decimals.
endedAtISO 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 }
  }
}
FieldTypeNotes
eventstringAlways "call.completed".
callIdstring
agentIdstring | null
endedReasonstring | nullSame semantics as call.ended — null when the call never connected.
connectionStatusstring | nullSame semantics as call.ended.
durationfloat (seconds)
recordingUrlstring | nullDownload URL for the WAV recording (https://cdn.webrexstudio.com/zoxaAI/recordings/{callId}.wav). Also available later via GET /calls/{callId}.
transcriptarray | nullOrdered list of message turns and tool calls. role is assistant, user, or tool — see the webhooks guide for the entry shapes.
transcriptUrlstring | nullDownload URL for the transcript as JSON (https://cdn.webrexstudio.com/zoxaAI/transcripts/{callId}.json, shape {"messages": [...]}). Same content as the inline transcript.
summarystring | nullLLM-generated summary. Set only if the agent's enableSummarization is true.
usageobject | nullCleaned-up usage metrics per service. See below.

usage shape

Per-service breakdown — only present if the underlying pipecat service emitted metrics.

ServiceFields
llmprovider, model, plus any metrics the LLM service emitted (prompt_tokens, completion_tokens, cache_read_tokens, cache_creation_tokens, etc.).
ttsprovider, model, characters.
sttprovider, 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"
  }
}
FieldTypeNotes
eventstringAlways "call.transfer_recording.completed".
callIdstringSame id as the call's other lifecycle events.
agentIdstring | nullnull for transient (inline) agents.
recordingUrlstringURL for the transfer-leg recording, re-hosted in zoxaAI storage (CDN link or presigned URL) — never a carrier URL.
durationSecondsfloat | nullLength of the transfer-leg recording, rounded to 2 decimals. null if the carrier didn't report it.
telephonyProviderstring | nullWhich carrier produced the recording (twilio, vobiz, plivo, telnyx, vonage, cloudonix, ari, smartflo).
transferobjectThe 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"
  }
}
FieldTypeNotes
eventstringAlways "call.transfer_recording.failed".
callIdstringSame id as the call's other lifecycle events.
agentIdstring | nullnull for transient (inline) agents.
reasonstringno_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).
telephonyProviderstring | nullThe carrier that handled the transfer.
transferobjectSee transfer object.

transfer object

FieldTypeNotes
transferIdstring | nullzoxaAI's id for the transfer.
destinationstring | nullNumber or SIP address the call was transferred to.
carrierCallIdstring | nullThe 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

PropertyValue
Retry on failureNone — fire-and-forget. Single 10-second timeout.
Delivery guaranteeAt-most-once.
OrderingBest-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 responseLogged as a warning. Not retried.
5xx responseLogged as a warning. Not retried.
Timeout10 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.

On this page