API reference / Voice and AI
Detection
Detection listens to the first seconds of an outbound call and tells you who answered: a live person, voicemail, or nobody. It also tells you the moment the voicemail beep ends, so your message starts on the beep rather than over the greeting.
You stream call audio over a WebSocket, and every result streams back to you on the same socket as it happens. You don't need a webhook. The signed webhook exists for platforms such as Twilio whose streams can't carry our results back to you; see Connect from Twilio.
| WebSocket URL | wss://detect.dropcowboy.com/carrier/ws |
| Twilio Media Streams URL | wss://detect.dropcowboy.com/carrier/ws/twilio |
| Credential | A Detection API key (see Create a Detection key) |
| Audio | Mono, 16-bit PCM or mu-law, 8 kHz or 16 kHz |
The Detection API key is separate from your REST API keys. If you use webhooks, the same key signs them. Keep it on your server.
Detection is for calls you place on your own carrier or platform. Sends and campaigns you run through Drop Cowboy® don't need it.
Create a Detection key
Create Detection keys with your REST API key, or on the Detection page under Building Blocks in the dashboard. Your account must be verified and have Building Blocks enabled.
| Method | Route | Scope | What it does |
|---|---|---|---|
POST |
/register/public/detection-keys |
account:write |
Create a key |
GET |
/register/public/detection-keys |
balance:read |
List your keys |
DELETE |
/register/public/detection-keys/{key_id} |
account:write |
Revoke a key |
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | No | Up to 100 characters. Defaults to "Detection key" and the date. |
expires_in_seconds |
integer | No | 300 (5 minutes) to 31536000 (1 year). Leave it out for a key that doesn't expire. |
curl -X POST https://api-v2.dropcowboy.com/register/public/detection-keys \
-H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET" \
-H "content-type: application/json" \
-d '{"name": "Media server", "expires_in_seconds": 7776000}'
The route answers 201:
{
"data": {
"key_id": "3e4f5a6b-7c8d-4e9f-a0b1-c2d3e4f5a6b7",
"name": "Media server",
"api_key": "EXAMPLE-detection-key-not-a-real-credential-0001",
"key_hint": "0001",
"created_at": 1774041600000,
"expires_at": 1781817600000
},
"meta": { "request_id": "5e8b2d4f-7a1c-4e9b-8d3f-2c6a9e1b4d70" }
}
api_key appears only in this response. Store it now. Timestamps are epoch
milliseconds, and expires_at is null on a key that doesn't expire.
The list returns key_id, name, key_hint (the last 4 characters),
status, created_at and expires_at for each key, never the key itself.
status is one of:
| Status | Meaning | What to do |
|---|---|---|
active |
The key works. | Nothing. |
suspended |
The account is out of funds, or Detection is switched off for it. | Add funds. The key resumes on its own. |
expired |
expires_at has passed. |
Create a new key. |
Revoking returns {"key_id": "...", "revoked": true}. New sessions with the key
are refused within 60 seconds. Sessions already open run to completion. To
rotate a key, create the new one, deploy it, then revoke the old one.
| Status | Problem type | What to do |
|---|---|---|
400 |
validation-error |
Fix name or expires_in_seconds, or the key_id in the path, which must be a UUID. |
403 |
trial_feature_blocked |
Detection isn't available on a trial account. Upgrade the account. |
403 |
team-not-verified |
Verify your account. |
403 |
byoc-required |
Enable Building Blocks. |
404 |
not-found |
No key with that key_id. List your keys to find it. |
409 |
conflict |
Your account already has 25 keys. Revoke one you no longer use. |
503 |
service-unavailable |
Retry after a few seconds. |
Connect
Send the key as a bearer token on the WebSocket upgrade:
GET /carrier/ws HTTP/1.1
Host: detect.dropcowboy.com
Upgrade: websocket
Connection: Upgrade
Authorization: Bearer <detection_api_key>
If your WebSocket client can't set headers, add ?api_key=<detection_api_key>
to the URL instead.
When the upgrade fails, you get an HTTP status before any frames:
| Status | Meaning | What to do |
|---|---|---|
401 or 403 |
The key is missing, malformed, unknown, revoked or expired, or Detection is switched off for the account. | Send the key. Check its status in the key list, or create a new one. |
402 |
The account has no funds. | Add funds. |
503 |
The server is at capacity or restarting. | Reconnect with backoff. If you can't connect in time, place the call without detection. |
Session flow
- Open the socket.
- Send one welcome frame as JSON text.
- Wait for the
readyevent. - Stream audio as binary frames, in real time, as the call plays.
- Read
detectionandbeepevents as they arrive. - Send
{"command": "stop"}when the call ends. The server sendsclose, thenstopped, then closes the socket with code1000(stop).
Open a new socket for the next call. Don't reconnect in the middle of a call: a new socket starts a new session with no memory of the audio so far.
To pool sockets instead, set "keep_alive": true on each welcome you want
pooled. The socket then stays open after stopped and takes the next welcome.
A pooled socket that gets no welcome within 60 seconds is closed with 1001
(idle). Sending {"type": "ping"} gets a pong back, but doesn't extend
that window.
Welcome frame
{
"type": "welcome",
"payload": {
"call_id": "4e8c2a6d-1f9b-4d3e-a5c7-2b6f8d4a9e31",
"audio_encoding": "pcm16",
"sample_rate_hz": 8000,
"strategy": "standard"
}
}
| Field | Type | Required | Description |
|---|---|---|---|
call_id |
string | No | Your identifier. Echoed on every event and webhook. |
audio_encoding |
string | No | pcm16 (16-bit little-endian, the default) or mulaw. |
sample_rate_hz |
integer | No | 8000 (the default) or 16000. |
strategy |
string | No | standard (the default), beep_only or live_check. See below. |
detection_timeout_sec |
integer | No | Send timeout after this many seconds without a verdict. Default 45. |
keep_alive |
boolean | No | Keep the socket open after stop so you can reuse it. Default false. |
webhook.url |
string | No | Also POST each event here. Not needed on this route, since events already arrive on the socket. A public HTTPS URL; private, loopback and link-local addresses are refused. |
webhook.events |
array | No | Event names to deliver to webhook.url. Default: all of them. |
webhook.headers |
object | No | Extra headers sent with each delivery, for example your own auth token. |
| Strategy | Use it for |
|---|---|
standard |
Live person versus voicemail, refined as more speech arrives, plus beep timing. |
beep_only |
You already know it's voicemail and only need the beep. |
live_check |
A faster live-person check, without the transcript-based refinement. |
Events
Every event arrives as a JSON text frame:
{
"type": "detection",
"event": "detection",
"payload": {
"session_id": "6b2e9d4a-8f1c-4a7e-9d3b-5c8f2a6e1d47",
"call_id": "4e8c2a6d-1f9b-4d3e-a5c7-2b6f8d4a9e31",
"timestamp_ms": 2310,
"type": "voicemail",
"confidence": 0.91,
"beep_detected": false,
"is_final": false,
"is_refinement": false
}
}
Every payload carries session_id, timestamp_ms and, if you sent one,
call_id.
| Event | Meaning |
|---|---|
ready |
The session is set up. Start streaming audio. |
detection |
A verdict. type is live_person, voicemail or silence; treat any other value as no result. confidence is 0 to 1. |
beep |
The voicemail beep ended. Start your message now. Carries timestamp_ms, frequency_hz, duration_ms, confidence and is_final, which is always true. |
screening |
A call screener answered. Carries suggested_action and sometimes suggested_dtmf. |
transcript, speech_start, speech_end, silence |
Progress while the call is classified. Safe to ignore. |
timeout |
No verdict within detection_timeout_sec. |
error |
Carries error, code and recoverable. If recoverable is false, the session is over. |
close |
The session ended. Carries reason and duration_ms. |
stopped |
Your stop command was processed. |
If the server is full when your welcome arrives, you get an error with
code: "session_cap" and recoverable: true, and the socket stays open. Send
the welcome again after a short wait, or carry on with the call without
detection.
Acting on verdicts
A detection event can be followed by a better one:
is_refinement: truemeans this verdict replaces an earlier one, because more speech arrived.is_final: truemeans no further verdict follows.
A common pattern:
- On the first
live_personwith good confidence, connect the agent. - On
voicemail, keep streaming, wait forbeep, then play your message. - On
silenceortimeout, hang up or retry later.
Close codes
| Code | Reason | What to do |
|---|---|---|
1000 |
stop |
Normal end after stopped. |
1001 |
idle |
No welcome within 60 seconds. Open a new socket for the next call. |
1003 |
bad welcome json, or none |
The first frame wasn't a valid JSON welcome sent as text. Send the welcome as a text frame before any audio. |
1008 |
webhook rejected |
webhook.url isn't a public HTTPS URL. Fix it and reconnect. |
Connect from Twilio
To detect on a Twilio call, use Twilio Media Streams. Point a bidirectional
<Stream> at the Twilio URL. A Twilio stream can't carry our results back to
you, so on this route they arrive only by webhook. Pass settings as
<Parameter> elements, because Twilio can't set headers on a stream:
<Response>
<Connect>
<Stream url="wss://detect.dropcowboy.com/carrier/ws/twilio">
<Parameter name="api_key" value="<detection_api_key>" />
<Parameter name="webhook_url" value="https://hooks.example.com/detection" />
<Parameter name="webhook_events" value="detection,beep,close" />
<Parameter name="strategy" value="standard" />
<Parameter name="call_id" value="4e8c2a6d-1f9b-4d3e-a5c7-2b6f8d4a9e31" />
</Stream>
</Connect>
</Response>
| Parameter | Required | Description |
|---|---|---|
api_key |
Yes | Your Detection API key. |
webhook_url |
No | Where results go. Set it: on this route, results arrive only by webhook. |
webhook_events |
No | Comma-separated event names. Default: all of them. |
strategy |
No | standard, beep_only or live_check. |
call_id |
No | Defaults to Twilio's CallSid. |
detection_timeout_sec |
No | Default 45. |
You don't send a welcome frame on this route. Detection reads the stream's mu-law audio at 8 kHz.
If the key is missing, unknown, revoked or out of funds, the stream is closed
with code 1008 (unauthorized) as soon as it starts. Check the key's
status in the key list.
Only Twilio can open this route: a connection without Twilio's
X-Twilio-Signature header gets 403. To test without Twilio, use
/carrier/ws. Custom webhook headers can't be set through TwiML, so verify
deliveries with the signature.
Webhooks
You need a webhook only on the Twilio route, or on another platform whose
stream can't take results back. On /carrier/ws the socket already carries
every event. Each selected event is POSTed to your webhook URL as JSON:
{
"delivery_id": "d1f3a8e2-7c4b-4f9a-9d22-9c1e2f3a4b5c",
"event": "beep",
"emitted_at": "2026-03-20T15:04:05.120Z",
"session_id": "6b2e9d4a-8f1c-4a7e-9d3b-5c8f2a6e1d47",
"call_id": "4e8c2a6d-1f9b-4d3e-a5c7-2b6f8d4a9e31",
"source": "ws",
"data": {
"timestamp_ms": 6420,
"frequency_hz": 1000,
"duration_ms": 380,
"confidence": 0.94,
"is_final": true
}
}
data is the event payload, without the ids already on the envelope.
Deliveries from the Twilio route carry source: "twilio", plus
twilio_call_sid, twilio_account_sid and twilio_stream_sid.
Headers
| Header | Value |
|---|---|
X-Signature |
v1=hmac-sha256,<hex digest> |
X-Timestamp |
Unix time in milliseconds when the delivery was signed |
X-Delivery-Id |
Same as delivery_id. Constant across retries, so use it to deduplicate. |
X-Event |
The event name |
X-Source |
ws or twilio |
Verify the signature
The digest is HMAC-SHA256 over {timestamp}.{raw body}, keyed with your
Detection API key. Verify the raw bytes before you parse them.
const crypto = require('crypto');
function verifyDetectionWebhook(rawBody, headers, detectionKey) {
const header = headers['x-signature'] || '';
const timestamp = headers['x-timestamp'];
const provided = header.split(',')[1];
if (!header.startsWith('v1=hmac-sha256,') || !provided || !timestamp) return false;
// Reject deliveries older than 5 minutes (the timestamp is in milliseconds).
if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60 * 1000) return false;
const expected = crypto.createHmac('sha256', detectionKey)
.update(timestamp + '.')
.update(rawBody)
.digest('hex');
const a = Buffer.from(provided, 'hex');
const b = Buffer.from(expected, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Use express.raw({ type: 'application/json' }), or your framework's
equivalent, on the route so rawBody is the exact bytes that were signed.
Retries
Answer with any 2xx within 10 seconds.
- A
5xxor a timeout is retried with exponential backoff: 4 attempts in about 45 seconds. - A
429is retried after yourRetry-After. - Any other
4xxisn't retried.
On /carrier/ws, webhooks carry the same events as the socket. If you read
the socket, you can treat the webhook as an audit trail.