Přeskočit na obsah

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). durationSecs je 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é, s endReason: "caller_loop" (cena 0, 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ázkou call.no_answer. Hovor na vlastním motoru volai (od sestavení z 15. 10. 2026) nese navíc blok engine s protectedUtterances a protectedUtterancesCount: repliky volaného z okna pozdravu, které motor záměrně nevzal ("Haló?" nebo jméno během první věty agenta) - nejsou v transcript a agent na ně nereagoval; stejný tvar jako v GET /v1/calls/{id}, u ElevenLabs a staršího motoru klíč chybí.
  • call.failed - hovor skončil ve stavu failed - 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 nese reason (technický důvod) a endReason. 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 nese transcript/summary/durationSecs/priceHal, u ostatních reason tahle čtyři pole chybí. U takového hovoru je reason buď engine_stall nebo engine_error (chyba motoru), nebo unfinished, když hovor přerušil restart nebo zastavení motoru uprostřed hovoru (třeba při nasazení nové verze). Takový hovor se neúčtuje (priceHal je 0), nemá nahrávku a summary krátkým textem vysvětluje, co se stalo. transcript nese 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). durationSecs je přibližná doba do přerušení (když ji motor nestihl změřit, nejvýš 1 800 s). Blok engine s 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 podle kind: "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élku 0 a cenu 0; 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. Pole smsNumber nese číslo, ze kterého zpráva odešla, u sdíleného odesílatele SMSinfo je null. 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 z GET /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. data nese id, from (odesílatel: telefonní číslo v E.164, nebo textové jméno, např. „Google“, nejvýš 32 znaků; chybí-li odesílatel, je to unknown), to (tvoje číslo, na které zpráva přišla), body, segments a receivedAt (č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 v GET /v1/messages?direction=in. ts v obálce je čas vzniku události (také v ms, kdežto t= 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 v GET /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 (stav releasing), se zpráva uloží, ale message.received se pro ni neodešle. Účtu, který ručně pozastavil provozovatel volai, se message.received neposílá vůbec a pro zprávy přijaté za pozastavení se po obnovení účtu zpětně nedoručí; i ty najdeš v GET /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

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ý 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

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

Stejný tvar má i call.missed (zmeškaný PŘÍCHOZÍ hovor) - liší se jen jméno události, 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"
  }
}

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.

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": "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.

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Í 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 });
}
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()
    # ... zpracování na pozadí, viz Tipy níže
    del event

    return "ok", 200

Vlastní 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.

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"}}'
  • 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-signature a hop-by-hop hlavičky si doručení nastavuje samo, ty nastavit nejde.
  • Hodnoty jsou tajemství: GET /v1/webhook je vrací jen zkrácené (Bear***). Pošleš-li takovou masku zpátky, zůstane uložená hodnota.
  • Vynechané headers uložené hlavičky ponechá, {} je smaže. Když změníš URL na jinou adresu (jiná doména, schéma nebo port, i www. 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 chyba invalid_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-Signature ano. I přesměrování z example.com na www.example.com je 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 - localhost ani 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/webhook dočasně přepni na jeho adresu.