Preskoči na sadržaj

Vodiči

Webhookovi

Na vaš URL pozvat ćemo čim se nešto dogodi - završen poziv, neuspjeli pokušaj, poslan ili primljen SMS. Postavite to jednom (PUT /v1/webhook, vidi REST API) i dalje se ne morate ni o čemu brinuti.

Događaji

Šest događaja, svaki s vlastitim oblikom polja data:

  • call.completed - poziv je završio, bez obzira na to je li imao agenta. Kod poziva s agentom dodatno sadrži transkript (transcript), sažetak (summary) i zapisane podatke (data). durationSecs je u sekundama i kod poziva na vlastitom glasovnom motoru volai može biti decimalni broj. Poziv na čijem je drugom kraju bio automat koji stalno ponavlja istu poruku također ovamo stiže, s endReason: "caller_loop" (cijena 0 ako je trajao najviše pet minuta). Ovamo stiže i podignuto probno zvonjenje kapije (kind: "ring", ring.outcome: "answered"), uz jednu iznimku opisanu ispod primjera call.no_answer. Poziv na vlastitom motoru volai (od izdanja od 15. 10. 2026) dodatno nosi blok engine s protectedUtterances i protectedUtterancesCount: replike pozvane strane iz prozora pozdrava koje motor namjerno nije preuzeo ("Halo?" ili ime tijekom prve rečenice agenta) - nisu u transcript i agent na njih nije reagirao; isti oblik kao u GET /v1/calls/{id}, kod ElevenLabsa i starijeg motora ključ nedostaje.
  • call.failed - poziv je završio u stanju failed - bilo da ga je glasovna platforma odmah odbila, bilo da je mreža zakazala. Stiže kod agenta na motoru volai i na ElevenLabsu, kod izravnog spajanja (bridge), kod BYO relay leasea, kod probnog zvonjenja kapije (operater je odbio narudžbu, kind: "ring") i kod probnog poziva - uvijek točno jednom. Tijelo nosi reason (tehnički razlog) i endReason. Poziv na koji se nitko nije javio ovamo NE SPADA, on ima vlastiti događaj niže. Poziv koji se SPOJIO, ali je završio tehničkom pogreškom glasovnog motora (endReason: "engine_error"), ovamo također stiže - njegovo tijelo dodatno nosi transcript/summary/durationSecs/priceHal, kod ostalih reason ta četiri polja nedostaju. Kod takvog je poziva reason ili engine_stall ili engine_error (pogreška motora), ili unfinished, kad je poziv prekinuo ponovno pokretanje ili zaustavljanje motora usred poziva (primjerice pri puštanju nove verzije). Takav se poziv ne naplaćuje (priceHal je 0), nema snimku, a summary kratkim tekstom objašnjava što se dogodilo. transcript nosi samo ono što se do prekida stiglo zabilježiti i može biti i prazan (prazan je kad zapis o pozivu iz motora nije stigao pa smo poziv zatvorili iz njegova pregleda poziva, koji transkript ne nosi). durationSecs je približno trajanje do prekida (ako ga motor nije stigao izmjeriti, najviše 1 800 s). Blok engine s replikama odbačenima u prozoru pozdrava ovdje nedostaje jer motor prozor pozdrava kod prekinutog poziva nije mjerio.
  • call.no_answer - na odlazni poziv pozvana strana se nije javila (zvonilo je, bez odziva). Tako stiže i probno zvonjenje kapije (POST /v1/rings) koje je kapija odbila ili koje je dozvonilo do kraja tražene duljine - prepoznat ćete ga po kind: "ring", vidi niže.
  • call.missed - dolazni poziv ostao je bez odziva (propušteni poziv). Stiže i za dolazni poziv koji je glasovni motor odbio jer su sve linije bile pune (pozivatelj nije uspio doći do vas, javite mu se natrag). Zapis takvog poziva ima endReason: "capacity", duljinu 0 i cijenu 0; webhook ga međutim ne mora nositi ako je o nepodignutom pozivu ranije obavijestio operater, pouzdano ga pročitate iz zapisa poziva (GET /v1/calls/{id}).
  • message.sent - operater je prihvatio SMS na slanje - to nije potvrda isporuke. Stiže i za SMS poslan s mobilnog broja ili s proizvoda broj za pozive i SMS. Polje smsNumber nosi broj s kojeg je poruka otišla, kod zajedničkog pošiljatelja SMSinfo je null. Potvrda isporuke (deliveryStatus) kod SMS-a s mobilnog broja ni naknadni neuspjeh (failed, stanje se tada vraća) ne izazivaju nikakav događaj - stanje učitajte iz GET /v1/messages/{id}.
  • message.received - na vaš mobilni broj ili na vaš broj za pozive i SMS stigla je poruka. data nosi id, from (pošiljatelj: telefonski broj u E.164 ili tekstualno ime, npr. „Google“, najviše 32 znaka; ako pošiljatelj nedostaje, to je unknown), to (vaš broj na koji je poruka stigla), body, segments i receivedAt (vrijeme primitka poruke, vremenska oznaka u ms). Isporučuje se preko reda s ponavljanjem kao događaji poziva. Kod mobilnog broja poruku koju primanje nije odmah zabilježilo nadoknađuje redovita provjera u roku od otprilike 15 minuta, a događaj tada odlazi s odgodom, slobodno i nakon novijih poruka. Kod broja broj za pozive i SMS takve provjere nema: operater ponavlja isporuku poruke najviše sat vremena, pa ako primanje kod nas ne radi dulje, poruka se može izgubiti i nećete je naći ni u GET /v1/messages?direction=in. ts u omotnici je vrijeme nastanka događaja (također u ms, dok je t= u zaglavlju potpisa u sekundama). Iznad 300 primljenih poruka na sat po jednom broju (mobilni broj i broj za pozive i SMS) webhook se za daljnje poruke ne šalje; poruke se i dalje spremaju i naći ćete ih u GET /v1/messages?direction=in, koji drži najviše 1 000 najnovijih primljenih poruka po računu i čuva ih 90 dana. Na broju broj za pozive i SMS koji se oslobađa (stanje releasing) poruka se sprema, ali se za nju message.received ne šalje. Računu koji je ručno suspendirao operator volai message.received se uopće ne šalje, a za poruke primljene tijekom suspenzije ne isporučuje se naknadno nakon obnove računa; i njih ćete naći u GET /v1/messages?direction=in.

