Telephony with Twilio

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Written on 2026-10-01 against the Twilio Voice documentation (TwiML, ConversationRelay and Media Streams), the twilio Python helper 9.11.2, twilio-java 13.0.2, FastAPI 0.142.2 on Python 3.12, and Spring AI 2.0.1 on Spring Boot 4.1.x with Java 21 (see the version baseline). Nothing on this page was run: a Twilio account, a purchased number and live calls are needed, and real-time telephone audio cannot be exercised here. The message shapes, the TwiML and the SDK calls were taken from the official documentation linked after each block; the handlers around them are illustrative and not compiled. Verify attribute names and defaults against the Twilio documentation before relying on them, because ConversationRelay in particular has been gaining options.

Twilio is the most common way to give the DocsAssistant a telephone number. It owns the phone network side (numbers, carriers, the call itself) and hands your server either text or raw audio over a WebSocket. That choice is the decisive one, and it is the same spectrum as in Voice Architectures: with ConversationRelay Twilio runs speech recognition and speech synthesis and you only exchange text with the DocsAssistant; with Media Streams you receive 8 kHz audio and run the whole speech pipeline yourself. The hosted alternative where a vendor does everything, including the number, is on ElevenLabs Agents.

sequenceDiagram participant C as Caller (PSTN) participant T as Twilio Programmable Voice participant W as Your webhook (/voice) participant S as WebSocket server (/relay) participant R as DocsAssistant core (RAG) C->>T: dials the Twilio number T->>W: HTTP POST (CallSid, From, To) + X-Twilio-Signature W-->>T: TwiML Connect ConversationRelay url=wss://.../relay T->>S: WebSocket upgrade T->>S: setup (callSid, from, to) C->>T: speaks (Twilio STT) T->>S: prompt (voicePrompt, last=true) S->>R: ask(callSid, from, voicePrompt) R-->>S: token stream S-->>T: text tokens (last=false ... last=true) T-->>C: Twilio TTS speaks the answer C->>T: interrupts T->>S: interrupt (utteranceUntilInterrupt) S->>R: cancel generation

Programmable Voice and TwiML

A Twilio number is configured with a voice webhook: when a call arrives, Twilio sends an HTTP request to your URL with form parameters such as CallSid, From, To and CallStatus, and expects TwiML (an XML dialect) back. Each element is a verb that tells Twilio what to do next: <Say> and <Play> produce audio, <Gather> collects speech or digits, <Dial> connects the caller to another party, and <Connect> hands the call to a long-lived integration through a nested noun such as <ConversationRelay> or <Stream>. Status callbacks report the call lifecycle to a second URL.

The webhook for the DocsAssistant has one job: answer with the TwiML that connects the call to your WebSocket server.

voice_webhook.py: TwiML that connects an inbound call to ConversationRelay (FastAPI and the twilio Python helper)
from fastapi import FastAPI, Form
from fastapi.responses import Response
from twilio.twiml.voice_response import Connect, VoiceResponse

app = FastAPI()


@app.post("/voice")
async def voice(CallSid: str = Form(), From: str = Form()):
    response = VoiceResponse()
    connect = Connect()
    connect.conversation_relay(
        url="wss://docs.example.com/relay",
        welcome_greeting="Hello, this is an AI assistant for the documentation. How can I help?",
        language="en-US",
        tts_provider="ElevenLabs",
        voice="<voice id from the Twilio ConversationRelay voices page>",
        transcription_provider="Deepgram",
        dtmf_detection=True,
        interruptible=True,
    )
    response.append(connect)
    return Response(content=str(response), media_type="application/xml")

Source: Twilio: TwiML Connect ConversationRelay and TwiML overview. The helper builds the same XML you could write by hand (<Response><Connect><ConversationRelay url="wss://..." .../></Connect></Response>). The welcome greeting is spoken by Twilio before any WebSocket message is needed, which hides the connection setup time; saying in it that the caller is talking to an AI is also the disclosure that most jurisdictions expect.

ConversationRelay

