Send a ringless voicemail from your own caller ID (BYOC)

This guide sends one ringless voicemail over a carrier you already have, from a number you hold there, using an audio file you host. It takes about 10 minutes once your carrier is connected. At the end you have a voicemail queued to a number you own and a way to match its result to your own record.

Send only to people who agreed to hear from you. See Consent. Test with numbers you own.

A 202 means accepted, not delivered. The result of each voicemail arrives on the contact.rvm.status webhook.

Run it from GitHub. This guide is a working recipe in the Drop Cowboy® examples: npm run send:byoc in Node.js, python -m dropcowboy_examples.recipes.send_rvm_byoc in Python, or dotnet run -- byoc in C#. Each one sends the voicemail and waits for the result.

Before you start

You need Details Where to get it
A plan A BYOC account with a carrier connected. Step 1 checks it. Bring your own carrier
API key scopes rvm:send, and balance:read for the connection check Authentication
A caller ID A number you hold at your carrier and are entitled to use as your caller ID, in E.164 form. Your carrier has to accept it. It does not need to exist in Drop Cowboy. Your carrier's portal
Audio An MP3 or WAV file, up to 50 MB, at a public URL that stays up until the send finishes. Sending it as audio_url must be enabled by support; otherwise send a media_id. Your own hosting, or Media
A recipient A number in E.164 form whose owner agreed to hear from you. Test with your own number. Consent
Somewhere for results A public HTTPS URL for a callback or webhook Receive results

1. Check the carrier connection

Check with curl

curl https://api-v2.dropcowboy.com/integration/public/byoc \
  -H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET"
{
  "data": {
    "connected": true,
    "providers": [
      {
        "provider": "twilio",
        "integration_type": "twilio",
        "integration_id": "2f8c4a6e-9b3d-4e1f-a7c5-6d2b9e4f1a38",
        "enabled": true,
        "default": true,
        "pool_id": "6e1f3a5c-9b2d-4c8e-a7f1-3d5b9c2e4a61"
      }
    ],
    "pool_id": "6e1f3a5c-9b2d-4c8e-a7f1-3d5b9c2e4a61"
  },
  "meta": { "request_id": "c2a6e8f4-3b9d-4c1e-8a7f-5d3b1e9c6a24" }
}

You need connected: true and a provider with enabled: true. If not, connect first. Connecting can move an account off a retail plan and release rented numbers, so read Connect your carrier before you call it. A send has no route field, because the route is part of the connection.

2. Host the audio

Drop Cowboy downloads the file from audio_url when the voicemail is sent, not when you call POST /rvm. A voicemail outside calling hours is held and retried for up to 3 days, so keep the file at that address until the result arrives.

audio_url is available on BYOC plans only and must be enabled by support. Contact support to enable it. If you're testing on a retail account before connecting your carrier, support can enable it for testing; while testing, voice sends go only to your test numbers. Otherwise, upload your file and send media_id, or use text to speech. A signed upload takes a file from your own server: create the entry, PUT the bytes with exactly the returned content_type, then complete it.

3. Send the voicemail

The fields that matter:

Field Meaning
caller_id A number at your carrier that you are entitled to use as your caller ID. Shown exactly as given.
audio_url Public MP3 or WAV URL. Or send media_id, or tts_body with voice_id.
byoc.sti_orig_id Optional. The origination ID your carrier gave you for STIR/SHAKEN signing, as a UUID. Anything else fails with 3017.
byoc.sti_attestation Optional. The level your carrier assigned for this number: A, B or C. Don't send a higher level than your carrier gave you. Anything else fails with 3018.
Idempotency-Key header A UUID you make up for each new send. If a request times out or answers 500 or 502, retry with the same key and body and the voicemail is not sent twice. A new key is a new send.
foreign_id Your own reference, up to 256 characters, echoed on the callback only.
callback_url Optional. One unsigned POST with the result, tried once. A wrong key still gets 202, and 3007 shows up here only.

