Przejdź do treści

Przewodniki

Webhooki

Wywołamy Państwa adres URL, gdy tylko coś się wydarzy - zakończone połączenie, nieudana próba, wysłany lub odebrany SMS. Konfiguruje się to jednorazowo (PUT /v1/webhook, zob. REST API), a potem nie trzeba już o nic dbać.

Zdarzenia

Sześć zdarzeń, każde z własnym kształtem pola data:

  • call.completed - połączenie się zakończyło, niezależnie od tego, czy miało agenta. W przypadku połączeń z agentem zawiera dodatkowo transkrypt (transcript), podsumowanie (summary) i zapisane dane (data). durationSecs jest w sekundach i przy połączeniach na własnym silniku głosowym volai może być liczbą dziesiętną. Połączenie, po którego drugiej stronie był automat powtarzający wciąż ten sam komunikat, przychodzi tutaj również, z endReason: "caller_loop" (cena 0, jeśli trwało najwyżej pięć minut). Tutaj przychodzi także odebrane dzwonienie na bramę (kind: "ring", ring.outcome: "answered"), z jednym wyjątkiem opisanym pod przykładem call.no_answer. Połączenie na własnym silniku volai (od wersji z 15. 10. 2026) zawiera dodatkowo blok engine z protectedUtterances i protectedUtterancesCount: wypowiedzi osoby, która odebrała, z okna powitania, które silnik celowo pominął ("Halo?" lub imię podczas pierwszego zdania agenta) - nie ma ich w transcript, a agent na nie nie reagował; kształt taki sam jak w GET /v1/calls/{id}, przy ElevenLabs i starszym silniku klucz nie występuje.
  • call.failed - połączenie zakończyło się w stanie failed - niezależnie od tego, czy platforma głosowa odrzuciła je od razu, czy zawiodła sieć. Przychodzi przy agencie na silniku volai i na ElevenLabs, przy bezpośrednim połączeniu (bridge), przy leasingu BYO relay, przy dzwonieniu na bramę (operator odrzucił zlecenie, kind: "ring") i przy połączeniu próbnym - zawsze dokładnie raz. Treść zawiera reason (przyczyna techniczna) i endReason. Połączenie nieodebrane tu NIE należy, ma własne zdarzenie poniżej. Połączenie, które zostało NAWIĄZANE, ale zakończyło się błędem technicznym silnika głosowego (endReason: "engine_error"), przychodzi tutaj również - jego treść zawiera dodatkowo transcript/summary/durationSecs/priceHal, a przy pozostałych wartościach reason tych czterech pól brak. Dla takiego połączenia reason to engine_stall albo engine_error (awaria silnika), albo unfinished, gdy połączenie przerwał w trakcie rozmowy restart lub zatrzymanie silnika (na przykład podczas wdrożenia nowej wersji). Takie połączenie nie jest rozliczane (priceHal wynosi 0), nie ma nagrania, a summary krótkim tekstem wyjaśnia, co się stało. transcript zawiera tylko to, co zdążono zapisać do przerwania, i może być pusty (bywa pusty, gdy zapis połączenia z silnika nie dotarł, a połączenie zamknęliśmy na podstawie jego listy połączeń, która nie zawiera transkryptu). durationSecs to przybliżony czas do przerwania (gdy silnik nie zdążył go zmierzyć, najwyżej 1 800 s). Blok engine z wypowiedziami odrzuconymi w oknie powitania w tym przypadku nie występuje, ponieważ silnik nie mierzył okna powitania dla przerwanego połączenia.
  • call.no_answer - połączenie wychodzące nie zostało odebrane (dzwoniło, bez odpowiedzi). W ten sposób przychodzi także dzwonienie na bramę (POST /v1/rings), które brama odrzuciła albo które dzwoniło do końca żądanego czasu - rozpoznaje się je po kind: "ring", zob. niżej.
  • call.missed - połączenie przychodzące pozostało bez odpowiedzi (połączenie nieodebrane). Przychodzi także dla połączenia przychodzącego, które silnik głosowy odrzucił, ponieważ wszystkie linie były zajęte (dzwoniący się nie dodzwonił, proszę do niego oddzwonić). Zapis takiego połączenia ma endReason: "capacity", długość 0 i cenę 0; webhook jednak nie musi go zawierać, jeśli wcześniej o nieodebranym połączeniu poinformował operator, a wiarygodnie można go odczytać z zapisu połączenia (GET /v1/calls/{id}).
  • message.sent - operator przyjął SMS do wysłania - to nie jest potwierdzenie doręczenia. Przychodzi także przy SMS wysłanym z numeru komórkowego lub z numeru typu numer do połączeń i SMS. Pole smsNumber zawiera numer, z którego wiadomość wyszła, a przy wspólnym nadawcy SMSinfo ma wartość null. Potwierdzenie doręczenia (deliveryStatus) przy SMS z numeru komórkowego ani późniejsze niepowodzenie (failed, saldo wraca) nie wywołuje żadnego zdarzenia - stan należy pobrać z GET /v1/messages/{id}.
  • message.received - na numer komórkowy (dodatek numer komórkowy) lub na numer typu numer do połączeń i SMS przyszła wiadomość. data zawiera id, from (nadawca: numer telefonu w formacie E.164 albo nazwa tekstowa, np. „Google”, najwyżej 32 znaki; jeśli nadawcy brak, jest to unknown), to (Państwa numer, na który wiadomość przyszła), body, segments i receivedAt (czas odebrania wiadomości, znacznik czasu w ms). Jest doręczane przez kolejkę z ponawianiem tak samo jak zdarzenia połączeń. W przypadku numeru komórkowego wiadomość, której odbiór nie zarejestrował od razu, uzupełnia regularna kontrola w ciągu około 15 minut i zdarzenie wychodzi wtedy z opóźnieniem, nawet po nowszych wiadomościach. Dla numeru typu numer do połączeń i SMS takiej kontroli nie ma: operator ponawia doręczenie wiadomości najwyżej przez godzinę, więc jeśli odbiór u nas nie działa dłużej, wiadomość może zginąć i nie będzie jej także w GET /v1/messages?direction=in. ts w kopercie to czas powstania zdarzenia (również w ms, podczas gdy t= w nagłówku podpisu jest w sekundach). Powyżej 300 odebranych wiadomości na godzinę na jeden numer (numer komórkowy i numer do połączeń i SMS łącznie) webhook dla kolejnych wiadomości nie jest wysyłany; wiadomości są nadal zapisywane i można je znaleźć w GET /v1/messages?direction=in, który przechowuje najwyżej 1 000 najnowszych odebranych wiadomości na konto i trzyma je 90 dni. Na numerze typu numer do połączeń i SMS, który jest zwalniany (stan releasing), wiadomość zostanie zapisana, ale message.received nie zostanie dla niej wysłane. Kontu, które ręcznie zawiesił operator volai, message.received nie jest wysyłane w ogóle, a dla wiadomości odebranych w czasie zawieszenia nie zostanie po przywróceniu konta doręczone wstecz; one również są w GET /v1/messages?direction=in.

