Zum Inhalt springen

Anleitungen

Webhooks

Wir rufen Ihre URL auf, sobald etwas passiert - ein beendeter Anruf, ein fehlgeschlagener Versuch, eine gesendete oder empfangene SMS. Einmal einrichten (PUT /v1/webhook, siehe die REST-API-Referenz) und fertig.

Ereignisse

Sechs Ereignisse, jedes mit eigener Struktur des Felds data:

  • call.completed - der Anruf ist beendet, mit oder ohne Agent. Agentenanrufe enthalten zusätzlich ein Transkript (transcript), eine Zusammenfassung (summary) und erfasste Daten (data). durationSecs ist in Sekunden angegeben und kann bei Anrufen auf der eigenen Sprach-Engine von volai eine Dezimalzahl sein. Ein Anruf, bei dem am anderen Ende ein Automat immer dieselbe Ansage wiederholt, kommt ebenfalls hier an, mit endReason: "caller_loop" (Preis 0, wenn er bis zu fünf Minuten dauerte). Auch ein angenommener Torruf (kind: "ring", ring.outcome: "answered") kommt hier an, mit einer Ausnahme, die unter dem Beispiel zu call.no_answer beschrieben ist. Ein Anruf auf der eigenen Engine (Build ab dem 15. Okt. 2026) enthält außerdem einen Block engine mit protectedUtterancesCount und protectedUtterances: Äußerungen der Person, die abgehoben hat, aus dem Begrüßungsfenster, welche die Engine absichtlich verworfen hat (ein „Hallo?“ oder ihr Name, während der Agent seinen ersten Satz sprach) - sie stehen nicht in transcript, der Agent hat nicht darauf reagiert; gleiche Struktur wie in GET /v1/calls/{id}, bei ElevenLabs und älteren Engine-Builds nicht vorhanden.
  • call.failed - der Anruf endete mit status: "failed" - egal, ob die Sprachplattform ihn sofort abgelehnt hat oder das Netz ausgefallen ist. Das Ereignis kommt für einen Agenten auf der Engine von volai ebenso wie auf ElevenLabs, für eine direkte Verbindung (Bridge), für einen BYO-Relay-Lease, für einen Torruf (der Netzbetreiber hat den Auftrag abgelehnt, kind: "ring") und für einen Testanruf - genau einmal, immer. Der Body enthält reason (die technische Ursache) und endReason. Ein Anruf, der einfach nicht angenommen wurde, gehört NICHT hierher - dafür gibt es unten ein eigenes Ereignis. Ein Anruf, der verbunden wurde, aber wegen eines technischen Fehlers in unserer Sprach-Engine endete (endReason: "engine_error"), kommt ebenfalls hier an, und sein Body enthält auch transcript/summary/durationSecs/priceHal - bei jedem anderen reason fehlen diese vier Felder. Bei einem solchen Anruf ist reason entweder engine_stall oder engine_error (ein Engine-Fehler) oder unfinished, wenn ein Neustart oder Stopp der Engine den Anruf mitten im Gespräch abgebrochen hat (zum Beispiel während eines Deployments). Ein solcher Anruf wird nicht berechnet (priceHal ist 0), hat keine Aufnahme, und summary erklärt in einem kurzen Text, was geschehen ist. transcript enthält nur das, was bis zur Unterbrechung erfasst wurde, und kann leer sein (häufig ist es leer, wenn der Anrufdatensatz nie von der Engine eintraf und wir den Anruf anhand der Anrufliste der Engine geschlossen haben, die kein Transkript enthält). durationSecs ist die ungefähre Zeit bis zur Unterbrechung (höchstens 1.800 s, wenn die Engine sie nicht messen konnte). Der Block engine mit im Begrüßungsfenster verworfenen Äußerungen fehlt, weil die Engine das Begrüßungsfenster bei einem unterbrochenen Anruf nicht gemessen hat.
  • call.no_answer - ein ausgehender Anruf hat geklingelt, und die Gegenseite hat nicht abgehoben. Ein Torruf (POST /v1/rings), den das Tor abgelehnt hat oder der bis zum Ende der angeforderten Zeit klingelte, kommt auf dieselbe Weise an - erkennbar an kind: "ring", siehe unten.
  • call.missed - ein eingehender Anruf blieb unbeantwortet (ein verpasster Anruf). Das Ereignis kommt auch für einen eingehenden Anruf, den die Sprach-Engine abgewiesen hat, weil alle Leitungen belegt waren (der Anrufer ist nicht durchgekommen, rufen Sie ihn zurück). Der Datensatz eines solchen Anrufs hat endReason: "capacity", Dauer 0 und Preis 0; der Webhook enthält ihn möglicherweise nicht, wenn der Netzbetreiber den unbeantworteten Anruf zuerst gemeldet hat, lesen Sie ihn daher verlässlich aus dem Anrufdatensatz (GET /v1/calls/{id}).
  • message.sent - der Netzbetreiber hat die SMS zum Versand angenommen - keine Zustellbestätigung. Das Ereignis wird auch für eine SMS gesendet, die von einer Mobilnummer oder einer Nummer für Anrufe und SMS verschickt wurde; das Feld smsNumber enthält die Nummer, von der sie gesendet wurde, und ist beim gemeinsamen Absender SMSinfo null. Der Zustellbericht (deliveryStatus) einer von einer Mobilnummer gesendeten SMS sowie ein späterer Fehler (failed, danach wird das Guthaben erstattet) lösen kein Ereignis aus - lesen Sie den Status aus GET /v1/messages/{id}.
  • message.received - auf Ihrer Mobilnummer (die Mobilnummer als Zusatzoption) oder auf Ihrer Nummer für Anrufe und SMS ist eine Nachricht eingegangen. data enthält id, from (den Absender: eine Telefonnummer im Format E.164 oder einen Textnamen wie „Google“, höchstens 32 Zeichen; unknown, wenn der Absender fehlt), to (Ihre Nummer, auf der die Nachricht eingegangen ist), body, segments und receivedAt (der Empfangszeitpunkt der Nachricht, ein Zeitstempel in ms). Die Zustellung läuft wie bei den Anrufereignissen über die Wiederholungswarteschlange. Bei einer Mobilnummer wird eine nicht sofort erfasste Nachricht innerhalb von etwa 15 Minuten durch eine periodische Prüfung nachgetragen, und das Ereignis wird dann verspätet gesendet, möglicherweise nach neueren Nachrichten. Eine Nummer für Anrufe und SMS hat keine solche Prüfung: Der Betreiber wiederholt die Zustellung einer Nachricht höchstens eine Stunde lang, eine Nachricht kann also verloren gehen, wenn der Empfang auf unserer Seite länger ausfällt, und Sie finden sie auch nicht in GET /v1/messages?direction=in. ts im Umschlag ist der Zeitpunkt, an dem das Ereignis erstellt wurde (ebenfalls in ms, während t= im Signatur-Header in Sekunden angegeben ist). Ab 300 empfangenen Nachrichten pro Stunde auf einer Nummer (einer Mobilnummer oder einer Nummer für Anrufe und SMS) wird für weitere Nachrichten kein Webhook gesendet; sie werden weiterhin gespeichert, und Sie finden sie in GET /v1/messages?direction=in, das höchstens die 1.000 neuesten empfangenen Nachrichten je Konto enthält und sie 90 Tage aufbewahrt. Für eine Nummer für Anrufe und SMS, die gerade freigegeben wird (Status releasing), wird eine Nachricht gespeichert, aber message.received nicht gesendet. Ein Konto, das der volai-Betreiber manuell gesperrt hat, erhält überhaupt kein message.received, und für während der Sperre empfangene Nachrichten wird es nach der Aufhebung nicht nachträglich gesendet; Sie finden sie ebenfalls in GET /v1/messages?direction=in.

