v4.2  ·  July 2026
What changed in v4.2 (July 2026):
  • 6 new classification tokensFAXAMD, CALLASSISTAMD, CALLASSISTSCRNAMD, CALLRECAMD, FAS2AMD, HELLOFASAMD. See Section 8.
  • PLVMSFASAMD renamed to PLVMSAMD — update any hardcoded checks. See Section 8.
  • immediate_detection encoding extended — now also accepts floats (1.0/0.0) and strings "yes"/"no"/"on"/"off" (case-insensitive). See Section 9.
  • Stage frame format corrected — field order is STAGE-<stage>-<CLS>-<duration>-<confidence>. Multiple stages per chunk are semicolon-joined. See Section 9.
  • SILENCEAMD semantic clarified — returned when stripped audio is too short to classify, not just on long silence. Duration reflects raw audio including leading silence. See Section 7.
  • Cascade extended — early stages 1k→2k→3k now precede the existing 4k→32k sequence. See Section 6.
v4.1 note: stage_results flag introduced — streams STAGE-… progress frames between checkpoints. immediate_detection relaxed to accept true, 1, "true".
v4 note: The old Firewall Manager API (portal.amdy.io/client-api/ips) is retired. IP registration is now POST app.amdy.io/api/v1/ips/register — see Section 3.

1 Getting Started

What you need and how the service works.

The service delivers real-time, streaming answering-machine detection for telephony. You stream 8 kHz audio over WebSocket and receive a detection result as soon as the model reaches a confident decision.

How it works

  1. Register your server's public IP via the Registration API (once, during setup).
  2. Connect to ws://api.amdy.io:2700 with your API key in the header.
  3. Send one JSON config frame describing the call.
  4. Stream raw PCM audio in binary frames.
  5. Receive an empty reply (keep streaming) or a non-empty result (stop).

Requirements

ItemDetail
API key64-character hex string — obtained from the portal at app.amdy.io
Public IPYour server's outbound IP, registered via the Registration API
WebSocket clientAny standard WS library (Python websockets, Node ws, etc.)
Audio format8 kHz · 16-bit · mono · raw PCM (little-endian)

2 API Key & Authentication

Your API key is the same credential for both IP registration and WebSocket detection.

API keys are 64-character hex strings. Retrieve yours from the Settings page on the portal.

Passing the key

Use either the Authorization header or the X-API-Key header:

HTTP / Registration API
Authorization: Bearer YOUR_API_KEY
# or
X-API-Key: YOUR_API_KEY
WebSocket
headers: { "X-API-Key": "YOUR_API_KEY" }
Keep your key secret. Store it in an environment variable, never in client-side code or version control. Rotate it from the Settings page if compromised.

3 IP Registration API

Register your server's public IP so it can reach the WebSocket detection endpoint.

Every server that will stream audio to the detection API must have its public IP registered first. Registration is a one-time step per server — the install scripts handle it automatically. For manual integrations, call this endpoint once during setup.

Base URL: https://app.amdy.io
POST /api/v1/ips/register

Register a public IP for your account and activate the account if it is pending. Safe to call repeatedly — duplicate IPs return success.

Authentication

Authorization: Bearer <api_key> or X-API-Key: <api_key>

Request body (JSON)

ipstringPublic IPv4 or IPv6 address to register. If omitted, the server auto-detects from X-Forwarded-For.
descriptionstringOptional label for this server (hostname, datacenter, etc.). Defaults to "Installed via installamd-v2.sh".

Example request

cURL
curl -X POST https://app.amdy.io/api/v1/ips/register \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"ip": "203.0.113.42", "description": "prod-dialer-01"}'
Python
import requests

resp = requests.post(
    "https://app.amdy.io/api/v1/ips/register",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    json={"ip": "203.0.113.42", "description": "prod-dialer-01"},
)
resp.raise_for_status()
print(resp.json())   # {"ok": true, "activated": true, "ip": "203.0.113.42", ...}
Bash (auto-detect IP)
# Omit "ip" — the server reads X-Forwarded-For automatically
curl -X POST https://app.amdy.io/api/v1/ips/register \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"description": "auto-registered"}'

