Gå til indholdet

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). durationSecs er 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, med endReason: "caller_loop" (pris 0, hvis det varede højst fem minutter). Hertil kommer også et besvaret gate-ring (kind: "ring", ring.outcome: "answered"), med én undtagelse beskrevet under eksemplet call.no_answer. Et opkald på volais egen motor (fra versionen af 15. 10. 2026) har desuden en blok engine med protectedUtterances og protectedUtterancesCount: 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 i transcript, og agenten reagerede ikke på dem; samme form som i GET /v1/calls/{id}, hos ElevenLabs og ældre motor mangler nøglen.
  • call.failed - opkaldet endte i status failed - 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 indeholder reason (den tekniske årsag) og endReason. 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 desuden transcript/summary/durationSecs/priceHal, mens disse fire felter mangler ved alle andre reason. Ved et sådant opkald er reason enten engine_stall eller engine_error (en motorfejl), eller unfinished, 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 (priceHal er 0), har ingen optagelse, og summary forklarer i en kort tekst, hvad der skete. transcript indeholder 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). durationSecs er den omtrentlige tid frem til afbrydelsen (højst 1.800 s, hvis motoren ikke nåede at måle den). Blokken engine med 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 har endReason: "capacity", længde 0 og pris 0; 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. Feltet smsNumber indeholder det nummer, beskeden blev sendt fra, og er null for 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 fra GET /v1/messages/{id}.
  • message.received - der kom en besked til dit mobilnummer (tilvalget mobilnummer) eller til din nummer til opkald og sms. data indeholder id, from (afsenderen: et telefonnummer i E.164 eller et tekstnavn, fx "Google", højst 32 tegn; mangler afsenderen, er det unknown), to (dit nummer, som beskeden kom til), body, segments og receivedAt (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 i GET /v1/messages?direction=in. ts i konvolutten er tidspunktet for hændelsens opståen (også i ms, mens t= 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 i GET /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 (status releasing), gemmes en besked, men message.received sendes ikke for den. En konto, som volai-udbyderen har suspenderet manuelt, får slet ikke message.received, og for beskeder modtaget under suspenderingen leveres den ikke bagudrettet, når kontoen genoprettes; også dem finder du i GET /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

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": "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

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

Samme form har også call.missed (et ubesvaret INDGÅENDE opkald) - kun hændelsesnavnet, direction og status er forskellige.

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"
  }
}

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.

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": "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.

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 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 });
}
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()
    # ... behandling i baggrunden, se Tips nedenfor
    del event

    return "ok", 200

Egne 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.

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"}}'
  • 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-signature og hop-by-hop-headere sætter leveringen selv, dem kan du ikke sætte.
  • Værdierne er hemmeligheder: GET /v1/webhook returnerer 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 ekstra www.), 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 fejlen invalid_webhook_headers (400), ligesom ved en forbudt eller ugyldig header.
  • Ved omdirigering til en anden adresse sendes egne headere ikke, signaturen Volai-Signature gør. Også en omdirigering fra example.com til www.example.com er 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 - localhost og 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/webhook til dens adresse.