Illustration of two smartphones with anonymous calls, and a shield labeled "Voice Proxy" between them, connected by lines.

Forward a Telephone Call Via Voice Proxy with Python and Starlette

Published on October 15, 2026

Time to read: 11 minutes

Using a Voice Proxy to Keep Personal Numbers Private 

You know the routine: you've ordered delivery, but the door code to your apartment building isn't working, so the delivery person needs to call you to complete the order. Neither you nor the delivery person wants to expose a personal phone number to the other. That's a privacy concern and it's also a practical one. Callers are more likely to answer a number with a familiar area code than an unknown one. 

Call forwarding can route calls through an intermediary number, keeping personal phone numbers private. This pattern is known as a voice proxy. 

In this tutorial, we'll use Starlette and Vonage to build a voice proxy in Python. Starlette handles the server logic and Vonage handles the telephony. Read on to learn about each component before completing the tutorial, or jump straight to the README to get the code up and running. 

What Is Starlette? 

If you’re a Python developer, you’re probably familiar with some of the most popular frameworks from Flask to Django to FastAPI. Each one provides structure and opinions about HTTP-based development, but at their core they all share a fundamental pattern of routing requests to handlers and returning responses. Starlette is the lightweight ASGI toolkit that powers FastAPI and can be used directly when you need a minimal but fully async-capable web framework without all the additional features. 

What is ASGI and How Does Starlette Leverage It? 

The Asynchronous Server Gateway Interface (ASGI) is a specification (or calling convention) for web servers to forward requests to async-capable Python frameworks. You can think of ASGI as a contract that defines how web servers interact with Python applications. The interface signature looks like this: 

async def app(scope, receive, send): 
	pass 

The parameters in this interface are defined as: 

  • scope is a dictionary of metadata describing the incoming request. This parameter helps define what kind of connection needs to be created so communication between a server and a Python application can be successful. 

  • receive is a callable used to accept and process events from a client. It returns a dictionary representing the events it receives. 

  • send is a callable that sends events back to the client. This is what is called to build and send a response. 

So an example of a complete ASGI application looks like this:

async def app(scope, receive, send): 

    # Determine the type of connection from the `scope` metadata 
    if scope["type"] == "http" and scope["method"] == "POST": 

        # You need the body data for `send()`so call `receive()` 
        body_event = await receive() 

        body = body_event["body"] 
         
        # Parse and use the body data 
        data = json.loads(body) 

        # Then send response 
        await send({ 
            "type": "http.response.start", 
            "status": 200, 
            "headers": [[b"content-type", b"application/json"]], 
        }) 

        await send({ 
            "type": "http.response.body", 
            "body": json.dumps({"received": data}).encode(), 
        }) 

This pattern is what Starlette abstracts away so you don’t have to write out each contract by hand. Without Starlette, your web app would have to handle your routing with layers of if/else statements: 

async def app(scope, receive, send):

    if scope["type"] == "http":

        path = scope["path"]
        method = scope["method"]

        # Manual routing logic
        if path == "/users" and method == "GET":
            await send(
                {
                    "type": "http.response.start",
                    "status": 200,
                    "headers": [[b"content-type", b"application/json"]],
                }
            )
            await send(
                {
                    "type": "http.response.body",
                    "body": b'[{"id": 1, "name": "Alice"}]',
                }
            )

        elif path == "/users/1" and method == "GET":
            await send(
                {
                    "type": "http.response.start",
                    "status": 200,
                    "headers": [[b"content-type", b"application/json"]],
                }
            )
            await send(
                {
                    "type": "http.response.body",
                    "body": b'{"id": 1, "name": "Alice"}',
                }
            )

        else:
            await send(
                {
                    "type": "http.response.start",
                    "status": 404,
                    "headers": [[b"content-type", b"text/plain"]],
                }
            )
            await send(
                {
                    "type": "http.response.body",
                    "body": b"Not Found",
                }
            )

Starlette abstracts away all the boilerplate redundancy into a clean API: 

from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route

async def list_users(request):
    return JSONResponse([{"id": 1, "name": "Alice"}])

async def get_user(request):
    user_id = request.path_params["user_id"]
    return JSONResponse({"id": user_id, "name": "Alice"})

