Things you’ll need

  • An active Twilio developer key
  • One or more Twilio provisioned phone numbers (covered below)
  • The Twilio Python client library (pip install twilio)

Prefer to look at code? See the example project!

We have a complete example project using Daily as a transport with Twilio as a phone provider. This guide walks through the important components and best practices when integrating voice calls.

How It Works

Here’s the sequence of events when someone calls your Twilio number:

  1. Twilio receives an incoming call to your phone number
  2. Twilio calls your webhook server (/call endpoint)
  3. Your server creates a Daily room with SIP capabilities
  4. Your server starts the bot process with the room details
  5. Your server responds to Twilio with TwiML that puts the caller on hold with music
  6. Upon receiving the on_dialin_ready event, the bot forwards the call to the Daily SIP endpoint
  7. The caller and bot are connected, and the bot handles the conversation

Getting a phone number

  • Visit console.twilio.com and purchase a new phone number (or via the API)
  • Ensure your purchased number supports Voice capabilities
  • Ensure your purchased number appears in your ‘active numbers’ list

Project setup

You’ll need to set two environment variables for your project: TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN, which you can obtain from the Twilio console.

.env
DAILY_API_KEY=...
DAILY_API_URL=https://api.daily.co/v1
TWILIO_ACCOUNT_SID=...
TWILIO_AUTH_TOKEN=...
OPENAI_API_KEY=...
CARTESIA_API_KEY=...

Configuring your bot runner

You’ll need a HTTP service that can receive incoming call hooks and trigger a new agent session. We discussed the concept of a bot runner in the deployment section, which we’ll build on here to add support for incoming phone calls.

Here’s how to implement this with FastAPI:

server.py
@app.post("/call", response_class=PlainTextResponse)
async def handle_call(request: Request):
    """Handle incoming Twilio call webhook."""
    print("Received call webhook from Twilio")

    try:
        # Get form data from Twilio webhook
        form_data = await request.form()
        data = dict(form_data)

        # Extract call ID (required to forward the call later)
        call_sid = data.get("CallSid")
        if not call_sid:
            raise HTTPException(status_code=400, detail="Missing CallSid in request")

        print(f"Processing call with ID: {call_sid}")

        # 1. Create a Daily room with SIP capabilities
        # More on this in the next section
        room_details = await create_sip_room(request.app.session)
        room_url = room_details["room_url"]
        token = room_details["token"]
        sip_endpoint = room_details["sip_endpoint"]

        # 2. Start the bot process with the necessary parameters
        bot_cmd = f"python bot.py -u {room_url} -t {token} -i {call_sid} -s {sip_endpoint}"
        subprocess.Popen(shlex.split(bot_cmd))
        print(f"Started bot process with command: {bot_cmd}")

        # 3. IMPORTANT: Put the caller on hold with music while the bot initializes
        # This is critical to avoid timing issues with Daily SIP initialization
        resp = VoiceResponse()
        resp.play(
            url="https://your-hold-music.mp3",  # Your custom hold music URL
            loop=10,  # Loop the music until the bot is ready
        )

        return str(resp)

    except Exception as e:
        print(f"Unexpected error: {str(e)}")
        raise HTTPException(status_code=500, detail=f"Server error: {str(e)}")

Creating a SIP-enabled room

We’ll need to configure the Daily room to be setup to receive SIP connections. In our example project, utils/daily-helpers.py demonstrates how to set up the Daily room using the Daily REST helpers available in Pipecat. We just need to pass through new SIP parameters as part of room creation:

utils/daily_helpers.py
async def create_sip_room(session: Optional[aiohttp.ClientSession] = None) -> Dict[str, str]:
    """Create a Daily room with SIP capabilities for phone calls."""
    daily_helper = await get_daily_helper(session)

    # Configure SIP parameters
    sip_params = DailyRoomSipParams(
        display_name="phone-user",  # This can be customized with caller info
        video=False,                # Audio-only call
        sip_mode="dial-in",         # For receiving calls
        num_endpoints=1,            # Number of SIP endpoints needed
    )

    # Create room properties with SIP enabled
    properties = DailyRoomProperties(
        sip=sip_params,
        enable_dialout=True,  # For future expansion if needed
        start_video_off=True, # Voice only
    )

    # Create and return the room
    room = await daily_helper.create_room(DailyRoomParams(properties=properties))
    token = await daily_helper.get_token(room.url, 24 * 60 * 60)  # 24 hours validity

    return {
        "room_url": room.url,
        "token": token,
        "sip_endpoint": room.config.sip_endpoint
    }

