Telnyx Media Streaming (bidirectional) — telephony transport
Scope: the Telnyx counterpart of the Twilio adapter (verify docs). Twin of docs/providers/twilio.md (read that first
for the shared design: mu-law/A-law via docs/providers/g711.md, buffer-clear on interruption,
playback tracking via marks feeds played-text truncation). Related: docs/providers/call-control.md (DTMF, transfer).
Researched: 2026-10-08. Every fact below was read from the pages listed under Sources on that date.
Anything not confirmed is marked UNVERIFIED. This is a transport, not an STT/LLM/TTS provider:
Telnyx is the WebSocket CLIENT, brisvox is the WebSocket SERVER (same role as Twilio).
How the facts were obtained: the Telnyx pages were read through a summarizing fetch tool. The authoritative JSON examples and schemas below come from the AsyncAPI 3.0.0 spec (v2.0.0) that Telnyx links from its docs (https://telnyx-openapi-ng.s3.us-east-1.amazonaws.com/media-streaming/media-streaming.yml, read raw on 2026-10-08), so field names, types, enums and examples are exact. Prose rules (limits, mark semantics) are quoted/paraphrased from the guide and API reference. The guide's own JSON blocks could not be reproduced by the tool; they were checked only as key lists and agree with the spec.
- Docs: https://developers.telnyx.com/docs/voice/programmable-voice/media-streaming (fetched 2026-10-08); WebSocket spec https://developers.telnyx.com/api-reference/websockets/stream-call-media-over-websocket (fetched 2026-10-08); full source list at the bottom.
- Auth / trust:
- Starting a stream (REST):
Authorization: Bearer <API key>onPOST https://api.telnyx.com/v2/.... That is Telnyx's API, not brisvox's; brisvox core does not hold it unless it also hangs up or transfers via REST (see "Ending the call"). - The WebSocket itself has NO signature (no
X-Telnyx-Signature-style header is documented for the upgrade). The only documented authenticity mechanism is an optional shared token: thestream_auth_tokenfield ofstreaming_startand ofdial(string, max length 4000; streaming_start reference: "An authentication token to be sent as part of the WebSocket connection"). When configured, Telnyx sends it as the WebSocket upgrade headerx-telnyx-streaming-auth-tokenAND in the first frame asconnected.x-telnyx-streaming-auth-token(spec: "When a streaming auth token is configured, Telnyx includes it in theconnected.x-telnyx-streaming-auth-tokenfield and sends it as thex-telnyx-streaming-auth-tokenWebSocket header"). Brisvox should compare it in constant time and refuse the upgrade (HTTP 401/403) on mismatch. Never log it. stream_auth_tokenis listed on the Call Controlstreaming_startreference and on thedial(POST /v2/calls) reference (first ~100k of 168k characters of the dial page read; the stream_* parameters are all in that part). It is NOT listed in theanswerrequest parameters (whole page, 129k characters, read in two fetches on 2026-10-08), not in the media-streaming guide, and not a TeXML<Stream>attribute. So withansweror TeXML you cannot set it; start the stream withstreaming_startafter answering, or authenticate with a secret in the URL path or a custom parameter (see below). Exact upgrade-header casing: UNVERIFIED (spec writes it lower case).custom_parameters(streaming_start: array of{name, value}; TeXML:<Parameter name= value=>) arrive instart.custom_parameters(a map of strings). They are part of a WS message, not the upgrade, and not signed; treat as untrusted unless the token/URL secret validated. Top-levelcustom_parameterswas not found onansweror in the part ofdialthat was read (UNVERIFIED there; it exists onstreaming_start).- Network allowlist: Telnyx does not publish a separate stream source-IP list. The IP-whitelisting page says for the webhook CIDRs: "These ranges also apply to WebSocket stream connections." Webhook CIDRs: CH1 192.76.120.128/29, DC2 192.76.120.136/29, SV1 192.76.120.144/29, LD6 185.246.41.0/29, FR5 185.246.41.8/29, AM6 185.246.41.16/29, SY1 103.115.244.0/29, SG1 103.115.244.8/29; webhooks use TCP 443. The list may change (the page does not say how often): fetch it again before pinning (UNVERIFIED that it is complete for WS).
- Webhooks (not the WS) are signed: headers
telnyx-signature-ed25519andtelnyx-timestamp(stated on the TeXML stream callback reference page; that page notes the headers are compatible with the Standard Webhooks spec and can be mapped towebhook-timestamp/webhook-signature). The algorithm being Ed25519 is inferred from the header name; the page does not describe verification steps (UNVERIFIED). Only relevant if brisvox ever serves the Call Control webhook; it does not (the user's HTTP app does). Crate/verification details not researched.
- Starting a stream (REST):
- Endpoint(s) / regions:
- Brisvox hosts a
wss://endpoint (the spec's server iswss, host/path chosen by the customer, address/). TLS certificate requirements, supported TLS versions, accepted ports and whetherws://is accepted: NOT stated (UNVERIFIED; use a publicly trusted certificate on 443). Query strings instream_url: not forbidden in the docs seen (Twilio forbids them); UNVERIFIED, so prefer path secret + parameters. - Regions: no region selection for streaming found (UNVERIFIED). Telnyx source regions are the webhook CIDRs above.
- Prerequisite (third-party integration guide found by search, not Telnyx docs, so UNVERIFIED; not re-checked in the 2026-10-08 fact check): a Call Control app with a webhook URL is needed for programmable calls; a plain number connection is not enough.
- Brisvox hosts a
- How a call is set up to stream. Four ways (all documented):
- Call Control
dial(POST /v2/calls) withstream_url,stream_track, optionallystream_bidirectional_mode,stream_bidirectional_codec(guide's examples). Thedialreference also listsstream_codec,stream_bidirectional_target_legs,stream_bidirectional_sampling_rate,stream_auth_tokenandstream_establish_before_call_originate(boolean, default false; meaning not read: UNVERIFIED). - Call Control
answer(POST /v2/calls/{call_control_id}/actions/answer) with the samestream_*fields (answer reference listsstream_url,stream_track,stream_codec,stream_bidirectional_mode,stream_bidirectional_codec,stream_bidirectional_target_legs; it does NOT liststream_bidirectional_sampling_rateorstream_auth_token, whole page read). Settingstream_urlalso triggersstreaming.started/streaming.stopped/streaming.failedwebhooks (streaming.failedis not named in the pages re-read on 2026-10-08; the guide and streaming-stop page namestreaming.startedandstreaming.stopped:streaming.failedUNVERIFIED). - Call Control
streaming_starton a live call (POST https://api.telnyx.com/v2/calls/{call_control_id}/actions/streaming_start). - TeXML:
<Start><Stream>(asynchronous, call flow continues) or<Connect><Stream>(synchronous: the call flow waits until streaming stops, then the instructions after<Connect>run; the Connect page says "The following instructions will be executed when the service is stopped"; it has no XML example, so the<Connect><Stream ...>snippet further below is derived, not a doc sample). TeXML REST also hasPOST /v2/texml/Accounts/{account_sid}/Calls/{call_sid}/Streams.json(form-encodedUrl,Track,Name,BidirectionalMode,BidirectionalCodec(PCMU|PCMA|G722 there),StatusCallback,StatusCallbackMethod).
- Only ONE streaming/fork operation per call ("Currently only one streaming/fork operation is supported per
call"); a media-forking request "will be stopped and replaced by a RTP connection" (the WS stream is replaced).
The
streaming_startreference shows generic 422 examples (not stream-specific): invalid call control ID (90015), call already ended (90018), call not answered yet (90034), "Media Streaming is used" (90045, detail "This command can't be issued when media streaming is used"), plus 10004/10032/10033 validation errors. - Brisvox example (derived, not a doc sample; JSON key names are from the
streaming_startreference):
The same fields exist as top-levelPOST https://api.telnyx.com/v2/calls/{call_control_id}/actions/streaming_start Authorization: Bearer <API key> { "stream_url": "wss://agent.example.com/telnyx/stream", "stream_track": "inbound_track", "stream_codec": "PCMU", "stream_bidirectional_mode": "rtp", "stream_bidirectional_codec": "PCMU", "stream_bidirectional_target_legs": "opposite", "stream_bidirectional_sampling_rate": 8000, "stream_auth_token": "<random secret>", "custom_parameters": [ { "name": "agent", "value": "support-tr" } ], "client_state": "<base64>", "command_id": "<unique>" }stream_*fields ondial(withstream_auth_token) and, exceptstream_bidirectional_sampling_rate/stream_auth_token, onanswer. TeXML equivalent (derived; attributes from the TeXML<Stream>page; no bidirectional XML example exists in the docs):<Response> <Connect> <Stream url="wss://agent.example.com/telnyx/stream" bidirectionalMode="rtp" bidirectionalCodec="PCMU" bidirectionalSamplingRate="8000" enableReconnect="false"> <Parameter name="agent" value="support-tr" /> </Stream> </Connect> </Response> streaming_startparameters (exact, reference page; no field is marked required):stream_url(string, e.g.wss://www.example.com/websocket);stream_track(inbound_trackdefault |outbound_track|both_tracks);stream_codec(PCMU|PCMA|G722|OPUS|AMR-WB|L16|default, defaultdefault; "When set to 'default' or when transcoding is not possible, the codec from the call will be used");stream_bidirectional_mode(mp3DEFAULT |rtp);stream_bidirectional_codec(PCMUdefault |PCMA|G722|OPUS|AMR-WB|L16; used only with modertp; case sensitive);stream_bidirectional_target_legs(both|self|opposite, defaultopposite);stream_bidirectional_sampling_rate(integer8000default |16000|22050|24000|48000);enable_dialogflow(bool, default false),dialogflow_config(analyze_sentiment,partial_automated_agent_reply, both bool default false);client_state(base64);command_id(dedupe per call_control_id);custom_parameters;stream_auth_token(max 4000). Response 200{"data":{"result":"ok"}}; 422/defaulterrors[]withcode,title,detail,source.- TeXML
<Stream>attributes (exact):url(required),track,name,codec(PCMU|PCMA|G722|OPUS|AMR-WB|default; "Currently, transcoding is only supported between PCMU and PCMA codecs"),bidirectionalMode(mp3default|rtp),bidirectionalCodec(PCMUdefault|PCMA|G722|OPUS|AMR-WB),bidirectionalSamplingRate(8000default|16000|24000),statusCallback,statusCallbackMethod(GET|POST, defaultPOST),enableReconnect(defaulttrue). Child<Parameter name= value=>. Status callbacks:stream-started,stream-stopped,stream-failed. Doc examples on the page use<Start><Stream url="wss://yourdomain.com/stream" track="both_tracks" />and astatusCallback+<Parameter>variant. Docs discrepancy: Call Control allows rates22050/48000, TeXML only8000/16000/24000, and the TeXML<Stream>codec list has noL16; use Call Control for L16.
- Call Control
- Protocol (messages in/out, with field names). All frames are JSON text frames (binary frames not mentioned:
UNVERIFIED). Field names are snake_case; stream id key is
stream_id(a UUID), notstreamSid.- Telnyx -> server, in order (spec "Connection Flow"):
connected,start, thenmedia(repeating) and optionallydtmf/mark/error, finallystop.
With a configured token:{ "event": "connected", "version": "1.0.0" }{ "event": "connected", "connected": { "x-telnyx-streaming-auth-token": "3c8b06a4-94a8-4d03-9fd1-941bc3f7166a" }, "version": "1.0.0" }{ "event": "start", "sequence_number": "1", "stream_id": "32DE0DEA-53CB-4B21-89A4-9E1819C043BC", "start": { "user_id": "3E6F995F-85F7-4705-9741-53B116D28237", "call_control_id": "v2:T02llQxIyaRkhfRKxgAP8nY511EhFLizdvdUKJiSw8d6A9BborherQ", "call_session_id": "ff55a038-6f5d-11ef-9692-02420aeffb1f", "from": "+13122010094", "to": "+13122123456", "tags": [ "TAG1", "TAG2" ], "client_state": "aGF2ZSBhIG5pY2UgZGF5ID1d", "custom_parameters": { "customer_id": "customer_123" }, "media_format": { "encoding": "PCMU", "sample_rate": 8000, "channels": 1 } } }{ "event": "media", "sequence_number": "4", "stream_id": "32DE0DEA-53CB-4B21-89A4-9E1819C043BC", "media": { "track": "inbound", "chunk": "2", "timestamp": "5", "payload": "no+JhoaJjpzSHxAKBgYJDhtEopGKh4aIjZm7JhILBwYIDRg1qZSLh4aIjJev" } }{ "event": "dtmf", "stream_id": "32DE0DEA-53CB-4B21-89A4-9E1819C043BC", "occurred_at": "2025-06-05T08:54:19.698408Z", "sequence_number": "5", "dtmf": { "digit": "1" } }{ "event": "mark", "sequence_number": "5", "stream_id": "32DE0DEA-53CB-4B21-89A4-9E1819C043BC", "mark": { "name": "some_mark_name" } }{ "event": "stop", "sequence_number": "5", "stream_id": "32DE0DEA-53CB-4B21-89A4-9E1819C043BC", "stop": { "user_id": "3E6F995F-85F7-4705-9741-53B116D28237", "call_control_id": "v2:T02llQxIyaRkhfRKxgAP8nY511EhFLizdvdUKJiSw8d6A9BborherQ" } }{ "event": "error", "stream_id": "32DE0DEA-53CB-4B21-89A4-9E1819C043BC", "payload": { "code": 100004, "title": "invalid_media", "detail": "Media provided was not base64 encoded" } } - Field notes (spec schemas; every Telnyx-sent object has
additionalProperties: false, so unknown fields are not expected, but ignore them anyway for forward compatibility):connected: requiredevent,version(string, protocol version, example1.0.0); optionalconnectedobject with optionalx-telnyx-streaming-auth-token. NOTE:connectedhas nosequence_number/stream_id.start: requiredevent,sequence_number,stream_id(UUID),start. Required insidestart:user_id(UUID),call_control_id,media_format. Optional:call_session_id(UUID),from,to("Caller number or SIP URI when available" forfrom; "Callee number or SIP URI when available" forto),tags(strings),client_state(base64 string),custom_parameters(string -> string).media_format.encodingenumPCMU|PCMA|G722|OPUS|AMR-WB|L16;sample_rateinteger enum8000|16000;channelsinteger, constant1. The spec has notracklist instart(Twilio hastracks).media: requiredevent,sequence_number,stream_id,media{track, chunk, timestamp, payload}.trackenuminbound|outbound("Call leg track that produced the media frame");chunkstring, "Per-stream media chunk number. Use to reorder media frames if needed.";timestampstring, "RTP timestamp associated with this media frame." (so NOT milliseconds like Twilio; units/clock rate, wrap-around and relation to ms: UNVERIFIED; the example"5"next to chunk"2"can't be interpreted);payloadbase64 "RTP payload without RTP headers".sequence_number: string, "Monotonic sequence number for frames sent by Telnyx on this WebSocket connection."connectedanderrorframes carry none.- TYPES:
sequence_number,chunk,timestampare JSON STRINGS in schema and examples;sample_rate,channelsand errorcodeare numbers. Parse strings; accept numbers too, defensively (as for Twilio). dtmf: requiredevent,sequence_number,stream_id,dtmf.digit; optionaloccurred_at(date-time, "Timestamp captured by the telephony engine. Use this to order DTMF events if they arrive out of order."). Digit enum:0-9,*,#,A,B,C,D. Notrackfield (Twilio hasdtmf.track).stop: sent "when Telnyx stops media streaming for the call" (spec). The guide says it is sent when the call ends, after thestreaming.stoppedwebhook; whether it is also sent when the stream is stopped via REST while the call stays up: not stated in the pages (UNVERIFIED). Carriesstop.user_idandstop.call_control_id.error: requiredevent,stream_id,payload{code, title, detail}; code/title enum:100002 unknown_error,100003 malformed_frame,100004 invalid_media(example detail "Media provided was not base64 encoded"),100005 rate_limit_reached(guide: "Too many requests"). Whether the stream/connection survives an error frame: not stated (UNVERIFIED).
- Ordering: "The order of events is not guaranteed and the chunk number can be used to reorder the events." (guide);
the spec says to use
media.chunk,media.timestampandsequence_number. This is a real difference from Twilio: brisvox needs a small bounded reorder buffer keyed onmedia.chunk(and tolerate gaps), and DTMF should be ordered byoccurred_at. How far out of order frames get, and the reorder window to use: UNVERIFIED (measure live). Whether thestartframe can arrive after amediaframe: not stated. - Server -> Telnyx (bidirectional; client schemas also have
additionalProperties: false):{ "event": "media", "media": { "payload": "eW91ciBiYXNlNjQgZW5jb2RlZCBhdWRpbyBwYXlsb2Fk" } }{ "event": "mark", "mark": { "name": "some_mark_name" } }
These carry NO{ "event": "clear" }stream_id(Twilio requiresstreamSidon every message). Because the schemas forbid extra properties, sending astream_idmay be rejected asmalformed_frame(100003): inference from the schema, not a doc sentence, UNVERIFIED. Pipecat's official Telnyx serializer sends exactly{"event":"media","media":{"payload":...}}and{"event":"clear"}with no stream id (source read 2026-10-08; it storesstream_idbut never sends it), which is consistent. There is no server->Telnyxstop, and no documented way to send DTMF or an error to Telnyx over the socket.
- Telnyx -> server, in order (spec "Connection Flow"):
- Outbound audio format and chunking:
- Bidirectional mode
rtp: payload = base64 of the RTP payload (no RTP header) in the codec chosen bystream_bidirectional_codec(default PCMU). Guide table of codecs and rates: PCMU 8 kHz (default), PCMA 8 kHz, G722 8 kHz, OPUS 8 and 16 kHz, AMR-WB 8 and 16 kHz, L16 16 kHz. Verbatim warnings: when audio is sent in a different encoding than the call's "it will be transcoded, which may cause a degradation in quality"; the guide recommends L16 for AI voice agents (lower latency, no transcoding overhead). So the call's own codec (negotiated by the carrier/SIP leg) matters; thestart.media_formattells the INBOUND encoding actually streamed. - Chunk size: "Provided chunks of audio can be in a size of 20 milliseconds to 30 seconds" (RTP mode). So Telnyx accepts large chunks, unlike a fixed 20 ms pacing; brisvox can still use 20 ms frames (160 bytes PCMU at 8 kHz). Minimum is 20 ms: smaller chunks are probably rejected or merged (UNVERIFIED; do not send < 20 ms).
- L16 byte order (big/little endian), and whether it is raw linear PCM16: the docs say "L16" and "linear PCM" only; RFC 3551 L16 is network byte order, but Telnyx does not state it: UNVERIFIED (test live before using L16).
- Rate limit: the guide says "Media payloads can only be submitted once per second." and this sentence sits under the
guide's "Sending media files" subsection (MP3 files) of "Bidirectional media streaming", not under "Sending RTP
stream". The spec adds MP3 frames are "played in submission order, with a one-message-per-second rate limit"; its RTP
sentence ("RTP media chunks sent back to Telnyx can contain 20 milliseconds to 30 seconds of audio") carries no rate
limit. So per the docs the once-per-second limit is for MP3 file payloads; that it does not apply to RTP-mode
mediaframes (50/s at 20 ms) is an inference, not a doc sentence (UNVERIFIED): confirm live and watch for error 100005. - MP3 mode (the DEFAULT mode when
stream_bidirectional_modeis not set):media.payloadis a base64 MP3 FILE, queued and played in order. Not suitable for streaming TTS: always setstream_bidirectional_modetortp. A stream started without it would take brisvox PCM/G.711 frames as broken MP3 (UNVERIFIED consequence). (Note the guide's dial example for rtp also showsrtpexplicitly.) stream_bidirectional_target_legs:opposite(default),self,both"Specifies which call legs should receive the bidirectional stream audio". For an inbound call answered by brisvox the stream is on the caller's leg; the meaning ofoppositefor a single-leg inbound call is not explained in the pages read: UNVERIFIED. Test whether the caller hears the audio with defaults, else setself.- Tracks:
stream_trackselectsinbound_track(caller audio; default),outbound_track(audio Telnyx plays to the caller, i.e. includes brisvox's own bidirectional audio, UNVERIFIED), orboth_tracks. For the agent useinbound_trackonly;media.trackisinboundfor that. Drop frames whosetrackis notinbound. - Buffer capacity (Twilio: 10 minutes): not documented for Telnyx (UNVERIFIED). Whether brisvox may send ahead of real
time and rely on
clear: implied by theclear/marktext below but the queue size limit is unknown.
- Bidirectional mode
- Mark semantics (guide + spec):
- Server sends
markafter themediato track. Telnyx returns{"event":"mark",...,"mark":{"name":..}}"when the media immediately preceding a submitted mark finishes playing. If no audio is playing or queued, the mark is returned immediately." (spec). Guide: "Submitting marks when no audio is played or queued will result in them being immediately sent back." This is stated explicitly for Telnyx (Twilio only implies it). clear: guide "Sending a clear message will immediately stop the media playing on the stream and clear the media queue." Spec: "Stops the current media playback and clears queued media. Queued marks are sent back by Telnyx." So the pattern is the same as Twilio: marks for cleared audio are echoed with the same JSON as played marks; brisvox must track locally that it sentclearand drop the echoes (exactly as the Twilio adapter forgets in-flight marks before sendingclear). Note the improvement over Twilio:clear"immediately stops the media playing", i.e. also the currently playing chunk (Twilio says it empties the buffer). Residual audio already in the phone network: UNVERIFIED.- Marks carry no timestamp or played-sample count; the mark name is a free string ("Customer-defined mark name"; length
and charset limits not documented, UNVERIFIED; reuse the Twilio scheme
e<n>/p<n>). Whether two in-flight marks may share a name: not stated (use unique names). - Whether a mark echo arrives in order relative to media/dtmf frames: no ordering guarantee exists for Telnyx frames in general (see Ordering); use mark names, not arrival order, to match.
- Whether marks work in MP3 mode: the guide says they track when media "ends on the stream"; not stated which modes. Use
rtpmode. - Playback tracking design for played-text truncation is identical to Twilio's (progress marks every ~200 ms plus utterance-end marks; heard = confirmed marks + estimate of the unconfirmed tail); see twilio.md "Marks, clear and played-text tracking".
- Server sends
- Interruption (barge-in): send
{"event":"clear"}the moment the turn epoch advances, before any new-epoch audio; stop sending old-epochmedia; drop mark echoes that follow the clear (they refer to cleared audio). Whether media sent right afterclearplays normally: implied (queue emptied), not stated (UNVERIFIED). - Keepalive / timeouts / reconnect:
- No ping interval, idle timeout, maximum stream duration or close codes are documented (UNVERIFIED). Brisvox applies its
own timeouts (time to first
startafter upgrade, stall timer on inboundmedia, ping/pong from our side as in the generic WebSocket server). Inboundmediacadence and frame duration: not documented (UNVERIFIED; the spec says amediaframe carries one RTP payload, plausibly 20 ms). - Reconnect: TeXML
<Stream enableReconnect>(defaulttrue): "Whether the platform should automatically attempt to reconnect the WebSocket stream if disconnected." The TeXML page suggestsenableReconnect="false""to end the stream when the WebSocket disconnects" (page: end the stream immediately on disconnection). If reconnect is on and brisvox closes the socket (e.g. hang-up attempt) Telnyx may reconnect and deliver a freshconnected/startfor the same call; for the engine this means the samecall_control_idcould reach the server twice (inference, UNVERIFIED). Brisvox must (a) recommendenableReconnect="false"for<Stream>, (b) key sessions bycall_control_idand refuse/close a second connection for a live or ended call. Whether Call Control streams have an equivalent setting (none instreaming_start) and whetherstream_idstays the same after a reconnect: UNVERIFIED. streaming.failedwebhook (and TeXMLstream-failed) = "Stream failed to start or was interrupted". The TeXMLstream-failedwording ("failed to start or was interrupted") is confirmed on the TeXML stream page; the Call Controlstreaming.failedwebhook name was not found on the pages re-read on 2026-10-08 (UNVERIFIED).- Closing the WS from the server: the guide does NOT say closing the WebSocket stops the stream or ends the call (UNVERIFIED).
For
<Connect><Stream>the next verbs run after the service stops; whether a server-initiated close counts as "stops" is not stated.
- No ping interval, idle timeout, maximum stream duration or close codes are documented (UNVERIFIED). Brisvox applies its
own timeouts (time to first
- Ending the call / stopping the stream (differs from Twilio):
- There is no server->Telnyx
stopmessage and (unlike Twilio) closing the socket is not documented as a hang-up. To hang up, call RESTPOST /v2/calls/{call_control_id}/actions/hangup(body optional fieldsclient_state,command_id,custom_headers(SIP BYE headers); 200{"data":{"result":"ok"}}; 422 codes90015invalid call control ID,90018call already ended; webhookscall.hangup,call.recording.saved). Pipecat's Telnyx serializer does exactly this (auto_hang_updefault on, needscall_control_idand API key) on itsEndFrame/CancelFrame. - To stop only the stream:
POST /v2/calls/{call_control_id}/actions/streaming_stopwith optionalstream_id(UUID; if omitted "stops all streams associated with the call_control_id"),client_state,command_id(the request body object itself is marked required, all its fields optional, so send{}); sends thestreaming.stoppedwebhook; 422 codes 90015/90018/90034. The TeXML equivalent ("update streaming") "moves the stream to a completed state and closes its WebSockets" (TeXML streams overview page). - Consequence for brisvox: the engine's
Hangupneeds a REST client (API key +call_control_idfromstart), supplied by the host app; a transport-level hang-up by closing the socket alone may leave the call up (UNVERIFIED either way). Provide a pluggable hang-up callback (e.g. aHanguphook that calls the REST API) in the adapter config, and still close the socket after. - As with Twilio, to play a goodbye: send audio, then an utterance-end mark, wait for the echoed mark (own timeout), then hang up.
- There is no server->Telnyx
- Webhooks around the stream (Call Control; field names from the guide as key lists):
streaming.startedandstreaming.stoppedhave top-levelevent_type,id,occurred_at,payload,record_type;payloadhascall_control_id,stream_url(the top-level key list was not re-confirmed on 2026-10-08, only thepayloadkeys: UNVERIFIED).call.answeredcarriesstream_url,stream_track(not re-confirmed: UNVERIFIED). TeXMLstatusCallbackpayload fields: not found (the stream callback reference page had no schema: UNVERIFIED). These are HTTP callbacks to the user's app, not to brisvox core. - Timestamps / interim results / tool-call deltas: not applicable. Relevant timing data: inbound
media.timestampis an RTP timestamp string (not ms),chunkcounter (gaps = lost or reordered inbound audio),sequence_numbercounts every Telnyx-sent frame; outboundmediahas no timestamp or chunk field. - Concurrency / rate limits: one streaming/fork operation per call; one bidirectional RTP stream per call (guide: "The
number of bidirectional RTP streams per call is limited to 1."); media chunk 20 ms to 30 s; "Media payloads can only be
submitted once per second" (guide places it under media-file/MP3 sending, see above); error 100005
rate_limit_reached. Per-account concurrent stream limits, pricing and messages-per-second for RTP: NOT found (UNVERIFIED). - Turkish support: not applicable to the transport (language-agnostic audio). Telephony quality note as in twilio.md: PCMU/PCMA 8 kHz is narrowband; L16/G722/OPUS 16 kHz (if the call leg negotiates it) gives wideband audio that Deepgram/Cartesia can use directly. Whether a Turkish PSTN call ever negotiates a wideband codec: UNVERIFIED (carrier dependent).
Differences from Twilio (summary)
(The Twilio column was not re-checked in the 2026-10-08 fact check; see twilio.md for its sources.)
| Topic | Twilio Media Streams | Telnyx Media Streaming |
|---|---|---|
| Start of call | TwiML <Connect><Stream url> | Call Control dial/answer/streaming_start with stream_url; or TeXML <Connect><Stream> / <Start><Stream> |
| Bidirectional enable | implicit with <Connect> | explicit stream_bidirectional_mode: rtp (default is mp3) |
| Key naming | camelCase (streamSid, callSid, mediaFormat, sequenceNumber) | snake_case (stream_id, call_control_id, media_format, sequence_number) |
| Stream id | streamSid (MZ...), required in every server message | stream_id UUID in Telnyx frames; NOT sent by the server |
connected | has protocol, version | has version only (+ optional token) |
start | streamSid, callSid, accountSid, tracks, mediaFormat, customParameters | stream_id, start{user_id, call_control_id, call_session_id, from, to, tags, client_state, custom_parameters, media_format} |
| Codec | always audio/x-mulaw 8000 mono | PCMU/PCMA/G722/OPUS/AMR-WB/L16; 8000 or 16000 in start; bidirectional codec set separately (default PCMU) |
media.timestamp | ms since stream start | "RTP timestamp" (unit unverified) |
| Frame order | in order | NOT guaranteed; reorder by chunk |
| DTMF | dtmf.track, dtmf.digit | dtmf.digit, occurred_at (no track) |
| Mark on empty queue | implied | explicit: returned immediately |
| Clear | empties buffer, returns marks | "immediately stop[s] the media playing" and clears queue, returns marks |
| Chunk size | any | 20 ms to 30 s (RTP) |
| Send-rate rule | none stated (10-minute buffer cap) | "once per second" for media-file (MP3) payloads per the guide; RTP scope unverified; rate_limit_reached |
| Errors to server | none; only logs | error frame codes 100002..100005 |
| Hang-up | close the WS | REST hangup (WS close not documented as hang-up) |
stop from server | not accepted | not accepted |
| Request authenticity | X-Twilio-Signature HMAC-SHA1 over URL | none on WS; optional stream_auth_token header/field (streaming_start/dial only); webhooks signed (telnyx-signature-ed25519) |
| Reconnect | none documented | TeXML enableReconnect default true |
| Custom params | <Parameter> (<500 chars name+value) | custom_parameters / <Parameter> (no limits documented) |
Mapping to the brisvox transport trait (proposal reusing the Twilio adapter structure, check the real trait when implementing)
- Inbound: parse by
event;connected(check token header/field if configured),start->CallInfo(call_id=call_control_id; metadatatransport=telnyx,stream_id,call_control_id,call_session_id,user_id,from,to,tags,client_state,encoding,sample_rate,custom.<name>),media(trackinbound) -> reorder buffer (bychunk) -> decode bystart.media_format.encoding: PCMU/PCMA viabrisvox_audio::codec(g711.md); L16 (16 kHz) as PCM16; G722/OPUS/AMR-WB not decodable in-tree (refuse with a clear error, or require a codec instream_codec) -> PCM16AudioFrame.dtmf-> DTMF event (ordered byoccurred_at),mark->MarkAckor drop (post-clear),stop-> hang-up (PeerHangup),error-> fault (code in message), unknowneventignored. Drop frames whosestream_iddiffers fromstart. - Outbound: PCM16 at the stream rate -> encode in the SAME codec set as
stream_bidirectional_codec(document that the host must setstream_bidirectional_codecequal to the inbound encoding to avoid transcoding; start-time config is the host's job) -> base64 ->{"event":"media","media":{"payload":..}}; markse<n>/p<n>as in the Twilio adapter;clearper rule above. The adapter must know the outbound codec: it is NOT announced instart(that is the inboundmedia_format). Make it aTelnyxConfigfield (default PCMU 8 kHz) and assert it equalsstart.media_formatwhen inbound==outbound is expected. - Auth: constant-time compare of
x-telnyx-streaming-auth-token(upgrade header; fall back toconnected.*) with the configured secret; additional option of a secret path segment. 401/403 on mismatch before the upgrade completes. The token can only be set viastreaming_startordial, notanswer/TeXML (see Auth), so the path secret is the fallback. - Hang-up: host-provided async hook (REST
hangup), plus close frame; see "Ending the call". - Reuse of the Twilio server infrastructure (call limit, accept queue, ping/idle/write timeouts, mark bookkeeping) is expected; the new parts are the reorder buffer, snake_case/UUID parsing, the codec choice and the hang-up hook.
- Gotchas found during implementation (
brisvox_transport::telnyx, tests with a scripted peer only):- The reorder buffer anchors on the first frame it sees. If chunk 2 overtakes chunk 1 at the very start, chunk 1 is dropped as late (20 ms). The first chunk number is not documented (UNVERIFIED), so nothing is assumed.
- A frame arriving while earlier ones are held takes the buffer path even when it is the chunk due (it is released at
once);
media_reorderedcounts those too. - Frames may overtake
start(nothing says they cannot): they are dropped and counted, at most 64, then the connection is refused (1008). Tested with a start timeout of 30 s: the 65th frame refuses (media_before_start == 65), 64 frames followed bystartopen the call. - Review fixes (verified findings), all by the adapter's own tests with a scripted peer:
- A stream the HOST stops on purpose (
streaming_stop, e.g. the retry after atransfer/conference command was refused with90045) must not look like the caller hanging up. Telnyx then closes the socket; whether it sendsstopfirst is UNVERIFIED (open item 7). Without help the adapter would report a hang-up to the engine (ending its session and the tool call in flight) or have the hook REST-hang-up the call being transferred. Additive API:TelnyxServer::stream_stopper()returns a cloneableStreamStopper;expect_stop(call_control_id)is called BEFOREstreaming_stopand returns whether the call streams here. ForSTOP_EXPECTED_WITHIN(30 s; a conservative window that covers the REST call plus the close, and makes a forgotten announcement harmless) astopframe, a close frame or a vanished socket of that call is the host's own doing: no hook call, no hang-up event (the session is kept, what the engine sends is discarded), the call key stays claimed (a TeXML reconnect is refused). The session ends when the engine ends it, the server shuts down or afterSTOPPED_LINGER(120 s, conservative upper bound for a retried transfer; the engine then sees the peer disconnect). Intended caller: the call-control binding's90045retry path, which must callexpect_stopbefore it issuesstreaming_stop(the API is additive; the binding itself is a separate change). max_control_bytes(default 16 KiB) bounds control messages only. Amediaframe is bounded bymax_audio_bytes(default 64 KiB; about 32 KiB of G.711, 43 KB of base64) through the websocket limit and the payload check; it used to be rejected as "control message too large" (1009, call hung up) above 16 KiB although Telnyx documents no size for inbound chunks. Everything that is notmedia, or does not parse, is still bounded bymax_control_bytes.
- A stream the HOST stops on purpose (
- Header casing of the token is irrelevant to the code (HTTP header names are case-insensitive), so open item 1 only concerns whether the header is sent at all; the adapter accepts the token from either place and refuses a wrong one from either.
- DTMF is ordered by
sequence_number(a per-connection counter shared with every other frame, so it has gaps) after a short hold (reorder_timeout), not byoccurred_at; both work, the counter avoids parsing RFC 3339. - Not done: L16 (byte order), G722/OPUS/AMR-WB,
stream_track=both_tracks(chunk numbering across tracks is undocumented), a transport-level REST hang-up (the host passes aHangupHook), per-account stream limits.
Open items to settle with a live test (needs a Telnyx account, a number, a Call Control app and a public wss URL)
- Exact header casing of
x-telnyx-streaming-auth-tokenon the upgrade; whetheranswer/TeXML have an undocumented way to set it (docs: onlystreaming_startanddialliststream_auth_token). - Whether
mediaframes inrtpmode are subject to the "once per second" rule (expected not; the guide puts the rule under media-file sending), and the behavior on 100005. - How out-of-order inbound frames really are (window size for the reorder buffer); unit of
media.timestamp;mediacadence. - L16 byte order and exact sample format; effect of
stream_bidirectional_sampling_ratevs codec (PCMU at 16000?) and which sample ratestart.media_formatreports for each combination; whether 24000/48000 work with L16/OPUS. - Meaning of
stream_bidirectional_target_legsfor a single-leg inbound call (doesoppositereach the caller?). - Residual audio after
clear; whether marks echoed bycleararrive before or after other frames; whether media afterclearplays at once. - Whether closing the WS ends the call, stops the stream, or triggers reconnect (and
enableReconnectbehavior); close codes; keepalive. - Whether a
stream_idin server frames is rejected (malformed_frame) or ignored; whether extra properties are tolerated. - Whether Telnyx plays queued audio after the server closes the socket;
errorframe effect on the connection. - Latency Telnyx adds between our
mediaand the caller's ear (feeds the LATENCY.md budget). - Query strings in
stream_url; TLS requirements; source IPs actually used (compare against the webhook CIDRs).
Sources (all fetched 2026-10-08; re-fetched and fact-checked 2026-10-08)
- https://developers.telnyx.com/docs/voice/programmable-voice/media-streaming (and .md; limits, codec table, mark/clear rules, error table, webhooks, dtmf, ordering)
- https://developers.telnyx.com/api-reference/websockets/stream-call-media-over-websocket (and .md; AsyncAPI summary, field descriptions)
- https://telnyx-openapi-ng.s3.us-east-1.amazonaws.com/media-streaming/media-streaming.yml (AsyncAPI 3.0.0 v2.0.0 raw; all JSON examples and schemas; token header)
- https://developers.telnyx.com/api-reference/call-commands/streaming-start.md (parameters, enums, defaults, response)
- https://developers.telnyx.com/api-reference/call-commands/streaming-stop.md (stream_id optional, streaming.stopped)
- https://developers.telnyx.com/api-reference/call-commands/answer-call.md (stream_* parameters; whole page read in two fetches, 129k characters)
- https://developers.telnyx.com/api-reference/call-commands/dial.md (stream_* parameters incl.
stream_auth_token,stream_bidirectional_sampling_rate; first 100k of 168k characters read) - https://developers.telnyx.com/api-reference/call-commands/hangup-call.md (hangup endpoint and error codes)
- https://developers.telnyx.com/docs/voice/programmable-voice/texml-verbs/stream (and .md; attributes, enableReconnect, Parameter, callbacks)
- https://developers.telnyx.com/docs/voice/programmable-voice/texml-verbs/connect.md (
<Connect>synchronous; instructions after run when the service stops; no Stream XML example) - https://developers.telnyx.com/docs/voice/texml/rest-api/streams.md and https://developers.telnyx.com/api-reference/texml-rest-commands/start-streaming-media-from-a-call.md (TeXML REST start/stop)
- https://developers.telnyx.com/api-reference/callbacks/texml-stream.md (webhook signing headers
telnyx-timestamp,telnyx-signature-ed25519; no payload schema) - https://developers.telnyx.com/docs/voice/sip-trunking/network-configuration/ip-whitelisting.md (webhook CIDRs apply to WebSocket streams; TCP 443)
- https://raw.githubusercontent.com/pipecat-ai/pipecat/main/src/pipecat/serializers/telnyx.py (official Pipecat serializer source: media/clear without stream id; REST auto hang-up)
- https://docs.pipecat.ai/api-reference/server/services/serializers/telnyx.md (serializer parameters; auto_hang_up)
- Web search summaries (not primary text, used only where labeled UNVERIFIED): Call Control app prerequisite from a third-party guide; the IP-allowlist answer was cross-checked against the fetched IP page.
- Not fetched / not researched: crates.io (no new crate is needed for this transport: JSON, base64 and tungstenite are already in the tree; Ed25519 webhook verification crate selection is out of scope since brisvox core does not serve the webhook), Telnyx pricing, per-account stream limits, Telnyx sample repos (team-telnyx on GitHub), the sip.telnyx.com
voice.jsonfile.
Fact-checked 2026-10-08: 62 claims checked, 9 corrected. Corrections: (1) stream_auth_token also exists on dial (POST /v2/calls), not only streaming_start; it is absent from answer (whole page read) and TeXML, so the old "unverified whether settable via dial" is resolved for dial/answer/TeXML; (2) dial also lists stream_bidirectional_sampling_rate and stream_establish_before_call_originate, while answer does not list the sampling rate (was UNVERIFIED); (3) the streaming_start description quote for stream_auth_token corrected to "An authentication token to be sent as part of the WebSocket connection"; (4) "once per second" is under the guide's "Sending media files" (MP3) subsection, not RTP; scope for RTP downgraded from "unclear" to "inference, UNVERIFIED"; (5) the claimed TeXML Connect page example (<Connect><Stream .../></Connect><Say>) does not exist on the Connect page; removed and the snippet is marked derived; (6) the 422 list under streaming_start is generic spec examples (90015, 90018, 90034, 90045, 10004, 10032, 10033), not stream-specific cases; (7) Ed25519 is inferred from the header name telnyx-signature-ed25519 (page gives no algorithm text), plus a Standard Webhooks compatibility note was added; (8) the guide says stop is sent when the call ends after streaming.stopped; the "or stream stopped via REST" part is UNVERIFIED; the streaming_stop body object is required (fields optional); (9) the Call Control streaming.failed webhook name, the webhook top-level key list and call.answered fields were not found in the re-read pages and are now marked UNVERIFIED. Still UNVERIFIED after re-check: top-level custom_parameters on dial/answer, the Call Control app prerequisite (third-party source, not re-checked), the Twilio comparison column (belongs to twilio.md), and all behavioral items in "Open items".