Průvodci
Webhooky
Na tvoji URL zavoláme, jakmile se něco stane - dokončený hovor, neúspěšný pokus, odeslaná nebo přijatá SMS. Nastavíš to jednou (PUT /v1/webhook, viz REST API) a dál se o nic nestaráš.
Události
Šest událostí, každá s vlastním tvarem pole data:
call.completed- hovor skončil, ať už měl agenta nebo ne. U agentních hovorů obsahuje navíc přepis (transcript), shrnutí (summary) a zapsané údaje (data).durationSecsje ve vteřinách a u hovorů na vlastním hlasovém motoru volai může být desetinné číslo. Hovor, na jehož druhém konci byl automat opakující pořád tutéž hlášku, sem chodí také, sendReason: "caller_loop"(cena0, pokud trval nejvýš pět minut).call.failed- hovor skončil ve stavufailed- ať už ho hlasová platforma rovnou odmítla, nebo selhala síť. Chodí u agenta na motoru volai i na ElevenLabs, u přímého spojení (bridge), u BYO relay leasu i u zkušebního hovoru - vždy přesně jednou. Tělo nesereason(technický důvod) aendReason. Nezvednutý hovor sem NEPATŘÍ, ten má vlastní událost níž. Karta K6 (audit R02): hovor, který se SPOJIL, ale skončil technickou chybou hlasového motoru (endReason: "engine_error"), sem chodí taky - jeho tělo navíc nesetranscript/summary/durationSecs/priceHal, u ostatníchreasontahle čtyři pole chybí.call.no_answer- odchozí hovor volaný nezvedl (zvonilo, bez odezvy).call.missed- příchozí hovor zůstal bez odezvy (zmeškaný hovor).message.sent- operátor přijal SMS k odeslání - není to potvrzení doručení.message.received- na tvoje SMS číslo (doplněk SMS číslo) přišla zpráva.dataneseid,from(odesílatele),to(tvoje SMS číslo),body,segmentsareceivedAt(časové razítko v ms). Doručuje se přes frontu s opakováním jako hovorové události. Nad 300 přijatých zpráv za hodinu na jedno SMS číslo se webhook pro další zprávy neposílá; zprávy se dál ukládají a najdeš je vGET /v1/messages?direction=in, který drží nejvýš 1 000 nejnovějších přijatých zpráv na účet a uchovává je 90 dní.
Které z nich chceš odebírat, řekneš polem events v PUT /v1/webhook. Pozor na jeden protimyslný detail: prázdné pole znamená všechny, ne žádnou - a zůstane tak i pro události, které přibudou v budoucnu. Když chceš doopravdy vypnout doručování, webhook v portálu smaž.
Každá událost dorazí jako POST s tímhle obalem:
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": "Dobrý den, tady recepce kavárny Nula, jak vám mohu pomoct?" },
{ "role": "caller", "message": "Dobrý den, chtěl bych si objednat dva latte s sebou." },
{ "role": "agent", "message": "Jasně, dva latte na vyzvednutí, bude to za patnáct minut." }
],
"summary": "Zákazník si objednal dva latte s sebou, vyzvednutí za 15 minut.",
"data": {
"jmeno": "Jana Nováková",
"pocet_kav": 2,
"spechalo": "bezna"
}
}
}Vnořené data.data (ano, dvakrát - vnější data je obal každé události) jsou údaje, které si agent z hovoru zapsal podle svých dataFields - klíče si určuješ ty, viz Hlasový agent. Hodnota null znamená, že to v hovoru nezaznělo. Když u agenta žádná pole nastavená nemáš, klíč data v těle vůbec není - stejně jako transcript a summary u hovoru bez agenta.
Nahrávka v těle webhooku nechodí
Zvuk se webhookem neposílá a nechodí ani příznak hasRecording. Až budeš nahrávku chtít, sáhni si po GET /v1/calls/{id} (řekne hasRecording) a nahrávku stáhni z GET /v1/calls/{id}/recording - formát určuje hlavička content-type odpovědi: u agenta na ElevenLabs audio/mpeg (MP3), u agenta na motoru volai dnes audio/ogg. Nahrávky držíme 90 dní od hovoru.
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
Stejný tvar má i call.missed (zmeškaný PŘÍCHOZÍ hovor) - liší se jen jméno události, direction a status.
{
"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
}
}message.received
{
"event": "message.received",
"ts": 1756111900000,
"event_id": "evt_4kTz_P8wQmL2Xc9RvNdA1",
"data": {
"id": "msg_3d9b5e71",
"from": "+420777123456",
"to": "+420770112233",
"body": "Díky, přijdu v pátek v 10:00.",
"segments": 1,
"receivedAt": 1756111900000
}
}body je text od cizího odesílatele. Ber ho jako nedůvěryhodný vstup: neprováděj podle něj příkazy ani akce bez ověření. Tělo nese event_id, podle kterého odfiltruješ duplicitní doručení.
Ověření podpisu
Každý požadavek nese hlavičku Volai-Signature ve tvaru t=<unix>,v1=<hex>, kde v1 = HMAC_SHA256(secret, "{t}.{rawBody}") a secret je ten, který jsi dostal při PUT /v1/webhook. Nikdy nezpracovávej tělo dřív, než podpis ověříš - jinak si na tvoji URL může poslat cokoli kdokoli.
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 MUSÍ být přesně to, co přišlo po drátě (request.text()), ne JSON.parse a zpátky na 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);
// ... zpracování na pozadí, viz Tipy níže
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()
# ... zpracování na pozadí, viz Tipy níže
del event
return "ok", 200Opakování při chybě
Pokud tvůj endpoint neodpoví úspěšným stavem (2xx), volai to zkusí ještě dvakrát - celkem tedy 3 pokusy s pauzou 3 vteřiny mezi nimi. U hovorových událostí (`call.completed`, `call.failed`, `call.no_answer`, `call.missed`) a u `message.received` tím doručování nekončí: když selžou všechny tři pokusy, událost zůstává ve frontě a doručení se v příštích minutách ještě několikrát zopakuje, nejvýš do 5 kol. Tělo proto nese `event_id` - podle něj doručení odfiltruješ jako duplicitní, kdyby ti výjimečně dorazilo víckrát; opakované doručení nese úplně stejné tělo jako to první (jen odeslané o pár minut později). `message.sent` frontu nemá: po 3 neúspěšných pokusech se zaloguje a znovu se už neposílá.
Výjimka je testovací událost níž - ta se pošle jen jednou, bez opakování, ať dostaneš odpověď hned.
Webhook není jediný zdroj pravdy
Webhook nese jen výtah - pro cokoli, na čem opravdu záleží (fakturace, audit, text zprávy), si radši dotáhni i kompletní záznam z REST API: GET /v1/calls/{id} a GET /v1/messages/{id}, kdykoli si o něj řekneš.
Testovací událost
Než začneš čekat na první skutečný hovor, ověř si doručování rovnou - POST /v1/webhook/test (viz REST API) pošle na tvoji URL jednu ukázkovou událost webhook.test, BEZ OHLEDU na to, které events máš v PUT /v1/webhook zaškrtnuté - testuje se doručitelnost samotné URL a podpisu, ne odběr konkrétních událostí. Nejvýš 10 pokusů za hodinu.
Přehled doručení
Posledních 50 doručení (úspěšných, neúspěšných i testovacích) uvidíš přes GET /v1/webhook/deliveries (viz REST API), nebo rovnou v portálu pod formulářem webhooku. Stejný výtah nese i GET /v1/webhook v poli recentDeliveries.
Tipy
- Odpověz 200 co nejrychleji - ověření podpisu a uložení ID stihneš hned, těžkou práci (e-maily, přepočty, volání dalších API) odsuň na pozadí.
- Endpoint musí být veřejně dostupný přes HTTPS -
localhostani self-signed certifikát nefungují. - Použij
data.id(ID hovoru nebo zprávy) k odfiltrování duplicit, kdyby ti náhodou stejná událost dorazila víckrát. - Testuj lokálně přes tunel (např. ngrok) a URL v
PUT /v1/webhookdočasně přepni na jeho adresu.
Související
MCP server
Připojení Claude Code, Codex CLI, Codex desktopu, Cursoru a dalších AI editorů.
REST API
Kompletní reference všech endpointů: čísla, hovory, SMS, agenti a jejich koncepty, nástroje, nahrávky, webhooky, seznam nevolat, relay, účet, úkoly, kredit, doklady, kalendáře a integrace.
Hlasový agent
Systémový prompt, nástroje, přepojení na člověka, nahrávky a zapsané údaje z hovoru.