routes = [
    Route("/users", list_users, methods=["GET"]),
    Route("/users/{user_id}", get_user, methods=["GET"]),
]

app = Starlette(routes=routes)

The code above can be broken down into these building blocks: 

  • The Route object maps a URL path and HTTP method to a function. In this example, "/users" and the GET method are mapped to the list_users function. 

  • The Request object that both list_users and get_user take as a parameter contains the metadata about the incoming connection (the scope) as well as the function that processes the message received from the client (the receive callable). 

  • The JSONResponse object processes the message that will be sent back to the client (the send callable). 

Because Starlette handles routing and request/response handling without bundling additional layers like ORM, authentication, or templating, it's an ideal fit for a focused project like this voice proxy.  

You can refer to the project README for an example of a minimal Starlette application. To learn more about Starlette, refer to the documentation. 

What Is a Voice Proxy and How Does It Work? 

A voice proxy (also known as call masking) is a method by which phone calls are routed to their destinations through an intermediary phone number. The final phone number is masked behind the intermediary number, thereby maintaining its privacy. Each participant receives a temporary number enabling them to communicate directly in real-time without divulging either party’s personal number. Once the call is completed, the intermediary number is released and available to be used in another proxy.  

When someone calls a voice proxy number, the call is received by an application that then forwards it to the real number of the intended recipient without ever revealing it to the caller. Similarly, the person receiving the call sees only the intermediary number and not the caller’s actual number, thus maintaining mutual phone number anonymity. 

Call masking is useful in peer-to-peer commerce scenarios such as the one described earlier in this tutorial and and can help track and control calls and transactions. With Vonage Voice and the Vonage Python SDK, you can implement call masking with an API that abstracts away all the complex telephony infrastructure. 

Vonage Voice and Call Control Objects (NCCO) 

The Vonage Voice API lets you connect people around the world and automate voice interactions that extend your product experience. You can programmatically manage voice calls using two complementary models: 

  • One is initiated by your application, using REST API calls or SDKs. 

  • The other is requested from your application by Vonage, using webhooks and Call Control Objects (NCCO) instructions. 

This dual approach supports flexible call handling to implement various call flows and programmable voice use cases, from simple notifications to sophisticated contact center flows and AI integration. 

An NCCO is a JSON-based structure that defines what should happen during a call. You return it in response to Vonage’s webhook request when the call connects. 

Common NCCO actions include: 

  • talk: Convert text to speech and play it to the caller 

  • stream: Play an audio file 

  • record: Record the call audio 

  • connect: Connect another participant to the conversation 

For this application, we'll use the connect action to connect the call to different endpoints. An endpoint can be another phone number, a WebSocket, or a SIP endpoint. In this tutorial, we'll connect to a phone number to mask the origin numbers on either side of the call. 

Refer to the Voice API and NCCO documentation to learn more. 

Tunneling 

Since we’ll be running the Starlette application locally, we’ll need a way to make it accessible to Vonage via a public URL. That’s where tunneling comes in. Tunneling exposes local servers to the public internet through temporary or static public URLs. ngrok is a software platform that provides this service. 

You can learn about using ngrok with Vonage in the documentation. 

Tutorial: Forward a Telephone Call Via Voice Proxy with Python, Starlette, and Vonage 

In this tutorial, we will use Starlette to define a webhook that the Voice API can make a request to and create a voice proxy using a NCCO connect action. The workflow is as follows: 

  1. The end-user dials a Vonage virtual number. 

  2. Vonage makes a request to the webhook defined in the Starlette server. 

  3. The server returns a connect NCCO action. 

  4. Vonage forwards the call to the configured destination number. 

  5. Neither party sees the real phone numbers – they only see the virtual number. 

View the full code for this tutorial on GitHub and follow the README to get it up and running. We'll cover the notable parts of the code below. 

Prerequisites 

Tutorial Setup 

Create a Vonage Voice application 

You will need to create a Vonage Voice application to use with this tutorial. There are two ways you can do that: 

Either way, make sure to select and link a Voice capable number to use with this tutorial. 

Set up your development environment 

