> For a complete page index, fetch https://docs.synthflow.ai/llms.txt. For full documentation content, fetch https://docs.synthflow.ai/llms-full.txt.

# Websites and apps

> Start a voice call and stream audio over WebSocket with a single API request, with no WebRTC signaling, ICE, or SDP on the client.

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](/create-an-agent). 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](#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.

### Request

POST [https://api.synthflow.ai/v2/calls/outbound-test-ws-media](https://api.synthflow.ai/v2/calls/outbound-test-ws-media)

```curl
curl -X POST https://api.synthflow.ai/v2/calls/outbound-test-ws-media \
     -H "Authorization: Bearer <token>" \
     -H "Content-Type: application/json" \
     -d '{
  "modelId": "8edd032a-5fef-4847-abe2-55fb16057ece",
  "fromPhoneNumber": "+15559876543",
  "toPhoneNumber": "+15551234567",
  "leadName": "Test Lead",
  "agentVersion": "draft",
  "wsMediaCodec": "opus"
}'
```

```python
import requests

url = "https://api.synthflow.ai/v2/calls/outbound-test-ws-media"

payload = {
    "modelId": "8edd032a-5fef-4847-abe2-55fb16057ece",
    "fromPhoneNumber": "+15559876543",
    "toPhoneNumber": "+15551234567",
    "leadName": "Test Lead",
    "agentVersion": "draft",
    "wsMediaCodec": "opus"
}
headers = {
    "Authorization": "Bearer <token>",
    "Content-Type": "application/json"
}

response = requests.post(url, json=payload, headers=headers)

print(response.json())
```

```go
package main

import (
	"fmt"
	"strings"
	"net/http"
	"io"
)

func main() {

	url := "https://api.synthflow.ai/v2/calls/outbound-test-ws-media"

	payload := strings.NewReader("{\n  \"modelId\": \"8edd032a-5fef-4847-abe2-55fb16057ece\",\n  \"fromPhoneNumber\": \"+15559876543\",\n  \"toPhoneNumber\": \"+15551234567\",\n  \"leadName\": \"Test Lead\",\n  \"agentVersion\": \"draft\",\n  \"wsMediaCodec\": \"opus\"\n}")

	req, _ := http.NewRequest("POST", url, payload)

	req.Header.Add("Authorization", "Bearer <token>")
	req.Header.Add("Content-Type", "application/json")

	res, _ := http.DefaultClient.Do(req)

	defer res.Body.Close()
	body, _ := io.ReadAll(res.Body)

	fmt.Println(res)
	fmt.Println(string(body))

}
```

```ruby
require 'uri'
require 'net/http'

url = URI("https://api.synthflow.ai/v2/calls/outbound-test-ws-media")

http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true

request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n  \"modelId\": \"8edd032a-5fef-4847-abe2-55fb16057ece\",\n  \"fromPhoneNumber\": \"+15559876543\",\n  \"toPhoneNumber\": \"+15551234567\",\n  \"leadName\": \"Test Lead\",\n  \"agentVersion\": \"draft\",\n  \"wsMediaCodec\": \"opus\"\n}"

response = http.request(request)
puts response.read_body
```

```java
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;

HttpResponse<String> response = Unirest.post("https://api.synthflow.ai/v2/calls/outbound-test-ws-media")
  .header("Authorization", "Bearer <token>")
  .header("Content-Type", "application/json")
  .body("{\n  \"modelId\": \"8edd032a-5fef-4847-abe2-55fb16057ece\",\n  \"fromPhoneNumber\": \"+15559876543\",\n  \"toPhoneNumber\": \"+15551234567\",\n  \"leadName\": \"Test Lead\",\n  \"agentVersion\": \"draft\",\n  \"wsMediaCodec\": \"opus\"\n}")
  .asString();
```

```csharp
using RestSharp;

var client = new RestClient("https://api.synthflow.ai/v2/calls/outbound-test-ws-media");
var request = new RestRequest(Method.POST);
request.AddHeader("Authorization", "Bearer <token>");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{\n  \"modelId\": \"8edd032a-5fef-4847-abe2-55fb16057ece\",\n  \"fromPhoneNumber\": \"+15559876543\",\n  \"toPhoneNumber\": \"+15551234567\",\n  \"leadName\": \"Test Lead\",\n  \"agentVersion\": \"draft\",\n  \"wsMediaCodec\": \"opus\"\n}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```

```swift
import Foundation

let headers = [
  "Authorization": "Bearer <token>",
  "Content-Type": "application/json"
]
let parameters = [
  "modelId": "8edd032a-5fef-4847-abe2-55fb16057ece",
  "fromPhoneNumber": "+15559876543",
  "toPhoneNumber": "+15551234567",
  "leadName": "Test Lead",
  "agentVersion": "draft",
  "wsMediaCodec": "opus"
] as [String : Any]

let postData = JSONSerialization.data(withJSONObject: parameters, options: [])

let request = NSMutableURLRequest(url: NSURL(string: "https://api.synthflow.ai/v2/calls/outbound-test-ws-media")! as URL,
                                        cachePolicy: .useProtocolCachePolicy,
                                    timeoutInterval: 10.0)
request.httpMethod = "POST"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data

let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
  if (error != nil) {
    print(error as Any)
  } else {
    let httpResponse = response as? HTTPURLResponse
    print(httpResponse)
  }
})

dataTask.resume()
```

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.

```json
{
  "type": "call_info",
  "codec": "opus",
  "sample_rate": 48000,
  "channels": 1,
  "ptime_ms": 20,
  "payload_type": 100
}
```

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

| `wsMediaCodec`   | `codec` | `payload_type` | `sample_rate` |
| ---------------- | ------- | -------------- | ------------- |
| `opus` (default) | `opus`  | 100            | 48000         |
| `pcma`           | `pcma`  | 8              | 8000          |
| `pcmu`           | `pcmu`  | 0              | 8000          |

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`.

```ts
type CallInfo = {
  type: "call_info";
  codec: string;
  sample_rate: number;
  channels: number;
  ptime_ms: number;
  payload_type: number;
};

async function connectWsMedia(wsMediaUrl: string) {
  const ws = new WebSocket(wsMediaUrl);
  ws.binaryType = "arraybuffer";

  const callInfo = await new Promise<CallInfo>((resolve, reject) => {
    ws.onmessage = (event) => {
      if (typeof event.data === "string") {
        const info = JSON.parse(event.data) as CallInfo;
        if (info.type === "call_info") resolve(info);
      }
    };
    ws.onerror = () => reject(new Error("WebSocket error"));
    ws.onclose = () => reject(new Error("WebSocket closed before call_info"));
  });

  console.log("Ready:", callInfo);

  ws.onmessage = (event) => {
    if (event.data instanceof ArrayBuffer) {
      // Decode RTP payload with your codec library (Opus, G.711, etc.)
      playRtpPacket(new Uint8Array(event.data), callInfo);
    }
  };

  const micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
  startRtpSender(ws, micStream, callInfo);

  return {
    callInfo,
    hangup: () => ws.close(),
  };
}
```

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](/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

| Symptom                      | What to check                                                                                                 |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------- |
| WebSocket closes immediately | `wsMediaUrl` expired or wrong region; request a fresh URL                                                     |
| No audio received            | Confirm 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 websocket                              | WebSocket media (this guide)                                                        |
| ----------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------- |
| Start a session   | `GET /websocket/token/{assistant_id}`             | Start a call via API (see [Step 1](#step-1-start-a-call-and-get-the-websocket-url)) |
| Audio format      | Raw PCM16 (48 kHz in, 16 kHz out)                 | Binary RTP (Opus by default)                                                        |
| Readiness signals | `status_client_ready` / `status_agent_ready` JSON | `call_info` JSON, then RTP only                                                     |
| Client signaling  | None                                              | None (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](/create-an-agent), 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](#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](#troubleshooting) for more symptoms.