Send a ringless voicemail with text to speech

By the end of this guide you'll have sent a ringless voicemail that speaks text you wrote, in a voice you picked, with no audio file to record or upload. It takes about ten minutes.

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:tts in Node.js, python -m dropcowboy_examples.recipes.send_rvm_tts in Python, or dotnet run -- tts in C#. Each one sends the voicemail and waits for the result.

Before you start

You need Details
An account Either kind works. A standard account sends from a phone line. A bring-your-own-carrier (BYOC) account can send from a number you are entitled to use as your caller ID, with caller_id. See Bring your own carrier.
An API key In DC_KEY and DC_SECRET, with scopes media:read (list voices), rvm:send, voice:send (only for the preview) and numbers:read (only to list phone lines). See Authentication.
A sender A phone line on your account, or on BYOC your carrier's number in E.164.
A recipient Someone who agreed to hear from you. For a test, list a number you own as a test number. Calling hours still apply. See Test numbers.
A results URL A public HTTPS address for callback_url.

1. Pick a voice

A voice is the speaker for your text. The list holds your own cloned and designed voices plus the platform catalog. Only a voice whose status is ready can speak.

curl "https://api-v2.dropcowboy.com/voice/public/voices?limit=20" \
  -H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET"
{
  "data": {
    "voices": [
      {
        "voice_id": "7d2a9e4b-1c6f-4b3a-8e5d-2f9c7a1b4e63",
        "team_id": null,
        "name": "Jordan",
        "type": null,
        "gender": "female",
        "status": "ready",
        "pro_voice": true,
        "created_at": 1774041600000,
        "failed_at": null
      }
    ],
    "total": 0
  },
  "meta": { "request_id": "c2a6e8f4-3b9d-4c1e-8a7f-5d3b1e9c6a24" }
}

Keep the voice_id of a ready voice. Catalog voices have a null team_id, and total counts only your own voices. Add include_urls=true to get a sample url for each voice to listen to. To make a voice of your own, see Voices, speech and transcription.

2. Write the text

Put the words in tts_body. Keep these limits in mind:

  • It can be up to 1,200 characters, counted after merge fields are filled in. A longer text fails with 3021.
  • Write numbers and abbreviations the way you want them said. Read the text aloud once, or preview it in step 3.
  • Merge fields such as {{contact.first_name|there}} fill in only when you send to a contact_id. A send to to has no contact to merge. Give every token a default after the |. See Merge fields.

A short message that works on its own:

Hi, this is Example Dental. Your appointment is tomorrow at ten. Call us back
if you need to change it.

Say who you are at the start, and give a way to reach you.

3. Preview the audio (optional)

A preview turns your text into a file you can play, so you can check the voice and the wording before anything goes to a recipient. It's separate from the send.

curl https://api-v2.dropcowboy.com/voice/public/tts/synthesize \
  -H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "voice_id": "7d2a9e4b-1c6f-4b3a-8e5d-2f9c7a1b4e63",
    "text": "Hi, this is Example Dental. Your appointment is tomorrow at ten."
  }'
{
  "data": {
    "audio_url": "https://media.example.com/tts/1b7e3c9a.mp3",
    "expires_at": 1774045200000,
    "tts_characters": 64,
    "content_type": "audio/mpeg"
  },
  "meta": { "request_id": "c2a6e8f4-3b9d-4c1e-8a7f-5d3b1e9c6a24" }
}

Open audio_url and listen. It works for about an hour, until expires_at. A preview is billed per character, and tts_characters reports the count, so preview a script once, not in a loop. Don't send the preview's audio_url as your audio. Send the text itself, as in step 5.

4. Choose the sender

On a standard account, the voicemail goes out from a phone line, and the recipient sees one of its numbers. List your lines and keep the ivr_id of the one to use. Its value is the phone_line_id.

curl "https://api-v2.dropcowboy.com/phone/public/lines?type=voice" \
  -H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET"

The answer is { "data": [ ... ] }, one object per line, each with an ivr_id, a name and is_default. If you leave phone_line_id out, the send uses your default line. With no default line, the send fails with 4010.

On a BYOC account, use your carrier's number as caller_id instead, in E.164. It shows as the caller ID exactly as given. Use only a number you are entitled to use as your caller ID. Always identify your business location truthfully when asked by recipients. If you send a phone_line_id too, the line wins and caller_id is ignored. To have the platform choose among several of your numbers, see Choose the caller ID automatically with phone lines (BYOC).

5. Send

