Developer platform · v2

Delivery

View as MarkdownAll docs

Webhooks

Endpoints#

Console: /console/v2/projects/{project_id}/webhooks. Machine: /v2/projects/{project_id}/webhooks with webhook:read to list and webhook:write to create, test or disable (the path project must match the key's project).

POST with { "name", "url", "event_types" } creates an endpoint. The URL must be public HTTPS on port 443 with no credentials or fragment. Event types are a closed set for this voice release: call.completed, call.failed, webhook.ping, and the wildcards call.*, webhook.*, *. The 201 contains signing_secret once; no route returns it again. Up to 10 endpoints per project.

GET lists endpoints (include_disabled=true to see disabled ones, which keep secret_hint and revision). DELETE .../{endpoint_id} disables the endpoint, bumps its revision, and cancels queued deliveries. POST .../{endpoint_id}/test queues a signed webhook.ping and returns 202 with delivery_id and queued — recorded, not yet sent. GET .../dead-letters lists deliveries that exhausted their retries with event_type, endpoint_id, attempts, max_attempts, and last_error; payloads are not retained on terminal rows.

Delivery and signature#

Every delivery is an HTTP POST with a JSON body and these headers:

HeaderValue
X-Webhook-Eventthe event name, e.g. call.completed
X-Webhook-Timestampunix seconds when the delivery was signed
X-Webhook-Signaturet=<timestamp>,v1=<hex HMAC-SHA256>
X-Webhook-Deliverythe delivery id; deduplicate retries on it
X-Dvaarik-Event, X-Dvaarik-Signaturelegacy aliases; sha256=<hex> over the body alone — do not build new verifiers on them

The signed bytes are "<timestamp>.<raw body>" and the key is the endpoint's signing secret. Verify before parsing, compare in constant time, and reject a timestamp outside your tolerance window (five minutes is usual) to stop replays. Respond 2xx promptly; anything else is retried with backoff and then dead-lettered.

js
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyDvaarikWebhook(req, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(String(req.get("X-Webhook-Signature") ?? "").split(",").map((kv) => kv.split("=")));
  const timestamp = req.get("X-Webhook-Timestamp");
  if (!parts.t || !parts.v1 || parts.t !== timestamp) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(req.body).digest("hex");
  const a = Buffer.from(expected, "hex"), b = Buffer.from(parts.v1, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}
python
import hmac, hashlib, time

def verify_dvaarik_webhook(headers, raw_body: bytes, secret: str, tolerance_s=300) -> bool:
    parts = dict(kv.split("=", 1) for kv in headers.get("X-Webhook-Signature", "").split(",") if "=" in kv)
    timestamp = headers.get("X-Webhook-Timestamp", "")
    if not parts.get("t") or not parts.get("v1") or parts["t"] != timestamp:
        return False
    if abs(time.time() - int(timestamp)) > tolerance_s:
        return False
    expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

The agent-level webhook_url (set on the agent) is signed the same way with the agent's own webhook_secret.

Use one project per boundary

Provider secrets stay encrypted and project API keys stay on your server.