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). Sem chodí i zvednuté prozvonění brány (kind: "ring",ring.outcome: "answered"), s jednou výjimkou popsanou pod ukázkoucall.no_answer. Hovor na vlastním motoru volai (od sestavení z 15. 10. 2026) nese navíc blokenginesprotectedUtterancesaprotectedUtterancesCount: repliky volaného z okna pozdravu, které motor záměrně nevzal ("Haló?" nebo jméno během první věty agenta) - nejsou vtranscripta agent na ně nereagoval; stejný tvar jako vGET /v1/calls/{id}, u ElevenLabs a staršího motoru klíč chybí.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, u prozvonění brány (operátor odmítl objednávku,kind: "ring") 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íž. 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í. U takového hovoru jereasonbuďengine_stallneboengine_error(chyba motoru), nebounfinished, když hovor přerušil restart nebo zastavení motoru uprostřed hovoru (třeba při nasazení nové verze). Takový hovor se neúčtuje (priceHalje0), nemá nahrávku asummarykrátkým textem vysvětluje, co se stalo.transcriptnese jen to, co se do přerušení stihlo zaznamenat, a může být i prázdný (prázdný bývá, když záznam o hovoru z motoru nedorazil a hovor jsme uzavřeli z jeho přehledu hovorů, který přepis nenese).durationSecsje přibližná doba do přerušení (když ji motor nestihl změřit, nejvýš 1 800 s). Blokengines replikami zahozenými v okně pozdravu u něj chybí, protože motor okno pozdravu u přerušeného hovoru neměřil.call.no_answer- odchozí hovor volaný nezvedl (zvonilo, bez odezvy). Takhle chodí i prozvonění brány (POST /v1/rings), které brána odmítla nebo které dozvonilo do konce požadované doby - poznáš ho podlekind: "ring", viz níž.call.missed- příchozí hovor zůstal bez odezvy (zmeškaný hovor). Chodí i za příchozí hovor, který hlasový motor odmítl, protože byly všechny linky plné (volající se nedovolal, ozvi se mu zpět). Záznam takového hovoru máendReason: "capacity", délku0a cenu0; webhook ho ale nemusí nést, když o nezvednutém hovoru dřív informoval operátor, spolehlivě ho přečteš ze záznamu hovoru (GET /v1/calls/{id}).message.sent- operátor přijal SMS k odeslání - není to potvrzení doručení. Chodí i u SMS odeslané ze SMS čísla nebo z čísla pro hovory i SMS. PolesmsNumbernese číslo, ze kterého zpráva odešla, u sdíleného odesílatele SMSinfo jenull. Doručenka (deliveryStatus) u SMS ze SMS čísla ani dodatečné selhání (failed, kredit se pak vrátí) žádnou událost nevyvolá - stav načti zGET /v1/messages/{id}.message.received- na tvoje SMS číslo (doplněk SMS číslo) nebo na tvoje číslo pro hovory i SMS přišla zpráva.dataneseid,from(odesílatel: telefonní číslo v E.164, nebo textové jméno, např. „Google“, nejvýš 32 znaků; chybí-li odesílatel, je tounknown),to(tvoje číslo, na které zpráva přišla),body,segmentsareceivedAt(čas přijetí zprávy, časové razítko v ms). Doručuje se přes frontu s opakováním jako hovorové události. U SMS čísla zprávu, kterou příjem hned nezachytil, doplní pravidelná kontrola do zhruba 15 minut a událost pak odejde se zpožděním, klidně po novějších zprávách. U čísla pro hovory i SMS žádná taková kontrola není: operátor doručení zprávy opakuje nejvýš hodinu, takže když příjem u nás nefunguje déle, zpráva se může ztratit a nenajdeš ji ani vGET /v1/messages?direction=in.tsv obálce je čas vzniku události (také v ms, kdežtot=v hlavičce podpisu je v sekundách). Nad 300 přijatých zpráv za hodinu na jedno číslo (SMS číslo i číslo pro hovory i SMS) 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í. Na čísle pro hovory i SMS, které se uvolňuje (stavreleasing), se zpráva uloží, alemessage.receivedse pro ni neodešle. Účtu, který ručně pozastavil provozovatel volai, semessage.receivedneposílá vůbec a pro zprávy přijaté za pozastavení se po obnovení účtu zpětně nedoručí; i ty najdeš vGET /v1/messages?direction=in.
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ž.
Tělo každé hovorové události (call.completed, call.failed, call.no_answer, call.missed) nese navíc kind, druh hovoru stejně jako v GET /v1/calls (agent, bridge, relay, inbound, transfer, sip, ring, voice_message). U hlasové zprávy (POST /v1/voice-messages) je v těle blok voiceMessage s text, repeat a po skončení hovoru repeatPlayed (kolikrát zpráva zazněla) a completed (zazněla celá); nezvednutá zpráva chodí jako call.no_answer. U prozvonění brány je v těle i blok ring s requestedSecs, outcome a rangSecs, u ostatních hovorů klíč ring chybí. Obě pole jsou aditivní, starší integrace je prostě ignoruje.
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"
},
"kind": "inbound"
}
}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",
"kind": "agent"
}
}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",
"kind": "agent"
}
}Nezvednuté prozvonění brány (POST /v1/rings) chodí taky jako call.no_answer, s kind: "ring" a blokem ring: outcome je rang (zvonilo do konce požadované doby) nebo ended_early (hovor skončil dřív, třeba ho brána odmítla, tedy se otevřela). Zvednuté prozvonění (outcome: "answered") jde jako call.completed. Výjimka: když operátor zvednutí potvrdí až v záznamu hovoru (CDR) poté, co volai prozvonění uzavřelo jako nezvednuté, přijde jen call.no_answer a záznam v GET /v1/calls/{id} se dodatečně změní na completed s ring.outcome: "answered" a doúčtovanými vteřinami; druhá událost nepřijde. Výsledek unknown událost nevyvolá, dokud ho nedoplní záznamy operátora.
{
"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": "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", 200Vlastní hlavičky
Když cíl bere jen přihlášené požadavky (třeba automatizace v Cursoru nebo vlastní API za bránou), přidej klíč jako vlastní hlavičku. Pošle se s každým doručením i s testovací událostí, třeba Authorization: Bearer <klíč>. Nastavíš ji polem headers v PUT /v1/webhook, v MCP nástroji set_webhook, nebo v portálu v Propojení a API v části Webhooky.
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"}}'- Nejvýš 5 hlaviček, každá hodnota 1 až 1000 znaků, jen tisknutelné ASCII (bez diakritiky). Názvy nerozlišují velikost písmen a nesmí se opakovat.
host,content-type,content-length,volai-signaturea hop-by-hop hlavičky si doručení nastavuje samo, ty nastavit nejde. - Hodnoty jsou tajemství:
GET /v1/webhookje vrací jen zkrácené (Bear***). Pošleš-li takovou masku zpátky, zůstane uložená hodnota. - Vynechané
headersuložené hlavičky ponechá,{}je smaže. Když změníš URL na jinou adresu (jiná doména, schéma nebo port, iwww.navíc), uložené hlavičky se zahodí a hodnoty zadáš znovu celé: klíč na cizí adresu neposíláme. Pošleš-li v tu chvíli místo hodnoty masku, vrátí se chybainvalid_webhook_headers(400), stejně jako u zakázané nebo neplatné hlavičky. - Při přesměrování na jinou adresu se vlastní hlavičky neposílají, podpis
Volai-Signatureano. I přesměrování zexample.comnawww.example.comje jiná adresa, do webhooku proto zadej rovnou tu cílovou.
Podpis Volai-Signature chodí dál vedle vlastních hlaviček. Hlavička říká, že požadavek smí dovnitř, podpis, že ho opravdu poslal volai.
Opaková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. Test posílá jen webhook.test, ne ostatní události: tvar message.received vyzkoušíš až skutečnou SMS na své SMS číslo nebo číslo pro hovory i SMS (ukázka výš).
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, SMS čísla pro příjem, 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.