Pāriet uz saturu

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). durationSecs ir 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, ar endReason: "caller_loop" (cena 0, 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 zem call.no_answer piemēra. Zvans uz volai paša dzinēja (no 2026. gada 15. oktobra būvējuma) papildus nes bloku engine ar protectedUtterances un protectedUtterancesCount: 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 nav transcript, 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 nes reason (tehnisko iemeslu) un endReason. 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 nes transcript/summary/durationSecs/priceHal, pie citiem reason šo četru lauku nav. Pie šāda zvana reason ir vai nu engine_stall, vai engine_error (dzinēja kļūda), vai unfinished, 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 (priceHal ir 0), tam nav ieraksta, un summary ar īsu tekstu izskaidro, kas notika. transcript nes 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). durationSecs ir aptuvenais laiks līdz pārtraukumam (ja dzinējs to neizmērīja, ne vairāk kā 1 800 s). Bloka engine ar 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ēc kind: "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 ir endReason: "capacity", ilgums 0 un cena 0; 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. Lauks smsNumber nes numuru, no kura ziņa tika nosūtīta, pie kopīgā sūtītāja SMSinfo tas ir null. 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 no GET /v1/messages/{id}.
  • message.received - uz jūsu mobilo numuru (papildinājums) vai uz jūsu numurs zvaniem un SMS ienāca ziņa. data nes id, 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 ir unknown), to (jūsu numurs, uz kuru ziņa ienāca), body, segments un receivedAt (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. ts apvalkā ir notikuma rašanās laiks (arī ms, kamēr t= 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īsiet GET /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 (statuss releasing), ziņa tiek saglabāta, bet message.received par to netiek nosūtīts. Kontam, ko manuāli apturēja volai operators, message.received netiek 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īsiet GET /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

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

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

Tādu pašu formu ir arī call.missed (neatbildēts IENĀKOŠAIS zvans) - atšķiras tikai notikuma nosaukums, direction un 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"
  }
}

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.

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

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 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 });
}
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()
    # ... apstrāde fonā, skatiet Padomus zemāk
    del event

    return "ok", 200

Pielā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.

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"}}'
  • 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-signature un hop-by-hop galvenes piegāde iestata pati, tās iestatīt nevar.
  • Vērtības ir noslēpums: GET /v1/webhook tās atgriež tikai saīsinātas (Bear***). Ja šādu masku nosūtāt atpakaļ, saglabātā vērtība paliek.
  • Izlaists headers saglabātās galvenes atstāj, {} tās dzēš. Ja URL nomaināt uz citu adresi (cits domēns, shēma vai ports, arī papildu www.), 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ļūda invalid_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-Signature gan. Arī pāradresēšana no example.com uz www.example.com ir 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 - localhost un 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.