Guides
Webhooks
We call your URL whenever something happens - a completed call, a failed attempt, a sent or received SMS. Set it once (PUT /v1/webhook, see the REST API reference) and you're done.
Events
Six 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).durationSecsis in seconds and can be a decimal number for calls on volai's own voice engine. A call with an automated caller on the other end repeating the same message also arrives here, withendReason: "caller_loop"(price0if it lasted up to five minutes).call.failed- the call ended up instatus: "failed"- whether the voice platform rejected it outright or the network failed. It arrives for an agent on volai's engine as well as ElevenLabs, for a direct connection (bridge), for a BYO relay lease, and for a test call - exactly once, always. The body carriesreason(the technical cause) andendReason. A call that just wasn't picked up does NOT belong here - that has its own event below. A call that DID connect but ended due to a technical error in our voice engine (endReason: "engine_error") also arrives here, and its body carriestranscript/summary/durationSecs/priceHaltoo - every otherreasonomits those four fields.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 carrier accepted the SMS for sending - not a delivery receipt. The event is also sent for an SMS sent from an SMS number; thesmsNumberfield carries the SMS number it was sent from and isnullfor the shared sender SMSinfo. The delivery report (deliveryStatus) of an SMS sent from an SMS number, and a later failure (failed, after which the credit is refunded), trigger no event - read the state fromGET /v1/messages/{id}.message.received- a message arrived on your SMS number (the SMS number add-on).datacarriesid,from(the sender: a phone number in E.164, or a text name such as “Google”, at most 32 characters;unknownif the sender is missing),to(your SMS number),body,segmentsandreceivedAt(the time the message was received, a timestamp in ms). It is delivered through the retry queue, like the call events. A message that was not caught right away is added by a periodic check within about 15 minutes, and the event is then sent late, possibly after newer messages;tsin the envelope is the time the event was created (also in ms, whereast=in the signature header is in seconds). Above 300 received messages per hour on one SMS number the webhook is not sent for the further messages; they are still stored and you find them inGET /v1/messages?direction=in, which holds at most the 1,000 newest received messages per account and keeps them for 90 days. An account that the volai operator has manually suspended gets nomessage.receivedat all, and for messages received during the suspension it is not sent retroactively once the account is restored; you find them inGET /v1/messages?direction=intoo.
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
{
"event": "call.completed",
"ts": 1756111640000,
"event_id": "evt_V1StGXR8_Z5jdHi6B-myT",
"data": {
"id": "c_8f2ac1d4e5b0",
"direction": "in",
"from": "+420777123456",
"to": "+420601234567",
"status": "completed",
"durationSecs": 47,
"priceHal": 236,
"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 it from GET /v1/calls/{id}/recording - the format follows the response's content-type header: audio/mpeg (MP3) for an agent on ElevenLabs, audio/ogg today for an agent on volai's engine. We keep recordings for 90 days after the call.
call.failed
{
"event": "call.failed",
"ts": 1756111640000,
"event_id": "evt_Qp3xLk9F-2WyoTr8cNaZ1",
"data": {
"id": "c_9d4e2b7f1a63",
"direction": "out",
"from": "+420601234567",
"to": "+420777998877",
"reason": "rejected",
"endReason": "failed"
}
}call.no_answer
call.missed (a missed INBOUND call) has the same shape - only the event name, direction, and status differ.
{
"event": "call.no_answer",
"ts": 1756111640000,
"event_id": "evt_9mYbT_R2xVn7Kd4LpZaQ8",
"data": {
"id": "c_3b7e1c9a4f20",
"direction": "out",
"from": "+420601234567",
"to": "+420777998877",
"status": "no_answer",
"durationSecs": 0,
"priceHal": 0,
"answeredBy": "unknown",
"endReason": "no_answer"
}
}message.sent
{
"event": "message.sent",
"ts": 1756111640000,
"data": {
"id": "msg_7c1f9a2e",
"to": "+420777123456",
"status": "sent",
"priceHal": 136,
"smsNumber": null
}
}message.received
{
"event": "message.received",
"ts": 1756111900000,
"event_id": "evt_4kTz_P8wQmL2Xc9RvNdA1",
"data": {
"id": "msg_3d9b5e71",
"from": "+420777123456",
"to": "+420770112233",
"body": "Thanks, I will come on Friday at 10:00.",
"segments": 1,
"receivedAt": 1756111900000
}
}body is text from an outside sender. Treat it as untrusted input: do not run commands or actions from it without checking. The body carries an event_id you can use to filter out a duplicate delivery.
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.
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 });
}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", 200Retries 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. Call events (`call.completed`, `call.failed`, `call.no_answer`, `call.missed`) and `message.received` don't stop there: if all three attempts fail, the event stays queued and delivery is retried a few more times over the following minutes, up to 5 rounds total. That's why the body carries an `event_id` - use it to filter out a duplicate, in the rare case the same event arrives more than once; a repeat delivery carries the exact same body as the first one (just sent a few minutes later). `message.sent` has no queue: after 3 failed attempts it's logged and not sent again.
The test event below is the exception - it's sent only once, with no retry, so you get an answer right away.
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.
Test event
Before you wait for the first real call, verify delivery right away - POST /v1/webhook/test (see the REST API) sends one sample webhook.test event to your URL, REGARDLESS of which events you have checked in PUT /v1/webhook - this tests the deliverability of the URL and signature itself, not the subscription to specific events. At most 10 attempts per hour. The test sends only webhook.test, not the other events: you can try the shape of message.received only with a real SMS to your SMS number (see the sample above).
Delivery history
You can see the last 50 deliveries (successful, failed, and test ones) via GET /v1/webhook/deliveries (see the REST API), or right in the portal below the webhook form. The same summary is also included in GET /v1/webhook as recentDeliveries.
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 -
localhostand 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/webhookat its address.
Related
MCP server
Connect Claude Code, Codex CLI, Codex desktop, Cursor and other AI editors.
REST API
Complete reference for every endpoint: numbers, calls, SMS, SMS numbers for receiving, agents and their drafts, tools, recordings, webhooks, do-not-call list, relay, account, tasks, credit, documents, calendars and integrations.
Voice agent
System prompt, tools, handoff to a human, recordings and structured call data.