Use your own numbers on BYOC

A bring-your-own-carrier (BYOC) account sends through your carrier, from numbers you hold there. There are four ways to put those numbers to work. This page shows each one, with code that ends in a ringless voicemail.

Connect the carrier first. See Bring your own carrier.

Pick a way in

You want to Do this Receive calls back? Text from it?
Send now, with nothing set up Pass a caller ID No No
Use numbers already on your carrier account Sync them from your carrier in the dashboard Yes, after you point the trunk With a registered texting campaign
Use a list of numbers you copied from your carrier Load the list with the API Yes, after you point the trunk With a registered texting campaign
Get new numbers from your carrier Buy them through Drop Cowboy® Yes With a registered texting campaign

All four need the carrier connected. The first needs nothing else.

Before you start

  • Create an API key with the scopes you use: rvm:send to send, numbers:write to load or buy numbers, numbers:read to list them. See Authentication.
  • The examples read your key from DC_KEY and DC_SECRET.
  • Every voicemail on BYOC needs audio. Give a public audio_url (mp3 or wav, up to 50 MB), a media_id, or tts_body with voice_id.
  • Use numbers you hold and are allowed to call from. Your carrier decides whether it accepts a number as a caller ID.

Send from a caller ID

Nothing is loaded into Drop Cowboy. You name your carrier's number on the send and it goes out as given, through the route made when you connected the carrier. There is no route, trunk or host field on a send.

curl -X POST https://api-v2.dropcowboy.com/rvm \
  -H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET" \
  -H "Idempotency-Key: 3b7e9d1a-6c4f-4a82-b5d0-8f1e2c7a9b46" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+13125550142",
    "caller_id": "+17735550188",
    "audio_url": "https://cdn.example.com/voicemails/appointment-reminder.mp3"
  }'

The answer is 202 with a message_id. The result arrives on the status webhook and your callback_url. See Where results arrive.

Don't send phone_line_id with caller_id. The line wins, and a number that isn't in Drop Cowboy isn't on a line.

Calls back to this number go to your carrier, not to Drop Cowboy. To have them reach a phone line, load the number as in the next sections.

Sync numbers from your carrier

Use this when the numbers are already on your carrier account. This is a dashboard step. The API has no route that lists your carrier's account.

  1. Open Connect your carrier in the left menu and click your carrier.
  2. Under Next: add your phone numbers, click Set Up Phone Line & Add Numbers.
  3. On the line's Numbers tab, click Add Phone Number, then Sync from Carrier.
  4. Click Load Carrier Numbers. Tick the numbers, or tick Import all numbers on account, then click Sync All Numbers.

The numbers join a phone line and stay with your carrier. List your numbers to read each one's line in voice_ivr_id, or list your lines and take each line's ivr_id. That value is the phone_line_id you send with.

curl https://api-v2.dropcowboy.com/phone/public/numbers \
  -H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET"

Then send from the line. The call uses the line's caller ID, and calls back reach the line.

curl -X POST https://api-v2.dropcowboy.com/rvm \
  -H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET" \
  -H "Idempotency-Key: 5c8f2a7e-1d4b-4e96-a3c0-7b2d9e6f1a58" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+13125550142",
    "phone_line_id": "234ecab5-1811-4c7d-a7a9-8c9ad9ca2e08",
    "audio_url": "https://cdn.example.com/voicemails/appointment-reminder.mp3"
  }'

You can also keep sending with caller_id after a number is synced. Leave out phone_line_id when you do.

Load a list of numbers

Use this when you have the numbers as a list, for example copied from your carrier's console, and want them on a phone line without the dashboard. POST /phone/public/numbers/import loads them. It needs numbers:write, a BYOC account and a connected carrier. Drop Cowboy doesn't check with your carrier that you own them, so load only numbers you hold.

Field Type Required Description
phone_numbers array of strings Yes E.164 numbers, up to 5,000. One bad value fails the request and is named in the message.
phone_line_id string No The line the numbers go on. Default: your default voice line.
overwrite_routing boolean No true moves numbers already on your account onto this line. Default false: they stay where they are.

The route answers 202 with a long_job_id. Poll GET /phone/public/numbers/import/{job_id} until status is completed or failed. A number another account holds isn't taken. It's counted in conflicts, and the rest still load.

