Skip to main content
This guide uses an OpenAI Realtime agent as the example of a custom voice agent that lives behind Twilio. It has two halves. Parts 1 to 4 get you to a working integration: Twilio SIP domain, a small server, an OpenAI Realtime agent, and a Bluejay simulation running calls against it. You can stop there. Part 5 builds on that working setup so each Bluejay result also shows the tool calls the agent made and an OpenTelemetry trace. Audio never passes through your server. Twilio dials OpenAI’s SIP endpoint directly. Your server answers two HTTP requests per call.

How it connects

Prerequisites

  • A Twilio account with Programmable Voice and one phone number (used as the caller id on the leg to OpenAI)
  • An OpenAI account with a project that has Realtime API access
  • Somewhere to run a Node 20+ server on a public HTTPS URL (Railway, Fly, Render, a VM)
  • A Bluejay account

Part 1: OpenAI

1. Get the project id and an API key

Open platform.openai.com, pick the project, and go to Settings, then Project, then General. Copy the Project ID. It starts with proj_. Create an API key in the same project under API keys. The key and the project id must belong to the same project. If they do not, OpenAI rejects the accept call with mismatched_project. To confirm which project a key belongs to, make any POST request with it and read the openai-project response header:

2. Register the webhook

OpenAI announces each incoming SIP call by posting to a webhook. Without one, every INVITE to sip.api.openai.com fails. Go to Settings, then Project, then Webhooks, and create an endpoint: Copy the signing secret shown after you save. It starts with whsec_ and is shown once. If the project already has an endpoint for realtime.call.incoming, edit that one. Two endpoints both try to accept the same call. You now have OPENAI_PROJECT_ID, OPENAI_API_KEY, and OPENAI_WEBHOOK_SECRET.

Part 2: Twilio

Everything in this part is in the Twilio Console under Communications, then Voice, then SIP domains. Twilio Console sidebar: Communications, Voice, SIP domains

3. Create a credential list

Bluejay authenticates to your SIP domain with a username and password. Twilio stores those in a credential list.
  1. Open the Credential lists tab and click Create Credential List.
SIP Domains page with the Credential lists tab selected and the Create Credential List button
  1. Credential list friendly name: bluejay-sip-cred.
  2. Under Add credentials, enter a Username of bluejay and a Password of at least 12 characters with one uppercase letter, one lowercase letter, and one digit.
  3. Click Create.
Create new credential list dialog with friendly name, username, and password fields Keep the username and password. They go on the Bluejay agent in Part 4.

4. Create the SIP domain

  1. Open the SIP domains tab and click Create SIP Domain.
SIP Domains list with the Create SIP Domain button
  1. SIP domain friendly name: Twilio OpenAI.
  2. SIP URI: pick a name, for example twilio-openai. Twilio appends .sip.twilio.com. The name must be unique across all of Twilio.
  3. Under Voice authentication, open Credential lists and select bluejay-sip-cred. Leave IP access control lists empty.
  4. Click Create.
Create SIP Domain form with friendly name, SIP URI, and the credential list selected under Voice authentication
  1. The domain’s details page opens. Under Call control configuration, click Edit configuration.
  2. Set Configure method to Webhook, Webhook URL to https://<your-server>/twiml, and HTTP method to HTTP POST. Click Save.
Edit call control configuration dialog with Configure method Webhook, Webhook URL, and HTTP POST
  1. Leave SIP registration disabled.
The webhook URL is what Twilio requests when a call arrives at the domain. Your server answers it with TwiML in Part 3.
The REST parameter for the webhook URL is named VoiceUrl.
The mapping must be created under Auth/Calls. The domain also has a top-level CredentialListMappings resource; that one controls SIP registration and does nothing for incoming calls.

Part 3: The server

Three routes. Twilio calls /twiml when a call arrives and /dial-status when the leg to OpenAI ends. OpenAI calls /openai-webhook when its SIP endpoint receives the call, and your server accepts it with the agent’s instructions.

5. Create the project

6. Set the environment

7. Write the server

server.js
Three things in this file matter more than they look:
  • /openai-webhook uses express.raw(). The signature is computed over the exact request bytes, so the body must not be parsed before verification.
  • The route responds 200 before calling accept. OpenAI drops the call if the webhook takes too long.
  • callerId on <Dial> is required. The incoming leg’s caller is a SIP URI, and Twilio needs a phone number to place the outbound leg.

8. Deploy

Deploy to any host with a public HTTPS URL and set the environment variables from step 6. Confirm the routes answer:
If your server’s hostname was not known when you registered the webhook in step 2 or the SIP domain in step 4, go back and set it now.

Part 4: Bluejay

9. Create the agent

In Agent Settings, then Connection, set the connection type to SIP and fill in: The user part of the URI (openai here) can be any value.

10. Create and run a simulation

  1. Go to the Simulations page, click Create in the top right, select the agent, and add a digital human. An intent like “Your dishwasher is leaking. Book a repair for next Tuesday afternoon.” works well with the example agent.
  2. In the Simulations tab, click Run on the simulation, then click Run calls.

11. Check the result

Open the finished call. You should see a transcript where the agent greets the caller, collects the appliance and the day, and confirms a date that matches the calendar. Your server logs show the two requests it answered:
Your agent is integrated. Bluejay can run any simulation against it from here. Everything below is optional.

Part 5: Going further, tool calls and traces

With the integration working, you can make each Bluejay result richer. Bluejay puts an X-Simulation-Result-Id header on every call it places. If your server carries that id through the call and posts back when the call ends, the result page shows the tool calls the agent made and an OpenTelemetry trace next to the transcript. Three changes to the server from Part 3.

12. Carry the result id through the call

Twilio passes Bluejay’s SIP headers to /twiml as form fields prefixed with SipHeader_. Read SipHeader_X-Simulation-Result-Id and append it to the SIP URI. Twilio sends headers written after ? on the URI, and OpenAI returns them to your webhook in data.sip_headers.
In /openai-webhook, read it before accepting:

13. Give the agent a tool

Tools are declared in the accept body. This example books a repair. Replace runTool with a call into your own system, and tell the model in INSTRUCTIONS that it must call the tool before confirming anything, or it will announce a booking it never made.
Add tools, tool_choice, and input transcription to the accept body, and start a session after a successful accept:

14. Run the call session and report to Bluejay

Install the extra packages:
Add BLUEJAY_API_KEY to the environment. After accept, attach to the call with a websocket. The model emits response.function_call_arguments.done when it wants a tool. Run the tool, send the result back as a function_call_output item, and ask for the next response. Transcripts arrive as conversation.item.input_audio_transcription.completed for the caller and response.output_audio_transcript.done for the agent. Each call gets one root span. Tool calls and utterances become child spans. The exporter sends them to Bluejay’s OTLP endpoint with your API key.
Two separate POSTs to Bluejay on purpose. The trace id goes up as soon as the session opens. Tool calls go up once, after the call ends. The endpoint appends tool calls on every call and also derives tool calls from any trace_ids in the same body, so sending both together produces duplicates.

15. Check the result again

Run the simulation from step 10 again and open the call. Alongside the transcript you should now see:
  • A book_repair entry under tool calls, with the parameters the model extracted and the confirmation your runTool returned.
  • A trace with a voice.call root span, one execute_tool book_repair child, and agent.speech and customer.speech spans for each turn.
Your server logs show the reporting:

Troubleshooting

For help, ask in our Slack community.