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 202 means accepted, not delivered. The result of each voicemail arrives on the contact.rvm.status webhook.

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 to X-Signature in constant time. It rejects a timestamp more than 300 seconds old.
  • DC_WEBHOOK_SECRET is 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 401 with a code, 503 with no secret set, 400 for a body that is not a JSON object, and 200 otherwise. A repeated event_id gets 200 without 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 2xx within 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_id with a unique index in your database. Every try of a delivery carries the same event_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 to DC_WEBHOOK_SECRET right away.
  • Treat callback_url as a hint, because anyone can post to it.

Next steps


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.

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 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)
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)
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";
}
Send a test callback
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
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"
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"]
  }'
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"