This script loads two numbers, waits for the import, then sends from the first.

Node.js (18 or later):

const BASE = 'https://api-v2.dropcowboy.com';
const AUTH = { 'x-key': process.env.DC_KEY, 'x-secret': process.env.DC_SECRET };

async function dc(method, path, body, extraHeaders) {
  const headers = Object.assign({ 'Content-Type': 'application/json' }, AUTH, extraHeaders);
  const res = await fetch(BASE + path, {
    method,
    headers,
    body: body ? JSON.stringify(body) : undefined
  });
  const json = await res.json();
  if (!res.ok) {
    throw new Error(res.status + ' ' + (json.detail || json.title) + ' ' + JSON.stringify(json.details || {}));
  }
  return json;
}

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function loadNumbers(phoneNumbers) {
  const accepted = await dc('POST', '/phone/public/numbers/import', { phone_numbers: phoneNumbers });
  const jobId = accepted.data.long_job_id;

  for (let attempt = 0; attempt < 60; attempt++) {
    const job = (await dc('GET', '/phone/public/numbers/import/' + jobId)).data;
    if (job.status === 'completed') return job.result;
    if (job.status === 'failed') throw new Error('Import failed: ' + job.error);
    await wait(2000);
  }
  throw new Error('Import is still running. Check job ' + jobId + ' later.');
}

async function sendVoicemail(to, callerId, audioUrl) {
  return dc('POST', '/rvm', { to, caller_id: callerId, audio_url: audioUrl }, {
    'Idempotency-Key': crypto.randomUUID()
  });
}

(async () => {
  const numbers = ['+13125550142', '+13125550143'];
  const result = await loadNumbers(numbers);
  console.log('Loaded', result.added, 'new,', result.updated, 'updated,', result.conflicts, 'held by another account');

  const queued = await sendVoicemail(
    '+13125550150',
    numbers[0],
    'https://cdn.example.com/voicemails/appointment-reminder.mp3'
  );
  console.log('Queued', queued.message_id);
})();

Python (3.9 or later, with requests):

import os
import time
import uuid

import requests

BASE = "https://api-v2.dropcowboy.com"
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, BASE + 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')} {data.get('details', {})}")
    return data


def load_numbers(phone_numbers):
    accepted = dc("POST", "/phone/public/numbers/import", {"phone_numbers": phone_numbers})
    job_id = accepted["data"]["long_job_id"]

    for _ in range(60):
        job = dc("GET", f"/phone/public/numbers/import/{job_id}")["data"]
        if job["status"] == "completed":
            return job["result"]
        if job["status"] == "failed":
            raise RuntimeError(f"Import failed: {job['error']}")
        time.sleep(2)
    raise RuntimeError(f"Import is still running. Check job {job_id} later.")


def send_voicemail(to, caller_id, audio_url):
    return dc(
        "POST",
        "/rvm",
        {"to": to, "caller_id": caller_id, "audio_url": audio_url},
        {"Idempotency-Key": str(uuid.uuid4())},
    )


numbers = ["+13125550142", "+13125550143"]
result = load_numbers(numbers)
print("Loaded", result["added"], "new,", result["updated"], "updated,", result["conflicts"], "held by another account")

queued = send_voicemail(
    "+13125550150",
    numbers[0],
    "https://cdn.example.com/voicemails/appointment-reminder.mp3",
)
print("Queued", queued["message_id"])

To send from the line instead of one number, replace caller_id with phone_line_id in the send body.

The import doesn't change anything at your carrier. To receive calls on these numbers, point the trunk at us.

Buy numbers from your carrier

Use this to get new numbers. They're bought from your connected carrier, so your carrier account holds them. In the API it takes three calls: search, rent, send.

  • In the dashboard, open the phone line's Numbers tab, click Add Phone Number, then Buy a New Number, and search as usual.
  • With the API, POST /phone/public/numbers/available searches your carrier's live inventory, and POST /phone/public/numbers/rent buys the numbers you pick. See Search for numbers to rent.

Search works for Twilio, Commio (ThinQ) and Sinch carriers. For any other carrier, buy the number at your carrier and load it. The search needs numbers:read and the rent needs numbers:write. Each fails with 400 and a code when no carrier is connected: byoc_not_configured for the search and pool_required for the rent.

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": 5 }'