Send tts_body and voice_id together, and no other audio field. Don't add media_id or audio_url. To send a recorded file instead, add it with a signed upload and send its media_id.

On a standard account:

curl -X POST https://api-v2.dropcowboy.com/rvm \
  -H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET" \
  -H "Idempotency-Key: 9b4e7c1a-2d8f-4a63-b5e0-3f7a1c9d6e28" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+17735550150",
    "phone_line_id": "234ecab5-1811-4c7d-a7a9-8c9ad9ca2e08",
    "voice_id": "7d2a9e4b-1c6f-4b3a-8e5d-2f9c7a1b4e63",
    "tts_body": "Hi, this is Example Dental. Your appointment is tomorrow at ten.",
    "foreign_id": "3b8f6d12-a4c7-4e90-9d15-7c2e5a1b8f43",
    "callback_url": "https://your-server.example.com/dropcowboy/rvm-result"
  }'

The ids are placeholders: use the ivr_id from step 4 and the voice_id from step 1. This body keeps working after you connect your own carrier, once your numbers are on the line; see Test on retail, then go live on BYOC.

On a BYOC account, replace phone_line_id with the number to show:

curl -X POST https://api-v2.dropcowboy.com/rvm \
  -H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET" \
  -H "Idempotency-Key: 4c8a1e6d-7b2f-4d93-a5e1-2f6b9c3d7a40" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+17735550150",
    "caller_id": "+13125550142",
    "voice_id": "7d2a9e4b-1c6f-4b3a-8e5d-2f9c7a1b4e63",
    "tts_body": "Hi, this is Example Dental. Your appointment is tomorrow at ten.",
    "callback_url": "https://your-server.example.com/dropcowboy/rvm-result"
  }'

Either one answers:

{ "status": "queued", "message_id": "8e4a2c6f-9d1b-4f7e-b3a5-6c9e1f4d2b78" }
Field What to know
tts_body, voice_id Send both, or neither. tts_body without voice_id fails with 3002. voice_id without tts_body fails with 3015.
contact_id Use it instead of to to fill merge fields from the contact. If you send both, to is used.
foreign_id, callback_url Your reference, and where the result is posted. The post echoes foreign_id.
Idempotency-Key A new UUID for each send. Reuse it only to retry the same send. See Retry safely.

A 202 means the send was queued. Problems with the text or the voice show up afterward, in the result, so read it.

6. Read the result

The outcome arrives on your callback_url and as a signed contact.rvm.status webhook, with a reason_code. A reason_code of 0 means the voicemail was left in the contact's mailbox. Every other code is in Outcomes. To subscribe and verify signatures, see Receive the results of your sends.

Run the whole flow in code

Each script lists your voices, uses DC_VOICE_ID or else the first ready voice, and sends. Set these first:

Variable Value
DC_KEY, DC_SECRET Your API key
DC_TO The recipient, in E.164
DC_TEXT The text to speak
DC_CALLBACK_URL Your results URL
DC_VOICE_ID Optional. A ready voice.
DC_PHONE_LINE_ID Optional. Standard accounts: the line to send from.
DC_CALLER_ID Optional. BYOC accounts: a number you are entitled to use as your caller ID, in E.164.

Node.js

Node.js 18 or later. Save it as send.mjs and run node send.mjs.

import { randomUUID } from 'node:crypto';

async function dc(method, path, body, extraHeaders) {
  const res = await fetch('https://api-v2.dropcowboy.com' + path, {
    method,
    headers: Object.assign({
      'x-key': process.env.DC_KEY,
      'x-secret': process.env.DC_SECRET,
      'Content-Type': 'application/json'
    }, extraHeaders),
    body: body ? JSON.stringify(body) : undefined
  });
  const json = await res.json();
  if (!res.ok) throw new Error(res.status + ' ' + (json.detail || json.title));
  return json;
}

if (!process.env.DC_CALLBACK_URL) throw new Error('Set DC_CALLBACK_URL first.');

let voiceId = process.env.DC_VOICE_ID;
if (!voiceId) {
  const list = await dc('GET', '/voice/public/voices?limit=100');
  const voice = list.data.voices.find((v) => v.status === 'ready');
  if (!voice) throw new Error('No voice is ready.');
  voiceId = voice.voice_id;
  console.log('Voice', voice.name);
}

const body = {
  to: process.env.DC_TO,
  voice_id: voiceId,
  tts_body: process.env.DC_TEXT,
  callback_url: process.env.DC_CALLBACK_URL
};
if (process.env.DC_PHONE_LINE_ID) body.phone_line_id = process.env.DC_PHONE_LINE_ID;
if (process.env.DC_CALLER_ID) body.caller_id = process.env.DC_CALLER_ID;

