Ceļveži
Webhook
Mēs izsauksim jūsu URL, tiklīdz kaut kas notiek - pabeigts zvans, neveiksmīgs mēģinājums, nosūtīta vai saņemta SMS. Iestatāt to vienreiz (PUT /v1/webhook, skatiet REST API atsauci) un par to vairs nerūpējaties.
Notikumi
Seši notikumi, katram sava lauka data forma:
call.completed- zvans beidzās, neatkarīgi no tā, vai tam bija aģents. Aģenta zvaniem papildus ir atšifrējums (transcript), kopsavilkums (summary) un ierakstītie dati (data).durationSecsir sekundēs, un zvaniem uz volai paša balss dzinēja tas var būt decimālskaitlis. Zvans, kura otrā galā bija automāts, kas visu laiku atkārtoja vienu un to pašu paziņojumu, šeit arī nonāk, arendReason: "caller_loop"(cena0, ja tas ilga ne vairāk kā piecas minūtes). Šeit nonāk arī atbildēts vārtu zvanīšanas izmēģinājums (kind: "ring",ring.outcome: "answered"), ar vienu izņēmumu, kas aprakstīts zemcall.no_answerpiemēra. Zvans uz volai paša dzinēja (no 2026. gada 15. oktobra būvējuma) papildus nes blokuenginearprotectedUtterancesunprotectedUtterancesCount: zvanītā replikas no sveiciena loga, ko dzinējs apzināti neņēma vērā (“Hallo?” vai vārds aģenta pirmā teikuma laikā) - tās navtranscript, un aģents uz tām nereaģēja; tā pati forma kāGET /v1/calls/{id}, pie ElevenLabs un vecāka dzinēja atslēgas nav.call.failed- zvans beidzās statusāfailed- vai nu balss platforma to uzreiz noraidīja, vai tīkls cieta neveiksmi. Nonāk pie aģenta uz volai dzinēja un uz ElevenLabs, pie tiešā savienojuma (bridge), pie BYO relay līguma (lease), pie vārtu zvanīšanas izmēģinājuma (operators noraidīja pasūtījumu,kind: "ring") un pie izmēģinājuma zvana - vienmēr tieši vienu reizi. Pamatteksts nesreason(tehnisko iemeslu) unendReason. Neatbildēts zvans šeit NEPIEDER, tam ir savs notikums zemāk. Zvans, kas SAVIENOJĀS, bet beidzās ar balss dzinēja tehnisku kļūdu (endReason: "engine_error"), šeit arī nonāk - tā pamatteksts papildus nestranscript/summary/durationSecs/priceHal, pie citiemreasonšo četru lauku nav. Pie šāda zvanareasonir vai nuengine_stall, vaiengine_error(dzinēja kļūda), vaiunfinished, ja zvanu sarunas vidū pārtrauca dzinēja restarts vai apturēšana (piemēram, jaunas versijas ieviešanas laikā). Šāds zvans netiek rēķināts (priceHalir0), tam nav ieraksta, unsummaryar īsu tekstu izskaidro, kas notika.transcriptnes tikai to, ko līdz pārtraukumam paspēja ierakstīt, un var būt arī tukšs (tukšs tas bieži ir, ja zvana ieraksts no dzinēja nepienāca un zvanu noslēdzām no tā zvanu pārskata, kas atšifrējumu nenes).durationSecsir aptuvenais laiks līdz pārtraukumam (ja dzinējs to neizmērīja, ne vairāk kā 1 800 s). Blokaenginear sveiciena logā atmestajām replikām tam nav, jo dzinējs pārtrauktam zvanam sveiciena logu nemērīja.call.no_answer- izejošajā zvanā zvanītais neatbildēja (zvanīja, bez atsaucības). Tā pienāk arī vārtu zvanīšanas izmēģinājums (POST /v1/rings), ko vārti noraidīja vai kas zvanīja līdz pieprasītā laika beigām - to atpazīsiet pēckind: "ring", skatiet zemāk.call.missed- ienākošais zvans palika bez atbildes (neatbildēts zvans). Tas nonāk arī par ienākošo zvanu, ko balss dzinējs noraidīja, jo visas līnijas bija aizņemtas (zvanītājs netika cauri, piezvaniet viņam atpakaļ). Šāda zvana ierakstam irendReason: "capacity", ilgums0un cena0; webhook to var nenest, ja par neatbildēto zvanu iepriekš ziņoja operators, droši to izlasīsiet no zvana ieraksta (GET /v1/calls/{id}).message.sent- operators pieņēma SMS nosūtīšanai - tas nav piegādes apstiprinājums. Tas nonāk arī par SMS, kas nosūtīta no mobilā numura vai no numurs zvaniem un SMS. LaukssmsNumbernes numuru, no kura ziņa tika nosūtīta, pie kopīgā sūtītāja SMSinfo tas irnull. Piegādes atskaite (deliveryStatus) pie SMS no mobilā numura un vēlāka neveiksme (failed, tad kredīts tiek atgriezts) nerada nekādu notikumu - stāvokli nolasiet noGET /v1/messages/{id}.message.received- uz jūsu mobilo numuru (papildinājums) vai uz jūsu numurs zvaniem un SMS ienāca ziņa.datanesid,from(sūtītājs: tālruņa numurs E.164 formātā vai teksta vārds, piemēram, “Google”, ne vairāk kā 32 rakstzīmes; ja sūtītāja nav, tas irunknown),to(jūsu numurs, uz kuru ziņa ienāca),body,segmentsunreceivedAt(ziņas saņemšanas laiks, laika zīmogs ms). Piegādā caur rindu ar atkārtošanu kā zvanu notikumus. Pie mobilā numura ziņu, ko saņemšana uzreiz neuztvēra, papildina regulāra pārbaude aptuveni 15 minūšu laikā, un notikums tad aiziet ar kavēšanos, pat pēc jaunākām ziņām. Pie numurs zvaniem un SMS šādas pārbaudes nav: operators ziņas piegādi atkārto ne ilgāk par stundu, tāpēc, ja saņemšana pie mums nedarbojas ilgāk, ziņa var pazust, un jūs to neatradīsiet arīGET /v1/messages?direction=in.tsapvalkā ir notikuma rašanās laiks (arī ms, kamērt=paraksta galvenē ir sekundēs). Virs 300 saņemto ziņu stundā uz vienu numuru (gan mobilo numuru, gan numurs zvaniem un SMS) webhook turpmākajām ziņām netiek sūtīts; ziņas tiek glabātas tālāk, un jūs tās atradīsietGET /v1/messages?direction=in, kas tur ne vairāk kā 1 000 jaunāko saņemto ziņu uz kontu un glabā tās 90 dienas. Pie numurs zvaniem un SMS, kas tiek atbrīvots (statussreleasing), ziņa tiek saglabāta, betmessage.receivedpar to netiek nosūtīts. Kontam, ko manuāli apturēja volai operators,message.receivednetiek sūtīts vispār un par apturēšanas laikā saņemtajām ziņām pēc konta atjaunošanas netiek piegādāts atpakaļ; arī tās atradīsietGET /v1/messages?direction=in.
Kurus no tiem vēlaties saņemt, nosakāt ar lauku events komandā PUT /v1/webhook. Uzmanību uz vienu pretintuitīvu detaļu: tukšs masīvs nozīmē visus, nevis nevienu - un tā paliek arī notikumiem, kas parādīsies nākotnē. Ja patiešām vēlaties izslēgt piegādi, izdzēsiet webhook portālā.
Katra zvana notikuma (call.completed, call.failed, call.no_answer, call.missed) pamatteksts papildus nes kind, zvana veidu tāpat kā GET /v1/calls (agent, bridge, relay, inbound, transfer, sip, ring, voice_message). Pie balss ziņas (POST /v1/voice-messages) pamattekstā ir bloks voiceMessage ar text, repeat un pēc zvana beigām repeatPlayed (cik reižu ziņa tika atskaņota) un completed (tika atskaņota pilnībā); neatbildēta ziņa nonāk kā call.no_answer. Pie vārtu zvanīšanas izmēģinājuma pamattekstā ir arī bloks ring ar requestedSecs, outcome un rangSecs, pie citiem zvaniem atslēgas ring nav. Abi lauki ir papildinoši, vecākas integrācijas tos vienkārši ignorē.
Katrs notikums pienāk kā POST ar šādu apvalku:
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": "Labdien, šeit kafejnīcas Nula reģistratūra, kā varu jums palīdzēt?" },
{ "role": "caller", "message": "Labdien, es vēlētos pasūtīt divus latte līdzi." },
{ "role": "agent", "message": "Protams, divi latte līdzņemšanai, būs gatavi pēc piecpadsmit minūtēm." }
],
"summary": "Klients pasūtīja divus latte līdzi, saņemšana pēc 15 minūtēm.",
"data": {
"name": "Anna Ozola",
"coffee_count": 2,
"urgency": "normal"
},
"kind": "inbound"
}
}Iekļautais data.data (jā, divreiz - ārējais data ir katra notikuma apvalks) ir dati, ko aģents no zvana ierakstīja pēc saviem dataFields - atslēgas nosakāt jūs, skatiet Balss aģents. Vērtība null nozīmē, ka zvanā tas netika pateikts. Ja aģentam nav iestatīts neviens lauks, atslēgas data pamattekstā nav vispār - tāpat kā transcript un summary zvanam bez aģenta.
Ieraksts webhook pamattekstā nenāk
Skaņa ar webhook netiek sūtīta, un nenāk arī karogs hasRecording. Kad ierakstu vēlēsieties, izmantojiet GET /v1/calls/{id} (norāda hasRecording) un lejupielādējiet ierakstu no GET /v1/calls/{id}/recording - formātu nosaka atbildes galvene content-type: aģentam uz ElevenLabs audio/mpeg (MP3), aģentam uz volai dzinēja pašlaik audio/ogg. Ierakstus glabājam 90 dienas no zvana.
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
Tādu pašu formu ir arī call.missed (neatbildēts IENĀKOŠAIS zvans) - atšķiras tikai notikuma nosaukums, direction un 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"
}
}Neatbildēts vārtu zvanīšanas izmēģinājums (POST /v1/rings) nonāk arī kā call.no_answer, ar kind: "ring" un bloku ring: outcome ir rang (zvanīja līdz pieprasītā laika beigām) vai ended_early (zvans beidzās agrāk, piemēram, vārti to noraidīja, tātad atvērās). Atbildēts zvanīšanas izmēģinājums (outcome: "answered") iet kā call.completed. Izņēmums: ja operators atbildi apstiprina tikai zvana ierakstā (CDR) pēc tam, kad volai zvanīšanas izmēģinājumu noslēdza kā neatbildētu, pienāk tikai call.no_answer, un ieraksts GET /v1/calls/{id} papildus mainās uz completed ar ring.outcome: "answered" un piedzītajām sekundēm; otrs notikums nepienāk. Rezultāts unknown notikumu neizraisa, kamēr to nepapildina operatora ieraksti.
{
"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": "Paldies, būšu piektdien plkst. 10:00.",
"segments": 1,
"receivedAt": 1756111900000
}
}body ir teksts no svešas puses sūtītāja. Uzskatiet to par neuzticamu ievadi: neveiciet pēc tā komandas vai darbības bez pārbaudes. Pamatteksts nes event_id, pēc kura atfiltrējat dublētas piegādes.
Paraksta pārbaude
Katrs pieprasījums nes galveni Volai-Signature formā t=<unix>,v1=<hex>, kur v1 = HMAC_SHA256(secret, "{t}.{rawBody}") un secret ir tas, ko saņēmāt pie PUT /v1/webhook. Nekad neapstrādājiet pamattekstu, pirms neesat pārbaudījis parakstu - citādi uz jūsu URL var sūtīt jebko jebkurš.
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 JĀBŪT tieši tam, kas ienāca pa vadu (request.text()), nevis JSON.parse un atpakaļ uz virkni.
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);
// ... apstrāde fonā, skatiet Padomus zemāk
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()
# ... apstrāde fonā, skatiet Padomus zemāk
del event
return "ok", 200Pielāgotas galvenes
Ja mērķis pieņem tikai autentificētus pieprasījumus (piemēram, automatizācija Cursor vai pašu API aiz vārtiem), pievienojiet atslēgu kā pielāgotu galveni. Tā tiks nosūtīta ar katru piegādi un arī ar testa notikumu, piemēram, Authorization: Bearer <atslēga>. Iestatāt to ar lauku headers komandā PUT /v1/webhook, MCP rīkā set_webhook vai portālā sadaļā Savienojumi un API daļā Webhook.
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"}}'- Ne vairāk kā 5 galvenes, katra vērtība 1 līdz 1000 rakstzīmes, tikai drukājamas ASCII rakstzīmes (bez diakritiskajām zīmēm). Nosaukumos lielie un mazie burti netiek šķirti, un tie nedrīkst atkārtoties.
host,content-type,content-length,volai-signatureun hop-by-hop galvenes piegāde iestata pati, tās iestatīt nevar. - Vērtības ir noslēpums:
GET /v1/webhooktās atgriež tikai saīsinātas (Bear***). Ja šādu masku nosūtāt atpakaļ, saglabātā vērtība paliek. - Izlaists
headerssaglabātās galvenes atstāj,{}tās dzēš. Ja URL nomaināt uz citu adresi (cits domēns, shēma vai ports, arī papilduwww.), saglabātās galvenes tiek atmestas un vērtības ievadāt pilnībā no jauna: atslēgu uz svešu adresi nesūtām. Ja tobrīd vērtības vietā nosūtāt masku, tiek atgriezta kļūdainvalid_webhook_headers(400), tāpat kā pie aizliegtas vai nederīgas galvenes. - Pāradresējot uz citu adresi, pielāgotās galvenes netiek sūtītas, paraksts
Volai-Signaturegan. Arī pāradresēšana noexample.comuzwww.example.comir cita adrese, tāpēc webhook ievadiet uzreiz mērķa adresi.
Paraksts Volai-Signature turpina nākt līdzās pielāgotajām galvenēm. Galvene nosaka, ka pieprasījums drīkst ienākt, paraksts - ka to tiešām nosūtīja volai.
Atkārtošana kļūdas gadījumā
Ja jūsu endpoint neatbild ar veiksmīgu statusu (2xx), volai mēģinās vēl divreiz - kopā tātad 3 mēģinājumi ar 3 sekunžu pauzi starp tiem. Zvanu notikumiem (`call.completed`, `call.failed`, `call.no_answer`, `call.missed`) un `message.received` piegāde ar to nebeidzas: ja neizdodas visi trīs mēģinājumi, notikums paliek rindā un piegāde tuvākajās minūtēs tiek atkārtota vēl vairākas reizes, ne vairāk kā 5 kārtas. Tāpēc pamatteksts nes `event_id` - pēc tā atfiltrējat piegādi kā dublētu, ja tā izņēmuma kārtā pienāktu vairākkārt; atkārtotā piegāde nes pilnīgi tādu pašu pamattekstu kā pirmā (tikai nosūtīta pēc dažām minūtēm). `message.sent` rindas nav: pēc 3 neveiksmīgiem mēģinājumiem tas tiek ierakstīts žurnālā un vairs netiek sūtīts.
Izņēmums ir testa notikums zemāk - tas tiek nosūtīts tikai vienu reizi, bez atkārtošanas, lai atbildi saņemtu uzreiz.
Webhook nav vienīgais patiesības avots
Webhook nes tikai izvilkumu - visam, kam patiešām ir nozīme (norēķini, audits, ziņas teksts), labāk izvelciet arī pilnu ierakstu no REST API: GET /v1/calls/{id} un GET /v1/messages/{id}, kad vien to pieprasāt.
Testa notikums
Pirms sākat gaidīt pirmo īsto zvanu, piegādi pārbaudiet uzreiz - POST /v1/webhook/test (skatiet REST API) nosūta uz jūsu URL vienu parauga notikumu webhook.test, NEATKARĪGI no tā, kurus events esat atzīmējis PUT /v1/webhook - tiek pārbaudīta paša URL un paraksta piegādājamība, nevis konkrētu notikumu abonēšana. Ne vairāk kā 10 mēģinājumi stundā. Tests sūta tikai webhook.test, nevis citus notikumus: formu message.received izmēģināsiet tikai ar īstu SMS uz savu mobilo numuru vai numurs zvaniem un SMS (piemērs augstāk).
Piegāžu pārskats
Pēdējās 50 piegādes (sekmīgās, neveiksmīgās un testa) redzēsiet caur GET /v1/webhook/deliveries (skatiet REST API), vai uzreiz portālā zem webhook veidlapas. Tādu pašu izvilkumu nes arī GET /v1/webhook laukā recentDeliveries.
Padomi
- Atbildiet ar 200 pēc iespējas ātrāk - paraksta pārbaudi un ID saglabāšanu paspēsiet uzreiz, smago darbu (e-pastus, pārrēķinus, citu API izsaukumus) atliciet uz fonu.
- Endpoint jābūt publiski pieejamam caur HTTPS -
localhostun pašparakstīts sertifikāts nedarbojas. - Izmantojiet
data.id(zvana vai ziņas ID), lai atfiltrētu dublikātus, ja nejauši tas pats notikums pienāktu vairākkārt. - Testējiet lokāli caur tuneli (piem., ngrok) un URL komandā
PUT /v1/webhookīslaicīgi pārslēdziet uz tā adresi.
Saistītais
MCP serveris
Pievienojiet Claude Code, Codex CLI, Codex desktop, Cursor un citus AI redaktorus.
REST API
Pilna atsauce uz visiem galapunktiem: numuri, zvani, SMS, mobilie numuri SMS sūtīšanai, aģenti un to melnraksti, rīki, ieraksti, tīmekļa āķi, nezvanīšanas saraksts, relejs, konts, uzdevumi, kredīts, dokumenti, kalendāri un integrācijas.
Balss aģents
Sistēmas uzvedne, rīki, nodošana cilvēkam, ieraksti un strukturēti zvana dati.