Webhooks
DialBots webhooks
DialBots sends an HTTPS POST to your endpoint when something happens in your workspace. Every request is signed. Verify the signature on the raw request body before you parse the JSON.
Endpoints
POST /v1/webhooks {url, events[], description?}(scopewebhooks:write): Create an endpoint. The response contains secret (whsec_...) once.GET /v1/webhooks, GET /v1/webhooks/{id}(scopewebhooks:read): List or read (never returns the secret).PATCH /v1/webhooks/{id} {url?, events?, description?, active?}(scopewebhooks:write): Edit or pause.DELETE /v1/webhooks/{id}(scopewebhooks:write): Remove.POST /v1/webhooks/{id}/rotate-secret(scopewebhooks:write): New secret, returned once. The old secret stops working immediately: deploy the new one first or accept failures until you do. Deliveries are retried, and each retry is signed with the secret current at that moment.POST /v1/webhooks/{id}/test(scopewebhooks:write): Sends a signed ping now (not retried) and returns {ok, status_code, error, latency_ms}.GET /v1/webhooks/{id}/deliveries(scopewebhooks:read): Delivery log (status: pending, success, exhausted, failed).POST /v1/webhooks/deliveries/{id}/redeliver(scopewebhooks:write): Queue a delivery again.
url must be https and resolve to a public address. Private, loopback and link-local addresses are refused, and redirects are not followed.
Events
call.started, call.answered, call.completed, call.failed, agent.published, contact.created, contact.updated, contact.deleted, contact.import.completed, wallet.low_balance, wallet.topped_up. Subscribe to "*" for all of them.
The body is always this envelope:
{"id": "<delivery uuid>", "type": "call.completed", "created_at": "2026-11-01T10:00:20.123456+00:00", "data": { }}For call.*, data is the same JSON as GET /v1/calls/{id} without recording_url. For wallet.topped_up, data is {payment_id, amount, gst, total, currency, balance, invoice_id, invoice_number} (amount is what the wallet received; total what was paid).
Request headers
| Header | Value |
|---|---|
DialBots-Signature | t=<unix seconds>,v1=<hex> |
DialBots-Event | the event name, e.g. call.completed |
DialBots-Delivery | the delivery id (same as id in the body); use it to de-duplicate |
Content-Type | application/json |
User-Agent | DialBots-Webhooks/1 |
The signature
signed_payload = "<t>" + "." + <raw request body bytes>
v1 = hex( HMAC_SHA256( key = secret as UTF-8, message = signed_payload ) )
header = "t=<t>,v1=<v1>"secretis the whole string you were given, including thewhsec_prefix.- The timestamp
tis generated again for every attempt, so a retry carries a fresh signature. - Tolerance: 300 seconds. Reject a request whose
tdiffers from your clock by more than 300 s (replay protection). Keep your server clock in sync. - Compare in constant time.
Python
import hashlib
import hmac
import time
def verify_dialbots(secret: str, header: str, raw_body: bytes, tolerance_s: int = 300) -> bool:
try:
parts = dict(item.split("=", 1) for item in header.split(","))
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance_s:
return False
mac = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(f"t={t},v1={mac}", header)Flask: verify_dialbots(SECRET, request.headers["DialBots-Signature"], request.get_data()). FastAPI: await request.body() (before request.json()).
Node.js
const crypto = require("node:crypto");
function verifyDialbots(secret, header, rawBody, toleranceS = 300) {
const parts = Object.fromEntries(String(header).split(",").map((kv) => kv.split(/=(.*)/s, 2)));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceS) return false;
const mac = crypto
.createHmac("sha256", secret)
.update(Buffer.concat([Buffer.from(`${t}.`), Buffer.from(rawBody)]))
.digest("hex");
const expected = Buffer.from(`t=${t},v1=${mac}`);
const got = Buffer.from(String(header));
return expected.length === got.length && crypto.timingSafeEqual(expected, got);
}Express: use express.raw({ type: "application/json" }) on the route so req.body is the untouched Buffer, then verifyDialbots(SECRET, req.get("DialBots-Signature"), req.body). A body that went through express.json() is no longer the bytes that were signed.
cURL / openssl (to check by hand)
T=1730455200; BODY='{"id":"..."}'
printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex # compare with v1Delivery and retries
- Your endpoint must answer
2xxwithin 10 seconds. Anything else (non-2xx, timeout, connection error) is a failure. - Failed deliveries are retried with exponential backoff: 60 s, 120 s, 240 s, ... capped at 30 minutes, for up to 6 attempts in total; then the delivery is
exhausted(you canredeliverit from the log). - Delivery is at least once. Use
DialBots-Deliveryto ignore repeats. Order is not guaranteed. - Respond quickly and process asynchronously.