Route Phone Calls by Geography at the Carrier Edge
Edge Geo Smart Router — a 256-line Flask webhook that detects caller region from the E.164 number prefix and applies different language, voice, recording, and GDPR consent policy per region. US callers get English AI, LATAM gets Spanish, EU gets a consent prompt before any recording starts.
The Problem With Global Phone Numbers
A single phone number can ring from anywhere. A US toll-free number might receive calls from New York, São Paulo, London, and Berlin in the same hour. Traditional IVRs handle this with a single language menu — "Press 1 for English, 2 for Spanish" — which puts the burden on the caller and guarantees nothing about compliance.
The compliance part is the hard one. If you record a call from an EU caller without explicit consent, you've violated GDPR. If you don't record a US call, you lose your quality assurance trail. If you greet a LATAM caller in English, you've already lost them.
The Edge Geo Smart Router solves this by making the routing decision at the carrier edge — before the first word of greeting is spoken. The caller's number prefix (+1 for US, +55 for Brazil, +44 for UK) is the geo signal. No geoip lookup, no external database, no round-trip to a backend. The webhook receives call.initiated, reads the from field, classifies the region, and applies the right config for the rest of the call.
What It Does
One phone number. Three regions. Three compliant experiences.
| Region | Language | Voice | Recording | Consent |
|---|---|---|---|---|
| US | en-US | AWS.Polly.Joanna-Neural | ✅ auto-start | ❌ not required |
| LATAM | es-MX | AWS.Polly.Lupe-Neural | ✅ auto-start | ❌ not required |
| EU | en-GB | AWS.Polly.Amy-Neural | ⏸️ after consent | ✅ DTMF prompt |
| DEFAULT | en-US | AWS.Polly.Joanna-Neural | ✅ auto-start | ❌ not required |
US and LATAM callers get straight into a conversation with AI in their language. EU callers hear: "This call will be recorded for quality purposes. Press 1 to consent and continue, or press 2 to proceed without recording." If they press 1, recording starts. If they press 2, the call proceeds unrecorded. Either way, GDPR is satisfied.
The Architecture
Everything lives in one 256-line Flask file. No database, no Redis, no message queue. Call state travels in Telnyx's client_state field — a base64-encoded JSON blob that Telnyx passes back to you on every webhook event for the same call.
Caller dials your number
↓
Telnyx sends call.initiated webhook → /webhooks/voice
↓
app detects region from `from` number prefix
↓
app calls /actions/answer with client_state = {region, step: "greeting"}
↓
Telnyx sends call.answered → app reads client_state
↓
┌─────────────┬─────────────┬─────────────┐
│ US / LATAM │ EU │ DEFAULT │
│ │ │ │
│ record_start│ gather DTMF │ record_start│
│ gather_speak│ (consent) │ gather_speak│
│ (greeting) │ │ (greeting) │
└─────┬───────┴──────┬──────┴──────┬──────┘
↓ ↓ ↓
call.gather.ended → app reads step from client_state
↓
"consent" step → did they press 1?
↓ → record_start + gather (greeting)
"conversation" step → call_inference(speech, language)
↓ → gather_speak (AI response)
loop until hangup
↓
call.hangup → clean up session
Region Detection: The Prefix Match
The routing logic is 12 lines of Python. No geoip, no database, no external API call.
EU_PREFIXES = ["+33", "+34", "+39", "+44", "+49", "+31", "+32", "+43",
"+45", "+46", "+47", "+48", "+351", "+353", "+358",
"+352", "+356", "+370", "+371", "+372", "+420", "+421"]
LATAM_PREFIXES = ["+52", "+55", "+54", "+56", "+57", "+51", "+58",
"+53", "+506", "+507", "+502", "+503", "+504", "+505"]
def detect_region(phone):
if not phone:
return "DEFAULT"
for prefix in EU_PREFIXES:
if phone.startswith(prefix):
return "EU"
for prefix in LATAM_PREFIXES:
if phone.startswith(prefix):
return "LATAM"
if phone.startswith("+1"):
return "US"
return "DEFAULT"
EU is checked first because some European country codes share the +1 prefix with NANP. By checking EU first, we avoid misrouting through the US path.
Per-Region Config
Each region has its own voice, language, greeting, recording policy, and consent requirement.
REGION_CONFIG = {
"US": {"language": "en-US", "voice": "AWS.Polly.Joanna-Neural",
"greeting": "Welcome. How can I help you today?",
"requires_consent": False, "record": True},
"LATAM": {"language": "es-MX", "voice": "AWS.Polly.Lupe-Neural",
"greeting": "Bienvenido. Como puedo ayudarle hoy?",
"requires_consent": False, "record": True},
"EU": {"language": "en-GB", "voice": "AWS.Polly.Amy-Neural",
"greeting": "This call will be recorded for quality purposes. "
"Press 1 to consent and continue, or press 2 to "
"proceed without recording.",
"requires_consent": True, "record": False},
"DEFAULT":{"language": "en-US", "voice": "AWS.Polly.Joanna-Neural",
"greeting": "Welcome. How can I help you?",
"requires_consent": False, "record": True}
}
The voice ID gotcha
Bare "female" and "male" voice IDs only work with service_level: "basic", which is en-US only. If you try "voice": "female" with "language": "es-MX", the call silently fails. The fix is to use full neural voice IDs — AWS.Polly.Lupe-Neural for es-MX, AWS.Polly.Amy-Neural for en-GB. Each voice supports its respective language.
The GDPR Consent Flow
EU callers get a DTMF (touch-tone) consent prompt before any recording starts. The flow is:
-
call.answeredfires for an EU caller - App sees
requires_consent: Truein the region config - App calls
gather_using_speakwith the consent greeting,minimum_digits: 1,maximum_digits: 1 - Caller presses 1 (consent) or 2 (no consent)
-
call.gather.endedfires withstep: "consent"in client_state - If digit is
"1": app callsrecord_start, thengather_using_speakwith the actual greeting - If digit is
"2": app callsgather_using_speakwith the greeting, no recording
if step == "consent":
digits = ep.get("digits", "")
if digits == "1":
requests.post(f".../actions/record_start",
headers=HEADERS, json={"format": "mp3", "channels": "dual"})
call_sessions.get(cc_id, {})["consented"] = True
requests.post(f".../actions/gather_using_speak",
json={"payload": "Thank you. How can I help you today?",
"voice": config["voice"], "language": config["language"],
"input_type": "speech", "timeout_secs": 30,
"client_state": encode_state({"region": region, "step": "conversation"})})
The key insight: recording only starts AFTER the caller presses 1. If they press 2, the call proceeds with AI conversation but no recording is captured. GDPR satisfied by design, not by policy.
State Management Without a Database
Every call carries its own state in Telnyx's client_state field. This is a base64-encoded JSON blob that Telnyx passes back to you on every subsequent webhook event for the same call. You set it when you call an action (answer, gather, record), and you read it when the next event fires.
def encode_state(data):
return base64.b64encode(json.dumps(data).encode()).decode()
def decode_state(b64):
try: return json.loads(base64.b64decode(b64).decode())
except: return {}
The state contains just two fields: region (so every event handler knows which region config to apply) and step (so call.gather.ended knows whether it's handling the consent prompt or the actual conversation). No session store, no Redis, no database. The call is self-describing.
Webhook Signature Verification
Telnyx signs every webhook with an Ed25519 key. The signature is over <timestamp>|<raw body>. Verifying it before you trust the payload is non-optional — without it, anyone can POST to your webhook URL and inject fake call events.
def verify_telnyx_signature(raw_body, headers):
if _TELNYX_VERIFY_KEY is None:
return False
signature = headers.get("telnyx-signature-ed25519", "")
timestamp = headers.get("telnyx-timestamp", "")
if not signature or not timestamp:
return False
try:
if abs(time.time() - int(timestamp)) > MAX_SKEW_SECONDS:
return False
signed = f"{timestamp}|".encode() + raw_body
_TELNYX_VERIFY_KEY.verify(base64.b64decode(signature), signed)
return True
except (InvalidSignature, ValueError, TypeError):
return False
The 300-second skew window rejects replay attacks. The raw body is verified, not the parsed JSON — because JSON parsing is not canonical (key order, whitespace), and the signature would fail. Get the public key from Portal → Keys & Credentials.
Recording Archival to Telnyx Storage
When call.recording.saved fires, the app downloads the recording and uploads it to Telnyx Storage (S3-compatible). The key includes the region and call session ID for later retrieval.
elif event_type == "call.recording.saved":
recording_url = ep.get("recording_urls", {}).get("mp3")
if not (recording_url and is_telnyx_url(recording_url)):
return jsonify({"status": "ok"})
if s3 is None:
app.logger.info("Recording ready but Storage credentials not configured; skipping.")
return jsonify({"status": "ok"})
audio = requests.get(recording_url, headers=HEADERS, timeout=30).content
key = f"recordings/{region}/{call_session_id}.mp3"
s3.put_object(Bucket=STORAGE_BUCKET, Key=key, Body=audio, ContentType="audio/mpeg")
Two guards here:
SSRF protection:
is_telnyx_url()only fetches URLs ontelnyx.comor*.telnyx.comhosts. The recording URL comes from the webhook payload, which is attacker-controllable. Without this check, a forged webhook could make your server download from any URL — including internal services.Storage credentials ≠ API key: Telnyx Storage is S3-compatible but uses its own access/secret key pair, created under Portal → Storage → Credentials. The original version of this sample reused the Telnyx API key as both the S3 access key AND the secret key — which silently failed. The fix: separate
STORAGE_ACCESS_KEYandSTORAGE_SECRET_KEYenvironment variables. If they're not set, archival is gracefully skipped (the call still works, just no recording saved).
AI Inference: Multilingual Replies
When the caller speaks, the app sends the transcript to Telnyx AI Inference (OpenAI-compatible /v2/ai/chat/completions endpoint) with a language instruction derived from the region config.
def call_inference(prompt, language="en-US"):
lang_instruction = "Respond in Spanish." if "es" in language else "Respond in English."
resp = requests.post(INFERENCE_URL, headers=HEADERS, timeout=15, json={
"model": AI_MODEL,
"messages": [{"role": "system",
"content": f"You are a helpful business assistant. Be concise. {lang_instruction}"},
{"role": "user", "content": prompt}]
})
if resp.ok:
return resp.json()["choices"][0]["message"]["content"]
return "I apologize, I'm having trouble right now. Please try again."
The AI response is then spoken back to the caller via gather_using_speak with the region's voice and language, and the loop continues until the caller hangs up.
Try It Yourself
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/edge-geo-smart-router-python
cp .env.example .env # add TELNYX_API_KEY, TELNYX_PUBLIC_KEY, optional Storage creds
pip install -r requirements.txt
python app.py
Then:
ngrok http 5000
Configure your Call Control Application webhook URL to https://<id>.ngrok.io/webhooks/voice in the Telnyx Portal.
Call your Telnyx number from:
- A US number (+1) → English AI, auto-recording
- A Brazil number (+55) → Spanish AI, auto-recording
- A UK number (+44) → English (en-GB) AI, consent prompt first
Check the routing config:
curl http://localhost:5000/regions
Check health:
curl http://localhost:5000/health
Key links:
- Repo: https://github.com/team-telnyx/telnyx-code-examples/tree/main/edge-geo-smart-router-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
Top comments (0)