Telephony
Connecting a provider, mapping human teammates to phone numbers, and the callback endpoints the provider itself calls.
Providers
Plivo and Exotel are supported. Plivo is the fuller integration — outbound campaigns, recording, and transfer all use Plivo's REST API.
List connected providers
GET /api/voice/integrations/telephony
{
"providers": [
{
"provider": "plivo",
"is_connected": true,
"connected_at": "2026-09-02T10:11:12Z",
"phone_number": "+915550010000",
"auth_id": "MAXXXXXXXXXXXXXXXXXX"
}
]
}
Only non-secret fields are returned. Auth tokens never leave the database, even for an admin — the console shows whether a provider is connected, not what it is connected with.
Connect a provider
PUT /api/voice/integrations/telephony/{provider} — admin only
{provider} must be plivo or exotel.
{
"auth_id": "MAXXXXXXXXXXXXXXXXXX",
"auth_token": "your-plivo-auth-token",
"phone_number": "+915550010000"
}
{ "provider": "plivo", "is_connected": true }
The body is stored as given, so provider-specific fields can be added without
an API change. phone_number must be valid E.164 — a campaign aborts at
startup if it is not.
Re-sending replaces the stored credentials.
Disconnect
DELETE /api/voice/integrations/telephony/{provider} — admin only
Human agents
Mapping teammates to phone numbers, so calls can be placed from — and handed to — a specific person.
Phone numbers
GET /api/voice/agents/phone-numbers
{ "phone_numbers": [] }
PUT /api/voice/agents/phone-numbers/{user_uuid} — admin only
{ "phone_number": "+919876543210" }
DELETE /api/voice/agents/phone-numbers/{user_uuid} — admin only
This number is the leg that rings first on a click-to-call: the teammate answers, then the customer is dialled.
Tag assignments
GET /api/voice/agents/tag-assignments
{ "tag_assignments": [] }
GET /api/voice/agents/tag-assignments/me — the caller's own
PUT /api/voice/agents/tag-assignments/{user_uuid} — admin only
{ "tags": ["north", "winback"] }
DELETE /api/voice/agents/tag-assignments/{user_uuid} — admin only
Tag assignments do double duty: they route handoffs to the right person, and
they restrict what that person can see. A teammate assigned north sees only
customers carrying that tag, and only calls belonging to those customers. A
teammate with no assignment sees everything.
Click-to-call
POST /api/voice/customers/{uuid}/calls
Dials the customer from the caller's own mapped number. This is a human call — no AI agent is involved — but it is still recorded and transcribed, and it appears in the call list with the teammate's name.
Outcomes specific to these calls: agent_answered, agent_no_answer,
customer_no_answer, cancelled.
Provider callbacks
These are called by the provider, not by you. They are unauthenticated by necessity and act only on calls that already exist in your database.
| Endpoint | Purpose |
|---|---|
ANY /api/voice/webhooks/plivo/answer | Inbound answer — returns the stream XML |
ANY /api/voice/webhooks/plivo/answer/inbound | Same handler, legacy path |
ANY /api/voice/webhooks/plivo/answer/outbound | Campaign answer |
ANY /api/voice/webhooks/plivo/hangup | Records how a call ended |
ANY /api/voice/webhooks/plivo/status | Terminal call state |
ANY /api/voice/webhooks/plivo/recording | Where the recording landed |
ANY /api/voice/webhooks/plivo/handoff | Returns the transfer XML |
ANY /api/voice/webhooks/plivo/stream-status | The provider's verdict on the media stream |
ANY /api/voice/webhooks/plivo/click-to-call/answer/:call_uuid | Human call, agent leg answered |
ANY /api/voice/webhooks/plivo/click-to-call/status/:call_uuid | Human call, final state |
ANY /api/voice/webhooks/exotel/answer | Inbound answer |
ANY /api/voice/webhooks/exotel/status | Call status |
Both GET and POST are accepted because providers differ and some retry with
the other method.
Pointing a provider at them
Configure the Plivo application with:
Answer URL : https://your-host/api/voice/webhooks/plivo/answer (POST)
Hangup URL : https://your-host/api/voice/webhooks/plivo/hangup (POST)
Outbound campaign calls set their own answer and hangup URLs at dial time; you do not configure those.
Why an inbound call might not be answered
The answer webhook looks up an active inbound flow whose number matches the one dialled. No match returns hangup XML rather than answering in the wrong company's voice:
[plivo] no active inbound flow for +915550010000 — hanging up
Check that the flow is active, not draft, and that its phone_number
matches on its last ten digits.
The media socket address
The WebSocket URL handed to the provider is derived from the request's own
Host header, not from PUBLIC_BASE_URL — the provider reached you on that
host, so it is reachable by definition. A configured base URL can be stale
after an ngrok restart, or carry stray whitespace that makes the provider
reject the whole XML document as invalid, which looks like a working call with
no transcript.
PUBLIC_BASE_URL remains the fallback, and is what outbound campaigns use to
build their callback URLs — which is why a campaign refuses to start without it.