Tutorial Factory

Powered by Telnyx

Dev Docs Low Latency Club GitHub Log in

Outbound Call with Python and Flask

Overview

Build a production-ready Flask endpoint that initiates outbound calls using the Telnyx Voice API. This tutorial demonstrates the Call Control command-event model, proper webhook handling, and secure credential management for voice applications.

Prerequisites

  • Python 3.8 or higher.
  • A Telnyx account with an active API key from the Telnyx Portal.
  • A Telnyx phone number enabled for outbound calling.
  • A Call Control Application configured in the Telnyx Portal.
  • pip (Python package manager).
  • ngrok or similar tool for webhook testing (optional but recommended).

Step 1: Setup

Install the required dependencies:

pip install telnyx flask python-dotenv

Create a project directory and navigate into it:

mkdir telnyx-outbound-calls
cd telnyx-outbound-calls

Step 2: Configuration

Create a .env file in your project root to store credentials securely:

TELNYX_API_KEY=YOUR_API_KEY_HERE
TELNYX_PHONE_NUMBER=+15551234567
TELNYX_CONNECTION_ID=YOUR_CONNECTION_ID_HERE

Replace YOUR_API_KEY_HERE with your actual API key, +15551234567 with your Telnyx phone number in E.164 format, and YOUR_CONNECTION_ID_HERE with your Call Control Application ID from the Telnyx Portal.

Step 3: Implementation

Create app.py and initialize the Telnyx client. Define helper functions for call management with proper validation:

import os
import telnyx
from dotenv import load_dotenv

load_dotenv()

# Initialize client with the new SDK pattern
client = telnyx.Telnyx(api_key=os.getenv("TELNYX_API_KEY"))


def initiate_call(to_number: str) -> dict:
    """Initiate outbound call via Telnyx and return JSON-serializable response data."""
    from_number = os.getenv("TELNYX_PHONE_NUMBER")
    connection_id = os.getenv("TELNYX_CONNECTION_ID")

    if not from_number:
        raise ValueError("TELNYX_PHONE_NUMBER environment variable not set")
    if not connection_id:
        raise ValueError("TELNYX_CONNECTION_ID environment variable not set")

    # Validate E.164 format to prevent API errors
    if not to_number.startswith("+"):
        raise ValueError("Phone number must be in E.164 format (e.g., +15551234567)")

    # Use client.calls.dial() with connection_id (NOT call_control_id)
    response = client.calls.dial(
        from_=from_number,
        to=to_number,
        connection_id=connection_id,
    )

    # Extract serializable data — SDK objects are NOT JSON-serializable
    return {
        "call_control_id": response.data.call_control_id,
        "call_session_id": response.data.call_session_id,
        "from": from_number,
        "to": to_number,
        "status": "initiated",
    }


def get_call_status(call_control_id: str) -> dict:
    """Retrieve call status and return JSON-serializable response data."""
    response = client.calls.retrieve_status(call_control_id)

    return {
        "call_control_id": response.data.call_control_id,
        "is_alive": response.data.is_alive,
        "call_session_id": response.data.call_session_id,
    }


def hangup_call(call_control_id: str) -> dict:
    """Hangup active call and return confirmation."""
    response = client.calls.actions.hangup(call_control_id)

    return {
        "call_control_id": response.data.call_control_id,
        "status": "hangup_requested",
    }

Step 4: Testing

Add Flask routes with comprehensive error handling and webhook support:

from flask import Flask, jsonify, request

app = Flask(__name__)

# Store active calls for demo purposes (use database in production)
active_calls = {}


@app.route("/calls/initiate", methods=["POST"])
def initiate_call_endpoint():
    """HTTP endpoint to initiate outbound call."""
    data = request.get_json()

    if not data:
        return jsonify({"error": "Request body required"}), 400

    to_number = data.get("to")

    if not to_number:
        return jsonify({"error": "Missing required field: 'to'"}), 400

    try:
        result = initiate_call(to_number)
        # Store call for tracking (use database in production)
        active_calls[result["call_control_id"]] = result
        return jsonify(result), 200

    except telnyx.AuthenticationError:
        return jsonify({"error": "Invalid API key"}), 401
    except telnyx.RateLimitError:
        return jsonify({"error": "Rate limit exceeded. Please slow down."}), 429
    except telnyx.APIStatusError as e:
        return jsonify({"error": str(e), "status_code": e.status_code}), e.status_code
    except telnyx.APIConnectionError:
        return jsonify({"error": "Network error connecting to Telnyx"}), 503
    except ValueError as e:
        return jsonify({"error": str(e)}), 400