Success response (200)

{
  "ok": true,
  "activated": true,
  "account_activated_now": false,
  "client_id": 1042,
  "ip": "203.0.113.42",
  "registered": { "ip": "203.0.113.42", "status": "registered" }
}

Status codes

200IP registered (or already existed). registered.status is "registered" or "already_registered". 400Missing or invalid IP address. 401Missing or invalid API key. 403API key exists but is not active. 502Internal registration failure. The response body includes a hint field — include it in any support request.

Managing IPs

To view, rename, or remove registered IPs, use the Settings page in the portal. There is no public DELETE endpoint — removals are portal-only.

4 WebSocket Detection API

The real-time detection endpoint your registered servers connect to.
PropertyValue
Endpointws://api.amdy.io:2700
TransportPlain WebSocket routed through HAProxy
AuthX-API-Key header on connect
GateConnection is accepted only from registered IPs
Python
import websockets
headers = {"X-API-Key": "YOUR_API_KEY"}
ws = await websockets.connect("ws://api.amdy.io:2700", extra_headers=headers)
Node.js
const WebSocket = require('ws');
const ws = new WebSocket('ws://api.amdy.io:2700', {
  headers: { 'X-API-Key': 'YOUR_API_KEY' }
});

5 Configuration Message

The first frame you send after connecting — one JSON text frame, then switch to binary audio.
{
  "config": {
    "sample_rate": 8000,
    "VID": "unique_call_id_12345",
    "immediate_detection": false,
    "stage_results": false
  }
}
FieldRequiredDescription
sample_rateYesMust be 8000.
VIDRecommendedUnique call identifier — used in logs and the analytics portal.
phoneNoDestination phone number.
country_code / ccNo2-letter country code.
caller_id / calleridNoCaller ID string.
max_detection_timeNoSeconds before forced result (0.5–10, default 8).
immediate_detectionNoEnable fast mode. Truthy values: JSON boolean true, integer 1, float 1.0, or strings "true"/"yes"/"on"/"1" (case-insensitive). Falsy: false, 0, 0.0, "false"/"no"/"off", or absent. Default false. See Section 9.
stage_resultsNoStream intermediate cascade progress frames (STAGE-…) instead of empty replies during detection. Accepts the same truthy/falsy values as immediate_detection. Default false. See Section 9.

6 Streaming Protocol

Send audio binary frames, read a reply after each one.
  1. Send the JSON config frame (text).
  2. Stream audio as binary frames. ~1 second per chunk (8000 samples) is recommended.
  3. After each chunk, read the server reply:
    "" (empty string) — keep streaming (standard mode).
    STAGE-… — intermediate progress frame; keep streaming (stage_results mode, see Section 9).
    Any other non-empty string — final result, stop and close.
  4. If audio runs out before a result, signal end-of-audio: send {"eof": 1} (text) or an empty binary frame. The server returns a forced result immediately.

Buffer thresholds

Samples bufferedModel stageAction
< 1 000No reply yet, keep streaming
1 000 – 1 9991kReply after this chunk
2 000 – 2 9992kReply after this chunk
3 000 – 3 9993kReply after this chunk
4 000 – 7 9994kReply after this chunk
8 000 – 11 9998kReply after this chunk
12 000 – 15 99912kReply after this chunk
16 000 – 23 99916kReply after this chunk
24 000 – 31 99924kReply after this chunk
≥ 32 000 (~4.0 s)32k + greetingForced result

7 Response Format

A dash-delimited string: CLASSIFICATION-DURATION_SECONDS-CONFIDENCE
ExampleMeaning
""Still detecting — keep streaming
HUMAN-4.50-0.9950Live person, detected at 4.5 s, confidence 99.5%
AMD-8.20-0.9234Generic answering machine
WELCOMEVMAMD-4.50-1.0000Standard voicemail greeting
HONEYPOTAMD-4.50-0.9876Anti-spam honeypot line
VERIZONAMD-4.50-1.0000Carrier forwarding message
SILENCEAMD-8.34-0.0000Insufficient speech after silence removal — treated as machine

Parsing

