Preskočiť na obsah

Sprievodcovia

Webhooky

Na Vašu URL zavoláme, hneď ako sa niečo stane - dokončený hovor, neúspešný pokus, odoslaná alebo prijatá SMS. Nastavíte to raz (PUT /v1/webhook, pozrite REST API) a ďalej sa o nič nestaráte.

Udalosti

Šesť udalostí, každá s vlastným tvarom poľa data:

  • call.completed - hovor skončil, či už mal agenta, alebo nie. Pri agentných hovoroch obsahuje navyše prepis (transcript), zhrnutie (summary) a zapísané údaje (data). durationSecs je v sekundách a pri hovoroch na vlastnom hlasovom motore volai môže byť desatinné číslo. Hovor, na druhom konci ktorého bol automat opakujúci stále tú istú hlášku, sem chodí tiež, s endReason: "caller_loop" (cena 0, ak trval najviac päť minút). Sem chodí aj zdvihnuté prezvonenie brány (kind: "ring", ring.outcome: "answered"), s jednou výnimkou opísanou pod ukážkou call.no_answer. Hovor na vlastnom motore volai (od zostavenia z 15. 10. 2026) nesie navyše blok engine s protectedUtterances a protectedUtterancesCount: repliky volaného z okna pozdravu, ktoré motor zámerne nezobral ("Haló?" alebo meno počas prvej vety agenta) - nie sú v transcript a agent na ne nereagoval; rovnaký tvar ako v GET /v1/calls/{id}, pri ElevenLabs a staršom motore kľúč chýba.
  • call.failed - hovor skončil v stave failed - či už ho hlasová platforma rovno odmietla, alebo zlyhala sieť. Chodí pri agentovi na motore volai aj na ElevenLabs, pri priamom spojení (bridge), pri BYO relay leaseu, pri prezvonení brány (operátor odmietol objednávku, kind: "ring") aj pri skúšobnom hovore - vždy presne raz. Telo nesie reason (technický dôvod) a endReason. Nezdvihnutý hovor sem NEPATRÍ, ten má vlastnú udalosť nižšie. Hovor, ktorý sa SPOJIL, ale skončil technickou chybou hlasového motora (endReason: "engine_error"), sem chodí tiež - jeho telo navyše nesie transcript/summary/durationSecs/priceHal, pri ostatných reason tieto štyri polia chýbajú. Pri takomto hovore je reason buď engine_stall alebo engine_error (chyba motora), alebo unfinished, keď hovor prerušil reštart alebo zastavenie motora uprostred hovoru (napríklad pri nasadení novej verzie). Takýto hovor sa neúčtuje (priceHal je 0), nemá nahrávku a summary krátkym textom vysvetľuje, čo sa stalo. transcript nesie len to, čo sa do prerušenia stihlo zaznamenať, a môže byť aj prázdny (prázdny býva, keď záznam o hovore z motora nedorazil a hovor sme uzavreli z jeho prehľadu hovorov, ktorý prepis nenesie). durationSecs je približná doba do prerušenia (keď ju motor nestihol zmerať, najviac 1 800 s). Blok engine s replikami zahodenými v okne pozdravu tu chýba, pretože motor okno pozdravu pri prerušenom hovore nemeral.
  • call.no_answer - odchádzajúci hovor volaný nezdvihol (zvonilo, bez odozvy). Takto chodí aj prezvonenie brány (POST /v1/rings), ktoré brána odmietla alebo ktoré dozvonilo do konca požadovanej doby - spoznáte ho podľa kind: "ring", pozrite nižšie.
  • call.missed - prichádzajúci hovor zostal bez odozvy (zmeškaný hovor). Chodí aj za prichádzajúci hovor, ktorý hlasový motor odmietol, pretože boli všetky linky plné (volajúci sa nedovolal, ozvite sa mu späť). Záznam takéhoto hovoru má endReason: "capacity", dĺžku 0 a cenu 0; webhook ho však nemusí niesť, keď o nezdvihnutom hovore skôr informoval operátor, spoľahlivo ho prečítate zo záznamu hovoru (GET /v1/calls/{id}).
  • message.sent - operátor prijal SMS na odoslanie - nie je to potvrdenie doručenia. Chodí aj pri SMS odoslanej z mobilného čísla alebo z čísla číslo pre hovory aj SMS. Pole smsNumber nesie číslo, z ktorého správa odišla, pri zdieľanom odosielateľovi SMSinfo je null. Doručenka (deliveryStatus) pri SMS z mobilného čísla ani dodatočné zlyhanie (failed, kredit sa potom vráti) žiadnu udalosť nevyvolá - stav načítajte z GET /v1/messages/{id}.
  • message.received - na Vaše mobilné číslo alebo na Vaše číslo pre hovory aj SMS prišla správa. data nesie id, from (odosielateľ: telefónne číslo v E.164, alebo textové meno, napr. „Google“, najviac 32 znakov; ak odosielateľ chýba, je to unknown), to (Vaše číslo, na ktoré správa prišla), body, segments a receivedAt (čas prijatia správy, časová pečiatka v ms). Doručuje sa cez frontu s opakovaním ako hovorové udalosti. Pri mobilnom čísle správu, ktorú príjem hneď nezachytil, doplní pravidelná kontrola do približne 15 minút a udalosť potom odíde s oneskorením, pokojne aj po novších správach. Pri čísle číslo pre hovory aj SMS žiadna takáto kontrola nie je: operátor doručenie správy opakuje najviac hodinu, takže keď príjem u nás nefunguje dlhšie, správa sa môže stratiť a nenájdete ju ani v GET /v1/messages?direction=in. ts v obálke je čas vzniku udalosti (tiež v ms, kým t= v hlavičke podpisu je v sekundách). Nad 300 prijatých správ za hodinu na jedno číslo (mobilné číslo aj číslo pre hovory aj SMS) sa webhook pre ďalšie správy neposiela; správy sa ďalej ukladajú a nájdete ich v GET /v1/messages?direction=in, ktorý drží najviac 1 000 najnovších prijatých správ na účet a uchováva ich 90 dní. Na čísle číslo pre hovory aj SMS, ktoré sa uvoľňuje (stav releasing), sa správa uloží, ale message.received sa pre ňu neodošle. Účtu, ktorý ručne pozastavil prevádzkovateľ volai, sa message.received neposiela vôbec a pre správy prijaté počas pozastavenia sa po obnovení účtu spätne nedoručí; aj tie nájdete v GET /v1/messages?direction=in.