Configuring your bot

Your bot needs to handle the on_dialin_ready event to forward the call at the right time:

bot.py
async def run_bot(room_url: str, token: str, call_id: str, sip_uri: str) -> None:
    """Run the voice bot with the given parameters."""
    logger.info(f"Starting bot with room: {room_url}")
    logger.info(f"SIP endpoint: {sip_uri}")

    # IMPORTANT: Track if call has been forwarded to avoid multiple forwards
    call_already_forwarded = False

    # Setup the Daily transport
    transport = DailyTransport(
        room_url,
        token,
        "Phone Bot",
        DailyParams(
            audio_in_enabled=True,
            audio_out_enabled=True,
            transcription_enabled=True,
            vad_analyzer=SileroVADAnalyzer(),
        ),
    )

    # ... rest of your bot setup code ...

    # Handle call ready to forward
    @transport.event_handler("on_dialin_ready")
    async def on_dialin_ready(transport, cdata):
        nonlocal call_already_forwarded

        # We only want to forward the call once
        # The on_dialin_ready event will be triggered for each SIP endpoint
        if call_already_forwarded:
            logger.warning("Call already forwarded, ignoring this event.")
            return

        logger.info(f"Forwarding call {call_id} to {sip_uri}")

        try:
            # Update the Twilio call with TwiML to forward to the Daily SIP endpoint
            twilio_client.calls(call_id).update(
                twiml=f"<Response><Dial timeout=\"30\"><Sip>{sip_uri}</Sip></Dial></Response>"
            )
            logger.info("Call forwarded successfully")
            call_already_forwarded = True
        except Exception as e:
            logger.error(f"Failed to forward call: {str(e)}")
            raise

Setting up the Twilio webhook

Configure your Twilio phone number to use your server’s webhook URL:

  1. Go to the Twilio Console
  2. Navigate to Phone Numbers → Manage → Active Numbers
  3. Click on your phone number
  4. Under “Configure”, set “A Call Comes In” to:
    • Webhook: https://your-server.com/call (your server’s URL)
    • HTTP Method: POST

Testing locally

For local development, you can use ngrok to expose your local server:

# Start your server
python server.py

# In another terminal, start ngrok
ngrok http 8000

# Use the ngrok URL (e.g., https://a1b2c3.ngrok.io/call) as your webhook

Best Practices and Common Pitfalls

✅ Best Practice: Put the call on hold

Always respond to Twilio’s initial webhook with hold music. This gives your bot time to initialize and the Daily SIP endpoint to become ready.

resp = VoiceResponse()
resp.play(url="https://your-hold-music.mp3", loop=10)
return str(resp)

❌ Pitfall: Using <Pause> instead of hold music

Don’t use the TwiML <Pause> element to wait for the bot to initialize:

# DON'T DO THIS
resp = VoiceResponse()
resp.pause(length=10)  # This can cause connection issues

Twilio can only pause a call for a short duration (~5 seconds), which may not be enough time for the Daily SIP setup to complete.

✅ Best Practice: Handle multiple on_dialin_ready events

If your Daily room has multiple SIP endpoints, use a flag to ensure you only forward the call once:

call_already_forwarded = False

@transport.event_handler("on_dialin_ready")
async def on_dialin_ready(transport, cdata):
    nonlocal call_already_forwarded

    if call_already_forwarded:
        logger.info("Call already forwarded, ignoring this event.")
        return

    # Forward the call...
    call_already_forwarded = True

A single SIP endpoint is sufficient for the initial connection. A second SIP endpoint is needed only if you plan to forward the call.