Naar de inhoud springen

Handleidingen

Webhooks

We roepen uw URL aan zodra er iets gebeurt - een afgerond gesprek, een mislukte poging, een verzonden of ontvangen sms. Stel het één keer in (PUT /v1/webhook, zie de referentie van de REST API) en u bent klaar.

Events

Zes events, elk met een eigen vorm voor het veld data:

  • call.completed - het gesprek is beëindigd, met of zonder agent. Agentgesprekken bevatten ook een transcript (transcript), een samenvatting (summary) en vastgelegde gegevens (data). durationSecs is in seconden en kan een decimaal getal zijn bij gesprekken op de eigen spraakengine van volai. Een gesprek waarbij aan de andere kant een geautomatiseerde beller steeds hetzelfde bericht herhaalt, komt hier ook binnen, met endReason: "caller_loop" (prijs 0 als het hoogstens vijf minuten duurde). Een beantwoorde beltoon van de poort (kind: "ring", ring.outcome: "answered") komt hier eveneens binnen, met één uitzondering die onder het voorbeeld van call.no_answer staat beschreven. Een gesprek op de eigen engine van volai (build van 15 okt. 2026 of nieuwer) bevat ook een blok engine met protectedUtterances en protectedUtterancesCount: zinnen van de persoon die opnam, uit het begroetingsvenster, die de engine bewust heeft genegeerd (een "Hallo?" of de naam van die persoon terwijl de agent zijn eerste zin uitsprak) - ze staan niet in transcript, de agent reageerde er niet op; dezelfde vorm als in GET /v1/calls/{id}, afwezig bij ElevenLabs en oudere engine-builds.
  • call.failed - het gesprek is geëindigd met status: "failed" - of het nu direct door het spraakplatform is geweigerd of door een netwerkstoring is mislukt. Het komt binnen voor een agent op de engine van volai en op ElevenLabs, voor een directe verbinding (bridge), voor een BYO-relaylease, voor een beltoon van de poort (de provider wees de opdracht af, kind: "ring") en voor een testgesprek - altijd precies één keer. De body bevat reason (de technische oorzaak) en endReason. Een gesprek dat gewoon niet werd opgenomen hoort hier NIET thuis - daarvoor is er hieronder een eigen event. Een gesprek dat WEL is verbonden maar eindigde door een technische fout in onze spraakengine (endReason: "engine_error") komt hier ook binnen, en de body bevat dan ook transcript/summary/durationSecs/priceHal - elke andere reason laat die vier velden weg. Bij zo'n gesprek is reason ofwel engine_stall of engine_error (een storing van de engine), ofwel unfinished wanneer een herstart of stop van de engine het gesprek midden in het gesprek afbrak (bijvoorbeeld tijdens een uitrol). Zo'n gesprek wordt niet in rekening gebracht (priceHal is 0), heeft geen opname en summary legt in een korte tekst uit wat er is gebeurd. Het transcript bevat alleen wat tot de onderbreking is vastgelegd en kan leeg zijn (het is vaak leeg wanneer het gespreksrecord nooit van de engine binnenkwam en we het gesprek hebben afgesloten op basis van de gesprekslijst van de engine, die geen transcript bevat). durationSecs is de geschatte tijd tot de onderbreking (hoogstens 1.800 s wanneer de engine het niet kon meten). Het blok engine met genegeerde zinnen uit het begroetingsvenster ontbreekt hier, omdat de engine het begroetingsvenster van een onderbroken gesprek niet heeft gemeten.
  • call.no_answer - een uitgaand gesprek ging over en de gebelde persoon nam niet op. Een beltoon van de poort (POST /v1/rings) die de poort heeft afgewezen, of die tot het einde van de gevraagde tijd is overgegaan, komt op dezelfde manier binnen - u herkent die aan kind: "ring", zie hieronder.
  • call.missed - een inkomend gesprek bleef onbeantwoord (een gemiste oproep). Het komt ook binnen voor een inkomend gesprek dat de spraakengine heeft afgewezen omdat alle lijnen bezet waren (de beller kwam niet door, bel terug). Het record van zo'n gesprek heeft endReason: "capacity", duur 0 en prijs 0; de webhook bevat het mogelijk niet wanneer de provider eerst het onbeantwoorde gesprek meldde, dus lees het betrouwbaar uit het gespreksrecord (GET /v1/calls/{id}).
  • message.sent - de provider heeft de sms voor verzending geaccepteerd - geen ontvangstbevestiging. Het event wordt ook verstuurd voor een sms die vanaf een mobiel nummer of een nummer voor bellen en sms is verzonden; het veld smsNumber bevat het nummer waarvandaan is verzonden en is null voor de gedeelde afzender SMSinfo. Het afleverrapport (deliveryStatus) van een vanaf een mobiel nummer verzonden sms en een latere mislukking (failed, waarna het tegoed wordt terugbetaald) activeren geen event - lees de status uit GET /v1/messages/{id}.
  • message.received - er is een bericht binnengekomen op uw mobiele nummer (de add-on mobiel nummer) of op uw nummer voor bellen en sms. data bevat id, from (de afzender: een telefoonnummer in E.164, of een tekstnaam zoals “Google”, hoogstens 32 tekens; unknown als de afzender ontbreekt), to (uw nummer waarop het bericht binnenkwam), body, segments en receivedAt (het moment van ontvangst, een timestamp in ms). Het wordt afgeleverd via de wachtrij voor nieuwe pogingen, net als de gespreksevents. Bij een mobiel nummer wordt een bericht dat niet direct is opgepikt binnen ongeveer 15 minuten aangevuld door een periodieke controle, en het event wordt dan te laat verstuurd, mogelijk na nieuwere berichten. Een nummer voor bellen en sms heeft zo'n controle niet: de operator herhaalt de aflevering van een bericht hoogstens een uur, dus een bericht kan verloren gaan als ontvangst aan onze kant langer uitvalt, en u vindt het dan ook niet in GET /v1/messages?direction=in. ts in de envelop is het moment waarop het event is aangemaakt (eveneens in ms, terwijl t= in de signatuurheader in seconden is). Boven 300 ontvangen berichten per uur op één nummer (een mobiel nummer of een nummer voor bellen en sms) wordt de webhook niet meer verstuurd voor de volgende berichten; ze worden nog wel opgeslagen en u vindt ze in GET /v1/messages?direction=in, dat per account hoogstens de 1.000 nieuwste ontvangen berichten bevat en ze 90 dagen bewaart. Voor een nummer voor bellen en sms die wordt vrijgegeven (status releasing) wordt een bericht opgeslagen maar wordt er geen message.received voor verstuurd. Een account dat de beheerder van volai handmatig heeft opgeschort, krijgt helemaal geen message.received, en voor berichten die tijdens de opschorting binnenkomen wordt het na herstel van het account niet alsnog verstuurd; ook die vindt u in GET /v1/messages?direction=in.