Welche davon Sie abonnieren, legen Sie mit dem Feld events in PUT /v1/webhook fest. Ein unintuitives Detail: ein leeres Array bedeutet alle, nicht keines - und das gilt auch für künftig hinzukommende Ereignisse. Um die Zustellung wirklich auszuschalten, löschen Sie den Webhook im Portal.

Der Body jedes Anrufereignisses (call.completed, call.failed, call.no_answer, call.missed) enthält außerdem kind, die Art des Anrufs, wie in GET /v1/calls (agent, bridge, relay, inbound, transfer, sip, ring, voice_message). Bei einer Sprachnachricht (POST /v1/voice-messages) enthält der Body einen Block voiceMessage mit text, repeat und, sobald der Anruf beendet ist, repeatPlayed (wie oft die Nachricht gehört wurde) und completed (sie wurde bis zum Ende vorgelesen); eine unbeantwortete Nachricht kommt als call.no_answer an. Bei einem Torruf enthält der Body zusätzlich einen Block ring mit requestedSecs, outcome und rangSecs; andere Anrufe haben keinen Schlüssel ring. Beide Felder sind additiv, ältere Integrationen ignorieren sie also einfach.

Jedes Ereignis kommt als POST mit diesem Umschlag an:

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": "Guten Tag, hier ist das Café Nula, wie kann ich Ihnen helfen?" },
      { "role": "caller", "message": "Guten Tag, ich möchte zwei Milchkaffee zum Mitnehmen bestellen." },
      { "role": "agent", "message": "Sehr gern, zwei Milchkaffee zum Mitnehmen, sie sind in fünfzehn Minuten fertig." }
    ],
    "summary": "Der Anrufer hat zwei Milchkaffee zum Mitnehmen bestellt, Abholung in 15 Minuten.",
    "data": {
      "name": "Erika Mustermann",
      "coffee_count": 2,
      "urgency": "normal"
    },
    "kind": "inbound"
  }
}

