Skip to content

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.

encodingsample_rate20 ms frame
mulaw8000160 bytes
alaw8000160 bytes
linear168000320 bytes
linear1616000640 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.

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

voicemaildetector/v1/detector.proto
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;
}
main.go
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_GREETING

WebSocket

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.

listen.js
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.

extensions.conf
; 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 trunk

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

fs_cli
# 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.

curl
# 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
200 OK
{
  "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.

POST https://your-app.example/hooks/vmd
{
  "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.