Tutorial Factory

Powered by Telnyx

Dev Docs Low Latency Club GitHub Log in

Inbound Call Webhook with Python and FastAPI

Overview

Build a production-ready FastAPI webhook endpoint to handle inbound calls using the Telnyx Voice API. This tutorial demonstrates the command-event model of Call Control, proper webhook validation, and automated call handling with answer, speak, and hangup actions.

Prerequisites

  • Python 3.8 or higher.
  • A Telnyx account with an active API key from the Telnyx Portal.
  • A Telnyx phone number configured for inbound calls.
  • A Call Control Application ID (connection_id).
  • ngrok or similar tool for webhook testing.

Step 1: Setup

Install the required dependencies:

pip install telnyx fastapi uvicorn python-dotenv

Create a project directory and navigate into it:

mkdir telnyx-inbound-webhook
cd telnyx-inbound-webhook

Step 2: Configuration

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

TELNYX_API_KEY=YOUR_API_KEY_HERE
TELNYX_CONNECTION_ID=YOUR_CONNECTION_ID_HERE

Replace YOUR_API_KEY_HERE with your actual API key and YOUR_CONNECTION_ID_HERE with your Call Control Application ID from the Telnyx Portal.

Step 3: Implementation

Create main.py and initialize the Telnyx client. Define helper functions to handle call actions with proper error handling:

import os
import telnyx
from dotenv import load_dotenv
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse

load_dotenv()

app = FastAPI(title="Telnyx Inbound Call Webhook")

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


def answer_call(call_control_id: str) -> dict:
    """Answer an incoming call and return response data."""
    response = client.calls.actions.answer(call_control_id)
    return {
        "call_control_id": response.data.call_control_id,
        "status": "answered"
    }


def speak_to_caller(call_control_id: str, message: str) -> dict:
    """Play text-to-speech message to the caller."""
    response = client.calls.actions.speak(
        call_control_id,
        payload=message,
        voice="female",
        language="en-US"
    )
    return {
        "call_control_id": response.data.call_control_id,
        "status": "speaking"
    }


def hangup_call(call_control_id: str) -> dict:
    """Terminate the call."""
    response = client.calls.actions.hangup(call_control_id)
    return {
        "call_control_id": response.data.call_control_id,
        "status": "hangup"
    }

Step 4: Testing

Add the webhook endpoint with comprehensive event handling for production resilience:

@app.post("/webhook/voice")
async def handle_voice_webhook(request: Request):
    """Handle incoming voice webhook events from Telnyx."""
    try:
        payload = await request.json()
        event_type = payload.get("data", {}).get("event_type")
        call_control_id = payload.get("data", {}).get("payload", {}).get("call_control_id")

        if not event_type or not call_control_id:
            raise HTTPException(status_code=400, detail="Invalid webhook payload")

        # Handle different call events
        if event_type == "call.initiated":
            # Answer the incoming call
            result = answer_call(call_control_id)
            return result

        elif event_type == "call.answered":
            # Play welcome message after call is answered
            message = "Hello! Thank you for calling. This call will now end. Goodbye!"
            result = speak_to_caller(call_control_id, message)
            return result

        elif event_type == "call.speak.ended":
            # Hang up after the message finishes playing
            result = hangup_call(call_control_id)
            return result

        elif event_type == "call.hangup":
            # Call ended - log for cleanup
            return {
                "call_control_id": call_control_id,
                "status": "call_ended",
                "message": "Call cleanup completed"
            }

        # Return success for unhandled events
        return {"status": "received", "event_type": event_type}

    except telnyx.AuthenticationError:
        raise HTTPException(status_code=401, detail="Invalid API key")
    except telnyx.RateLimitError:
        raise HTTPException(status_code=429, detail="Rate limit exceeded")
    except telnyx.APIStatusError as e:
        raise HTTPException(status_code=e.status_code, detail=str(e))
    except telnyx.APIConnectionError:
        raise HTTPException(status_code=503, detail="Network error connecting to Telnyx")


@app.get("/health")
async def health_check():
    """Health check endpoint for monitoring."""
    return {"status": "healthy", "service": "telnyx-voice-webhook"}


if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

Start the server:

