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

  1. Open the socket.
  2. Send one welcome frame as JSON text.
  3. Wait for the ready event.
  4. Stream audio as binary frames, in real time, as the call plays.
  5. Read detection and beep events as they arrive.
  6. Send {"command": "stop"} when the call ends. The server sends close, then stopped, then closes the socket with code 1000 (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: true means this verdict replaces an earlier one, because more speech arrived.
  • is_final: true means no further verdict follows.

A common pattern:

  • On the first live_person with good confidence, connect the agent.
  • On voicemail, keep streaming, wait for beep, then play your message.
  • On silence or timeout, 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 5xx or a timeout is retried with exponential backoff: 4 attempts in about 45 seconds.
  • A 429 is retried after your Retry-After.
  • Any other 4xx isn'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.

Code samples

The requests from this page, ready to copy. Set DC_KEY and DC_SECRET to your API key pair first.

Create a Detection key
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}'
Connect from Twilio
<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>
Verify the signature
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);
}