ConversationRelay is Twilio’s managed speech layer for AI voice agents. Twilio performs speech-to-text on the caller’s audio, detects the end of the utterance, sends you the transcript as text, and speaks the text you send back with a text-to-speech provider, all over a single WebSocket that your server accepts. For a retrieval-augmented assistant this is the shortest path on Twilio, because the DocsAssistant core keeps its text interface (ask(conversationId, userId, text), as in Serving RAG as an API) and no audio code is written.

Messages

Every message is a JSON text frame with a type field.

Direction type Purpose and fields

Twilio to you

setup

Sent once after the connection opens: sessionId, accountSid, callSid, parentCallSid, from, to, forwardedFrom, callType, callerName, direction, callStatus and customParameters. This is where the call is mapped to a conversation and a user.

Twilio to you

prompt

The caller’s speech as text: voicePrompt, lang and last. With the default behaviour you receive the finished utterance with last true.

Twilio to you

interrupt

The caller spoke over the agent: utteranceUntilInterrupt (the part of your text that had been spoken) and durationUntilInterruptMs.

Twilio to you

dtmf

A key press: digit.

Twilio to you

error

A problem report: description.

You to Twilio

text

Text to speak: token, last, and optionally lang, interruptible and preemptible. Send tokens as they arrive from the LLM and mark the final one with last true.

You to Twilio

play

Play an audio file: source, optionally loop, interruptible and preemptible.

You to Twilio

sendDigits

Send DTMF tones into the call: digits (for navigating another system’s menu).

You to Twilio

language

Change the speech languages mid-call: ttsLanguage and transcriptionLanguage.

You to Twilio

end

End the session and return control of the call to TwiML, optionally with handoffData (a string you choose).

Source: Twilio: ConversationRelay WebSocket messages. Because Twilio does the end-of-turn detection, the tuning concepts in Turn-Taking and Interruptions apply as configuration (the transcription provider and speech model, hints for product names) rather than as code you write.

A FastAPI handler

The handler keeps one task per call so that an interrupt can cancel the answer that is being generated. ask is the same async token iterator used by the other channels; it should be configured with the voice persona (short sentences, no markdown, numbers spelled for speech).

relay.py: ConversationRelay WebSocket handler calling the RAG core (FastAPI)
import asyncio
import json

from fastapi import FastAPI, WebSocket, WebSocketDisconnect

from docsassistant.core import ask   # async iterator of text tokens, markdown-free for voice

app = FastAPI()


@app.websocket("/relay")
async def relay(ws: WebSocket):
    await ws.accept()
    call_sid, caller = "unknown", "anonymous"
    answer: asyncio.Task | None = None

    async def speak(text: str):
        try:
            async for token in ask(call_sid, caller, text):
                await ws.send_text(json.dumps({"type": "text", "token": token, "last": False}))
            await ws.send_text(json.dumps({"type": "text", "token": "", "last": True}))
        except asyncio.CancelledError:
            raise   # caller interrupted: stop generating, send nothing more

    try:
        while True:
            message = json.loads(await ws.receive_text())
            kind = message["type"]
            if kind == "setup":
                call_sid, caller = message["callSid"], message["from"]
            elif kind == "prompt" and message.get("last", True):
                if answer and not answer.done():
                    answer.cancel()
                answer = asyncio.create_task(speak(message["voicePrompt"]))
            elif kind == "interrupt":
                if answer and not answer.done():
                    answer.cancel()
                # message["utteranceUntilInterrupt"] is what the caller actually heard; store it in memory
            elif kind == "dtmf":
                if message["digit"] == "0":
                    if answer and not answer.done():    # stop the answer before ending, or tokens follow "end"
                        answer.cancel()
                        await asyncio.gather(answer, return_exceptions=True)
                    await ws.send_text(json.dumps({"type": "end", "handoffData": json.dumps({"reason": "agent_requested"})}))
            elif kind == "error":
                print("relay error:", message["description"])
    except WebSocketDisconnect:
        if answer:
            answer.cancel()

