Outbound dialer — end-to-end
Complete recipe — agent + outbound call + lifecycle webhook + transcript fetch.
A production-ready outbound flow. Stand up an agent once, dial customers on demand, react to lifecycle events via webhook, fetch transcript when the call ends.
This is the most common production pattern — the foundation of sales bots, appointment reminders, surveys, follow-ups.
Create the agent
curl -X POST https://dashboard.zoxa.ai/api/v1/agents \
-H "X-API-Key: zsk_..." \
-H "Content-Type: application/json" \
-d '{
"name": "Order follow-up bot",
"systemPrompt": "You are calling {{customerName}} about order {{orderId}}. Ask if it arrived on time and whether they have any feedback. Keep replies short.",
"greeting": { "firstMessages": ["Hi {{customerName}}, I'm calling about your recent order {{orderId}}. Quick question — did it arrive on time?"] },
"llm": { "provider": "openai", "model": "gpt-5.4-mini", "temperature": 0.7 },
"tts": { "provider": "elevenlabs" },
"stt": { "provider": "soniox", "interruptionMinWords": 2 },
"tools": [
{ "type": "endCall", "name": "end_call", "config": { "messageType": "custom", "customMessages": ["Thanks for your time. Have a great day!"] } }
],
"maxCallDurationS": 240,
"webhook": {
"url": "https://your.app/zoxa/webhook",
"enableSummarization": true
}
}'Note agent.uuid from the response — that's what you'll pass to POST /call.
Place an outbound call
curl -X POST https://dashboard.zoxa.ai/api/v1/call \
-H "X-API-Key: zsk_..." \
-H "Content-Type: application/json" \
-d '{
"type": "outbound",
"callConfig": {
"provider": "twilio",
"phoneNumber": "+14155550100",
"auth": { "accountSid": "AC...", "authToken": "..." }
},
"toNumber": "+14155559999",
"agentId": "<agent.uuid>",
"contextVariables": {
"customerName": "Aman",
"orderId": "ORD-42"
}
}'Response:
{ "callId": "call_4e4e571f8c9b3cf8e99d", "status": "initiated" }Handle lifecycle webhooks
zoxaAI POSTs to the agent's webhook.url as the call progresses. The event name is in the body's event field (not a header), and only X-Zoxa-Signature is set on the request. Verify every payload before trusting it. See Webhook events for full payload shapes.
import crypto from "node:crypto";
import express from "express";
const WEBHOOK_SECRET = process.env.ZOXA_WEBHOOK_SECRET; // 64-char hex; not exposed by the API
const app = express();
app.use(express.raw({ type: "application/json" })); // need raw bytes for HMAC verify
app.post("/zoxa/webhook", (req, res) => {
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(req.body) // raw bytes — don't re-serialize
.digest("hex");
const got = req.header("X-Zoxa-Signature") ?? "";
if (!crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(got, "hex"))) {
return res.status(401).end();
}
const payload = JSON.parse(req.body.toString());
switch (payload.event) {
case "call.started":
console.log(`Call ${payload.callId} started (${payload.transport})`);
break;
case "call.ended":
// Cheap, fast — terminal state known. Transcript / recording not yet ready.
console.log(`Ended: ${payload.callId} reason=${payload.endedReason} duration=${payload.duration}s`);
break;
case "call.completed":
// Final event after post-processing — includes transcript, recordingUrl, summary, usage.
console.log(`Completed: ${payload.callId}`);
console.log("Summary:", payload.summary);
console.log("Recording:", payload.recordingUrl);
// hand off to your downstream pipeline here
break;
}
res.status(200).end(); // no retries — your 2xx is final
});
app.listen(3000);import hashlib, hmac, json
from fastapi import FastAPI, Header, HTTPException, Request
WEBHOOK_SECRET = "..." # 64-char hex; not exposed by the API
app = FastAPI()
@app.post("/zoxa/webhook")
async def webhook(request: Request, x_zoxa_signature: str = Header("")):
raw = await request.body()
expected = hmac.new(WEBHOOK_SECRET.encode(), raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, x_zoxa_signature):
raise HTTPException(401)
payload = json.loads(raw)
event = payload.get("event")
if event == "call.started":
print(payload["callId"], payload["transport"])
elif event == "call.ended":
print(payload["callId"], payload["endedReason"], payload["duration"])
elif event == "call.completed":
print(payload["callId"], payload.get("summary"))
print("Recording:", payload.get("recordingUrl"))
return {"ok": True}No retries
Webhook delivery is fire-and-forget with a 10-second timeout. If your endpoint is down, the event is lost. For state you must not miss, treat webhooks as a notification and re-fetch from GET /calls/{callId} as the source of truth.
(Optional) Fetch the transcript
If you didn't enable summarization, or you need the full transcript / recording, hit GET /calls/{callId} once the call is terminal.
curl https://dashboard.zoxa.ai/api/v1/calls/call_4e4e571f8c9b3cf8e99d \
-H "X-API-Key: zsk_..." | jq '{
connectionStatus,
endedReason,
durationSeconds,
cost,
transcript: .transcript.url,
recording: .recordings[0].url,
summary: .analysis.summary,
perTurn: .turns
}'Signed URLs expire in ~1 hour. Re-fetch the call detail to get fresh URLs.
Why this shape works in production
| Concern | How this flow handles it |
|---|---|
| Customer changes order id between calls | contextVariables per request — same agent, different context. |
| Need to react to outcomes quickly | Webhook is push, fires within a second of the call ending. |
| Need to backfill history if webhook handler was down | GET /calls?call_source=api_outbound&from_date=... lists every call you placed. |
| Want to scale to thousands of calls | Wrap this in a campaign — concurrency, retry, scheduling, and circuit-breaker built in. |
Related
- Example agents — copy-pasteable agent configs
- Campaigns overview — bulk version of this flow
- Webhook events — full payload reference