Websites and apps

Start a voice call and stream audio over a single WebSocket, with no WebRTC signaling on the client.
View as Markdown

Add live voice conversations between your users and a Synthflow agent, right inside your own website or app. You start the call with one API request, open the WebSocket URL it returns, and stream audio both ways over that single connection. Synthflow handles the call setup and telephony on the server, so your client only needs a WebSocket and an audio pipeline.

Use this when you want to build your own voice experience instead of using the voice widget. For example, you might add a custom call button to your product, let users speak to an agent from a mobile app, or connect an internal tool that listens to active calls.

The integration has three parts:

  1. Create the call. Your backend asks Synthflow to start a voice session.
  2. Connect your app. Your client opens the secure WebSocket URL returned by Synthflow.
  3. Send and receive audio. Your app sends the user’s voice and plays back the agent’s response in real time.

This guide focuses on the end-to-end integration flow for websites and apps, including codec behavior, RTP frame handling, and the client responsibilities for streaming audio.

Prerequisites

Before you start, make sure you have the pieces that create the call, protect your credentials, and handle audio in your app.

  • Voice agent. Create or choose a voice agent in your workspace, then copy its model ID from the agent editor. You will use this as the modelId when you start the call.
  • Backend endpoint. Your app needs a server-side route that can call Synthflow with your API key. The browser or mobile app should call your backend, not Synthflow directly. See Authentication for the recommended pattern.
  • Audio handling. Your client needs to capture microphone audio, play back the agent’s response, and encode or decode RTP for the selected codec. Use opus unless you specifically need pcma or pcmu for G.711 compatibility.

Step 1. Start a call and get the WebSocket URL

Start by creating a voice session from your backend.

The response includes a callId and a short-lived wsMediaUrl. Keep callId on your backend so you can look up the call later, and pass wsMediaUrl to your client for Step 2.

You do not need a separate token request or any browser-side call setup.

Step 2. Connect your client to the call

Use the wsMediaUrl from Step 1 to open a WebSocket connection. Set binaryType = "arraybuffer" so your client can receive audio frames as binary data.

When the connection opens, Synthflow sends a single JSON setup message called call_info. It tells your client which codec, sample rate, and RTP payload type to use for the audio stream.

1{
2 "type": "call_info",
3 "codec": "opus",
4 "sample_rate": 48000,
5 "channels": 1,
6 "ptime_ms": 20,
7 "payload_type": 100
8}

Use these values to configure your RTP encoder. After this setup message, there are no more JSON messages on this socket.

wsMediaCodeccodecpayload_typesample_rate
opus (default)opus10048000
pcmapcma88000
pcmupcmu08000

The call_info message is the only text frame on this WebSocket. Treat every later message as binary media.

Step 3. Send and receive audio

After call_info, the WebSocket is ready for audio. From this point on, your client sends microphone audio to Synthflow and receives the call audio it should play back to the user.

  • Client to server. Send complete RTP packets (12-byte header plus payload) as binary WebSocket frames.
  • Server to client. Receive mixed audio from the call room (agent plus other participants) as binary RTP frames.

There are no additional JSON status messages on this socket. Treat every binary frame as a complete RTP packet for the codec advertised in call_info.

Example browser client

This example shows the client-side flow in a browser: open the WebSocket URL from your backend, wait for call_info, play incoming audio frames, and start sending microphone audio. It uses the browser WebSocket and getUserMedia APIs, and assumes your backend already returned wsMediaUrl.