Ktoré z nich chcete odoberať, určíte poľom events v PUT /v1/webhook. Pozor na jeden nelogický detail: prázdne pole znamená všetky, nie žiadnu - a zostane tak aj pre udalosti, ktoré pribudnú v budúcnosti. Keď chcete naozaj vypnúť doručovanie, webhook v portáli odstráňte.

Telo každej hovorovej udalosti (call.completed, call.failed, call.no_answer, call.missed) nesie navyše kind, druh hovoru rovnako ako v GET /v1/calls (agent, bridge, relay, inbound, transfer, sip, ring, voice_message). Pri hlasovej správe (POST /v1/voice-messages) je v tele blok voiceMessage s text, repeat a po skončení hovoru repeatPlayed (koľkokrát správa zaznela) a completed (zaznela celá); nezdvihnutá správa chodí ako call.no_answer. Pri prezvonení brány je v tele aj blok ring s requestedSecs, outcome a rangSecs, pri ostatných hovoroch kľúč ring chýba. Obe polia sú aditívne, staršie integrácie ich jednoducho ignorujú.

Každá udalosť dorazí ako POST s touto obálkou:

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": "Dobrý deň, tu recepcia kaviarne Nula, ako Vám môžem pomôcť?" },
      { "role": "caller", "message": "Dobrý deň, chcel by som si objednať dve latte so sebou." },
      { "role": "agent", "message": "Jasné, dve latte na odber, bude to hotové za pätnásť minút." }
    ],
    "summary": "Zákazník si objednal dve latte so sebou, odber o 15 minút.",
    "data": {
      "meno": "Jana Nováková",
      "pocet_kav": 2,
      "naliehavost": "bezna"
    },
    "kind": "inbound"
  }
}