const queued = await dc('POST', '/rvm', body, { 'Idempotency-Key': randomUUID() });
console.log('Queued', queued.message_id);

Python

Python 3.9 or later, with requests.

import os
import uuid

import requests

AUTH = {"x-key": os.environ["DC_KEY"], "x-secret": os.environ["DC_SECRET"]}


def dc(method, path, body=None, extra_headers=None):
    headers = dict(AUTH)
    headers.update(extra_headers or {})
    res = requests.request(method, "https://api-v2.dropcowboy.com" + path,
                           json=body, headers=headers, timeout=30)
    data = res.json()
    if not res.ok:
        raise RuntimeError(f"{res.status_code} {data.get('detail') or data.get('title')}")
    return data


if not os.environ.get("DC_CALLBACK_URL"):
    raise RuntimeError("Set DC_CALLBACK_URL first.")

voice_id = os.environ.get("DC_VOICE_ID")
if not voice_id:
    voices = dc("GET", "/voice/public/voices?limit=100")["data"]["voices"]
    ready = [v for v in voices if v["status"] == "ready"]
    if not ready:
        raise RuntimeError("No voice is ready.")
    voice_id = ready[0]["voice_id"]
    print("Voice", ready[0]["name"])

body = {
    "to": os.environ["DC_TO"],
    "voice_id": voice_id,
    "tts_body": os.environ["DC_TEXT"],
    "callback_url": os.environ["DC_CALLBACK_URL"],
}
if os.environ.get("DC_PHONE_LINE_ID"):
    body["phone_line_id"] = os.environ["DC_PHONE_LINE_ID"]
if os.environ.get("DC_CALLER_ID"):
    body["caller_id"] = os.environ["DC_CALLER_ID"]

queued = dc("POST", "/rvm", body, {"Idempotency-Key": str(uuid.uuid4())})
print("Queued", queued["message_id"])

C#

.NET 8 or later. Create a console project and replace Program.cs.

using System.Text;
using System.Text.Json.Nodes;

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

var http = new HttpClient { BaseAddress = new Uri("https://api-v2.dropcowboy.com") };
http.DefaultRequestHeaders.Add("x-key", Env("DC_KEY"));
http.DefaultRequestHeaders.Add("x-secret", Env("DC_SECRET"));

async Task<JsonNode> Dc(HttpMethod method, string path, JsonNode? body = null, string? key = null)
{
    using var request = new HttpRequestMessage(method, path);
    if (body != null) request.Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
    if (key != null) request.Headers.Add("Idempotency-Key", key);
    using var response = await http.SendAsync(request);
    var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
    if (!response.IsSuccessStatusCode) throw new Exception($"{(int)response.StatusCode} {json["detail"] ?? json["title"]}");
    return json;
}

if (Env("DC_CALLBACK_URL") == "") throw new InvalidOperationException("Set DC_CALLBACK_URL first.");

var voiceId = Env("DC_VOICE_ID");
if (voiceId == "")
{
    var voices = (await Dc(HttpMethod.Get, "/voice/public/voices?limit=100"))["data"]!["voices"]!.AsArray();
    var voice = voices.FirstOrDefault(v => (string?)v!["status"] == "ready");
    if (voice == null) throw new Exception("No voice is ready.");
    voiceId = (string)voice["voice_id"]!;
    Console.WriteLine("Voice " + voice["name"]);
}

var body = new JsonObject
{
    ["to"] = Env("DC_TO"),
    ["voice_id"] = voiceId,
    ["tts_body"] = Env("DC_TEXT"),
    ["callback_url"] = Env("DC_CALLBACK_URL")
};
if (Env("DC_PHONE_LINE_ID") != "") body["phone_line_id"] = Env("DC_PHONE_LINE_ID");
if (Env("DC_CALLER_ID") != "") body["caller_id"] = Env("DC_CALLER_ID");

var queued = await Dc(HttpMethod.Post, "/rvm", body, Guid.NewGuid().ToString());
Console.WriteLine("Queued " + queued["message_id"]);

Tips

  • Preview once, then send. A preview is billed per character.
  • Keep the text short and spoken. Long text is harder to follow on a voicemail.
  • Keep the same voice_id across a campaign so recipients hear one speaker.
  • Send a foreign_id so the result matches your data.

If it does not work

