Skip to main content

AgentStream

Stream real-time audio between live phone calls and your bot server over WebSocket. Three ways to connect โ€” pick the one that fits your setup.


How it worksโ€‹

Caller โ†’ Exophone โ†’ Exotel โ†’ wss://your-bot-server

When a call is answered, Exotel opens a WebSocket to your endpoint and streams raw PCM audio every ~100 ms. Send audio back on the same socket to speak to the caller.


Connect Voice AIโ€‹

Dial a number and connect the answered call directly to your bot. No flow setup needed.

POST /v1/accounts/{account_sid}/calls/connect

RegionBase URL
Mumbaihttps://api.in.exotel.com
Singaporehttps://api.exotel.com

Auth: HTTP Basic โ€” API key as username, API token as password.

Parametersโ€‹

ParameterRequiredDescription
fromYesNumber to dial โ€” E.164 format (+919876543210)
calleridYesYour Exophone (shown as caller ID)
streamurlYesBot WebSocket URL โ€” wss:// or ws://, max 600 chars
streamtypeYesbidirectional
recordNotrue to record the call
recordingchannelsNosingle (merged) or dual (separate tracks)
timelimitNoMax duration in seconds (max 14400)
customfieldNoMetadata string, max 128 chars
statuscallbackNoWebhook URL for call status events
statuscallbackevents[]Noanswered ยท terminal ยท ringing
streamnameNoLabel for the stream, max 32 chars

Requestโ€‹

curl -X POST \
'https://<api_key>:<api_token>@api.in.exotel.com/v1/accounts/<account_sid>/calls/connect' \
-F 'from=+919876543210' \
-F 'callerid=08047491899' \
-F 'streamurl=wss://bot.example.com/media' \
-F 'streamtype=bidirectional' \
-F 'statuscallback=https://your-server.com/callback' \
-F 'statuscallbackevents[]=terminal'

Responseโ€‹

{
"call": {
"sid": "a1b2c3d4e5f6",
"status": "in-progress",
"from": "+919876543210",
"phonenumbersid": "08047491899",
"direction": "outbound-api",
"datecreated": "2026-05-14 10:00:00",
"recordingurl": null
}
}

Status values: queued ยท in-progress ยท completed ยท failed ยท busy ยท no-answer


Connect Voice API with Flowโ€‹

Dial a number and run the call through an Exotel flow โ€” IVR menus, greetings, DTMF collection โ€” before reaching your bot.

Same endpoint:

POST /v1/accounts/{account_sid}/calls/connect

Parametersโ€‹

ParameterRequiredDescription
fromYesNumber to dial โ€” E.164 format
calleridYesYour Exophone
urlYesFlow URL: https://my.exotel.com/{account_sid}/exoml/start_voice/{flow_id}
calltypeNotrans for transactional calls
timelimitNoMax duration in seconds (max 14400)
timeoutNoRing timeout in seconds
statuscallbackNoWebhook URL for status events
statuscallbackeventsNoterminal ยท answered ยท both
customfieldNoPassed into the flow via the Passthru applet
note

Don't pass streamurl or streamtype here โ€” the WebSocket URL is configured inside the flow's Voicebot/Stream applet.

Requestโ€‹

curl -X POST \
'https://<api_key>:<api_token>@api.in.exotel.com/v1/accounts/<account_sid>/calls/connect' \
-d 'from=+919876543210' \
-d 'callerid=08047491899' \
-d 'url=https://my.exotel.com/<account_sid>/exoml/start_voice/<flow_id>' \
-d 'statuscallback=https://your-server.com/callback' \
-d 'statuscallbackevents=terminal'

Responseโ€‹

Same shape as Connect Voice AI response.

Common flow patternsโ€‹

PatternApplets in flow
Greeting โ†’ botPlay โ†’ Voicebot
IVR menu โ†’ botGather (DTMF) โ†’ Voicebot
Bot โ†’ human agentVoicebot โ†’ Transfer
Listen-only (transcription)Stream โ†’ Passthru

Programmable Voice APIs (ExoML)โ€‹

Full runtime control via Legs API โ€” per-call stream URLs, dynamic routing, greeting + stream in parallel.

Step 1 โ€” Dial the customerโ€‹

