Docs / API reference

AMD Detection API

Real-time answering-machine detection over WebSocket — one API key, your registered IPs, streaming results. This page documents the registration endpoint, the streaming protocol, response parsing, and every classification value the server can return.

Platform guides: ViciDial · Asterisk install · FreeSWITCH install. This page is the API reference those guides build on.

1Getting Started

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 — 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)

2API 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.

3IP Registration API

Register your server's public IP so it can reach the WebSocket detection endpoint. Every server that will stream audio 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 to register. If omitted, the server auto-detects from X-Forwarded-For.
descriptionstringOptional label for this server (hostname, datacenter). 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 body includes a hint field — include it in any support request.

Managing IPs

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

4WebSocket 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' }
});

5Configuration 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: true, 1, 1.0, "true"/"yes"/"on"/"1" (case-insensitive). Falsy: false, 0, "false"/"no"/"off", or absent. Default false. See Detection modes.
stage_resultsNoStream intermediate cascade progress frames (STAGE-…) instead of empty replies during detection. Same truthy/falsy values as immediate_detection. Default false. See Detection modes.

6Streaming 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).
    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 000—No 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

7Response 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).

8Classification Values

The statuses the production server returns today (42-class greeting model). Everything ending in AMD is a machine.

HUMAN
Live person detected
AMD
Machine, no greeting subtype (see the deadline note above)
OTHERAMD
Machine audio that fits no specific class
BEEPAMD
Bare voicemail beep, no greeting
BUSYAMD
"The number you are calling is busy, try later"
CALLASSISTAMD
Call assistant that records the call for the person
CALLASSISTSCRNAMD
Google Call Screen: "Hi, I'm a call assistant..."
CALLGUARDIPHONEAMD
iPhone Live Voicemail / call screening: "record your name and reason for calling"
CALLRECAMD
"Your call is being recorded..."
CANTLEAVEAMD
"You cannot leave a message" / mailbox not accepting
CANTTAKEAMD
"I can't take your call right now..."
CLICKAMD
Click / pickup noise, no speech
CUSTOMVMAMD
Personal recorded voicemail greeting
DISCONNECTEDAMD
"Not in service" / disconnected number
FASAMD
False answer: "Your call has been forwarded to an automated voice messaging system"
FAS2AMD
False answer, variant 2 (rare)
FASV3AMD
Forwarded-voicemail variant 3
FASV4AMD
Forwarded-voicemail variant 4
FAXAMD
Fax tone
FWDVMTONEAMD
Forwarded to voice messaging, tone variant
GOOGLESUBAMD
Google Voice subscriber message
GVOICEAMD
Google Voice
HELLOFASAMD
"Hello?" false answer, then voicemail
HELLOMSGAMD
"Hello, leave a message after the tone"
HONEYPOTAMD
Honeypot / spam-trap line answering like a person
IVRAMD
IVR / auto-attendant: "press 1..."
LADYBOTAMD
Synthetic-voice answering bot
MAGICJACKAMD
magicJack voicemail
MAILBOXFULLAMD
"The mailbox is full"
MUSICAMD
Music or hold music, no speech
NETWORKAMD
Carrier / network robot message
NOAUDIOAMD
No audio received
NUMBERSAMD
Number read-out ("...mailbox number 6062")
PLVMSAMD
"Please leave your message"
PLVMSFORAMD
"Please leave your message for..."
RINGINGAMD
Ringback tone heard as the answer
SPANISHAMD
Spanish greeting: "por favor grabe su mensaje..."
SUBNOTAVAILAMD
"The subscriber you are calling is not available"
TELAMD
"The telephone number ... is not available"
THANKYOUAMD
"Thank you for calling..."
VERIZONAMD
Verizon: forwarded to voice messaging + number read-out
VMNOTSETUPAMD
"The mailbox has not been set up"
VOICEMAILAMD
"Your call has been forwarded to voicemail" (carrier voicemail)
VOICEMAILV2AMD
Forwarded-voicemail variant 2
WELCOMEVMAMD
"Welcome to voicemail..."
SILENCEAMD
Too little speech after removing silence, treated as machine
SILENCE
Complete silence only

Coming with the next greeting model

These changes are not live yet. Production returns the list above until the new 46-class greeting model is deployed. Until then, the new statuses below come back as plain AMD or another machine status.

StatusChangeWhat it is
YOUMAILAMDNewYouMail: "The YouMail subscriber at ..." or "protected by YouMail, press N"
FASRECLINEAMDNewFalse answer on an ordinary number: "Hello, this is ... you are on a recorded line"
ALLSTATEBLKAMDNewAllstate / carrier robocall-blocker announcement
HIYASCRNAMDNewHiya call screening: "this number is protected by Hiya..."
NOMOROBOAMDNewNomorobo: "...to prove you're not a robocaller..."
SMARTBLKAMDNewSmart Call Blocker announcement
FWDVMTONEAMDRetiredThese calls will report VERIZONAMD
PLVMSFORAMDRetiredThese calls will report PLVMSAMD

If your dialplan or reports match on FWDVMTONEAMD or PLVMSFORAMD, treat them the same as VERIZONAMD and PLVMSAMD now, so nothing changes for you when the new model ships. Anything ending in AMD is still a machine.

Human rescue: If the greeting model detects a real person mid-cascade — even if earlier stages leaned AMD — the result is always HUMAN.

9Detection 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.
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.

10Code Examples

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

11Audio 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.

12Error 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

13Support

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.
AMD Detection API — reference (WebSocket protocol & classification codes) | AMDY.IO