Send a ringless voicemail on a retail plan

This guide sends one ringless voicemail from a phone line on your Drop Cowboy® account, using an audio file you upload, and then shows you how to read the result. It takes about 10 minutes. 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:retail in Node.js, python -m dropcowboy_examples.recipes.send_rvm_retail in Python, or dotnet run -- retail 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 retail plan, meaning no carrier of your own is connected. On your own carrier, use the BYOC guide. Quickstart
API key scopes rvm:send, numbers:read to list lines, media:write to add audio Authentication
A phone line Drop Cowboy picks the caller ID from the line's numbers, and retail accounts ignore caller_id. Step 1
Audio An MP3 or WAV file at a public URL, 50 MiB or smaller. For spoken text, use text to speech. Step 2
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. Choose a phone line

A phone line decides which number the recipient sees and where return calls go. List yours:

List lines with curl

curl https://api-v2.dropcowboy.com/phone/public/lines \
  -H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET"
{
  "data": [
    {
      "ivr_id": "234ecab5-1811-4c7d-a7a9-8c9ad9ca2e08",
      "type": "voice",
      "name": "Main line",
      "is_default": true
    }
  ],
  "meta": { "request_id": "ceeb5da8-09c2-47b5-98dd-f672bb92dc1f" }
}

(The response is trimmed.) A line's ivr_id is the phone_line_id you send. Leave phone_line_id out to use your default line. With no default line the send fails with 4010. If you send caller_id as well, it is ignored on a retail plan. See Phone numbers.

2. Add the audio

Add the file once, then send it by media_id as often as you like. Drop Cowboy downloads it while you wait, so it must be reachable and download within 15 seconds.

Add audio with curl

curl -X POST https://api-v2.dropcowboy.com/media/public/media \
  -H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "October follow-up",
    "type": "rvm",
    "url": "https://files.example.com/october-follow-up.mp3",
    "ext": ".mp3"
  }'
{
  "data": {
    "media_id": "55b9e55e-23f1-4c16-8865-f4b6261ebeea",
    "name": "October follow-up",
    "type": "rvm",
    "media_exists": true,
    "approved_at": 1759312800000,
    "api_allowed": true
  },
  "meta": { "request_id": "3028a01c-9f13-4158-a864-2daa70189301" }
}

If the file is on your own server rather than at a public URL, use a signed upload: create the entry with signed_upload: true, PUT the bytes with exactly the returned content_type, then complete it. A 403 from the PUT means a different Content-Type or an expired URL.

To send spoken text instead, replace media_id with tts_body and voice_id, as in the text to speech guide.

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.

3. Send the voicemail

Each send needs three things beyond the audio and line:

  • Idempotency-Key: a UUID you make up for each new send, as a header. If a request times out or answers 500 or 502, send it again with the same key and body, and the voicemail is not sent twice. A new key is a new send. The same key with a different body fails with 3027.
  • foreign_id: your own reference, up to 256 characters. Drop Cowboy only echoes it on the callback.
  • callback_url: optional. One unsigned POST with the result, tried once. Use it while you build. A wrong key or secret still gets 202, and the 3007 failure shows up on the callback only.

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: 9d2f6b1e-4a7c-4e35-8b90-1c5a3d7f2e64" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+13125550142",
    "phone_line_id": "234ecab5-1811-4c7d-a7a9-8c9ad9ca2e08",
    "media_id": "55b9e55e-23f1-4c16-8865-f4b6261ebeea",
    "foreign_id": "7c1e5a93-2d4b-4f68-a0b9-3e6d8c1f5a27",
    "callback_url": "https://your-server.example.com/dropcowboy/rvm-result"
  }'

The ids are placeholders: use an ivr_id from step 1 and the media_id from step 2. A media_id copied from this page fails with 3001. This body keeps working if you later connect your own carrier; see Test on retail, then go live on BYOC.