1type CallInfo = {
2 type: "call_info";
3 codec: string;
4 sample_rate: number;
5 channels: number;
6 ptime_ms: number;
7 payload_type: number;
8};
9
10async function connectWsMedia(wsMediaUrl: string) {
11 const ws = new WebSocket(wsMediaUrl);
12 ws.binaryType = "arraybuffer";
13
14 const callInfo = await new Promise<CallInfo>((resolve, reject) => {
15 ws.onmessage = (event) => {
16 if (typeof event.data === "string") {
17 const info = JSON.parse(event.data) as CallInfo;
18 if (info.type === "call_info") resolve(info);
19 }
20 };
21 ws.onerror = () => reject(new Error("WebSocket error"));
22 ws.onclose = () => reject(new Error("WebSocket closed before call_info"));
23 });
24
25 console.log("Ready:", callInfo);
26
27 ws.onmessage = (event) => {
28 if (event.data instanceof ArrayBuffer) {
29 // Decode RTP payload with your codec library (Opus, G.711, etc.)
30 playRtpPacket(new Uint8Array(event.data), callInfo);
31 }
32 };
33
34 const micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
35 startRtpSender(ws, micStream, callInfo);
36
37 return {
38 callInfo,
39 hangup: () => ws.close(),
40 };
41}

The example leaves playRtpPacket and startRtpSender to your RTP and codec stack.

End the call

Close the WebSocket when the client is done with the media stream. Keep the callId from Step 1 so your backend can look up the call later.

Security

Before you ship, check the parts of the flow that expose credentials or call access:

  • Keep your API key on the backend. Never expose Synthflow API keys in browser code, mobile apps, logs, or analytics tools. See Authentication for the standard API key pattern.
  • Protect your own start-call route. The endpoint that calls Synthflow should require your app’s user authentication, so only authorized users can start calls from your account.
  • Treat wsMediaUrl like a temporary secret. Anyone with the URL can join that media connection. Send it over HTTPS, do not store it long term, and request a fresh URL for each session.
  • Keep callId server-side. Store it with the user’s session or call record so your backend can look up the call later.

Attach to an existing call

If a call is already running (started via API, phone, or portal), you can join its media leg without starting a new call. Pass the running call’s ID as roomId and receive a fresh wsMediaUrl.

You can open multiple WebSocket legs on the same call (for example, listen-only monitoring).

Troubleshooting

SymptomWhat to check
WebSocket closes immediatelywsMediaUrl expired or wrong region; request a fresh URL
No audio receivedConfirm you are sending valid RTP after call_info; check payload_type and sample_rate match call_info

Legacy websocket

If you previously used the legacy PCM websocket at widget.synthflow.ai, use this table to map the old flow to WebSocket media. The legacy websocket was deprecated on June 15, 2026. Widget embeds are not affected.

Legacy PCM websocketWebSocket media (this guide)
Start a sessionGET /websocket/token/{assistant_id}Start a call via API (see Step 1)
Audio formatRaw PCM16 (48 kHz in, 16 kHz out)Binary RTP (Opus by default)
Readiness signalsstatus_client_ready / status_agent_ready JSONcall_info JSON, then RTP only
Client signalingNoneNone (Synthflow provisions the room server-side)

For most integrations, the main change is that your backend starts the call and your client streams RTP over the returned WebSocket URL.

FAQ

No. If you only need a drop-in website widget, use the voice widget, which requires no custom WebSocket code. This guide is for programmatic integrations where you control the audio pipeline in a browser, mobile, or headless client.

Use opus (the default) for the best quality on browser and app clients. Choose pcma or pcmu when you need G.711 compatibility with telephony systems. Always configure your encoder from the call_info values (codec, payload_type, sample_rate) rather than hardcoding them.

No. Synthflow provisions the room on the server, so there is no trickle ICE, SDP exchange, or status_client_ready handshake. Your client opens wsMediaUrl, reads the single call_info frame, and then streams binary RTP.

Yes. Pass the running call’s ID as roomId to receive a fresh wsMediaUrl without starting a new call. You can open multiple WebSocket legs on the same call, for example a listen-only monitoring leg. See Attach to an existing call.

The wsMediaUrl is short-lived and region-scoped. If it expired or was requested for the wrong region, request a fresh URL from your backend and reconnect. See Troubleshooting for more symptoms.