Source: Twilio: ConversationRelay onboarding and the message reference. When the caller interrupts, Twilio stops the audio itself; the server’s duty is to stop generating and to record that only the text up to utteranceUntilInterrupt was heard, so that the conversation memory does not claim that the caller heard the whole answer. The WebSocket endpoint must be reachable from Twilio over wss://, which in development means a tunnel to the local port. The DTMF handoff (key 0) cancels the running answer before sending end. Otherwise text tokens would keep arriving after the session has been told to end.

A Spring Boot handler

The same logic in Spring Boot uses a TextWebSocketHandler. The reactive ChatClient.stream() is subscribed to once per prompt and the Disposable is kept so that an interrupt can cancel it (see Streaming and Reactive).

RelayHandler.java: ConversationRelay WebSocket handler (Spring Boot, Spring AI)
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import reactor.core.Disposable;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;

public class RelayHandler extends TextWebSocketHandler {

    private final ChatClient docsAssistant;
    private final ObjectMapper json = new ObjectMapper();
    private final Map<String, Disposable> running = new ConcurrentHashMap<>();

    public RelayHandler(ChatClient docsAssistant) {
        this.docsAssistant = docsAssistant;
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws IOException {
        JsonNode in = this.json.readTree(message.getPayload());
        switch (in.path("type").asText()) {
            case "setup" -> {
                session.getAttributes().put("callSid", in.path("callSid").asText());
                session.getAttributes().put("from", in.path("from").asText());
            }
            case "prompt" -> {
                if (in.path("last").asBoolean(true)) {
                    cancel(session);
                    String callSid = (String) session.getAttributes().get("callSid");
                    Disposable d = this.docsAssistant.prompt()
                            .user(in.path("voicePrompt").asText())
                            .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, callSid))
                            .stream().content()
                            .doOnNext(t -> send(session, Map.of("type", "text", "token", t, "last", false)))
                            .doOnComplete(() -> send(session, Map.of("type", "text", "token", "", "last", true)))
                            .subscribe();
                    this.running.put(session.getId(), d);
                }
            }
            case "interrupt" -> cancel(session);
            case "dtmf" -> {
                if ("0".equals(in.path("digit").asText())) {
                    cancel(session);   // stop the running answer first, or its tokens would follow "end"
                    send(session, Map.of("type", "end", "handoffData", "{\"reason\":\"agent_requested\"}"));
                }
            }
            default -> { }
        }
    }

    private void cancel(WebSocketSession session) {
        Disposable d = this.running.remove(session.getId());
        if (d != null) {
            d.dispose();
        }
    }

    private void send(WebSocketSession session, Map<String, Object> body) {
        try {
            synchronized (session) {
                session.sendMessage(new TextMessage(this.json.writeValueAsString(body)));
            }
        } catch (IOException e) {
            cancel(session);
        }
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        cancel(session);
    }
}

Source: Spring Framework: WebSocket server (TextWebSocketHandler, registered with @EnableWebSocket and a WebSocketConfigurer) and Twilio: ConversationRelay WebSocket messages. Jackson 3 (tools.jackson) is the default in Spring Boot 4; adjust the imports if the project still uses Jackson 2. A WebSocketSession is not safe for concurrent sends, hence the synchronized block. For the underlying reactive model see Reactive Programming.

Language, voices, providers and DTMF

  • Language and voice are TwiML attributes on <ConversationRelay>: language (or separate ttsLanguage and transcriptionLanguage), ttsProvider and voice. Twilio documents Google, Amazon Polly and ElevenLabs as text-to-speech providers and Google and Deepgram for transcription, each with its own voice identifiers and speechModel options; the list is in Voice configuration. Pick the provider with the criteria from Text to Speech and Speech to Text: latency to first audio, voice quality and language coverage.

  • Switching languages mid-call is the language outbound message, for example after the assistant detects that the caller changed language.

  • Hints (a hints attribute) bias recognition towards product names and acronyms that the DocsAssistant corpus uses.

  • DTMF is enabled with dtmfDetection; key presses then arrive as dtmf messages, which is the dependable way to offer "press 0 for a person" and to collect account numbers that speech recognition would garble.

  • Interruption is on by default (interruptible); sensitivity attributes tune how easily background noise cuts the agent off.