Follow the instructions in the README to create a virtual environment and install the project dependencies: 

python -m venv venv 
source venv/bin/activate 

And then:

pip install -r requirements.txt

Learn more about virtual environments with our blog post. 

Configure your environment variables

Copy and rename the env.template file: 

cp .env.template .env

And populate the file with your Vonage virtual number and the number for a device that you will use to test the voice proxy application. This is the number that Vonage will connect to using a virtual number to mask the origin number: 

VONAGE_VIRTUAL_NUMBER=your-vonage-virtual-number
TEST_TO_NUMBER=your-test-number-for-voice-proxy

Spin up an ngrok tunnel 

To make your application accessible to Vonage, use ngrok to create a tunnel to a public URL on port 8000: 

ngrok http 8000

To learn more about using ngrok with Vonage applications, read our blog post. 

Define and Configure Your Webhooks With Starlette and Vonage

The Starlette application can be found in the server.py file. With all the boilerplate of ASGI abstracted away, Starlette enables us to build a voice proxy with fewer than 100 lines of code, while Vonage handles the telephony complexity through an API backed by a Pydantic-powered SDK.  

Here we define a VoiceProxy class that extends Starlette’s HTTPEndpoint class. This class allows us to configure HTTP handlers as methods on one class rather than writing a single function that requires manually checking the request method.  

class VoiceProxy(HTTPEndpoint):
    """A Starlette application that defines webhook endpoints
    for Vonage to make a request to when the Vonage virtual number is called"""

    async def get(self, request):

        from_number = request.query_params.get("from")
        to_number_endpoint = PhoneEndpoint(number=TEST_TO_NUMBER)

        logger.info(
            f"Received call to: {VONAGE_VIRTUAL_NUMBER} from: {from_number} -- creating voice proxy to: {TEST_TO_NUMBER}"
        )

        greeting = (
            "Hello! Voice proxy in process. Check logs for details. "
            "You should notice that the from number uses the intermediary virtual number."
        )

        ncco: list[NccoAction] = [
            Talk(text=greeting),
            Connect(endpoint=[to_number_endpoint], from_=VONAGE_VIRTUAL_NUMBER),
        ]

        return JSONResponse(
            [action.model_dump(by_alias=True, exclude_none=True) for action in ncco]
        )

    async def post(self, request):
        event = await request.json()

        uuid = event.get("uuid")
        from_number = event.get("from")
        to_number = event.get("to")
        direction = event.get("direction")
        status = event.get("status")

        logger.info(
            f"Voice proxy for uuid: {uuid} -- {direction} TO number: {to_number} FROM number: {from_number} -- STATUS: {status}"
        )

        return PlainTextResponse()

The GET method is responsible for returning the NCCO action that creates the call masking with connect. In this instance, we have connect configured to use a phone number endpoint. This is the number that will receive the masked call from the configured from number, which, in this scenario, is your Vonage virtual number: 

    async def get(self, request):


        from_number = request.query_params.get("from")
        to_number_endpoint = PhoneEndpoint(number=TEST_TO_NUMBER)


        logger.info(
            f"Received call to: {VONAGE_VIRTUAL_NUMBER} from: {from_number} -- creating voice proxy to: {TEST_TO_NUMBER}"
        )


        greeting = (
            "Hello! Voice proxy in process. Check logs for details. "
            "You should notice that the from number uses the intermediary virtual number."
        )


        ncco: list[NccoAction] = [
            Talk(text=greeting),
            Connect(endpoint=[to_number_endpoint], from_=VONAGE_VIRTUAL_NUMBER),
        ]


        return JSONResponse(
            [action.model_dump(by_alias=True, exclude_none=True) for action in ncco]
        )

This method is the webhook Vonage will send a request to when your virtual number is called. 

Vonage Voice applications can also be configured with an event webhook. This is an endpoint that requests are sent to as Voice events occur. In our voice proxy application, we use the post method to log our Voice workflow and demonstrate call masking in action: 

    async def post(self, request):
        event = await request.json()


        uuid = event.get("uuid")
        from_number = event.get("from")
        to_number = event.get("to")
        direction = event.get("direction")
        status = event.get("status")


        logger.info(
            f"Voice proxy for uuid: {uuid} -- {direction} TO number: {to_number} FROM number: {from_number} -- STATUS: {status}"
        )


        return PlainTextResponse()