The answer is a list in data. Each result has a phone_number. Pass the ones you want to the rent route:

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"
  }'

voice_ivr_id is optional. Without it the numbers go on your default phone line. Each new number fires the number.provisioned webhook.

This Node.js code finds a number, buys it and sends from it. It uses the dc() and sendVoicemail() helpers from Load a list of numbers:

async function buyAndSend(areaCode, lineId, to, audioUrl) {
  const found = (await dc('POST', '/phone/public/numbers/available', {
    pattern: areaCode,
    limit: 5
  })).data;
  if (found.length === 0) throw new Error('No numbers in ' + areaCode);

  const chosen = found[0].phone_number;
  await dc('POST', '/phone/public/numbers/rent', { numbers: [chosen], voice_ivr_id: lineId });
  return sendVoicemail(to, chosen, audioUrl);
}

The same in Python, with dc() and send_voicemail() from the same section:

def buy_and_send(area_code, line_id, to, audio_url):
    found = dc("POST", "/phone/public/numbers/available", {"pattern": area_code, "limit": 5})["data"]
    if not found:
        raise RuntimeError(f"No numbers in {area_code}")

    chosen = found[0]["phone_number"]
    dc("POST", "/phone/public/numbers/rent", {"numbers": [chosen], "voice_ivr_id": line_id})
    return send_voicemail(to, chosen, audio_url)

Receive calls on your numbers

Sending needs no inventory. Receiving does. When someone calls back the number on their voicemail, the call reaches your carrier first. Drop Cowboy only sees it when both of these are done:

  1. The number is on a phone line. Sync it, load it or buy it as above. The line's rules decide what happens: forward, queue, an AI agent and so on.
  2. Your carrier's trunk points at us. Send inbound calls for the number to sip.dropcowboy.com on port 5060. Connecting Twilio, Telnyx, SignalWire, Plivo, Bandwidth, Vonage, Sinch, Flowroute or Commio (ThinQ) sets this up for you. A custom trunk is set up by hand in your carrier's portal.

Texting from a number also needs a registered texting campaign on its line. See Texts.

Who can choose a caller ID

Account caller_id on a send Phone line
BYOC (force_byoc) Used as given, with no number loaded Optional
Any other account Ignored. The send uses the phone line you name, or your default line. Required, or a default line

Other places a caller ID is read follow the same rule. A campaign's caller_id and a dialer call's caller_id must be a number on your account unless the account is BYOC. A text reply's caller_id must be an SMS-enabled number on your account.

Common problems

Result What it means Try this next
403 with byoc_required The account isn't on a BYOC plan. Connect a carrier. See the plan change.
400 with pool_required or byoc_not_configured No carrier is connected. Connect one, then load, search or buy numbers.
400 with invalid_phone_numbers A value isn't E.164. Send + and the country code. The message names the refused values.
400 with phone_line_required No phone_line_id and no default voice line. Pass phone_line_id, or set a default line.
conflicts above 0 on the import Another account holds those numbers. They weren't loaded. Check the numbers.
Send fails with 3014 audio_url isn't allowed on this account. Connect a carrier, or send a media_id.
Send fails with 4010 No phone_line_id and no default line. Pass phone_line_id or caller_id (BYOC), or set a default line.
403 with caller_id_not_owned or CALLER_ID_NOT_OWNED A non-BYOC account named a number it doesn't hold. Use a number on your account, or connect a carrier.
Nothing answers when people call back The trunk isn't pointed at us, or the number isn't on a phone line. Do both under Receive calls on your numbers.

Every code is in Outcomes. For more symptoms, see Troubleshooting.

Code samples

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

Send from a caller ID
curl -X POST https://api-v2.dropcowboy.com/rvm \
  -H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET" \
  -H "Idempotency-Key: 3b7e9d1a-6c4f-4a82-b5d0-8f1e2c7a9b46" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+13125550142",
    "caller_id": "+17735550188",
    "audio_url": "https://cdn.example.com/voicemails/appointment-reminder.mp3"
  }'
Sync numbers from your carrier
curl https://api-v2.dropcowboy.com/phone/public/numbers \
  -H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET"