Best practices

  • Stream tokens as soon as they arrive; Twilio starts speaking before the answer is complete, which is the single largest latency saving.

  • Write for the ear in the system prompt: short sentences, no lists or URLs, spelled-out numbers. Citations become "the installation guide says" rather than links.

  • Key conversation memory on callSid, and look up the caller from from only when the product’s privacy rules allow it (see Access Control and Privacy).

  • Handle a closed socket by cancelling generation; paying for tokens that nobody will hear is waste.

  • Pass your own identifiers through customParameters (the <Parameter> TwiML noun) instead of encoding them in the URL.

Media Streams

Media Streams gives you the call’s audio over a WebSocket. The TwiML is <Connect><Stream url="wss://…​"/></Connect> (bidirectional, and the call stays open while the stream runs) or <Start><Stream> (unidirectional fork that does not block the call). The audio is 8 kHz, 8-bit mu-law (audio/x-mulaw), mono, base64-encoded in JSON text frames, in 20 ms chunks. A telephone call therefore carries byte per second of audio per direction, far below the 16 or 24 kHz that speech models prefer, which is why speech-to-text accuracy on phone audio needs a telephony-tuned model (see Speech to Text).

Direction event Content

Twilio to you

connected

protocol and version; the first frame after the upgrade.

Twilio to you

start

streamSid, callSid, accountSid, tracks, mediaFormat (encoding, sample rate, channels) and customParameters.

Twilio to you

media

media.payload (base64 audio), media.track, media.chunk and media.timestamp, plus sequenceNumber.

Twilio to you

dtmf

dtmf.digit for a key press.

Twilio to you

mark

mark.name, echoed when Twilio has finished playing everything you sent before the mark.

Twilio to you

stop

The stream ended (the call hung up or the stream was stopped).

You to Twilio

media

streamSid and media.payload: base64 mu-law audio to play to the caller.

You to Twilio

mark

streamSid and mark.name: ask for a notification when playback reaches this point.

You to Twilio

clear

streamSid: discard audio that Twilio has buffered but not yet played; this is the barge-in primitive.

Source: Twilio: Media Streams WebSocket messages and TwiML Stream. Twilio plays outbound audio faster than real time into its own buffer, so the interruption logic is different from a browser: when the user-speaking event fires, the server sends clear to drop the buffered audio and uses the mark acknowledgements to know what was actually played (the concepts are on Turn-Taking and Interruptions).

stream_twiml.py: TwiML for a bidirectional Media Stream with a custom parameter
from twilio.twiml.voice_response import Connect, Stream, VoiceResponse

response = VoiceResponse()
connect = Connect()
stream = Stream(url="wss://docs.example.com/media")
stream.parameter(name="tenant", value="docs")
connect.append(stream)
response.append(connect)
print(response)

Source: Twilio: TwiML Stream (the <Parameter> noun and the parameter helper in TwiML for Python).

Wiring Media Streams into Pipecat

Pipecat includes a Twilio frame serializer that converts between the JSON events above and Pipecat audio frames, resampling between 8 kHz mu-law and the pipeline’s internal rate. The framework page is Pipecat; the telephony specific part is the transport.

pipecat_twilio.py: a Twilio Media Streams transport in Pipecat
from fastapi import FastAPI, WebSocket
from pipecat.runner.utils import parse_telephony_websocket
from pipecat.serializers.twilio import TwilioFrameSerializer
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams, FastAPIWebsocketTransport

app = FastAPI()


@app.websocket("/media")
async def media(ws: WebSocket):
    await ws.accept()
    transport_type, call_data = await parse_telephony_websocket(ws)   # reads connected and start
    serializer = TwilioFrameSerializer(
        stream_sid=call_data["stream_id"],
        call_sid=call_data["call_id"],
        account_sid="<Twilio account SID>",
        auth_token="<Twilio auth token>",   # lets the serializer end the call through the REST API
    )
    transport = FastAPIWebsocketTransport(
        websocket=ws,
        params=FastAPIWebsocketParams(audio_in_enabled=True, audio_out_enabled=True, serializer=serializer),
    )
    # build the pipeline: transport.input() -> stt -> context aggregator -> llm (DocsAssistant) -> tts
    #                      -> transport.output(), then run it with a PipelineTask