curl -X POST \
'https://<api_key>:<api_token>@api.in.exotel.com/v2/accounts/<account_sid>/legs' \
-H 'Content-Type: application/json' \
-d '{
"contact_uri": "+919876543210",
"exophone": "08047491899",
"leg_event_endpoint": "grpc://your-event-server.example.com",
"timeout": 30
}'

Exotel emits: leg_connecting โ†’ leg_ringing โ†’ leg_answered

Step 2 โ€” Start stream on leg_answeredโ€‹

curl -X POST \
'https://<api_key>:<api_token>@api.in.exotel.com/v2/accounts/<account_sid>/legs/<leg_sid>/actions/start_stream' \
-H 'Content-Type: application/json' \
-d '{
"direction": "bidirectional",
"url": "wss://bot.example.com/stream",
"content_type": "audio/x-mulaw;rate=8000"
}'

Optional โ€” Play greeting while stream initialisesโ€‹

Fire both requests simultaneously on leg_answered to eliminate dead air:

# Start say in parallel with start_stream
curl -X POST \
'.../legs/<leg_sid>/actions/start_say' \
-H 'Content-Type: application/json' \
-d '{ "text": "Please hold.", "loop": 0 }'

Stop it as soon as stream_started fires:

curl -X POST '.../legs/<leg_sid>/actions/stop_say'
ScenarioUse greeting?
Inbound IVR / tech supportNo
Outbound sales / collectionsYes

Applet configurationโ€‹

Stream Applet โ€” Unidirectional (Exotel โ†’ your server)โ€‹

Audio flows one way only โ€” from the caller to your server. Your server cannot send audio back. Use for transcription, agent assist, call monitoring.

#ParameterRequiredDescription
1ActionYesStart to begin streaming ยท Stop to end it
2URLYeswss://your-server.com/stream โ€” or https:// endpoint returning {"url":"wss://..."}
3Next AppletNoCall flow continues to next applet immediately after stream creation
note

Unidirectional streams fork audio immediately. If used with a Connect applet that rings multiple agents, audio from all ringing legs is sent โ€” filter on your end.


VoiceBot Applet โ€” Bidirectional (Exotel โ†” your server)โ€‹

Audio flows both ways โ€” your server receives caller audio and can send audio back in real time. Use for conversational AI, IVR replacement, outbound bots.

#ParameterRequiredDescription
1URLYeswss://bot.example.com/stream โ€” or https:// endpoint returning {"url":"wss://..."} for dynamic routing
2AuthenticationNoIP whitelist or Basic auth header. Email hello@exotel.com for Exotel's IP ranges.
3Sample RateNo8000 (default) ยท 16000 (recommended) ยท 24000. Append ?sample-rate=16000 to URL.
4Custom ParametersNoUp to 3 key-value pairs in URL, max 256 chars total. Arrive in the start event.
5RecordNoGenerates recording URL in the Passthru applet
6Next AppletNoStream closes automatically โ€” no Stop applet needed

WebSocket protocolโ€‹

Event support by applet typeโ€‹

EventStream Applet (Unidirectional)VoiceBot Applet (Bidirectional)
connectedโœ“โœ“
startโœ“โœ“
media (receive)โœ“โœ“
dtmf (receive)โœ—โœ“
mark (receive)โœ—โœ“
stopโœ“โœ“
media (send)โœ—โœ“
mark (send)โœ—โœ“
clear (send)โœ—โœ“

Events โ€” Exotel โ†’ your serverโ€‹

connectedโ€‹

{ "event": "connected" }

startโ€‹

{
"event": "start",
"sequence_number": "1",
"stream_sid": "MZxxxxxxxxxxxxxxxxxxxxxxxx",
"start": {
"stream_sid": "MZxxxxxxxxxxxxxxxxxxxxxxxx",
"call_sid": "CAxxxxxxxxxxxxxxxxxxxxxxxx",
"account_sid": "ACxxxxxxxxxxxxxxxxxxxxxxxx",
"from": "+919876543210",
"to": "+918047491899",
"custom_parameters": { "key1": "value1" },
"media_format": {
"encoding": "audio/x-raw",
"sample_rate": "8000",
"bit_rate": "16"
}
}
}

mediaโ€‹

{
"event": "media",
"sequence_number": "3",
"stream_sid": "MZxxxxxxxxxxxxxxxxxxxxxxxx",
"media": {
"chunk": "2",
"timestamp": "200",
"payload": "<base64-encoded PCM>"
}
}

