Průvodci
Webhooky
Na tvoji URL zavoláme, jakmile se něco stane - dokončený hovor, neúspěšný pokus, odeslaná SMS. Nastavíš to jednou (PUT /v1/webhook, viz REST API) a dál se o nic nestaráš.
Události
Tři události, 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) a shrnutí (summary).call.failed- hovor se nepodařilo spojit (nezvedl to, obsazeno, síťová chyba).message.sent- SMS byla úspěšně odeslána.
Každá událost dorazí jako POST s tímhle obalem:
call.completed
{
"event": "call.completed",
"ts": 1756111640000,
"data": {
"id": "call_8f2ac1d4",
"direction": "in",
"from": "+420777123456",
"to": "+420601234567",
"durationSecs": 47,
"priceHal": 236,
"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."
}
}call.failed
{
"event": "call.failed",
"ts": 1756111640000,
"data": {
"id": "call_9d4e2b7f",
"direction": "out",
"from": "+420601234567",
"to": "+420777998877",
"reason": "no-answer"
}
}message.sent
{
"event": "message.sent",
"ts": 1756111640000,
"data": {
"id": "msg_7c1f9a2e",
"to": "+420777123456",
"status": "sent",
"priceHal": 136
}
}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.
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 });
}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", 200Opaková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 krátkou pauzou mezi nimi. Když selžou všechny tři, událost se zaloguje a znovu se už neposílá - v MVP není žádná fronta ani úložiště chyb, na které bys mohl sáhnout ty sám.
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š.
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 -
localhostani 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/webhookdočasně přepni na jeho adresu.