Plug Sonar into your outbound calls
Stream the callee's audio from the moment they answer and get a verdict within seconds: human, voicemail, carrier message or switchboard. You hang up on machines; your AI talks to humans.
wss://167-233-100-203.sslip.io/v1/stream
In short
- You stream the callee
The callee's audio only, never your AI's voice, starting at answer.
- Sonar sends verdicts as the call unfolds
Human, voicemail, carrier message, switchboard or IVR.
- You hang up
By applying the rule in When to hang up. Your AI talks from the moment the call is answered, without waiting for Sonar.
No audio is stored. Sonar keeps one line per call: ID, duration and verdict.
Live demo #
Audio is streamed to the API in real time, exactly as during a call, and the page hangs up when the production rule says so.
No audio stored · each test counts as one call on your key
You play the callee. Answer as you would a call ("Hello? Yes?"): Sonar should conclude human around 11 s and let the call go on. Play a voicemail greeting near the microphone: it should hang up.
Access #
Development environment.
| Real-time WebSocket | wss://167-233-100-203.sslip.io/v1/stream |
| REST API | https://167-233-100-203.sslip.io |
| Key | Sent separately. Put it in SONAR_API_KEY, never in the code. |
| Quota | 20,000 analysed calls per month |
| Concurrent streams | 10 at most on this environment |
| Analysed duration | 120 s of audio at most per call |
A call counts as soon as it has sent at least 1 s of audio. This is a development endpoint: the address will change for production.
WebSocket connection #
One WebSocket connection per call, on /v1/stream.
1. First message, as text: start
{"type": "start", "api_key": "<SONAR_API_KEY>", "call_id": "CA1234...",
"sample_rate": 8000, "format": "mulaw"}| Field | Values |
|---|---|
call_id | Your call ID (Twilio CallSid, FreeSWITCH UUID), 128 characters max. |
sample_rate | 8000 recommended; other rates are resampled. |
format | mulaw (Twilio), alaw, s16le (16-bit little-endian PCM), f32le. |
wrap | Optional. "transcription" wraps every reply as {"type":"transcription","data":{…}}, the format FreeSWITCH's mod_audio_fork expects. |
2. Then, audio as binary messages
Mono audio of the callee, in the announced format, in 20 to 200 ms chunks, as the call goes.
Start at answer (answer), not while ringing nor during early media.
3. End (optional)
Send {"type": "end"}, or simply close the connection.
Messages you receive #
Sonar sends four kinds of JSON messages.
{"type": "verdict", "call_id": "CA1234...", "verdict": "machine", "label": "operator_msg",
"event": "confirmed", "action": "hangup", "t_decision": 8.4}One verdict at each step of the decision, as the call unfolds.
{"type": "beep", "call_id": "CA1234...", "t": 15.04}Signals a voicemail beep.
{"type": "final", "call_id": "CA1234...", "audio_seconds": 20.4, "verdict": "machine",
"label": "operator_msg", "action": "hangup", "beep": true}Always arrives last, when the stream closes.
{"type": "error", "code": 401, "message": "clé API invalide ou révoquée"}Followed by the connection closing. The message text is in French; rely on code. See Errors.
| Field | Values |
|---|---|
verdict | machine or human (no_decision possible in final). |
label | human; voicemail (personal greeting); operator_msg (voicemail or carrier message); robot_ivr (switchboard, IVR, waiting queue). |
event | provisional: first suspicion, may be withdrawn. confirmed: machine confirmed. final: definitive decision. update: late decision after monitoring. revoked: suspicion withdrawn. |
action | hangup: hang up. wait: switchboard or IVR, a human may still pick up. continue: human, let your AI talk. |
t_decision | Seconds of audio elapsed since answer when the verdict was issued. |
When to hang up #
Apply it exactly as written.
action == "hangup" and event in confirmed, final, update.
A beep received after a verdict with action == "wait" or event == "revoked".
A provisional verdict can still be withdrawn: never hang up on it.
hang up if verdict.action == "hangup" AND verdict.event in {"confirmed", "final", "update"}
hang up if "beep" received AFTER a verdict with action == "wait" or event == "revoked"
NEVER hang up on a "provisional" verdict aloneFail-open. Never block the call on Sonar. If the connection fails, closes or gives no verdict, the call goes on normally.
Errors and close codes #
| Code | Cause |
|---|---|
4001 | Missing or invalid key (error.code 401), account disabled (403), quota reached (429). |
4002 | Unknown format. |
1013 | Maximum capacity reached. The call goes on without AMD; retry on the next call. |
Latency observed in production #
Measured on ~400,000 calls in October 2026.
| Step | Time after answer |
|---|---|
| First machine suspicion | 5.5 s median |
| Hang-up | 9 s median, 90% under 14 s |
| Definitive human verdict | around 11 s |
Sonar deliberately waits for confirmation so it never cuts a human. Your AI talks from the moment the call is answered, without waiting for the human verdict.
Recipe 1: Twilio Media Streams #
Twilio sends 8 kHz μ-law audio, base64-encoded, in 20 ms frames. Decode the base64 and forward the bytes as they are, with format: "mulaw".
- You already run a WebSocket server for
<Connect><Stream>(bidirectional): add one Sonar session per call, as below. This stream only carries theinboundtrack, which is the callee: exactly what you need. - To plug Sonar next to your current pipeline without touching it: add
<Start><Stream url="wss://your-server/sonar" track="inbound_track"/></Start>to the TwiML. Twilio then sends a copy of the callee's audio to a second WebSocket, where you run the same code.
"""One Sonar session per Twilio call. Fail-open: any error lets the call go on."""
import asyncio, base64, json, os
import websockets
from twilio.rest import Client
SONAR_URL = "wss://167-233-100-203.sslip.io/v1/stream"
SONAR_KEY = os.environ["SONAR_API_KEY"]
SHADOW = os.environ.get("SONAR_SHADOW", "1") == "1" # 1 = log only, never hang up
twilio = Client(os.environ["TWILIO_ACCOUNT_SID"], os.environ["TWILIO_AUTH_TOKEN"])
class SonarSession:
def __init__(self, call_sid: str):
self.call_sid = call_sid
self.ws = None
self.waiting_beep = False
self.done = False
async def start(self):
try:
self.ws = await websockets.connect(SONAR_URL, open_timeout=3)
await self.ws.send(json.dumps({
"type": "start", "api_key": SONAR_KEY, "call_id": self.call_sid,
"sample_rate": 8000, "format": "mulaw"}))
asyncio.create_task(self._read())
except Exception:
self.ws = None
async def feed(self, payload_b64: str):
if self.ws and not self.done:
try:
await self.ws.send(base64.b64decode(payload_b64))
except Exception:
self.ws = None
async def stop(self):
if self.ws:
try:
await self.ws.send(json.dumps({"type": "end"}))
await self.ws.close()
except Exception:
pass
async def _read(self):
try:
async for raw in self.ws:
m = json.loads(raw)
if m["type"] == "verdict":
if m["action"] == "hangup" and m["event"] in ("confirmed", "final", "update"):
await self._hangup(f'{m["event"]}:{m["label"]}')
elif m["action"] == "wait" or m["event"] == "revoked":
self.waiting_beep = True
elif m["type"] == "beep" and self.waiting_beep:
await self._hangup("beep")
elif m["type"] == "final":
print("sonar final", self.call_sid, m) # log it on your side
except Exception:
pass
async def _hangup(self, reason: str):
if self.done:
return
self.done = True
print("sonar hangup", self.call_sid, reason, "(shadow)" if SHADOW else "")
if not SHADOW:
await asyncio.to_thread(twilio.calls(self.call_sid).update, status="completed")
# In your Twilio WebSocket handler:
# event "start" → sonar = SonarSession(msg["start"]["callSid"]); await sonar.start()
# event "media" → if msg["media"].get("track", "inbound") == "inbound":
# await sonar.feed(msg["media"]["payload"])
# event "stop" → await sonar.stop()Dependencies: pip install websockets twilio.
/** One Sonar session per Twilio call. Fail-open: any error lets the call go on. */
import WebSocket from "ws";
import twilio from "twilio";
const SONAR_URL = "wss://167-233-100-203.sslip.io/v1/stream";
const SONAR_KEY = process.env.SONAR_API_KEY ?? "";
const SHADOW = (process.env.SONAR_SHADOW ?? "1") === "1"; // 1 = log only, never hang up
const client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
type SonarMessage =
| { type: "verdict"; call_id: string; verdict: "machine" | "human"; label: string;
event: "provisional" | "confirmed" | "final" | "update" | "revoked";
action: "hangup" | "wait" | "continue"; t_decision: number }
| { type: "beep"; call_id: string; t: number }
| { type: "final"; call_id: string; audio_seconds: number; verdict: "machine" | "human" | "no_decision";
label: string | null; action: string | null; beep: boolean }
| { type: "error"; code: number; message: string };
export class SonarSession {
private ws: WebSocket | null = null;
private pending: Buffer[] = []; // audio received while connecting to Sonar
private waitingBeep = false;
private done = false;
constructor(private readonly callSid: string) {}
start(): void {
try {
const ws = new WebSocket(SONAR_URL, { handshakeTimeout: 3000 });
ws.on("open", () => {
ws.send(JSON.stringify({ type: "start", api_key: SONAR_KEY, call_id: this.callSid,
sample_rate: 8000, format: "mulaw" }));
for (const chunk of this.pending) ws.send(chunk);
this.pending = [];
});
ws.on("message", (raw) => {
try {
void this.onMessage(JSON.parse(raw.toString()) as SonarMessage);
} catch {
// unreadable message: ignored
}
});
ws.on("error", () => { this.ws = null; });
ws.on("close", () => { this.ws = null; });
this.ws = ws;
} catch {
this.ws = null;
}
}
feed(payloadB64: string): void {
const ws = this.ws;
if (!ws || this.done) return;
const chunk = Buffer.from(payloadB64, "base64");
if (ws.readyState === WebSocket.OPEN) ws.send(chunk);
else if (ws.readyState === WebSocket.CONNECTING && this.pending.length < 250) this.pending.push(chunk);
}
stop(): void {
const ws = this.ws;
if (!ws) return;
try {
// Sonar answers with "final", then closes the connection itself
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "end" }));
setTimeout(() => ws.close(), 5000).unref();
} catch {
// fail-open
}
}
private async onMessage(m: SonarMessage): Promise<void> {
if (m.type === "verdict") {
if (m.action === "hangup" && (m.event === "confirmed" || m.event === "final" || m.event === "update")) {
await this.hangup(`${m.event}:${m.label}`);
} else if (m.action === "wait" || m.event === "revoked") {
this.waitingBeep = true;
}
} else if (m.type === "beep" && this.waitingBeep) {
await this.hangup("beep");
} else if (m.type === "final") {
console.log("sonar final", this.callSid, m); // log it on your side
}
}
private async hangup(reason: string): Promise<void> {
if (this.done) return;
this.done = true;
console.log("sonar hangup", this.callSid, reason, SHADOW ? "(shadow)" : "");
if (!SHADOW) {
try {
await client.calls(this.callSid).update({ status: "completed" });
} catch {
// the call may already be over
}
}
}
}
// In your Twilio WebSocket handler:
// event "start" → const sonar = new SonarSession(msg.start.callSid); sonar.start();
// event "media" → if ((msg.media.track ?? "inbound") === "inbound") sonar.feed(msg.media.payload);
// event "stop" → sonar.stop();Dependencies (Node 18+): npm i ws twilio and npm i -D @types/ws. Audio received while connecting to Sonar is buffered, then sent.
- The Twilio stream starts at answer, when the TwiML runs: nothing to filter.
- If Twilio's own answering machine detection (
machineDetection) is on, do not let it hang up at the same time as Sonar. Keeping it in shadow mode, however, is the best way to compare both: logAnsweredBynext to Sonar's verdict. - To hang up, you can also stop your AI agent instead of ending the Twilio call, depending on your architecture.
Recipe 2: FreeSWITCH #
With mod_audio_fork. This is the integration running in production since July 2026, more than 1.5 million calls analysed.
When the callee's leg is answered (CHANNEL_ANSWER event, never during early media), start the fork over ESL:
bgapi uuid_audio_fork <uuid> start wss://167-233-100-203.sslip.io/v1/stream mono 8000 {"type":"start","api_key":"<SONAR_API_KEY>","call_id":"<uuid>","sample_rate":8000,"format":"s16le","wrap":"transcription"}mono: only the audio received on this channel is sent, which is the callee. Your AI's voice is never sent.- The last argument is the
startmessage. It must be compact JSON with no spaces, because it is a single ESL argument. - Use
bgapi, notapi. Otherwise the WebSocket handshake blocks the ESL connection; under load, events pile up and FreeSWITCH ends up closing the ESL. "wrap":"transcription"is required:mod_audio_forkonly relays messages of typetranscription.
Receive the verdicts
Subscribe the ESL to these events:
events plain CHANNEL_ANSWER CHANNEL_HANGUP CUSTOM mod_audio_fork::transcription- Each verdict arrives as a
CUSTOMevent, subclassmod_audio_fork::transcription. - The JSON is in the event body, and
Unique-IDgives the channel UUID. - Depending on the module version, the body holds the full envelope
{"type":"transcription","data":{…}}or directly{…}: unwrapdatawhen present.
Hang up
Apply the When to hang up rule, then bgapi uuid_kill <uuid> NORMAL_CLEARING. If your AI sits on another bridged leg, kill the leg that carries it.
With mod_audio_stream instead
Same principle (uuid_audio_stream <uuid> start <url> mono 8k <metadata>). Sonar's messages arrive as mod_audio_stream::json events: do not use wrap. Check the exact syntax of your module version, and set sample_rate to 16000 if you stream at 16k.
Test without a real call #
Replay a recording as a live call
You need Python 3.10+, pip install websockets and ffmpeg.
"""python sonar_replay.py call.wav [--format mulaw] [--channel 0] [--realtime]"""
import argparse, asyncio, json, os, subprocess
import websockets
ap = argparse.ArgumentParser()
ap.add_argument("wav")
ap.add_argument("--format", default="mulaw", choices=["mulaw", "alaw", "s16le"])
ap.add_argument("--channel", type=int, default=0, help="callee channel if stereo")
ap.add_argument("--realtime", action="store_true")
a = ap.parse_args()
width = 2 if a.format == "s16le" else 1
audio = subprocess.run(["ffmpeg", "-v", "quiet", "-i", a.wav, "-af", f"pan=mono|c0=c{a.channel}",
"-ar", "8000", "-f", a.format, "-"], capture_output=True).stdout
async def main():
async with websockets.connect("wss://167-233-100-203.sslip.io/v1/stream") as ws:
await ws.send(json.dumps({"type": "start", "api_key": os.environ["SONAR_API_KEY"],
"call_id": a.wav, "sample_rate": 8000, "format": a.format}))
async def reader():
async for raw in ws:
m = json.loads(raw)
print(m)
if m["type"] in ("final", "error"):
return
r = asyncio.create_task(reader())
step = 1600 * width # 200 ms
for i in range(0, len(audio), step):
await ws.send(audio[i:i + step])
if a.realtime:
await asyncio.sleep(0.2)
await ws.send(json.dumps({"type": "end"}))
await asyncio.wait_for(r, 15)
asyncio.run(main())Analyse a whole file at once
WAV or MP3; channel = the callee's channel.
curl -H "x-api-key: $SONAR_API_KEY" -F file=@call.wav \
"https://167-233-100-203.sslip.io/v1/verify?channel=0"Usage and latest calls
curl -H "x-api-key: $SONAR_API_KEY" https://167-233-100-203.sslip.io/v1/usage
curl -H "x-api-key: $SONAR_API_KEY" "https://167-233-100-203.sslip.io/v1/calls?limit=50"Measure the gain (shadow mode) #
To put a number on the return on investment before letting Sonar hang up.
- Plug Sonar in with
SONAR_SHADOW=1It gives its verdict but cuts nothing.
- Log every call
Sonar's decision and when it would have hung up, next to what actually happened: duration, Twilio's
AnsweredBy, conversation outcome. - Compare
The AI and telephony seconds saved on machines, and the humans the other detection wrongly cut.
- Switch to
SONAR_SHADOW=0Once the numbers are validated.