Към съдържанието

Ръководства

Уебхукове

Извикваме Вашия 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

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": "Добър ден, кафене Нула, с какво мога да Ви помогна?" },
      { "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

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

Същия вид има и call.missed (пропуснато ВХОДЯЩО обаждане) - различават се само името на събитието, direction и 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"
  }
}

Невдигнато позвъняване към врата (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 не предизвиква събитие, докато не бъде допълнен от записите на оператора.

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": "Благодаря, ще дойда в петък в 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.

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 ТРЯБВА да е точно това, което е дошло по мрежата (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 });
}
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()
    # ... обработка във фонов режим, вижте Съвети по-долу
    del event

    return "ok", 200

Собствени хедъри

Когато целта приема само удостоверени заявки (например автоматизация в Cursor или собствено API зад шлюз), добавете ключа като собствен хедър. Той се изпраща с всяка доставка и с тестовото събитие, например Authorization: Bearer <ключ>. Задавате го с полето headers в PUT /v1/webhook, в MCP инструмента set_webhook или в портала, в Връзки и API, в частта Уебхукове.

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"}}'
  • Най-много 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 с неговия адрес.