DEV Community

Harpreet Singh Seehra
Harpreet Singh Seehra

Posted on

Route Phone Calls by Geography at the Carrier Edge — One Number, Three Regions, Three Compliant Experiences

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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}
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. call.answered fires for an EU caller
  2. App sees requires_consent: True in the region config
  3. App calls gather_using_speak with the consent greeting, minimum_digits: 1, maximum_digits: 1
  4. Caller presses 1 (consent) or 2 (no consent)
  5. call.gather.ended fires with step: "consent" in client_state
  6. If digit is "1": app calls record_start, then gather_using_speak with the actual greeting
  7. If digit is "2": app calls gather_using_speak with 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"})})
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

Two guards here:

  1. SSRF protection: is_telnyx_url() only fetches URLs on telnyx.com or *.telnyx.com hosts. 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.

  2. 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_KEY and STORAGE_SECRET_KEY environment 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."
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then:

ngrok http 5000
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Check health:

curl http://localhost:5000/health
Enter fullscreen mode Exit fullscreen mode

Key links:

Top comments (0)