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 2xx within 5 seconds.
  • Subscribe once per event type. To receive five event types, make five calls. They can share one URL; branch on event in 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_at and signing_secret_created_at. They don't include the secret; fetch it from GET /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 example 401 from 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.
Email 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_id on the envelope, shared by every event from the same operation (an import emits contact.created, contact.list.added and contact.import.complete with one operation_id);
  • data.total_count and up to 25 contacts in data.contacts;
  • data.truncated, data.next_cursor and, for list events, data.more_via, a ready-made URL for GET /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
Email 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 2xx within 5 seconds; process from a job queue.
  • Deduplicate on event_id.
  • Re-read the signing secret after you resubscribe to an event type.
  • Treat 202 as 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.

Code samples

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

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