zoxaAI
Homepage
API ReferenceCalls

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/call

Register 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

FieldTypeRequiredDescription
type"inbound"✓Discriminator.
callConfigobject✓Provider, the phone number to register, and the auth credentials.
agentId / agent—exactly oneSame 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—forbiddenReturns 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"
}
FieldTypeDescription
bindingIdintegerThe DB row id. Use it for GET/PATCH/DELETE on inbound-bindings.
phoneNumberstringCanonical E.164.
providerstringEcho of the provider you registered.
webhookUrlstringThe URL that's now set on the provider's number/application. Useful for cross-checking in your Twilio/Vobiz console.
statusstringAlways "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

  1. Verifies credentials via a cheap provider-side probe (Twilio account GET, Vobiz application list).
  2. Resolves agent snapshot — loads the saved agent or uses your inline agent. Stored on the binding row as agentSnapshot.
  3. Provisions the webhook on the provider:
    • Twilio → POST /IncomingPhoneNumbers/{sid} with VoiceUrl=<our webhook>.
    • Vobiz → resolves applicationId, PATCHes its answer_url, then attaches the number to that application.
  4. Upserts the binding row keyed by (organization_id, provider, phoneNumber) via Postgres ON CONFLICT DO UPDATE. If the same (provider, phoneNumber) already exists, agent + credentials are atomically swapped.
  5. 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

StatusdetailWhen
400"No organization selected"API key missing org context.
400validation 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.
401auth errorsSee Errors.
422model-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.
501not-implementedProvider'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:

  1. Inspect on the provider — Twilio console: the number's Voice URL is now https://dashboard.zoxa.ai/api/v1/telephony/run. Vobiz dashboard: the bound Application's answer_url is the same URL, and the number is attached to that Application.

  2. Place a real test call to the number from any phone. zoxaAI verifies the inbound signature, creates a calls row (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:

  1. Detects the provider from the payload + headers.
  2. Matches (provider, dialedNumber) against inbound_phone_bindings — your binding wins over any dashboard-configured route for the same number.
  3. Verifies the inbound signature using the credentials stored on the binding.
  4. Creates a new calls row tied to the binding (callSource=api_inbound, transport=inbound).
  5. 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.

On this page