{ "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.

Send with Node.js

Node.js 18 or later. Set DC_KEY, DC_SECRET, DC_TO, DC_PHONE_LINE_ID, DC_MEDIA_ID and DC_CALLBACK_URL. The program stops with a message when one is unset.

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

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

const body = {
  to: process.env.DC_TO,
  phone_line_id: process.env.DC_PHONE_LINE_ID,
  media_id: process.env.DC_MEDIA_ID,
  foreign_id: randomUUID(),
  callback_url: process.env.DC_CALLBACK_URL
};

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_PHONE_LINE_ID", "DC_MEDIA_ID", "DC_CALLBACK_URL"):
    if not os.environ.get(name):
        raise SystemExit(f"Set {name} first.")

body = {
    "to": os.environ["DC_TO"],
    "phone_line_id": os.environ["DC_PHONE_LINE_ID"],
    "media_id": os.environ["DC_MEDIA_ID"],
    "foreign_id": str(uuid.uuid4()),
    "callback_url": os.environ["DC_CALLBACK_URL"],
}

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_PHONE_LINE_ID", "DC_MEDIA_ID", "DC_CALLBACK_URL" })
    if (Env(name).Length == 0) throw new InvalidOperationException($"Set {name} first.");

var body = new JsonObject
{
    ["to"] = Env("DC_TO"),
    ["phone_line_id"] = Env("DC_PHONE_LINE_ID"),
    ["media_id"] = Env("DC_MEDIA_ID"),
    ["foreign_id"] = Guid.NewGuid().ToString(),
    ["callback_url"] = Env("DC_CALLBACK_URL")
};

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. A voicemail outside calling hours is held and retried, and fails if it still cannot go out after 3 days.

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.

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.
4013 Too many attempts Wait for the frequency window to pass. See Contact frequency limit.
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.

If it does not work

You see Likely cause Fix
400 on the request The body is not a JSON object, or a header has characters that are not printable ASCII Fix the request
429 You are sending too fast Back off, retry with the same key. See Rate limits.
3007 on the callback Wrong key or secret, or a token without rvm:send Fix the credentials
3013 phone_line_id is not valid or not yours Use an ivr_id from step 1
3001 The media_id isn't on your account, for example one copied from this page Send the media_id from step 2
3014 audio_url isn't enabled for your account, or no audio was given Send a media_id, or text to speech
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
403 on the upload PUT The Content-Type differs from the returned one, or the URL expired See Troubleshooting
202, then no result Your callback route answered 404 or another non-2xx Accept POST, answer 200. Check Settings > API Logs.
4010 No phone_line_id and no default line Pass one, or set a default line
3027 The Idempotency-Key was used with a different body Use a new key per new send
4013 while testing You hit the frequency limit Add test numbers (numbers you own) on the Dialing rules page. Calling hours still apply.

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.
  • Store foreign_id and match results to it. Do not parse message_id.
  • 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.
  • Handle 429 with a pause and the same Idempotency-Key.

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.

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.

List lines with curl
curl https://api-v2.dropcowboy.com/phone/public/lines \
  -H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET"
Add audio with curl
curl -X POST https://api-v2.dropcowboy.com/media/public/media \
  -H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "October follow-up",
    "type": "rvm",
    "url": "https://files.example.com/october-follow-up.mp3",
    "ext": ".mp3"
  }'
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: 9d2f6b1e-4a7c-4e35-8b90-1c5a3d7f2e64" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+13125550142",
    "phone_line_id": "234ecab5-1811-4c7d-a7a9-8c9ad9ca2e08",
    "media_id": "55b9e55e-23f1-4c16-8865-f4b6261ebeea",
    "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_PHONE_LINE_ID', 'DC_MEDIA_ID', 'DC_CALLBACK_URL']) {
  if (!process.env[name]) throw new Error('Set ' + name + ' first.');
}

const body = {
  to: process.env.DC_TO,
  phone_line_id: process.env.DC_PHONE_LINE_ID,
  media_id: process.env.DC_MEDIA_ID,
  foreign_id: randomUUID(),
  callback_url: process.env.DC_CALLBACK_URL
};

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_PHONE_LINE_ID", "DC_MEDIA_ID", "DC_CALLBACK_URL"):
    if not os.environ.get(name):
        raise SystemExit(f"Set {name} first.")

body = {
    "to": os.environ["DC_TO"],
    "phone_line_id": os.environ["DC_PHONE_LINE_ID"],
    "media_id": os.environ["DC_MEDIA_ID"],
    "foreign_id": str(uuid.uuid4()),
    "callback_url": os.environ["DC_CALLBACK_URL"],
}

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_PHONE_LINE_ID", "DC_MEDIA_ID", "DC_CALLBACK_URL" })
    if (Env(name).Length == 0) throw new InvalidOperationException($"Set {name} first.");

var body = new JsonObject
{
    ["to"] = Env("DC_TO"),
    ["phone_line_id"] = Env("DC_PHONE_LINE_ID"),
    ["media_id"] = Env("DC_MEDIA_ID"),
    ["foreign_id"] = Guid.NewGuid().ToString(),
    ["callback_url"] = Env("DC_CALLBACK_URL")
};

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) ?? "";