To, które z nich mają być odbierane, określa się polem events w PUT /v1/webhook. Uwaga na jeden sprzeczny z intuicją szczegół: pusta tablica oznacza wszystkie, nie żadne - i tak zostanie również dla zdarzeń, które pojawią się w przyszłości. Aby naprawdę wyłączyć doręczanie, należy usunąć webhook w portalu.

Treść każdego zdarzenia połączenia (call.completed, call.failed, call.no_answer, call.missed) zawiera dodatkowo kind, czyli rodzaj połączenia tak samo jak w GET /v1/calls (agent, bridge, relay, inbound, transfer, sip, ring, voice_message). Przy wiadomości głosowej (POST /v1/voice-messages) w treści jest blok voiceMessage z text, repeat oraz po zakończeniu połączenia repeatPlayed (ile razy wiadomość zabrzmiała) i completed (zabrzmiała w całości); nieodebrana wiadomość przychodzi jako call.no_answer. Przy dzwonieniu na bramę w treści jest także blok ring z requestedSecs, outcome i rangSecs, przy pozostałych połączeniach klucza ring brak. Oba pola są addytywne, starsze integracje po prostu je ignorują.

Każde zdarzenie dociera jako POST z taką kopertą:

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": "Dzień dobry, tu recepcja kawiarni Nula, w czym mogę pomóc?" },
      { "role": "caller", "message": "Dzień dobry, chciałbym zamówić dwie latte na wynos." },
      { "role": "agent", "message": "Oczywiście, dwie latte na wynos, będą gotowe za piętnaście minut." }
    ],
    "summary": "Klient zamówił dwie latte na wynos, odbiór za 15 minut.",
    "data": {
      "imie": "Anna Kowalska",
      "liczba_kaw": 2,
      "pilnosc": "normalna"
    },
    "kind": "inbound"
  }
}

