Preskoči na vsebino

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). durationSecs je 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, z endReason: "caller_loop" (cena 0, če je trajal največ pet minut). Sem prispe tudi sprejet preklic vrat (kind: "ring", ring.outcome: "answered"), z eno izjemo, opisano pod primerom call.no_answer. Klic na lastnem motorju volai (od različice 15. 10. 2026) dodatno nosi blok engine s protectedUtterances in protectedUtterancesCount: replike klicanega iz okna pozdrava, ki jih motor namenoma ni upošteval ("Halo?" ali ime med prvim stavkom agenta) - niso v transcript in agent se nanje ni odzval; enaka oblika kot v GET /v1/calls/{id}, pri ElevenLabs in starejšem motorju ključ manjka.
  • call.failed - klic se je končal v stanju failed - 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 nosi reason (tehnični razlog) in endReason. 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 nosi transcript/summary/durationSecs/priceHal, pri drugih reason teh štirih polj ni. Pri takem klicu je reason ali engine_stall ali engine_error (napaka motorja) ali unfinished, kadar je klic sredi pogovora prekinil ponovni zagon ali ustavitev motorja (na primer ob namestitvi nove različice). Tak klic se ne obračuna (priceHal je 0), nima posnetka in summary s kratkim besedilom pojasni, kaj se je zgodilo. transcript nosi 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). durationSecs je približen čas do prekinitve (kadar ga motor ni uspel izmeriti, največ 1 800 s). Bloka engine z 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 po kind: "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 ima endReason: "capacity", trajanje 0 in ceno 0; 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. Polje smsNumber nosi številko, s katere je bilo sporočilo poslano, pri skupnem pošiljatelju SMSinfo je null. 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 iz GET /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. data nosi id, from (pošiljatelj: telefonska številka v E.164 ali besedilno ime, na primer „Google“, največ 32 znakov; če pošiljatelja ni, je unknown), to (vaša številka, na katero je sporočilo prispelo), body, segments in receivedAt (č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 v GET /v1/messages?direction=in. ts v ovojnici je čas nastanka dogodka (prav tako v ms, medtem ko je t= 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 v GET /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 (stanje releasing), se sporočilo shrani, vendar se zanj message.received ne pošlje. Računu, ki ga je upravljavec volai ročno začasno ustavil, se message.received sploh ne pošilja in za sporočila, prejeta med ustavitvijo, se po obnovitvi računa ne dostavi nazaj; tudi ta najdete v GET /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

json
{
  "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

json
{
  "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.

json
{
  "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.

json
{
  "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

json
{
  "event": "message.sent",
  "ts": 1756111640000,
  "data": {
    "id": "msg_7c1f9a2e",
    "to": "+420777123456",
    "status": "sent",
    "priceHal": 136,
    "smsNumber": null
  }
}

message.received

json
{
  "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.

typescript
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 });
}
python
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", 200

Lastne 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.

bash
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-signature in hop-by-hop glave dostava nastavi sama, teh nastaviti ni mogoče.
  • Vrednosti so skrivnost: GET /v1/webhook jih vrača samo skrajšane (Bear***). Če tako masko pošljete nazaj, ostane shranjena vrednost.
  • Izpuščen headers shranjene glave ohrani, {} jih izbriše. Če URL spremenite na drug naslov (druga domena, shema ali vrata, tudi dodan www.), 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 napaka invalid_webhook_headers (400), enako kot pri prepovedani ali neveljavni glavi.
  • Pri preusmeritvi na drug naslov se lastne glave ne pošiljajo, podpis Volai-Signature pa. Tudi preusmeritev z example.com na www.example.com je 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 - localhost in 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/webhook začasno preklopite na njegov naslov.