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).
  • 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 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íž. Karta K6 (audit R02): 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í.
  • call.no_answer - odchozí hovor volaný nezvedl (zvonilo, bez odezvy).
  • call.missed - příchozí hovor zůstal bez odezvy (zmeškaný hovor).
  • message.sent - operátor přijal SMS k odeslání - není to potvrzení doručení.
  • message.received - na tvoje SMS číslo (doplněk SMS číslo) přišla zpráva. data nese id, from (odesílatele), to (tvoje SMS číslo), body, segments a receivedAt (časové razítko v ms). Doručuje se přes frontu s opakováním jako hovorové události. Nad 300 přijatých zpráv za hodinu na jedno SMS číslo 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í.

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

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"
    }
  }
}

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"
  }
}

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"
  }
}

message.sent

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

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

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.

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.