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). An answered gate ring (kind: "ring",ring.outcome: "answered") arrives here too, with one exception described under thecall.no_answersample. A call on volai's own engine (build from 15 Oct 2026 on) also carries anengineblock withprotectedUtterancesandprotectedUtterancesCount: lines of the person who answered from the greeting window that the engine dropped on purpose (a "Hello?" or their name while the agent spoke its first line) - not intranscript, the agent did not react to them; same shape as inGET /v1/calls/{id}, absent on ElevenLabs and older engine builds.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, for a gate ring (the carrier rejected the order,kind: "ring"), 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. For such a callreasonis eitherengine_stallorengine_error(an engine failure), orunfinishedwhen a restart or stop of the engine cut the call off mid-conversation (for example during a deployment). Such a call is not billed (priceHalis0), has no recording, andsummaryexplains in a short text what happened. Thetranscriptholds only what was captured up to the interruption and can be empty (it is often empty when the call record never arrived from the engine and we closed the call from the engine's call list, which carries no transcript).durationSecsis the approximate time until the interruption (at most 1,800 s when the engine could not measure it). Theengineblock with utterances dropped in the greeting window is absent for it, because the engine did not measure the greeting window for an interrupted call.call.no_answer- an outbound call rang and the callee didn't pick up. A gate ring (POST /v1/rings) that the gate declined, or that rang to the end of the requested time, arrives the same way - you recognise it bykind: "ring", see below.call.missed- an inbound call went unanswered (a missed call). It also arrives for an inbound call the voice engine turned away because every line was busy (the caller did not get through, call them back). The record of such a call hasendReason: "capacity", duration0and price0; the webhook may not carry it when the carrier reported the unanswered call first, so read it reliably from the call record (GET /v1/calls/{id}).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 or a calls and SMS number; thesmsNumberfield carries the 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) or on your calls and SMS number.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 number the message arrived on),body,segmentsandreceivedAt(the time the message was received, a timestamp in ms). It is delivered through the retry queue, like the call events. For an SMS number, 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. A calls and SMS number has no such check: the operator repeats the delivery of a message for at most an hour, so a message can be lost if receiving on our side is down for longer, and you will not find it inGET /v1/messages?direction=ineither.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 number (an SMS number or a calls and 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. For a calls and SMS number that is being released (statusreleasing), a message is stored butmessage.receivedis not sent for it. 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.
The body of every call event (call.completed, call.failed, call.no_answer, call.missed) also carries kind, the kind of call, the same as in GET /v1/calls (agent, bridge, relay, inbound, transfer, sip, ring, voice_message). For a voice message (POST /v1/voice-messages) the body holds a voiceMessage block with text, repeat and, once the call has ended, repeatPlayed (how many times the message was heard) and completed (it was read to the end); an unanswered message arrives as call.no_answer. For a gate ring the body also holds a ring block with requestedSecs, outcome and rangSecs; other calls have no ring key. Both fields are additive, so older integrations simply ignore them.
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"
},
"kind": "inbound"
}
}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",
"kind": "agent"
}
}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",
"kind": "agent"
}
}An unanswered gate ring (POST /v1/rings) also arrives as call.no_answer, with kind: "ring" and a ring block: outcome is rang (it rang to the end of the requested time) or ended_early (the call ended sooner, for example because the gate declined it, which means it opened). An answered ring (outcome: "answered") arrives as call.completed. One exception: when the carrier confirms the pickup only in its call record (CDR), after volai has already closed the ring as unanswered, only call.no_answer arrives, and the record in GET /v1/calls/{id} later changes to completed with ring.outcome: "answered" and the answered seconds billed; no second event is sent. An unknown result triggers no event until the carrier's records fill it in.
{
"event": "call.no_answer",
"ts": 1756111640000,
"event_id": "evt_Hn6vD_k3WqXy8Tz1LbMc4",
"data": {
"id": "c_x7f2k9a1b3c4",
"direction": "out",
"from": "+420601234567",
"to": "+420777123456",
"status": "no_answer",
"durationSecs": 0,
"priceHal": 25,
"endReason": "no_answer",
"kind": "ring",
"ring": { "requestedSecs": 10, "outcome": "ended_early", "rangSecs": 2 }
}
}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", 200Custom headers
When the target only accepts authenticated requests (for example a Cursor automation or your own API behind a gateway), add the key as a custom header. It is sent with every delivery and with the test event, for example Authorization: Bearer <key>. Set it with the headers field of PUT /v1/webhook, the MCP tool set_webhook, or in the portal under Connections & API in the Webhooks section.
curl -X PUT https://volai.cz/v1/webhook \
-H "Authorization: Bearer $VOLAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://api.example.com/hooks/volai", "events": ["message.received", "call.completed", "call.missed"], "headers": {"Authorization": "Bearer YOUR_TARGET_KEY"}}'- At most 5 headers, each value 1 to 1000 characters of printable ASCII (no accented letters). Names are case-insensitive and must be unique.
host,content-type,content-length,volai-signatureand hop-by-hop headers are set by the delivery itself and cannot be set. - Values are secrets:
GET /v1/webhookreturns them shortened (Bear***). If you send such a mask back, the stored value is kept. - Omitting
headerskeeps the stored ones,{}removes them. When you change the URL to a different address (another domain, scheme or port, even an addedwww.), stored headers are dropped and you enter the full values again: we do not send a key to a different address. If you send a mask instead of a value at that point, you get theinvalid_webhook_headerserror (400), the same as for a forbidden or invalid header. - When a delivery is redirected to a different address, custom headers are not sent; the
Volai-Signatureheader still is. A redirect fromexample.comtowww.example.comis a different address too, so put the final URL in the webhook.
The Volai-Signature signature is still sent alongside custom headers. The header gets the request in, the signature proves volai really sent it.
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. 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 or calls and 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.