Skip to content

Guides

Webhooks

We call your URL whenever something happens - a completed call, a failed attempt, a sent SMS. Set it once (PUT /v1/webhook, see the REST API reference) and you're done.

Events

Five events, each with its own shape for the data field:

  • call.completed - the call ended, whether it had an agent or not. Agent calls also carry a transcript (transcript), a summary (summary), and captured data (data).
  • call.failed - the voice platform rejected the call outright, or the network failed. A call that just wasn't picked up does NOT belong here - that has its own event below.
  • call.no_answer - an outbound call rang and the callee didn't pick up.
  • call.missed - an inbound call went unanswered (a missed call).
  • message.sent - the SMS was sent successfully.

You choose which of these to subscribe to with the events field in PUT /v1/webhook. One counter-intuitive detail: an empty array means all of them, not none - and that stays true for events added in the future too. To actually turn off delivery, delete the webhook in the portal.

Every event arrives as a POST with this envelope:

call.completed

json
{
  "event": "call.completed",
  "ts": 1756111640000,
  "data": {
    "id": "c_8f2ac1d4e5b0",
    "direction": "in",
    "from": "+420777123456",
    "to": "+420601234567",
    "status": "completed",
    "durationSecs": 47,
    "priceHal": 236,
    "answeredBy": "human",
    "endReason": "completed",
    "transcript": [
      { "role": "agent", "message": "Hi, this is cafe Nula, how can I help you?" },
      { "role": "caller", "message": "Hi, I would like to order two lattes to go." },
      { "role": "agent", "message": "Sure, two lattes for pickup, they will be ready in fifteen minutes." }
    ],
    "summary": "The caller ordered two lattes to go, pickup in 15 minutes.",
    "data": {
      "name": "Jane Smith",
      "coffee_count": 2,
      "urgency": "normal"
    }
  }
}

The nested data.data (yes, twice - the outer data is the envelope every event shares) is the data the agent captured from the call according to its dataFields - you choose the keys, see Voice agent. A null value means it wasn't mentioned in the call. If the agent has no fields configured at all, the data key isn't in the body at all - same as transcript and summary for a call without an agent.

The recording doesn't travel in the webhook body

Audio isn't sent with the webhook, and neither is the hasRecording flag. When you want the recording, reach for GET /v1/calls/{id} (tells you hasRecording) and download the MP3 itself from GET /v1/calls/{id}/recording. We keep recordings for 90 days after the call.

call.failed

json
{
  "event": "call.failed",
  "ts": 1756111640000,
  "data": {
    "id": "c_9d4e2b7f1a63",
    "direction": "out",
    "from": "+420601234567",
    "to": "+420777998877",
    "reason": "rejected"
  }
}

call.no_answer

call.missed (a missed INBOUND call) has the same shape - only the event name, direction, and status differ.

json
{
  "event": "call.no_answer",
  "ts": 1756111640000,
  "data": {
    "id": "c_3b7e1c9a4f20",
    "direction": "out",
    "from": "+420601234567",
    "to": "+420777998877",
    "status": "no_answer",
    "durationSecs": 0,
    "priceHal": 0,
    "answeredBy": "unknown",
    "endReason": "no_answer"
  }
}

message.sent

json
{
  "event": "message.sent",
  "ts": 1756111640000,
  "data": {
    "id": "msg_7c1f9a2e",
    "to": "+420777123456",
    "status": "sent",
    "priceHal": 136
  }
}

Signature verification

Every request carries a Volai-Signature header shaped like t=<unix>,v1=<hex>, where v1 = HMAC_SHA256(secret, "{t}.{rawBody}") and secret is the one you got from PUT /v1/webhook. Never process the body before you verify the signature - otherwise anyone can post anything to your URL.

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

function verifyVolaiSignature(
  rawBody: string,
  signatureHeader: string | null,
  secret: string,
  toleranceSecs = 300,
): boolean {
  if (!signatureHeader) return false;

  const parts = new Map<string, string>();
  for (const piece of signatureHeader.split(",")) {
    const idx = piece.indexOf("=");
    if (idx > 0) parts.set(piece.slice(0, idx), piece.slice(idx + 1));
  }

  const t = parts.get("t");
  const v1 = parts.get("v1");
  if (!t || !v1) return false;

  const age = Math.abs(Date.now() / 1000 - Number(t));
  if (!Number.isFinite(age) || age > toleranceSecs) return false;

  const expected = createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(v1, "utf8");
  return a.length === b.length && timingSafeEqual(a, b);
}

// Next.js route handler - rawBody MUST be exactly what came over the wire (request.text()), not JSON.parse and back to a string.
export async function POST(request: Request) {
  const rawBody = await request.text();
  const valid = verifyVolaiSignature(
    rawBody,
    request.headers.get("volai-signature"),
    process.env.VOLAI_WEBHOOK_SECRET!,
  );
  if (!valid) return new Response("invalid signature", { status: 401 });

  const event = JSON.parse(rawBody);
  // ... background processing, see Tips below
  void event;

  return new Response("ok", { status: 200 });
}
python
import hashlib
import hmac
import time

from flask import Flask, request

app = Flask(__name__)
WEBHOOK_SECRET = "whsec_..."


def verify_volai_signature(raw_body, signature_header, secret, tolerance_secs=300):
    if not signature_header:
        return False

    parts = {}
    for piece in signature_header.split(","):
        if "=" in piece:
            key, value = piece.split("=", 1)
            parts[key] = value

    t = parts.get("t")
    v1 = parts.get("v1")
    if not t or not v1:
        return False

    if abs(time.time() - float(t)) > tolerance_secs:
        return False

    payload = f"{t}.{raw_body}".encode("utf-8")
    expected = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()

    return hmac.compare_digest(expected, v1)


@app.route("/webhooks/volai", methods=["POST"])
def volai_webhook():
    raw_body = request.get_data(as_text=True)
    signature = request.headers.get("Volai-Signature", "")

    if not verify_volai_signature(raw_body, signature, WEBHOOK_SECRET):
        return "invalid signature", 401

    event = request.get_json()
    # ... background processing, see Tips below
    del event

    return "ok", 200

Retries on failure

If your endpoint doesn't respond with a success status (2xx), volai tries twice more - 3 attempts total, with a 3-second pause between them. If all three fail, the event is logged and not sent again - there's no queue or error store in the MVP that you could reach into yourself.

The webhook isn't the only source of truth

A webhook only carries a summary - for anything that really matters (billing, audits, the exact text of a message), pull the full record from the REST API instead: GET /v1/calls/{id} and GET /v1/messages/{id}, whenever you need it.

Tips

  • Respond with 200 as fast as possible - verify the signature and store the ID right away, and push heavy work (emails, recalculations, calls to other APIs) to the background.
  • The endpoint must be publicly reachable over HTTPS - localhost and self-signed certificates don't work.
  • Use data.id (the call or message ID) to filter out duplicates, in case the same event happens to arrive more than once.
  • Test locally through a tunnel (ngrok, for example) and temporarily point the URL in PUT /v1/webhook at its address.