CallwiseDocs

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

HeaderValue
x-callwise-timestampUnix time in seconds when the request was signed
x-callwise-signatureHex 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

StatusWhen
200 { "ok": true, "duplicate": false }Event applied
200 { "ok": true, "duplicate": true }An event with this id was already applied; nothing changed
400Body is not valid JSON, or doesn't match the schema (for example an unknown event type)
401Signature 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:

FieldTypeNotes
idstring, 1–200 charsEvent id, unique per event
typecall.started | call.turn | call.ended
accountIduuidThe workspace (team account)
callobjectcall.id (uuid) is required. The voice service picks it and uses the same id for every event of that call.
turnobjectOnly 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 fieldType
seqinteger ≥ 0, order within the call
atMsinteger ≥ 0, milliseconds since the call started
speakeragent | caller | staff | system
textstring

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 fieldValues
endedAtISO 8601
durationSinteger seconds
intentbooking reschedule cancel pricing insurance billing hours emergency general spam
topicfree text
outcomebooked rescheduled cancelled info transferred voicemail missed spam
sentimentpositive neutral negative
confidence0–1
summaryfree text
handoffReasonfree 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.

On this page