Sync numbers from your carrier (2)
curl -X POST https://api-v2.dropcowboy.com/rvm \
  -H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET" \
  -H "Idempotency-Key: 5c8f2a7e-1d4b-4e96-a3c0-7b2d9e6f1a58" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+13125550142",
    "phone_line_id": "234ecab5-1811-4c7d-a7a9-8c9ad9ca2e08",
    "audio_url": "https://cdn.example.com/voicemails/appointment-reminder.mp3"
  }'
Load a list of numbers
const BASE = 'https://api-v2.dropcowboy.com';
const AUTH = { 'x-key': process.env.DC_KEY, 'x-secret': process.env.DC_SECRET };

async function dc(method, path, body, extraHeaders) {
  const headers = Object.assign({ 'Content-Type': 'application/json' }, AUTH, extraHeaders);
  const res = await fetch(BASE + path, {
    method,
    headers,
    body: body ? JSON.stringify(body) : undefined
  });
  const json = await res.json();
  if (!res.ok) {
    throw new Error(res.status + ' ' + (json.detail || json.title) + ' ' + JSON.stringify(json.details || {}));
  }
  return json;
}

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function loadNumbers(phoneNumbers) {
  const accepted = await dc('POST', '/phone/public/numbers/import', { phone_numbers: phoneNumbers });
  const jobId = accepted.data.long_job_id;

  for (let attempt = 0; attempt < 60; attempt++) {
    const job = (await dc('GET', '/phone/public/numbers/import/' + jobId)).data;
    if (job.status === 'completed') return job.result;
    if (job.status === 'failed') throw new Error('Import failed: ' + job.error);
    await wait(2000);
  }
  throw new Error('Import is still running. Check job ' + jobId + ' later.');
}

async function sendVoicemail(to, callerId, audioUrl) {
  return dc('POST', '/rvm', { to, caller_id: callerId, audio_url: audioUrl }, {
    'Idempotency-Key': crypto.randomUUID()
  });
}

(async () => {
  const numbers = ['+13125550142', '+13125550143'];
  const result = await loadNumbers(numbers);
  console.log('Loaded', result.added, 'new,', result.updated, 'updated,', result.conflicts, 'held by another account');

  const queued = await sendVoicemail(
    '+13125550150',
    numbers[0],
    'https://cdn.example.com/voicemails/appointment-reminder.mp3'
  );
  console.log('Queued', queued.message_id);
})();
Load a list of numbers (2)
import os
import time
import uuid

import requests

BASE = "https://api-v2.dropcowboy.com"
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, BASE + 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')} {data.get('details', {})}")
    return data


def load_numbers(phone_numbers):
    accepted = dc("POST", "/phone/public/numbers/import", {"phone_numbers": phone_numbers})
    job_id = accepted["data"]["long_job_id"]

    for _ in range(60):
        job = dc("GET", f"/phone/public/numbers/import/{job_id}")["data"]
        if job["status"] == "completed":
            return job["result"]
        if job["status"] == "failed":
            raise RuntimeError(f"Import failed: {job['error']}")
        time.sleep(2)
    raise RuntimeError(f"Import is still running. Check job {job_id} later.")


def send_voicemail(to, caller_id, audio_url):
    return dc(
        "POST",
        "/rvm",
        {"to": to, "caller_id": caller_id, "audio_url": audio_url},
        {"Idempotency-Key": str(uuid.uuid4())},
    )


numbers = ["+13125550142", "+13125550143"]
result = load_numbers(numbers)
print("Loaded", result["added"], "new,", result["updated"], "updated,", result["conflicts"], "held by another account")

queued = send_voicemail(
    "+13125550150",
    numbers[0],
    "https://cdn.example.com/voicemails/appointment-reminder.mp3",
)
print("Queued", queued["message_id"])
Buy numbers from your carrier
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": 5 }'
Buy numbers from your carrier (2)
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"
  }'
Buy numbers from your carrier (3)
async function buyAndSend(areaCode, lineId, to, audioUrl) {
  const found = (await dc('POST', '/phone/public/numbers/available', {
    pattern: areaCode,
    limit: 5
  })).data;
  if (found.length === 0) throw new Error('No numbers in ' + areaCode);

  const chosen = found[0].phone_number;
  await dc('POST', '/phone/public/numbers/rent', { numbers: [chosen], voice_ivr_id: lineId });
  return sendVoicemail(to, chosen, audioUrl);
}