Met het veld events in PUT /v1/webhook kiest u op welke van deze events u zich abonneert. Eén contra-intuïtief detail: een lege array betekent alle events, niet geen - en dat blijft ook gelden voor events die in de toekomst worden toegevoegd. Om de aflevering echt uit te zetten, verwijdert u de webhook in het portaal.

De body van elk gespreksevent (call.completed, call.failed, call.no_answer, call.missed) bevat ook kind, het type gesprek, hetzelfde als in GET /v1/calls (agent, bridge, relay, inbound, transfer, sip, ring, voice_message). Bij een spraakbericht (POST /v1/voice-messages) bevat de body een blok voiceMessage met text, repeat en, zodra het gesprek is beëindigd, repeatPlayed (hoe vaak het bericht is beluisterd) en completed (het is tot het einde voorgelezen); een onbeantwoord bericht komt binnen als call.no_answer. Bij een beltoon van de poort bevat de body ook een blok ring met requestedSecs, outcome en rangSecs; andere gesprekken hebben geen sleutel ring. Beide velden zijn aanvullend, dus oudere integraties negeren ze gewoon.

Elk event komt binnen als een POST met deze envelop:

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": "Goedendag, met café Nula, waarmee kan ik u helpen?" },
      { "role": "caller", "message": "Goedendag, ik wil graag twee latte om mee te nemen bestellen." },
      { "role": "agent", "message": "Natuurlijk, twee latte om mee te nemen, over vijftien minuten staan ze klaar." }
    ],
    "summary": "De beller bestelde twee latte om mee te nemen, ophalen over 15 minuten.",
    "data": {
      "name": "Jan de Vries",
      "coffee_count": 2,
      "urgency": "normal"
    },
    "kind": "inbound"
  }
}