@app.route("/calls/<call_control_id>/status", methods=["GET"])
def get_call_status_endpoint(call_control_id):
    """HTTP endpoint to retrieve call status."""
    try:
        result = get_call_status(call_control_id)
        return jsonify(result), 200

    except telnyx.AuthenticationError:
        return jsonify({"error": "Invalid API key"}), 401
    except telnyx.APIStatusError as e:
        return jsonify({"error": str(e), "status_code": e.status_code}), e.status_code
    except telnyx.APIConnectionError:
        return jsonify({"error": "Network error connecting to Telnyx"}), 503


@app.route("/calls/<call_control_id>/hangup", methods=["POST"])
def hangup_call_endpoint(call_control_id):
    """HTTP endpoint to hangup active call."""
    try:
        result = hangup_call(call_control_id)
        # Remove from active calls tracking
        active_calls.pop(call_control_id, None)
        return jsonify(result), 200

    except telnyx.AuthenticationError:
        return jsonify({"error": "Invalid API key"}), 401
    except telnyx.APIStatusError as e:
        return jsonify({"error": str(e), "status_code": e.status_code}), e.status_code
    except telnyx.APIConnectionError:
        return jsonify({"error": "Network error connecting to Telnyx"}), 503


@app.route("/webhooks/voice", methods=["POST"])
def voice_webhook():
    """Handle Telnyx voice webhooks for call events."""
    data = request.get_json()

    if not data:
        return jsonify({"error": "Invalid webhook payload"}), 400

    event_type = data.get("data", {}).get("event_type")
    call_control_id = data.get("data", {}).get("payload", {}).get("call_control_id")

    print(f"Received webhook: {event_type} for call {call_control_id}")

    # Handle different call events
    if event_type == "call.answered":
        print(f"Call {call_control_id} was answered")
    elif event_type == "call.hangup":
        print(f"Call {call_control_id} ended")
        # Clean up call tracking
        active_calls.pop(call_control_id, None)
    elif event_type == "call.initiated":
        print(f"Call {call_control_id} was initiated")

    return jsonify({"status": "received"}), 200


if __name__ == "__main__":
    app.run(debug=True, port=5000)

Start the server:

python app.py

Test the endpoint using curl:

curl -X POST http://localhost:5000/calls/initiate \
  -H "Content-Type: application/json" \
  -d '{"to": "+15559876543"}'

Expected response:

{
  "call_control_id": "v3:T02llQxIyaRkhfRKxgAP8nY511EhFLizdvdUKJKHjsUBrQ",
  "call_session_id": "428c31b6-7a59-4cf0-8b2b-8b2b8b2b8b2b",
  "from": "+15551234567",
  "to": "+15559876543",
  "status": "initiated"
}

Complete Code

#!/usr/bin/env python3
"""Production-ready Flask endpoint for outbound calls via Telnyx Voice API."""

import os
import telnyx
from dotenv import load_dotenv
from flask import Flask, jsonify, request

load_dotenv()

app = Flask(__name__)

# Initialize client with the new SDK pattern
client = telnyx.Telnyx(api_key=os.getenv("TELNYX_API_KEY"))

# Store active calls for demo purposes (use database in production)
active_calls = {}


def initiate_call(to_number: str) -> dict:
    """Initiate outbound call via Telnyx and return JSON-serializable response data."""
    from_number = os.getenv("TELNYX_PHONE_NUMBER")
    connection_id = os.getenv("TELNYX_CONNECTION_ID")

    if not from_number:
        raise ValueError("TELNYX_PHONE_NUMBER environment variable not set")
    if not connection_id:
        raise ValueError("TELNYX_CONNECTION_ID environment variable not set")

    # Validate E.164 format to prevent API errors
    if not to_number.startswith("+"):
        raise ValueError("Phone number must be in E.164 format (e.g., +15551234567)")

    # Use client.calls.dial() with connection_id (NOT call_control_id)
    response = client.calls.dial(
        from_=from_number,
        to=to_number,
        connection_id=connection_id,
    )

    # Extract serializable data — SDK objects are NOT JSON-serializable
    return {
        "call_control_id": response.data.call_control_id,
        "call_session_id": response.data.call_session_id,
        "from": from_number,
        "to": to_number,
        "status": "initiated",
    }


def get_call_status(call_control_id: str) -> dict:
    """Retrieve call status and return JSON-serializable response data."""
    response = client.calls.retrieve_status(call_control_id)

    return {
        "call_control_id": response.data.call_control_id,
        "is_alive": response.data.is_alive,
        "call_session_id": response.data.call_session_id,
    }


