Guider
Webhooks
Din URL anropas så fort något händer - ett avslutat samtal, ett misslyckat försök, ett skickat eller mottaget sms. Det ställs in en gång (PUT /v1/webhook, se REST API) och därefter behövs inget mer.
Händelser
Sex händelser, var och en med sin egen form på fältet data:
call.completed- samtalet har avslutats, oavsett om det hade en agent eller inte. För agentsamtal ingår dessutom transkription (transcript), sammanfattning (summary) och nedtecknade uppgifter (data).durationSecsanges i sekunder och kan vara ett decimaltal för samtal på volais egen röstmotor. Ett samtal där den andra änden var en automat som upprepade samma meddelande kommer också hit, medendReason: "caller_loop"(pris0om det varade högst fem minuter). Hit kommer också en besvarad gate-ringning (kind: "ring",ring.outcome: "answered"), med ett undantag som beskrivs under exempletcall.no_answer. Ett samtal på volais egen motor (från bygget den 15. 10. 2026) har dessutom blocketenginemedprotectedUtterancesochprotectedUtterancesCount: repliker från den uppringda under hälsningsfönstret som motorn medvetet inte tog emot ("Hallå?" eller ett namn under agentens första mening) - de finns inte itranscriptoch agenten reagerade inte på dem; samma form som iGET /v1/calls/{id}, för ElevenLabs och den äldre motorn saknas nyckeln.call.failed- samtalet har avslutats i statusenfailed- antingen avvisade röstplattformen det direkt eller så föll nätet. Kommer för en agent på volais motor och på ElevenLabs, för direktkoppling (bridge), för BYO relay-lease, för gate-ringning (operatören avvisade beställningen,kind: "ring") och för provsamtal - alltid exakt en gång. Body harreason(teknisk orsak) ochendReason. Ett obesvarat samtal hör INTE hit, det har en egen händelse nedan. Ett samtal som KOPPLADES men avslutades av ett tekniskt fel i röstmotorn (endReason: "engine_error") kommer också hit - dess body har dessutomtranscript/summary/durationSecs/priceHal, vid övrigareasonsaknas dessa fyra fält. För ett sådant samtal ärreasonantingenengine_stallellerengine_error(motorfel), ellerunfinishednär samtalet avbröts av en omstart eller ett stopp av motorn mitt i samtalet (till exempel vid driftsättning av en ny version). Ett sådant samtal debiteras inte (priceHalär0), har ingen inspelning ochsummaryförklarar med en kort text vad som hände.transcripthar bara det som hann spelas in före avbrottet och kan också vara tom (tom är den när motorns samtalspost inte kom fram och samtalet stängdes utifrån motorns samtalsöversikt, som inte har någon transkription).durationSecsär den ungefärliga tiden fram till avbrottet (om motorn inte hann mäta den, högst 1 800 s). Blocketenginemed repliker som kastades under hälsningsfönstret saknas här, eftersom motorn inte mätte hälsningsfönstret för ett avbrutet samtal.call.no_answer- den uppringda svarade inte på ett utgående samtal (det ringde, utan svar). Så kommer också gate-ringning (POST /v1/rings) som gaten avvisade eller som ringde ut hela den begärda tiden - känns igen påkind: "ring", se nedan.call.missed- ett inkommande samtal förblev obesvarat (missat samtal). Kommer också för ett inkommande samtal som röstmotorn avvisade därför att alla linjer var upptagna (den som ringde kom inte fram, hör av dig tillbaka). Posten för ett sådant samtal harendReason: "capacity", längd0och pris0; webhooken behöver dock inte bära det, om operatören tidigare rapporterade det obesvarade samtalet, säkert läses det från samtalsposten (GET /v1/calls/{id}).message.sent- operatören tog emot sms:et för utskick - det är ingen leveransbekräftelse. Kommer också för ett sms som skickats från ett mobilnummer eller från ett nummer för samtal och sms. FältetsmsNumberhar numret som meddelandet skickades från, för den delade avsändaren SMSinfo är detnull. Leveranskvitto (deliveryStatus) för sms från ett mobilnummer och ett senare misslyckande (failed, saldot återförs då) utlöser ingen händelse - hämta statusen frånGET /v1/messages/{id}.message.received- ett meddelande kom till ditt mobilnummer eller ditt nummer för samtal och sms.dataharid,from(avsändare: telefonnummer i E.164, eller ett textnamn, t.ex. „Google“, högst 32 tecken; saknas avsändaren är detunknown),to(ditt nummer som meddelandet kom till),body,segmentsochreceivedAt(tidpunkten då meddelandet togs emot, tidsstämpel i ms). Levereras via en kö med nya försök som samtalshändelserna. För ett mobilnummer kompletteras ett meddelande som mottagningen inte fångade direkt av en regelbunden kontroll inom ungefär 15 minuter, och händelsen går då iväg med fördröjning, gärna efter nyare meddelanden. För ett nummer för samtal och sms finns ingen sådan kontroll: operatören upprepar leveransen av meddelandet i högst en timme, så fungerar mottagningen inte hos oss längre än så kan meddelandet gå förlorat och hittas inte heller iGET /v1/messages?direction=in.tsi kuvertet är tidpunkten då händelsen uppstod (också i ms, medant=i signaturheadern är i sekunder). Över 300 mottagna meddelanden per timme till ett nummer (mobilnummer och nummer för samtal och sms) skickas ingen webhook för ytterligare meddelanden; meddelandena sparas ändå och finns iGET /v1/messages?direction=in, som håller högst 1 000 av de senaste mottagna meddelandena per konto och sparar dem i 90 dagar. På ett nummer för samtal och sms som frigörs (statusreleasing) sparas meddelandet, menmessage.receivedskickas inte för det. För ett konto som driftsättaren av volai manuellt har pausat skickasmessage.receivedinte alls, och för meddelanden som tagits emot under pausen levereras den inte i efterhand när kontot återupptas; även de finns iGET /v1/messages?direction=in.
Vilka av dem som ska prenumereras anges med fältet events i PUT /v1/webhook. Se upp för en kontraintuitiv detalj: ett tomt fält betyder alla, inte ingen - och det förblir så även för händelser som tillkommer i framtiden. Vill du på riktigt stänga av leveransen, ta bort webhooken i portalen.
Body för varje samtalshändelse (call.completed, call.failed, call.no_answer, call.missed) har dessutom kind, samtalets typ på samma sätt som i GET /v1/calls (agent, bridge, relay, inbound, transfer, sip, ring, voice_message). För ett röstmeddelande (POST /v1/voice-messages) finns i body blocket voiceMessage med text, repeat och, när samtalet har avslutats, repeatPlayed (hur många gånger meddelandet spelades) och completed (hela meddelandet spelades); ett obesvarat meddelande kommer som call.no_answer. För gate-ringning finns i body även blocket ring med requestedSecs, outcome och rangSecs, för övriga samtal saknas nyckeln ring. Båda fälten är additiva, äldre integrationer ignorerar dem helt enkelt.
Varje händelse kommer som en POST med det här kuvertet:
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": "God dag, du har kommit till receptionen på caféet Nula, hur kan jag hjälpa dig?" },
{ "role": "caller", "message": "God dag, jag skulle vilja beställa två latte att ta med." },
{ "role": "agent", "message": "Visst, två latte för avhämtning, det är klart om femton minuter." }
],
"summary": "Kunden beställde två latte att ta med, avhämtning om 15 minuter.",
"data": {
"namn": "Anna Svensson",
"antal_kaffe": 2,
"bradska": "normal"
},
"kind": "inbound"
}
}Det nästlade data.data (ja, två gånger - det yttre data är kuvertet för varje händelse) är uppgifterna som agenten antecknade från samtalet enligt sina dataFields - nycklarna väljer du själv, se Röstagent. Värdet null betyder att det inte nämndes i samtalet. Har agenten inga fält inställda finns nyckeln data inte alls i body - precis som transcript och summary för ett samtal utan agent.
Inspelningen följer inte med i webhookens body
Ljudet skickas inte med webhooken, och inte heller flaggan hasRecording. När inspelningen behövs, hämta GET /v1/calls/{id} (anger hasRecording) och ladda ned inspelningen från GET /v1/calls/{id}/recording - formatet anges av svarets header content-type: för en agent på ElevenLabs audio/mpeg (MP3), för en agent på volais motor idag audio/ogg. Inspelningar sparas i 90 dagar från samtalet.
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
Samma form har också call.missed (ett missat INKOMMANDE samtal) - bara händelsens namn, direction och status skiljer sig.
{
"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"
}
}En obesvarad ringning av en gate (POST /v1/rings) kommer också som call.no_answer, med kind: "ring" och blocket ring: outcome är rang (det ringde ut hela den begärda tiden) eller ended_early (samtalet avslutades tidigare, till exempel avvisade gaten det, alltså öppnades den). En besvarad ringning (outcome: "answered") går som call.completed. Undantag: bekräftar operatören besvarandet först i samtalsposten (CDR) efter att volai stängt ringningen som obesvarad, kommer bara call.no_answer, och posten i GET /v1/calls/{id} ändras i efterhand till completed med ring.outcome: "answered" och efterdebiterade sekunder; någon andra händelse kommer inte. Utfallet unknown utlöser ingen händelse förrän operatörens poster har kompletterat det.
{
"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": "Tack, jag kommer på fredag kl. 10:00.",
"segments": 1,
"receivedAt": 1756111900000
}
}body är text från en okänd avsändare. Behandla den som opålitlig indata: utför inga kommandon eller åtgärder utifrån den utan verifiering. Body har event_id, med vilket dubbletter av leveranser filtreras bort.
Verifiering av signaturen
Varje förfrågan har headern Volai-Signature i formen t=<unix>,v1=<hex>, där v1 = HMAC_SHA256(secret, "{t}.{rawBody}") och secret är den som erhölls vid PUT /v1/webhook. Bearbeta aldrig body innan signaturen har verifierats - annars kan vem som helst skicka vad som helst till din 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 MÅSTE vara exakt det som kom över tråden (request.text()), inte JSON.parse och tillbaka till en sträng.
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);
// ... bearbetning i bakgrunden, se Tips nedan
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()
# ... bearbetning i bakgrunden, se Tips nedan
del event
return "ok", 200Egna headers
Tar målet bara emot inloggade förfrågningar (till exempel en automation i Cursor eller ett eget API bakom en gateway), lägg till nyckeln som en egen header. Den skickas med varje leverans och med testhändelsen, till exempel Authorization: Bearer <nyckel>. Den ställs in med fältet headers i PUT /v1/webhook, i MCP-verktyget set_webhook, eller i portalen under Anslutning och API i delen Webhooks.
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"}}'- Högst 5 headers, varje värde 1 till 1000 tecken, bara skrivbar ASCII (utan diakritiska tecken). Namnen är skiftlägesokänsliga och får inte upprepas.
host,content-type,content-length,volai-signatureoch hop-by-hop-headers ställer leveransen in själv, de kan inte anges. - Värdena är hemligheter:
GET /v1/webhookreturnerar dem bara förkortade (Bear***). Skickas en sådan mask tillbaka behålls det sparade värdet. - Utelämnade
headersbehåller de sparade headerna,{}raderar dem. Ändras URL:en till en annan adress (annan domän, schema eller port, även ett extrawww.) kastas de sparade headerna och värdena anges på nytt i sin helhet: nyckeln skickas inte till en främmande adress. Skickas då en mask i stället för ett värde returneras feletinvalid_webhook_headers(400), liksom vid en förbjuden eller ogiltig header. - Vid omdirigering till en annan adress skickas inte de egna headerna, men signaturen
Volai-Signatureskickas. Även en omdirigering frånexample.comtillwww.example.comär en annan adress, ange därför direkt måladressen i webhooken.
Signaturen Volai-Signature skickas fortfarande vid sidan av de egna headerna. Headern säger att förfrågan får komma in, signaturen att det verkligen var volai som skickade den.
Nya försök vid fel
Svarar din endpoint inte med en lyckad status (2xx) försöker volai ytterligare två gånger - totalt alltså 3 försök med 3 sekunders paus mellan dem. För samtalshändelser (`call.completed`, `call.failed`, `call.no_answer`, `call.missed`) och för `message.received` slutar leveransen inte där: misslyckas alla tre försök ligger händelsen kvar i kön och leveransen upprepas ytterligare några gånger under de närmaste minuterna, i högst 5 omgångar. Body har därför `event_id` - med det filtreras leveransen bort som dubblett om den undantagsvis skulle komma flera gånger; en upprepad leverans har exakt samma body som den första (bara skickad några minuter senare). `message.sent` har ingen kö: efter 3 misslyckade försök loggas den och skickas inte igen.
Undantaget är testhändelsen nedan - den skickas bara en gång, utan nya försök, så att svaret kommer direkt.
Webhooken är inte den enda sanningskällan
Webhooken har bara ett utdrag - för allt som verkligen spelar roll (fakturering, revision, meddelandetext) är det bättre att även hämta hela posten från REST API: GET /v1/calls/{id} och GET /v1/messages/{id}, när som helst.
Testhändelse
Innan du börjar vänta på det första riktiga samtalet, verifiera leveransen direkt - POST /v1/webhook/test (se REST API) skickar en exempelhändelse webhook.test till din URL, OAVSETT vilka events som är ikryssade i PUT /v1/webhook - det som testas är själva URL:ens och signaturens leveransbarhet, inte prenumerationen på särskilda händelser. Högst 10 försök per timme. Testet skickar bara webhook.test, inte övriga händelser: formen på message.received provas först med ett riktigt sms till ditt mobilnummer eller nummer för samtal och sms (exemplet ovan).
Översikt över leveranser
De senaste 50 leveranserna (lyckade, misslyckade och testleveranser) visas via GET /v1/webhook/deliveries (se REST API), eller direkt i portalen under webhookformuläret. Samma utdrag finns också i GET /v1/webhook i fältet recentDeliveries.
Tips
- Svara 200 så snabbt som möjligt - verifiering av signaturen och sparande av ID hinns med direkt, tungt arbete (e-post, omräkningar, anrop till andra API) skjuts till bakgrunden.
- Endpointen måste vara offentligt tillgänglig över HTTPS -
localhostoch självsignerade certifikat fungerar inte. - Använd
data.id(ID för samtal eller meddelande) för att filtrera bort dubbletter om samma händelse av en slump skulle komma flera gånger. - Testa lokalt via en tunnel (t.ex. ngrok) och byt tillfälligt URL:en i
PUT /v1/webhooktill dess adress.
Relaterat
MCP server
Connect Claude Code, Codex CLI, Codex desktop, Cursor and other AI editors.
REST API
Complete reference for every endpoint: numbers, calls, SMS, mobile numbers for SMS, agents and their drafts, tools, recordings, webhooks, do-not-call list, relay, account, tasks, credit, documents, calendars and integrations.
Voice agent
System prompt, tools, handoff to a human, recordings and structured call data.