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).durationSecsis 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, metendReason: "caller_loop"(prijs0als 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 vancall.no_answerstaat beschreven. Een gesprek op de eigen engine van volai (build van 15 okt. 2026 of nieuwer) bevat ook een blokenginemetprotectedUtterancesenprotectedUtterancesCount: 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 intranscript, de agent reageerde er niet op; dezelfde vorm als inGET /v1/calls/{id}, afwezig bij ElevenLabs en oudere engine-builds.call.failed- het gesprek is geëindigd metstatus: "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 bevatreason(de technische oorzaak) enendReason. 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 ooktranscript/summary/durationSecs/priceHal- elke anderereasonlaat die vier velden weg. Bij zo'n gesprek isreasonofwelengine_stallofengine_error(een storing van de engine), ofwelunfinishedwanneer 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 (priceHalis0), heeft geen opname ensummarylegt in een korte tekst uit wat er is gebeurd. Hettranscriptbevat 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).durationSecsis de geschatte tijd tot de onderbreking (hoogstens 1.800 s wanneer de engine het niet kon meten). Het blokenginemet 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 aankind: "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 heeftendReason: "capacity", duur0en prijs0; 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 veldsmsNumberbevat het nummer waarvandaan is verzonden en isnullvoor 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 uitGET /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.databevatid,from(de afzender: een telefoonnummer in E.164, of een tekstnaam zoals “Google”, hoogstens 32 tekens;unknownals de afzender ontbreekt),to(uw nummer waarop het bericht binnenkwam),body,segmentsenreceivedAt(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 inGET /v1/messages?direction=in.tsin de envelop is het moment waarop het event is aangemaakt (eveneens in ms, terwijlt=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 inGET /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 (statusreleasing) wordt een bericht opgeslagen maar wordt er geenmessage.receivedvoor verstuurd. Een account dat de beheerder van volai handmatig heeft opgeschort, krijgt helemaal geenmessage.received, en voor berichten die tijdens de opschorting binnenkomen wordt het na herstel van het account niet alsnog verstuurd; ook die vindt u inGET /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
{
"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
{
"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.
{
"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.
{
"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": "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.
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 });
}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", 200Eigen 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.
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-signatureen hop-by-hop-headers worden door de aflevering zelf ingesteld en kunnen niet worden gezet. - Waarden zijn geheimen:
GET /v1/webhookgeeft ze ingekort terug (Bear***). Als u zo'n masker terugstuurt, blijft de opgeslagen waarde behouden. - Als u
headersweglaat, blijven de opgeslagen headers behouden,{}verwijdert ze. Wanneer u de URL wijzigt naar een ander adres (ander domein, schema of poort, zelfs een toegevoegdewww.), 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 foutinvalid_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-Signaturewel. Een redirect vanexample.comnaarwww.example.comis 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 -
localhosten 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/webhooktijdelijk naar het adres daarvan wijzen.
Gerelateerd
MCP-server
Koppel Claude Code, Codex CLI, Codex desktop, Cursor en andere AI-editors.
REST API
Volledige referentie van alle endpoints: nummers, gesprekken, sms, mobiele nummers voor sms, agents en hun concepten, tools, opnames, webhooks, niet-bellen-lijst, relay, account, taken, tegoed, documenten, agenda's en integraties.
Spraakagent
Systeemprompt, tools, overdracht aan een mens, opnames en gestructureerde gespreksgegevens.