cls, dur, conf = result.split("-")   # never more than 3 parts — classification never contains dashes
is_human = cls == "HUMAN"
duration  = float(dur)
confidence = float(conf)
Duration is the total raw audio received including leading silence — not the length of the speech segment. SILENCEAMD is returned when stripped audio (after removing leading silence) is too short to classify, regardless of total audio length.
Deadline results can be generic AMD. The greeting sub-classifier needs ~4.5 s of speech. If a call has a long silent lead-in, max_detection_time (default 8 s, clamp 0.5–10) can expire before enough speech accumulates. In that case the server returns the verdict of the deepest completed cascade stage — e.g. AMD-8.10-0.9419: 8.1 s streamed, ~3.9 s silence, all stages agreed machine, greeting stage never reached. The verdict is fully valid; only the machine subtype is absent. If subtypes matter more than latency, raise max_detection_time (up to 10).

8 Classification Values

42+ classifications from the 42-class greeting model. Everything ending in AMD is a machine.
Breaking change — PLVMSFASAMD renamed to PLVMSAMD (v4.2+)
The token previously returned as PLVMSFASAMD is now PLVMSAMD. Any client code that hard-matches the old string will silently stop recognizing “please leave your message” greetings and fail to play voicemail drops for this pattern. Update your switch/case or if-chains before upgrading to a v4.2+ build.
HUMAN
Live person detected
AMD
Machine (unclassified)
BEEPAMD
Beep tone
BUSYAMD
"Busy, try again later"
CANTLEAVEAMD
"Cannot leave a message"
CANTTAKEAMD
"Can't take your call"
CLICKAMD
Click / immediate hangup
CUSTOMVMAMD
Custom voicemail greeting
DISCONNECTEDAMD
Out of service number
VOICEMAILAMD
"Forwarded to voicemail"
GVOICEAMD
Google Voice screening
GOOGLESUBAMD
Google subscriber message
HELLOMSGAMD
"Hello, leave message..."
HONEYPOTAMD
Anti-spam trap line
IVRAMD
IVR menu detected
LADYBOTAMD
Automated assistant
MAILBOXFULLAMD
"Mailbox is full"
MAGICJACKAMD
MagicJack voicemail
MUSICAMD
Hold music
NETWORKAMD
Carrier announcement
PLVMSAMD
"Please leave your message"
PLVMSFORAMD
"Please leave message for..."
SPANISHAMD
Spanish-language greeting
RINGINGAMD
Ring tone only
NUMBERSAMD
Number reading
SCREENINGAMD
Call screening
VMNOTSETUPAMD
"Voicemail not set up"
SUBNOTAVAILAMD
"Subscriber not available"
TELAMD
"The number you dialed..."
THANKYOUAMD
Business thank-you greeting
WELCOMEVMAMD
Standard welcome voicemail
FASAMD
Forward-to-voicemail (v1)
VOICEMAILV2AMD
Voicemail variant 2
FASV3AMD
Forward-to-voicemail (v3)
FASV4AMD
Forward-to-voicemail (v4)
FAS2AMD
Forward-to-voicemail (v2)
HELLOFASAMD
"Hello" false-answer pattern
FAXAMD
Fax tone detected
CALLASSISTAMD
Carrier call-assist message
CALLASSISTSCRNAMD
Call-assist screening message
CALLRECAMD
Call-recording announcement
VERIZONAMD
Verizon carrier callback
SILENCEAMD
Insufficient speech — treated as machine
SILENCE
Complete silence only
Human rescue: If the greeting model detects a real person mid-cascade — even if earlier stages leaned AMD — the result is always HUMAN.

9 Detection Modes

Three independent flags control how the server behaves during the cascade.

Standard mode (default)

Streams through the full cascade (1k → 2k → 3k → 4k → 8k → 12k → 16k → 24k → 32k + greeting at 4.5 s of speech). Returns "" after each chunk until a high-confidence verdict. Accuracy ~99%, typical latency 3–4 seconds.

{"config": {"sample_rate": 8000, "VID": "...", "immediate_detection": false}}
ChunkBufferReply
14k samples"" — keep going
28k samples"" — keep going
312k samples"" — keep going
524k samplesHUMAN-3.00-0.9981 — stop

