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 open a secure WebSocket, tell Synthflow to start the call on the node you reached, then stream audio both ways over that same connection. Synthflow handles 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 four parts:

  1. Get the media URL. Your backend asks Synthflow for a region-correct WebSocket base URL.
  2. Connect your app. Your client opens the WebSocket and Synthflow tells it which media node it reached.
  3. Start the call on that node. Your backend starts the voice session pinned to that same node and gets back a session id.
  4. Bind and stream audio. Your client binds the session over the open WebSocket, then sends and receives audio in real time.

This connect-then-bind flow is what keeps the integration correct when Synthflow runs media across multiple nodes. Your audio connection and the call are always placed on the same node, so there is no cross-node mismatch. See Why you connect before starting the call.

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 Security 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. Get the media base URL

Ask Synthflow for the region-correct WebSocket base URL from your backend. This is a stateless call: it does not start a call or reserve anything.

The response returns wsMediaBaseUrl, a secure wss:// URL with no session id attached. Pass it to your client for Step 2.

1{
2 "wsMediaBaseUrl": "wss://webrtc-us.synthflow.ai/ws-audio"
3}

Step 2. Connect your client and read the media node

Open a WebSocket to wsMediaBaseUrl and set binaryType = "arraybuffer" so your client can receive audio frames as binary data.

As soon as the connection opens, Synthflow sends a single JSON message called server_info. It tells your client which media node the connection reached. Keep the server-name value for Step 3.

1{
2 "type": "server_info",
3 "server-name": "media-7f3c9e2a"
4}

Treat server-name as an opaque token. You do not need to interpret it, only pass it back when you start the call.

Step 3. Start the call on that node

Call your backend, which starts the voice session and passes wsMediaServerName set to the server-name from Step 2. Synthflow provisions the call on that exact media node.

The response includes a callId and a sid. Keep callId on your backend so you can look up the call later, and pass sid to your client for Step 4.

1{
2 "callId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
3 "sid": "7f3c9e2a1b0d4f8e"
4}

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

Set wsMediaFraming to payload in this request if you want raw codec payloads instead of full RTP packets (simpler for AI/STT/TTS pipelines). The default is rtp. See Raw payload vs. RTP framing.

Step 4. Bind the session and stream audio

Bind the call to the WebSocket you already opened in Step 2. Send a single JSON text frame with the sid from Step 3.

1{ "type": "bind", "sid": "7f3c9e2a1b0d4f8e" }

Synthflow attaches your connection to the call and replies with one call_info message. It tells your client which codec, sample rate, RTP payload type, and framing to use.

1{
2 "type": "call_info",
3 "codec": "pcmu",
4 "sample_rate": 8000,
5 "channels": 1,
6 "ptime_ms": 20,
7 "payload_type": 0,
8 "framing": "rtp"
9}

Use these values to configure your encoder. The framing field confirms the wire format you requested in Step 3 (rtp or payload). call_info is the last text frame on this WebSocket. After it, treat every message as binary media.

wsMediaCodeccodecpayload_typesample_rate
opus (default)opus10048000
pcmapcma88000
pcmupcmu08000
l16l1610616000

l16 is raw big-endian 16-bit PCM (linear), so no codec library is needed — each 20 ms frame is 320 samples (640 bytes) at 16 kHz.

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 binary WebSocket frames in the negotiated framing — full RTP packets (12-byte header plus payload) for rtp, or raw codec payloads for payload.
  • Server to client. Receive mixed audio from the call room (agent plus other participants), in the same framing.

There are no further JSON messages on this socket. Treat every binary frame as media in the framing advertised in call_info.

Raw payload vs. RTP framing

The wsMediaFraming request field (Step 3) selects the binary wire format, confirmed back to you as framing in call_info:

  • rtp (default). Each binary frame is a full RTP packet: a 12-byte RTP header followed by the codec payload. Use this for WebRTC-style clients or when you want RTP timing/sequence metadata.
  • payload. Each binary frame is a raw codec payload with the RTP header stripped. This is simpler for external AI/STT/TTS pipelines that just want audio bytes and don’t parse RTP.

Codec / framing compatibility

Codecrtppayload
opus❌ (timing drift, still compressed — see below)
pcma
pcmu
l16✅ (best for raw PCM)

Constant-bitrate codecs (pcma/pcmu/l16) work in both framings. For a payload client that wants uncompressed audio, l16 is the simplest — raw 16-bit PCM with nothing to decode. opus is only safe on rtp.