Koje od njih želite primati, određujete poljem events u PUT /v1/webhook. Pozor na jedan nelogičan detalj: prazno polje znači sve, a ne nijedan - i tako ostaje i za događaje koji se dodaju u budućnosti. Ako doista želite isključiti isporuku, obrišite webhook u portalu.

Tijelo svakog događaja poziva (call.completed, call.failed, call.no_answer, call.missed) dodatno nosi kind, vrstu poziva jednako kao u GET /v1/calls (agent, bridge, relay, inbound, transfer, sip, ring, voice_message). Kod glasovne poruke (POST /v1/voice-messages) u tijelu je blok voiceMessage s text, repeat i, nakon završetka poziva, repeatPlayed (koliko je puta poruka odzvučana) i completed (odzvučala je cijela); nepodignuta poruka stiže kao call.no_answer. Kod probnog zvonjenja kapije u tijelu je i blok ring s requestedSecs, outcome i rangSecs, kod ostalih poziva ključ ring nedostaje. Oba su polja aditivna, starije integracije ih jednostavno ignoriraju.

Svaki događaj stiže kao POST s ovom omotnicom:

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": "Dobar dan, ovdje recepcija kafića Nula, kako vam mogu pomoći?" },
      { "role": "caller", "message": "Dobar dan, htio bih naručiti dvije latte za van." },
      { "role": "agent", "message": "Naravno, dvije latte za van, bit će gotovo za petnaest minuta." }
    ],
    "summary": "Klijent je naručio dvije latte za van, preuzimanje za 15 minuta.",
    "data": {
      "ime": "Jana Novak",
      "broj_kava": 2,
      "hitnost": "uobičajena"
    },
    "kind": "inbound"
  }
}