Immediate mode

Returns at first confident detection (4k–8k). Accuracy ~94–96%, latency 0.5–1 second. Best for high-volume, latency-sensitive routing.

{"config": {"sample_rate": 8000, "VID": "...", "immediate_detection": true}}

Accepted truthy values for immediate_detection: JSON true, integer 1, float 1.0, or strings "true" / "yes" / "on" / "1" (case-insensitive). Falsy: false, 0, 0.0, "false" / "no" / "off", or absent. On server builds older than 2026-07-31 only the unquoted JSON boolean works — other encodings are silently ignored and the mode stays off. If the flag appears to have no effect, verify the encoding first.

ChunkBufferReply
14k samplesHUMAN-0.50-0.8740 — stop

Stage results mode

Instead of silent "" replies between checkpoints, the server streams a STAGE-… progress frame after each cascade stage completes. The final result frame is identical to standard mode — no parsing changes required for existing consumers. Combine with standard mode for diagnostic visibility; combine with immediate mode for fast routing with progress frames.

{"config": {"sample_rate": 8000, "VID": "...", "stage_results": true}}

Stage frame format

Format: STAGE-<stage>-<CLS>-<duration>-<confidence>. Stage frames occupy the same reply slot as empty strings. A frame always starts with STAGE- and is never a valid final classification token. If multiple cascade stages complete between two audio chunks, they are semicolon-joined in a single frame.

ChunkBufferReply (stage_results: true)
14k samplesSTAGE-1k-AMD-0.12-0.55;STAGE-2k-AMD-0.25-0.61;STAGE-3k-AMD-0.37-0.68;STAGE-4k-AMD-0.50-0.72 — keep going
28k samplesSTAGE-8k-AMD-1.00-0.79 — keep going
312k samplesSTAGE-12k-AMD-1.50-0.88 — keep going
524k samplesWELCOMEVMAMD-3.00-0.9734 — stop (final)
Handling stage frames in Python
msg = await ws.recv()
if not msg:
    pass                          # empty — keep streaming (standard mode)
elif msg.startswith("STAGE-"):
    for frame in msg.split(";"):
        _, stage, cls, dur, conf = frame.split("-", 4)
        print(f"  {stage}: {cls} @ {dur}s ({float(conf):.0%})")   # optional
    # keep streaming regardless
else:
    cls, dur, conf = msg.split("-")   # final result
    break
stage_results is orthogonal to immediate_detection. You can enable both: you get stage frames at 1k–4k (whichever complete before the immediate-mode cutoff), then the final result. With stage_results off and immediate_detection on, behavior is unchanged from v4.0.

10 Code Examples

Complete, production-ready implementations.

Python

import asyncio, json, websockets, numpy as np, soundfile as sf

async def detect(audio_file, api_key, immediate=False):
    uri = "ws://api.amdy.io:2700"
    async with websockets.connect(uri, extra_headers={"X-API-Key": api_key}) as ws:
        # 1) Send config
        await ws.send(json.dumps({"config": {
            "sample_rate": 8000,
            "VID": f"CALL_{int(asyncio.get_event_loop().time()*1000)}",
            "immediate_detection": immediate,
        }}))

        # 2) Load and resample to 8 kHz int16
        audio, sr = sf.read(audio_file)
        if audio.ndim > 1: audio = audio.mean(axis=1)
        if sr != 8000:
            from scipy import signal
            audio = signal.resample(audio, int(len(audio)*8000/sr))
        pcm = (audio * 32767).astype(np.int16)

        # 3) Stream 1-second chunks, stop on non-empty reply
        CHUNK = 8000
        for i in range(0, len(pcm), CHUNK):
            await ws.send(pcm[i:i+CHUNK].tobytes())
            try:
                res = await asyncio.wait_for(ws.recv(), timeout=0.5)
            except asyncio.TimeoutError:
                continue
            if res:
                return parse(res)

        # 4) Audio exhausted — force a result
        await ws.send(json.dumps({"eof": 1}))
        return parse(await ws.recv())

