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).durationSecsjest 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ż, zendReason: "caller_loop"(cena0, 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łademcall.no_answer. Połączenie na własnym silniku volai (od wersji z 15. 10. 2026) zawiera dodatkowo blokenginezprotectedUtterancesiprotectedUtterancesCount: wypowiedzi osoby, która odebrała, z okna powitania, które silnik celowo pominął ("Halo?" lub imię podczas pierwszego zdania agenta) - nie ma ich wtranscript, a agent na nie nie reagował; kształt taki sam jak wGET /v1/calls/{id}, przy ElevenLabs i starszym silniku klucz nie występuje.call.failed- połączenie zakończyło się w staniefailed- 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ść zawierareason(przyczyna techniczna) iendReason. 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 dodatkowotranscript/summary/durationSecs/priceHal, a przy pozostałych wartościachreasontych czterech pól brak. Dla takiego połączeniareasontoengine_stallalboengine_error(awaria silnika), albounfinished, 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 (priceHalwynosi0), nie ma nagrania, asummarykrótkim tekstem wyjaśnia, co się stało.transcriptzawiera 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).durationSecsto przybliżony czas do przerwania (gdy silnik nie zdążył go zmierzyć, najwyżej 1 800 s). Blokenginez 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 pokind: "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 maendReason: "capacity", długość0i 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. PolesmsNumberzawiera 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ć zGET /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ść.datazawieraid,from(nadawca: numer telefonu w formacie E.164 albo nazwa tekstowa, np. „Google”, najwyżej 32 znaki; jeśli nadawcy brak, jest tounknown),to(Państwa numer, na który wiadomość przyszła),body,segmentsireceivedAt(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 wGET /v1/messages?direction=in.tsw kopercie to czas powstania zdarzenia (również w ms, podczas gdyt=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źć wGET /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 (stanreleasing), wiadomość zostanie zapisana, alemessage.receivednie zostanie dla niej wysłane. Kontu, które ręcznie zawiesił operator volai,message.receivednie 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ą wGET /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
{
"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
{
"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.
{
"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.
{
"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": "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.
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 });
}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", 200Wł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.
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-signaturei nagłówki hop-by-hop doręczenie ustawia samo, nie można ich ustawić. - Wartości są tajemnicą:
GET /v1/webhookzwraca je tylko w skróconej postaci (Bear***). Jeśli taka maska zostanie odesłana z powrotem, zapisana wartość pozostaje. - Pominięte
headerszostawia zapisane nagłówki,{}je usuwa. Gdy zmienia się URL na inny adres (inna domena, schemat lub port, także dodanewww.), 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łądinvalid_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-Signaturetak. Także przekierowanie zexample.comnawww.example.comto 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 -
localhostani 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/webhookna jego adres.
Powiązane
Serwer MCP
Podłącz Claude Code, Codex CLI, Codex desktop, Cursor i inne edytory AI.
REST API
Pełna dokumentacja wszystkich endpointów: numery, połączenia, SMS, numery komórkowe do SMS, agenci i ich wersje robocze, narzędzia, nagrania, webhooki, lista „nie dzwonić”, relay, konto, zadania, saldo, dokumenty, kalendarze i integracje.
Agent głosowy
Prompt systemowy, narzędzia, przekazanie człowiekowi, nagrania i ustrukturyzowane dane z połączeń.