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: call.started, call.completed, call.failed, call.insights.ready, call.insights.failed, call.handoff, transfer.started, transfer.completed, transfer.failed, chat.message.received, chat.reply.sent, chat.reply.failed, chat.channel.attention, webhook.ping, and the wildcards call.*, transfer.*, chat.*, webhook.*, *. A new endpoint subscribes to call.* unless you choose otherwise. 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. A dead letter keeps its event body for 120 days (so it can be delivered again), never a signing secret or transcript text; a delivered record is deleted after 7 days.

Call events#

When a voice session's audio connects, its project's subscribed endpoints get call.started (once per session; same body, status active, duration 0). Every voice session that ends sends call.completed (it connected) or call.failed (it never connected: no answer, busy, refused, or an error) to each active endpoint of its project that subscribed, once per session. The body is { "event", "data" }; data carries callId, status, direction, endedReason, duration (the seconds the bill rounds up), costNanoUsd (what the session cost, in integer nano-USD: 5000000 is $0.005), currency, startedAt, endedAt, to, from, carrierCallId, agentId, agentVersion, your own metadata, and fallbackUsed: true when the agent's backup brain answered any part of the call (always present, false for an agent without one; the detail is the session's timings.fallback). The call.insights.* events carry fallbackUsed too. call.completed and call.failed also carry handoffs: the call's hand-off segments, who held the call from which second to which and why (null on a call whose agent never offered a hand-off). call.started, call.completed and call.failed, and an agent's own webhook, also carry zeroRetention: true when the call ran under its project's zero retention (always present, false otherwise). Then nothing of the conversation was kept: transcript and recordingUrl are null even on an agent webhook that asked for them, endedReason is a code (agent_ended when the agent hung up), and the call sends no call.insights.* event. The same call's transfer and hand-off events carry reason: null, and a zero-retention project's chat events carry text: null. costPaise is an older field and is 0 on USD sessions; read costNanoUsd. Project endpoints never receive transcript text or recording links; those go only to an agent's own webhook, and only when you switched them on for that agent.

New keys may appear in an event's data as the platform grows, like handoffs on call.completed: ignore the keys you do not know, rather than refusing the event.

Hand-off events#

When an agent hands a call to another agent of the project (Hand the call to another agent), endpoints subscribed to call.handoff, call.* or * get call.handoff, once for each switch. It is the call's own event, so call.* includes it, and it is sent only for agents whose hand-offs you switched on. An agent's own webhook_url never receives it.

data carries callId, fromAgentId, fromAgentName, toAgentId, toAgentName, toAgentVersion, destination (the destination's name), reason (the agent's own words, at most 200 characters), handoff (how many switches the call has made, this one included), atSecond (on the call's billed clock) and returned: true when the receiving agent did not answer in time and the call went straight back to the agent it came from (then destination is null).

Transfer events#

When an agent hands a phone call to a person (Transfer to a person), endpoints subscribed to transfer.* or * get transfer.started as it begins, then transfer.completed when the phone company accepted it or transfer.failed when it did not. call.* does not include them, and an agent's own webhook_url never receives them.

data carries event, occurredAt, callId, agentId, destination (the destination's name, never its number), reason (the agent's own words, at most 200 characters) and attempt (1 or 2). transfer.failed adds error, a sentence, and ambiguous: true when the phone company did not answer in time, so the transfer may still have happened. A blind transfer cannot tell whether the person answered.

Chat events#

When someone writes to an agent (Chat agents), endpoints subscribed to chat.* or * get chat.message.received, then chat.reply.sent once the reply was delivered and charged, or chat.reply.failed when it was not sent or may not have arrived. A failed reply is never charged. call.* does not include them, and an agent's own webhook_url never receives them.

data carries conversation_id, message_id, channel_id (null for the chat API), channel (api, telegram, whatsapp or instagram), agent_id, external_id, role, created_at and text: the words only when the agent's webhook_include_transcript is on, null otherwise, and never on a failed reply. The two reply events add status, agent_version, currency, cost_minor (in the wallet's own unit; "0" when not charged) and parts (the channel messages it took). chat.reply.failed adds error (a sentence), error_code (window_closed when Meta's 24-hour window had passed) and ambiguous: true when we could not tell whether the channel received it, so it was not sent again.

chat.channel.attention is about a channel, not a message: data carries channel_id, channel, status, error_code (token_refused when the provider refused the token, which also turns the channel error, or token_expiring when an Instagram token could not be renewed and runs out within a week), error (a sentence), account_id, token_expires_at and created_at. It is sent at most once an hour for the same channel and reason.

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.