Skip to main content

OpenAI Live SIP Integration

This guide will walk you through integrating Bandwidth's Voice Network with OpenAI's Live API SIP Connector. This integration allows OpenAI to handle media directly over SIP — your application only needs to handle webhooks and issue commands via the OpenAI SDK's sideband connection.

info

This integration is only available on the Universal Platform.

Please reach out to your Bandwidth CSM to confirm that your account and trunk configuration are correctly enabled to interconnect with OpenAI's Live API via SIP Connector.

What you'll need​

Call Flow​

Before we dive in, let's walk through what an inbound call flow looks like with this integration.

This flow demonstrates a call that is answered by an AI agent and then transferred to a human agent. Let's break it down:

  1. A user calls your Bandwidth number.
  2. Bandwidth routes the call to OpenAI's Live SIP endpoint using your Project ID.
  3. OpenAI sends a live.transport.incoming event to your webhook URL.
  4. Your application calls sessions.accept() to accept the call and configure the AI agent.
  5. OpenAI responds with a 200 OK.
  6. Your application asynchronously opens a sideband connection to receive tool call notifications and issue commands during the call.
  7. Your application responds to the initial webhook with a 200 OK.
    1. This is critical — OpenAI will not connect the call until your application responds.
  8. The user and the AI agent can now converse.
  9. When the user asks to speak to a human, the Responses backend calls the refer function tool.
  10. Your sideband listener receives the response.output_item.done notification and calls sessions.refer().
  11. OpenAI sends a SIP REFER to Bandwidth using the sip: URI you provided.
  12. Bandwidth refers the call, and the user and human agent can now converse.
note

The Live API SIP Connector requires a sip: URI for the REFER target. Your application must extract the SIP host from the Contact header in the live.transport.incoming webhook and use it to construct sip:<number>@<sip_host>. The sample app's LiveTransportData.get_sip_host() helper does this automatically.

Let's Build It!​

For convenience, we have provided a sample application to get you started: bandwidth-samples/openai-live-sip-python. The sample application is built using Python and FastAPI, but you can use any language or framework you prefer.

The following sections will walk you through the sample application code.

Setup our Environment​

Clone the sample application:

git clone https://github.com/Bandwidth-Samples/openai-live-sip-python
cd openai-live-sip-python

Create a .env file in the root of the project:

OPENAI_API_KEY="your_openai_api_key_here"
REFER_TO="+19195554321"
LOG_LEVEL="INFO"
LOCAL_PORT=3000

Using Docker​

docker compose up --build

Using Local Python Environment​

python -m venv .venv
source .venv/bin/activate
cd app
pip install -r requirements.txt
python main.py

A successful startup should log:

INFO: Uvicorn running on http://0.0.0.0:3000 (Press CTRL+C to quit)
INFO: Application startup complete.

Configure Your OpenAI Project​

In your OpenAI project settings, set the webhook URL to:

https://<your-public-url>/webhooks/openai/live/transport/inbound

Your Bandwidth trunk termination destination must point to:

sip:<PROJECT_ID>@sip.api.openai.com;transport=tls

Creating our FastAPI Server​

The sample application uses FastAPI to handle incoming webhook events from OpenAI.

# main.py

import asyncio
import http
import logging
import os
import sys
from pathlib import Path

from dotenv import load_dotenv

load_dotenv(Path(__file__).parent.parent / ".env", override=True)

from openai import AsyncOpenAI
from fastapi import FastAPI, Response
import uvicorn

from models.live_transport_incoming import LiveTransportIncoming

OPENAI_API_KEY = os.environ["OPENAI_API_KEY"]
REFER_TO = os.environ["REFER_TO"]
LOG_LEVEL = os.environ["LOG_LEVEL"].upper()
LOCAL_PORT = int(os.environ.get("LOCAL_PORT", 3000))

openai_client = AsyncOpenAI(api_key=OPENAI_API_KEY)

OPENAI_LIVE_MODEL = "gpt-live-1"
OPENAI_RESPONSES_MODEL = "gpt-5.6-terra"
AGENT_VOICE = "alloy"
with open("sample-prompt.md", "r") as file:
AGENT_PROMPT = file.read()

REFER_TOOL = {
"type": "function",
"name": "refer",
"description": "Transfer the call to a live human agent. ONLY call this when the caller explicitly says they want to speak to a human or be transferred.",
"parameters": {"type": "object", "properties": {}},
}

TOOLS = [REFER_TOOL, {"type": "web_search"}]

# Appended to AGENT_PROMPT for the Live model only. The Live model decides when
# to delegate a turn to the Responses backend — without explicit conditions here,
# it answers everything itself and tools are never reached.
DELEGATION_INSTRUCTIONS = """

## Delegating to the backend

Delegate to the backend when:
* The caller asks to speak to a human, be transferred, or get an agent.
* The caller asks a factual question (prices, news, availability).

Delegate before giving an answer that depends on backend work.
Do not delegate for greetings, small talk, or answers already given.
"""

app = FastAPI()


@app.get("/health", status_code=http.HTTPStatus.NO_CONTENT)
def health():
return


if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=LOCAL_PORT, log_level="info", reload=True)

The application also provides a models directory containing a Pydantic model for the live.transport.incoming webhook event. You can find it in the models/live_transport_incoming.py file of the sample application.

Handle Inbound Call Event​

When a user calls your Bandwidth number, OpenAI sends a live.transport.incoming webhook to your application. You must accept the call and return a 200 OK before OpenAI connects the media.

