Hoppa till innehållet

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). durationSecs anges 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, med endReason: "caller_loop" (pris 0 om det varade högst fem minuter). Hit kommer också en besvarad gate-ringning (kind: "ring", ring.outcome: "answered"), med ett undantag som beskrivs under exemplet call.no_answer. Ett samtal på volais egen motor (från bygget den 15. 10. 2026) har dessutom blocket engine med protectedUtterances och protectedUtterancesCount: 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 i transcript och agenten reagerade inte på dem; samma form som i GET /v1/calls/{id}, för ElevenLabs och den äldre motorn saknas nyckeln.
  • call.failed - samtalet har avslutats i statusen failed - 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 har reason (teknisk orsak) och endReason. 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 dessutom transcript/summary/durationSecs/priceHal, vid övriga reason saknas dessa fyra fält. För ett sådant samtal är reason antingen engine_stall eller engine_error (motorfel), eller unfinished nä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 är 0), har ingen inspelning och summary förklarar med en kort text vad som hände. transcript har 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). Blocket engine med 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 har endReason: "capacity", längd 0 och pris 0; 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ältet smsNumber har numret som meddelandet skickades från, för den delade avsändaren SMSinfo är det null. 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ån GET /v1/messages/{id}.
  • message.received - ett meddelande kom till ditt mobilnummer eller ditt nummer för samtal och sms. data har id, from (avsändare: telefonnummer i E.164, eller ett textnamn, t.ex. „Google“, högst 32 tecken; saknas avsändaren är det unknown), to (ditt nummer som meddelandet kom till), body, segments och receivedAt (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 i GET /v1/messages?direction=in. ts i kuvertet är tidpunkten då händelsen uppstod (också i ms, medan t= 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 i GET /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 (status releasing) sparas meddelandet, men message.received skickas inte för det. För ett konto som driftsättaren av volai manuellt har pausat skickas message.received inte alls, och för meddelanden som tagits emot under pausen levereras den inte i efterhand när kontot återupptas; även de finns i GET /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

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

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

Samma form har också call.missed (ett missat INKOMMANDE samtal) - bara händelsens namn, direction och status skiljer sig.

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

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.

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

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 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 });
}
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()
    # ... bearbetning i bakgrunden, se Tips nedan
    del event

    return "ok", 200

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

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ö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-signature och hop-by-hop-headers ställer leveransen in själv, de kan inte anges.
  • Värdena är hemligheter: GET /v1/webhook returnerar dem bara förkortade (Bear***). Skickas en sådan mask tillbaka behålls det sparade värdet.
  • Utelämnade headers behåller de sparade headerna, {} raderar dem. Ändras URL:en till en annan adress (annan domän, schema eller port, även ett extra www.) 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 felet invalid_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-Signature skickas. Även en omdirigering från example.com till www.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 - localhost och 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/webhook till dess adress.