Preview · subject to change before general availability
Integrate in an afternoon.
Voicemail Detector listens to the first seconds of an answered call and returns one verdict: human, machine or undecided, with a reason. Stream audio while the call is live, or send a recording when it is not.
Every transport takes the same three inputs (audio format, mode and deadline) and returns the same verdict object.
- Streamingwss://stream.voicemaildetector.com/v1/listen
- gRPCgrpc.voicemaildetector.com:443
- RESThttps://api.voicemaildetector.com/v1
Authentication
Create API keys in the dashboard. Send them as a bearer token: the authorization header on WebSocket, REST and gRPC metadata. Keys start with vmd_live_ or vmd_test_; test keys are free, rate-limited and never billed.
Where a client cannot set headers, such as some media-forking modules, pass a short-lived stream token as token in the URL. Mint it from your backend with POST /v1/stream-tokens; it is valid for one stream and 60 seconds.
Audio
Mono audio, as it arrives from the trunk. Send 20 ms frames when you can; any frame size from 10 to 100 ms works.
| encoding | sample_rate | 20 ms frame |
|---|---|---|
| mulaw | 8000 | 160 bytes |
| alaw | 8000 | 160 bytes |
| linear16 | 8000 | 320 bytes |
| linear16 | 16000 | 640 bytes |
linear16 is signed 16-bit little-endian PCM. Start streaming at answer: ringback before the answer is not the callee and only spends your deadline.
Modes
Pick a mode per stream with mode. The default is balanced.
fast: Acts on the first solid evidence. For high-volume dialing, where every second is billed.balanced: The default. Waits for a confirming window before it calls a person a person.careful: Collects more evidence before any verdict. For flows where misrouting a person costs more than a few seconds.
Shadow evaluation is not a mode: run the engine next to your current AMD, log both, and act on yours until you are ready to switch.
Deadlines
Every stream has a deadline: the gRPC deadline of the call, or deadline_ms on WebSocket and Asterisk/FreeSWITCH. The engine answers 500 ms before it. If the evidence is not enough by then, the verdict is undecided with reason deadline, so your dialer always has an answer in time.
Deadlines between 3 and 6 seconds suit most outbound traffic. Shorter deadlines leave greetings undecided more often; longer ones cost seconds only on the calls that need them.
The verdict
One object per call, whatever the transport.
{
"id": "evl_01jb6x8m4q2r7t9v",
"label": "machine",
"reason": "greeting",
"decided_at_ms": 3120,
"mode": "balanced",
"audio": {"encoding": "mulaw", "sample_rate": 8000},
"evidence": {
"voice_onset_ms": 300,
"voiced_since_onset": 0.91,
"classifier": ["speech", "speech", "speech"],
"known_recording": null
}
}Labels
human: connect.machine: cut.undecided: your policy.
Reasons
human_speech(human): Speech followed by the pause of someone waiting for an answer.greeting(machine): A voice that keeps talking past the point where a person would stop and listen.synthetic_voice(machine): Text-to-speech and IVR prompts, like the carrier’s “the person you are calling is not available”.known_recording(machine): An acoustic fingerprint match against a library of carrier greetings and answering bots.tone(machine): Busy signals, special information tones and other tones on the line.dead_air(machine): Seconds of digital silence after the answer. Nobody, and nothing, is there.deadline(undecided): Not enough evidence inside your time budget. You get an explicit undecided before the deadline, and your policy decides.
gRPC
A client-streaming RPC: send the config, then audio frames as they arrive. The engine closes its side as soon as it decides; CloseAndRecv returns the verdict. Set the deadline on the call context.
syntax = "proto3";
package voicemaildetector.v1;
service Detector {
// Config first, then audio frames. One verdict, before the call's deadline.
rpc Evaluate(stream EvaluateRequest) returns (Verdict);
}
message EvaluateRequest {
oneof payload {
StreamConfig config = 1;
bytes audio = 2;
}
}
message StreamConfig {
Encoding encoding = 1; // MULAW, ALAW, LINEAR16
int32 sample_rate_hz = 2; // 8000 or 16000
Mode mode = 3; // FAST, BALANCED, CAREFUL
string call_id = 4;
map<string, string> metadata = 5;
}
message Verdict {
string id = 1;
Label label = 2; // HUMAN, MACHINE, UNDECIDED
Reason reason = 3; // HUMAN_SPEECH, GREETING, SYNTHETIC_VOICE, KNOWN_RECORDING, TONE, DEAD_AIR, DEADLINE
int32 decided_at_ms = 4;
}ctx, cancel := context.WithTimeout(ctx, 4*time.Second) // your deadline: the verdict lands before it
defer cancel()
stream, err := detector.Evaluate(metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+apiKey))
if err != nil {
return err
}
stream.Send(&vmd.EvaluateRequest{Payload: &vmd.EvaluateRequest_Config{Config: &vmd.StreamConfig{
Encoding: vmd.Encoding_MULAW, SampleRateHz: 8000, Mode: vmd.Mode_BALANCED, CallId: callID,
}}})
for frame := range rtpFrames { // 20 ms each, as they arrive
if err := stream.Send(&vmd.EvaluateRequest{Payload: &vmd.EvaluateRequest_Audio{Audio: frame}}); err != nil {
break // the engine closed the stream: the verdict is ready
}
}
verdict, err := stream.CloseAndRecv()
// verdict.Label == vmd.Label_MACHINE, verdict.Reason == vmd.Reason_GREETINGWebSocket
Open wss://stream.voicemaildetector.com/v1/listen with the format, mode and deadline in the query string. Send audio as binary messages. The server sends JSON text messages and closes the socket after the verdict.
const ws = new WebSocket('wss://stream.voicemaildetector.com/v1/listen?encoding=mulaw&sample_rate=8000&mode=balanced&deadline_ms=4000', {
headers: {authorization: `Bearer ${process.env.VMD_API_KEY}`}
})
call.on('audio', frame => ws.readyState === ws.OPEN && ws.send(frame)) // raw 20 ms frames
ws.on('message', data => {
const event = JSON.parse(data)
if (event.type !== 'verdict') return
if (event.label === 'human') call.bridgeToAgent()
else call.hangup() // machine, or undecided by your policy
})Events: {"type":"ready"} once the stream is accepted, then exactly one {"type":"verdict", ...} with the verdict fields.
Asterisk
vmd-connector is a small container you run next to Asterisk. It registers an ARI application, snoops the answered channel, streams the callee audio to the engine and writes the verdict back as channel variables before continuing the dialplan. It uses chan_websocket where your Asterisk ships it, and AudioSocket or RTP external media elsewhere.
; Answered calls enter Stasis; vmd-connector snoops the channel over ARI and streams
; the callee audio to the engine (chan_websocket, AudioSocket or RTP external media).
[outbound-amd]
exten => s,1,Set(VMD_MODE=balanced)
same => n,Set(VMD_DEADLINE_MS=4000)
same => n,Stasis(vmd) ; returns with VMD_LABEL and VMD_REASON set
same => n,GotoIf($["${VMD_LABEL}" = "human"]?agents,s,1)
same => n,Hangup() ; machine or undecided: free the trunkAfter Stasis(vmd) returns, VMD_LABEL, VMD_REASON and VMD_DECIDED_AT_MS are set on the channel.
FreeSWITCH
Fork the callee audio with mod_audio_stream straight to the WebSocket endpoint, at 8k or 16k. Pass a stream token in the URL when your build of the module cannot send an authorization header.
# On answer, fork the callee audio to the engine with mod_audio_stream (8k or 16k).
uuid_audio_stream ${uuid} start wss://stream.voicemaildetector.com/v1/listen?encoding=linear16&sample_rate=8000&mode=fast&deadline_ms=3500&token=${vmd_token} mono 8k
# The verdict arrives as a mod_audio_stream::json event on the event socket:
# {"type":"verdict","label":"machine","reason":"greeting","decided_at_ms":3120}
# Hang up or transfer from your ESL handler:
uuid_kill ${uuid}REST
For recordings: post-call QA, carrier disputes and backtests against the AMD you run today. Upload WAV, or raw µ-law and A-law with encoding and sample_rate. The response adds beep_at_ms when a record tone is found.
# Not streaming? Send a recording and get the same verdict, plus when it would have cut.
curl https://api.voicemaildetector.com/v1/evaluations \
-H "authorization: Bearer $VMD_API_KEY" \
-F audio=@call-0193.wav \
-F mode=balanced{
"id": "evl_01jb6xa9c7d2k5mn",
"label": "machine",
"reason": "synthetic_voice",
"decided_at_ms": 2890,
"beep_at_ms": 7100,
"audio": {"encoding": "linear16", "sample_rate": 8000, "duration_ms": 31840}
}Webhooks
Add an endpoint in the dashboard to receive every verdict, from every transport, as verdict.created. Requests are signed with vmd-signature, an HMAC-SHA256 of the body with your endpoint secret, and retried with backoff for 24 hours.
{
"type": "verdict.created",
"created_at": "2026-10-18T14:03:22.418Z",
"data": {"id": "evl_01jb6x8m4q2r7t9v", "call_id": "c-88213", "label": "human", "reason": "human_speech", "decided_at_ms": 2140}
}Errors & limits
gRPC status codes and WebSocket close codes carry the same meaning.
UNAUTHENTICATED · 4001Missing, revoked or malformed API key.INVALID_ARGUMENT · 4000Unknown encoding, a sample rate other than 8000 or 16000, or audio sent before the config.FAILED_PRECONDITION · 4008Deadline shorter than 1 second: there is no room to decide.RESOURCE_EXHAUSTED · 4029Concurrent stream limit reached. Refused at once, never queued.UNAVAILABLE · 1013The engine is restarting or draining. Retry on another connection.