De geneste data.data (ja, twee keer - de buitenste data is de envelop die elk event deelt) zijn de gegevens die de agent tijdens het gesprek heeft vastgelegd volgens zijn dataFields - u kiest de sleutels zelf, zie Spraakagent. Een waarde null betekent dat het in het gesprek niet ter sprake kwam. Als de agent helemaal geen velden heeft ingesteld, staat de sleutel data niet in de body - net als transcript en summary bij een gesprek zonder agent.

De opname reist niet mee in de webhook-body

Audio wordt niet met de webhook meegestuurd, en de vlag hasRecording evenmin. Wilt u de opname, gebruik dan GET /v1/calls/{id} (daar staat hasRecording) en download hem via GET /v1/calls/{id}/recording - het formaat volgt de header content-type van de respons: audio/mpeg (MP3) voor een agent op ElevenLabs, momenteel audio/ogg voor een agent op de engine van volai. We bewaren opnames 90 dagen na het gesprek.

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 (een gemist INKOMEND gesprek) heeft dezelfde vorm - alleen de naam van het event, direction en status verschillen.

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

Een onbeantwoorde beltoon van de poort (POST /v1/rings) komt ook binnen als call.no_answer, met kind: "ring" en een blok ring: outcome is rang (het is tot het einde van de gevraagde tijd overgegaan) of ended_early (het gesprek eindigde eerder, bijvoorbeeld omdat de poort het afwees, wat betekent dat hij openging). Een beantwoorde beltoon (outcome: "answered") komt binnen als call.completed. Eén uitzondering: wanneer de provider de opname pas bevestigt in zijn gespreksrecord (CDR), nadat volai de beltoon al als onbeantwoord had afgesloten, komt alleen call.no_answer binnen, en het record in GET /v1/calls/{id} verandert later in completed met ring.outcome: "answered" en de in rekening gebrachte beantwoorde seconden; er wordt geen tweede event verstuurd. Een resultaat unknown activeert geen event totdat de records van de provider het aanvullen.

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": "Bedankt, ik kom vrijdag om 10:00 uur.",
    "segments": 1,
    "receivedAt": 1756111900000
  }
}

body is tekst van een externe afzender. Behandel het als onbetrouwbare invoer: voer er geen opdrachten of acties uit zonder te controleren. De body bevat een event_id waarmee u een dubbele aflevering kunt wegfilteren.

Verificatie van de handtekening

Elk verzoek bevat een header Volai-Signature in de vorm t=<unix>,v1=<hex>, waarbij v1 = HMAC_SHA256(secret, "{t}.{rawBody}") en secret het geheim is dat u van PUT /v1/webhook hebt gekregen. Verwerk de body nooit voordat u de handtekening hebt geverifieerd - anders kan iedereen alles naar uw URL posten.

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 MOET exact zijn wat er over de lijn kwam (request.text()), niet JSON.parse en weer terug naar een tekenreeks.
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);
  // ... verwerking op de achtergrond, zie Tips hieronder
  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()
    # ... verwerking op de achtergrond, zie Tips hieronder
    del event

    return "ok", 200

Eigen headers

