Sending basics

Four routes each send one message to one person. Use them when something happens in your system: an order ships, a form comes in, an appointment is booked. To send to a whole list, use Campaigns. For email, see Email.

Route Sends Page
POST /rvm A ringless voicemail Ringless voicemail
POST /sms A text, an MMS, or an RCS message Texts
POST /voice-broadcast A call that plays one message to a person and another to a machine Voice broadcasts and AI calls
POST /ai-broadcast A call handled by one of your AI agents Voice broadcasts and AI calls

All four answer 202 when the request is queued. The recipient, consent, calling hours and your balance are checked after that, so a 202 doesn't mean the message went out. See What a 202 means.

Choose a recipient

Give one of these:

Field Type Required Description
to string One of to or contact_id Phone number in E.164, for example +13125550142
contact_id string One of to or contact_id A contact UUID from GET /contact/public/contacts
phone_selector string No Which of the contact's numbers to use. Only read with contact_id. Default primary.

If you send both, we use to and ignore contact_id. Send contact_id when you want merge fields filled in: a send to to has no contact data to merge.

phone_selector Uses
primary The first number found in main, mobile, home, office, then other phone
any The first number the contact has
main_phone, mobile_phone, home_phone, office_phone, other_phone Only that number

When the recipient can't be resolved, the send fails after the 202. The reason and reason_code arrive on your callback_url and the status webhook:

reason Code What to do
Must be E.164 format 3011 Send the number with + and the country code.
Invalid contact_id 3020 Pass a contact UUID from GET /contact/public/contacts.
Invalid phone_selector 3020 Use one of the values in the table above.
No contact 3020 The contact doesn't exist on your account, or was deleted. Check the id.
Contact on DNC 4016 The contact is on your do-not-contact list. Don't send.
No phone number for contact 3012 The selected number is empty. Add it to the contact, or use primary.
Contact lookup failed 3999 Temporary. Retry with the same Idempotency-Key.

Fields every send accepts

Field Type Required Description
foreign_id string No Your own reference, up to 256 characters. Echoed on callback_url. Status webhooks don't carry it, so match those on contact_id or to. Without it, callback_url carries the contact's Record ID field when there is one.
callback_url string No A public http(s) URL that receives one unsigned POST with the outcome. See callback_url. A value that isn't a URL fails the send with 3019.
postal_code string No The recipient's postal code. It improves the time-zone guess for calling hours.
brand_id string No Registered brand to send under. Required when your account enforces brand registration; without it the send fails with 6009. Find yours with GET /automation/public/brands.
max_attempts integer No Tighten the contact frequency limit for this send. Only a value lower than your account's limit has an effect.
max_attempt_window_ms integer No Lengthen the frequency window for this send, in milliseconds. Only a value longer than your account's window has an effect. The most is 30 days (2592000000).

Send an Idempotency-Key header on every send so a retry can't send twice. See Retry safely with Idempotency-Key.

Merge fields

With contact_id, {{ }} tokens are filled from the contact before sending. Tokens work in body on texts, tts_body on ringless voicemail, and the tts_on_* fields on voice broadcasts.

Token Value
{{contact.first_name}}, {{contact.last_name}}, {{contact.full_name}} Name. full_name is first and last name together.
{{contact.company}}, {{contact.email}} Company and email
{{contact.address}}, {{contact.city}}, {{contact.state}}, {{contact.zip_code}}, {{contact.country}} Address
{{contact.custom.<slug>}} A custom field, by its slug from List custom fields, for example {{contact.custom.membership_tier}}

Add a default after | for when the field is empty: {{contact.first_name|there}}. Put a default with spaces in quotes: {{contact.company|"your company"}}. A token with no value and no default becomes empty, so give every token a default.

Square brackets with / pick one option at random per message: [Hi/Hello/Hey] {{contact.first_name|there}}. This works in texts and spoken text, not in RCS templates.

Act on the result

Each send ends in one status webhook: contact.rvm.status for ringless voicemail, voice broadcasts and AI calls, or contact.sms.status for texts. Branch on data.reason_code. The full list is in Outcomes.

Code Meaning What to do
0 Sent Record it.
4001, 4002 Their voicemail isn't set up, or is full Another voicemail won't help. Text them if they've agreed to texts.
4005, 4006 No answer, or busy Try again later, inside calling hours and the 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 Alert yourself and fix the setup.

These handlers verify the signature, answer quickly, then act on the code. Subscribe them to contact.rvm.status and contact.sms.status; see Webhooks. Signature checks are explained in Verify the signature.

Node.js (Express)

