Telephony
Where calls arrive from and leave through: numbers bought through a managed carrier account, numbers you already own, the accounts behind them and the routes that decide which trunk a call leaves by.
For your own carrier trunk, see SIP trunking.
What each carrier can do
Nine provider ids are declared. Six have an adapter; zadarma, didlogic and chime throw a 501 naming themselves and telling you to bring the DID over a SIP trunk instead.
Every adapter declares its own capability matrix, and they differ enormously. Read the matrix, not the name. GET /telephony/providers returns the live version.
| Twilio | Telnyx | Plivo | DIDWW | BYO SIP | Custom SIP | |
|---|---|---|---|---|---|---|
| Search available numbers | ✅ | ✅ | ✅ | ✖ | ✖ | ✖ |
| Purchase / release | ✅ | ✅ | ✅ | ✖ | ✖ | ✖ |
| List account numbers | ✅ | ✅ | ✅ | ✅ | ✖ | ◐ |
| Configure number webhooks | ✅ | ✅ | ✅ | ✖ | — | — |
| Inbound voice | ✅ | ✅ | ✖ | ✅¹ | ✅ | ✅ |
| Outbound voice | ✅ | ✅ | ✅ | ✖ | ✅ | ✅ |
| Media streams (reaching the AI pipeline) | ✅ | ⚠️² | ✖ | — | ✅³ | ✅³ |
| Recording | ✅ | ✅ | ✖ | — | ✅ | ✅ |
| Dual-channel recording | ✅ | ✅ | ✖ | — | ✅ | ✅ |
| Transfer | ✅ | ✅ | ✖ | ✖ | ✅ | ✅ |
| DTMF | ✅ | ✅ | ✅ | ✖ | ✅ | ✅ |
| Answering-machine detection | ✅ | ◐⁴ | ✖ | ✖ | ✖ | ✖ |
| SMS / MMS | ✅ | ✅ | ✅ (SMS) | ✖ | ✖ | ✖ |
| Webhook signature | ✅ | ✅ | ✅ | ✖ | ✅ | ✅ |
| SIP trunking | ◐⁵ | ✅ | — | — | ✅ | ✅ |
✅ implemented · ◐ partial · ✖ not implemented · — not applicable
- Carried over a CallAgent SIP trunk, not over DIDWW's own API.
- Telnyx declares
media_streamsas implemented and nothing in the codebase builds a Telnyx media-stream instruction.placeOutboundCall()posts a Call-Control webhook URL and stops. A Telnyx call will not reach an assistant today. This is a declared capability that disagrees with the runtime — treat the runtime as authoritative. - Declared implemented, and blocked in practice by the same media handoff that blocks SIP. See SIP and What is not built.
- Telnyx exposes AMD only on Call Control legs started with
answering_machine_detectionset at dial time, never retroactively. - Twilio Elastic SIP Trunking is configured out of band, not through this API.
The practical summary
Twilio is the only carrier proven to carry a call into the AI pipeline. Everything else either declares a capability it cannot deliver end to end, or does not declare it.
DIDWW's own comment explains why its buy path is absent rather than stubbed: a DIDWW order is a multi-step flow (country → city → DID group → order → capacity pool) that needs product decisions nobody has made, and the interface must not offer a Buy button that half-works.
Telephony accounts and credentials
An account binds a provider credential to an organization. Credentials are encrypted with a key separate from APP_KEY (CredentialCipher, decision D-004), so rotating the application key does not touch tenant provider secrets.
GET/POST /api/v1/telephony/accounts
GET/PATCH/DELETE /api/v1/telephony/accounts/{id}
POST /api/v1/telephony/accounts/{id}/health
GET /api/v1/telephony/providersTwilio's credential is account_sid + auth_token; Telnyx's and DIDWW's are api_key. See Quickstart step 2.
Numbers
This API is camelCase
The telephony endpoints take camelCase field names (friendlyName, destinationType, sipTrunkId). The assistants, tools and calls endpoints take snake_case. This is an inconsistency in the product; the pages here show whichever the endpoint actually accepts.
Register a number you already own
curl -X POST "$CALLAGENT_API_URL/telephony/phone-numbers" \
-H "Authorization: Bearer $CALLAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"number": "+40312345901",
"friendlyName": "Support",
"assistantId": "01JQ…",
"capabilities": ["voice"]
}'capabilities entries are voice, sms, mms, fax. The row is created with provider: "byo_sip", and giving assistantId (or squadId) sets destination_type and destination_id for you.
POST /telephony/phone-numbers/import is the bulk version: it lists every number on your provider account and creates the rows.
Buy one
curl -X POST "$CALLAGENT_API_URL/telephony/phone-numbers/search" \
-H "Authorization: Bearer $CALLAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "provider": "twilio", "country": "RO", "type": "local", "limit": 20 }'provider and a two-letter country are required. type is local, mobile, toll_free or national and defaults to local. Then:
curl -X POST "$CALLAGENT_API_URL/telephony/phone-numbers/purchase" \
-H "Authorization: Bearer $CALLAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "provider": "twilio", "number": "+40312345678", "friendlyName": "Support" }'A purchase is three steps: buy at the carrier, insert the row, push webhooks. If the webhook step fails the number is not rolled back — you own it, so the row stays, with webhooks_in_sync: false and the failure appended to notes. Re-run configure.
Configure and release
POST /api/v1/telephony/phone-numbers/{id}/configure push webhook URLs to the carrier
POST /api/v1/telephony/phone-numbers/{id}/release release at the carrier
DELETE /api/v1/telephony/phone-numbers/{id} soft-delete the row onlyconfigure takes no request body. It answers { "configured": true, "webhooks_in_sync": true } or a 502. It does not choose a destination — that is PATCH.
Point a number somewhere
curl -X PATCH "$CALLAGENT_API_URL/telephony/phone-numbers/$NUMBER_ID" \
-H "Authorization: Bearer $CALLAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"destinationType": "assistant",
"destinationId": "01JQ…",
"recordingMode": "dual_channel",
"maxConcurrentCalls": 10
}'Destination types, all ten:
| Type | Needs |
|---|---|
assistant, squad, queue, ivr, ring_group, extension, voicemail | destinationId |
external, sip_uri | destinationValue |
hook | hookUrl and the hook fields below |
Other PATCH fields worth knowing: inboundEnabled, outboundEnabled, outboundCallerIdNumber / Name, smsEnabled, smsAssistantId, recordingMode (disabled · mono · dual_channel · inherit), recordingConsentRequired, callerIdPolicyId, status (pending · active · suspended · released · error), metadata.
hookSecret is never assigned through the ordinary path — the column holds ciphertext, and writing a raw secret to the attribute would store it in the clear.
How an inbound call is routed
First match wins:
- an inbound route matching the number, in priority order;
- the number's dynamic hook, if
hookEnabled; - the number's static destination;
- for a SIP-trunk call only, the trunk's fallback destination.
The number lookup deliberately runs across tenants, because no tenant is bound yet, and the organization is then bound from the row that matched — never from anything in the request. A number that matches nothing is rejected rather than routed to a default.
Dynamic hooks
Instead of a fixed destination, a number can ask your server at call time:
| Field | Meaning |
|---|---|
hookUrl | Where to ask. |
hookSecret | Signs the request, like a webhook. |
hookTimeoutMs | 200–3000. Past that, the fallback applies. |
hookFallback | static · reject · voicemail — what to do when your server is slow or wrong. |
Choose the fallback deliberately. static is forgiving; reject is honest; nothing is worse than a caller hearing silence because a hook timed out.
Outbound
Two endpoints originate a call, and they are not the same thing:
| Endpoint | Use it for |
|---|---|
POST /api/v1/calls | The normal one. Assistant-driven, admission-checked, idempotent, produces a full call record. snake_case. |
POST /api/v1/telephony/calls | Originating through a telephony account directly, for PBX-style dialling that is not an assistant call. |
curl -X POST "$CALLAGENT_API_URL/calls" \
-H "Authorization: Bearer $CALLAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"assistant_id": "01JQ…",
"phone_number_id": "01JQ…",
"to": "+40712345678",
"idempotency_key": "order-4471-callback",
"answering_machine_detection": true,
"ring_timeout_seconds": 30
}'Full body: to (required), from, assistant_id, assistant_version_id, assistant_overrides, squad_id, phone_number_id, sip_trunk_id, customer_id, campaign_id, transport (pstn · sip · web · mobile), provider, variables, metadata, idempotency_key, answering_machine_detection, ring_timeout_seconds (5–300).
Admission
Before a leg exists, in this order:
- Destination policy — organization active and not frozen, direction enabled, allowlist, blocklist, country policy, number class (premium
1900/1976, satellite870–874and881–883, North Korea850, and the rest of the high-cost prefix tables), hourly, daily and daily-minutes caps. - Routing — an outbound route must match; a
blockedroute throwsroute_blocked. - Caller ID — a caller-ID policy pool, or the number you asked for only if your organization owns it; otherwise
unverified_caller_id(403). - Concurrency —
included_concurrency + burst_concurrency, plus per-campaign, per-number and per-trunk dimensions. A denial isconcurrency_limit(429). - Originate — and if the carrier throws, the concurrency slot is released immediately, because nothing will ever send a hangup for a call that never began.
Separately, billing admission runs cheapest-check-first: organization status, trial expiry and phone verification, dunning freeze, daily spend cap, plan volume ceilings, funds, concurrency. It does not debit an estimated cost up front; usage is metered continuously.
Refusals arrive as an ended_reason on the call, not as an opaque 500:
ended_reason | Caused by |
|---|---|
concurrency_limit | Concurrency denial. |
destination_blocked | Route blocked, blocklisted, country blocked, prefix blocked, premium, satellite, high cost, any cap. |
telephony_error | Missing destination, no route, missing from, unverified caller ID, unknown provider, not implemented, missing credential, connection error. |
provider_error | Anything else the carrier said. |
Idempotency
A key is generated for you if you omit one. With a key, a repeat returns the existing call row and does not dial again — enforced by a unique index on (organization_id, idempotency_key), not by application logic alone.
One caveat: the replay is not compared against the original request. Reusing a key with a different body returns the original call rather than an error.
Routes
GET/POST /api/v1/telephony/inbound-routes
PATCH/DELETE /api/v1/telephony/inbound-routes/{id}
GET/POST /api/v1/telephony/outbound-routes
PATCH/DELETE /api/v1/telephony/outbound-routes/{id}Inbound routes match a number and win over the number's own destination. Outbound routes pick the trunk or account a dialled number leaves through, and a route can be blocked to refuse a destination outright.
Transfers
Eight transfer modes exist: blind, warm, assistant_warm, dynamic, sip, phone, queue, on_hold_specialist. warm, assistant_warm and on_hold_specialist are consultative.
The contract spells these differently
TransferPlanSchema in @callagent/contracts uses blind, warm, warm_with_summary, warm_with_assistant, hold_specialist. The PHP enum that drives the transfer service uses the eight above. They do not map one to one. Use the PHP spelling over the REST API.
Warm transfer is implemented with FreeSWITCH commands (hold, originate, park, bridge) and has no Twilio equivalent. On a Twilio call, a "warm" transfer is a conference join, and a private consultation between two operators is not offered rather than faked. See Contact centre.
API
GET/POST /api/v1/telephony/phone-numbers
POST /api/v1/telephony/phone-numbers/search
POST /api/v1/telephony/phone-numbers/purchase
POST /api/v1/telephony/phone-numbers/import
GET/PATCH/DELETE /api/v1/telephony/phone-numbers/{id}
POST /api/v1/telephony/phone-numbers/{id}/configure
POST /api/v1/telephony/phone-numbers/{id}/release
GET/POST /api/v1/telephony/accounts
GET/PATCH/DELETE /api/v1/telephony/accounts/{id}
POST /api/v1/telephony/accounts/{id}/health
GET /api/v1/telephony/providers
GET/POST /api/v1/telephony/inbound-routes
PATCH/DELETE /api/v1/telephony/inbound-routes/{id}
GET/POST /api/v1/telephony/outbound-routes
PATCH/DELETE /api/v1/telephony/outbound-routes/{id}
POST /api/v1/telephony/callsFull parameters and responses: phone numbers, accounts, routes, outbound calls.