To show this exact caller_id, don't send phone_line_id. When both are present the phone line wins and caller_id is ignored. If you tested on retail with phone_line_id and no caller_id, that body still works once your numbers are on the line; see Test on retail, then go live on BYOC. To have Drop Cowboy pick the caller ID for each recipient from several of your numbers, put them on a phone line. See Choose the caller ID automatically with phone lines (BYOC).

Send with curl

curl -X POST https://api-v2.dropcowboy.com/rvm \
  -H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET" \
  -H "Idempotency-Key: 4a8e1c7d-2f5b-4d93-9e60-7b3c1a5f8d24" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+13125550142",
    "caller_id": "+13125550100",
    "audio_url": "https://your-server.example.com/audio/october-follow-up.mp3",
    "byoc": {
      "sti_orig_id": "c4a7e1d2-9b3f-4a68-8d05-2e7f6b1a9c34",
      "sti_attestation": "A"
    },
    "foreign_id": "7c1e5a93-2d4b-4f68-a0b9-3e6d8c1f5a27",
    "callback_url": "https://your-server.example.com/dropcowboy/rvm-result"
  }'

The values are placeholders. Use your own caller ID, audio address and sti_orig_id, and the attestation level your carrier gave you. The value A here is an example.

{ "status": "queued", "message_id": "8e4a2c6f-9d1b-4f7e-b3a5-6c9e1f4d2b78" }

The answer is flat, without a data envelope. message_id is a receipt and does not appear on later results. Leave byoc out if you do not sign calls.

Send with Node.js

Node.js 18 or later. Set DC_KEY, DC_SECRET, DC_TO, DC_CALLER_ID, DC_AUDIO_URL and DC_CALLBACK_URL. The program stops with a message when one is unset. DC_STI_ORIG_ID and DC_STI_ATTESTATION are optional.

const { randomUUID } = require('crypto');

for (const name of ['DC_KEY', 'DC_SECRET', 'DC_TO', 'DC_CALLER_ID', 'DC_AUDIO_URL', 'DC_CALLBACK_URL']) {
  if (!process.env[name]) throw new Error('Set ' + name + ' first.');
}

const body = {
  to: process.env.DC_TO,
  caller_id: process.env.DC_CALLER_ID,
  audio_url: process.env.DC_AUDIO_URL,
  foreign_id: randomUUID(),
  callback_url: process.env.DC_CALLBACK_URL
};
const byoc = {};
if (process.env.DC_STI_ORIG_ID) byoc.sti_orig_id = process.env.DC_STI_ORIG_ID;
if (process.env.DC_STI_ATTESTATION) byoc.sti_attestation = process.env.DC_STI_ATTESTATION;
if (Object.keys(byoc).length > 0) body.byoc = byoc;

