API reference / Guides
Receive the result of a send
A send route answers 202 before anything is sent, so the result of each
send reaches you on another channel. This guide builds a small web server
that receives those results, verifies webhook signatures, and prints each
result. It takes about 15 minutes, and you can test it without sending a
voicemail.
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.
Before you start
| You need | Details | Where to get it |
|---|---|---|
| A plan | Any plan. Retail and BYOC accounts get results the same way. | Quickstart |
| API key scopes | webhooks:write to create webhooks, webhooks:read to read signing secrets, rvm:send to send |
Authentication |
| A runtime | Node.js 18 or later with Express 4, Python 3.9 or later with Flask, or the .NET 8 SDK | Your package manager |
| A public HTTPS URL | An address on a private network, such as localhost, is never called. A tunnel works while you build. |
Step 3 |
| A voicemail to send | One test send to a number you own | Retail guide or BYOC guide |
Pick a channel
callback_url |
Signed webhook | |
|---|---|---|
| Signed | No | Yes, HMAC-SHA256 in X-Signature |
| Retried | No. One attempt, 10-second timeout | Yes. Up to 3 tries on 408, 429, 5xx and timeouts |
Carries your foreign_id |
Yes | No |
| Use it for | Tying a result to your own record while you build | Anything you act on |
Act on the webhook and treat the callback as a convenience. You can use both on one send. See callback_url.
1. Run a receiver
One program with two routes: /callbacks/dropcowboy takes the unsigned
callback, and /webhooks/dropcowboy verifies the signature before it trusts
anything. Both read the raw request bytes, because the signature covers the
exact bytes Drop Cowboy® sent.
Node.js (Express)
Run npm install express@4, save this as receiver.js, then start it with
DC_WEBHOOK_SECRET=local-test-secret node receiver.js.
const express = require('express');
const crypto = require('crypto');
const SECRETS = (process.env.DC_WEBHOOK_SECRET || '').split(',').map((s) => s.trim()).filter(Boolean);
const seenEventIds = new Set();
const raw = express.raw({ type: '*/*', limit: '1mb' });
const app = express();
function bytesOf(req) {
return Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
}
function parseObject(bytes) {
try {
const value = JSON.parse(bytes.toString('utf8'));
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
} catch (err) {
return null;
}
}
function signatureProblem(bytes, headers) {
const signature = headers['x-signature'];
const timestamp = headers['x-timestamp'];
if (!signature || !timestamp) return 'missing_signature';
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return 'stale_timestamp';
const version = headers['x-signature-version'];
if (version && version !== 'v1') return 'invalid_signature';
const provided = Buffer.from(signature);
let matched = false;
for (const secret of SECRETS) {
const expected = Buffer.from('sha256=' + crypto.createHmac('sha256', secret)
.update(timestamp + '.').update(bytes).digest('hex'));
if (provided.length === expected.length && crypto.timingSafeEqual(provided, expected)) matched = true;
}
return matched ? null : 'invalid_signature';
}
app.post('/callbacks/dropcowboy', raw, (req, res) => {
const result = parseObject(bytesOf(req));
if (!result) return res.status(400).json({ error: 'invalid_json' });
console.log('callback', result.foreign_id, result.status, result.reason_code, result.reason);
res.status(200).json({ received: true });
});
app.post('/webhooks/dropcowboy', raw, (req, res) => {
if (SECRETS.length === 0) return res.status(503).json({ error: 'no_signing_secret' });
const bytes = bytesOf(req);
const problem = signatureProblem(bytes, req.headers);
if (problem) return res.status(401).json({ error: problem });
const event = parseObject(bytes);
if (!event) return res.status(400).json({ error: 'invalid_json' });
const eventId = event.event_id || req.get('X-Event-Id');
if (eventId && seenEventIds.has(eventId)) return res.status(200).json({ received: true, duplicate: true });
if (eventId) seenEventIds.add(eventId);
const data = event.data || {};
if (event.event === 'contact.rvm.status') {
console.log('status', data.to, data.status, data.reason_code, data.reason);
} else if (event.event === 'contact.rvm.receipt') {
console.log('receipt', data.to, data.proof_of_delivery_url);
}
res.status(200).json({ received: true });
});
app.listen(process.env.PORT || 3000);
Python (Flask)
Run pip install flask, save this as receiver.py, then start it with
DC_WEBHOOK_SECRET=local-test-secret python3 receiver.py.
import hashlib
import hmac
import json
import os
import re
import time
from flask import Flask, jsonify, request
SECRETS = [s.strip() for s in os.environ.get("DC_WEBHOOK_SECRET", "").split(",") if s.strip()]
seen_event_ids = set()
app = Flask(__name__)
def parse_object(raw_body):
try:
value = json.loads(raw_body)
except ValueError:
return None
return value if isinstance(value, dict) else None
def signature_problem(raw_body, headers):
signature = headers.get("X-Signature", "")
timestamp = headers.get("X-Timestamp", "")
if not signature or not timestamp:
return "missing_signature"
if not re.fullmatch(r"[0-9]+", timestamp) or abs(time.time() - int(timestamp)) > 300:
return "stale_timestamp"
version = headers.get("X-Signature-Version")
if version and version != "v1":
return "invalid_signature"
matched = False
for secret in SECRETS:
digest = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
if hmac.compare_digest(signature.encode(), ("sha256=" + digest).encode()):
matched = True
return None if matched else "invalid_signature"
@app.post("/callbacks/dropcowboy")
def callback():
result = parse_object(request.get_data())
if result is None:
return jsonify(error="invalid_json"), 400
print("callback", result.get("foreign_id"), result.get("status"), result.get("reason_code"), result.get("reason"))
return jsonify(received=True)
@app.post("/webhooks/dropcowboy")
def webhook():
if not SECRETS:
return jsonify(error="no_signing_secret"), 503
raw_body = request.get_data()
problem = signature_problem(raw_body, request.headers)
if problem:
return jsonify(error=problem), 401
event = parse_object(raw_body)
if event is None:
return jsonify(error="invalid_json"), 400
event_id = event.get("event_id") or request.headers.get("X-Event-Id")
if event_id and event_id in seen_event_ids:
return jsonify(received=True, duplicate=True)
if event_id:
seen_event_ids.add(event_id)
data = event.get("data") or {}
if event.get("event") == "contact.rvm.status":
print("status", data.get("to"), data.get("status"), data.get("reason_code"), data.get("reason"))
elif event.get("event") == "contact.rvm.receipt":
print("receipt", data.get("to"), data.get("proof_of_delivery_url"))
return jsonify(received=True)
if __name__ == "__main__":
app.run(port=int(os.environ.get("PORT", "3000")))
C# (ASP.NET Core)
Run dotnet new web -o Receiver, replace Program.cs with this, then start
it from that folder with DC_WEBHOOK_SECRET=local-test-secret dotnet run. It
listens on port 3000.
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
var secrets = (Environment.GetEnvironmentVariable("DC_WEBHOOK_SECRET") ?? "")
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var seenEventIds = new HashSet<string>();
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(options => options.ListenLocalhost(3000));
var app = builder.Build();
app.MapPost("/callbacks/dropcowboy", async (HttpRequest request) =>
{
var result = ParseObject(await ReadBytes(request));
if (result is null) return Results.Json(new { error = "invalid_json" }, statusCode: 400);
Console.WriteLine($"callback {result["foreign_id"]} {result["status"]} {result["reason_code"]} {result["reason"]}");
return Results.Json(new { received = true });
});
app.MapPost("/webhooks/dropcowboy", async (HttpRequest request) =>
{
if (secrets.Length == 0) return Results.Json(new { error = "no_signing_secret" }, statusCode: 503);
var bytes = await ReadBytes(request);
var problem = SignatureProblem(bytes, request.Headers);
if (problem is not null) return Results.Json(new { error = problem }, statusCode: 401);
var evt = ParseObject(bytes);
if (evt is null) return Results.Json(new { error = "invalid_json" }, statusCode: 400);
var eventId = evt["event_id"]?.ToString() ?? request.Headers["X-Event-Id"].ToString();
if (eventId.Length > 0)
{
bool firstTime;
lock (seenEventIds) { firstTime = seenEventIds.Add(eventId); }
if (!firstTime) return Results.Json(new { received = true, duplicate = true });
}
var data = evt["data"];
if (evt["event"]?.ToString() == "contact.rvm.status")
Console.WriteLine($"status {data?["to"]} {data?["status"]} {data?["reason_code"]} {data?["reason"]}");
else if (evt["event"]?.ToString() == "contact.rvm.receipt")
Console.WriteLine($"receipt {data?["to"]} {data?["proof_of_delivery_url"]}");
return Results.Json(new { received = true });
});
app.Run();
static async Task<byte[]> ReadBytes(HttpRequest request)
{
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer);
return buffer.ToArray();
}
static JsonObject? ParseObject(byte[] bytes)
{
try { return JsonNode.Parse(bytes) as JsonObject; }
catch (JsonException) { return null; }
}
string? SignatureProblem(byte[] bytes, IHeaderDictionary headers)
{
var signature = headers["X-Signature"].ToString();
var timestamp = headers["X-Timestamp"].ToString();
if (signature.Length == 0 || timestamp.Length == 0) return "missing_signature";
if (!long.TryParse(timestamp, NumberStyles.None, CultureInfo.InvariantCulture, out var signedAt)
|| Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - signedAt) > 300) return "stale_timestamp";
var version = headers["X-Signature-Version"].ToString();
if (version.Length > 0 && version != "v1") return "invalid_signature";
var message = Encoding.UTF8.GetBytes(timestamp + ".").Concat(bytes).ToArray();
var provided = Encoding.UTF8.GetBytes(signature);
var matched = false;
foreach (var secret in secrets)
{
var digest = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), message);
var expected = Encoding.UTF8.GetBytes("sha256=" + Convert.ToHexString(digest).ToLowerInvariant());
if (CryptographicOperations.FixedTimeEquals(expected, provided)) matched = true;
}
return matched ? null : "invalid_signature";
}
How the webhook route decides:
- It signs
X-Timestamp, a dot and the raw body with HMAC-SHA256, keyed with the signing secret, and compares the result toX-Signaturein constant time. It rejects a timestamp more than 300 seconds old. DC_WEBHOOK_SECRETis a comma-separated list. Each webhook has its own secret, so the receiver accepts a delivery signed with any of them. That also lets two secrets overlap while you rotate.- It answers
401with a code,503with no secret set,400for a body that is not a JSON object, and200otherwise. A repeatedevent_idgets200without the work running twice.
2. Test it without Drop Cowboy
Send a test callback
The receiver prints the foreign_id and status.
curl -i -X POST localhost:3000/callbacks/dropcowboy \
-H "Content-Type: application/json" \
-d '{"drop_id":"b3e7a1c9-8d5f-4b2e-9a6c-1f4d7b3e8a52","phone_number":"+13125550142","status":"success","reason":"","reason_code":0,"foreign_id":"7c1e5a93-2d4b-4f68-a0b9-3e6d8c1f5a27"}'
Send a signed test webhook
Sign a webhook yourself with openssl, the way Drop Cowboy does, using the
secret you started the receiver with.
SECRET="local-test-secret"
BODY='{"event_id":"2694f968-93fd-44ca-9b92-2110ed1ee61e","event":"contact.rvm.status","event_at":1774041912000,"data":{"contact_id":"5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d","drop_id":"b3e7a1c9-8d5f-4b2e-9a6c-1f4d7b3e8a52","campaign_type":"rvm","status":"success","reason":"","reason_code":0,"to":"+13125550142","from":"+12125550100"}}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -i -X POST localhost:3000/webhooks/dropcowboy \
-H "Content-Type: application/json" \
-H "X-Timestamp: $TS" -H "X-Signature: sha256=$SIG" \
--data-binary "$BODY"
Expect 200 and {"received":true}. Run the curl again and the answer
adds "duplicate":true. Change one character of $BODY after signing and the
answer is 401 with invalid_signature.
3. Create the webhook
Point a tunnel at port 3000, for example ngrok http 3000, and copy the
https address it prints. This guide calls it https://receiver.example.com.
One webhook can receive several event types: contact.rvm.status for the
result of each send, and contact.rvm.receipt for the proof-of-delivery link.
Create it with curl
curl -X POST https://api-v2.dropcowboy.com/register/public/webhooks \
-H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET" \
-H "Content-Type: application/json" \
-d '{
"name": "Voicemail results",
"hook_url": "https://receiver.example.com/webhooks/dropcowboy",
"event_types": ["contact.rvm.status", "contact.rvm.receipt"]
}'
The 201 response carries webhook_id, event_types and the webhook's own
signing_secret. An event name that is not on GET /register/public/events
is rejected with 400. To change the URL or events later, send
PUT /register/public/webhooks/{id} with only what changes; the
webhook_id and secret stay the same. See
Update a webhook.
4. Load the signing secrets
Read the secret of every webhook, set them as a comma-separated
DC_WEBHOOK_SECRET, and restart the receiver. The call returns one entry per
webhook.
Read secrets with curl
curl https://api-v2.dropcowboy.com/register/public/account/webhook-signing-secret \
-H "x-key: $DC_KEY" -H "x-secret: $DC_SECRET"
Five 401 answers in a row pause the endpoint, so load every secret before
you send. See Endpoint pauses.
5. Send and read the result
Add foreign_id (your own reference, up to 256 characters, echoed back and
nothing more) and callback_url (a public address that gets one unsigned
POST per send) to a send, as in the retail guide
or BYOC guide. The callback looks like this
(trimmed):
{
"drop_id": "b3e7a1c9-8d5f-4b2e-9a6c-1f4d7b3e8a52",
"contact_id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d",
"phone_number": "+13125550142",
"status": "success",
"reason": "",
"reason_code": 0,
"foreign_id": "7c1e5a93-2d4b-4f68-a0b9-3e6d8c1f5a27"
}
The contact.rvm.status webhook carries event_id, event, event_at, and
a data object with to, from, status, reason, reason_code,
contact_id and drop_id, as in the test body above.
Status webhooks do not carry foreign_id. Match a webhook to your record on
to, or on contact_id when you send to a contact. The callback body carries
drop_id and contact_id as well, so save them next to your foreign_id
when the callback arrives. A send that fails before it starts has no
drop_id. The 202 response message_id does not appear on later results.
What to do with a result
Branch on reason_code. 0 means the voicemail was left, and
contact.rvm.receipt adds a proof-of-delivery link.
4016, 4017, 6005 and 6011 mean stop contacting that person. The
retail guide lists the common codes
with what to do, and Outcomes lists every one.
If it does not work
| You see | Likely cause | Fix |
|---|---|---|
202 but no result |
The delivery rules are checked after the 202 |
Set a callback_url. See I got a 202 but nothing happened. |
Callback shows 3007, no webhook |
Wrong key, secret or scope on the send | Fix the credentials. 3007 never fires a webhook. |
3019 |
callback_url is not a URL |
Pass a full https:// address. |
401 invalid_signature |
The body was parsed before it was verified, or the secret is stale | Verify the raw bytes. Read the secrets again. |
401 stale_timestamp |
Clock skew over 5 minutes, or the header read as milliseconds | Sync the clock. The header is in seconds. |
| No webhook at all | No webhook covers the event, or the endpoint is paused | See No webhook arrives. |
Production checklist
- Verify the signature on the raw bytes, and reject timestamps older than 5 minutes.
- Answer
2xxwithin 5 seconds and do the work from your own queue. A failing endpoint is paused, and events during a pause are dropped. - Deduplicate on
event_idwith a unique index in your database. Every try of a delivery carries the sameevent_id. - Keep signing secrets in a vault, not in code, and rotate one with
POST /register/public/webhooks/{id}/rotate-secret. The old secret stops working at once and retries are signed with the new one, so add the new secret toDC_WEBHOOK_SECRETright away. - Treat
callback_urlas a hint, because anyone can post to it.
Next steps
- Send a ringless voicemail on a retail plan
- Send a ringless voicemail from your own caller ID (BYOC)
- Choose the caller ID automatically with phone lines (BYOC)
- Send a ringless voicemail with text to speech
- Webhooks
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.
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.