Twilio and Deepgram STT
This guide walks through real-time transcription of a phone call: a caller dials a Twilio number, speaks, and their words appear as live text in your server console within a fraction of a second. The app never speaks back β this is the listening half of a voice pipeline, in isolation.
The architecture is small. Twilio streams the callβs audio to your server, and your server forwards that audio to Deepgramβs streaming speech-to-text API over a single WebSocket, receiving interim and finalized transcripts as the caller talks. By the end you have a working transcriber you can call from any phone, and a clear view of how little code sits between a phone call and live text.
How it works
Transcription is a one-directional flow: audio goes in, text comes out. Your server forwards the callerβs audio to Deepgram and prints the transcripts it streams back. Your server never sends anything back to the caller, as the diagram shows.
The implementation is a single WebSocket handler per call. It forwards the callerβs audio into Deepgram with send_media and reacts to one kind of message β a transcript β by printing it. No audio path runs back to the caller, and that absence is exactly what makes this simpler than a full agent.
Which Twilio and Deepgram integration do you need?
This guide builds real-time transcription only β speech in, text out, nothing spoken back. If you need more of the pipeline, two companion guides cover those cases:
If you only need to know what was said β call analytics, compliance capture, live captions, note taking β this guide is the whole job. Reach for the Voice Agent when you also need to think and speak back.
Before you begin
You need the following accounts, keys, and tools.
- A Twilio account with a voice-capable phone number.
- A Deepgram API key. Sign up free and you start with credits, no card required.
- Python 3.10 or later.
- A tunneling tool to expose your local server to Twilio. This guide uses ngrok.
Install ngrok and authenticate it once with the token from your ngrok dashboard.
Step 1: Set up the project
Clone the companion repository, which holds the complete app.py, requirements.txt, and .env.example referenced throughout this guide.
Install the dependencies and set up configuration. The dependency list is short.
The .env file holds two values your application reads on startup (plus two optional security values covered later).
This app needs no LLM key and no text-to-speech key β it only listens and transcribes. Speech-to-text runs through the official Deepgram Python SDK (deepgram-sdk).
Verify: running python -c "import app" with the two environment variables set imports cleanly.
Step 2: Serve TwiML with <Start><Stream>
When a call connects, Twilio asks your webhook what to do. You answer with TwiML, Twilioβs XML instruction set. The key instruction here is <Start><Stream>, which opens a one-way WebSocket: Twilio forks a copy of the callerβs audio to your server and then keeps executing the rest of the TwiML. You receive audio; you never send any back.
That one design choice separates transcription from a voice agent. <Start><Stream> runs one-way in the background, which fits perfectly when your server never speaks back. Its bidirectional sibling, <Connect><Stream>, exists specifically to carry audio back into the call β which a transcriber never does. Because the stream runs in the background, the call itself needs something to do or Twilio hangs up; the <Say> and <Pause> simply hold the line open while the caller talks.
Verify: curl -X POST https://YOUR_HOST/twiml returns the XML above with your wss:// URL and a <Start> (not <Connect>) element.
Step 3: Bridge the Twilio media stream to Deepgram
The /media WebSocket carries the callβs audio. Accept the socket, open a Deepgram STT connection for the call, then forward every audio frame Twilio sends into Deepgram.
Twilio sends start, media, and stop events. On each media event, decode the base64 payload and forward the raw mulaw bytes straight into Deepgram with send_media. Nothing relays in the other direction β that missing half is the whole simplification.
Step 4: Open the Deepgram STT connection
Open one streaming connection for the life of the call. Requesting mulaw at 8 kHz mono matches Twilioβs Media Streams format exactly, so the callerβs bytes flow to Deepgram with no resampling.
interim_results gives you low-latency partial transcripts that refine as the caller keeps talking; endpointing sets how much silence marks the end of an utterance. This guide uses nova-3 on listen.v1, Deepgramβs general-purpose streaming model. For turn-taking-heavy conversational apps, Deepgram also offers Flux on v2/listen, with a different, turn-based message schema.
Step 5: Handle transcripts
Register a callback for EventType.MESSAGE and inspect each message. Transcript messages arrive as ListenV1Results objects; read the top alternativeβs text.
Two SDK details matter here:
- The callback runs inside the receive loop. On the async client an
async defhandler is legal β the SDK awaits whatever the callback returns β but it awaits it inline, between reads of the WebSocket. Anything slow in the callback stalls the socket and backs up incoming audio, so keep the handler cheap and hand real work to the event loop withasyncio.create_task. Transcription has no async work to do, so this handler just prints. is_finalvsspeech_final. Anis_finalsegment is stable text that wonβt change; interim results before it are living hypotheses.speech_finaladditionally means Deepgram detected the end of an utterance (viaendpointing). Accumulateis_finalsegments untilspeech_finalif you want one line per spoken turn.
With TwiML answering the call, audio forwarding into Deepgram, and transcripts printing as they arrive, the transcriber is complete β all thatβs left is to point a real phone call at it.
Step 6: Run and test
Start the application and open a tunnel so Twilio can reach it.
Use port 5050 (not 5000) to avoid the macOS AirPlay Receiver, which squats on port 5000 and returns 403. Use the 127.0.0.1: form so ngrok forwards over IPv4 to uvicorn (plain localhost can resolve to IPv6 and miss the server).
Next, connect the phone number to your webhook. In the Twilio Console, open Phone Numbers β Manage β Active numbers β [your number] β Voice Configuration, set A call comes in to a Webhook pointing at https://YOUR_HOST/twiml with method HTTP POST, and save.
Now place the call and confirm transcription end to end.
- Call the number and listen for the
<Say>prompt. - Speak a few sentences.
- Watch the console:
[interim]lines update live, and each finished segment prints as[final ](or[final*]at the end of an utterance). - Watch for the
[call] started stream ...line when the call connects.
Live [final] lines that match what you said confirm the bridge and Deepgram are working together.
Secure the endpoints
Your tunnel exposes both endpoints to the public internet. The companion app.py ships two optional guards that two environment variables switch on:
TWILIO_AUTH_TOKENβ validates theX-Twilio-Signatureheader so/twimlanswers only real Twilio requests. Find it in the Twilio Console under Account β API keys & tokens β Auth Token.STREAM_SECRETβ a random string the TwiML passes as a<Parameter>and the app checks on the/mediastartevent, so/mediaaccepts only the sockets your own TwiML opened. Generate one withpython -c "import secrets; print(secrets.token_urlsafe(32))".
Both are off by default (the app prints a warning) so a first local run just works, but set them before leaving the tunnel up.
Go further with Deepgram
Once the core transcriber runs, several enhancements build on the same streaming API.
- Transcribe both sides of the call. Set
track="both_tracks"on the<Stream>to capture the caller and whoever theyβre connected to via<Dial>. Twilio then sends two independent streams of mono media events β each frame tagged"track": "inbound"or"outbound"β never interleaved stereo. So keepchannels=1, readmsg["media"]["track"], and open one Deepgram connection per track. Settingchannels=2instead tells Deepgram the bytes are interleaved stereo: the connection is accepted and the transcripts come back garbled, with no error to point at the cause. - Turn on richer formatting. Add
diarize=Trueto label speakers, setlanguage=...for other languages, or tunenumerals/smart_formatfor how numbers and dates render. - Persist the transcript. Instead of printing, write finals to a database, POST them to a webhook, or push them over a WebSocket to a live-captions UI.
- Use Flux for conversational turn-taking. Deepgramβs Flux model (
v2/listen) adds built-in end-of-turn detection with a turn-based message schema β a good fit if youβre heading toward an interactive assistant. - Layer in audio intelligence. Add summaries, topics, sentiment, or intents over the same stream.