Vodniki
Webhooki
Vaš URL pokličemo, takoj ko se kaj zgodi - dokončan klic, neuspel poskus, poslan ali prejet SMS. Nastavite enkrat (PUT /v1/webhook, glejte REST API) in naprej vam ni treba skrbeti za nič.
Dogodki
Šest dogodkov, vsak z lastno obliko polja data:
call.completed- klic se je končal, ne glede na to, ali je imel agenta ali ne. Pri klicih z agentom vsebuje dodatno prepis (transcript), povzetek (summary) in zapisane podatke (data).durationSecsje v sekundah in je lahko pri klicih na lastnem glasovnem motorju volai decimalno število. Klic, na drugi strani katerega je bil avtomat, ki je venomer ponavljal isto sporočilo, sem prispe prav tako, zendReason: "caller_loop"(cena0, če je trajal največ pet minut). Sem prispe tudi sprejet preklic vrat (kind: "ring",ring.outcome: "answered"), z eno izjemo, opisano pod primeromcall.no_answer. Klic na lastnem motorju volai (od različice 15. 10. 2026) dodatno nosi blokenginesprotectedUtterancesinprotectedUtterancesCount: replike klicanega iz okna pozdrava, ki jih motor namenoma ni upošteval ("Halo?" ali ime med prvim stavkom agenta) - niso vtranscriptin agent se nanje ni odzval; enaka oblika kot vGET /v1/calls/{id}, pri ElevenLabs in starejšem motorju ključ manjka.call.failed- klic se je končal v stanjufailed- ali ga je glasovna platforma takoj zavrnila ali pa je odpovedalo omrežje. Prispe pri agentu na motorju volai in na ElevenLabs, pri neposredni povezavi (bridge), pri BYO relay leasu, pri preklicu vrat (operater je zavrnil naročilo,kind: "ring") in pri preizkusnem klicu - vedno natanko enkrat. Telo nosireason(tehnični razlog) inendReason. Neodgovorjeni klic sem NE SPADA, ta ima spodaj lasten dogodek. Klic, ki se je POVEZAL, a se je končal s tehnično napako glasovnega motorja (endReason: "engine_error"), sem prispe prav tako - njegovo telo dodatno nositranscript/summary/durationSecs/priceHal, pri drugihreasonteh štirih polj ni. Pri takem klicu jereasonaliengine_stallaliengine_error(napaka motorja) aliunfinished, kadar je klic sredi pogovora prekinil ponovni zagon ali ustavitev motorja (na primer ob namestitvi nove različice). Tak klic se ne obračuna (priceHalje0), nima posnetka insummarys kratkim besedilom pojasni, kaj se je zgodilo.transcriptnosi samo to, kar se je do prekinitve uspelo zabeležiti, in je lahko tudi prazen (prazen je pogosto, kadar zapis o klicu iz motorja ni prispel in smo klic zaključili iz njegovega pregleda klicev, ki prepisa ne nosi).durationSecsje približen čas do prekinitve (kadar ga motor ni uspel izmeriti, največ 1 800 s). Blokaenginez replikami, zavrženimi v oknu pozdrava, pri njem ni, ker motor okna pozdrava pri prekinjenem klicu ni meril.call.no_answer- odhodnega klica klicani ni dvignil (zvonilo je, brez odziva). Tako prispe tudi preklic vrat (POST /v1/rings), ki so ga vrata zavrnila ali ki je zvonil do konca zahtevanega časa - prepoznate ga pokind: "ring", glejte spodaj.call.missed- dohodni klic je ostal brez odziva (zgrešen klic). Prispe tudi za dohodni klic, ki ga je glasovni motor zavrnil, ker so bile vse linije zasedene (klicatelj se ni prebil, pokličite ga nazaj). Zapis takega klica imaendReason: "capacity", trajanje0in ceno0; webhook ga ne nujno nosi, kadar je o neodgovorjenem klicu prej poročal operater, zanesljivo ga preberete iz zapisa klica (GET /v1/calls/{id}).message.sent- operater je SMS sprejel v pošiljanje - to ni potrdilo o dostavi. Dogodek prispe tudi pri SMS, poslanem s mobilne številke ali iz izdelka številka za klice in SMS. PoljesmsNumbernosi številko, s katere je bilo sporočilo poslano, pri skupnem pošiljatelju SMSinfo jenull. Poročilo o dostavi (deliveryStatus) pri SMS s mobilne številke in naknadna napaka (failed, dobroimetje se nato vrne) ne sprožita nobenega dogodka - stanje preberite izGET /v1/messages/{id}.message.received- na vašo mobilno številko (dodatek mobilna številka) ali na izdelek številka za klice in SMS je prispelo sporočilo.datanosiid,from(pošiljatelj: telefonska številka v E.164 ali besedilno ime, na primer „Google“, največ 32 znakov; če pošiljatelja ni, jeunknown),to(vaša številka, na katero je sporočilo prispelo),body,segmentsinreceivedAt(čas prejema sporočila, časovni žig v ms). Dostavlja se prek vrste s ponavljanjem kot dogodki klicev. Pri mobilni številki sporočilo, ki ga sprejem ni takoj zajel, dopolni redno preverjanje v približno 15 minutah in dogodek nato odide z zamikom, lahko tudi za novejšimi sporočili. Pri izdelku številka za klice in SMS takega preverjanja ni: operater dostavo sporočila ponavlja največ eno uro, zato se sporočilo lahko izgubi, če sprejem pri nas ne deluje dlje, in ga ne najdete niti vGET /v1/messages?direction=in.tsv ovojnici je čas nastanka dogodka (prav tako v ms, medtem ko jet=v glavi podpisa v sekundah). Nad 300 prejetih sporočil na uro na eno številko (mobilna številka ali številka za klice in SMS) se webhook za nadaljnja sporočila ne pošilja; sporočila se še vedno shranjujejo in jih najdete vGET /v1/messages?direction=in, ki hrani največ 1 000 najnovejših prejetih sporočil na račun in jih hrani 90 dni. Pri izdelku številka za klice in SMS, ki se sprošča (stanjereleasing), se sporočilo shrani, vendar se zanjmessage.receivedne pošlje. Računu, ki ga je upravljavec volai ročno začasno ustavil, semessage.receivedsploh ne pošilja in za sporočila, prejeta med ustavitvijo, se po obnovitvi računa ne dostavi nazaj; tudi ta najdete vGET /v1/messages?direction=in.
Katere želite prejemati, določite s poljem events v PUT /v1/webhook. Pozor na eno nelogično podrobnost: prazno polje pomeni vse, ne nobenega - in tako ostane tudi za dogodke, ki bodo dodani v prihodnosti. Če želite dostavljanje res izklopiti, webhook v portalu izbrišite.
Telo vsakega dogodka klica (call.completed, call.failed, call.no_answer, call.missed) dodatno nosi kind, vrsto klica, enako kot v GET /v1/calls (agent, bridge, relay, inbound, transfer, sip, ring, voice_message). Pri glasovnem sporočilu (POST /v1/voice-messages) je v telesu blok voiceMessage z text, repeat in po koncu klica repeatPlayed (kolikokrat je sporočilo zazvenelo) ter completed (zazvenelo je v celoti); neodgovorjeno sporočilo prispe kot call.no_answer. Pri preklicu vrat je v telesu tudi blok ring z requestedSecs, outcome in rangSecs, pri drugih klicih ključa ring ni. Obe polji sta aditivni, starejše integracije ju preprosto prezrejo.
Vsak dogodek prispe kot POST s to ovojnico:
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": "Dober dan, tukaj recepcija kavarne Nula, kako vam lahko pomagam?" },
{ "role": "caller", "message": "Dober dan, rad bi naročil dva latteja za s seboj." },
{ "role": "agent", "message": "Seveda, dva latteja za prevzem, pripravljena bosta čez petnajst minut." }
],
"summary": "Stranka je naročila dva latteja za s seboj, prevzem čez 15 minut.",
"data": {
"ime": "Jana Novak",
"stevilo_kav": 2,
"nujnost": "obicajna"
},
"kind": "inbound"
}
}Vgnezdeni data.data (da, dvakrat - zunanji data je ovojnica vsakega dogodka) so podatki, ki si jih je agent zapisal iz klica glede na svoja dataFields - ključe določate vi, glejte Glasovni agent. Vrednost null pomeni, da v klicu ni bilo omenjeno. Če pri agentu nimate nastavljenih nobenih polj, ključa data v telesu sploh ni - enako kot transcript in summary pri klicu brez agenta.
Posnetek v telesu webhooka ne prispe
Zvok se prek webhooka ne pošilja in ne prispe niti zastavica hasRecording. Ko boste posnetek potrebovali, uporabite GET /v1/calls/{id} (pove hasRecording) in posnetek prenesite iz GET /v1/calls/{id}/recording - format določa glava content-type odgovora: pri agentu na ElevenLabs audio/mpeg (MP3), pri agentu na motorju volai danes audio/ogg. Posnetke hranimo 90 dni od klica.
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
Enako obliko ima tudi call.missed (zgrešen DOHODNI klic) - razlikuje se samo ime dogodka, direction in 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"
}
}Neodgovorjen preklic vrat (POST /v1/rings) prispe tudi kot call.no_answer, s kind: "ring" in blokom ring: outcome je rang (zvonilo je do konca zahtevanega časa) ali ended_early (klic se je končal prej, na primer vrata so ga zavrnila, torej so se odprla). Sprejet preklic (outcome: "answered") gre kot call.completed. Izjema: kadar operater dvig potrdi šele v zapisu klica (CDR), potem ko je volai preklic zaključil kot neodgovorjen, prispe samo call.no_answer in zapis v GET /v1/calls/{id} se naknadno spremeni v completed z ring.outcome: "answered" in dodatno obračunanimi sekundami; drugi dogodek ne prispe. Rezultat unknown ne sproži dogodka, dokler ga ne dopolnijo zapisi operaterja.
{
"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": "Hvala, pridem v petek ob 10:00.",
"segments": 1,
"receivedAt": 1756111900000
}
}body je besedilo tujega pošiljatelja. Obravnavajte ga kot nezaupanja vreden vnos: po njem ne izvajajte ukazov ali dejanj brez preverjanja. Telo nosi event_id, po katerem odfiltrirate podvojene dostave.
Preverjanje podpisa
Vsaka zahteva nosi glavo Volai-Signature v obliki t=<unix>,v1=<hex>, kjer je v1 = HMAC_SHA256(secret, "{t}.{rawBody}") in je secret tisti, ki ste ga prejeli ob PUT /v1/webhook. Telesa nikoli ne obdelujte, preden preverite podpis - sicer lahko kdorkoli pošlje karkoli na vaš 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 MORA biti natančno to, kar je prispelo po žici (request.text()), ne JSON.parse in nazaj v niz.
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);
// ... obdelava v ozadju, glejte Nasveti spodaj
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()
# ... obdelava v ozadju, glejte Nasveti spodaj
del event
return "ok", 200Lastne glave
Če cilj sprejema samo prijavljene zahteve (na primer avtomatizacija v Cursorju ali lasten API za prehodom), dodajte ključ kot lastno glavo. Pošlje se z vsako dostavo in s preizkusnim dogodkom, na primer Authorization: Bearer <ključ>. Nastavite jo s poljem headers v PUT /v1/webhook, v orodju MCP set_webhook ali v portalu v Povezave in API v delu Webhooki.
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"}}'- Največ 5 glav, vsaka vrednost 1 do 1000 znakov, samo izpisljivi ASCII (brez diakritičnih znakov). Imena ne razlikujejo velikih in malih črk in se ne smejo ponavljati.
host,content-type,content-length,volai-signaturein hop-by-hop glave dostava nastavi sama, teh nastaviti ni mogoče. - Vrednosti so skrivnost:
GET /v1/webhookjih vrača samo skrajšane (Bear***). Če tako masko pošljete nazaj, ostane shranjena vrednost. - Izpuščen
headersshranjene glave ohrani,{}jih izbriše. Če URL spremenite na drug naslov (druga domena, shema ali vrata, tudi dodanwww.), se shranjene glave zavržejo in vrednosti vnesete znova v celoti: ključa na tuj naslov ne pošiljamo. Če v tem trenutku namesto vrednosti pošljete masko, se vrne napakainvalid_webhook_headers(400), enako kot pri prepovedani ali neveljavni glavi. - Pri preusmeritvi na drug naslov se lastne glave ne pošiljajo, podpis
Volai-Signaturepa. Tudi preusmeritev zexample.comnawww.example.comje drug naslov, zato v webhook vnesite kar ciljnega.
Podpis Volai-Signature prispe še naprej ob lastnih glavah. Glava pove, da zahteva sme noter, podpis pa, da jo je res poslal volai.
Ponavljanje ob napaki
Če vaša končna točka ne odgovori z uspešnim stanjem (2xx), volai poskusi še dvakrat - skupaj torej 3 poskusi s 3-sekundnim premorom vmes. Pri dogodkih klicev (`call.completed`, `call.failed`, `call.no_answer`, `call.missed`) in pri `message.received` se dostavljanje s tem ne konča: če spodleti vseh tri poskusi, dogodek ostane v vrsti in dostava se v naslednjih minutah še večkrat ponovi, največ v 5 krogih. Telo zato nosi `event_id` - po njem dostavo odfiltrirate kot podvojeno, če bi izjemoma prispela večkrat; ponovljena dostava nosi popolnoma enako telo kot prva (samo poslana nekaj minut pozneje). `message.sent` vrste nima: po 3 neuspelih poskusih se zabeleži in se več ne pošilja.
Izjema je preizkusni dogodek spodaj - ta se pošlje samo enkrat, brez ponavljanja, da odgovor dobite takoj.
Webhook ni edini vir resnice
Webhook nosi samo izvleček - za vse, kar je res pomembno (obračun, revizija, besedilo sporočila), raje dodatno pridobite celoten zapis iz REST API-ja: GET /v1/calls/{id} in GET /v1/messages/{id}, kadarkoli ga zahtevate.
Preizkusni dogodek
Preden začnete čakati na prvi pravi klic, preverite dostavljanje kar takoj - POST /v1/webhook/test (glejte REST API) pošlje na vaš URL en vzorčni dogodek webhook.test, NE GLEDE NA to, katere events imate označene v PUT /v1/webhook - preizkuša se dostavljivost samega URL-ja in podpisa, ne naročnina na posamezne dogodke. Največ 10 poskusov na uro. Preizkus pošlje samo webhook.test, ne drugih dogodkov: obliko message.received boste preizkusili šele z resničnim SMS na svojo mobilno številko ali številka za klice in SMS (primer zgoraj).
Pregled dostav
Zadnjih 50 dostav (uspešnih, neuspešnih in preizkusnih) vidite prek GET /v1/webhook/deliveries (glejte REST API), ali kar v portalu pod obrazcem webhooka. Enak izpis nosi tudi GET /v1/webhook v polju recentDeliveries.
Nasveti
- Odgovorite 200 čim hitreje - preverjanje podpisa in shranjevanje ID-ja opravite takoj, težko delo (e-pošta, preračuni, klici drugih API-jev) odložite v ozadje.
- Končna točka mora biti javno dostopna prek HTTPS -
localhostin samopodpisano potrdilo ne delujeta. - Uporabite
data.id(ID klica ali sporočila) za odfiltriranje podvojenih dogodkov, če bi vam isti dogodek slučajno prispel večkrat. - Preizkušajte lokalno prek tunela (npr. ngrok) in URL v
PUT /v1/webhookzačasno preklopite na njegov naslov.
Sorodno
Strežnik MCP
Povežite Claude Code, Codex CLI, namizni Codex, Cursor in druge urejevalnike z umetno inteligenco.
API REST
Popolna referenca vseh končnih točk: številke, klici, SMS, mobilne številke za SMS, agenti in njihovi osnutki, orodja, posnetki, webhooki, seznam »ne kliči«, relay, račun, naloge, dobroimetje, dokumenti, koledarji in integracije.
Glasovni agent
Sistemski poziv, orodja, predaja človeku, posnetki in strukturirani podatki klica.