API reference / Track results
Webhooks
Webhooks push events to your server as they happen: a voicemail sent, a text
failed, a contact replied, an AI agent finished a call. They are how you learn
the result of a send, because send routes answer 202 before anything is
sent.
Manage subscriptions
| Route | Scope | Purpose |
|---|---|---|
GET /register/public/events |
webhooks:read |
Event types you can subscribe to |
POST /register/public/webhooks |
webhooks:write |
Subscribe a URL to one event type |
GET /register/public/webhooks |
webhooks:read |
List subscriptions |
GET /register/public/webhooks/{hook_type} |
webhooks:read |
One subscription |
DELETE /register/public/webhooks/{hook_type} |
webhooks:write |
Unsubscribe |
GET /register/public/account/webhook-signing-secret |
webhooks:read |
Signing secret for each subscription |
You can also manage subscriptions in the dashboard under Developers > Webhooks.
Subscribe
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 '{
"hook_type": "contact.rvm.status",
"hook_url": "https://hooks.example.com/dropcowboy"
}'
{
"data": {
"webhook_id": "5c9e3a7f-2b6d-4e1a-8f4c-9d3b7e1a5c62",
"signing_secret": "e4b1c7a9-3f2d-4e8b-9a6c-5d1f8b2e7c30"
},
"meta": { "request_id": "c2a6e8f4-3b9d-4c1e-8a7f-5d3b1e9c6a24" }
}
Store signing_secret; you need it to verify deliveries.
- Use an event name from
GET /register/public/events, exactly as listed. The subscribe call doesn't reject a misspelt name, so check it against the list. - Use a public HTTPS URL that answers
2xxwithin 5 seconds. - Subscribe once per event type. To receive five event types, make five
calls. They can share one URL; branch on
eventin the body. - Subscribing again replaces the subscription. It issues a new signing secret, so that is also how you rotate a secret or move a URL. Update your verifier at the same time.
- The path parameter on get and delete is the event type, for example
/register/public/webhooks/contact.rvm.status. Deleting succeeds even if there was no subscription. - The list and the single get return
webhook_id,hook_type,hook_url,created_atandsigning_secret_created_at. They don't include the secret; fetch it fromGET /register/public/account/webhook-signing-secret.
What a delivery looks like
Each delivery is a POST with a JSON body:
{
"event_id": "d1f3a8e2-7c4b-4f9a-9d22-9c1e2f3a4b5c",
"event": "contact.rvm.status",
"event_at": 1774041600000,
"data": {
"contact_id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d",
"drop_id": "b3e7a1c9-8d5f-4b2e-9a6c-1f4d7b3e8a52",
"campaign_id": "a9c3e8f1-4b2d-4a7c-8e9b-1c2d3e4f5a6b",
"campaign_type": "rvm",
"status": "success",
"reason": "",
"reason_code": 0,
"to": "+13125550142",
"from": "+12125550100"
}
}
| Field | Meaning |
|---|---|
event_id |
UUID for this event. The same on every attempt and for every subscription that receives it, so use it to skip duplicates. |
event |
The event type |
event_at |
When it happened, in epoch milliseconds |
operation_id |
Only on bulk events. See Bulk events. |
data |
The event payload |
For a send made through the API, campaign_id is the id of your team's API
campaign, which holds every API send. It's the same on every API send, so
match events to requests with drop_id, not campaign_id.
Status events also carry team, user, contact, list and
campaign_session objects looked up from those ids, or null when the id is
null. They exist for automations; build on the flat fields above.
Headers on every delivery:
| Header | Value |
|---|---|
X-Signature |
sha256=<hex digest> |
X-Timestamp |
Unix time in seconds when the delivery was signed |
X-Signature-Version |
v1 |
X-Event-Id |
Same as event_id |
X-Attempt |
1 for the first try, then 2, 3 |
User-Agent |
DropCowboy-Webhook/1.0 |
Verify the signature
The signature is HMAC-SHA256 over {timestamp}.{raw body}, keyed with the
subscription's signing secret.
Verify the raw bytes you received. Parsing the JSON and serializing it again can change spacing or key order, and then no signature matches.
Node.js (Express)
const express = require('express');
const crypto = require('crypto');
const app = express();
const SECRET = process.env.DROPCOWBOY_SIGNING_SECRET;
function verifyWebhook(rawBody, headers, secret) {
const signature = headers['x-signature'];
const timestamp = headers['x-timestamp'];
if (!signature || !timestamp) return false;
// Reject deliveries signed more than 5 minutes ago.
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return false;
const expected = 'sha256=' + crypto.createHmac('sha256', secret)
.update(timestamp + '.')
.update(rawBody)
.digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// express.raw keeps req.body as the exact bytes that were signed.
app.post('/webhooks/dropcowboy', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyWebhook(req.body, req.headers, SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString('utf8'));
// Queue the event for processing, then answer.
res.status(200).json({ received: true });
});
Python (Flask)
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["DROPCOWBOY_SIGNING_SECRET"].encode()
def verify_webhook(raw_body: bytes, headers) -> bool:
signature = headers.get("X-Signature", "")
timestamp = headers.get("X-Timestamp", "")
if not signature or not timestamp:
return False
if abs(time.time() - int(timestamp)) > 300:
return False
expected = "sha256=" + hmac.new(
SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
@app.post("/webhooks/dropcowboy")
def dropcowboy_webhook():
if not verify_webhook(request.get_data(), request.headers):
abort(401)
event = request.get_json()
return {"received": True}
If verification fails, see A webhook arrives but the signature doesn't verify.
Delivery and retries
Answer with any 2xx within 5 seconds. Do the real work after you
answer, from a job queue of your own.
| Your response | What happens |
|---|---|
2xx |
Delivered |
408, 429, 5xx, a timeout or a connection error |
Retried |
Any other status, for example 400, 401, 404, 410 |
Not retried |
A delivery is tried at most 3 times: immediately, about 1 minute later,
and about 5 minutes after that. Then it is dropped. A retry goes only to the
subscriptions that failed; the ones that answered 2xx aren't sent the event
again. You can still receive the same event_id more than once, so make your
handler idempotent.
URLs that resolve to private, loopback or link-local addresses are never called.
Endpoint pauses
Deliveries to an origin (scheme, host and port) pause when it keeps failing:
- 100 retriable failures in a row (timeouts,
5xx,429), or - 5 rejections in a row (other
4xx, for example401from a verifier with the wrong secret).
The first pause lasts 5 minutes; each further one lasts longer: 15, 30, then 60 minutes. A successful delivery resets the counts. Events that occur during a pause are dropped. They aren't sent when the pause ends, so fix a failing endpoint quickly and reconcile from the API afterwards.
Results of your sends
A 202 from a send route means your request was received, not that anything
was sent. The delivery rules (calling hours, the contact frequency limit,
consent and your balance) are checked after it, and the result arrives as a
status event. Delivery rules explains each
rule.
Subscribe to the event for each channel you send on:
| You send | Subscribe to |
|---|---|
Ringless voicemail (POST /rvm) |
contact.rvm.status with campaign_type: rvm |
Voice broadcast (POST /voice-broadcast) |
contact.rvm.status with campaign_type: voice_broadcast |
AI call (POST /ai-broadcast) |
contact.rvm.status with campaign_type: ai_broadcast, plus one ai_agent.* event for the call |
| Proof of delivery, for any ringless voicemail, voice broadcast or AI call a voicemail system answered, or an AI call a person answered when the agent records calls | contact.rvm.receipt, once the recording is ready. See Proof of delivery. |
Texts (POST /sms) |
contact.sms.status. campaign_type is mms for a send with media. |
contact.email.status, plus contact.email.bounce, contact.email.bounce_hard and contact.email.complaint |
|
| AI agent calls | ai_agent.call.completed, ai_agent.call.failed, ai_agent.outcome.* (see AI agents) |
| Campaigns | The channel events above, plus campaign.started, campaign.paused, campaign.completed |
Every call and text status event carries status (success or failure),
reason and reason_code. Outcomes says what each code means
and what to do.
{
"event": "contact.sms.status",
"data": {
"campaign_type": "sms",
"status": "failure",
"reason": "Too Many Attempts",
"reason_code": 4013,
"to": "+13125550142"
}
}
A 4013 status event also carries frequency_limit: the limit applied to
that send and the attempts counted. See
Contact frequency limit.
"frequency_limit": { "max_attempts": 3, "window_days": 3, "attempts_in_window": 3 }
A send that fails before anything is sent, such as 3027 (Idempotency Key
Conflict), still fires its status event, with drop_id set to null. to
is null too when the request named no number, or more numbers than one
request allows. A send with wrong credentials (3007) fires no event; it is
reported on callback_url only.
For an email sent with POST /email/public/email, contact.email.status
starts at delivered when the email goes out. If the email is blocked before
it's sent, for example by an opt-out or your email frequency limit, you get one
contact.email.status with status: failure and a 5xxx reason_code
instead, with campaign_id and session_id set to null.
Send routes also accept a callback_url, an unsigned one-shot POST per
send that echoes your foreign_id. Its body and rules are in
callback_url.
Inbound messages
contact.msg.received fires when a contact texts one of your numbers.
Replies such as STOP also fire contact.msg.opt_out.
data.sms holds the message. An MMS adds sms.media, one entry per file,
with metadata only and never a link. Fetch
GET /phone/public/sms/{sms_id} for a download link that works for 24 hours.
contact.msg.sent, which fires after a text reply, has
the same sms object. contact.sms.status carries no media.
{
"event": "contact.msg.received",
"data": {
"received_at": 1757932200000,
"sms": {
"sms_id": "8945b5d3-d4b9-435e-ab6d-a21bb5b9b628",
"sms_type": "inbound",
"from": "+13125550142",
"to": "+14155550120",
"sms_body": "Here is the photo.",
"media": [
{ "media_id": "a9c3e8f1-4b2d-4a7c-8e9b-1c2d3e4f5a6b", "content_type": "image/jpeg", "size": 184220, "width": 1024, "height": 768, "duration_sec": null, "scan_status": "pending" }
]
}
}
}
media is absent on a plain text. Inbound files are scanned for malware after
they arrive, so scan_status is usually pending on this event. It changes
to clean or infected shortly after, with no further webhook. The message
read returns a link only once a file is clean.
Bulk events
Changes that can touch many contacts at once (imports, bulk list adds,
contact.created, contact.updated, contact.list.added,
contact.list.removed, contact.pipeline.stage.entered,
contact.pipeline.stage.exited) arrive as one event per operation, not
one per contact. They carry:
operation_idon the envelope, shared by every event from the same operation (an import emitscontact.created,contact.list.addedandcontact.import.completewith oneoperation_id);data.total_countand up to 25 contacts indata.contacts;data.truncated,data.next_cursorand, for list events,data.more_via, a ready-made URL forGET /contact/public/lists/{list_id}/contacts?after_id={cursor}to page through the rest.
Event types
GET /register/public/events lists every event you can subscribe to. Only
subscribe to names on that list. The main groups:
| Group | Examples |
|---|---|
| Contacts | contact.created, contact.updated, contact.deleted, contact.disposition |
| Lists and tags | contact.list.added, contact.list.removed, contact.tag.added, contact.tag.removed |
| Consent | contact.consent.granted, contact.consent.revoked |
| Calls | contact.call.answered, contact.call.hangup, contact.call.missed, contact.call.recording.available |
| Texts | contact.sms.status, contact.msg.sent, contact.msg.received, contact.msg.opt_out |
| Voice sends | contact.rvm.status, contact.rvm.receipt, contact.voicemail.received |
contact.email.status, contact.email.received, contact.email.link_clicked, contact.email.bounce |
|
| AI agents | ai_agent.call.completed, ai_agent.call.failed, ai_agent.outcome.* |
| Campaigns | campaign.created, campaign.started, campaign.paused, campaign.completed |
| Numbers | number.provisioned, number.released, number.flagged |
| Imports and exports | contact.import.complete, contact.export.complete |
| Web chat | contact.chat.session_started, contact.chat.identified, contact.chat.received, contact.chat.conversation_closed |
| Appointments | appointment.booked, appointment.rescheduled, appointment.cancelled, appointment.completed, appointment.no_show, appointment.waitlist.* |
| Pipelines | contact.pipeline.stage.entered, contact.pipeline.stage.exited, contact.pipeline.stage.aged, contact.inactivity.threshold |
| Forms and interest | form.submitted, contact.product_interest.captured, contact.assigned |
| Dispositions and goals | disposition.completed, disposition.sale.closed, disposition.dnc.requested, goal.triggered |
| Revenue and commissions | revenue.subscription.*, revenue.refund.created, customer.trial.converted, commission.approved, commission.paid |
| Reviews | review_request.sent, review_request.rated, review_request.feedback_submitted |
| Your plan | subscription.changed, subscription.paused, subscription.cancelled, subscription.invoiced |
Detection webhooks
Detection sessions send their own webhooks, signed with your Detection API key and different headers. They are configured per session, not through these routes.
Checklist
- Verify the signature on the raw body, and reject old timestamps.
- Answer
2xxwithin 5 seconds; process from a job queue. - Deduplicate on
event_id. - Re-read the signing secret after you resubscribe to an event type.
- Treat
202as received. Wait for the status event before you report a send as sent or failed. - When a webhook doesn't arrive, see No webhook arrives.
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.