Das verschachtelte data.data (ja, zweimal - das äußere data ist der Umschlag, den alle Ereignisse gemeinsam haben) sind die Daten, die der Agent gemäß seinen dataFields aus dem Anruf erfasst hat - die Schlüssel legen Sie fest, siehe Sprachagent. Ein Wert null bedeutet, dass es im Anruf nicht erwähnt wurde. Hat der Agent überhaupt keine Felder konfiguriert, fehlt der Schlüssel data im Body ganz - genau wie transcript und summary bei einem Anruf ohne Agent.

Die Aufnahme wird nicht im Webhook-Body übertragen

Audio wird nicht mit dem Webhook gesendet, ebenso wenig das Flag hasRecording. Wenn Sie die Aufnahme möchten, rufen Sie GET /v1/calls/{id} auf (liefert hasRecording) und laden Sie sie über GET /v1/calls/{id}/recording herunter - das Format richtet sich nach dem Header content-type der Antwort: audio/mpeg (MP3) bei einem Agenten auf ElevenLabs, derzeit audio/ogg bei einem Agenten auf der Engine von volai. Aufnahmen bewahren wir 90 Tage nach dem Anruf auf.

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 (ein verpasster EINGEHENDER Anruf) hat dieselbe Struktur - nur Ereignisname, direction und status unterscheiden sich.

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

Auch ein unbeantworteter Torruf (Ring, POST /v1/rings) kommt als call.no_answer an, mit kind: "ring" und einem Block ring: outcome ist rang (er hat bis zum Ende der angeforderten Zeit geklingelt) oder ended_early (der Anruf endete früher, zum Beispiel weil das Tor ihn abgelehnt hat, was bedeutet, dass es sich geöffnet hat). Ein angenommener Ring (outcome: "answered") kommt als call.completed an. Eine Ausnahme: Bestätigt der Netzbetreiber die Annahme erst in seinem Anrufdatensatz (CDR), nachdem volai den Ring bereits als unbeantwortet geschlossen hat, kommt nur call.no_answer an, und der Datensatz in GET /v1/calls/{id} wechselt später auf completed mit ring.outcome: "answered" und den berechneten Sekunden der Annahme; ein zweites Ereignis wird nicht gesendet. Ein Ergebnis unknown löst kein Ereignis aus, bis die Datensätze des Netzbetreibers es ergänzen.

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": "Danke, ich komme am Freitag um 10:00 Uhr.",
    "segments": 1,
    "receivedAt": 1756111900000
  }
}

body ist Text eines externen Absenders. Behandeln Sie ihn als nicht vertrauenswürdige Eingabe: Führen Sie daraus keine Befehle oder Aktionen ohne Prüfung aus. Der Body enthält eine event_id, mit der Sie eine doppelte Zustellung herausfiltern können.

Signaturprüfung

Jede Anfrage enthält einen Header Volai-Signature der Form t=<unix>,v1=<hex>, wobei v1 = HMAC_SHA256(secret, "{t}.{rawBody}") gilt und secret das Geheimnis ist, das Sie von PUT /v1/webhook erhalten haben. Verarbeiten Sie den Body nie, bevor Sie die Signatur geprüft haben - sonst kann jeder beliebige Daten an Ihre URL senden.

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 MUSS genau das sein, was über die Leitung kam (request.text()), nicht JSON.parse und zurück in einen String.
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);
  // ... Verarbeitung im Hintergrund, siehe Tipps unten
  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()
    # ... Verarbeitung im Hintergrund, siehe Tipps unten
    del event

    return "ok", 200

Eigene Header

