Place an outbound phone call
POST /api/v1/call with type=outbound — dial out via Twilio or Vobiz using inline credentials and your agent config.
POST /api/v1/callPlace an outbound phone call. Credentials are passed inline — no dashboard pre-configuration required. The request returns immediately with a callId; the actual dial happens asynchronously and the call's lifecycle lands on the calls history row.
One request, full call
Everything the call needs — provider credentials, agent config, context variables, per-call webhook — fits in one POST body. zoxaAI stores nothing about your Twilio account; the credentials live only as long as the call.
Authentication
Standard API key required:
X-API-Key: zsk_...Request body
The request body is discriminated by type. For outbound, send type: "outbound".
| Field | Type | Required | Description |
|---|---|---|---|
type | "outbound" | ✓ | Discriminator. Selects this dispatch branch. |
callConfig | object | XOR telephonyConfigurationId | Inline provider + phone number + auth. See callConfig below. |
telephonyConfigurationId | int | XOR callConfig | Reference a saved org telephony configuration by id instead of passing inline credentials — a dashboard convenience. Provide exactly one of callConfig / telephonyConfigurationId. |
toNumber | string (E.164) | ✓ | The number to dial, e.g. "+14155551234". |
agentId | UUID | XOR agent | Use a saved agent (persistent). |
agent | object | XOR agentId | Inline transient agent — a full Agent Config; at minimum name + llm: { provider, model }. Provide exactly one of agentId / agent. |
contextVariables | {string: string} | — | Substituted into {{key}} placeholders anywhere in the resolved agent config (system prompt, greeting lines, tool descriptions, HTTP-tool URLs and headers, transfer destinations, etc.). See Variable substitution below. |
Telephony: inline credentials or a saved configuration
Supply telephony either as an inline callConfig (provider + credentials + phoneNumber, no dashboard setup) or as a telephonyConfigurationId pointing at a telephony configuration you've saved in the dashboard. Sending both is a 400. Omit both and zoxaAI dials from your organization's default outbound configuration — a 400 if you have none, or several with no clear default.
Lifecycle webhook and AI-summary toggle live inside the agent config (webhook / enableSummarization — see Webhooks). For per-call variations of a saved agent, fetch its config, tweak it, and send it inline as agent.
Strict body validation
Unknown top-level fields cause a 400. The schema uses extra="forbid" everywhere — typos surface as clean validation errors instead of being silently ignored.
callConfig
Discriminated by provider. Each provider has a different auth shape.
| Field | Type | Required | Description |
|---|---|---|---|
provider | "twilio", "vobiz", "plivo", "vonage", "telnyx", "smartflo" | ✓ | Telephony provider. |
phoneNumber | string (E.164) | ✓ | The number to dial out from. Must belong to the account in auth. |
auth | object | ✓ (except smartflo) | Provider credentials — shape varies, see below. smartflo takes no auth (credentials resolve from the saved telephony configuration that owns phoneNumber; sending auth is a 400). |
Provider support today
Twilio, Vobiz, and Smartflo are fully wired for outbound (Smartflo with no auth object — the dial routes through the saved telephony configuration that owns phoneNumber). plivo, vonage, and telnyx validate at the schema level but return 501 Not Implemented from initiate_call_stateless until their inline-credential dial paths are wired up; use the saved-config path (telephonyConfigurationId) for those providers instead.
auth shape by provider
{
"accountSid": "AC...",
"authToken": "..."
}| Field | Required | Notes |
|---|---|---|
accountSid | ✓ | Twilio Account SID (AC prefix). |
authToken | ✓ | Twilio Auth Token from console. |
{
"authId": "MA_...",
"authToken": "...",
"applicationId": "30378500269436896"
}| Field | Required | Notes |
|---|---|---|
authId | ✓ | Vobiz Account ID. |
authToken | ✓ | Vobiz Auth Token. |
applicationId | — | Optional. Vobiz routes inbound calls via Applications; for outbound this field is unused. Required for type=inbound. |
{ "authId": "MA...", "authToken": "..." }Outbound not yet wired — returns 501 Not Implemented.
{
"apiKey": "...",
"apiSecret": "...",
"applicationId": "...",
"privateKey": "..."
}Outbound not yet wired — returns 501 Not Implemented.
{ "apiKey": "KEY...", "connectionId": "..." }Outbound not yet wired — returns 501 Not Implemented.
{ "provider": "smartflo", "phoneNumber": "+91..." }No auth object — Tata Smartflo is streaming-native, so credentials resolve server-side from the saved telephony configuration that owns phoneNumber (sending auth is a 400). The dial routes through that configuration, and the number must already be added — and active — under one of your Smartflo telephony configurations (otherwise 400 phone_number_not_configured). See Tata Smartflo setup.
Response
Returns HTTP 201 Created.
{
"callId": "call_4e4e571f8c9b3cf8e99d",
"status": "initiated"
}| Field | Type | Description |
|---|---|---|
callId | string | zoxaAI's internal call identifier. Use it with GET /calls/{callId} to fetch the full lifecycle. |
status | string | Always "initiated" on success. The terminal state lands on the calls row via provider callbacks — query history for the real outcome. |
callId is stable for the lifetime of the call and the history row. The provider's own call SID (Twilio CallSid, Vobiz call_uuid) gets stamped onto providerCallSid once the provider accepts the dial.
Examples
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": "550e8400-e29b-41d4-a716-446655440000",
"contextVariables": { "customerName": "Aman" }
}'const res = await fetch("https://dashboard.zoxa.ai/api/v1/call", {
method: "POST",
headers: {
"X-API-Key": "zsk_...",
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "outbound",
callConfig: {
provider: "twilio",
phoneNumber: "+14155550100",
auth: { accountSid: "AC...", authToken: "..." },
},
toNumber: "+14155559999",
agentId: "550e8400-e29b-41d4-a716-446655440000",
contextVariables: { customerName: "Aman" },
}),
});
if (!res.ok) {
console.error(res.status, await res.json());
} else {
const { callId } = await res.json();
console.log("Call placed:", callId);
}import httpx
resp = httpx.post(
"https://dashboard.zoxa.ai/api/v1/call",
headers={"X-API-Key": "zsk_..."},
json={
"type": "outbound",
"callConfig": {
"provider": "twilio",
"phoneNumber": "+14155550100",
"auth": {"accountSid": "AC...", "authToken": "..."},
},
"toNumber": "+14155559999",
"agentId": "550e8400-e29b-41d4-a716-446655440000",
"contextVariables": {"customerName": "Aman"},
},
timeout=30,
)
resp.raise_for_status()
print("Call placed:", resp.json()["callId"])With an inline transient agent (no saved record)
Models that are no longer offered
If an inline agent names an llm, stt or tts provider or model that isn't in the model catalog, the call still runs: that section switches to the platform default — OpenAI gpt-4.1, Soniox stt-rt-v5, ElevenLabs eleven_v4_turbo — or, when the default doesn't support the agent's languages, the first model that does. A model that's gone from one provider switches to that provider's default model and keeps your voice. Every other field is still validated strictly. Compare requestSnapshot with configSnapshot on GET /calls/{callId} to see what ran.
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": "vobiz",
"phoneNumber": "+917971542879",
"auth": { "authId": "MA_...", "authToken": "..." }
},
"toNumber": "+919755857161",
"agent": {
"name": "Support bot",
"systemPrompt": "You are a friendly support agent. Keep replies short.",
"languages": ["en"],
"greeting": { "firstMessages": ["Hi! How can I help today?"] },
"llm": { "provider": "openai", "model": "gpt-5.4-mini" },
"tts": { "provider": "elevenlabs" },
"stt": { "provider": "soniox", "interruptionMinWords": 2 }
}
}'await fetch("https://dashboard.zoxa.ai/api/v1/call", {
method: "POST",
headers: {
"X-API-Key": "zsk_...",
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "outbound",
callConfig: {
provider: "vobiz",
phoneNumber: "+917971542879",
auth: { authId: "MA_...", authToken: "..." },
},
toNumber: "+919755857161",
agent: {
name: "Support bot",
systemPrompt: "You are a friendly support agent. Keep replies short.",
languages: ["en"],
greeting: { firstMessages: ["Hi! How can I help today?"] },
llm: { provider: "openai", model: "gpt-5.4-mini" },
tts: { provider: "elevenlabs" },
stt: { provider: "soniox", interruptionMinWords: 2 },
},
}),
});import httpx
httpx.post(
"https://dashboard.zoxa.ai/api/v1/call",
headers={"X-API-Key": "zsk_..."},
json={
"type": "outbound",
"callConfig": {
"provider": "vobiz",
"phoneNumber": "+917971542879",
"auth": {"authId": "MA_...", "authToken": "..."},
},
"toNumber": "+919755857161",
"agent": {
"name": "Support bot",
"systemPrompt": "You are a friendly support agent. Keep replies short.",
"languages": ["en"],
"greeting": {"firstMessages": ["Hi! How can I help today?"]},
"llm": {"provider": "openai", "model": "gpt-5.4-mini"},
"tts": {"provider": "elevenlabs"},
"stt": {"provider": "soniox", "interruptionMinWords": 2},
},
},
timeout=30,
)Varying a saved agent for one call
There is no per-call override field — a call runs either a saved agent as-is (agentId) or a full inline config (agent). For most per-call variation, contextVariables is the right tool: keep {{placeholders}} in the saved prompt and greeting, and fill them per call.
When you genuinely need different config (say, another voice) for one call, fetch the saved config, tweak it, and send it inline:
const { config } = await fetch(`https://dashboard.zoxa.ai/api/v1/agents/${agentUuid}`, {
headers: { "X-API-Key": "zsk_..." },
}).then((r) => r.json());
config.greeting.firstMessages = ["Hi Aman — calling about your appointment."];
config.llm.temperature = 0.7;
await fetch("https://dashboard.zoxa.ai/api/v1/call", {
method: "POST",
headers: { "X-API-Key": "zsk_...", "Content-Type": "application/json" },
body: JSON.stringify({ type: "outbound", callConfig, toNumber: "+14155559999", agent: config }),
});The saved agent is untouched; the call's history row snapshots exactly what ran.
Using a saved telephony configuration
Instead of inline credentials, reference a telephony configuration saved in the dashboard by its id:
{
"type": "outbound",
"telephonyConfigurationId": 12,
"toNumber": "+14155559999",
"agentId": "550e8400-e29b-41d4-a716-446655440000"
}The saved configuration supplies the provider, credentials, and outbound phoneNumber. Send telephonyConfigurationId or callConfig — not both.
Errors
| Status | Body detail | When |
|---|---|---|
400 | "No organization selected" | API key has no active org (rare — only happens for keys created without an org context). |
404 | "agent_not_found" | agentId doesn't resolve to an agent in your organization. |
400 | "invalid_credentials: <reason>" | Provider rejected the credentials. The <reason> is the upstream message from the provider's auth probe. |
400 | validation object ({ error, details }) — e.g. "'toNumber' is required when type='outbound'" in details | Schema or cross-field validation failed: missing toNumber, a mode rule like "Must provide either 'agentId' or 'agent'.", an unknown field, or a wrong type. See error response shapes below. |
401 | "Authorization header required" / "Invalid or expired API key" | Auth header missing or key revoked. |
402 | quota-exceeded message | Organization is over its monthly minutes/calls quota. |
400 | {"code": "provider_rejected", "provider": "vobiz", "providerStatus": 429, "callId": "call_...", "message": "..."} | Your telephony provider's API rejected the dial with a 4xx — e.g. your own Vobiz account hit its rate/concurrency limit, bad token, bad destination. Account/request-side: retrying won't help until you fix it. Distinct from our 429 below. callId points at the history row (connectionStatus: dial_failed) whose logs.dial_error has the full provider message. |
429 | {"code": "concurrency_limit_reached", "current_limit": N, "current_active": M, "message": "..."} | Your org's concurrent-call limit is full on zoxaAI's side. The attempt is persisted as a rejected call in history (connectionStatus: concurrency_limit_reached, endedReason: null; throttled to one row per reason per minute under retry storms). Log detail.current_active/current_limit — not just the status code. |
501 | "<provider>.initiate_call_stateless not implemented" | The chosen provider's outbound path isn't wired yet. |
502 | same provider_rejected shape as the 400 row | Your telephony provider's API failed with a 5xx (or returned an unusable response) — upstream outage, retry with backoff. The call lands in history with connectionStatus: dial_failed. |
The provider may still fail the call after the 201 (number busy, no answer, carrier reject). Those land asynchronously on the calls row as connectionStatus via the provider's status callback — query GET /calls/{callId} for the final outcome.
Error response shapes
Schema and cross-field validation failures return 400. The detail is an object: a top-level error summary plus a flat details list, one entry per failed field (path is the dot-joined field location, type is the Pydantic error code):
{
"detail": {
"error": "Agent configuration validation failed",
"details": [
{
"path": "phone_number",
"message": "Extra inputs are not permitted",
"type": "extra_forbidden"
},
{
"path": "agentId",
"message": "Input should be a valid UUID, version 4",
"type": "uuid_parsing"
}
]
}
}Every other error is a single string under detail — including the 422 off-catalog-model error:
{ "detail": "invalid_credentials: authentication failed" }The provider_rejected (400/502) and concurrency_limit_reached (429) errors carry their own structured objects under detail — see those rows in the table above.
What zoxaAI does after the 201
- Writes the calls row with
transport=outbound,callSource=api_outbound,status=pending, and the resolved agent snapshot. The raw POST body is stored undertelephony_snapshot._rawRequest(withcallConfig.authstripped) for audit. - Builds two provider URLs:
- Answer URL —
https://dashboard.zoxa.ai/api/v1/telephony/run?call_id={callId}. The provider POSTs here when the callee answers; the response is TwiML/NCCO that opens a media stream to our agent. - Status URL —
https://dashboard.zoxa.ai/api/v1/telephony/{provider}/status/by-call/{callId}. The provider POSTs lifecycle events here (initiated → ringing → answered → completed, or busy/no-answer/failed).
- Answer URL —
- Issues the dial to the provider with both URLs attached and the inline credentials.
- If the provider rejects the dial (e.g. invalid number format), the calls row is marked
errorwithconnectionStatus="dial_failed"(endedReasonstaysnull— the call never connected) and a500-class error is surfaced — though most dial failures happen async and arrive via the status URL.
Lifecycle states
The calls row transitions through these status values:
status | When | Terminal? |
|---|---|---|
pending | Row written; provider not yet asked to dial, or dialing but no callback yet. | no |
active | Pipeline is bridged and audio is flowing. connectionStatus is completed from this point. | no |
completed | Call connected and ended cleanly. connectionStatus is completed; endedReason says how the conversation ended (user_hangup, agent_hangup, max_duration, ...). | yes |
error | Call failed. Either it never connected — connectionStatus carries the reason (busy, no_answer, rejected, connection_failed, dial_failed, cancelled, no_participant, missing_credentials, ...) and endedReason is null — or it connected and the pipeline crashed (connectionStatus: completed, endedReason: pipeline_error). See connectionStatus values. | yes |
A typical successful outbound transitions pending → active → completed. A no-answer transitions pending → error with connectionStatus: no_answer (no active because the call never connected).
Polling for completion
There's no synchronous wait endpoint — poll GET /calls/{callId} until status is completed or error.
CALL_ID=$(curl -s -X POST https://dashboard.zoxa.ai/api/v1/call \
-H "X-API-Key: zsk_..." \
-H "Content-Type: application/json" \
-d '{ /* ... */ }' | jq -r .callId)
while true; do
STATUS=$(curl -s "https://dashboard.zoxa.ai/api/v1/calls/$CALL_ID" \
-H "X-API-Key: zsk_..." | jq -r .status)
echo "status=$STATUS"
[[ "$STATUS" == "completed" || "$STATUS" == "error" ]] && break
sleep 5
doneasync function pollForCompletion(callId, intervalMs = 5000, timeoutMs = 600_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(`https://dashboard.zoxa.ai/api/v1/calls/${callId}`, {
headers: { "X-API-Key": "zsk_..." },
});
const call = await res.json();
if (call.status === "completed" || call.status === "error") return call;
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error("Timed out waiting for call completion");
}import time, httpx
def poll_for_completion(call_id: str, interval_s: float = 5, timeout_s: float = 600):
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
call = httpx.get(
f"https://dashboard.zoxa.ai/api/v1/calls/{call_id}",
headers={"X-API-Key": "zsk_..."},
).json()
if call["status"] in ("completed", "error"):
return call
time.sleep(interval_s)
raise TimeoutError("Call did not complete in time")Webhooks are better than polling
Set agent.webhook on the agent and zoxaAI POSTs call.ended to you the moment the call terminates — no polling required. See Webhook events.
Idempotency
POST /call with type=outbound is not idempotent. Each request places a new dial. If you need exactly-once semantics, dedupe on your side with a client-generated request key and a database constraint before calling this endpoint.
Variable substitution
{{key}} placeholders are substituted at runtime (when the call connects), not at request time. The stored config_snapshot keeps the raw placeholders so the saved snapshot mirrors your API payload 1:1 — useful for debugging in the call-history UI.
Scope: recursive across every string field in the snapshot. Covered surfaces include systemPrompt, greeting.firstMessages, every inline tool's description, HTTP-tool server.url / server.headers (values) / request body, query-tool KB names/descriptions, endCall customMessages, transferCall destination and customMessage, and webhook.url. Single-pass — values containing their own {{...}} are not re-expanded.
Sources, in precedence order (lowest → highest)
-
The agent's saved
contextVariables— defaults. An explicitly-saved blank renders empty (it never leaks the literal{{token}}). -
This request's
contextVariables— override a saved default only when non-blank; sending""falls back to the saved default. -
System built-ins — always win and can never be shadowed:
Variable Outbound calls Inbound calls Web / WebSocket {{user_number}}toNumber(the human being dialed)the caller's number empty string {{agent_number}}callConfig.phoneNumber(your DID)your DID empty string {{current_time}}/{{current_date}}/{{current_day}}/{{current_timezone}}resolved against the config's timezonesame same The intuition:
user_numberis always the end-user's phone, regardless of who initiated the call;agent_numberis the platform-side DID.
Placeholders for keys in no source are left as-is in the string — deliberate, so a typo doesn't silently blank a URL. Key matching is case- and space-insensitive: customerName, customername, and customer name all fill {{customerName}}.
Example
{
"type": "outbound",
"callConfig": { "provider": "twilio", "auth": { "...": "..." }, "phoneNumber": "+19014463176" },
"toNumber": "+14155551234",
"agent": {
"name": "Order lookup bot",
"systemPrompt": "You're calling {{customerName}}.",
"llm": { "provider": "openai", "model": "gpt-5.4-mini" },
"tools": [{
"type": "function",
"name": "lookup_order",
"description": "Look up the caller's open orders.",
"config": {
"server": {
"url": "https://api.acme.com/orders?phone={{user_number}}",
"method": "GET"
}
}
}]
},
"contextVariables": { "customerName": "Aman" }
}At runtime: {{customerName}} resolves to "Aman" (from contextVariables); {{user_number}} resolves to "+14155551234" (the dialed human on this outbound call). The HTTP tool's URL is rewritten before the request goes out.
Related
POST /call(inbound) — register a number for inbound routingGET /calls/{callId}— fetch call lifecycle, transcript, recordings- Agent Config schema — full inline-agent shape
- Webhook Events —
agent.webhooklifecycle payload
Calls Overview
How POST /api/v1/call dispatches between outbound phone, inbound phone, WebSocket, and WebRTC modes — plus the full list of call-related endpoints.
Register an inbound phone number
POST /api/v1/call with type=inbound — bind a phone number to an agent. Last-write-wins upsert; idempotent re-registration.