Finally, we register our routes and pass them to the Starlette app: 

routes = [
    Route("/", VoiceProxy),
]


app = Starlette(routes=routes)

Start the application using uvicorn:

uvicorn server:app --reload

Configure your webhooks with Vonage 

For Vonage to reach your app, use the public URL generated by ngrok. Edit your Voice application in the Vonage Developer Dashboard so that the Answer URL and Event URL point to the ngrok-provided URL. It will look something like this:

https://<your-ngrok-url>/

Make sure to set the appropriate request methods for each one:  

  • GET for the Answer URL 

  • POST for the Event URL 

Try It Out! 

To try out the application, you will need two devices capable of making and receiving a phone call. 

  1. On the device with the phone number not configured for use with the application, call your Vonage virtual number. 

  2. You should hear a text-to-speech greeting followed by ringing as Vonage calls the test number you provided to the application. 

  3. The device associated with the test number should ring, but instead of coming from the device you made the initial call with, the number displayed should be your virtual number. In other words, the Vonage virtual number masks your private phone number. 

You can check the call event logs to further confirm this:

INFO:server:Voice proxy for uuid: 90f693... -- inbound TO number: 12015551497 FROM number: 18055556289 -- STATUS: answered 

INFO:     216.147.2.112:0 - "POST / HTTP/1.1" 200 OK 

INFO:server:Voice proxy for uuid: 115e4... -- outbound TO number: 14155555833 FROM number: 12015551497 -- STATUS: ringing 

INFO:     216.147.2.112:0 - "POST / HTTP/1.1" 200 OK 

INFO:server:Voice proxy for uuid: 115e4... -- outbound TO number: 14155555833 FROM number: 12015551497 -- STATUS: started 

INFO:     216.147.2.13:0 - "POST / HTTP/1.1" 200 OK 

INFO:server:Voice proxy for uuid: 90f693... -- inbound TO number: 12016441497 FROM number: 18055556289 -- STATUS: completed 

INFO:     216.147.2.101:0 - "POST / HTTP/1.1" 200 OK 

INFO:server:Voice proxy for uuid: 115e4... -- outbound TO number: 14155555833 FROM number: 12015551497 -- STATUS: completed 

INFO:     216.147.2.101:0 - "POST / HTTP/1.1" 200 OK 

INFO:server:Voice proxy for uuid: 115e4... -- outbound TO number: 14155555833 FROM number: 12015551497 -- STATUS: cancelled 

In this example: 

  • 12015551497 is the intermediary masking number. 

  • 18055556289 is the number initiating the call. 

  • 14155555833 is the number being connected to using the masking number. 

Using a proxy, the callers can be connected without revealing their phone numbers to each other. This enables peer-to-peer customer service while protecting customer privacy.  

In Summary 

In this tutorial, you built a voice proxy application in Python using Starlette and the Vonage Voice API. A voice proxy  -- also known as call masking -- routes calls through an intermediary virtual number so neither party ever sees the other's real phone number. You learned how Starlette's lightweight ASGI toolkit simplifies webhook handling, how Vonage's Call Control Objects (NCCOs) define call behavior, and how ngrok exposes your local server to Vonage during development. By the end, you had a working application that connects two callers through a Vonage virtual number while keeping both phone numbers private -- a pattern with real-world applications in delivery, ride-sharing, marketplace, and customer service platforms. To learn more about how you can use Vonage to integrate Voice into your applications and services, check out our Voice Learning Paths or watch the video below on how to automate calls with AI. 

Further Reading and References 

Have a question or want to share what you're building?

Stay connected and keep up with the latest developer news, tips, and events.

Share:

https://a.storyblok.com/f/270183/400x400/2c4345217d/liz-acosta.jpeg
Liz AcostaDeveloper Advocate

Liz Acosta is a Developer Advocate at Vonage. While her career path from film student to marketer to engineer to Developer Advocate might seem unconventional, it’s pretty typical for Developer Relations! Liz loves pizza, plants, pugs, and Python.