Ghiduri
Webhookuri
Apelăm URL-ul dumneavoastră de îndată ce se întâmplă ceva - un apel încheiat, o încercare eșuată, un SMS trimis sau primit. Setați o singură dată (PUT /v1/webhook, vezi API-ul REST) și apoi nu mai aveți de ce să vă ocupați.
Evenimente
Șase evenimente, fiecare cu propria formă a câmpului data:
call.completed- apelul s-a încheiat, indiferent dacă a avut agent sau nu. La apelurile cu agent conține în plus transcrierea (transcript), rezumatul (summary) și datele înregistrate (data).durationSecseste în secunde și, la apelurile pe motorul vocal propriu al volai, poate fi un număr zecimal. Un apel la celălalt capăt al căruia se afla un automat care repeta mereu același mesaj ajunge și el aici, cuendReason: "caller_loop"(preț0, dacă a durat cel mult cinci minute). Aici ajunge și un apel de probă (ring) al porții care a fost preluat (kind: "ring",ring.outcome: "answered"), cu o singură excepție descrisă sub exemplulcall.no_answer. Un apel pe motorul propriu volai (de la versiunea din 15. 10. 2026) poartă în plus bloculenginecuprotectedUtterancesșiprotectedUtterancesCount: replicile persoanei apelate din fereastra de salut, pe care motorul nu le-a luat în considerare intenționat („Alo?” sau numele rostit în prima frază a agentului) - nu se află întranscript, iar agentul nu a reacționat la ele; aceeași formă ca înGET /v1/calls/{id}; la ElevenLabs și la motorul mai vechi cheia lipsește.call.failed- apelul s-a încheiat în stareafailed- fie că platforma vocală l-a respins imediat, fie că a căzut rețeaua. Ajunge pentru agentul de pe motorul volai și de pe ElevenLabs, pentru conexiunea directă (bridge), pentru un lease BYO relay, pentru apelul de probă al porții (operatorul a respins comanda,kind: "ring") și pentru apelul de test - întotdeauna exact o dată. Corpul conținereason(motivul tehnic) șiendReason. Un apel nepreluat NU aparține aici, are un eveniment propriu mai jos. Un apel care s-a CONECTAT, dar s-a încheiat cu o eroare tehnică a motorului vocal (endReason: "engine_error"), ajunge și el aici - corpul lui conține în plustranscript/summary/durationSecs/priceHal, iar la celelalte valorireasonaceste patru câmpuri lipsesc. La un astfel de apel,reasoneste fieengine_stallsauengine_error(eroare a motorului), fieunfinished, când apelul a fost întrerupt de o repornire sau oprire a motorului în mijlocul convorbirii (de exemplu la implementarea unei versiuni noi). Un astfel de apel nu se taxează (priceHaleste0), nu are înregistrare, iarsummaryexplică printr-un text scurt ce s-a întâmplat.transcriptconține doar ce s-a apucat înregistra până la întrerupere și poate fi chiar gol (este gol când înregistrarea apelului nu a sosit de la motor, iar apelul l-am închis din lista lui de apeluri, care nu conține transcrierea).durationSecseste durata aproximativă până la întrerupere (dacă motorul nu a apucat s-o măsoare, cel mult 1 800 s). Bloculenginecu replicile eliminate în fereastra de salut lipsește aici, deoarece motorul nu a măsurat fereastra de salut la un apel întrerupt.call.no_answer- destinatarul unui apel efectuat nu a răspuns (a sunat, fără reacție). Tot așa ajunge și apelul de probă al porții (POST /v1/rings) pe care poarta l-a respins sau care a sunat până la sfârșitul duratei cerute - îl recunoașteți dupăkind: "ring", vezi mai jos.call.missed- un apel primit a rămas fără răspuns (apel pierdut). Ajunge și pentru un apel primit pe care motorul vocal l-a respins deoarece toate liniile erau ocupate (apelantul nu a ajuns la dumneavoastră, sunați-l înapoi). Înregistrarea unui astfel de apel areendReason: "capacity", durata0și prețul0; webhookul nu îl poate conține însă dacă despre apelul nepreluat a informat anterior operatorul, deci îl citiți în mod sigur din înregistrarea apelului (GET /v1/calls/{id}).message.sent- operatorul a acceptat SMS-ul pentru trimitere - nu este o confirmare de livrare. Evenimentul ajunge și pentru un SMS trimis de la un număr de mobil sau de la număr pentru apeluri și SMS. CâmpulsmsNumberconține numărul de la care a plecat mesajul și estenullpentru expeditorul partajat SMSinfo. Confirmarea de livrare (deliveryStatus) la un SMS de la un număr de mobil și nici eșecul ulterior (failed, creditul se returnează apoi) nu declanșează niciun eveniment - citiți starea dinGET /v1/messages/{id}.message.received- a sosit un mesaj pe numărul dumneavoastră de mobil (add-onul număr de mobil) sau pe număr pentru apeluri și SMS al dumneavoastră.dataconțineid,from(expeditorul: un număr de telefon în format E.164 sau un nume text, de exemplu „Google”, cel mult 32 de caractere; dacă expeditorul lipsește, esteunknown),to(numărul dumneavoastră pe care a sosit mesajul),body,segmentsșireceivedAt(momentul primirii mesajului, marcă de timp în ms). Se livrează printr-o coadă cu reîncercări, ca evenimentele de apel. La un număr de mobil, un mesaj pe care recepția nu l-a captat imediat este completat de o verificare periodică în aproximativ 15 minute, iar evenimentul pleacă apoi cu întârziere, chiar și după mesaje mai noi. La număr pentru apeluri și SMS nu există o astfel de verificare: operatorul repetă livrarea mesajului cel mult o oră, deci dacă recepția la noi nu funcționează mai mult timp, mesajul se poate pierde și nu îl veți găsi nici înGET /v1/messages?direction=in.tsdin înveliș este momentul apariției evenimentului (tot în ms, pe cândt=din antetul semnăturii este în secunde). Peste 300 de mesaje primite pe oră pe un număr (număr de mobil sau număr pentru apeluri și SMS), webhookul nu se mai trimite pentru mesajele următoare; mesajele se salvează în continuare și le găsiți înGET /v1/messages?direction=in, care păstrează cel mult 1 000 de mesaje primite, cele mai noi, pe cont, și le păstrează 90 de zile. Pe un număr pentru apeluri și SMS care se eliberează (stareareleasing), mesajul se salvează, darmessage.receivednu se trimite pentru el. Unui cont suspendat manual de operatorul volai nu i se trimite delocmessage.received, iar pentru mesajele primite în timpul suspendării nu se livrează retroactiv după reactivarea contului; și pe acestea le găsiți înGET /v1/messages?direction=in.
Pe care dintre ele doriți să le primiți, spuneți prin câmpul events din PUT /v1/webhook. Atenție la un detaliu contraintuitiv: un câmp gol înseamnă toate, nu niciunul - și rămâne așa și pentru evenimentele care vor apărea în viitor. Dacă doriți cu adevărat să opriți livrarea, ștergeți webhookul în portal.
Corpul fiecărui eveniment de apel (call.completed, call.failed, call.no_answer, call.missed) conține în plus kind, tipul apelului, la fel ca în GET /v1/calls (agent, bridge, relay, inbound, transfer, sip, ring, voice_message). La un mesaj vocal (POST /v1/voice-messages), corpul conține blocul voiceMessage cu text, repeat și, după încheierea apelului, repeatPlayed (de câte ori a fost redat mesajul) și completed (a fost redat integral); un mesaj nepreluat ajunge ca call.no_answer. La apelul de probă al porții, corpul conține și blocul ring cu requestedSecs, outcome și rangSecs, iar la celelalte apeluri cheia ring lipsește. Ambele câmpuri sunt aditive, integrările mai vechi pur și simplu le ignoră.
Fiecare eveniment sosește ca POST cu acest înveliș:
call.completed
{
"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": "Bună ziua, aici recepția cafenelei Nula, cu ce vă pot ajuta?" },
{ "role": "caller", "message": "Bună ziua, aș dori să comand două latte la pachet." },
{ "role": "agent", "message": "Desigur, două latte la pachet, vor fi gata în cincisprezece minute." }
],
"summary": "Clientul a comandat două latte la pachet, ridicare peste 15 minute.",
"data": {
"jmeno": "Ioana Popescu",
"pocet_kav": 2,
"spechalo": "bezna"
},
"kind": "inbound"
}
}Câmpul imbricat data.data (da, de două ori - data exterior este învelișul fiecărui eveniment) conține datele pe care agentul le-a înregistrat din apel conform dataFields ale sale - cheile le stabiliți dumneavoastră, vezi Agent vocal. Valoarea null înseamnă că acel lucru nu a fost spus în apel. Dacă nu aveți niciun câmp setat la agent, cheia data nu se află deloc în corp - la fel ca transcript și summary la un apel fără agent.
Înregistrarea nu vine în corpul webhookului
Sunetul nu se trimite prin webhook și nici indicatorul hasRecording nu vine. Când aveți nevoie de înregistrare, apelați GET /v1/calls/{id} (vă spune hasRecording) și descărcați înregistrarea din GET /v1/calls/{id}/recording - formatul îl indică antetul content-type al răspunsului: la agentul de pe ElevenLabs audio/mpeg (MP3), la agentul de pe motorul volai în prezent audio/ogg. Înregistrările le păstrăm 90 de zile de la apel.
call.failed
{
"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
Aceeași formă o are și call.missed (un apel PRIMIT pierdut) - diferă doar numele evenimentului, direction și status.
{
"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"
}
}Un apel de probă (ring) nepreluat al porții (POST /v1/rings) ajunge tot ca call.no_answer, cu kind: "ring" și blocul ring: outcome este rang (a sunat până la sfârșitul duratei cerute) sau ended_early (apelul s-a încheiat mai devreme, de exemplu poarta l-a respins, adică s-a deschis). Un apel de probă preluat (outcome: "answered") pleacă ca call.completed. Excepție: dacă operatorul confirmă preluarea abia în înregistrarea apelului (CDR), după ce volai a închis apelul de probă ca nepreluat, sosește doar call.no_answer, iar înregistrarea din GET /v1/calls/{id} se modifică ulterior în completed cu ring.outcome: "answered" și secundele taxate suplimentar; al doilea eveniment nu mai sosește. Rezultatul unknown nu declanșează niciun eveniment până când nu este completat din înregistrările operatorului.
{
"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
{
"event": "message.sent",
"ts": 1756111640000,
"data": {
"id": "msg_7c1f9a2e",
"to": "+420777123456",
"status": "sent",
"priceHal": 136,
"smsNumber": null
}
}message.received
{
"event": "message.received",
"ts": 1756111900000,
"event_id": "evt_4kTz_P8wQmL2Xc9RvNdA1",
"data": {
"id": "msg_3d9b5e71",
"from": "+420777123456",
"to": "+420770112233",
"body": "Mulțumesc, vin vineri la 10:00.",
"segments": 1,
"receivedAt": 1756111900000
}
}body este textul de la un expeditor străin. Tratați-l ca o intrare nesigură: nu executați pe baza lui comenzi sau acțiuni fără verificare. Corpul conține event_id, după care filtrați livrările duplicate.
Verificarea semnăturii
Fiecare cerere poartă antetul Volai-Signature în forma t=<unix>,v1=<hex>, unde v1 = HMAC_SHA256(secret, "{t}.{rawBody}"), iar secret este cel pe care l-ați primit la PUT /v1/webhook. Nu procesați niciodată corpul înainte de a verifica semnătura - altfel oricine poate trimite orice la URL-ul dumneavoastră.
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);
}
// Route handler Next.js - rawBody TREBUIE să fie exact ce a sosit pe fir (request.text()), nu JSON.parse și înapoi în 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);
// ... procesare în fundal, vezi Sfaturile de mai jos
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()
# ... procesare în fundal, vezi Sfaturile de mai jos
del event
return "ok", 200Antete proprii
Dacă destinația acceptă doar cereri autentificate (de exemplu o automatizare în Cursor sau un API propriu din spatele unei porți), adăugați cheia ca antet propriu. Se trimite cu fiecare livrare și cu evenimentul de test, de exemplu Authorization: Bearer <cheie>. Îl setați prin câmpul headers din PUT /v1/webhook, prin instrumentul MCP set_webhook sau în portal, la Conexiuni și API, în secțiunea Webhookuri.
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"}}'- Cel mult 5 antete, fiecare valoare de 1 până la 1000 de caractere, doar ASCII imprimabil (fără diacritice). Numele nu fac diferența între majuscule și minuscule și nu se pot repeta.
host,content-type,content-length,volai-signatureși antetele hop-by-hop le setează livrarea însăși, deci nu pot fi setate. - Valorile sunt secrete:
GET /v1/webhookle returnează doar prescurtate (Bear***). Dacă trimiteți o astfel de mască înapoi, valoarea salvată rămâne. - Dacă omiteți
headers, antetele salvate se păstrează, iar{}le șterge. Dacă schimbați URL-ul cu o altă adresă (alt domeniu, altă schemă sau alt port, chiar și unwww.în plus), antetele salvate se elimină, iar valorile le introduceți din nou integral: nu trimitem cheia către o adresă străină. Dacă trimiteți în acel moment o mască în loc de valoare, se returnează eroareainvalid_webhook_headers(400), la fel ca pentru un antet interzis sau invalid. - La redirecționarea către o altă adresă, antetele proprii nu se trimit, semnătura
Volai-Signatureda. Și redirecționarea de laexample.comlawww.example.comeste o altă adresă, de aceea introduceți în webhook direct adresa finală.
Semnătura Volai-Signature continuă să sosească alături de antetele proprii. Antetul spune că cererea are voie să intre, semnătura - că a trimis-o într-adevăr volai.
Reîncercarea în caz de eroare
Dacă endpointul dumneavoastră nu răspunde cu o stare de succes (2xx), volai mai încearcă de două ori - în total deci 3 încercări, cu o pauză de 3 secunde între ele. La evenimentele de apel (`call.completed`, `call.failed`, `call.no_answer`, `call.missed`) și la `message.received`, livrarea nu se oprește aici: dacă toate cele trei încercări eșuează, evenimentul rămâne în coadă, iar livrarea se repetă în următoarele minute de câteva ori, cel mult în 5 runde. De aceea corpul conține `event_id` - după el filtrați livrarea ca duplicat, dacă vă ajunge excepțional de mai multe ori; livrarea repetată conține exact același corp ca prima (doar trimisă cu câteva minute mai târziu). `message.sent` nu are coadă: după 3 încercări eșuate se înregistrează în jurnal și nu se mai trimite.
Excepție este evenimentul de test de mai jos - acesta se trimite o singură dată, fără reîncercări, ca să primiți răspunsul imediat.
Webhookul nu este singura sursă de adevăr
Webhookul conține doar un extras - pentru tot ce contează cu adevărat (facturare, audit, textul mesajului), preluați din API-ul REST și înregistrarea completă: GET /v1/calls/{id} și GET /v1/messages/{id}, oricând o solicitați.
Eveniment de test
Înainte de a aștepta primul apel real, verificați livrarea chiar acum - POST /v1/webhook/test (vezi API-ul REST) trimite la URL-ul dumneavoastră un eveniment de probă webhook.test, INDIFERENT ce events aveți bifate în PUT /v1/webhook - se testează livrabilitatea URL-ului și a semnăturii în sine, nu abonarea la evenimente concrete. Cel mult 10 încercări pe oră. Testul trimite doar webhook.test, nu și celelalte evenimente: forma message.received o încercați abia cu un SMS real pe numărul dumneavoastră de mobil sau pe număr pentru apeluri și SMS (exemplul de mai sus).
Prezentarea livrărilor
Ultimele 50 de livrări (reușite, eșuate și de test) le vedeți prin GET /v1/webhook/deliveries (vezi API-ul REST), sau direct în portal, sub formularul webhookului. Același extras îl conține și GET /v1/webhook în câmpul recentDeliveries.
Sfaturi
- Răspundeți cu 200 cât mai repede - verificarea semnăturii și salvarea ID-ului le faceți pe loc, munca grea (e-mailuri, recalculări, apeluri către alte API-uri) mutați-o în fundal.
- Endpointul trebuie să fie accesibil public prin HTTPS -
localhostși certificatele self-signed nu funcționează. - Folosiți
data.id(ID-ul apelului sau al mesajului) pentru a filtra duplicatele, dacă același eveniment vă ajunge din întâmplare de mai multe ori. - Testați local printr-un tunel (de ex. ngrok) și schimbați temporar URL-ul din
PUT /v1/webhookcu adresa lui.
Conexe
Server MCP
Conectați Claude Code, Codex CLI, Codex desktop, Cursor și alte editoare AI.
API REST
Referință completă pentru toate endpointurile: numere, apeluri, SMS, numere de mobil pentru SMS, agenți și schițele lor, instrumente, înregistrări, webhookuri, lista „nu apelați”, relay, cont, sarcini, credit, documente, calendare și integrări.
Agent vocal
Prompt de sistem, instrumente, predare către un om, înregistrări și date structurate despre apel.