Do not combine framing: payload with codec: opus. In payload mode there is no RTP header, so the server reconstructs timing assuming a fixed 20 ms per frame. Two problems follow for Opus:

  • Timing drift — Opus frame duration is variable and can’t be derived from payload length, so the fixed-20 ms assumption drifts unless every frame is exactly 20 ms.
  • Not PCM — a raw Opus payload is still compressed Opus, so a naive raw-audio client still needs a decoder.

For payload framing, use a constant-bitrate codec — l16 (raw PCM), pcma, or pcmu; keep opus on rtp framing.

Why you connect before starting the call

Synthflow runs media on more than one node for capacity and resilience. Your audio connection is placed on whichever node your network reaches, and that choice cannot be known before the connection exists.

Connecting first solves this. Your client learns its node from server_info, your backend starts the call on that same node with wsMediaServerName, and your client binds the returned sid over the connection it already holds. The call and your audio always land on the same node, so audio never breaks because signaling and media diverged.

This is why the flow uses a sid-less base URL and a bind step instead of returning a ready-to-use URL with the session baked in.

Example browser client

This example shows the client-side flow in a browser: connect to the base URL, read server_info, hand the node back to your backend, bind the returned sid, then stream audio. It uses the browser WebSocket and getUserMedia APIs, and assumes your backend exposes getBaseUrl() (Step 1) and startCall(serverName) (Step 3).

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

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 3 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 routes. The backend routes that call Synthflow (Step 1 and Step 3) should require your app’s user authentication, so only authorized users can start calls from your account.
  • Treat the media URL and sid like temporary secrets. Together they grant access to a media connection. Send them over HTTPS, do not store them long term, and request fresh values 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. Follow the same connect-then-bind flow: get the base URL, connect, read server_info, then start the media leg with the running call’s ID as roomId and the wsMediaServerName you received. Synthflow returns a sid to bind over your open connection.

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

Troubleshooting

SymptomWhat to check
WebSocket closes right after bindThe sid is wrong or expired, or it was created for a different node. Make sure you passed the server-name from server_info as wsMediaServerName in Step 3, and bind the sid from that same response.
No server_info messageConfirm you connected to wsMediaBaseUrl from Step 1 and are reading text frames before sending any binary data.
No audio receivedConfirm you are sending valid RTP after call_info, and that payload_type and sample_rate match call_info.

Legacy single-request flow

Earlier integrations started the call first and received a ready-to-use wsMediaUrl with the session id already in the query string, then connected without a bind step. That flow only stays correct on a single-node deployment, because the returned URL can land on a different node than the one that created the session.

If you built against the older flow, move to connect-then-bind: get the base URL first (Step 1), read server_info (Step 2), start the call with wsMediaServerName (Step 3), and bind the returned sid (Step 4). The audio format and RTP handling are unchanged.

If you previously used the much older PCM websocket at widget.synthflow.ai, that path was deprecated on June 15, 2026. It used raw PCM16 and its own status_client_ready and status_agent_ready messages. Move to WebSocket media as described above. Widget embeds are not affected.

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.

Synthflow runs media across multiple nodes, and your connection is placed on whichever node your network reaches. Connecting first lets Synthflow start the call on that same node and return a sid you bind over the open connection, so signaling and media never diverge. See Why you connect before starting the call.

It is an opaque identifier for the media node your WebSocket reached. You do not interpret it. Pass it back unchanged as wsMediaServerName when you start the call in Step 3.

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. Choose l16 (raw 16-bit PCM at 16 kHz) when you want uncompressed audio with no codec library — ideal for AI/STT/TTS pipelines, especially with payload framing. Always configure your encoder from the call_info values (codec, payload_type, sample_rate) rather than hardcoding them.

Use rtp (the default) unless you have a reason not to — it carries the full RTP header with timing and sequence data. Use payload when an external AI/STT/TTS pipeline just wants raw audio bytes without parsing RTP. Only pair payload with pcma or pcmu; do not use payload with opus (variable Opus frame timing drifts against the fixed 20 ms assumption). See Raw payload vs. RTP framing.

No. Synthflow provisions the room on the server, so there is no trickle ICE, SDP exchange, or status_client_ready handshake. Your client connects, reads server_info, binds the sid, reads one call_info frame, and then streams binary RTP.

Yes. Follow the same connect-then-bind flow and pass the running call’s ID as roomId when you start the media leg. You can open multiple WebSocket legs on the same call, for example a listen-only monitoring leg. See Attach to an existing call.