Zagnieżdżone data.data (tak, dwa razy - zewnętrzne data jest kopertą każdego zdarzenia) to dane, które agent zapisał z rozmowy według swoich dataFields - klucze określają Państwo sami, zob. Agent głosowy. Wartość null oznacza, że w rozmowie to nie padło. Jeśli agent nie ma ustawionych żadnych pól, klucza data w treści w ogóle nie ma - tak samo jak transcript i summary przy połączeniu bez agenta.

Nagranie nie jest przesyłane w treści webhooka

Dźwięk nie jest przesyłany webhookiem i nie przychodzi także flaga hasRecording. Gdy nagranie będzie potrzebne, należy użyć GET /v1/calls/{id} (podaje hasRecording) i pobrać nagranie z GET /v1/calls/{id}/recording - format określa nagłówek content-type odpowiedzi: przy agencie na ElevenLabs audio/mpeg (MP3), przy agencie na silniku volai obecnie audio/ogg. Nagrania przechowujemy 90 dni od połączenia.

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

Taki sam kształt ma także call.missed (nieodebrane połączenie PRZYCHODZĄCE) - różni się tylko nazwa zdarzenia, 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"
  }
}

Nieodebrane dzwonienie na bramę (POST /v1/rings) przychodzi także jako call.no_answer, z kind: "ring" i blokiem ring: outcome to rang (dzwoniło do końca żądanego czasu) albo ended_early (połączenie zakończyło się wcześniej, na przykład brama je odrzuciła, czyli się otworzyła). Odebrane dzwonienie (outcome: "answered") idzie jako call.completed. Wyjątek: gdy operator potwierdzi odebranie dopiero w zapisie połączenia (CDR) po tym, jak volai zamknęło dzwonienie jako nieodebrane, przyjdzie tylko call.no_answer, a zapis w GET /v1/calls/{id} zostanie następnie zmieniony na completed z ring.outcome: "answered" i doliczonymi sekundami; drugie zdarzenie nie przyjdzie. Wynik unknown nie wywołuje zdarzenia, dopóki nie uzupełnią go zapisy operatora.

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": "Dziękuję, przyjdę w piątek o 10:00.",
    "segments": 1,
    "receivedAt": 1756111900000
  }
}

body to tekst od obcego nadawcy. Należy traktować go jako niezaufane dane wejściowe: nie wykonywać na jego podstawie poleceń ani działań bez weryfikacji. Treść zawiera event_id, po którym można odfiltrować zduplikowane doręczenia.

Weryfikacja podpisu

Każde żądanie zawiera nagłówek Volai-Signature w postaci t=<unix>,v1=<hex>, gdzie v1 = HMAC_SHA256(secret, "{t}.{rawBody}"), a secret to ten, który otrzymano przy PUT /v1/webhook. Nigdy nie należy przetwarzać treści, zanim podpis zostanie zweryfikowany - w przeciwnym razie każdy może wysłać cokolwiek na Państwa adres URL.

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 MUSI być dokładnie tym, co przyszło po sieci (request.text()), a nie JSON.parse i z powrotem 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);
  // ... przetwarzanie w tle, zob. Wskazówki poniżej
  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()
    # ... przetwarzanie w tle, zob. Wskazówki poniżej
    del event

    return "ok", 200

Własne nagłówki

