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.
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
- A Universal Platform account + a phone number associated to a Voice Configuration Package
- Your OpenAI Project ID
- Your OpenAI API Key
- Your OpenAI webhook signing secret (
OPENAI_WEBHOOK_SECRET) — found in your OpenAI project's webhook settings - Docker
- A publicly accessible server to host your webhook application (e.g., using ngrok)
- (Optional) Our sample application to get started: bandwidth-samples/openai-live-sip-python
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:
- A user calls your Bandwidth number.
- Bandwidth routes the call to OpenAI's Live SIP endpoint using your Project ID.
- OpenAI sends a
live.transport.incomingevent to your webhook URL. - Your application calls
sessions.accept()to accept the call and configure the AI agent. - OpenAI responds with a
200 OK. - Your application asynchronously opens a sideband connection to receive tool call notifications and issue commands during the call.
- Your application responds to the initial webhook with a
200 OK.- This is critical — OpenAI will not connect the call until your application responds.
- The user and the AI agent can now converse.
- When the user asks to speak to a human, the Responses backend calls the
referfunction tool. - Your sideband listener receives the
response.output_item.donenotification and callssessions.refer(). - OpenAI sends a SIP REFER to Bandwidth using the
tel:URI you provided. - Bandwidth refers the call, and the user and human agent can now converse.
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"
OPENAI_WEBHOOK_SECRET="whsec_..." # recommended: copy from OpenAI project webhook settings
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
Copy the webhook signing secret from your OpenAI project's webhook settings into OPENAI_WEBHOOK_SECRET in your .env file.
Your Bandwidth trunk termination destination must point to:
sip:<PROJECT_ID>@sip.api.openai.com;transport=tls
OpenAI's SIP Connector requires TLS for signaling and SRTP for media. Your Bandwidth trunk will be provisioned with TLS and SRTP support as part of the OpenAI Live SIP onboarding — audio codec is negotiated automatically during SIP setup.
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, Request, 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))
# Optional: verify OpenAI webhook signatures. Recommended in production.
OPENAI_WEBHOOK_SECRET = os.environ.get("OPENAI_WEBHOOK_SECRET")
openai_client = AsyncOpenAI(api_key=OPENAI_API_KEY, webhook_secret=OPENAI_WEBHOOK_SECRET)
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. If OPENAI_WEBHOOK_SECRET is set, the handler verifies the request signature using the OpenAI SDK before processing — invalid signatures are rejected with 400. Signature verification is optional but strongly recommended in production. 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(request: Request) -> Response:
payload = await request.body()
if OPENAI_WEBHOOK_SECRET:
try:
openai_client.webhooks.verify_signature(payload, dict(request.headers))
except Exception:
return Response(status_code=http.HTTPStatus.BAD_REQUEST)
event = LiveTransportIncoming.model_validate_json(payload)
if event.type == "live.transport.incoming" and event.data.type == "sip":
session_id = event.data.session_id
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))
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 includesDELEGATION_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 — noaudio.formatneeded.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.refertriggers a call transfer;web_searchhandles factual questions.
Why "responses" delegation 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) -> 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"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.
# Note: "success" means the REFER was accepted, not that
# the destination answered.
await connection.response.item.create(item={
"type": "function_call_output",
"call_id": tool_call_id,
"output": result,
})
# This pattern assumes one function call per delegation.
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:
- Call
connection.response.create()immediately after connecting — this triggers the opening greeting via the Responses backend. - Listen for
event.type == "response.event". - Check
event.event.get("type") == "response.output_item.done". - Check
item.get("type") == "function_call"anditem.get("name") == "refer". - Call
sessions.refer()with atel:URI. OpenAI sends a SIP REFER to Bandwidth, which transfers the call. - Send a
function_call_outputresult back and callconnection.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:
await openai_client.live.sessions.refer(
session_id,
target_uri=f"tel:{REFER_TO}"
)
Pass the destination as a standard E.164 tel: URI. OpenAI sends a SIP REFER to Bandwidth with the tel: target in the Refer-To header, and Bandwidth routes the transfer.
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