Realtime TTS-2 is live. Built for realtime conversation that feels human. Read the Realtime TTS-2 announcement

Connect

WebRTC call control

Control a Realtime API WebRTC call from your server: hang it up, or attach a sideband WebSocket to watch its events, update the session and answer tool calls.

Call control is in beta, its behavior may still change.

Call control lets your server act on a WebRTC call that a browser or mobile client started. It works like OpenAI's server-side controls (also called a sideband connection) and has two parts:

  • Hang up: POST /v1/realtime/calls/{call_id}/hangup ends the call.
  • Sideband: a WebSocket at wss://api.inworld.ai/v1/realtime?call_id={call_id} receives the call's events and sends client events to it.

Use it when the client should only carry audio, and your server should own the rest: instructions, tools that need your secrets or database, and when the call ends. The API key stays on your server.

Call control works only with WebRTC calls. A WebSocket session cannot be attached or hung up. Whoever opened it controls it, and closes that WebSocket to end it.

Get the call ID

POST /v1/realtime/calls returns 201 Created with the call ID in two places:

  • the Location header, /v1/realtime/calls/<call_id>, for every request content type;
  • the id field of the JSON response, when the request is application/json.

The call ID is a UUID. Have your client send it to your server after the call starts. Both call-control endpoints take this ID.

Do not pass a call ID as the key of a WebSocket session (/api/v1/realtime/session?key=<call_id>). That opens a separate, empty session, not the call. Use the sideband to reach a WebRTC call.

Hang up a call

Send POST https://api.inworld.ai/v1/realtime/calls/{call_id}/hangup with your API key. There is no request body.

bash
curl -X POST "https://api.inworld.ai/v1/realtime/calls/$CALL_ID/hangup" \
  -H "Authorization: Basic $INWORLD_API_KEY"

A 200 means the call has started to end. See Hang Up Call for every status and what Inworld closes.

Attach a sideband

Open a WebSocket to wss://api.inworld.ai/v1/realtime?call_id={call_id} from your server, with the header Authorization: Basic <INWORLD_API_KEY>. See Sideband WebSocket for the other authentication form, handshake errors and limits.

What the sideband receives

The sideband receives the same server events as the call's data channel, in the same order, including error events. Nothing is sent when the sideband attaches: there is no session.created. Response audio travels on the media track. response.output_audio.delta reaches the sideband only as an alignment event with an empty delta, when providerData.tts.timestamp_type is set. When back-channel is enabled, response.backchannel.audio.delta events, which carry audio, are sent too.

What the sideband can send

Client events sent on the sideband apply to the call exactly as if the client had sent them on the data channel. For example, send session.update, conversation.item.create (including function_call_output items), response.create and response.cancel. See the WebRTC API reference for each event.

The sideband takes no audio input. Inworld refuses input_audio_buffer.append with an error event.

Limits

A sideband does not count toward your concurrent session limit and is not billed separately. For the attach rate limit and when Inworld closes a slow sideband, see Sideband WebSocket.

Answer tool calls from your server

A server that answers tool calls over the sideband should control when the model continues:

  1. Set providerData.auto_tool_response to false. Your server can do this itself with session.update on the sideband.
  2. When you receive response.function_call_arguments.done, run the tool and add a function_call_output item with the tool call's call_id from that event (not the WebRTC call ID).
  3. Send response.create once all the outputs for that response are in.

If you keep the default (auto_tool_response: true), send only the function_call_output item, and Inworld continues on its own. Do not combine the default with your own response.create: Inworld then starts an extra, empty response that is cancelled first. See Control tool continuation.

Answer each tool on one side only. The client's data channel receives the same tool calls, so a client that also answers them sends a second output.

Example

This Node.js server attaches a sideband to a call, sets the instructions and a tool, answers tool calls, and hangs up after five minutes. It uses the ws library. Pass the call ID that your client received from POST /v1/realtime/calls.

javascript
import WebSocket from 'ws';

const API_KEY = process.env.INWORLD_API_KEY;
const callId = process.argv[2];

const sideband = new WebSocket(
  `wss://api.inworld.ai/v1/realtime?call_id=${encodeURIComponent(callId)}`,
  { headers: { Authorization: `Basic ${API_KEY}` } }
);

const send = (event) => sideband.send(JSON.stringify(event));

async function getWeather({ location }) {
  return { location, forecast: 'sunny', temperature_c: 21 };
}

async function hangUp() {
  const res = await fetch(
    `https://api.inworld.ai/v1/realtime/calls/${encodeURIComponent(callId)}/hangup`,
    { method: 'POST', headers: { Authorization: `Basic ${API_KEY}` } }
  );
  if (res.status === 404) console.log('The call has already ended');
  else if (!res.ok) console.error('Hangup failed:', res.status);
}

let hangUpTimer;

sideband.on('open', () => {
  send({
    type: 'session.update',
    session: {
      type: 'realtime',
      instructions: 'You are a concise concierge. Use get_weather for weather questions.',
      tools: [{
        type: 'function',
        name: 'get_weather',
        description: 'Fetch the weather for a location',
        parameters: {
          type: 'object',
          properties: { location: { type: 'string' } },
          required: ['location']
        }
      }],
      providerData: { auto_tool_response: false }
    }
  });
  hangUpTimer = setTimeout(hangUp, 5 * 60 * 1000);
});

sideband.on('message', async (data) => {
  const event = JSON.parse(data.toString());

  if (event.type === 'error') {
    console.error(event.error?.code, event.error?.message);
    return;
  }
  if (event.type !== 'response.function_call_arguments.done' || event.name !== 'get_weather') return;

  const result = await getWeather(JSON.parse(event.arguments));
  send({
    type: 'conversation.item.create',
    item: { type: 'function_call_output', call_id: event.call_id, output: JSON.stringify(result) }
  });
  send({ type: 'response.create' });
});

sideband.on('close', (code) => {
  clearTimeout(hangUpTimer);
  console.log('Sideband closed:', code);
});

This example sends response.create after each tool output. If a response can call several tools, send it once, after the last output.

When a call ends

Closing a sideband never ends the call. Use hangup to end it. When the call ends, Inworld closes every attached sideband. See Sideband WebSocket for how it closes.

Next steps