Gdy cel przyjmuje tylko uwierzytelnione żądania (na przykład automatyzacja w Cursorze albo własne API za bramą), klucz należy dodać jako własny nagłówek. Zostanie wysłany z każdym doręczeniem i ze zdarzeniem testowym, na przykład Authorization: Bearer <klucz>. Ustawia się go polem headers w PUT /v1/webhook, w narzędziu MCP set_webhook albo w portalu w Połączeniach i API w części Webhooki.

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"}}'
  • Najwyżej 5 nagłówków, każda wartość od 1 do 1000 znaków, tylko drukowalne ASCII (bez znaków diakrytycznych). Nazwy nie rozróżniają wielkości liter i nie mogą się powtarzać. host, content-type, content-length, volai-signature i nagłówki hop-by-hop doręczenie ustawia samo, nie można ich ustawić.
  • Wartości są tajemnicą: GET /v1/webhook zwraca je tylko w skróconej postaci (Bear***). Jeśli taka maska zostanie odesłana z powrotem, zapisana wartość pozostaje.
  • Pominięte headers zostawia zapisane nagłówki, {} je usuwa. Gdy zmienia się URL na inny adres (inna domena, schemat lub port, także dodane www.), zapisane nagłówki są odrzucane i wartości trzeba podać ponownie w całości: klucza na obcy adres nie wysyłamy. Jeśli w tej chwili zamiast wartości zostanie wysłana maska, zwracany jest błąd invalid_webhook_headers (400), tak samo jak przy zabronionym lub nieprawidłowym nagłówku.
  • Przy przekierowaniu na inny adres własne nagłówki nie są wysyłane, podpis Volai-Signature tak. Także przekierowanie z example.com na www.example.com to inny adres, dlatego w webhooku należy podać od razu adres docelowy.

Podpis Volai-Signature nadal przychodzi obok własnych nagłówków. Nagłówek mówi, że żądanie może wejść, podpis - że naprawdę wysłało je volai.

Ponawianie w razie błędu

Jeśli Państwa endpoint nie odpowie statusem sukcesu (2xx), volai spróbuje jeszcze dwa razy - łącznie więc 3 próby z 3-sekundową przerwą między nimi. W przypadku zdarzeń połączeń (`call.completed`, `call.failed`, `call.no_answer`, `call.missed`) i `message.received` na tym doręczanie się nie kończy: gdy wszystkie trzy próby zawiodą, zdarzenie pozostaje w kolejce, a doręczenie jest jeszcze kilkakrotnie ponawiane w kolejnych minutach, najwyżej do 5 rund. Treść zawiera dlatego `event_id` - po nim można odfiltrować doręczenie jako duplikat, gdyby wyjątkowo dotarło wielokrotnie; ponowne doręczenie zawiera dokładnie taką samą treść jak pierwsze (tylko wysłaną kilka minut później). `message.sent` kolejki nie ma: po 3 nieudanych próbach jest zapisywane w logu i nie jest już wysyłane.

Wyjątkiem jest zdarzenie testowe poniżej - jest wysyłane tylko raz, bez ponawiania, aby odpowiedź przyszła od razu.

Webhook nie jest jedynym źródłem prawdy

Webhook zawiera tylko wyciąg - w przypadku wszystkiego, na czym naprawdę zależy (fakturowanie, audyt, treść wiadomości), lepiej pobrać także pełny zapis z REST API: GET /v1/calls/{id} i GET /v1/messages/{id}, kiedy tylko będzie potrzebny.

Zdarzenie testowe

Zanim zacznie się czekać na pierwsze prawdziwe połączenie, można od razu sprawdzić doręczanie - POST /v1/webhook/test (zob. REST API) wyśle na Państwa adres URL jedno przykładowe zdarzenie webhook.test, BEZ WZGLĘDU na to, które events zaznaczono w PUT /v1/webhook - testowana jest możliwość doręczenia na sam URL i podpis, a nie subskrypcja konkretnych zdarzeń. Najwyżej 10 prób na godzinę. Test wysyła tylko webhook.test, nie inne zdarzenia: kształt message.received można sprawdzić dopiero prawdziwym SMS-em na swój numer komórkowy lub numer typu numer do połączeń i SMS (przykład wyżej).

Przegląd doręczeń

Ostatnie 50 doręczeń (udanych, nieudanych i testowych) można zobaczyć przez GET /v1/webhook/deliveries (zob. REST API), albo od razu w portalu pod formularzem webhooka. Ten sam wyciąg zawiera GET /v1/webhook w polu recentDeliveries.

Wskazówki

  • Należy odpowiedzieć 200 jak najszybciej - weryfikację podpisu i zapis ID da się wykonać od razu, ciężką pracę (e-maile, przeliczenia, wywołania innych API) warto odsunąć w tło.
  • Endpoint musi być publicznie dostępny przez HTTPS - localhost ani certyfikat self-signed nie działają.
  • Użyj data.id (ID połączenia lub wiadomości) do odfiltrowania duplikatów, gdyby przypadkiem to samo zdarzenie dotarło wielokrotnie.
  • Testuj lokalnie przez tunel (np. ngrok) i tymczasowo przełącz URL w PUT /v1/webhook na jego adres.