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 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:local-presence in Node.js, python -m dropcowboy_examples.recipes.send_rvm_byoc_local_presence in Python, or dotnet run -- local-presence in 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 from postal_code when 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 status is still pending_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.status results, not the 202.
  • Every recipient agreed to hear from you. See Consent.

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. Create the phone line
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" }'
Load numbers you own
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"
Search for numbers and buy them
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"
  }'
3. Confirm the numbers are on the line
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"
4. Send with the phone line
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"
  }'
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 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
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#
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"]);