dtmf (VoiceBot Applet only)โ€‹

{
"event": "dtmf",
"sequence_number": "7",
"stream_sid": "MZxxxxxxxxxxxxxxxxxxxxxxxx",
"dtmf": { "digit": "5", "duration": "100" }
}

mark (VoiceBot Applet only)โ€‹

Sent when audio you previously sent has finished playing.

{
"event": "mark",
"sequence_number": "15",
"stream_sid": "MZxxxxxxxxxxxxxxxxxxxxxxxx",
"mark": { "name": "my-label" }
}

stopโ€‹

{
"event": "stop",
"sequence_number": "20",
"stream_sid": "MZxxxxxxxxxxxxxxxxxxxxxxxx",
"stop": {
"call_sid": "CAxxxxxxxxxxxxxxxxxxxxxxxx",
"account_sid": "ACxxxxxxxxxxxxxxxxxxxxxxxx",
"reason": "callended"
}
}

reason: stopped (applet ended) ยท callended (caller hung up)


Events โ€” your server โ†’ Exotel (VoiceBot Applet only)โ€‹

note

These events only apply to the VoiceBot Applet (bidirectional). The Stream Applet is receive-only โ€” your server cannot send any events back.

media โ€” send audio to callerโ€‹

{
"event": "media",
"stream_sid": "MZxxxxxxxxxxxxxxxxxxxxxxxx",
"media": { "payload": "<base64-encoded PCM>" }
}

mark โ€” tag a playback positionโ€‹

{
"event": "mark",
"stream_sid": "MZxxxxxxxxxxxxxxxxxxxxxxxx",
"mark": { "name": "turn-3-end" }
}

clear โ€” flush buffered audio (barge-in)โ€‹

{ "event": "clear", "stream_sid": "MZxxxxxxxxxxxxxxxxxxxxxxxx" }

Audio formatโ€‹

PropertyValue
CodecRaw PCM (linear16) โ€” uncompressed
Bit depth16-bit signed, little-endian
ChannelsMono
Default sample rate8 000 Hz
Supported rates8 000 ยท 16 000 ยท 24 000 Hz
TransportBase64
Chunk size3 200โ€“100 000 bytes, must be a multiple of 320
Max session duration60 minutes

Passthru Appletโ€‹

Place immediately after the Voicebot/Stream applet. Exotel sends a GET request with stream metadata as query parameters to your callback URL when the stream ends.

FieldDescription
callsidParent call ID
streamsidStream session ID
streamurlWebSocket URL used
statusFinal stream status
durationStream duration in seconds
recordingurlRecording link (if enabled)
errorError detail โ€” copy as-is for support
dispositionOutcome classification
disconnectedbycaller ยท bot ยท system
detailedstatusFine-grained status

Active Stream Monitoringโ€‹

Check how many streams are live on your account.

curl -X GET \
'https://<api_key>:<api_token>@api.in.exotel.com/v1/accounts/<account_sid>/activestreams'
{
"status": "success",
"active_streams": 12,
"max_allowed_streams": 100,
"account_sid": "<account_sid>"
}

WSS error codesโ€‹

CodeMeaningCommon cause
1000Normal closureClean disconnect
1001Endpoint going awayServer restart
1002โ€“1003Protocol / data errorMalformed frames
1006Abnormal closureNetwork drop ยท TLS failure ยท server crash
1007โ€“1009Payload / policy / sizeChunk out of bounds
1011Server errorUnhandled exception
1012โ€“1013Restart / retryTransient โ€” retry

1006 checklist: WSS URL reachable โ†’ TLS cert valid โ†’ firewall allows Exotel IPs โ†’ handshake completes within 10 s.


Echo server (VoiceBot Applet / bidirectional only)โ€‹

import asyncio, json, websockets

async def handle(ws):
stream_sid = None
async for msg in ws:
ev = json.loads(msg)
match ev["event"]:
case "start":
stream_sid = ev["start"]["stream_sid"]
case "media":
await ws.send(json.dumps({
"event": "media",
"stream_sid": stream_sid,
"media": {"payload": ev["media"]["payload"]}
}))
case "stop":
break

async def main():
async with websockets.serve(handle, "0.0.0.0", 5001):
await asyncio.Future()

asyncio.run(main())
pip install websockets && python echo.py
ngrok http 5001 # use the https:// URL as streamurl