def hangup_call(call_control_id: str) -> dict:
    """Hangup active call and return confirmation."""
    response = client.calls.actions.hangup(call_control_id)

    return {
        "call_control_id": response.data.call_control_id,
        "status": "hangup_requested",
    }


@app.route("/calls/initiate", methods=["POST"])
def initiate_call_endpoint():
    """HTTP endpoint to initiate outbound call."""
    data = request.get_json()

    if not data:
        return jsonify({"error": "Request body required"}), 400

    to_number = data.get("to")

    if not to_number:
        return jsonify({"error": "Missing required field: 'to'"}), 400

    try:
        result = initiate_call(to_number)
        # Store call for tracking (use database in production)
        active_calls[result["call_control_id"]] = result
        return jsonify(result), 200

    except telnyx.AuthenticationError:
        return jsonify({"error": "Invalid API key"}), 401
    except telnyx.RateLimitError:
        return jsonify({"error": "Rate limit exceeded. Please slow down."}), 429
    except telnyx.APIStatusError as e:
        return jsonify({"error": str(e), "status_code": e.status_code}), e.status_code
    except telnyx.APIConnectionError:
        return jsonify({"error": "Network error connecting to Telnyx"}), 503
    except ValueError as e:
        return jsonify({"error": str(e)}), 400


@app.route("/calls/<call_control_id>/status", methods=["GET"])
def get_call_status_endpoint(call_control_id):
    """HTTP endpoint to retrieve call status."""
    try:
        result = get_call_status(call_control_id)
        return jsonify(result), 200

    except telnyx.AuthenticationError:
        return jsonify({"error": "Invalid API key"}), 401
    except telnyx.APIStatusError as e:
        return jsonify({"error": str(e), "status_code": e.status_code}), e.status_code
    except telnyx.APIConnectionError:
        return jsonify({"error": "Network error connecting to Telnyx"}), 503


@app.route("/calls/<call_control_id>/hangup", methods=["POST"])
def hangup_call_endpoint(call_control_id):
    """HTTP endpoint to hangup active call."""
    try:
        result = hangup_call(call_control_id)
        # Remove from active calls tracking
        active_calls.pop(call_control_id, None)
        return jsonify(result), 200

    except telnyx.AuthenticationError:
        return jsonify({"error": "Invalid API key"}), 401
    except telnyx.APIStatusError as e:
        return jsonify({"error": str(e), "status_code": e.status_code}), e.status_code
    except telnyx.APIConnectionError:
        return jsonify({"error": "Network error connecting to Telnyx"}), 503


@app.route("/webhooks/voice", methods=["POST"])
def voice_webhook():
    """Handle Telnyx voice webhooks for call events."""
    data = request.get_json()

    if not data:
        return jsonify({"error": "Invalid webhook payload"}), 400

    event_type = data.get("data", {}).get("event_type")
    call_control_id = data.get("data", {}).get("payload", {}).get("call_control_id")

    print(f"Received webhook: {event_type} for call {call_control_id}")

    # Handle different call events
    if event_type == "call.answered":
        print(f"Call {call_control_id} was answered")
    elif event_type == "call.hangup":
        print(f"Call {call_control_id} ended")
        # Clean up call tracking
        active_calls.pop(call_control_id, None)
    elif event_type == "call.initiated":
        print(f"Call {call_control_id} was initiated")

    return jsonify({"status": "received"}), 200


if __name__ == "__main__":
    app.run(debug=True, port=5000)

Troubleshooting

Issue Problem Solution
Authentication Error (401) The endpoint returns {"error": "Invalid API key"} with HTTP 401. Verify your TELNYX_API_KEY in the .env file matches the key shown in the Telnyx Portal. Ensure there are no trailing spaces or quotes. If the key was regenerated recently, update your environment file and restart the Flask server.
Missing Connection ID Error You receive ValueError: TELNYX_CONNECTION_ID environment variable not set when initiating calls. Ensure you have created a Call Control Application in the Telnyx Portal and copied its Connection ID to your .env file as TELNYX_CONNECTION_ID. This is different from your API key and phone number—it links your number to the Call Control application.
Invalid Phone Number Format You receive a 400 error stating "Phone number must be in E.164 format" or a Telnyx API error about invalid destination. Ensure all phone numbers use E.164 format: start with +, followed by country code and number without spaces or dashes. Example: +15551234567 (US) or +447700900123 (UK). Update your test curl command to use properly formatted numbers.

Next Steps