Source: Pipecat: Twilio serializer. The module paths and constructor arguments have moved between Pipecat releases (1.12.0 is the baseline), so confirm them against the installed version. The auth token is read from configuration in real code, never written in source.

Wiring Media Streams into the hand-rolled Spring pipeline

In Building a Voice Pipeline by Hand the pipeline is a set of stages connected by queues: audio in, voice-activity detection, transcription, the DocsAssistant, synthesis, audio out. A Twilio call adds only an adapter at both ends. Inbound media frames are base64-decoded, mu-law expanded to 16-bit PCM and resampled from 8 kHz to the rate the TranscriptionModel expects. Synthesised audio from the TextToSpeechModel is resampled to 8 kHz, mu-law compressed, split into 20 ms (160 byte) chunks, base64-encoded and sent as media events. The voice-activity stage’s "user started speaking" signal becomes a clear event, and mark events track playback position. A Spring TextWebSocketHandler as above is the adapter; the difference is that it now exchanges audio, not text. (SpeechModel is the Spring AI 1.0 name; the 2.0 interfaces are used here.)

Outbound calls

An outbound call is created through the REST API with the number to call, a Twilio number to call from, and a URL that returns the TwiML for the call once it is answered, the same /voice style endpoint.

outbound.py and OutboundCall.java: placing a call that connects to the assistant
from twilio.rest import Client

client = Client("<account sid>", "<auth token>")   # read from configuration in real code
call = client.calls.create(
    to="+15551230000",
    from_="+15559870000",
    url="https://docs.example.com/voice-outbound",
    status_callback="https://docs.example.com/call-status",
)
print(call.sid)
import com.twilio.Twilio;
import com.twilio.rest.api.v2010.account.Call;
import com.twilio.type.PhoneNumber;
import java.net.URI;

Twilio.init(accountSid, authToken);
Call call = Call.creator(
        new PhoneNumber("+15551230000"),
        new PhoneNumber("+15559870000"),
        URI.create("https://docs.example.com/voice-outbound"))
    .create();
System.out.println(call.getSid());

Source: Twilio: Call resource and the twilio-java repository. The outbound use cases that suit the DocsAssistant are appointment-style confirmations and callbacks the caller asked for; unsolicited calls need the recipient’s consent, and detecting an answering machine (the MachineDetection parameter) before speaking avoids talking to a voicemail greeting. Local rules on automated calls and on disclosing AI voices differ by country and are outside this page.

Transfer to a human

Two mechanisms hand the caller to a person.

  • ConversationRelay: send an end message. Twilio closes the WebSocket session and requests the next TwiML from the action URL configured on <Connect>; the handoffData string is posted to that URL as HandoffData, so the webhook can decide where to send the call. The handoff data should carry the reason and a short summary of the conversation so that the agent does not ask again.

  • Media Streams: stop the stream by updating the call with new TwiML (through the REST API, calls(call_sid).update(twiml=…​)), or end the connection so that Twilio proceeds to the next TwiML verb after <Connect>.

In both cases the TwiML that completes the transfer is <Dial>.

handoff.py: the action URL that returns TwiML for the transfer
import json

from fastapi import FastAPI, Form
from fastapi.responses import Response
from twilio.twiml.voice_response import Dial, VoiceResponse

app = FastAPI()


@app.post("/relay-ended")
async def relay_ended(HandoffData: str = Form(default="")):
    response = VoiceResponse()
    if HandoffData:
        reason = json.loads(HandoffData).get("reason")
        response.say("One moment, I am connecting you to a colleague.")
        dial = Dial(timeout=30, caller_id="+15559870000")
        dial.number("+15550001111")   # or dial.sip("sip:support@pbx.example.com") for a PBX
        response.append(dial)
    else:
        response.hangup()
    return Response(content=str(response), media_type="application/xml")