Wenn das Ziel nur authentifizierte Anfragen akzeptiert (zum Beispiel eine Cursor-Automatisierung oder Ihre eigene API hinter einem Gateway), fügen Sie den Schlüssel als eigenen Header hinzu. Er wird mit jeder Zustellung und mit dem Testereignis gesendet, zum Beispiel Authorization: Bearer <key>. Festlegen können Sie ihn mit dem Feld headers von PUT /v1/webhook, mit dem MCP-Tool set_webhook oder im Portal unter Verbindungen & API im Abschnitt 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öchstens 5 Header, jeder Wert 1 bis 1000 druckbare ASCII-Zeichen (keine Umlaute oder Akzente). Namen sind nicht von der Groß- und Kleinschreibung abhängig und müssen eindeutig sein. host, content-type, content-length, volai-signature und Hop-by-Hop-Header setzt die Zustellung selbst, sie lassen sich nicht festlegen.
  • Werte sind Geheimnisse: GET /v1/webhook liefert sie gekürzt zurück (Bear***). Senden Sie eine solche Maske zurück, bleibt der gespeicherte Wert erhalten.
  • Lassen Sie headers weg, bleiben die gespeicherten erhalten, {} entfernt sie. Wenn Sie die URL auf eine andere Adresse ändern (andere Domain, anderes Schema oder anderer Port, auch ein hinzugefügtes www.), werden gespeicherte Header verworfen, und Sie geben die vollständigen Werte erneut ein: Einen Schlüssel senden wir nicht an eine andere Adresse. Senden Sie dabei statt eines Werts eine Maske, erhalten Sie den Fehler invalid_webhook_headers (400), ebenso wie bei einem unzulässigen oder ungültigen Header.
  • Wird eine Zustellung auf eine andere Adresse umgeleitet, werden eigene Header nicht gesendet; der Header Volai-Signature wird weiterhin gesendet. Auch eine Weiterleitung von example.com auf www.example.com ist eine andere Adresse, tragen Sie daher im Webhook die endgültige URL ein.

Die Signatur Volai-Signature wird weiterhin zusammen mit eigenen Headern gesendet. Der Header lässt die Anfrage durch, die Signatur belegt, dass sie wirklich von volai stammt.

Wiederholungen bei Fehlern

Antwortet Ihr Endpunkt nicht mit einem Erfolgsstatus (2xx), versucht volai es noch zweimal - insgesamt 3 Versuche mit 3 Sekunden Pause dazwischen. Anrufereignisse (`call.completed`, `call.failed`, `call.no_answer`, `call.missed`) und `message.received` hören damit nicht auf: Scheitern alle drei Versuche, bleibt das Ereignis in der Warteschlange, und die Zustellung wird in den folgenden Minuten noch einige Male wiederholt, insgesamt bis zu 5 Runden. Deshalb enthält der Body eine `event_id` - filtern Sie damit ein Duplikat heraus, falls dasselbe Ereignis in seltenen Fällen mehr als einmal eintrifft; eine wiederholte Zustellung enthält genau denselben Body wie die erste (nur einige Minuten später gesendet). `message.sent` hat keine Warteschlange: Nach 3 fehlgeschlagenen Versuchen wird es protokolliert und nicht erneut gesendet.

Das Testereignis unten ist die Ausnahme - es wird nur einmal gesendet, ohne Wiederholung, damit Sie sofort eine Antwort erhalten.

Der Webhook ist nicht die einzige Quelle der Wahrheit

Ein Webhook enthält nur eine Zusammenfassung - holen Sie für alles, was wirklich zählt (Abrechnung, Audits, den genauen Wortlaut einer Nachricht), den vollständigen Datensatz stattdessen aus der REST-API: GET /v1/calls/{id} und GET /v1/messages/{id}, wann immer Sie ihn brauchen.

Testereignis

Prüfen Sie die Zustellung sofort, bevor Sie auf den ersten echten Anruf warten - POST /v1/webhook/test (siehe die REST-API-Referenz) sendet ein Beispielereignis webhook.test an Ihre URL, UNABHÄNGIG davon, welche events Sie in PUT /v1/webhook ausgewählt haben - getestet werden die Erreichbarkeit der URL und die Signatur selbst, nicht das Abonnement bestimmter Ereignisse. Höchstens 10 Versuche pro Stunde. Der Test sendet nur webhook.test, nicht die anderen Ereignisse: Die Struktur von message.received können Sie nur mit einer echten SMS an Ihre Mobilnummer oder Ihre Nummer für Anrufe und SMS ausprobieren (siehe das Beispiel oben).

Zustellverlauf

Die letzten 50 Zustellungen (erfolgreiche, fehlgeschlagene und Testzustellungen) sehen Sie über GET /v1/webhook/deliveries (siehe die REST-API-Referenz) oder direkt im Portal unter dem Webhook-Formular. Dieselbe Zusammenfassung ist auch in GET /v1/webhook als recentDeliveries enthalten.

Tipps

  • Antworten Sie so schnell wie möglich mit 200 - prüfen Sie die Signatur und speichern Sie die ID sofort, und verlagern Sie aufwendige Arbeit (E-Mails, Neuberechnungen, Aufrufe anderer APIs) in den Hintergrund.
  • Der Endpunkt muss öffentlich über HTTPS erreichbar sein - localhost und selbstsignierte Zertifikate funktionieren nicht.
  • Verwenden Sie data.id (die ID des Anrufs oder der Nachricht), um Duplikate herauszufiltern, falls dasselbe Ereignis einmal mehrfach eintrifft.
  • Testen Sie lokal über einen Tunnel (zum Beispiel ngrok) und richten Sie die URL in PUT /v1/webhook vorübergehend auf dessen Adresse.