Ugniježđeno data.data (da, dvaput - vanjski data je omotnica svakog događaja) su podaci koje je agent iz poziva zapisao prema svojim dataFields - ključeve određujete vi, vidi Glasovni agent. Vrijednost null znači da to u pozivu nije izrečeno. Ako kod agenta nemate postavljena nikakva polja, ključa data u tijelu uopće nema - jednako kao transcript i summary kod poziva bez agenta.

Snimka ne stiže u tijelu webhooka

Zvuk se webhookom ne šalje, a ne stiže ni oznaka hasRecording. Kad budete htjeli snimku, posegnite za GET /v1/calls/{id} (kaže hasRecording) i snimku preuzmite iz GET /v1/calls/{id}/recording - format određuje zaglavlje content-type odgovora: kod agenta na ElevenLabsu audio/mpeg (MP3), kod agenta na motoru volai danas audio/ogg. Snimke čuvamo 90 dana od poziva.

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

Isti oblik ima i call.missed (propušteni DOLAZNI poziv) - razlikuju se samo naziv događaja, direction i 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"
  }
}

Nepodignuto probno zvonjenje kapije (POST /v1/rings) stiže također kao call.no_answer, s kind: "ring" i blokom ring: outcome je rang (zvonilo je do kraja tražene duljine) ili ended_early (poziv je završio ranije, primjerice kapija ga je odbila, dakle otvorila se). Podignuto probno zvonjenje (outcome: "answered") ide kao call.completed. Iznimka: kad operater podizanje potvrdi tek u zapisu poziva (CDR) nakon što je volai probno zvonjenje zatvorio kao nepodignuto, stiže samo call.no_answer, a zapis u GET /v1/calls/{id} naknadno se mijenja u completed s ring.outcome: "answered" i naknadno obračunanim sekundama; drugi događaj ne stiže. Ishod unknown ne izaziva događaj dok ga ne dopune zapisi operatera.

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, dolazim u petak u 10:00.",
    "segments": 1,
    "receivedAt": 1756111900000
  }
}

body je tekst od nepoznatog pošiljatelja. Tretirajte ga kao nepouzdan ulaz: prema njemu ne izvršavajte naredbe ni radnje bez provjere. Tijelo nosi event_id, prema kojem filtrirate duplicirane isporuke.

Provjera potpisa

Svaki zahtjev nosi zaglavlje Volai-Signature u obliku t=<unix>,v1=<hex>, gdje je v1 = HMAC_SHA256(secret, "{t}.{rawBody}"), a secret je onaj koji ste dobili pri PUT /v1/webhook. Nikad ne obrađujte tijelo prije nego što potpis provjerite - inače na vaš URL bilo tko može poslati bilo što.

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 točno ono što je stiglo žicom (request.text()), ne JSON.parse i natrag u 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);
  // ... obrada u pozadini, vidi Savjete niže
  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()
    # ... obrada u pozadini, vidi Savjete niže
    del event

    return "ok", 200

Vlastita zaglavlja