Source: Twilio: TwiML Dial and ConversationRelay: the action URL and handoffData. Dialling a <Sip> endpoint is how a call reaches an Asterisk PBX; that side is on Telephony with Asterisk and SIP. Decide the transfer triggers explicitly (caller asks for a person, repeated low-confidence answers, a sensitive topic, a DTMF key) and test the case where nobody answers.

Webhook signature validation

Twilio signs every HTTP request it sends to your webhooks and status callbacks (and the WebSocket upgrade for ConversationRelay and Media Streams). The X-Twilio-Signature header is a base64 HMAC-SHA1 of the full request URL followed by the sorted POST parameters, keyed with the account’s auth token. A server that accepts unsigned requests lets anyone start billable sessions against the DocsAssistant, so every Twilio-facing endpoint validates the signature before doing work.

validate.py: signature validation (Python RequestValidator in a FastAPI dependency)
from fastapi import Depends, FastAPI, HTTPException, Request
from twilio.request_validator import RequestValidator

validator = RequestValidator("<auth token>")   # from configuration


async def twilio_signed(request: Request):
    form = dict(await request.form())
    signature = request.headers.get("X-Twilio-Signature", "")
    url = str(request.url)   # behind a proxy, rebuild the public https URL Twilio actually called
    if not validator.validate(url, form, signature):
        raise HTTPException(status_code=403)


app = FastAPI(dependencies=[Depends(twilio_signed)])
TwilioSignatureFilter.java: the same check with the Twilio Java SDK
import com.twilio.security.RequestValidator;
import java.util.Map;

RequestValidator validator = new RequestValidator(authToken);
boolean valid = validator.validate(publicUrl, formParameters, request.getHeader("X-Twilio-Signature"));
if (!valid) {
    response.setStatus(403);
    return;
}

Source: Twilio: Validating requests and the twilio-java repository. In Spring Boot put this in a servlet filter or a HandlerInterceptor on the webhook paths; formParameters is a Map<String, String> of the POST body. The commonest failure is a URL mismatch: behind a load balancer or tunnel the validator must receive the public https URL (including the query string) that Twilio signed, not the internal one. For the WebSocket upgrade the same header is present on the handshake request. Rotate the auth token if it is ever exposed, and prefer API keys over the primary token for REST calls.

The SDKs

Both helper libraries wrap the REST API, the TwiML builders and the request validator. twilio 9.11.2 for Python provides twilio.rest.Client, twilio.twiml.voice_response and twilio.request_validator. twilio-java 13.0.2 (com.twilio.sdk:twilio) provides Twilio.init, resource classes such as Call and Message, TwiML classes under com.twilio.twiml.voice, and com.twilio.security.RequestValidator. Newer ConversationRelay attributes may land in the TwiML documentation before a helper exposes a builder method for them; when that happens, return the XML as a string, which is always valid.

Choosing a Twilio integration

Option You write Latency and control Choose it when

ConversationRelay

A text WebSocket handler around the DocsAssistant core.

Twilio’s speech stack and providers; least code; you can tune the provider and language but not the models.

The assistant already works on text and a phone number is the goal.

Media Streams with a framework

A transport and pipeline configuration (Pipecat or LiveKit).

Full control of STT, TTS, VAD and turn detection; 8 kHz audio handling included.

You need your own speech models, a local pipeline or data residency.

Media Streams by hand

Audio adapters and every stage.

Maximum control, maximum work.

The pipeline is the product, or you are learning.

Native integration of a hosted agent platform

Almost nothing.

The vendor’s pipeline.

You want the fastest result and accept the vendor processing the audio (ElevenLabs Agents, Realtime APIs).

Start with ConversationRelay, validate every request, keep the transfer path to a person tested, and move to Media Streams only when a requirement (a model Twilio does not offer, audio that may not leave your network) forces it. Operating concerns such as call quality, cost per minute, logging and recordings are on Production Voice Agents.