Vertrag des Sprachdienstes
Der signierte Webhook, den der Sprachdienst aufruft, um Anrufe anzulegen, Transkriptwechsel hinzuzufügen und Anrufe zu schließen.
Nur ein Vertrag
Dies ist ein Vertrag. Der Python-Sprachdienst (Sprache zu Text, das Sprachmodell und Text zu Sprache über WebRTC und SIP) ist noch nicht gebaut. Die Callwise-Seite des Webhooks ist gebaut und getestet; in der Produktion ruft ihn nichts auf.
Der Sprachdienst ist der einzige Schreiber von Anrufdatensätzen. Er meldet jeden Anruf der App als eine Folge von Ereignissen. Die Quelle der Wahrheit für die Nutzlast ist das Zod-Schema voiceEventSchema in apps/web/lib/callwise/voice-events.ts.
Endpunkt
POST /api/voice/events
Content-Type: application/json
x-callwise-timestamp: 1760530212
x-callwise-signature: 5f0c2e…(64 hex characters)Signierung
| Header | Wert |
|---|---|
x-callwise-timestamp | Unix-Zeit in Sekunden, zu der die Anfrage signiert wurde |
x-callwise-signature | Hex-HMAC-SHA256 von `${timestamp}.${rawBody}` mit dem gemeinsamen Geheimnis CALLWISE_VOICE_SECRET |
Signieren Sie genau die Bytes, die Sie senden. Die App berechnet die Signatur über den rohen Body neu, bevor sie ihn parst.
import { createHmac } from 'node:crypto';
const timestamp = Math.floor(Date.now() / 1000).toString();
const rawBody = JSON.stringify(event);
const signature = createHmac('sha256', process.env.CALLWISE_VOICE_SECRET!)
.update(`${timestamp}.${rawBody}`)
.digest('hex');import hmac, hashlib, json, os, time
timestamp = str(int(time.time()))
raw_body = json.dumps(event, separators=(",", ":"))
signature = hmac.new(
os.environ["CALLWISE_VOICE_SECRET"].encode(),
f"{timestamp}.{raw_body}".encode(),
hashlib.sha256,
).hexdigest()Anfragen, deren Zeitstempel mehr als 5 Minuten von der Uhr des Servers abweicht, werden abgelehnt; das verhindert Wiederholungen alter Anfragen.
Antworten
| Status | Wann |
|---|---|
200 { "ok": true, "duplicate": false } | Ereignis angewendet |
200 { "ok": true, "duplicate": true } | Ein Ereignis mit dieser id wurde bereits angewendet; nichts hat sich geändert |
400 | Der Body ist kein gültiges JSON oder entspricht nicht dem Schema (zum Beispiel ein unbekannter Ereignistyp) |
401 | Signatur fehlt oder ist falsch, oder der Zeitstempel liegt außerhalb des Fensters von 5 Minuten |
Idempotenz
Jedes Ereignis hat seine eigene id. Dasselbe Ereignis zweimal zu senden ist sicher: Das zweite wird mit duplicate: true bestätigt und ignoriert. Wiederholen Sie bei Netzwerkfehlern und 5xx mit derselben id.
Form des Ereignisses
Jedes Ereignis hat denselben Umschlag:
| Feld | Typ | Hinweise |
|---|---|---|
id | string, 1–200 Zeichen | Ereignis-ID, eindeutig je Ereignis |
type | call.started | call.turn | call.ended | |
accountId | uuid | Der Arbeitsbereich (Teamkonto) |
call | object | call.id (uuid) ist erforderlich. Der Sprachdienst wählt sie und verwendet dieselbe ID für jedes Ereignis dieses Anrufs. |
turn | object | Nur bei call.turn |
call.started
Legt einen laufenden Anruf an. Der Anrufer wird anhand der Telefonnummer zugeordnet oder neu angelegt. Wenn agentId weggelassen wird, wird der Agent aus der gewählten Nummer bestimmt.
{
"id": "evt_01JA8S0K6Q2M3",
"type": "call.started",
"accountId": "3c1d6c1e-5c55-4f3e-9d0b-6b1f6f2a7a10",
"call": {
"id": "9b2f7d4e-1a7c-4e0f-8f5e-2d6c4b9a1e33",
"to": "+15552108842",
"callerPhone": "+15551237890",
"callerName": "Emily Johnson",
"direction": "inbound",
"startedAt": "2026-10-15T13:41:02Z",
"isTest": false,
"afterHours": false
}
}Optionale call-Felder: numberId (uuid) oder to (E.164), agentId, locationId, callerPhone, callerName, direction (inbound oder outbound), startedAt (ISO 8601), isTest, afterHours.
call.turn
Fügt eine Zeile des Transkripts hinzu. call trägt nur die ID. Wechsel werden nach (call id, seq) gespeichert, sodass das erneute Senden eines Wechsels ihn ersetzt.
{
"id": "evt_01JA8S0M1T7Z4",
"type": "call.turn",
"accountId": "3c1d6c1e-5c55-4f3e-9d0b-6b1f6f2a7a10",
"call": { "id": "9b2f7d4e-1a7c-4e0f-8f5e-2d6c4b9a1e33" },
"turn": {
"seq": 1,
"atMs": 4200,
"speaker": "caller",
"text": "Hi, I need a cleaning next week."
}
}turn-Feld | Typ |
|---|---|
seq | integer ≥ 0, Reihenfolge innerhalb des Anrufs |
atMs | integer ≥ 0, Millisekunden seit Beginn des Anrufs |
speaker | agent | caller | staff | system |
text | string |
call.ended
Schließt den Anruf. Abgerechnete Minuten sind ceil(durationS / 60) und werden zur Nutzung des Tages addiert. Die App belastet den Anruf dann mit Credits: abgerechnete Minuten × der Satz des Modell-Stacks des Agenten + eine Zusammenfassung (siehe KI-Modelle und Credits), entnommen aus dem gemeinsamen Credit-Pool. Wenn summary weggelassen wird, wird eine Vorlagenzusammenfassung eingesetzt.
Bei call.started sagt die Antwort dem Sprachdienst, wie der Anruf geroutet wird: route: "agent" oder route: "fallback" mit einem reason, wenn der Credit-Pool, das Parallelitätslimit oder das Stundenlimit Nein sagt. Anrufe werden niemals verworfen.
{
"id": "evt_01JA8S2B9W0K8",
"type": "call.ended",
"accountId": "3c1d6c1e-5c55-4f3e-9d0b-6b1f6f2a7a10",
"call": {
"id": "9b2f7d4e-1a7c-4e0f-8f5e-2d6c4b9a1e33",
"endedAt": "2026-10-15T13:42:26Z",
"durationS": 84,
"intent": "booking",
"topic": "Dental cleaning",
"outcome": "booked",
"sentiment": "positive",
"confidence": 0.96,
"summary": "New caller booked a dental cleaning for Tuesday at 10:00.",
"recordingPath": "3c1d6c1e-5c55-4f3e-9d0b-6b1f6f2a7a10/9b2f7d4e-1a7c-4e0f-8f5e-2d6c4b9a1e33.mp3"
}
}call-Feld | Werte |
|---|---|
endedAt | ISO 8601 |
durationS | integer Sekunden |
intent | booking reschedule cancel pricing insurance billing hours emergency general spam |
topic | Freitext |
outcome | booked rescheduled cancelled info transferred voicemail missed spam |
sentiment | positive neutral negative |
confidence | 0–1 |
summary | Freitext |
handoffReason | Freitext, wenn das Ergebnis transferred ist |
recordingPath | <accountId>/<callId>.mp3 im privaten Bucket cw-recordings |
Wie es angewendet wird
Die Route prüft die Signatur und das Schema und ruft dann die Postgres-Funktion cw_apply_voice_event(jsonb) mit der Service-Role auf. Nur die Service-Role kann diese Funktion ausführen, und angemeldete Benutzer haben kein Einfügerecht auf Anrufe oder Wechsel; der Webhook ist damit der einzige Weg, auf dem Anrufdatensätze entstehen.
Vor dem Start
Siehe den Eintrag zum Sprachdienst unter Verbindungen dafür, was geprüft werden muss: Latenz, Barge-in-Behandlung und Rotation des Geheimnisses.