# main.py

@app.post("/webhooks/openai/live/transport/inbound", status_code=http.HTTPStatus.OK)
async def handle_inbound_call(event: LiveTransportIncoming) -> Response:
if event.type == "live.transport.incoming" and event.data.type == "sip":
session_id = event.data.session_id
sip_host = event.data.get_sip_host()

await openai_client.live.sessions.accept(
session_id=session_id,
session={
"type": "live",
"model": OPENAI_LIVE_MODEL,
"instructions": AGENT_PROMPT + DELEGATION_INSTRUCTIONS,
"audio": {"output": {"voice": AGENT_VOICE}},
"delegation": {
"type": "responses",
"responses": {
"model": OPENAI_RESPONSES_MODEL,
"instructions": AGENT_PROMPT,
"tools": TOOLS,
"tool_choice": "auto",
},
},
},
)
asyncio.create_task(sideband_task(session_id, sip_host))

return Response()

Let's break down the session configuration:

  • model: The Live model that handles speech — gpt-live-1.
  • instructions: Voice-layer prompt for the Live model. This includes DELEGATION_INSTRUCTIONS, which tells the Live model when to hand a turn to the Responses backend. Without this, the Live model answers every turn itself and backend tools are never reached.
  • audio.output.voice: The voice to use. SIP negotiates the media format automatically — no audio.format needed.
  • delegation.type: "responses" delegates reasoning and tool execution to a backend Responses model.
  • delegation.responses.model: The Responses backend model that drives conversation logic.
  • delegation.responses.tools: Function tools the Responses model can invoke. refer triggers a call transfer; web_search handles factual questions.

:::info Why delegation.type: "responses" instead of "client"? The Live SIP session config does not accept tools at the top level. Tools must be placed inside the Responses delegation config. With "responses" delegation, the Responses model drives reasoning and tool calls; with "client" delegation, your app drives all responses via the sideband — no backend model is used. :::

Establish Sideband Connection​

The sideband is a WebSocket connection that lets your application receive Responses backend events and issue commands during an active session. Unlike the WebSocket integration, the sideband attaches to an existing SIP session — there is no session.start needed.

# main.py

async def sideband_task(session_id: str, sip_host: str | None = None) -> None:
try:
async with openai_client.live.sideband.connect(session_id=session_id) as connection:
# Kick off the opening greeting via the Responses backend.
# Without this call, the Live model waits for user input before speaking.
await connection.response.create()
async for event in connection:
match event.type:
case "response.event":
backend_event: dict = event.event
if backend_event.get("type") == "response.output_item.done":
item = backend_event.get("item", {})
if item.get("type") == "function_call" and item.get("name") == "refer":
tool_call_id = item.get("call_id") or item.get("id")
target_uri = (
f"sip:{REFER_TO}@{sip_host}" if sip_host else f"tel:{REFER_TO}"
)
try:
await openai_client.live.sessions.refer(
session_id, target_uri=target_uri
)
result = "success"
except Exception as e:
result = f"error: {e}"
# Always send function_call_output so the backend can
# respond to the caller after the tool executes.
await connection.response.item.create(item={
"type": "function_call_output",
"call_id": tool_call_id,
"output": result,
})
await connection.response.create()
case "session.closed":
return
case "session.usage.updated":
pass
except Exception as e:
if str(e):
print(f"Sideband connection error [{session_id}]: {e}")

Sideband events from the Responses backend arrive as response.event, which wraps a Responses API event object. To handle tool calls:

  1. Call connection.response.create() immediately after connecting — this triggers the opening greeting via the Responses backend.
  2. Listen for event.type == "response.event".
  3. Check event.event.get("type") == "response.output_item.done".
  4. Check item.get("type") == "function_call" and item.get("name") == "refer".
  5. Call sessions.refer() with a sip: URI built from REFER_TO and the sip_host extracted from the webhook's Contact header. OpenAI sends a SIP REFER to Bandwidth, which transfers the call.
  6. Send a function_call_output result back and call connection.response.create() again — this closes the tool-call loop so the Responses backend can speak a confirmation to the caller.

Because the webhook handler is async def, you can use asyncio.create_task() to run the sideband listener concurrently without blocking the 200 OK response back to OpenAI.

Call Transfer​

The sessions.refer() call instructs OpenAI to send a SIP REFER to transfer the call:

target_uri = f"sip:{REFER_TO}@{sip_host}" if sip_host else f"tel:{REFER_TO}"
await openai_client.live.sessions.refer(
session_id,
target_uri=target_uri
)

The sip_host is the IP and port extracted from the Contact header in the live.transport.incoming webhook (e.g. 67.231.11.6:5061). The sample app's LiveTransportData.get_sip_host() helper parses this automatically. A tel: fallback is used if no Contact header was present.

After the REFER completes, the session is closed by OpenAI and the sideband loop exits naturally via the session.closed event.

Other Session Controls​

The SDK exposes additional session commands you can issue over the sideband or via the REST API:

# Hang up the call
await openai_client.live.sessions.hangup(session_id)

# Reject an incoming call before accepting
await openai_client.live.sessions.reject(session_id, status_code=486)

# Mute/unmute the caller's microphone (put caller on hold)
async with openai_client.live.sideband.connect(session_id=session_id) as connection:
await connection.session.input_audio.mute() # hold
await connection.session.input_audio.unmute() # unhold