Symptom Likely cause What to do
Result 3002 tts_body was sent without voice_id, or the voice_id isn't valid. Send both. Take the voice_id from step 1.
Result 3015 voice_id was sent without tts_body. Send both, or use media_id and no voice_id.
Result 3021 tts_body is over 1,200 characters after merge fields are filled in. Shorten the text, or shorten the defaults.
Result 3000 Your account has no funds. Add funds. See Payment required.
Send fails with 4010 No phone_line_id, and no default phone line. Send a phone_line_id, or set a default line.
Result 3040 Your account is in testing mode, and the number isn't one of your test numbers. Add it under test numbers on the Dialing rules page.
202, then no result Your callback route answered 404 or another non-2xx, which isn't retried. Accept POST and answer 200. Check Settings > API Logs.
Preview answers 409 The voice is processing or failed. Wait until its status is ready, or pick another voice.
Preview answers 402 No funds, or the voice is pending_payment. Add funds, or complete checkout for the voice in the dashboard.
Preview answers 404 The voice doesn't exist, was deleted, or isn't yours. Take a voice_id from step 1.
Preview answers 400 voice_id or text is missing. Send both.
Result 4001 or 4002 Their mailbox can't take a message. Retrying won't help. See Act on the result.
Result 0, but nothing seen Carrier handling can take up to 24 hours, and some carriers filter voicemails as spam. Wait before you retry.

Every code is in Outcomes.

Production checklist

  • Every recipient agreed to hear from you. See Consent.
  • You've listened to the voice and the wording in a preview.
  • Every send has a new Idempotency-Key, and a retry reuses it.
  • Merge fields have defaults, and the text stays under 1,200 characters.
  • You act on results from the webhook, not on the 202.

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.

1. Pick a voice
curl "https://api-v2.dropcowboy.com/voice/public/voices?limit=20" \
  -H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET"
3. Preview the audio (optional)
curl https://api-v2.dropcowboy.com/voice/public/tts/synthesize \
  -H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "voice_id": "7d2a9e4b-1c6f-4b3a-8e5d-2f9c7a1b4e63",
    "text": "Hi, this is Example Dental. Your appointment is tomorrow at ten."
  }'
4. Choose the sender
curl "https://api-v2.dropcowboy.com/phone/public/lines?type=voice" \
  -H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET"
5. Send
curl -X POST https://api-v2.dropcowboy.com/rvm \
  -H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET" \
  -H "Idempotency-Key: 9b4e7c1a-2d8f-4a63-b5e0-3f7a1c9d6e28" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+17735550150",
    "phone_line_id": "234ecab5-1811-4c7d-a7a9-8c9ad9ca2e08",
    "voice_id": "7d2a9e4b-1c6f-4b3a-8e5d-2f9c7a1b4e63",
    "tts_body": "Hi, this is Example Dental. Your appointment is tomorrow at ten.",
    "foreign_id": "3b8f6d12-a4c7-4e90-9d15-7c2e5a1b8f43",
    "callback_url": "https://your-server.example.com/dropcowboy/rvm-result"
  }'
5. Send (2)
curl -X POST https://api-v2.dropcowboy.com/rvm \
  -H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET" \
  -H "Idempotency-Key: 4c8a1e6d-7b2f-4d93-a5e1-2f6b9c3d7a40" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+17735550150",
    "caller_id": "+13125550142",
    "voice_id": "7d2a9e4b-1c6f-4b3a-8e5d-2f9c7a1b4e63",
    "tts_body": "Hi, this is Example Dental. Your appointment is tomorrow at ten.",
    "callback_url": "https://your-server.example.com/dropcowboy/rvm-result"
  }'
Node.js
import { randomUUID } from 'node:crypto';

async function dc(method, path, body, extraHeaders) {
  const res = await fetch('https://api-v2.dropcowboy.com' + path, {
    method,
    headers: Object.assign({
      'x-key': process.env.DC_KEY,
      'x-secret': process.env.DC_SECRET,
      'Content-Type': 'application/json'
    }, extraHeaders),
    body: body ? JSON.stringify(body) : undefined
  });
  const json = await res.json();
  if (!res.ok) throw new Error(res.status + ' ' + (json.detail || json.title));
  return json;
}

if (!process.env.DC_CALLBACK_URL) throw new Error('Set DC_CALLBACK_URL first.');

let voiceId = process.env.DC_VOICE_ID;
if (!voiceId) {
  const list = await dc('GET', '/voice/public/voices?limit=100');
  const voice = list.data.voices.find((v) => v.status === 'ready');
  if (!voice) throw new Error('No voice is ready.');
  voiceId = voice.voice_id;
  console.log('Voice', voice.name);
}