def parse(res):
    res = res if isinstance(res, str) else res.decode()
    cls, dur, conf = res.split("-")
    return {"classification": cls, "duration": float(dur), "confidence": float(conf)}

# Usage
r = asyncio.run(detect("call.wav", "YOUR_API_KEY"))
print(r["classification"], f'{r["confidence"]:.2%}')
is_human = r["classification"] == "HUMAN"

Node.js

const WebSocket = require('ws');
const fs = require('fs');
const wav = require('node-wav');

function detect(audioFile, apiKey, immediate = false) {
  return new Promise((resolve, reject) => {
    const ws = new WebSocket('ws://api.amdy.io:2700', {
      headers: { 'X-API-Key': apiKey }
    });
    const CHUNK = 8000;
    let pcm, offset = 0;

    const next = () => {
      if (offset < pcm.length) {
        ws.send(pcm.slice(offset, offset += CHUNK).buffer);
      } else {
        ws.send(JSON.stringify({ eof: 1 }));   // force result
      }
    };

    ws.on('open', () => {
      ws.send(JSON.stringify({
        config: { sample_rate: 8000, VID: `CALL_${Date.now()}`, immediate_detection: immediate }
      }));
      const d = wav.decode(fs.readFileSync(audioFile));
      pcm = Int16Array.from(d.channelData[0], x => Math.max(-32768, Math.min(32767, x * 32767)));
      next();
    });

    ws.on('message', (data) => {
      const res = data.toString();
      if (res) {
        const [cls, dur, conf] = res.split('-');
        ws.close();
        resolve({ classification: cls, duration: +dur, confidence: +conf });
      } else {
        next();
      }
    });

    ws.on('error', reject);
  });
}

detect('call.wav', 'YOUR_API_KEY')
  .then(r => console.log(r.classification, (r.confidence * 100).toFixed(2) + '%'));

Bash (one-shot registration)

#!/bin/bash
# Register this server's IP — run once during setup
API_KEY="YOUR_API_KEY"
DESCRIPTION="$(hostname)"

curl -sfX POST https://app.amdy.io/api/v1/ips/register \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d "{"description": "$DESCRIPTION"}" | python3 -m json.tool

11 Audio Format

All audio must be 8 kHz · 16-bit · mono · raw PCM (signed little-endian).

FFmpeg conversion

ffmpeg -i input.mp3 -ar 8000 -ac 1 -f s16le -acodec pcm_s16le output.raw

Python conversion

import soundfile as sf, numpy as np
from scipy import signal

def to_pcm(path):
    a, sr = sf.read(path)
    if a.ndim > 1: a = a.mean(axis=1)
    if sr != 8000: a = signal.resample(a, int(len(a) * 8000 / sr))
    return (a * 32767).astype(np.int16)
Common pitfall: Sending stereo or 16 kHz audio without resampling is the #1 cause of SILENCEAMD results. Always verify format before streaming.

12 Error Handling

WebSocket close codes and recovery strategies.
CodeMeaningAction
1000Normal closure (result delivered)No action needed
1001Server maintenanceRetry after a few seconds
1006Connection lostRetry with exponential backoff
1008Policy violation (IP not whitelisted)Register the IP via POST /api/v1/ips/register
1011Server errorRetry; contact support if persistent

Common issues

SymptomCauseFix
Empty replies indefinitelyWrong audio format or no EOF sentVerify 8 kHz/16-bit/mono; send {"eof":1} when audio ends
Connection refused / 1008Source IP not registeredCall POST /api/v1/ips/register from that server
Slow detectionStandard mode running full cascadeEnable immediate_detection: true
Registration 502Internal sync issueRetry once; include the hint field when emailing support

Retry pattern

import time

def detect_with_retry(audio_file, api_key, max_retries=3):
    for attempt in range(max_retries):
        try:
            return asyncio.run(detect(audio_file, api_key))
        except Exception as e:
            if attempt == max_retries - 1: raise
            time.sleep(2 ** attempt)   # 1s, 2s, 4s

13 Support

How to reach us.
ChannelDetails
Portalapp.amdy.io — manage keys, IPs, billing, analytics
Email[email protected]
When reporting a registration issue, include the client_id and hint fields from the 502 response body.