Node 18 or later. Set DC_KEY, DC_SECRET, DC_SIGNING_SECRET and DC_PHONE_LINE_ID.

const express = require('express');
const crypto = require('crypto');

const API = 'https://api-v2.dropcowboy.com';
const NOT_ALLOWED = [4016, 4017, 6005, 6011];

function apiHeaders(idempotencyKey) {
  const headers = { 'x-key': process.env.DC_KEY, 'x-secret': process.env.DC_SECRET, 'Content-Type': 'application/json' };
  if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
  return headers;
}

function verified(req) {
  const ts = req.get('X-Timestamp');
  const sig = req.get('X-Signature') || '';
  if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = 'sha256=' + crypto.createHmac('sha256', process.env.DC_SIGNING_SECRET)
    .update(ts + '.').update(req.body).digest('hex');
  return sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}

async function canText(contactId) {
  const res = await fetch(`${API}/contact/public/contacts/${contactId}/consent`, { headers: apiHeaders() });
  if (!res.ok) return false;
  const { data } = await res.json();
  const latest = data.find(r => ['sms_optin', 'sms_optin_confirmed', 'sms_optout'].includes(r.consent_type));
  return Boolean(latest) && latest.consent_type !== 'sms_optout' && latest.consent_status === 'granted';
}

async function handle({ data }) {
  const code = data.reason_code;
  if (code === 0) return console.log('sent', data.drop_id);
  if ((code === 4001 || code === 4002) && data.contact_id && await canText(data.contact_id)) {
    await fetch(`${API}/sms`, {
      method: 'POST',
      headers: apiHeaders(data.drop_id),
      body: JSON.stringify({
        contact_id: data.contact_id,
        phone_line_id: process.env.DC_PHONE_LINE_ID,
        body: 'We tried to leave you a voicemail. Reply here if texting is easier.'
      })
    });
    return;
  }
  if (code === 4005 || code === 4006) return console.log('retry later', data.contact_id);
  if (NOT_ALLOWED.includes(code)) return console.log('stop contacting', data.contact_id);
  if (code >= 3000 && code < 4000) return console.error('fix setup', code, data.reason);
}

const app = express();
app.post('/webhooks/dropcowboy', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verified(req)) return res.sendStatus(401);
  res.sendStatus(200);
  handle(JSON.parse(req.body)).catch(err => console.error(err));
});
app.listen(3000);

Python (Flask)

Uses requests. Set the same environment variables.

import hashlib, hmac, os, time
import requests
from flask import Flask, request, abort

API = "https://api-v2.dropcowboy.com"
HEADERS = {"x-key": os.environ["DC_KEY"], "x-secret": os.environ["DC_SECRET"]}
NOT_ALLOWED = {4016, 4017, 6005, 6011}
app = Flask(__name__)