const body = {
  to: process.env.DC_TO,
  voice_id: voiceId,
  tts_body: process.env.DC_TEXT,
  callback_url: process.env.DC_CALLBACK_URL
};
if (process.env.DC_PHONE_LINE_ID) body.phone_line_id = process.env.DC_PHONE_LINE_ID;
if (process.env.DC_CALLER_ID) body.caller_id = process.env.DC_CALLER_ID;

const queued = await dc('POST', '/rvm', body, { 'Idempotency-Key': randomUUID() });
console.log('Queued', queued.message_id);
Python
import os
import uuid

import requests

AUTH = {"x-key": os.environ["DC_KEY"], "x-secret": os.environ["DC_SECRET"]}


def dc(method, path, body=None, extra_headers=None):
    headers = dict(AUTH)
    headers.update(extra_headers or {})
    res = requests.request(method, "https://api-v2.dropcowboy.com" + path,
                           json=body, headers=headers, timeout=30)
    data = res.json()
    if not res.ok:
        raise RuntimeError(f"{res.status_code} {data.get('detail') or data.get('title')}")
    return data


if not os.environ.get("DC_CALLBACK_URL"):
    raise RuntimeError("Set DC_CALLBACK_URL first.")

voice_id = os.environ.get("DC_VOICE_ID")
if not voice_id:
    voices = dc("GET", "/voice/public/voices?limit=100")["data"]["voices"]
    ready = [v for v in voices if v["status"] == "ready"]
    if not ready:
        raise RuntimeError("No voice is ready.")
    voice_id = ready[0]["voice_id"]
    print("Voice", ready[0]["name"])

body = {
    "to": os.environ["DC_TO"],
    "voice_id": voice_id,
    "tts_body": os.environ["DC_TEXT"],
    "callback_url": os.environ["DC_CALLBACK_URL"],
}
if os.environ.get("DC_PHONE_LINE_ID"):
    body["phone_line_id"] = os.environ["DC_PHONE_LINE_ID"]
if os.environ.get("DC_CALLER_ID"):
    body["caller_id"] = os.environ["DC_CALLER_ID"]

queued = dc("POST", "/rvm", body, {"Idempotency-Key": str(uuid.uuid4())})
print("Queued", queued["message_id"])
C#
using System.Text;
using System.Text.Json.Nodes;

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

var http = new HttpClient { BaseAddress = new Uri("https://api-v2.dropcowboy.com") };
http.DefaultRequestHeaders.Add("x-key", Env("DC_KEY"));
http.DefaultRequestHeaders.Add("x-secret", Env("DC_SECRET"));

async Task<JsonNode> Dc(HttpMethod method, string path, JsonNode? body = null, string? key = null)
{
    using var request = new HttpRequestMessage(method, path);
    if (body != null) request.Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
    if (key != null) request.Headers.Add("Idempotency-Key", key);
    using var response = await http.SendAsync(request);
    var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
    if (!response.IsSuccessStatusCode) throw new Exception($"{(int)response.StatusCode} {json["detail"] ?? json["title"]}");
    return json;
}

if (Env("DC_CALLBACK_URL") == "") throw new InvalidOperationException("Set DC_CALLBACK_URL first.");

var voiceId = Env("DC_VOICE_ID");
if (voiceId == "")
{
    var voices = (await Dc(HttpMethod.Get, "/voice/public/voices?limit=100"))["data"]!["voices"]!.AsArray();
    var voice = voices.FirstOrDefault(v => (string?)v!["status"] == "ready");
    if (voice == null) throw new Exception("No voice is ready.");
    voiceId = (string)voice["voice_id"]!;
    Console.WriteLine("Voice " + voice["name"]);
}

var body = new JsonObject
{
    ["to"] = Env("DC_TO"),
    ["voice_id"] = voiceId,
    ["tts_body"] = Env("DC_TEXT"),
    ["callback_url"] = Env("DC_CALLBACK_URL")
};
if (Env("DC_PHONE_LINE_ID") != "") body["phone_line_id"] = Env("DC_PHONE_LINE_ID");
if (Env("DC_CALLER_ID") != "") body["caller_id"] = Env("DC_CALLER_ID");

var queued = await Dc(HttpMethod.Post, "/rvm", body, Guid.NewGuid().ToString());
Console.WriteLine("Queued " + queued["message_id"]);