API reference / Outreach
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.