API reference / Guides
Choose the caller ID automatically with phone lines (BYOC)
By the end of this guide you'll have a phone line that holds several of your own numbers, and a ringless voicemail sent with that line instead of a caller ID. For each recipient, the platform picks the number on the line closest to them. It takes about fifteen minutes.
Send only to people who agreed to hear from you. See Consent. Test with numbers you own.
A
202means accepted, not delivered. The result of each voicemail arrives on thecontact.rvm.statuswebhook.
Run it from GitHub. This guide is a working recipe in the Drop Cowboy® examples:
npm run send:local-presencein Node.js,python -m dropcowboy_examples.recipes.send_rvm_byoc_local_presencein Python, ordotnet run -- local-presencein C#. Each one puts your numbers on a phone line, sends with the line and waits for the result.
Before you start
| You need | Details |
|---|---|
| A BYOC account | Your carrier is connected. See Bring your own carrier. |
| An API key | In DC_KEY and DC_SECRET, with scopes numbers:write, numbers:read and rvm:send. See Authentication. |
| Audio | A media_id from your upload or import, or from GET /media/public/media. See Signed upload. |
| 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. Create the phone line
A phone line is the set of numbers you send from.
curl -X POST https://api-v2.dropcowboy.com/phone/public/lines \
-H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET" \
-H "Content-Type: application/json" \
-d '{ "name": "Local presence", "type": "voice" }'
{ "data": { "ivr_id": "234ecab5-1811-4c7d-a7a9-8c9ad9ca2e08" } }
The ivr_id is the phone_line_id from here on. Keep it: creating a line
twice makes two lines. For rules on calls back, see
Create a phone line.
2. Put numbers on the line
Use either way below, or both.
Load numbers you own
Use this for numbers you already hold at your carrier. Nothing is bought, so
load only numbers you hold and are allowed to call from. The import is a
background job: it answers 202 with a long_job_id, and you poll the job
until status is completed or failed.
curl -X POST https://api-v2.dropcowboy.com/phone/public/numbers/import \
-H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET" \
-H "Content-Type: application/json" \
-d '{
"phone_numbers": ["+13125550142", "+12125550143", "+14155550144"],
"phone_line_id": "234ecab5-1811-4c7d-a7a9-8c9ad9ca2e08"
}'
curl https://api-v2.dropcowboy.com/phone/public/numbers/import/1d5b9f3e-7a2c-4e8d-b6f1-3c7a9e5d2b84 \
-H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET"
The first call answers { "data": { "long_job_id": "1d5b9f3e-7a2c-4e8d-b6f1-3c7a9e5d2b84" } }.
The second answers processing until the job ends, then completed with a
result that counts added, updated, invalid and conflicts.
phone_numbers takes up to 5,000 numbers in E.164, and one bad value fails the
whole request. A number another account holds is counted in conflicts, and
the rest still load. See
Load a list of numbers.
Search for numbers and buy them
Use this to get new numbers in the area codes you serve. A search buys
nothing. Renting buys the numbers from your connected carrier, under your
carrier account's pricing. Check your carrier's pricing first, and rent only
numbers you want to keep. Renting answers 402 when your account has no funds
or is past due.
Search works for Twilio, Commio and Sinch carriers. With any other carrier, buy the numbers at your carrier and load them as above.
curl -X POST https://api-v2.dropcowboy.com/phone/public/numbers/available \
-H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET" \
-H "Content-Type: application/json" \
-d '{ "country_iso": "US", "pattern": "312", "type": "local", "limit": 3 }'
curl -X POST https://api-v2.dropcowboy.com/phone/public/numbers/rent \
-H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET" \
-H "Content-Type: application/json" \
-d '{
"numbers": ["+13125550143"],
"voice_ivr_id": "234ecab5-1811-4c7d-a7a9-8c9ad9ca2e08"
}'
Pass the searched phone_number values as numbers on the rent call.
voice_ivr_id puts them on this line. Without it they join your default line. See Search for numbers to rent.
3. Confirm the numbers are on the line
A line with no numbers can't send, so check first.
curl https://api-v2.dropcowboy.com/phone/public/lines/234ecab5-1811-4c7d-a7a9-8c9ad9ca2e08/numbers \
-H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET"
{
"data": [
{ "phone_number": "+13125550142", "status": "ready", "area_code": "312" }
]
}
An id that isn't one of your lines gives an empty list.
4. Send with the phone line
Send the line's id and no caller_id.
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",
"media_id": "1b7e3c9a-4d2f-4a8b-9e6c-7f2a1d5b3c80",
"postal_code": "60601",
"foreign_id": "7c1e5a93-2d4b-4f68-a0b9-3e6d8c1f5a27",
"callback_url": "https://your-server.example.com/dropcowboy/rvm-result"
}'
{ "status": "queued", "message_id": "8e4a2c6f-9d1b-4f7e-b3a5-6c9e1f4d2b78" }
The ids are placeholders: use the ivr_id from step 1 and your own
media_id. A media_id copied from this page fails with 3001.
| Field | What to know |
|---|---|
caller_id |
Leave it out. When you send a line too, the line wins and caller_id is ignored. |
postal_code |
Optional. The recipient's postal code. The platform uses it, with the area code of to, to place the recipient. |
Idempotency-Key |
A new UUID per send. See Retry safely. |
5. See which number was used
The result names the number the voicemail went out from. On the callback_url
post it's caller_id.
{
"caller_id": "+13125550142",
"reason_code": 0,
"foreign_id": "7c1e5a93-2d4b-4f68-a0b9-3e6d8c1f5a27"
}
The webhook carries the number as data.from and no foreign_id, so match it
on to or contact_id. A reason_code of 0
means the voicemail was left in the contact's mailbox. Every other code is in
Outcomes. To subscribe, see
Receive the results of your sends.
How the number is chosen
When you send with phone_line_id, the platform chooses one number from that
line for each recipient:
- It picks the number on the line closest to the recipient. It places the
recipient from the area code of
to, or frompostal_codewhen you send one. - It's closest-number matching, not exact area-code matching. A line with no number in the recipient's area code still sends, from the nearest number. Don't expect the caller ID to share the recipient's area code.
- When it can't place the recipient, it uses a number on the line. When several numbers are equally close, it uses the one it used least recently.
- It passes over a number flagged as unhealthy while other numbers are
available, and skips a number whose
statusis stillpending_payment.
You can't pin a number on a line. To send from one specific number you are
entitled to use as your caller ID, send caller_id without a line. See
Who can choose a caller ID.
Always identify your business location truthfully when asked by recipients.
Run the whole flow in code
Each script uses your line or creates one, loads the numbers in DC_NUMBERS,
confirms the line has numbers, and sends. It doesn't search or rent, because
renting costs money. Set these variables first:
| Variable | Value |
|---|---|
DC_KEY, DC_SECRET, DC_TO, DC_MEDIA_ID, DC_CALLBACK_URL |
Your key pair, the recipient in E.164, the audio, and where to post the result. |
DC_PHONE_LINE_ID |
Optional. A line you made earlier. Without it the script makes one. |
DC_NUMBERS |
Optional. Numbers you own, comma separated, in E.164. |
Node.js
Node.js 18 or later. Save it as 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 lineId = process.env.DC_PHONE_LINE_ID;
if (!lineId) {
const created = await dc('POST', '/phone/public/lines', { name: 'Local presence', type: 'voice' });
lineId = created.data.ivr_id;
}
console.log('Phone line', lineId);
const owned = (process.env.DC_NUMBERS || '').split(',').map((n) => n.trim()).filter(Boolean);
if (owned.length > 0) {
const accepted = await dc('POST', '/phone/public/numbers/import', { phone_numbers: owned, phone_line_id: lineId });
const jobId = accepted.data.long_job_id;
let job;
for (let tries = 0; tries <= 30; tries++) {
job = (await dc('GET', '/phone/public/numbers/import/' + jobId)).data;
if (job.status === 'completed' || job.status === 'failed') break;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
if (job.status !== 'completed') {
throw new Error(job.status === 'failed' ? 'Import failed: ' + job.error : 'Import still running after 60 seconds. Job ' + jobId);
}
const r = job.result;
console.log('Import: added', r.added, 'updated', r.updated, 'invalid', r.invalid, 'conflicts', r.conflicts);
}
const onLine = (await dc('GET', '/phone/public/lines/' + lineId + '/numbers')).data;
if (onLine.length === 0) throw new Error('The line has no numbers. Set DC_NUMBERS or rent some (step 2).');
const queued = await dc('POST', '/rvm', {
to: process.env.DC_TO,
phone_line_id: lineId,
media_id: process.env.DC_MEDIA_ID,
callback_url: process.env.DC_CALLBACK_URL
}, { 'Idempotency-Key': randomUUID() });
console.log('Queued', queued.message_id);
Python
Python 3.9 or later, with requests.
import os
import time
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.")
line_id = os.environ.get("DC_PHONE_LINE_ID")
if not line_id:
created = dc("POST", "/phone/public/lines", {"name": "Local presence", "type": "voice"})
line_id = created["data"]["ivr_id"]
print("Phone line", line_id)
owned = [n.strip() for n in os.environ.get("DC_NUMBERS", "").split(",") if n.strip()]
if owned:
accepted = dc("POST", "/phone/public/numbers/import", {"phone_numbers": owned, "phone_line_id": line_id})
job_id = accepted["data"]["long_job_id"]
for _ in range(31):
job = dc("GET", f"/phone/public/numbers/import/{job_id}")["data"]
if job["status"] in ("completed", "failed"):
break
time.sleep(2)
if job["status"] != "completed":
raise RuntimeError(f"Import failed: {job['error']}" if job["status"] == "failed"
else f"Import still running after 60 seconds. Job {job_id}")
r = job["result"]
print("Import: added", r["added"], "updated", r["updated"], "invalid", r["invalid"], "conflicts", r["conflicts"])
on_line = dc("GET", f"/phone/public/lines/{line_id}/numbers")["data"]
if not on_line:
raise RuntimeError("The line has no numbers. Set DC_NUMBERS or rent some (step 2).")
queued = dc("POST", "/rvm", {
"to": os.environ["DC_TO"],
"phone_line_id": line_id,
"media_id": os.environ["DC_MEDIA_ID"],
"callback_url": os.environ["DC_CALLBACK_URL"],
}, {"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 lineId = Env("DC_PHONE_LINE_ID");
if (lineId == "")
{
var created = await Dc(HttpMethod.Post, "/phone/public/lines",
new JsonObject { ["name"] = "Local presence", ["type"] = "voice" });
lineId = (string)created["data"]!["ivr_id"]!;
}
Console.WriteLine("Phone line " + lineId);
var owned = Env("DC_NUMBERS").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (owned.Length > 0)
{
var numbers = new JsonArray(owned.Select(n => (JsonNode)n).ToArray());
var accepted = await Dc(HttpMethod.Post, "/phone/public/numbers/import",
new JsonObject { ["phone_numbers"] = numbers, ["phone_line_id"] = lineId });
var jobId = (string)accepted["data"]!["long_job_id"]!;
JsonNode job = new JsonObject();
for (var tries = 0; tries <= 30; tries++)
{
job = (await Dc(HttpMethod.Get, "/phone/public/numbers/import/" + jobId))["data"]!;
var status = (string?)job["status"];
if (status == "completed" || status == "failed") break;
await Task.Delay(2000);
}
if ((string?)job["status"] != "completed") throw new InvalidOperationException(
(string?)job["status"] == "failed" ? "Import failed: " + job["error"] : "Import still running after 60 seconds. Job " + jobId);
var r = job["result"]!;
Console.WriteLine($"Import: added {r["added"]} updated {r["updated"]} invalid {r["invalid"]} conflicts {r["conflicts"]}");
}
var onLine = (await Dc(HttpMethod.Get, $"/phone/public/lines/{lineId}/numbers"))["data"]!.AsArray();
if (onLine.Count == 0) throw new InvalidOperationException("The line has no numbers. Set DC_NUMBERS or rent some (step 2).");
var body = new JsonObject
{
["to"] = Env("DC_TO"),
["phone_line_id"] = lineId,
["media_id"] = Env("DC_MEDIA_ID"),
["callback_url"] = Env("DC_CALLBACK_URL")
};
var queued = await Dc(HttpMethod.Post, "/rvm", body, Guid.NewGuid().ToString());
Console.WriteLine("Queued " + queued["message_id"]);
Tips
- Spread numbers across the area codes you serve.
- Keep one line per use case, for example, one line for appointment reminders and another for separate campaigns.
- To move a number to another line, see Put a number on a phone line.
If it does not work
| What you see | What it means | What to do |
|---|---|---|
403 with byoc_required |
The account isn't on a BYOC plan. | Connect a carrier. See the plan change. |
400 with pool_required (import, rent) or byoc_not_configured (search) |
No carrier is connected. | Connect one, then try again. |
| The caller ID isn't the one you expected | The platform picks the closest number, not an exact area-code match. | Read How the number is chosen. |
Result 3040 |
The 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, or ask support to end testing mode. |
Result 3014 |
audio_url isn't enabled on your account. It must be enabled by support. |
Send a media_id or text to speech. |
For other symptoms, see Troubleshooting. Every code is in Outcomes.
Production checklist
- Every send has a new
Idempotency-Key. - You act on
contact.rvm.statusresults, not the202. - Every recipient agreed to hear from you. See Consent.
Next steps
- Receive the results of your sends
- Send a ringless voicemail on retail
- Send a ringless voicemail with your own carrier
- Send a ringless voicemail with text to speech
- Test on retail, then go live on BYOC
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.