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.
POST /api/v1/callRegister a phone number for inbound routing. zoxaAI verifies the provider credentials, provisions our webhook on the provider's side, and persists an inbound_phone_bindings row tied to your agent. From this moment, every call to that number is bridged to the agent.
Last-write-wins
Re-POSTing the same (provider, phoneNumber) overwrites the binding — same agent, different agent, different credentials, all swap atomically. There is no separate "update binding" call; just POST again with the new desired state.
Authentication
X-API-Key: zsk_...Request body
| Field | Type | Required | Description |
|---|---|---|---|
type | "inbound" | ✓ | Discriminator. |
callConfig | object | ✓ | Provider, the phone number to register, and the auth credentials. |
agentId / agent | — | exactly one | Same agent-selection rules as outbound: a saved agent by UUID, or a full inline Agent Config. |
contextVariables | {string: string} | — | Substituted into {{key}} placeholders anywhere in the agent config when each inbound call arrives. The same values are reused for every inbound call to this number until you re-register. The platform also auto-fills {{user_number}} (the caller dialing in) and {{agent_number}} (your bound DID) at runtime — see Variable substitution. |
toNumber | — | forbidden | Returns 400. The dialed number IS callConfig.phoneNumber. |
The lifecycle webhook lives inside the agent config's webhook field. See Webhooks for the shape.
callConfig shape
Same discriminated structure as outbound. The phoneNumber here is the inbound DID you want to bind.
{
"provider": "twilio",
"phoneNumber": "+14155550100",
"auth": { "accountSid": "AC...", "authToken": "..." }
}The number must already belong to the Twilio account in auth. zoxaAI looks it up by E.164, then PATCHes the IncomingPhoneNumber.VoiceUrl to point at our webhook.
{
"provider": "vobiz",
"phoneNumber": "+917971542879",
"auth": {
"authId": "MA_...",
"authToken": "...",
"applicationId": "30378500269436896"
}
}Vobiz routes inbound calls per Application, not per phone number. If applicationId is omitted, zoxaAI resolves the account's default_app. The resolved value is persisted back into the binding so DELETE later targets exactly the app that was modified.
{
"provider": "smartflo",
"phoneNumber": "+919000000000"
}No auth field — sending one returns a 400 (Extra inputs are not permitted). Tata Smartflo is streaming-native: credentials are resolved server-side from the saved telephony configuration that owns this number. Prerequisite: the number must already be added — and active — under one of your org's Smartflo configs, and the config's voice_bot_wss_url must be set in the Tata portal. Otherwise you get 400 phone_number_not_configured. See Tata Smartflo setup.
Response
Returns HTTP 201 Created.
{
"bindingId": 5,
"phoneNumber": "+917971542879",
"provider": "vobiz",
"webhookUrl": "https://dashboard.zoxa.ai/api/v1/telephony/run",
"status": "registered"
}| Field | Type | Description |
|---|---|---|
bindingId | integer | The DB row id. Use it for GET/PATCH/DELETE on inbound-bindings. |
phoneNumber | string | Canonical E.164. |
provider | string | Echo of the provider you registered. |
webhookUrl | string | The URL that's now set on the provider's number/application. Useful for cross-checking in your Twilio/Vobiz console. |
status | string | Always "registered" on success. |
Examples
curl -X POST https://dashboard.zoxa.ai/api/v1/call \
-H "X-API-Key: zsk_..." \
-H "Content-Type: application/json" \
-d '{
"type": "inbound",
"callConfig": {
"provider": "twilio",
"phoneNumber": "+14155550100",
"auth": { "accountSid": "AC...", "authToken": "..." }
},
"agentId": "550e8400-e29b-41d4-a716-446655440000"
}'await fetch("https://dashboard.zoxa.ai/api/v1/call", {
method: "POST",
headers: {
"X-API-Key": "zsk_...",
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "inbound",
callConfig: {
provider: "twilio",
phoneNumber: "+14155550100",
auth: { accountSid: "AC...", authToken: "..." },
},
agentId: "550e8400-e29b-41d4-a716-446655440000",
}),
});import httpx
httpx.post(
"https://dashboard.zoxa.ai/api/v1/call",
headers={"X-API-Key": "zsk_..."},
json={
"type": "inbound",
"callConfig": {
"provider": "twilio",
"phoneNumber": "+14155550100",
"auth": {"accountSid": "AC...", "authToken": "..."},
},
"agentId": "550e8400-e29b-41d4-a716-446655440000",
},
)What zoxaAI does on register
- Verifies credentials via a cheap provider-side probe (Twilio account GET, Vobiz application list).
- Resolves agent snapshot — loads the saved agent or uses your inline
agent. Stored on the binding row asagentSnapshot. - Provisions the webhook on the provider:
- Twilio →
POST /IncomingPhoneNumbers/{sid}withVoiceUrl=<our webhook>. - Vobiz → resolves
applicationId, PATCHes itsanswer_url, then attaches the number to that application.
- Twilio →
- Upserts the binding row keyed by
(organization_id, provider, phoneNumber)via PostgresON CONFLICT DO UPDATE. If the same(provider, phoneNumber)already exists, agent + credentials are atomically swapped. - On DB failure after the provider call succeeded, zoxaAI deprovisions the webhook so the provider isn't left pointing at a dead binding.
Idempotency
POST /call with type=inbound is fully idempotent on (provider, phoneNumber). Safe to retry on network failure. Safe to use as your "update agent on this number" call instead of PATCH /inbound-bindings/{id} — though the dedicated PATCH is cheaper because it skips re-provisioning.
Partial re-POST overwrites
Because this is last-write-wins, a partial re-POST that omits fields will revert those fields to schema defaults. Always send the full intended agent config when re-registering — not a diff.
Errors
| Status | detail | When |
|---|---|---|
400 | "No organization selected" | API key missing org context. |
400 | validation object ({ error, details }) | Schema or cross-field validation failed — e.g. you sent toNumber ("'toNumber' is not allowed when type='inbound'"), you set both agentId and agent (or neither), omitted callConfig, or sent an unknown field. See error response shapes below. |
400 | "invalid_credentials: <reason>" | Provider rejected the auth probe. |
400 | "Phone number +1... is not owned by this Twilio account." | Number isn't in the credentials' account. |
400 | "No applicationId supplied and no default_app found..." | Vobiz only — no app to attach the number to. |
400 | "phone_number_not_configured: ..." | Smartflo only — the number isn't added (and active) under any of your org's Smartflo telephony configurations. |
400 | "smartflo_config_missing_token: ..." | Smartflo only — the owning configuration has no auth token. |
401 | auth errors | See Errors. |
422 | model-catalog message (string) | The agent config references an off-catalog llm/tts/stt model id. Registering an inbound number saves the config, so it's validated strictly — unlike an outbound call, nothing is swapped for a default. |
501 | not-implemented | Provider's provision_inbound_webhook isn't wired. The attempt is persisted in call history as a rejected call (connectionStatus: provider_not_supported). |
Error response shapes
Schema and cross-field validation failures return 400 with an object under detail — a top-level error summary plus a flat details list, one entry per failed field:
{
"detail": {
"error": "Agent configuration validation failed",
"details": [
{
"path": "toNumber",
"message": "Value error, 'toNumber' is not allowed when type='inbound'",
"type": "value_error"
}
]
}
}Every other error — invalid credentials, unowned number, the off-catalog-model 422 — is a single string under detail:
{ "detail": "invalid_credentials: authentication failed" }Verifying the registration
Once you get a 201, the binding is live immediately. Two quick ways to verify:
-
Inspect on the provider — Twilio console: the number's
Voice URLis nowhttps://dashboard.zoxa.ai/api/v1/telephony/run. Vobiz dashboard: the bound Application'sanswer_urlis the same URL, and the number is attached to that Application. -
Place a real test call to the number from any phone. zoxaAI verifies the inbound signature, creates a
callsrow (callSource=api_inbound), and bridges to the agent. Confirm by listing recent inbound calls:curl "https://dashboard.zoxa.ai/api/v1/calls?call_source=api_inbound&per_page=5" \ -H "X-API-Key: zsk_..."
What lands when someone actually calls the number
For webhook providers (Twilio, Vobiz), the provider POSTs to https://dashboard.zoxa.ai/api/v1/telephony/run. zoxaAI:
- Detects the provider from the payload + headers.
- Matches
(provider, dialedNumber)againstinbound_phone_bindings— your binding wins over any dashboard-configured route for the same number. - Verifies the inbound signature using the credentials stored on the binding.
- Creates a new
callsrow tied to the binding (callSource=api_inbound,transport=inbound). - Returns provider XML/NCCO that opens a media stream to our agent WS.
For Smartflo (streaming-native, no HTTP webhook), the media stream arrives directly at the config's signed WSS URL; zoxaAI matches the dialed DID against your binding (format-tolerant — with or without +/country code) and runs the binding's agent snapshot. Same precedence: binding wins over the number's dashboard agent attachment.
The calls row is independent of the binding row — you can delete the binding and historical call rows survive.
Related
GET /telephony/inbound/bindings— list registered numbersPATCH /telephony/inbound/bindings/{id}— change agent without re-provisioningDELETE /telephony/inbound/bindings/{id}— unregister + deprovisionPOST /call(outbound) — symmetric outbound flow
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.
Create a WebSocket call
POST /api/v1/call (no type) with transport=websocket — create a call backed by a raw-PCM WebSocket. Two-step flow — create the call, then connect /ws/audio/{callId}.