Als het doel alleen geauthenticeerde verzoeken accepteert (bijvoorbeeld een Cursor-automatisering of uw eigen API achter een gateway), voegt u de sleutel toe als eigen header. Die wordt bij elke aflevering en bij het testevent meegestuurd, bijvoorbeeld Authorization: Bearer <key>. Stel het in met het veld headers van PUT /v1/webhook, met de MCP-tool set_webhook of in het portaal onder Koppelingen en API in het onderdeel 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"}}'
  • Hoogstens 5 headers, elke waarde bestaat uit 1 tot 1000 afdrukbare ASCII-tekens (geen letters met accenten). Namen zijn niet hoofdlettergevoelig en moeten uniek zijn. host, content-type, content-length, volai-signature en hop-by-hop-headers worden door de aflevering zelf ingesteld en kunnen niet worden gezet.
  • Waarden zijn geheimen: GET /v1/webhook geeft ze ingekort terug (Bear***). Als u zo'n masker terugstuurt, blijft de opgeslagen waarde behouden.
  • Als u headers weglaat, blijven de opgeslagen headers behouden, {} verwijdert ze. Wanneer u de URL wijzigt naar een ander adres (ander domein, schema of poort, zelfs een toegevoegde www.), worden opgeslagen headers verwijderd en voert u de volledige waarden opnieuw in: we sturen een sleutel niet naar een ander adres. Als u op dat moment een masker in plaats van een waarde verstuurt, krijgt u de fout invalid_webhook_headers (400), net als bij een verboden of ongeldige header.
  • Wanneer een aflevering naar een ander adres wordt doorgestuurd, worden eigen headers niet meegestuurd; de header Volai-Signature wel. Een redirect van example.com naar www.example.com is ook een ander adres, dus zet de uiteindelijke URL in de webhook.

De handtekening Volai-Signature wordt nog steeds naast eigen headers meegestuurd. De header zorgt dat het verzoek binnenkomt, de handtekening bewijst dat volai het echt heeft verstuurd.

Nieuwe pogingen bij fouten

Als uw endpoint niet met een succesvolle status (2xx) antwoordt, probeert volai het nog twee keer - in totaal 3 pogingen, met 3 seconden pauze ertussen. Gespreksevents (`call.completed`, `call.failed`, `call.no_answer`, `call.missed`) en `message.received` houden daar niet mee op: als alle drie de pogingen mislukken, blijft het event in de wachtrij staan en wordt de aflevering de komende minuten nog een paar keer herhaald, in totaal hoogstens 5 rondes. Daarom bevat de body een `event_id` - gebruik die om een duplicaat weg te filteren, in het zeldzame geval dat hetzelfde event meer dan eens binnenkomt; een herhaalde aflevering bevat exact dezelfde body als de eerste (alleen enkele minuten later verstuurd). `message.sent` heeft geen wachtrij: na 3 mislukte pogingen wordt het gelogd en niet opnieuw verstuurd.

Het testevent hieronder is de uitzondering - het wordt maar één keer verstuurd, zonder nieuwe poging, zodat u meteen antwoord krijgt.

De webhook is niet de enige bron van waarheid

Een webhook bevat alleen een samenvatting - haal voor alles wat echt telt (facturering, audits, de exacte tekst van een bericht) het volledige record op via de REST API: GET /v1/calls/{id} en GET /v1/messages/{id}, wanneer u dat nodig hebt.

Testevent

Voordat u op het eerste echte gesprek wacht, controleert u de aflevering meteen - POST /v1/webhook/test (zie de referentie van de REST API) stuurt één voorbeeldevent webhook.test naar uw URL, ONGEACHT welke events u in PUT /v1/webhook hebt aangevinkt - dit test de bereikbaarheid van de URL en de handtekening zelf, niet het abonnement op specifieke events. Hoogstens 10 pogingen per uur. De test verstuurt alleen webhook.test, niet de andere events: de vorm van message.received kunt u alleen uitproberen met een echte sms naar uw mobiele nummer of nummer voor bellen en sms (zie het voorbeeld hierboven).

Afleveringsgeschiedenis

U ziet de laatste 50 afleveringen (geslaagde, mislukte en testafleveringen) via GET /v1/webhook/deliveries (zie de referentie van de REST API), of direct in het portaal onder het webhookformulier. Dezelfde samenvatting zit ook in GET /v1/webhook als recentDeliveries.

Tips

  • Antwoord zo snel mogelijk met 200 - verifieer de handtekening en sla het ID meteen op, en schuif zwaar werk (e-mails, herberekeningen, aanroepen van andere API's) door naar de achtergrond.
  • Het endpoint moet openbaar bereikbaar zijn via HTTPS - localhost en zelfondertekende certificaten werken niet.
  • Gebruik data.id (het ID van het gesprek of bericht) om duplicaten weg te filteren, voor het geval hetzelfde event toch meer dan eens binnenkomt.
  • Test lokaal via een tunnel (bijvoorbeeld ngrok) en laat de URL in PUT /v1/webhook tijdelijk naar het adres daarvan wijzen.