Guides
Webhooks
Vi kalder din URL, så snart der sker noget - et afsluttet opkald, et mislykket forsøg, en sendt eller modtaget SMS. Du sætter det op én gang (PUT /v1/webhook, se REST API) og skal ikke tænke mere over det.
Hændelser
Seks hændelser, hver med sin egen form på feltet data:
call.completed- opkaldet er slut, uanset om det havde en agent eller ej. Ved agentopkald indeholder det desuden en udskrift (transcript), et resumé (summary) og registrerede data (data).durationSecser i sekunder og kan være et decimaltal ved opkald på volais egen stemmemotor. Et opkald, hvor der i den anden ende var en automat, som blev ved med at gentage den samme besked, kommer også hertil, medendReason: "caller_loop"(pris0, hvis det varede højst fem minutter). Hertil kommer også et besvaret gate-ring (kind: "ring",ring.outcome: "answered"), med én undtagelse beskrevet under eksempletcall.no_answer. Et opkald på volais egen motor (fra versionen af 15. 10. 2026) har desuden en blokenginemedprotectedUtterancesogprotectedUtterancesCount: replikker fra den, der tog telefonen, i hilsenvinduet, som motoren bevidst ikke tog med ("Hallo?" eller et navn under agentens første sætning) - de står ikke itranscript, og agenten reagerede ikke på dem; samme form som iGET /v1/calls/{id}, hos ElevenLabs og ældre motor mangler nøglen.call.failed- opkaldet endte i statusfailed- uanset om stemmeplatformen afviste det med det samme, eller netværket svigtede. Hændelsen kommer for en agent på volais motor såvel som på ElevenLabs, ved direkte forbindelse (bridge), ved en BYO relay-lease, ved et gate-ring (operatøren afviste bestillingen,kind: "ring") og ved et testopkald - altid præcis én gang. Body indeholderreason(den tekniske årsag) ogendReason. Et opkald, der blot ikke blev besvaret, hører IKKE hjemme her, det har sin egen hændelse nedenfor. Et opkald, der BLEV forbundet, men endte med en teknisk fejl i stemmemotoren (endReason: "engine_error"), kommer også hertil - dets body indeholder desudentranscript/summary/durationSecs/priceHal, mens disse fire felter mangler ved alle andrereason. Ved et sådant opkald erreasonentenengine_stallellerengine_error(en motorfejl), ellerunfinished, hvis en genstart eller standsning af motoren afbrød opkaldet midt i samtalen (fx under udrulning af en ny version). Et sådant opkald afregnes ikke (priceHaler0), har ingen optagelse, ogsummaryforklarer i en kort tekst, hvad der skete.transcriptindeholder kun det, der nåede at blive registreret før afbrydelsen, og kan også være tom (den er ofte tom, når opkaldsoptegnelsen ikke nåede frem fra motoren, og vi lukkede opkaldet ud fra motorens opkaldsoversigt, som ikke indeholder en udskrift).durationSecser den omtrentlige tid frem til afbrydelsen (højst 1.800 s, hvis motoren ikke nåede at måle den). Blokkenenginemed replikker, der blev kasseret i hilsenvinduet, mangler, fordi motoren ikke målte hilsenvinduet ved et afbrudt opkald.call.no_answer- den, der blev ringet op, tog ikke et udgående opkald (det ringede uden svar). Sådan kommer også et gate-ring (POST /v1/rings), som gaten afviste, eller som ringede hele den ønskede tid, - du kan genkende det påkind: "ring", se nedenfor.call.missed- et indgående opkald forblev ubesvaret (et ubesvaret opkald). Hændelsen kommer også for et indgående opkald, som stemmemotoren afviste, fordi alle linjer var optaget (den, der ringede, kom ikke igennem, ring tilbage). Optegnelsen for et sådant opkald harendReason: "capacity", længde0og pris0; webhooken behøver dog ikke indeholde det, hvis operatøren allerede har meldt det ubesvarede opkald, så læs det pålideligt fra opkaldsoptegnelsen (GET /v1/calls/{id}).message.sent- operatøren har modtaget SMS'en til afsendelse - det er ikke en leveringskvittering. Hændelsen kommer også for en SMS sendt fra et mobilnummer eller fra en nummer til opkald og sms. FeltetsmsNumberindeholder det nummer, beskeden blev sendt fra, og ernullfor den delte afsender SMSinfo. Leveringskvitteringen (deliveryStatus) for en SMS fra et mobilnummer og en senere fejl (failed, hvorefter saldoen refunderes) udløser ingen hændelse - hent status fraGET /v1/messages/{id}.message.received- der kom en besked til dit mobilnummer (tilvalget mobilnummer) eller til din nummer til opkald og sms.dataindeholderid,from(afsenderen: et telefonnummer i E.164 eller et tekstnavn, fx "Google", højst 32 tegn; mangler afsenderen, er detunknown),to(dit nummer, som beskeden kom til),body,segmentsogreceivedAt(tidspunktet for modtagelsen, tidsstempel i ms). Den leveres via en kø med gentagne forsøg som opkaldshændelserne. Ved et mobilnummer bliver en besked, som modtagelsen ikke fangede med det samme, suppleret af en regelmæssig kontrol inden for cirka 15 minutter, og hændelsen afsendes så med forsinkelse, gerne efter nyere beskeder. Ved en nummer til opkald og sms findes en sådan kontrol ikke: operatøren gentager leveringen af en besked i højst en time, så hvis modtagelsen hos os ligger nede længere, kan beskeden gå tabt, og du finder den heller ikke iGET /v1/messages?direction=in.tsi konvolutten er tidspunktet for hændelsens opståen (også i ms, menst=i signaturheaderen er i sekunder). Over 300 modtagne beskeder i timen på ét nummer (et mobilnummer såvel som en nummer til opkald og sms) sendes webhooken ikke for de følgende beskeder; de gemmes stadig, og du finder dem iGET /v1/messages?direction=in, som rummer højst de 1.000 nyeste modtagne beskeder pr. konto og opbevarer dem i 90 dage. På en nummer til opkald og sms, der frigives (statusreleasing), gemmes en besked, menmessage.receivedsendes ikke for den. En konto, som volai-udbyderen har suspenderet manuelt, får slet ikkemessage.received, og for beskeder modtaget under suspenderingen leveres den ikke bagudrettet, når kontoen genoprettes; også dem finder du iGET /v1/messages?direction=in.
Hvilke af dem du vil abonnere på, bestemmer du med feltet events i PUT /v1/webhook. Pas på én modstridende detalje: et tomt array betyder alle, ikke ingen - og det gælder også for hændelser, der kommer til i fremtiden. Hvis du for alvor vil slå levering fra, så slet webhooken i portalen.
Body for hver opkaldshændelse (call.completed, call.failed, call.no_answer, call.missed) indeholder desuden kind, opkaldstypen som i GET /v1/calls (agent, bridge, relay, inbound, transfer, sip, ring, voice_message). Ved en talebesked (POST /v1/voice-messages) indeholder body en blok voiceMessage med text, repeat og, når opkaldet er slut, repeatPlayed (hvor mange gange beskeden blev afspillet) og completed (den blev afspillet helt); en ubesvaret besked kommer som call.no_answer. Ved et gate-ring indeholder body også en blok ring med requestedSecs, outcome og rangSecs, ved andre opkald mangler nøglen ring. Begge felter er additive, så ældre integrationer ignorerer dem blot.
Hver hændelse kommer som en POST med denne konvolut:
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": "Goddag, det er receptionen på caféen Nula, hvordan kan jeg hjælpe?" },
{ "role": "caller", "message": "Goddag, jeg vil gerne bestille to latte to go." },
{ "role": "agent", "message": "Selvfølgelig, to latte til afhentning, de er klar om femten minutter." }
],
"summary": "Kunden bestilte to latte to go, afhentning om 15 minutter.",
"data": {
"navn": "Anne Jensen",
"antal_kaffe": 2,
"haster": "normal"
},
"kind": "inbound"
}
}Det indlejrede data.data (ja, to gange - det ydre data er konvolutten om hver hændelse) er de oplysninger, som agenten har noteret under opkaldet ud fra sine dataFields - nøglerne bestemmer du selv, se Stemmeagent. Værdien null betyder, at det ikke blev nævnt i opkaldet. Hvis du ikke har sat nogen felter op på agenten, er nøglen data slet ikke i body - ligesom transcript og summary ved et opkald uden agent.
Optagelsen kommer ikke med i webhookens body
Lyd sendes ikke via webhooken, og flaget hasRecording kommer heller ikke med. Når du vil have optagelsen, så brug GET /v1/calls/{id} (fortæller hasRecording) og hent optagelsen fra GET /v1/calls/{id}/recording - formatet bestemmes af svarets header content-type: for en agent på ElevenLabs audio/mpeg (MP3), for en agent på volais motor i dag audio/ogg. Optagelser opbevarer vi i 90 dage fra opkaldet.
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
Samme form har også call.missed (et ubesvaret INDGÅENDE opkald) - kun hændelsesnavnet, direction og status er forskellige.
{
"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"
}
}Et ubesvaret gate-ring (POST /v1/rings) kommer også som call.no_answer, med kind: "ring" og blokken ring: outcome er rang (det ringede hele den ønskede tid) eller ended_early (opkaldet sluttede tidligere, fx fordi gaten afviste det, altså åbnede sig). Et besvaret gate-ring (outcome: "answered") kommer som call.completed. Undtagelse: hvis operatøren først bekræfter besvarelsen i opkaldsoptegnelsen (CDR), efter at volai har lukket gate-ringet som ubesvaret, kommer kun call.no_answer, og optegnelsen i GET /v1/calls/{id} ændres bagefter til completed med ring.outcome: "answered" og efterafregnede sekunder; den anden hændelse kommer ikke. Resultatet unknown udløser ingen hændelse, før operatørens optegnelser supplerer 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": "Tak, jeg kommer fredag kl. 10:00.",
"segments": 1,
"receivedAt": 1756111900000
}
}body er tekst fra en fremmed afsender. Behandl den som ikke-betroet input: udfør ikke kommandoer eller handlinger ud fra den uden verifikation. Body indeholder event_id, som du bruger til at frasortere dobbelte leveringer.
Verifikation af signaturen
Hver forespørgsel har headeren Volai-Signature i formen t=<unix>,v1=<hex>, hvor v1 = HMAC_SHA256(secret, "{t}.{rawBody}"), og secret er den, du fik ved PUT /v1/webhook. Behandl aldrig body, før du har verificeret signaturen - ellers kan hvem som helst sende hvad som helst til 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 SKAL være præcis det, der kom over ledningen (request.text()), ikke JSON.parse og tilbage til en streng.
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);
// ... behandling i baggrunden, se Tips nedenfor
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()
# ... behandling i baggrunden, se Tips nedenfor
del event
return "ok", 200Egne headere
Når modtageren kun tager imod godkendte forespørgsler (fx en automatisering i Cursor eller dit eget API bag en gateway), så tilføj nøglen som en egen header. Den sendes med hver levering og med testhændelsen, fx Authorization: Bearer <nøgle>. Du sætter den med feltet headers i PUT /v1/webhook, i MCP-værktøjet set_webhook eller i portalen under Forbindelser og API i afsnittet 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øjst 5 headere, hver værdi på 1 til 1000 tegn, kun udskrivelig ASCII (uden diakritiske tegn). Navne skelner ikke mellem store og små bogstaver og må ikke gentages.
host,content-type,content-length,volai-signatureog hop-by-hop-headere sætter leveringen selv, dem kan du ikke sætte. - Værdierne er hemmeligheder:
GET /v1/webhookreturnerer dem kun forkortet (Bear***). Sender du sådan en maske tilbage, bevares den gemte værdi. - Udelades
headers, beholdes de gemte headere,{}sletter dem. Når du ændrer URL'en til en anden adresse (andet domæne, skema eller port, også et ekstrawww.), kasseres de gemte headere, og du indtaster værdierne igen i fuld længde: vi sender ikke en nøgle til en fremmed adresse. Sender du i det øjeblik en maske i stedet for en værdi, returneres fejleninvalid_webhook_headers(400), ligesom ved en forbudt eller ugyldig header. - Ved omdirigering til en anden adresse sendes egne headere ikke, signaturen
Volai-Signaturegør. Også en omdirigering fraexample.comtilwww.example.comer en anden adresse, så angiv i webhooken straks målet.
Signaturen Volai-Signature kommer fortsat ved siden af egne headere. Headeren siger, at forespørgslen må komme ind, signaturen, at det virkelig var volai, der sendte den.
Gentagne forsøg ved fejl
Hvis dit endpoint ikke svarer med en vellykket status (2xx), prøver volai to gange til - i alt altså 3 forsøg med 3 sekunders pause imellem. Ved opkaldshændelser (`call.completed`, `call.failed`, `call.no_answer`, `call.missed`) og ved `message.received` slutter leveringen ikke dér: hvis alle tre forsøg mislykkes, bliver hændelsen i køen, og leveringen gentages flere gange i de følgende minutter, højst op til 5 runder. Body indeholder derfor `event_id` - ud fra det frasorterer du leveringen som dublet, hvis den undtagelsesvis skulle komme flere gange; en gentaget levering har helt samme body som den første (blot sendt nogle minutter senere). `message.sent` har ingen kø: efter 3 mislykkede forsøg logges den og sendes ikke igen.
Undtagelsen er testhændelsen nedenfor - den sendes kun én gang, uden gentagelser, så du får svar med det samme.
Webhooken er ikke den eneste kilde til sandhed
Webhooken indeholder kun et uddrag - til alt, hvad der virkelig betyder noget (fakturering, revision, beskedens tekst), så hent hellere også den komplette optegnelse fra REST API'et: GET /v1/calls/{id} og GET /v1/messages/{id}, når du har brug for den.
Testhændelse
Før du begynder at vente på det første rigtige opkald, så kontrollér leveringen med det samme - POST /v1/webhook/test (se REST API) sender én eksempelhændelse webhook.test til din URL, UANSET hvilke events du har afkrydset i PUT /v1/webhook - det er selve URL'ens og signaturens leverbarhed, der testes, ikke abonnementet på bestemte hændelser. Højst 10 forsøg i timen. Testen sender kun webhook.test, ikke de øvrige hændelser: formen på message.received afprøver du først med en rigtig SMS til dit mobilnummer eller din nummer til opkald og sms (eksemplet ovenfor).
Oversigt over leveringer
De seneste 50 leveringer (vellykkede, mislykkede og testleveringer) kan du se via GET /v1/webhook/deliveries (se REST API), eller direkte i portalen under webhook-formularen. Det samme uddrag indeholder også GET /v1/webhook i feltet recentDeliveries.
Tips
- Svar 200 så hurtigt som muligt - verifikation af signaturen og lagring af ID'et når du med det samme, tungt arbejde (e-mails, omregninger, kald til andre API'er) skubber du til baggrunden.
- Endpointet skal være offentligt tilgængeligt via HTTPS -
localhostog selvsignerede certifikater virker ikke. - Brug
data.id(ID for opkaldet eller beskeden) til at frasortere dubletter, hvis den samme hændelse skulle komme flere gange. - Test lokalt via en tunnel (fx ngrok), og skift midlertidigt URL'en i
PUT /v1/webhooktil dens adresse.
Relateret
MCP-server
Forbind Claude Code, Codex CLI, Codex desktop, Cursor og andre AI-editorer.
REST API
Komplet reference for alle endpoints: numre, opkald, sms, mobilnumre til sms, agenter og deres kladder, værktøjer, optagelser, webhooks, ring-ikke-liste, relay, konto, opgaver, saldo, bilag, kalendere og integrationer.
Stemmeagent
Systemprompt, værktøjer, overdragelse til et menneske, optagelser og strukturerede opkaldsdata.