python main.py

Expose your local server using ngrok:

ngrok http 8000

Configure your Telnyx phone number to send webhooks to your ngrok URL:

https://your-ngrok-url.ngrok.io/webhook/voice

Test by calling your Telnyx phone number. You should hear the welcome message before the call ends.

Complete Code

#!/usr/bin/env python3
"""Production-ready FastAPI webhook for handling inbound calls via Telnyx."""

import os
import telnyx
from dotenv import load_dotenv
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse

load_dotenv()

app = FastAPI(title="Telnyx Inbound Call Webhook")

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


def answer_call(call_control_id: str) -> dict:
    """Answer an incoming call and return response data."""
    response = client.calls.actions.answer(call_control_id)
    return {
        "call_control_id": response.data.call_control_id,
        "status": "answered"
    }


def speak_to_caller(call_control_id: str, message: str) -> dict:
    """Play text-to-speech message to the caller."""
    response = client.calls.actions.speak(
        call_control_id,
        payload=message,
        voice="female",
        language="en-US"
    )
    return {
        "call_control_id": response.data.call_control_id,
        "status": "speaking"
    }


def hangup_call(call_control_id: str) -> dict:
    """Terminate the call."""
    response = client.calls.actions.hangup(call_control_id)
    return {
        "call_control_id": response.data.call_control_id,
        "status": "hangup"
    }


@app.post("/webhook/voice")
async def handle_voice_webhook(request: Request):
    """Handle incoming voice webhook events from Telnyx."""
    try:
        payload = await request.json()
        event_type = payload.get("data", {}).get("event_type")
        call_control_id = payload.get("data", {}).get("payload", {}).get("call_control_id")

        if not event_type or not call_control_id:
            raise HTTPException(status_code=400, detail="Invalid webhook payload")

        # Handle different call events
        if event_type == "call.initiated":
            # Answer the incoming call
            result = answer_call(call_control_id)
            return result

        elif event_type == "call.answered":
            # Play welcome message after call is answered
            message = "Hello! Thank you for calling. This call will now end. Goodbye!"
            result = speak_to_caller(call_control_id, message)
            return result

        elif event_type == "call.speak.ended":
            # Hang up after the message finishes playing
            result = hangup_call(call_control_id)
            return result

        elif event_type == "call.hangup":
            # Call ended - log for cleanup
            return {
                "call_control_id": call_control_id,
                "status": "call_ended",
                "message": "Call cleanup completed"
            }

        # Return success for unhandled events
        return {"status": "received", "event_type": event_type}

    except telnyx.AuthenticationError:
        raise HTTPException(status_code=401, detail="Invalid API key")
    except telnyx.RateLimitError:
        raise HTTPException(status_code=429, detail="Rate limit exceeded")
    except telnyx.APIStatusError as e:
        raise HTTPException(status_code=e.status_code, detail=str(e))
    except telnyx.APIConnectionError:
        raise HTTPException(status_code=503, detail="Network error connecting to Telnyx")


@app.get("/health")
async def health_check():
    """Health check endpoint for monitoring."""
    return {"status": "healthy", "service": "telnyx-voice-webhook"}


if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

Troubleshooting

Issue Problem Solution
Webhook Not Receiving Events Your FastAPI server is running but Telnyx webhooks are not being received when calls are made to your phone number. Verify your ngrok tunnel is active and the webhook URL in your Telnyx Call Control Application matches your ngrok URL exactly (including /webhook/voice path). Check that your phone number is associated with the correct Call Control Application in the Telnyx Portal. Test the webhook URL directly with a GET request to confirm it's accessible.
Authentication Error (401) The webhook endpoint returns HTTP 401 errors when processing Telnyx events. 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 around the API key. If the key was regenerated recently, update your environment file and restart the FastAPI server with python main.py.
Call Actions Failing Call control actions like answer, speak, or hangup return errors or don't execute properly. Confirm your TELNYX_CONNECTION_ID environment variable contains your Call Control Application ID, not a phone number or other identifier. Verify the Call Control Application is properly configured in the Telnyx Portal with the correct webhook URL. Check that the call_control_id from the webhook payload is being passed correctly to the action functions.

Next Steps