Ako odredište prihvaća samo prijavljene zahtjeve (primjerice automatizacija u Cursoru ili vlastiti API iza kapije), dodajte ključ kao vlastito zaglavlje. Šalje se sa svakom isporukom i s probnim događajem, primjerice Authorization: Bearer <ključ>. Postavljate ga poljem headers u PUT /v1/webhook, u MCP alatu set_webhook ili u portalu u Povezivanju i API-ju u dijelu Webhookovi.

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"}}'
  • Najviše 5 zaglavlja, svaka vrijednost 1 do 1000 znakova, samo ispisivi ASCII (bez dijakritika). Nazivi ne razlikuju velika i mala slova i ne smiju se ponavljati. host, content-type, content-length, volai-signature i hop-by-hop zaglavlja isporuka postavlja sama, njih nije moguće postaviti.
  • Vrijednosti su tajne: GET /v1/webhook vraća ih samo skraćene (Bear***). Ako takvu masku pošaljete natrag, spremljena vrijednost ostaje.
  • Izostavljeni headers zadržava spremljena zaglavlja, {} ih briše. Kad URL promijenite na drugu adresu (druga domena, shema ili port, čak i dodatni www.), spremljena zaglavlja se odbacuju i vrijednosti unosite ponovno u cijelosti: ključ ne šaljemo na tuđu adresu. Ako u tom trenutku umjesto vrijednosti pošaljete masku, vraća se pogreška invalid_webhook_headers (400), jednako kao kod zabranjenog ili nevažećeg zaglavlja.
  • Pri preusmjeravanju na drugu adresu vlastita se zaglavlja ne šalju, potpis Volai-Signature šalje se. I preusmjeravanje s example.com na www.example.com je druga adresa, pa u webhook odmah unesite ciljnu.

Potpis Volai-Signature i dalje stiže uz vlastita zaglavlja. Zaglavlje kaže da zahtjev smije unutra, potpis da ga je doista poslao volai.

Ponavljanje pri pogrešci

Ako vaš endpoint ne odgovori uspješnim stanjem (2xx), volai će pokušati još dvaput - ukupno dakle 3 pokušaja s pauzom od 3 sekunde između njih. Kod događaja poziva (`call.completed`, `call.failed`, `call.no_answer`, `call.missed`) i kod `message.received` isporuka tu ne završava: ako sva tri pokušaja zakažu, događaj ostaje u redu i isporuka se u sljedećih nekoliko minuta ponovi još nekoliko puta, najviše u 5 krugova. Tijelo stoga nosi `event_id` - prema njemu isporuku filtrirate kao dupliciranu ako vam iznimno stigne više puta; ponovljena isporuka nosi potpuno isto tijelo kao prva (samo poslana nekoliko minuta kasnije). `message.sent` nema red: nakon 3 neuspjela pokušaja zapisuje se u dnevnik i više se ne šalje.

Iznimka je probni događaj niže - on se šalje samo jednom, bez ponavljanja, da odgovor dobijete odmah.

Webhook nije jedini izvor istine

Webhook nosi samo izvadak - za sve do čega vam je doista stalo (naplata, revizija, tekst poruke) radije dohvatite i cjelovit zapis iz REST API-ja: GET /v1/calls/{id} i GET /v1/messages/{id}, kad god ga zatražite.

Probni događaj

Prije nego što počnete čekati prvi stvarni poziv, isporuku provjerite odmah - POST /v1/webhook/test (vidi REST API) šalje na vaš URL jedan primjer događaja webhook.test, BEZ OBZIRA na to koje events imate označene u PUT /v1/webhook - testira se isporučivost samog URL-a i potpisa, a ne pretplata na pojedine događaje. Najviše 10 pokušaja na sat. Test šalje samo webhook.test, ne ostale događaje: oblik message.received isprobat ćete tek stvarnim SMS-om na svoj mobilni broj ili broj za pozive i SMS (primjer iznad).

Pregled isporuka

Posljednjih 50 isporuka (uspješnih, neuspjelih i probnih) vidjet ćete preko GET /v1/webhook/deliveries (vidi REST API), ili odmah u portalu ispod obrasca webhooka. Isti izvadak nosi i GET /v1/webhook u polju recentDeliveries.

Savjeti

  • Odgovorite 200 što brže - provjeru potpisa i spremanje ID-ja stignete odmah, težak posao (e-poruke, preračunavanja, pozivanje drugih API-ja) odgodite u pozadinu.
  • Endpoint mora biti javno dostupan preko HTTPS-a - localhost ni samopotpisani certifikat ne rade.
  • Upotrijebite data.id (ID poziva ili poruke) za filtriranje duplikata, ako vam slučajno isti događaj stigne više puta.
  • Testirajte lokalno preko tunela (npr. ngrok) i URL u PUT /v1/webhook privremeno prebacite na njegovu adresu.