async function main() {
  const res = await fetch('https://api-v2.dropcowboy.com/rvm', {
    method: 'POST',
    headers: {
      'x-key': process.env.DC_KEY,
      'x-secret': process.env.DC_SECRET,
      'Content-Type': 'application/json',
      'Idempotency-Key': randomUUID()
    },
    body: JSON.stringify(body)
  });
  const json = await res.json();
  if (res.status !== 202) {
    console.error('Not accepted:', res.status, JSON.stringify(json));
    process.exit(1);
  }
  console.log('Queued', json.message_id, 'foreign_id', body.foreign_id);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

Send with Python

Python 3.9 or later with pip install requests. Same environment variables.

import os
import uuid

import requests

for name in ("DC_KEY", "DC_SECRET", "DC_TO", "DC_CALLER_ID", "DC_AUDIO_URL", "DC_CALLBACK_URL"):
    if not os.environ.get(name):
        raise SystemExit(f"Set {name} first.")

body = {
    "to": os.environ["DC_TO"],
    "caller_id": os.environ["DC_CALLER_ID"],
    "audio_url": os.environ["DC_AUDIO_URL"],
    "foreign_id": str(uuid.uuid4()),
    "callback_url": os.environ["DC_CALLBACK_URL"],
}
byoc = {}
if os.environ.get("DC_STI_ORIG_ID"):
    byoc["sti_orig_id"] = os.environ["DC_STI_ORIG_ID"]
if os.environ.get("DC_STI_ATTESTATION"):
    byoc["sti_attestation"] = os.environ["DC_STI_ATTESTATION"]
if byoc:
    body["byoc"] = byoc

res = requests.post(
    "https://api-v2.dropcowboy.com/rvm",
    headers={
        "x-key": os.environ["DC_KEY"],
        "x-secret": os.environ["DC_SECRET"],
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json=body,
    timeout=30,
)
if res.status_code != 202:
    raise SystemExit(f"Not accepted: {res.status_code} {res.text}")
print("Queued", res.json()["message_id"], "foreign_id", body["foreign_id"])

Send with C#

.NET 8. Create a project with dotnet new console, replace Program.cs, and set the same environment variables.

using System.Net.Http.Json;
using System.Text.Json.Nodes;

foreach (var name in new[] { "DC_KEY", "DC_SECRET", "DC_TO", "DC_CALLER_ID", "DC_AUDIO_URL", "DC_CALLBACK_URL" })
    if (Env(name).Length == 0) throw new InvalidOperationException($"Set {name} first.");

var body = new JsonObject
{
    ["to"] = Env("DC_TO"),
    ["caller_id"] = Env("DC_CALLER_ID"),
    ["audio_url"] = Env("DC_AUDIO_URL"),
    ["foreign_id"] = Guid.NewGuid().ToString(),
    ["callback_url"] = Env("DC_CALLBACK_URL")
};
var byoc = new JsonObject();
if (Env("DC_STI_ORIG_ID").Length > 0) byoc["sti_orig_id"] = Env("DC_STI_ORIG_ID");
if (Env("DC_STI_ATTESTATION").Length > 0) byoc["sti_attestation"] = Env("DC_STI_ATTESTATION");
if (byoc.Count > 0) body["byoc"] = byoc;

using var http = new HttpClient { BaseAddress = new Uri("https://api-v2.dropcowboy.com"), Timeout = TimeSpan.FromSeconds(30) };
using var request = new HttpRequestMessage(HttpMethod.Post, "/rvm") { Content = JsonContent.Create(body) };
request.Headers.Add("x-key", Env("DC_KEY"));
request.Headers.Add("x-secret", Env("DC_SECRET"));
request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());

using var response = await http.SendAsync(request);
var text = await response.Content.ReadAsStringAsync();
if ((int)response.StatusCode != 202)
{
    Console.Error.WriteLine($"Not accepted: {(int)response.StatusCode} {text}");
    return 1;
}
Console.WriteLine($"Queued {JsonNode.Parse(text)?["message_id"]} foreign_id {body["foreign_id"]}");
return 0;

static string Env(string name) => Environment.GetEnvironmentVariable(name) ?? "";

Read the result

A 202 means Drop Cowboy received the request. Calling hours, the contact frequency limit and your balance are checked afterwards, and so is consent when your account requires it. You remain responsible for having consent.

The result arrives on the callback_url and on the contact.rvm.status webhook. Receive the result of a send builds the receiver. Match a callback to your record on foreign_id. Status webhooks do not carry it, so match those on to, or on contact_id. If you signed the call with a certificate you uploaded, the callback and the status webhook also carry sti_identity, sti_attestation, sti_orig_id and sip_call_id. See Results of your sends.

reason_code Meaning What to do
0 Voicemail left Record it. See Proof of delivery.
4001, 4002 Their voicemail is not set up, or is full Another voicemail will not help.
4005, 4006 No answer, or busy Try again later, inside calling hours.
4016, 4017, 6005, 6011 Do-not-contact, known litigator, opted out, or no consent Mark the contact and stop.
3000 to 3999 A problem with your request or account Fix the setup.

Every code is in Outcomes. A person who calls back the number on their voicemail reaches your carrier first. To take those calls in Drop Cowboy, see Use your own numbers on BYOC.

If it does not work

You see Likely cause Fix
connected: false in step 1 No carrier connected Connect one. See Bring your own carrier.
3014 audio_url isn't enabled for your account, or no audio was given Contact support to enable it, or send a media_id
3040 Your account is in testing mode and the number isn't a test number Add it under test numbers on the Dialing rules page, or finish connecting your carrier
3001 The audio couldn't be downloaded or isn't MP3 or WAV, or the media_id isn't on your account Check the URL, or send your own media_id
202, then no result Your callback route answered 404 or another non-2xx Accept POST, answer 200. Check Settings > API Logs.
3016 The send has byoc, but BYOC is not on for the account Remove byoc, or connect a carrier
3017 or 3018 sti_orig_id is not a UUID, or sti_attestation is not A, B or C Fix the value
3007 on the callback Wrong key or secret, or a token without rvm:send Fix the credentials
4010 No caller_id and no phone line Pass caller_id
3027 The Idempotency-Key was used with a different body Use a new key per new send
3019 callback_url is not a URL Pass a full https:// address

More symptoms are in Troubleshooting.

Production checklist

  • Generate an Idempotency-Key per send and store it with your own record so a retry reuses it.
  • Keep the audio file at its URL until the result arrives, because it is fetched when the voicemail goes out.
  • Read results from the signed webhook, verify the raw bytes, and deduplicate on event_id.
  • Keep DC_SECRET and webhook signing secrets in a secrets manager.
  • Record each recipient's consent before the send, and stop on 4016, 4017, 6005 and 6011.
  • Use only a caller ID you are entitled to use, and check that your carrier accepts it. Always identify your business location truthfully when asked by recipients.

Next steps


Ringless voicemail technology delivers messages directly to voicemail inboxes. Delivery success depends on carrier compatibility, device type, and recipient settings. While designed for voicemail delivery, technical factors may affect performance. Drop Cowboy does not guarantee delivery rates or specific outcomes.

BYOC customers connect their own carrier accounts (Twilio, Bandwidth, etc.) to the Drop Cowboy platform. Customers are responsible for their carrier relationship, billing, and compliance with carrier terms of service. Drop Cowboy does not mark up or bill for carrier services. Message delivery and carrier connectivity depend on the customer's carrier account status and settings.

While Drop Cowboy provides tools to support compliance efforts, customers remain solely responsible for obtaining proper consent, maintaining opt-out lists, and complying with all federal and state telemarketing regulations. Consult with your legal counsel to ensure your specific use case and consent mechanisms comply with applicable laws.

This information is for educational purposes only and does not constitute legal advice. Regulations vary by jurisdiction and use case. Always consult with qualified legal counsel to ensure your specific practices comply with applicable federal and state laws.

Code samples

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

Check with curl
curl https://api-v2.dropcowboy.com/integration/public/byoc \
  -H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET"
Send with curl
curl -X POST https://api-v2.dropcowboy.com/rvm \
  -H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET" \
  -H "Idempotency-Key: 4a8e1c7d-2f5b-4d93-9e60-7b3c1a5f8d24" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+13125550142",
    "caller_id": "+13125550100",
    "audio_url": "https://your-server.example.com/audio/october-follow-up.mp3",
    "byoc": {
      "sti_orig_id": "c4a7e1d2-9b3f-4a68-8d05-2e7f6b1a9c34",
      "sti_attestation": "A"
    },
    "foreign_id": "7c1e5a93-2d4b-4f68-a0b9-3e6d8c1f5a27",
    "callback_url": "https://your-server.example.com/dropcowboy/rvm-result"
  }'
