DialBots

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?} (scope webhooks:write): Create an endpoint. The response contains secret (whsec_...) once.
  • GET /v1/webhooks, GET /v1/webhooks/{id} (scope webhooks:read): List or read (never returns the secret).
  • PATCH /v1/webhooks/{id} {url?, events?, description?, active?} (scope webhooks:write): Edit or pause.
  • DELETE /v1/webhooks/{id} (scope webhooks:write): Remove.
  • POST /v1/webhooks/{id}/rotate-secret (scope webhooks: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 (scope webhooks:write): Sends a signed ping now (not retried) and returns {ok, status_code, error, latency_ms}.
  • GET /v1/webhooks/{id}/deliveries (scope webhooks:read): Delivery log (status: pending, success, exhausted, failed).
  • POST /v1/webhooks/deliveries/{id}/redeliver (scope webhooks: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

HeaderValue
DialBots-Signaturet=<unix seconds>,v1=<hex>
DialBots-Eventthe event name, e.g. call.completed
DialBots-Deliverythe delivery id (same as id in the body); use it to de-duplicate
Content-Typeapplication/json
User-AgentDialBots-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>"
  • secret is the whole string you were given, including the whsec_ prefix.
  • The timestamp t is generated again for every attempt, so a retry carries a fresh signature.
  • Tolerance: 300 seconds. Reject a request whose t differs 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 v1

Delivery and retries

  • Your endpoint must answer 2xx within 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 can redeliver it from the log).
  • Delivery is at least once. Use DialBots-Delivery to ignore repeats. Order is not guaranteed.
  • Respond quickly and process asynchronously.