Webhooks
Receive real-time HTTP callbacks for call lifecycle events. Covers event types, payload structures, HMAC-SHA256 signing, and delivery semantics.
What you'll learn
How to configure webhooks to receive real-time notifications for call lifecycle events (started, ended, completed), the payload structure for each event type, how to verify webhook signatures with HMAC-SHA256, and delivery semantics including ordering and idempotency.
Overview
zoxaAI sends webhook HTTP POST requests to your server when call lifecycle events occur. Webhooks are fire-and-forget -- the platform dispatches them asynchronously and does not retry on failure.
Key Characteristics
| Property | Value |
|---|---|
| HTTP method | POST |
| Content-Type | application/json |
| Timeout | 10 seconds |
| Retries | None -- single attempt, fire-and-forget |
| Signing | HMAC-SHA256 via X-Zoxa-Signature header (when a webhook secret is configured) |
| Delivery order | Events are dispatched in order per call, but arrival order is not guaranteed |
Configuration
Webhooks are configured per-agent. You set the webhook URL and optional custom headers in the agent configuration, either via the dashboard or the API.
Setting Up Webhooks
Configure via the API
The agent config has a webhook field that accepts two shapes — pick the one that fits.
Shorthand: just the URL (no auth headers needed):
{
"webhook": "https://your-server.com/webhooks/zoxa",
"enableSummarization": true
}Full object: URL + auth headers (custom auth like an API key):
{
"webhook": {
"url": "https://your-server.com/webhooks/zoxa",
"headers": {
"X-Api-Key": "secret-token-123"
}
},
"enableSummarization": true
}The shorthand normalizes to the object form server-side, so the stored config always carries {url, headers}. Both shapes are valid forever.
| Field | Type | Default | Notes |
|---|---|---|---|
webhook | string | object | null | null | Bare URL string OR {url, headers} object. Setting null clears the webhook entirely. URL must start with http:// or https://. |
webhook.url | string | required when object form | HTTPS endpoint that receives webhook POST requests. ≤ 2048 chars. |
webhook.headers | object | null | Extra headers sent on every webhook POST. Merged in before the signature header — so a custom X-Zoxa-Signature is overwritten. |
enableSummarization | bool | false | Top-level field, not nested under webhook. When true, an AI summary of the call is persisted on the call row AND included in the call.completed event payload. Independent of webhook — you can enable summarization without configuring a webhook (the summary still shows in the dashboard). |
Example update:
curl -X PATCH https://dashboard.zoxa.ai/api/v1/agents/<uuid> \
-H "X-API-Key: zsk_..." \
-H "Content-Type: application/json" \
-d '{
"webhook": "https://your-server.com/webhooks/zoxa",
"enableSummarization": true
}'Set the webhook secret
Supply your own signing secret with the write-only webhookSecret field when creating or updating the agent (a sibling of the config fields, like name). Every outgoing webhook is then signed with X-Zoxa-Signature: <hmac-sha256(secret, body)> so your receiver can verify authenticity.
webhookSecret is write-only — it is never returned on any agent response. Store it in your receiving service's configuration. If you skip it, use HTTPS and embed an unguessable token in the receiving path (e.g. https://your.app/webhook/9f1c2a...) as the security boundary.
Per-Call Webhook Routing
There is no per-call override field — the webhook lives inside the agent config. To route a single call's events to a different URL, send the agent inline with a different webhook.url:
curl -X POST https://dashboard.zoxa.ai/api/v1/call \
-H "X-API-Key: zsk_..." \
-d '{
"type": "outbound",
"callConfig": { ... },
"toNumber": "+1...",
"agent": {
"name": "Webhook demo",
"llm": { "provider": "openai", "model": "gpt-5.4-mini" },
"systemPrompt": "...",
"webhook": { "url": "https://your-server.com/webhooks/zoxa" },
"enableSummarization": true
}
}'Event Types
Events dispatched during a call's lifecycle:
| Event | Timing | Dispatched by |
|---|---|---|
call.started | When the client connects (WebRTC/WebSocket connected) | Pipeline process (sync, non-blocking) |
call.ended | When the pipeline finishes or the client disconnects | Pipeline process (sync, non-blocking) |
call.completed | After post-call processing (recording upload, transcript save, optional summarization) | Background task (ARQ worker) |
call.transfer_recording.completed | Only for calls where a transferred conversation was recorded — fires once the carrier's recording is downloaded and stored, potentially minutes after call.completed (the transferred conversation outlives the agent's leg) | Background task (ARQ worker) |
call.transfer_recording.failed | Instead of call.transfer_recording.completed when the transferred conversation's recording could not be obtained — the carrier produced no file, or it could not be downloaded or stored | Background task (ARQ worker) or carrier callback handler |
call.started
Fired the moment a client connects to the voice pipeline.
{
"event": "call.started",
"callId": "call_a1b2c3d4e5",
"agentId": "ag_f6g7h8i9j0",
"transport": "web",
"startedAt": "2026-01-15T14:30:00.000000+00:00"
}| Field | Type | Description |
|---|---|---|
event | string | Always "call.started" |
callId | string | Unique call identifier (public ID) |
agentId | string or null | Agent UUID. Null for transient agents. |
transport | string | Transport type: "web", "websocket", "outbound", etc. |
startedAt | string | ISO 8601 timestamp |
call.ended
Fired when the pipeline shuts down -- whether the user hung up, the call duration limit was reached, or an error occurred.
{
"event": "call.ended",
"callId": "call_a1b2c3d4e5",
"agentId": "ag_f6g7h8i9j0",
"endedReason": "user_hangup",
"connectionStatus": "completed",
"duration": 127.45,
"endedAt": "2026-01-15T14:32:07.450000+00:00"
}| Field | Type | Description |
|---|---|---|
event | string | Always "call.ended" |
callId | string | Unique call identifier |
agentId | string or null | Agent UUID |
endedReason | string or null | Why the connected call ended (see table below). null means the call never connected — read connectionStatus for the reason. |
connectionStatus | string or null | "completed" when the call connected; otherwise why it never did (no_answer, busy, missing_credentials, ...) — see connectionStatus values. |
duration | number | Call duration in seconds, rounded to 2 decimal places |
endedAt | string | ISO 8601 timestamp |
Ended reasons (only for connected calls — null otherwise):
| Reason | Description |
|---|---|
"user_hangup" | The user hung up / disconnected |
"agent_hangup" | The agent ended the call (endCall tool) |
"silence_timeout" | User was silent beyond the idle timeout and retry limit |
"max_duration" | Call duration limit reached |
"voicemail" | Answering-machine detection fired |
"transferred" | Call was handed off via transferCall |
"pipeline_error" | Pipeline crashed with an unexpected error |
"cancelled" | Pipeline was cancelled mid-call (shutdown, abort) |
"unknown" | Connected call ended without attribution (rare fallback) |
call.completed
Fired after background post-processing finishes. This event contains the richest data -- recording URL, full transcript, optional summary, and usage metrics.
{
"event": "call.completed",
"callId": "call_a1b2c3d4e5",
"agentId": "ag_f6g7h8i9j0",
"endedReason": "user_hangup",
"connectionStatus": "completed",
"duration": 127.45,
"recordingUrl": "https://cdn.webrexstudio.com/zoxaAI/recordings/call_a1b2c3d4e5.wav",
"transcript": [
{"role": "assistant", "content": "Hello! How can I help you today?", "timestamp": "2026-01-15T14:30:00.000+00:00"},
{"role": "user", "content": "I need to reschedule my appointment.", "timestamp": "2026-01-15T14:30:04.210+00:00"},
{"role": "assistant", "content": "Of course. What date works for you?", "timestamp": "2026-01-15T14:30:06.840+00:00"},
{"role": "user", "content": "Next Thursday at 2 PM.", "timestamp": "2026-01-15T14:30:11.115+00:00"},
{"role": "tool", "name": "updateAppointment", "status": "success", "durationMs": 412, "t_ms": 13800, "timestamp": "2026-01-15T14:30:13.800+00:00"},
{"role": "assistant", "content": "Done! Your appointment is rescheduled to Thursday at 2 PM. Anything else?", "timestamp": "2026-01-15T14:30:14.600+00:00"},
{"role": "user", "content": "No, that's all. Thanks!", "timestamp": "2026-01-15T14:30:21.300+00:00"}
],
"transcriptUrl": "https://cdn.webrexstudio.com/zoxaAI/transcripts/call_a1b2c3d4e5.json",
"summary": "The user called to reschedule an appointment. The agent confirmed the new date as next Thursday at 2 PM. The call ended after the user confirmed no further needs.",
"usage": {
"llm": {
"provider": "openai",
"model": "gpt-4.1",
"prompt_tokens": 1250,
"completion_tokens": 340
},
"tts": {
"provider": "elevenlabs",
"model": "eleven_flash_v2_5",
"characters": 412
},
"stt": {
"provider": "soniox",
"model": "stt-rt-v5",
"seconds": 127.45
}
}
}| Field | Type | Description |
|---|---|---|
event | string | Always "call.completed" |
callId | string | Unique call identifier |
agentId | string or null | Agent UUID |
endedReason | string or null | Same values as call.ended — null when the call never connected |
connectionStatus | string or null | Same semantics as call.ended |
duration | number | Call duration in seconds |
recordingUrl | string or null | Download URL for the WAV recording, e.g. https://cdn.webrexstudio.com/zoxaAI/recordings/{callId}.wav. Null if recording failed or was not enabled. |
transcript | array or null | Ordered chronological list of message turns and tool calls. Each entry's role is one of "assistant", "user", or "tool". Null if no transcript was captured. See the Transcript entry types table below. |
transcriptUrl | string or null | Download URL for the transcript as a JSON file ({"messages": [...]}), e.g. https://cdn.webrexstudio.com/zoxaAI/transcripts/{callId}.json. Same content as the inline transcript array — use whichever fits your integration. |
summary | string or null | AI-generated 3-5 sentence summary. Only present when enableSummarization is true in the agent's webhook config. |
usage | object or null | Token/character/second usage broken down by service. See structure below. |
Transcript entry types:
The transcript array contains two kinds of entries, ordered by time. Consumers should branch on role.
Message entries (user or assistant):
| Field | Type | Description |
|---|---|---|
role | string | "user" or "assistant" |
content | string | The spoken text |
timestamp | string | ISO 8601 wall-clock time of the turn |
Tool entries (one per tool_call_completed event):
| Field | Type | Description |
|---|---|---|
role | string | Always "tool" |
name | string | Tool function name (e.g. "transferCall", "updateAppointment") |
status | string | "success", "failed", "timeout", or "completed" |
durationMs | number | Wall-clock time from tool_call_started to tool_call_completed |
t_ms | number | Offset in milliseconds from call start |
timestamp | string | ISO 8601 wall-clock time of the tool call |
For transferCall specifically, the tool's status and the carrier-reported reason follow Twilio's canonical CallStatus vocabulary (no-answer, busy, failed, canceled). See Call Transfer → Transfer Outcome Reasons for the full reference table.
Usage object structure:
| Key | Fields | Description |
|---|---|---|
llm | provider, model, prompt_tokens, completion_tokens | LLM token consumption |
tts | provider, model, characters | Text-to-speech character count |
stt | provider, model, seconds | Speech-to-text audio duration |
call.transfer_recording.completed
Fires only when the call's transfer tool recorded the transferred conversation (see Call Transfer → Recording the Transfer). Because the caller keeps talking to the transfer destination after the AI leaves, this event arrives after call.completed — usually minutes later, once the transferred conversation ends and the carrier's file is downloaded and stored. Match it to the earlier events by callId.
A transferred call shows up as endedReason: "transferred" on call.ended and call.completed. When its transfer was recorded, exactly one of call.transfer_recording.completed or call.transfer_recording.failed follows.
{
"event": "call.transfer_recording.completed",
"callId": "call_a1b2c3d4e5",
"agentId": "agent-uuid-or-null",
"recordingUrl": "https://cdn.example.com/zoxaAI/transfer_recordings/call_a1b2c3d4e5.mp3",
"durationSeconds": 184.0,
"telephonyProvider": "twilio",
"transfer": {
"transferId": "6f1c2a9e-4b7d-4e0a-9c3f-2d8e5b1a7c40",
"destination": "+15551234567",
"carrierCallId": "CA9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c"
}
}| Key | Type | Description |
|---|---|---|
event | string | Always "call.transfer_recording.completed" |
callId | string | The same call ID as call.started / call.completed — use this to correlate |
agentId | string | null | The agent's UUID (null for transient calls) |
recordingUrl | string | The transferred conversation's audio, re-hosted in Zoxa storage (stable CDN link when configured, presigned URL otherwise) |
durationSeconds | number | null | Recording length as reported by the carrier, when available |
telephonyProvider | string | null | Which carrier recorded the transfer (twilio, vobiz, plivo, telnyx, vonage, cloudonix, ari, smartflo) |
transfer | object | The transfer this recording belongs to — see transfer object |
transfer object
Sent on both call.transfer_recording.completed and call.transfer_recording.failed.
| Key | Type | Description |
|---|---|---|
transferId | string | null | Zoxa's id for this transfer |
destination | string | null | The 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, …) — use it to find the conversation in your carrier's console. null when the carrier doesn't report one |
When recording was disabled for the transfer, neither transfer-recording event fires.
call.transfer_recording.failed
Fires instead of call.transfer_recording.completed when the transferred conversation's recording could not be obtained. Like the completed event it arrives after call.completed, carries the same callId, and fires at most once per call.
{
"event": "call.transfer_recording.failed",
"callId": "call_a1b2c3d4e5",
"agentId": "agent-uuid-or-null",
"reason": "no_recording",
"telephonyProvider": "twilio",
"transfer": {
"transferId": "6f1c2a9e-4b7d-4e0a-9c3f-2d8e5b1a7c40",
"destination": "+15551234567",
"carrierCallId": "CA9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c"
}
}| Key | Type | Description |
|---|---|---|
event | string | Always "call.transfer_recording.failed" |
callId | string | The same call ID as the call's other events |
agentId | string | null | The agent's UUID (null for transient calls) |
reason | string | Why there is no recording — see below |
telephonyProvider | string | null | The carrier that handled the transfer |
transfer | object | See transfer object |
reason | Meaning |
|---|---|
no_recording | The carrier produced no file — usually because the transferred conversation was too short or had no audio |
download_failed | The carrier reported a recording, but it could not be downloaded after repeated retries (the last retry is about 45 minutes after the first attempt) |
storage_failed | The recording was downloaded but could not be saved to Zoxa storage |
Signature Verification
When a webhook secret is configured on the agent, every webhook request includes an X-Zoxa-Signature header containing an HMAC-SHA256 hex digest of the raw JSON body.
Verification Steps
Read the Raw Body
Read the raw request body as bytes. Do not parse JSON first.
Compute the HMAC
Compute the HMAC-SHA256 of the body using the agent's webhook_secret as the key.
Compare Digests
Compare the computed hex digest with the X-Zoxa-Signature header value using a constant-time comparison.
Python Example
import hashlib
import hmac
def verify_webhook(request_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode("utf-8"),
request_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)Node.js Example
const crypto = require('crypto');
function verifyWebhook(body, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(body)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}Header Details
| Header | Value |
|---|---|
X-Zoxa-Signature | Hex-encoded HMAC-SHA256 digest |
Content-Type | application/json |
Custom headers from the agent's webhook.headers config are applied before the signature header, so they cannot overwrite X-Zoxa-Signature.
Delivery Semantics
Fire-and-Forget
Webhooks are dispatched asynchronously. The platform does not:
- Retry failed deliveries
- Queue events for later delivery
- Wait for your server's response before continuing
If your endpoint is unreachable, returns an error, or times out (>10 seconds), the event is logged as a warning on the server and dropped.
Ordering
Within a single call, events are dispatched in order: call.started first, then call.ended, then call.completed. However, because call.completed is sent from a background worker after post-processing, there may be a delay of several seconds between call.ended and call.completed.
Network conditions can cause events to arrive out of order at your endpoint. Use the callId field to correlate events and the event field to determine the event type.
Idempotency
Each call produces exactly one of each event type. You will never receive duplicate call.started events for the same callId. If your server processes the event but crashes before acknowledging, you will not receive a retry -- design your handler to be tolerant of missing events.
Receiving Webhooks
Minimal Webhook Server (Python)
from fastapi import FastAPI, Request, Header
import hashlib, hmac
app = FastAPI()
WEBHOOK_SECRET = "your-webhook-secret"
@app.post("/webhooks/zoxa")
async def handle_webhook(
request: Request,
x_zoxa_signature: str = Header(None),
):
body = await request.body()
# Verify signature
if WEBHOOK_SECRET and x_zoxa_signature:
expected = hmac.new(
WEBHOOK_SECRET.encode(), body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, x_zoxa_signature):
return {"error": "invalid signature"}, 401
payload = await request.json()
event = payload["event"]
call_id = payload["callId"]
if event == "call.started":
print(f"Call {call_id} started via {payload['transport']}")
elif event == "call.ended":
print(f"Call {call_id} ended: {payload['endedReason']} ({payload['duration']}s)")
elif event == "call.completed":
print(f"Call {call_id} completed with {len(payload.get('transcript') or [])} messages")
if payload.get("summary"):
print(f"Summary: {payload['summary']}")
return {"ok": True}Minimal Webhook Server (Node.js)
const express = require('express');
const crypto = require('crypto');
const app = express();
const WEBHOOK_SECRET = 'your-webhook-secret';
app.post('/webhooks/zoxa', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-zoxa-signature'];
// Verify signature
if (WEBHOOK_SECRET && signature) {
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
return res.status(401).json({ error: 'invalid signature' });
}
}
const payload = JSON.parse(req.body);
console.log(`Event: ${payload.event}, Call: ${payload.callId}`);
res.json({ ok: true });
});
app.listen(3000);Best Practices
| Practice | Description |
|---|---|
| Respond quickly | Your endpoint has 10 seconds before the request times out. Offload heavy processing to a background queue. |
| Return 2xx | Any non-2xx response is logged as a warning. It does not trigger a retry, but 2xx confirms receipt. |
| Use HTTPS | The webhook URL must be reachable from the zoxaAI backend. Use HTTPS in production. |
| Validate signatures | Always verify X-Zoxa-Signature in production to ensure the request originated from zoxaAI. |
| Handle missing events | Since there are no retries, design your handler to tolerate missing events gracefully. |
Use callId for correlation | Correlate call.started, call.ended, and call.completed events using the callId field. |