zoxaAI
Homepage

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

PropertyValue
HTTP methodPOST
Content-Typeapplication/json
Timeout10 seconds
RetriesNone -- single attempt, fire-and-forget
SigningHMAC-SHA256 via X-Zoxa-Signature header (when a webhook secret is configured)
Delivery orderEvents 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.

FieldTypeDefaultNotes
webhookstring | object | nullnullBare URL string OR {url, headers} object. Setting null clears the webhook entirely. URL must start with http:// or https://.
webhook.urlstringrequired when object formHTTPS endpoint that receives webhook POST requests. ≤ 2048 chars.
webhook.headersobjectnullExtra headers sent on every webhook POST. Merged in before the signature header — so a custom X-Zoxa-Signature is overwritten.
enableSummarizationboolfalseTop-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:

EventTimingDispatched by
call.startedWhen the client connects (WebRTC/WebSocket connected)Pipeline process (sync, non-blocking)
call.endedWhen the pipeline finishes or the client disconnectsPipeline process (sync, non-blocking)
call.completedAfter post-call processing (recording upload, transcript save, optional summarization)Background task (ARQ worker)
call.transfer_recording.completedOnly 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.failedInstead 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 storedBackground 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"
}
FieldTypeDescription
eventstringAlways "call.started"
callIdstringUnique call identifier (public ID)
agentIdstring or nullAgent UUID. Null for transient agents.
transportstringTransport type: "web", "websocket", "outbound", etc.
startedAtstringISO 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"
}
FieldTypeDescription
eventstringAlways "call.ended"
callIdstringUnique call identifier
agentIdstring or nullAgent UUID
endedReasonstring or nullWhy the connected call ended (see table below). null means the call never connected — read connectionStatus for the reason.
connectionStatusstring or null"completed" when the call connected; otherwise why it never did (no_answer, busy, missing_credentials, ...) — see connectionStatus values.
durationnumberCall duration in seconds, rounded to 2 decimal places
endedAtstringISO 8601 timestamp

Ended reasons (only for connected calls — null otherwise):

ReasonDescription
"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
    }
  }
}
FieldTypeDescription
eventstringAlways "call.completed"
callIdstringUnique call identifier
agentIdstring or nullAgent UUID
endedReasonstring or nullSame values as call.ended — null when the call never connected
connectionStatusstring or nullSame semantics as call.ended
durationnumberCall duration in seconds
recordingUrlstring or nullDownload URL for the WAV recording, e.g. https://cdn.webrexstudio.com/zoxaAI/recordings/{callId}.wav. Null if recording failed or was not enabled.
transcriptarray or nullOrdered 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.
transcriptUrlstring or nullDownload 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.
summarystring or nullAI-generated 3-5 sentence summary. Only present when enableSummarization is true in the agent's webhook config.
usageobject or nullToken/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):

FieldTypeDescription
rolestring"user" or "assistant"
contentstringThe spoken text
timestampstringISO 8601 wall-clock time of the turn

Tool entries (one per tool_call_completed event):

FieldTypeDescription
rolestringAlways "tool"
namestringTool function name (e.g. "transferCall", "updateAppointment")
statusstring"success", "failed", "timeout", or "completed"
durationMsnumberWall-clock time from tool_call_started to tool_call_completed
t_msnumberOffset in milliseconds from call start
timestampstringISO 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:

KeyFieldsDescription
llmprovider, model, prompt_tokens, completion_tokensLLM token consumption
ttsprovider, model, charactersText-to-speech character count
sttprovider, model, secondsSpeech-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"
  }
}
KeyTypeDescription
eventstringAlways "call.transfer_recording.completed"
callIdstringThe same call ID as call.started / call.completed — use this to correlate
agentIdstring | nullThe agent's UUID (null for transient calls)
recordingUrlstringThe transferred conversation's audio, re-hosted in Zoxa storage (stable CDN link when configured, presigned URL otherwise)
durationSecondsnumber | nullRecording length as reported by the carrier, when available
telephonyProviderstring | nullWhich carrier recorded the transfer (twilio, vobiz, plivo, telnyx, vonage, cloudonix, ari, smartflo)
transferobjectThe transfer this recording belongs to — see transfer object

transfer object

Sent on both call.transfer_recording.completed and call.transfer_recording.failed.

KeyTypeDescription
transferIdstring | nullZoxa's id for this transfer
destinationstring | nullThe number 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, …) — 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"
  }
}
KeyTypeDescription
eventstringAlways "call.transfer_recording.failed"
callIdstringThe same call ID as the call's other events
agentIdstring | nullThe agent's UUID (null for transient calls)
reasonstringWhy there is no recording — see below
telephonyProviderstring | nullThe carrier that handled the transfer
transferobjectSee transfer object
reasonMeaning
no_recordingThe carrier produced no file — usually because the transferred conversation was too short or had no audio
download_failedThe 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_failedThe 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

HeaderValue
X-Zoxa-SignatureHex-encoded HMAC-SHA256 digest
Content-Typeapplication/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

PracticeDescription
Respond quicklyYour endpoint has 10 seconds before the request times out. Offload heavy processing to a background queue.
Return 2xxAny non-2xx response is logged as a warning. It does not trigger a retry, but 2xx confirms receipt.
Use HTTPSThe webhook URL must be reachable from the zoxaAI backend. Use HTTPS in production.
Validate signaturesAlways verify X-Zoxa-Signature in production to ensure the request originated from zoxaAI.
Handle missing eventsSince there are no retries, design your handler to tolerate missing events gracefully.
Use callId for correlationCorrelate call.started, call.ended, and call.completed events using the callId field.

On this page