Send with Node.js
const { randomUUID } = require('crypto');

for (const name of ['DC_KEY', 'DC_SECRET', 'DC_TO', 'DC_CALLER_ID', 'DC_AUDIO_URL', 'DC_CALLBACK_URL']) {
  if (!process.env[name]) throw new Error('Set ' + name + ' first.');
}

const body = {
  to: process.env.DC_TO,
  caller_id: process.env.DC_CALLER_ID,
  audio_url: process.env.DC_AUDIO_URL,
  foreign_id: randomUUID(),
  callback_url: process.env.DC_CALLBACK_URL
};
const byoc = {};
if (process.env.DC_STI_ORIG_ID) byoc.sti_orig_id = process.env.DC_STI_ORIG_ID;
if (process.env.DC_STI_ATTESTATION) byoc.sti_attestation = process.env.DC_STI_ATTESTATION;
if (Object.keys(byoc).length > 0) body.byoc = byoc;

async function main() {
  const res = await fetch('https://api-v2.dropcowboy.com/rvm', {
    method: 'POST',
    headers: {
      'x-key': process.env.DC_KEY,
      'x-secret': process.env.DC_SECRET,
      'Content-Type': 'application/json',
      'Idempotency-Key': randomUUID()
    },
    body: JSON.stringify(body)
  });
  const json = await res.json();
  if (res.status !== 202) {
    console.error('Not accepted:', res.status, JSON.stringify(json));
    process.exit(1);
  }
  console.log('Queued', json.message_id, 'foreign_id', body.foreign_id);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
Send with Python
import os
import uuid

import requests

for name in ("DC_KEY", "DC_SECRET", "DC_TO", "DC_CALLER_ID", "DC_AUDIO_URL", "DC_CALLBACK_URL"):
    if not os.environ.get(name):
        raise SystemExit(f"Set {name} first.")

body = {
    "to": os.environ["DC_TO"],
    "caller_id": os.environ["DC_CALLER_ID"],
    "audio_url": os.environ["DC_AUDIO_URL"],
    "foreign_id": str(uuid.uuid4()),
    "callback_url": os.environ["DC_CALLBACK_URL"],
}
byoc = {}
if os.environ.get("DC_STI_ORIG_ID"):
    byoc["sti_orig_id"] = os.environ["DC_STI_ORIG_ID"]
if os.environ.get("DC_STI_ATTESTATION"):
    byoc["sti_attestation"] = os.environ["DC_STI_ATTESTATION"]
if byoc:
    body["byoc"] = byoc

res = requests.post(
    "https://api-v2.dropcowboy.com/rvm",
    headers={
        "x-key": os.environ["DC_KEY"],
        "x-secret": os.environ["DC_SECRET"],
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json=body,
    timeout=30,
)
if res.status_code != 202:
    raise SystemExit(f"Not accepted: {res.status_code} {res.text}")
print("Queued", res.json()["message_id"], "foreign_id", body["foreign_id"])
Send with C#
using System.Net.Http.Json;
using System.Text.Json.Nodes;

foreach (var name in new[] { "DC_KEY", "DC_SECRET", "DC_TO", "DC_CALLER_ID", "DC_AUDIO_URL", "DC_CALLBACK_URL" })
    if (Env(name).Length == 0) throw new InvalidOperationException($"Set {name} first.");

var body = new JsonObject
{
    ["to"] = Env("DC_TO"),
    ["caller_id"] = Env("DC_CALLER_ID"),
    ["audio_url"] = Env("DC_AUDIO_URL"),
    ["foreign_id"] = Guid.NewGuid().ToString(),
    ["callback_url"] = Env("DC_CALLBACK_URL")
};
var byoc = new JsonObject();
if (Env("DC_STI_ORIG_ID").Length > 0) byoc["sti_orig_id"] = Env("DC_STI_ORIG_ID");
if (Env("DC_STI_ATTESTATION").Length > 0) byoc["sti_attestation"] = Env("DC_STI_ATTESTATION");
if (byoc.Count > 0) body["byoc"] = byoc;

using var http = new HttpClient { BaseAddress = new Uri("https://api-v2.dropcowboy.com"), Timeout = TimeSpan.FromSeconds(30) };
using var request = new HttpRequestMessage(HttpMethod.Post, "/rvm") { Content = JsonContent.Create(body) };
request.Headers.Add("x-key", Env("DC_KEY"));
request.Headers.Add("x-secret", Env("DC_SECRET"));
request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());

using var response = await http.SendAsync(request);
var text = await response.Content.ReadAsStringAsync();
if ((int)response.StatusCode != 202)
{
    Console.Error.WriteLine($"Not accepted: {(int)response.StatusCode} {text}");
    return 1;
}
Console.WriteLine($"Queued {JsonNode.Parse(text)?["message_id"]} foreign_id {body["foreign_id"]}");
return 0;

static string Env(string name) => Environment.GetEnvironmentVariable(name) ?? "";