docs · API v1 Guide .md
Answering machine detection · real time

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 wss://167-233-100-203.sslip.io/v1/stream
Try the live demo
0 msadded to the conversation: analysis runs in parallel
5.5 sfirst machine suspicion (median)
9 shang-up after answer (median)

In short

  1. You stream the callee

    The callee's audio only, never your AI's voice, starting at answer.

  2. Sonar sends verdicts as the call unfolds

    Human, voicemail, carrier message, switchboard or IVR.

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

Drop a recording or click to choose one WAV, MP3, M4A… up to 120 s analysed

Access #

Development environment.

Real-time WebSocketwss://167-233-100-203.sslip.io/v1/stream
REST APIhttps://167-233-100-203.sslip.io
KeySent separately. Put it in SONAR_API_KEY, never in the code.
Quota20,000 analysed calls per month
Concurrent streams10 at most on this environment
Analysed duration120 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

json
{"type": "start", "api_key": "<SONAR_API_KEY>", "call_id": "CA1234...",
 "sample_rate": 8000, "format": "mulaw"}
FieldValues
call_idYour call ID (Twilio CallSid, FreeSWITCH UUID), 128 characters max.
sample_rate8000 recommended; other rates are resampled.
formatmulaw (Twilio), alaw, s16le (16-bit little-endian PCM), f32le.
wrapOptional. "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.

json
{"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.

FieldValues
verdictmachine or human (no_decision possible in final).
labelhuman; voicemail (personal greeting); operator_msg (voicemail or carrier message); robot_ivr (switchboard, IVR, waiting queue).
eventprovisional: first suspicion, may be withdrawn. confirmed: machine confirmed. final: definitive decision. update: late decision after monitoring. revoked: suspicion withdrawn.
actionhangup: hang up. wait: switchboard or IVR, a human may still pick up. continue: human, let your AI talk.
t_decisionSeconds of audio elapsed since answer when the verdict was issued.

When to hang up #

Apply it exactly as written.

Hang upConfirmed verdict

action == "hangup" and event in confirmed, final, update.

Hang up on the beepAfter a wait verdict

A beep received after a verdict with action == "wait" or event == "revoked".

NeverOn a provisional verdict alone

A provisional verdict can still be withdrawn: never hang up on it.

rule
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 alone

Fail-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 #

CodeCause
4001Missing or invalid key (error.code 401), account disabled (403), quota reached (429).
4002Unknown format.
1013Maximum 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.

StepTime after answer
First machine suspicion5.5 s median
Hang-up9 s median, 90% under 14 s
Definitive human verdictaround 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 the inbound track, 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.
sonar_twilio.py
"""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.

  • 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: log AnsweredBy next 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:

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 start message. It must be compact JSON with no spaces, because it is a single ESL argument.
  • Use bgapi, not api. 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_fork only relays messages of type transcription.

Receive the verdicts

Subscribe the ESL to these events:

ESL
events plain CHANNEL_ANSWER CHANNEL_HANGUP CUSTOM mod_audio_fork::transcription
  • Each verdict arrives as a CUSTOM event, subclass mod_audio_fork::transcription.
  • The JSON is in the event body, and Unique-ID gives the channel UUID.
  • Depending on the module version, the body holds the full envelope {"type":"transcription","data":{…}} or directly {…}: unwrap data when 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.

sonar_replay.py
"""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.

bash
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

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

  1. Plug Sonar in with SONAR_SHADOW=1

    It gives its verdict but cuts nothing.

  2. Log every call

    Sonar's decision and when it would have hung up, next to what actually happened: duration, Twilio's AnsweredBy, conversation outcome.

  3. Compare

    The AI and telephony seconds saved on machines, and the humans the other detection wrongly cut.

  4. Switch to SONAR_SHADOW=0

    Once the numbers are validated.