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.
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
- Register your server's public IP via the Registration API (once, during setup).
- Connect to ws://api.amdy.io:2700 with your API key in the header.
- Send one JSON config frame describing the call.
- Stream raw PCM audio in binary frames.
- Receive an empty reply (keep streaming) or a non-empty result (stop).
Requirements
| Item | Detail |
|---|---|
| API key | 64-character hex string — from the portal at app.amdy.io |
| Public IP | Your server's outbound IP, registered via the Registration API |
| WebSocket client | Any standard WS library (Python websockets, Node ws, etc.) |
| Audio format | 8 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" }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.
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)
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
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.
| Property | Value |
|---|---|
| Endpoint | ws://api.amdy.io:2700 |
| Transport | Plain WebSocket routed through HAProxy |
| Auth | X-API-Key header on connect |
| Gate | Connection 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
}
}| Field | Required | Description |
|---|---|---|
| sample_rate | Yes | Must be 8000. |
| VID | Recommended | Unique call identifier — used in logs and the analytics portal. |
| phone | No | Destination phone number. |
| country_code / cc | No | 2-letter country code. |
| caller_id / callerid | No | Caller ID string. |
| max_detection_time | No | Seconds before forced result (0.5–10, default 8). |
| immediate_detection | No | Enable 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_results | No | Stream 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.
- Send the JSON config frame (text).
- Stream audio as binary frames. ~1 second per chunk (8000 samples) is recommended.
- 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. - 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 buffered | Model stage | Action |
|---|---|---|
| < 1 000 | — | No reply yet, keep streaming |
| 1 000 – 1 999 | 1k | Reply after this chunk |
| 2 000 – 2 999 | 2k | Reply after this chunk |
| 3 000 – 3 999 | 3k | Reply after this chunk |
| 4 000 – 7 999 | 4k | Reply after this chunk |
| 8 000 – 11 999 | 8k | Reply after this chunk |
| 12 000 – 15 999 | 12k | Reply after this chunk |
| 16 000 – 23 999 | 16k | Reply after this chunk |
| 24 000 – 31 999 | 24k | Reply after this chunk |
| ≥ 32 000 (~4.0 s) | 32k + greeting | Forced result |
7Response Format
A dash-delimited string: CLASSIFICATION-DURATION_SECONDS-CONFIDENCE
| Example | Meaning |
|---|---|
| "" | Still detecting — keep streaming |
| HUMAN-4.50-0.9950 | Live person, detected at 4.5 s, confidence 99.5% |
| AMD-8.20-0.9234 | Generic answering machine |
| WELCOMEVMAMD-4.50-1.0000 | Standard voicemail greeting |
| HONEYPOTAMD-4.50-0.9876 | Anti-spam honeypot line |
| VERIZONAMD-4.50-1.0000 | Carrier forwarding message |
| SILENCEAMD-8.34-0.0000 | Insufficient 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)8Classification Values
The statuses the production server returns today (42-class greeting model). Everything ending in AMD is a machine.
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.
| Status | Change | What it is |
|---|---|---|
| YOUMAILAMD | New | YouMail: "The YouMail subscriber at ..." or "protected by YouMail, press N" |
| FASRECLINEAMD | New | False answer on an ordinary number: "Hello, this is ... you are on a recorded line" |
| ALLSTATEBLKAMD | New | Allstate / carrier robocall-blocker announcement |
| HIYASCRNAMD | New | Hiya call screening: "this number is protected by Hiya..." |
| NOMOROBOAMD | New | Nomorobo: "...to prove you're not a robocaller..." |
| SMARTBLKAMD | New | Smart Call Blocker announcement |
| FWDVMTONEAMD | Retired | These calls will report VERIZONAMD |
| PLVMSFORAMD | Retired | These 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.
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}}| Chunk | Buffer | Reply |
|---|---|---|
| 1 | 4k samples | "" — keep going |
| 2 | 8k samples | "" — keep going |
| 3 | 12k samples | "" — keep going |
| 5 | 24k samples | HUMAN-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}}| Chunk | Buffer | Reply |
|---|---|---|
| 1 | 4k samples | HUMAN-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.
| Chunk | Buffer | Reply (stage_results: true) |
|---|---|---|
| 1 | 4k samples | STAGE-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 |
| 2 | 8k samples | STAGE-8k-AMD-1.00-0.79 — keep going |
| 3 | 12k samples | STAGE-12k-AMD-1.50-0.88 — keep going |
| 5 | 24k samples | WELCOMEVMAMD-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
break10Code 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.tool11Audio 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)12Error Handling
WebSocket close codes and recovery strategies.
| Code | Meaning | Action |
|---|---|---|
| 1000 | Normal closure (result delivered) | No action needed |
| 1001 | Server maintenance | Retry after a few seconds |
| 1006 | Connection lost | Retry with exponential backoff |
| 1008 | Policy violation (IP not whitelisted) | Register the IP via POST /api/v1/ips/register |
| 1011 | Server error | Retry; contact support if persistent |
Common issues
| Symptom | Cause | Fix |
|---|---|---|
| Empty replies indefinitely | Wrong audio format or no EOF sent | Verify 8 kHz/16-bit/mono; send {"eof":1} when audio ends |
| Connection refused / 1008 | Source IP not registered | Call POST /api/v1/ips/register from that server |
| Slow detection | Standard mode running full cascade | Enable immediate_detection: true |
| Registration 502 | Internal sync issue | Retry 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, 4s13Support
| Channel | Details |
|---|---|
| Portal | app.amdy.io — manage keys, IPs, billing, analytics |
| [email protected] |