def verified(raw, headers):
    ts, sig = headers.get("X-Timestamp", ""), headers.get("X-Signature", "")
    if not ts or abs(time.time() - int(ts)) > 300:
        return False
    expected = "sha256=" + hmac.new(os.environ["DC_SIGNING_SECRET"].encode(),
                                    ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(sig, expected)

def can_text(contact_id):
    res = requests.get(f"{API}/contact/public/contacts/{contact_id}/consent", headers=HEADERS, timeout=3)
    if not res.ok:
        return False
    types = ("sms_optin", "sms_optin_confirmed", "sms_optout")
    latest = next((r for r in res.json()["data"] if r["consent_type"] in types), None)
    return bool(latest) and latest["consent_type"] != "sms_optout" and latest["consent_status"] == "granted"

@app.post("/webhooks/dropcowboy")
def webhook():
    if not verified(request.get_data(), request.headers):
        abort(401)
    data = request.get_json()["data"]
    code = data["reason_code"]
    if code == 0:
        print("sent", data["drop_id"])
    elif code in (4001, 4002) and data.get("contact_id") and can_text(data["contact_id"]):
        requests.post(f"{API}/sms", timeout=3,
                      headers={**HEADERS, "Idempotency-Key": data["drop_id"]},
                      json={"contact_id": data["contact_id"],
                            "phone_line_id": os.environ["DC_PHONE_LINE_ID"],
                            "body": "We tried to leave you a voicemail. Reply here if texting is easier."})
    elif code in (4005, 4006):
        print("retry later", data["contact_id"])
    elif code in NOT_ALLOWED:
        print("stop contacting", data["contact_id"])
    elif 3000 <= code < 4000:
        print("fix setup", code, data["reason"])
    return "", 200

Using drop_id as the Idempotency-Key means a repeated webhook delivery can't send the follow-up text twice. Answer within 5 seconds: if your follow-up work is slow, put it on a job queue and answer first.

A contact with no consent record for texting gets no text from these handlers. Record consent when a contact agrees; see Consent.


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.

Code samples

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

Node.js (Express)
const express = require('express');
const crypto = require('crypto');

const API = 'https://api-v2.dropcowboy.com';
const NOT_ALLOWED = [4016, 4017, 6005, 6011];

function apiHeaders(idempotencyKey) {
  const headers = { 'x-key': process.env.DC_KEY, 'x-secret': process.env.DC_SECRET, 'Content-Type': 'application/json' };
  if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
  return headers;
}

function verified(req) {
  const ts = req.get('X-Timestamp');
  const sig = req.get('X-Signature') || '';
  if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = 'sha256=' + crypto.createHmac('sha256', process.env.DC_SIGNING_SECRET)
    .update(ts + '.').update(req.body).digest('hex');
  return sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}

async function canText(contactId) {
  const res = await fetch(`${API}/contact/public/contacts/${contactId}/consent`, { headers: apiHeaders() });
  if (!res.ok) return false;
  const { data } = await res.json();
  const latest = data.find(r => ['sms_optin', 'sms_optin_confirmed', 'sms_optout'].includes(r.consent_type));
  return Boolean(latest) && latest.consent_type !== 'sms_optout' && latest.consent_status === 'granted';
}

async function handle({ data }) {
  const code = data.reason_code;
  if (code === 0) return console.log('sent', data.drop_id);
  if ((code === 4001 || code === 4002) && data.contact_id && await canText(data.contact_id)) {
    await fetch(`${API}/sms`, {
      method: 'POST',
      headers: apiHeaders(data.drop_id),
      body: JSON.stringify({
        contact_id: data.contact_id,
        phone_line_id: process.env.DC_PHONE_LINE_ID,
        body: 'We tried to leave you a voicemail. Reply here if texting is easier.'
      })
    });
    return;
  }
  if (code === 4005 || code === 4006) return console.log('retry later', data.contact_id);
  if (NOT_ALLOWED.includes(code)) return console.log('stop contacting', data.contact_id);
  if (code >= 3000 && code < 4000) return console.error('fix setup', code, data.reason);
}

const app = express();
app.post('/webhooks/dropcowboy', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verified(req)) return res.sendStatus(401);
  res.sendStatus(200);
  handle(JSON.parse(req.body)).catch(err => console.error(err));
});
app.listen(3000);
Python (Flask)
import hashlib, hmac, os, time
import requests
from flask import Flask, request, abort

API = "https://api-v2.dropcowboy.com"
HEADERS = {"x-key": os.environ["DC_KEY"], "x-secret": os.environ["DC_SECRET"]}
NOT_ALLOWED = {4016, 4017, 6005, 6011}
app = Flask(__name__)

def verified(raw, headers):
    ts, sig = headers.get("X-Timestamp", ""), headers.get("X-Signature", "")
    if not ts or abs(time.time() - int(ts)) > 300:
        return False
    expected = "sha256=" + hmac.new(os.environ["DC_SIGNING_SECRET"].encode(),
                                    ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(sig, expected)

def can_text(contact_id):
    res = requests.get(f"{API}/contact/public/contacts/{contact_id}/consent", headers=HEADERS, timeout=3)
    if not res.ok:
        return False
    types = ("sms_optin", "sms_optin_confirmed", "sms_optout")
    latest = next((r for r in res.json()["data"] if r["consent_type"] in types), None)
    return bool(latest) and latest["consent_type"] != "sms_optout" and latest["consent_status"] == "granted"

@app.post("/webhooks/dropcowboy")
def webhook():
    if not verified(request.get_data(), request.headers):
        abort(401)
    data = request.get_json()["data"]
    code = data["reason_code"]
    if code == 0:
        print("sent", data["drop_id"])
    elif code in (4001, 4002) and data.get("contact_id") and can_text(data["contact_id"]):
        requests.post(f"{API}/sms", timeout=3,
                      headers={**HEADERS, "Idempotency-Key": data["drop_id"]},
                      json={"contact_id": data["contact_id"],
                            "phone_line_id": os.environ["DC_PHONE_LINE_ID"],
                            "body": "We tried to leave you a voicemail. Reply here if texting is easier."})
    elif code in (4005, 4006):
        print("retry later", data["contact_id"])
    elif code in NOT_ALLOWED:
        print("stop contacting", data["contact_id"])
    elif 3000 <= code < 4000:
        print("fix setup", code, data["reason"])
    return "", 200