Voice service contract
The signed webhook the voice service calls to create calls, add transcript turns and close calls.
Contract only
This is a contract. The Python voice service (speech to text, the language model and text to speech over WebRTC and SIP) is not built yet. The Callwise side of the webhook is built and tested; nothing calls it in production.
The voice service is the only writer of call records. It reports each call to the app as a series of events. The source of truth for the payload is the zod schema voiceEventSchema in apps/web/lib/callwise/voice-events.ts.
Endpoint
POST /api/voice/events
Content-Type: application/json
x-callwise-timestamp: 1760530212
x-callwise-signature: 5f0c2e…(64 hex characters)Signing
| Header | Value |
|---|---|
x-callwise-timestamp | Unix time in seconds when the request was signed |
x-callwise-signature | Hex HMAC-SHA256 of `${timestamp}.${rawBody}` with the shared secret CALLWISE_VOICE_SECRET |
Sign the exact bytes you send. The app recomputes the signature over the raw body before parsing it.
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()Requests whose timestamp is more than 5 minutes from the server's clock are refused, which stops replays of old requests.
Responses
| Status | When |
|---|---|
200 { "ok": true, "duplicate": false } | Event applied |
200 { "ok": true, "duplicate": true } | An event with this id was already applied; nothing changed |
400 | Body is not valid JSON, or doesn't match the schema (for example an unknown event type) |
401 | Signature missing or wrong, or timestamp outside the 5-minute window |
Idempotency
Every event has its own id. Sending the same event twice is safe: the second one is acknowledged with duplicate: true and ignored. Retry on network errors and 5xx with the same id.
Event shape
Every event has the same envelope:
| Field | Type | Notes |
|---|---|---|
id | string, 1–200 chars | Event id, unique per event |
type | call.started | call.turn | call.ended | |
accountId | uuid | The workspace (team account) |
call | object | call.id (uuid) is required. The voice service picks it and uses the same id for every event of that call. |
turn | object | Only on call.turn |
call.started
Creates a live call. The caller is matched by phone number, or created. If agentId is left out, the agent is chosen from the number that was dialled.
{
"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
}
}Optional call fields: numberId (uuid) or to (E.164), agentId, locationId, callerPhone, callerName, direction (inbound or outbound), startedAt (ISO 8601), isTest, afterHours.
call.turn
Adds one line of the transcript. call carries only the id. Turns are stored by (call id, seq), so resending a turn replaces it.
{
"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 field | Type |
|---|---|
seq | integer ≥ 0, order within the call |
atMs | integer ≥ 0, milliseconds since the call started |
speaker | agent | caller | staff | system |
text | string |
call.ended
Closes the call. Billed minutes are ceil(durationS / 60) and are added to the day's usage. The app then charges credits for the call: billed minutes × the agent's model stack rate + one summary (see AI models and credits), taken from the shared credit pool. If summary is left out, a template summary is filled in.
On call.started, the response tells the voice service how to route the call: route: "agent", or route: "fallback" with a reason when the credit pool, the concurrency limit or the hourly limit says no. Calls are never dropped.
{
"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 field | Values |
|---|---|
endedAt | ISO 8601 |
durationS | integer seconds |
intent | booking reschedule cancel pricing insurance billing hours emergency general spam |
topic | free text |
outcome | booked rescheduled cancelled info transferred voicemail missed spam |
sentiment | positive neutral negative |
confidence | 0–1 |
summary | free text |
handoffReason | free text, when the outcome is transferred |
recordingPath | <accountId>/<callId>.mp3 in the private cw-recordings bucket |
How it is applied
The route checks the signature and the schema, then calls the Postgres function cw_apply_voice_event(jsonb) with the service role. Only the service role can run that function, and signed-in users have no insert permission on calls or turns, so the webhook is the only way call records are created.
Before launch
See the voice service entry under Connections for what must be verified: latency, barge-in handling and secret rotation.