Connect two callers who speak different languages on the same phone call. Each caller speaks in their own language and hears the other in their own language. No third-party translation API — just Telnyx Voice Call Control and AI Inference.
The Problem With Language Barriers on Phone Calls
A US business needs to talk to a supplier in Mexico. The operations manager speaks English. The supplier speaks Spanish. Today, they use a human interpreter on a three-way call — expensive, hard to schedule, and impractical at 2 AM.
Or they use a consumer translation app on a separate device, passing the phone back and forth. Not a real conversation.
What if the phone call itself translated in real time? One caller speaks English, the other speaks Spanish, and each hears the other in their own language — live, on the same call, no interpreter in the loop.
The AI Real-Time Translation Bridge does this in 141 lines of Python. No Google Translate API, no AWS Translate, no third-party service. Just Telnyx Voice Call Control (for the phone call) and Telnyx AI Inference (for the translation).
What It Does
You POST two phone numbers and two languages to a single endpoint:
curl -X POST http://localhost:5000/bridge \
-H "Content-Type: application/json" \
-d '{"number_a": "+13125550001", "lang_a": "English",
"number_b": "+5215550002", "lang_b": "Spanish"}'
Your app calls caller A first. When they answer, it calls caller B. When B answers, the translation bridge goes live. The flow:
- Caller A speaks in English
- Telnyx transcribes the speech (STT) and sends it to your webhook
- Your app sends the transcript to Telnyx AI Inference: "Translate from English to Spanish"
- The translated text is spoken to caller B via TTS in Spanish (
es-USpronunciation) - Caller B speaks in Spanish — transcribed, translated to English, spoken to caller A
- Loop until either caller hangs up
Both callers stay on the same phone call. Neither needs to install anything. The translation happens server-side, transparently.
The Architecture
Everything lives in one 141-line Flask file. No database, no Redis, no message queue. Bridge state lives in an in-memory dict, keyed by a bridge ID that's passed through Telnyx's client_state field on every webhook event.
POST /bridge → create bridge record
│ → outbound call to caller A (client_state = {bid, side: "a"})
│
▼
call.answered (A) → TTS greeting → outbound call to B (client_state = {bid, side: "b"})
│
▼
call.answered (B) → TTS greeting → bridge state = "active"
│
▼
call.speak.ended → gather(speech, caller's language)
│
▼
call.gather.ended → translate(speech, from_lang, to_lang)
│ → TTS translated text to other caller (target language)
│ → gather next speech from same caller (their language)
│
└── loop until hangup
│
▼
call.hangup → hangup other caller → bridge state = "ended"
The key insight: the client_state field on every Telnyx action carries the bridge ID and which side (a or b) the call belongs to. When a webhook event fires, you decode the base64 client_state, look up the bridge, and know exactly which call leg you're handling.
The Language Code Mapping — Why It Matters
The original version of this sample had a subtle but critical bug: all TTS (text-to-speech) and STT (speech-to-text) calls used language_code="en-US" — even for Spanish. This meant:
- Spanish TTS was unintelligible: The translated Spanish text was spoken with English pronunciation. A Spanish speaker heard something that sounded like English-accented gibberish, not Spanish.
- Spanish STT was unreliable: The speech recognition engine listened for English phonemes. When the caller spoke Spanish, the transcription quality dropped sharply.
The fix is a simple language name → BCP-47 code mapping:
LANG_CODES = {
"english": "en-US", "spanish": "es-US", "french": "fr-FR",
"german": "de-DE", "italian": "it-IT", "portuguese": "pt-BR",
"hindi": "hi-IN", "arabic": "ar-SA", "chinese": "zh-CN",
"japanese": "ja-JP", "korean": "ko-KR", "russian": "ru-RU",
}
def lang_code(lang):
"""Map a language name to a BCP-47 code for TTS/STT."""
return LANG_CODES.get(lang.lower(), "en-US")
Now the TTS uses the target language (Spanish text → es-US pronunciation) and the STT gather uses the speaker's language (Spanish speech → es-US recognition):
# When caller A speaks English, translate to Spanish, speak to B:
client.calls.actions.speak(other_ccid, payload=translated,
voice="female", language_code=lang_code(to_lang)) # es-US
# Then gather next speech from A — A speaks English:
client.calls.actions.gather(ccid, input_type="speech",
language_code=lang_code(bridge[f"lang_{side}"])) # en-US
The same pattern applies on the call.speak.ended event — when a TTS message finishes playing to a caller, the next gather must listen in that caller's language:
elif event_type == "call.speak.ended" and bridge:
my_side = "a" if ccid == bridge["ccids"].get("a") else "b"
my_lang = lang_code(bridge[f"lang_{my_side}"])
client.calls.actions.gather(ccid, input_type="speech",
end_silence_timeout_secs=2, timeout_secs=15, language_code=my_lang)
Without this fix, the bridge "works" — calls connect, audio flows — but the translation is useless because neither caller can understand what the other is saying.
The Translation Call
Translation uses Telnyx AI Inference — the OpenAI-compatible /v2/ai/chat/completions endpoint. No external translation API. No Google, no AWS, no Azure. The same Telnyx API key that powers your phone calls powers the translation.
def translate(text, from_lang, to_lang):
resp = requests.post(INFERENCE_URL,
headers={"Authorization": f"Bearer {TELNYX_API_KEY}",
"Content-Type": "application/json"},
json={
"model": AI_MODEL,
"messages": [
{"role": "system",
"content": f"Translate from {from_lang} to {to_lang}. "
"Return ONLY the translation, nothing else."},
{"role": "user", "content": text}
],
"max_tokens": 200,
"temperature": 0.1
}, timeout=15)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
The system prompt is minimal: "Translate from English to Spanish. Return ONLY the translation, nothing else." The low temperature (0.1) keeps translations deterministic — you don't want creative interpretation of a business call. The 200-token cap is sufficient for conversational phrases.
The model defaults to moonshotai/Kimi-K2.6, available on the Telnyx AI Inference platform. You can swap to any model in the Telnyx model catalog by changing the AI_MODEL env var.
Webhook Signature Verification
Telnyx signs every webhook with an Ed25519 key. The app verifies the signature before trusting any event:
@app.route("/webhooks/voice", methods=["POST"])
def handle_voice():
try:
client.webhooks.unwrap(
request.get_data(as_text=True),
headers=dict(request.headers)
)
except Exception:
return jsonify({"error": "invalid signature"}), 401
Without this, anyone who knows your webhook URL can POST fake call events to your app — hang up real calls, inject fake speech transcripts, or trigger outbound calls. The telnyx Python SDK's webhooks.unwrap() method handles the Ed25519 verification internally.
You need your Telnyx public key (from Portal → API Keys) set as TELNYX_PUBLIC_KEY in your .env file for this to work.
State Management via client_state
Every Telnyx call action accepts a client_state field — a base64-encoded JSON blob that Telnyx passes back to you on every subsequent webhook event for the same call. This is how the app knows which bridge and which call leg (side "a" or "b") each event belongs to.
# When calling caller A:
client_state = base64.b64encode(
json.dumps({"bid": bid, "side": "a"}).encode()
).decode()
# When the webhook fires, decode it:
cs = json.loads(base64.b64decode(cs_raw))
bid = cs.get("bid") # "BR-1754020800"
side = cs.get("side") # "a" or "b"
bridge = bridges.get(bid)
No session store. No Redis. No database. The call carries its own context.
The Hangup Cascade
When either caller hangs up, the app hangs up the other caller too — a translation bridge with only one caller is pointless:
elif event_type == "call.hangup" and bridge:
other_side = "b" if side == "a" else "a"
other_ccid = bridge.get("ccids", {}).get(other_side)
if other_ccid:
requests.post(
f"https://api.telnyx.com/v2/calls/{other_ccid}/actions/hangup",
headers={"Authorization": f"Bearer {TELNYX_API_KEY}"},
json={}, timeout=10)
bridge["state"] = "ended"
Try It Yourself
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/ai-real-time-translation-bridge-python
cp .env.example .env # add TELNYX_API_KEY, TELNYX_PUBLIC_KEY, BRIDGE_NUMBER, CONNECTION_ID
pip install -r requirements.txt
python app.py
Then expose your local server:
ngrok http 5000
Configure your Call Control Application webhook URL to https://<id>.ngrok.io/webhooks/voice in the Telnyx Portal.
Trigger a bridge:
curl -X POST http://localhost:5000/bridge \
-H "Content-Type: application/json" \
-d '{"number_a": "+13125550001", "lang_a": "English",
"number_b": "+5215550002", "lang_b": "Spanish"}'
Check active bridges:
curl http://localhost:5000/bridges
Check health:
curl http://localhost:5000/health
Key links:
- Repo: https://github.com/team-telnyx/telnyx-code-examples/tree/main/ai-real-time-translation-bridge-python
- Telnyx Portal: https://portal.telnyx.com
- Call Control docs: https://developers.telnyx.com/docs/voice/call-control
- AI Inference docs: https://developers.telnyx.com/docs/inference