Vnorené data.data (áno, dvakrát - vonkajšie data je obálka každej udalosti) sú údaje, ktoré si agent z hovoru zapísal podľa svojich dataFields - kľúče si určujete Vy, pozrite Hlasový agent. Hodnota null znamená, že to v hovore nezaznelo. Keď pri agentovi nemáte nastavené žiadne polia, kľúč data v tele vôbec nie je - rovnako ako transcript a summary pri hovore bez agenta.

Nahrávka v tele webhooku nechodí

Zvuk sa webhookom neposiela a nechodí ani príznak hasRecording. Keď budete nahrávku chcieť, siahnite po GET /v1/calls/{id} (povie hasRecording) a nahrávku stiahnite z GET /v1/calls/{id}/recording - formát určuje hlavička content-type odpovede: pri agentovi na ElevenLabs audio/mpeg (MP3), pri agentovi na motore volai dnes audio/ogg. Nahrávky držíme 90 dní od hovoru.

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

Rovnaký tvar má aj call.missed (zmeškaný PRICHÁDZAJÚCI hovor) - líši sa len meno udalosti, direction a 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"
  }
}

Nezdvihnuté prezvonenie brány (POST /v1/rings) chodí tiež ako call.no_answer, s kind: "ring" a blokom ring: outcome je rang (zvonilo do konca požadovanej doby) alebo ended_early (hovor skončil skôr, napríklad ho brána odmietla, teda sa otvorila). Zdvihnuté prezvonenie (outcome: "answered") ide ako call.completed. Výnimka: keď operátor zdvihnutie potvrdí až v zázname hovoru (CDR) potom, čo volai prezvonenie uzavrelo ako nezdvihnuté, príde len call.no_answer a záznam v GET /v1/calls/{id} sa dodatočne zmení na completed s ring.outcome: "answered" a doúčtovanými sekundami; druhá udalosť nepríde. Výsledok unknown udalosť nevyvolá, kým ho nedoplnia záznamy operátora.

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": "Vďaka, prídem v piatok o 10:00.",
    "segments": 1,
    "receivedAt": 1756111900000
  }
}

body je text od cudzieho odosielateľa. Berte ho ako nedôveryhodný vstup: nevykonávajte podľa neho príkazy ani akcie bez overenia. Telo nesie event_id, podľa ktorého odfiltrujete duplicitné doručenia.

Overenie podpisu

Každá požiadavka nesie hlavičku Volai-Signature v tvare t=<unix>,v1=<hex>, kde v1 = HMAC_SHA256(secret, "{t}.{rawBody}") a secret je ten, ktorý ste dostali pri PUT /v1/webhook. Nikdy nespracúvajte telo skôr, než podpis overíte - inak si na Vašu URL môže poslať čokoľvek ktokoľvek.

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 MUSÍ byť presne to, čo prišlo po drôte (request.text()), nie JSON.parse a späť 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);
  // ... spracovanie na pozadí, pozrite Tipy nižšie
  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()
    # ... spracovanie na pozadí, pozrite Tipy nižšie
    del event

    return "ok", 200

Vlastné hlavičky

