Contrato del servicio de voz
El webhook firmado que el servicio de voz invoca para crear llamadas, agregar turnos de la transcripción y cerrar llamadas.
Solo el contrato
Esto es un contrato. El servicio de voz en Python (voz a texto, el modelo de lenguaje y texto a voz por WebRTC y SIP) aún no está construido. El lado de Callwise del webhook está construido y probado; nada lo invoca en producción.
El servicio de voz es el único que escribe los registros de llamadas. Informa cada llamada a la aplicación como una serie de eventos. La fuente de verdad de la carga es el esquema zod voiceEventSchema en 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)Firma
| Encabezado | Valor |
|---|---|
x-callwise-timestamp | Tiempo Unix en segundos en el momento en que se firmó la solicitud |
x-callwise-signature | HMAC-SHA256 en hexadecimal de `${timestamp}.${rawBody}` con el secreto compartido CALLWISE_VOICE_SECRET |
Firme los bytes exactos que envía. La aplicación vuelve a calcular la firma sobre el cuerpo sin procesar antes de analizarlo.
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()Las solicitudes cuya marca de tiempo se aleja más de 5 minutos del reloj del servidor se rechazan, lo que impide las repeticiones de solicitudes antiguas.
Respuestas
| Estado | Cuándo |
|---|---|
200 { "ok": true, "duplicate": false } | Evento aplicado |
200 { "ok": true, "duplicate": true } | Un evento con este id ya se había aplicado; no cambió nada |
400 | El cuerpo no es JSON válido o no coincide con el esquema (por ejemplo, un tipo de evento desconocido) |
401 | Falta la firma o es incorrecta, o la marca de tiempo está fuera de la ventana de 5 minutos |
Idempotencia
Cada evento tiene su propio id. Enviar el mismo evento dos veces es seguro: el segundo se confirma con duplicate: true y se ignora. Reintente ante errores de red y 5xx con el mismo id.
Forma del evento
Todos los eventos tienen el mismo sobre:
| Campo | Tipo | Notas |
|---|---|---|
id | string, 1–200 caracteres | Id del evento, único por evento |
type | call.started | call.turn | call.ended | |
accountId | uuid | El espacio de trabajo (cuenta de equipo) |
call | object | call.id (uuid) es obligatorio. El servicio de voz lo elige y usa el mismo id en cada evento de esa llamada. |
turn | object | Solo en call.turn |
call.started
Crea una llamada en vivo. Quien llama se busca por número de teléfono o se crea. Si se omite agentId, el agente se elige a partir del número que se marcó.
{
"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
}
}Campos opcionales de call: numberId (uuid) o to (E.164), agentId, locationId, callerPhone, callerName, direction (inbound o outbound), startedAt (ISO 8601), isTest, afterHours.
call.turn
Agrega una línea de la transcripción. call solo lleva el id. Los turnos se guardan por (call id, seq), así que reenviar un turno lo reemplaza.
{
"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."
}
}Campo de turn | Tipo |
|---|---|
seq | entero ≥ 0, orden dentro de la llamada |
atMs | entero ≥ 0, milisegundos desde que empezó la llamada |
speaker | agent | caller | staff | system |
text | string |
call.ended
Cierra la llamada. Los minutos facturados son ceil(durationS / 60) y se suman al uso del día. La aplicación cobra entonces créditos por la llamada: minutos facturados × la tarifa de la pila de modelos del agente + un resumen (consulte Modelos de IA y créditos), tomados de la bolsa de créditos compartida. Si se omite summary, se completa un resumen de plantilla.
En call.started, la respuesta le indica al servicio de voz cómo enrutar la llamada: route: "agent", o route: "fallback" con un reason cuando la bolsa de créditos, el límite de concurrencia o el límite por hora dicen que no. Las llamadas nunca se descartan.
{
"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"
}
}Campo de call | Valores |
|---|---|
endedAt | ISO 8601 |
durationS | segundos enteros |
intent | booking reschedule cancel pricing insurance billing hours emergency general spam |
topic | texto libre |
outcome | booked rescheduled cancelled info transferred voicemail missed spam |
sentiment | positive neutral negative |
confidence | 0–1 |
summary | texto libre |
handoffReason | texto libre, cuando el resultado es transferred |
recordingPath | <accountId>/<callId>.mp3 en el bucket privado cw-recordings |
Cómo se aplica
La ruta comprueba la firma y el esquema, y después llama a la función de Postgres cw_apply_voice_event(jsonb) con el rol de servicio. Solo el rol de servicio puede ejecutar esa función, y los usuarios con sesión iniciada no tienen permiso de inserción en las llamadas ni en los turnos, así que el webhook es la única vía por la que se crean los registros de llamadas.
Antes del lanzamiento
Consulte la entrada del servicio de voz en Conexiones para ver qué hay que verificar: la latencia, el manejo de interrupciones (barge-in) y la rotación del secreto.