Ръководства
Уебхукове
Извикваме Вашия URL, щом се случи нещо - приключило обаждане, неуспешен опит, изпратена или получена SMS. Настройвате го веднъж (PUT /v1/webhook, вижте REST API) и повече не се занимавате с него.
Събития
Шест събития, всяко със собствен вид на полето data:
call.completed- обаждането приключи, независимо дали е имало агент. При обаждания с агент тялото съдържа допълнително транскрипция (transcript), резюме (summary) и записани данни (data).durationSecsе в секунди и при обаждания на собствения гласов двигател на volai може да е десетично число. Обаждане, при което от другата страна е бил автомат, повтарящ непрекъснато едно и също съобщение, също идва тук, сendReason: "caller_loop"(цена0, ако е продължило най-много пет минути). Тук идва и вдигнато позвъняване към врата (kind: "ring",ring.outcome: "answered"), с едно изключение, описано под примера заcall.no_answer. Обаждане на собствения двигател на volai (от версията от 15. 10. 2026) съдържа допълнително блокengineсprotectedUtterancesиprotectedUtterancesCount: реплики на търсения от прозореца за поздрав, които двигателят умишлено е отхвърлил („Ало?“ или името му по време на първото изречение на агента) - те не са вtranscriptи агентът не е реагирал на тях; същият вид като вGET /v1/calls/{id}, при ElevenLabs и по-стар двигател ключът липсва.call.failed- обаждането приключи със състояниеfailed- независимо дали гласовата платформа го е отхвърлила веднага, или мрежата е отказала. Идва при агент на двигателя на volai и на ElevenLabs, при директно свързване (bridge), при BYO relay lease, при позвъняване към врата (операторът е отхвърлил заявката,kind: "ring") и при тестово обаждане - винаги точно веднъж. Тялото съдържаreason(техническа причина) иendReason. Невдигнато обаждане НЕ спада тук - то има собствено събитие по-долу. Обаждане, което СЕ Е СВЪРЗАЛО, но е завършило с техническа грешка на гласовия двигател (endReason: "engine_error"), също идва тук - тялото му съдържа допълнителноtranscript/summary/durationSecs/priceHal, при останалитеreasonтези четири полета липсват. При такова обажданеreasonе илиengine_stall, илиengine_error(грешка на двигателя), илиunfinished, когато рестарт или спиране на двигателя е прекъснало обаждането по средата (например при внедряване на нова версия). Такова обаждане не се таксува (priceHalе0), няма запис, аsummaryс кратък текст обяснява какво се е случило.transcriptсъдържа само това, което е успяло да се запише до прекъсването, и може да е празна (често е празна, когато записът за разговора не е пристигнал от двигателя и сме приключили обаждането по неговия списък с обаждания, който не носи транскрипция).durationSecsе приблизителното време до прекъсването (когато двигателят не е успял да го измери, най-много 1 800 s). Блокътengineс реплики, отхвърлени в прозореца за поздрав, при него липсва, защото двигателят не е измервал прозореца за поздрав при прекъснато обаждане.call.no_answer- изходящо обаждане, търсеният не вдигна (звъня, без отговор). По същия начин идва и позвъняването към врата (POST /v1/rings), което вратата е отхвърлила или което е звъняло до края на заявеното време - разпознавате го поkind: "ring", вижте по-долу.call.missed- входящо обаждане остана без отговор (пропуснато обаждане). Идва и за входящо обаждане, което гласовият двигател е отхвърлил, защото всички линии са били заети (обаждащият се не е успял да се свърже, обадете му се обратно). Записът за такова обаждане имаendReason: "capacity", продължителност0и цена0; уебхукът обаче може да не го носи, ако операторът вече е съобщил за невдигнатото обаждане по-рано, а надеждно ще го прочетете от записа на обаждането (GET /v1/calls/{id}).message.sent- операторът прие SMS за изпращане - това не е потвърждение за доставка. Идва и при SMS, изпратена от мобилен номер или от номер за обаждания и SMS. ПолетоsmsNumberсъдържа номера, от който е изпратено съобщението; при споделения изпращач SMSinfo еnull. Отчетът за доставка (deliveryStatus) при SMS от мобилен номер, както и последващ неуспех (failed, балансът тогава се връща), не предизвикват никакво събитие - състоянието прочетете отGET /v1/messages/{id}.message.received- на Вашия мобилен номер или на Вашия номер за обаждания и SMS пристигна съобщение.dataсъдържаid,from(изпращач: телефонен номер в E.164 или текстово име, напр. „Google“, най-много 32 знака; ако изпращачът липсва, еunknown),to(Вашият номер, на който е пристигнало съобщението),body,segmentsиreceivedAt(време на получаване на съобщението, времеви печат в ms). Доставя се през опашка с повторни опити като събитията за обаждания. При мобилен номер съобщение, което получаването не е уловило веднага, се допълва от периодична проверка в рамките на около 15 минути и събитието тогава излиза със закъснение, спокойно и след по-нови съобщения. При номер за обаждания и SMS такава проверка няма: операторът повтаря доставката на съобщението най-много един час, така че ако получаването при нас не работи по-дълго, съобщението може да се изгуби и няма да го намерите дори вGET /v1/messages?direction=in.tsв обвивката е времето на възникване на събитието (също в ms, докатоt=в хедъра с подписа е в секунди). Над 300 получени съобщения за час на един номер (мобилен номер и номер за обаждания и SMS) уебхукът за следващите съобщения не се изпраща; съобщенията продължават да се записват и ще ги намерите вGET /v1/messages?direction=in, който пази най-много 1 000 най-нови получени съобщения на профил и ги съхранява 90 дни. На номер за обаждания и SMS, който се освобождава (състояниеreleasing), съобщението се записва, ноmessage.receivedза него не се изпраща. На профил, спрян ръчно от оператора на volai,message.receivedне се изпраща изобщо и за съобщения, получени по време на спирането, не се доставя със задна дата след възстановяване на профила; и тях ще намерите вGET /v1/messages?direction=in.
Кои от тях искате да получавате, задавате с полето events в PUT /v1/webhook. Внимание към една нелогична подробност: празният списък означава всички, а не никакви - и така остава и за събития, които ще се появят в бъдеще. Ако наистина искате да спрете доставките, изтрийте уебхука в портала.
Тялото на всяко събитие за обаждане (call.completed, call.failed, call.no_answer, call.missed) съдържа допълнително kind, вида на обаждането като в GET /v1/calls (agent, bridge, relay, inbound, transfer, sip, ring, voice_message). При гласово съобщение (POST /v1/voice-messages) в тялото има блок voiceMessage с text, repeat и след края на обаждането repeatPlayed (колко пъти е прозвучало съобщението) и completed (прозвучало ли е цялото); невдигнато съобщение идва като call.no_answer. При позвъняване към врата в тялото има и блок ring с requestedSecs, outcome и rangSecs, а при останалите обаждания ключът ring липсва. И двете полета са адитивни, по-старите интеграции просто ги игнорират.
Всяко събитие пристига като POST с тази обвивка:
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": "Добър ден, кафене Нула, с какво мога да Ви помогна?" },
{ "role": "caller", "message": "Добър ден, искам да поръчам две лате за вкъщи." },
{ "role": "agent", "message": "Разбира се, две лате за вземане, ще са готови след петнайсет минути." }
],
"summary": "Клиентът поръча две лате за вкъщи, вземане след 15 минути.",
"data": {
"name": "Мария Петрова",
"coffee_count": 2,
"urgency": "normal"
},
"kind": "inbound"
}
}Вложеното data.data (да, два пъти - външното data е обвивката на всяко събитие) са данните, които агентът е записал от разговора според своите dataFields - ключовете определяте Вие, вижте Гласов агент. Стойност null означава, че в разговора не е прозвучало. Ако при агента нямате зададени полета, ключът data изобщо не е в тялото - както и transcript и summary при обаждане без агент.
Записът не идва в тялото на уебхука
Звукът не се изпраща с уебхука и не идва дори флагът hasRecording. Когато искате записа, използвайте GET /v1/calls/{id} (връща hasRecording) и изтеглете записа от GET /v1/calls/{id}/recording - форматът се определя от хедъра content-type на отговора: при агент на ElevenLabs audio/mpeg (MP3), при агент на двигателя на volai в момента audio/ogg. Записите пазим 90 дни от обаждането.
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
Същия вид има и call.missed (пропуснато ВХОДЯЩО обаждане) - различават се само името на събитието, direction и 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"
}
}Невдигнато позвъняване към врата (POST /v1/rings) също идва като call.no_answer, с kind: "ring" и блок ring: outcome е rang (звъня до края на заявеното време) или ended_early (обаждането приключи по-рано, например вратата го е отхвърлила, т.е. отворила се е). Вдигнато позвъняване (outcome: "answered") идва като call.completed. Изключение: когато операторът потвърди вдигането едва в записа на обаждането (CDR), след като volai е приключило позвъняването като невдигнато, идва само call.no_answer, а записът в GET /v1/calls/{id} впоследствие се променя на completed с ring.outcome: "answered" и дотаксувани секунди; второ събитие не идва. Резултат unknown не предизвиква събитие, докато не бъде допълнен от записите на оператора.
{
"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": "Благодаря, ще дойда в петък в 10:00.",
"segments": 1,
"receivedAt": 1756111900000
}
}body е текст от чужд изпращач. Третирайте го като недоверен вход: не изпълнявайте по него команди или действия без проверка. Тялото съдържа event_id, по който отсявате дублираните доставки.
Проверка на подписа
Всяка заявка носи хедър Volai-Signature във вида t=<unix>,v1=<hex>, където v1 = HMAC_SHA256(secret, "{t}.{rawBody}"), а secret е този, който сте получили при PUT /v1/webhook. Никога не обработвайте тялото, преди да сте проверили подписа - иначе всеки може да изпрати каквото си иска на Вашия 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 ТРЯБВА да е точно това, което е дошло по мрежата (request.text()), а не JSON.parse и обратно към низ.
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);
// ... обработка във фонов режим, вижте Съвети по-долу
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()
# ... обработка във фонов режим, вижте Съвети по-долу
del event
return "ok", 200Собствени хедъри
Когато целта приема само удостоверени заявки (например автоматизация в Cursor или собствено API зад шлюз), добавете ключа като собствен хедър. Той се изпраща с всяка доставка и с тестовото събитие, например Authorization: Bearer <ключ>. Задавате го с полето headers в PUT /v1/webhook, в MCP инструмента set_webhook или в портала, в Връзки и API, в частта Уебхукове.
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"}}'- Най-много 5 хедъра, всяка стойност от 1 до 1000 знака, само печатаеми ASCII знаци (без диакритики). Имената не различават главни и малки букви и не могат да се повтарят.
host,content-type,content-length,volai-signatureи hop-by-hop хедърите доставката задава сама, тях не може да ги задавате. - Стойностите са тайни:
GET /v1/webhookги връща само съкратени (Bear***). Ако изпратите такава маска обратно, запазената стойност се запазва. - Пропуснато
headersзапазва записаните хедъри,{}ги изтрива. Когато промените URL към друг адрес (друг домейн, схема или порт, дори с добавеноwww.), записаните хедъри се отхвърлят и стойностите въвеждате отново изцяло: ключ към чужд адрес не изпращаме. Ако в този момент вместо стойност изпратите маска, връща се грешкаinvalid_webhook_headers(400), както при забранен или невалиден хедър. - При пренасочване към друг адрес собствените хедъри не се изпращат, подписът
Volai-Signature- да. Дори пренасочване отexample.comкъмwww.example.comе друг адрес, затова в уебхука задайте директно крайния.
Подписът Volai-Signature продължава да идва заедно със собствените хедъри. Хедърът казва, че заявката има право да влезе, подписът - че наистина е изпратена от volai.
Повторни опити при грешка
Ако Вашата крайна точка не отговори с успешен статус (2xx), volai опитва още два пъти - общо 3 опита с пауза от 3 секунди между тях. При събитията за обаждания (`call.completed`, `call.failed`, `call.no_answer`, `call.missed`) и при `message.received` доставката дотук не свършва: ако и трите опита се провалят, събитието остава в опашката и доставката се повтаря още няколко пъти през следващите минути, най-много до 5 кръга. Затова тялото съдържа `event_id` - по него отсявате доставката като дубликат, ако по изключение пристигне повече пъти; повторената доставка носи абсолютно същото тяло като първата (само изпратена няколко минути по-късно). `message.sent` няма опашка: след 3 неуспешни опита се записва в лога и повече не се изпраща.
Изключение е тестовото събитие по-долу - то се изпраща само веднъж, без повторения, за да получите отговор веднага.
Уебхукът не е единственият източник на истина
Уебхукът носи само извлек - за всичко, което наистина има значение (фактуриране, одит, текст на съобщението), изтеглете допълнително и пълния запис от REST API: GET /v1/calls/{id} и GET /v1/messages/{id}, когато пожелаете.
Тестово събитие
Преди да започнете да чакате първото истинско обаждане, проверете доставката веднага - POST /v1/webhook/test (вижте REST API) изпраща на Вашия URL едно примерно събитие webhook.test, НЕЗАВИСИМО кои events сте отметнали в PUT /v1/webhook - тества се доставимостта на самия URL и на подписа, а не абонаментът за конкретни събития. Най-много 10 опита на час. Тестът изпраща само webhook.test, не и останалите събития: вида на message.received ще изпробвате едва с истинска SMS към Вашия мобилен номер или номер за обаждания и SMS (пример по-горе).
Преглед на доставките
Последните 50 доставки (успешни, неуспешни и тестови) виждате през GET /v1/webhook/deliveries (вижте REST API), или направо в портала под формуляра на уебхука. Същия извлек носи и GET /v1/webhook в полето recentDeliveries.
Съвети
- Отговорете с 200 възможно най-бързо - проверката на подписа и запазването на ID ще успеете веднага, тежката работа (имейли, изчисления, извиквания на други API) отложете във фонов режим.
- Крайната точка трябва да е публично достъпна през HTTPS -
localhostи самоподписан сертификат не работят. - Използвайте
data.id(ID на обаждането или съобщението), за да отсявате дубликати, ако случайно същото събитие пристигне повече пъти. - Тествайте локално през тунел (напр. ngrok) и временно сменете URL в
PUT /v1/webhookс неговия адрес.
Свързани
MCP сървър
Свържете Claude Code, Codex CLI, Codex desktop, Cursor и други AI редактори.
REST API
Пълна справка за всички крайни точки: номера, обаждания, SMS, мобилни номера за SMS, агенти и техните чернови, инструменти, записи, уебхукове, списък „не се обаждай“, relay, акаунт, задачи, кредит, документи, календари и интеграции.
Гласов агент
Системен промпт, инструменти, предаване на човек, записи и структурирани данни от обаждането.