Treceți la conținut

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). durationSecs este î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, cu endReason: "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 exemplul call.no_answer. Un apel pe motorul propriu volai (de la versiunea din 15. 10. 2026) poartă în plus blocul engine cu protectedUtterances și protectedUtterancesCount: 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ă în transcript, iar agentul nu a reacționat la ele; aceeași formă ca în GET /v1/calls/{id}; la ElevenLabs și la motorul mai vechi cheia lipsește.
  • call.failed - apelul s-a încheiat în starea failed - 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ține reason (motivul tehnic) și endReason. 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 plus transcript/summary/durationSecs/priceHal, iar la celelalte valori reason aceste patru câmpuri lipsesc. La un astfel de apel, reason este fie engine_stall sau engine_error (eroare a motorului), fie unfinished, 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ă (priceHal este 0), nu are înregistrare, iar summary explică printr-un text scurt ce s-a întâmplat. transcript conț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). durationSecs este durata aproximativă până la întrerupere (dacă motorul nu a apucat s-o măsoare, cel mult 1 800 s). Blocul engine cu 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 are endReason: "capacity", durata 0 și prețul 0; 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âmpul smsNumber conține numărul de la care a plecat mesajul și este null pentru 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 din GET /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ă. data conține id, 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, este unknown), to (numărul dumneavoastră pe care a sosit mesajul), body, segments și receivedAt (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 în GET /v1/messages?direction=in. ts din înveliș este momentul apariției evenimentului (tot în ms, pe când t= 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 în GET /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ă (starea releasing), mesajul se salvează, dar message.received nu se trimite pentru el. Unui cont suspendat manual de operatorul volai nu i se trimite deloc message.received, iar pentru mesajele primite în timpul suspendării nu se livrează retroactiv după reactivarea contului; și pe acestea le găsiți în GET /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

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

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

Aceeași formă o are și call.missed (un apel PRIMIT pierdut) - diferă doar numele evenimentului, direction și 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"
  }
}

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.

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

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);
}

// 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 });
}
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()
    # ... procesare în fundal, vezi Sfaturile de mai jos
    del event

    return "ok", 200

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

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"}}'
  • 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/webhook le 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 un www. î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ă eroarea invalid_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-Signature da. Și redirecționarea de la example.com la www.example.com este 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/webhook cu adresa lui.