Keď cieľ berie iba prihlásené požiadavky (napríklad automatizácia v Cursore alebo vlastné API za bránou), pridajte kľúč ako vlastnú hlavičku. Pošle sa s každým doručením aj s testovacou udalosťou, napríklad Authorization: Bearer <kľúč>. Nastavíte ju poľom headers v PUT /v1/webhook, v MCP nástroji set_webhook, alebo v portáli v Prepojeniach a API v časti Webhooky.

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"}}'
  • Najviac 5 hlavičiek, každá hodnota 1 až 1000 znakov, len tlačiteľné ASCII (bez diakritiky). Názvy nerozlišujú veľkosť písmen a nesmú sa opakovať. host, content-type, content-length, volai-signature a hop-by-hop hlavičky si doručenie nastavuje samo, tie nastaviť nejde.
  • Hodnoty sú tajomstvo: GET /v1/webhook ich vracia len skrátené (Bear***). Ak takúto masku pošlete späť, zostane uložená hodnota.
  • Vynechané headers uložené hlavičky ponechá, {} ich odstráni. Keď zmeníte URL na inú adresu (iná doména, schéma alebo port, aj www. navyše), uložené hlavičky sa zahodia a hodnoty zadáte znova celé: kľúč na cudziu adresu neposielame. Ak v tej chvíli pošlete namiesto hodnoty masku, vráti sa chyba invalid_webhook_headers (400), rovnako ako pri zakázanej alebo neplatnej hlavičke.
  • Pri presmerovaní na inú adresu sa vlastné hlavičky neposielajú, podpis Volai-Signature áno. Aj presmerovanie z example.com na www.example.com je iná adresa, do webhooku preto zadajte rovno cieľovú.

Podpis Volai-Signature chodí ďalej popri vlastných hlavičkách. Hlavička hovorí, že požiadavka smie dnu, podpis, že ju naozaj poslalo volai.

Opakovanie pri chybe

Ak Váš endpoint neodpovie úspešným stavom (2xx), volai to skúsi ešte dvakrát - spolu teda 3 pokusy s pauzou 3 sekundy medzi nimi. Pri hovorových udalostiach (`call.completed`, `call.failed`, `call.no_answer`, `call.missed`) a pri `message.received` tým doručovanie nekončí: keď zlyhajú všetky tri pokusy, udalosť zostáva vo fronte a doručenie sa v nasledujúcich minútach ešte niekoľkokrát zopakuje, najviac do 5 kôl. Telo preto nesie `event_id` - podľa neho doručenie odfiltrujete ako duplicitné, keby Vám výnimočne dorazilo viackrát; opakované doručenie nesie úplne rovnaké telo ako to prvé (len odoslané o pár minút neskôr). `message.sent` frontu nemá: po 3 neúspešných pokusoch sa zaloguje a znova sa už neposiela.

Výnimkou je testovacia udalosť nižšie - tá sa pošle len raz, bez opakovania, aby ste dostali odpoveď hneď.

Webhook nie je jediný zdroj pravdy

Webhook nesie len výťažok - pre čokoľvek, na čom naozaj záleží (fakturácia, audit, text správy), si radšej dotiahnite aj kompletný záznam z REST API: GET /v1/calls/{id} a GET /v1/messages/{id}, kedykoľvek si oň poviete.

Testovacia udalosť

Skôr než začnete čakať na prvý skutočný hovor, overte si doručovanie rovno - POST /v1/webhook/test (pozrite REST API) pošle na Vašu URL jednu ukážkovú udalosť webhook.test, BEZ OHĽADU na to, ktoré events máte v PUT /v1/webhook zaškrtnuté - testuje sa doručiteľnosť samotnej URL a podpisu, nie odber konkrétnych udalostí. Najviac 10 pokusov za hodinu. Test posiela len webhook.test, nie ostatné udalosti: tvar message.received vyskúšate až skutočnou SMS na svoje mobilné číslo alebo číslo pre hovory aj SMS (ukážka vyššie).

Prehľad doručení

Posledných 50 doručení (úspešných, neúspešných aj testovacích) uvidíte cez GET /v1/webhook/deliveries (pozrite REST API), alebo rovno v portáli pod formulárom webhooku. Rovnaký výpis nesie aj GET /v1/webhook v poli recentDeliveries.

Tipy

  • Odpovedzte 200 čo najrýchlejšie - overenie podpisu a uloženie ID stihnete hneď, ťažkú prácu (e-maily, prepočty, volania ďalších API) odložte na pozadie.
  • Endpoint musí byť verejne dostupný cez HTTPS - localhost ani self-signed certifikát nefungujú.
  • Použite data.id (ID hovoru alebo správy) na odfiltrovanie duplicít, keby Vám náhodou rovnaká udalosť dorazila viackrát.
  • Testujte lokálne cez tunel (napr. ngrok) a URL v PUT /v1/webhook dočasne prepnite na jeho adresu.