Documentación de la API
La API de Voza usa HTTPS, JSON y los verbos HTTP de siempre. Los cuerpos de mensajes, llamadas y webhooks siguen el formato de la API oficial de WhatsApp (Meta), así que el mismo código sirve para los dos tipos de número de Voza.
Dirección base:
https://www.vozaapi.com/v1
| Número por código QR | Número oficial (Meta) | |
|---|---|---|
| Cómo se conecta | Escaneando un código QR con el celular, como en WhatsApp Web | Con las credenciales de tu app de Meta (WhatsApp Cloud API) |
| Cuenta de WhatsApp | WhatsApp o WhatsApp Business común; el celular sigue funcionando | WhatsApp Business Platform, en tu cuenta de Meta |
| Mensajes | Texto y archivos multimedia | Texto, archivos multimedia, plantillas, mensajes interactivos, stickers, ubicación, contactos y reacciones |
| Ventana de 24 horas | No hay | Fuera de ella, solo plantillas aprobadas por Meta |
| Llamadas | Sí, varias al mismo tiempo en el mismo número | Sí, cuando Meta habilita las llamadas para el número |
| Cobro de las conversaciones | Ninguno además del plan | Meta cobra en tu cuenta de Meta |
| Riesgo | WhatsApp puede bloquear números que envían spam | Sigue las políticas y la calidad de Meta |
Inicio rápido
- Crea una cuenta y, en Panel › Claves de API, una clave.
- Crea un número:
curl https://www.vozaapi.com/v1/numbers \ -H "Authorization: Bearer vz_SUA_CHAVE" \ -H "Content-Type: application/json" \ -d '{"name": "Tienda Centro", "webhook_url": "https://tu-sistema.com/webhooks/whatsapp"}'
- Abre el
qr_urlde la respuesta y escanea el código QR con WhatsApp en el celular (Dispositivos vinculados › Vincular un dispositivo). O conecta un número oficial (consulta Números oficiales). - Envía el primer mensaje:
curl https://www.vozaapi.com/v1/numbers/NUMBER_ID/messages \ -H "Authorization: Bearer vz_SUA_CHAVE" \ -H "Content-Type: application/json" \ -d '{"to": "5511987654321", "type": "text", "text": {"body": "¡Hola! 👋"}}'
- Las respuestas del contacto llegan a tu webhook, en el formato de Meta. Listo: el resto de esta página son detalles.
Autenticación
Crea una clave en Panel › Claves de API y envíala en todas las solicitudes, en el encabezado Authorization:
Authorization: Bearer vz_SUA_CHAVE
- Usa la clave solo en tu servidor. Nunca la pongas en el navegador ni en la app de tu cliente.
- La clave aparece una sola vez, cuando se crea. Si la pierdes, revócala y crea otra.
- Una clave revocada deja de funcionar en el momento.
- El teléfono web usa otro token, de corta duración y de un solo número (
wp_…): consulta Teléfono web.
Errores
Todos los errores tienen el mismo cuerpo. El código es estable y sirve para que tu sistema decida qué hacer; el mensaje es para personas y puede cambiar.
El mensaje llega en el idioma pedido en el encabezado Accept-Language o en el parámetro ?lang=: pt-BR, en o es (otro idioma recibe inglés). Sin pedido, llega en el idioma de quien creó la cuenta.
{
"error": {
"code": "invalid_request",
"message": "El campo to es obligatorio.",
"details": { "to": ["El campo to es obligatorio."] }
}
}
Cuando la falla viene de Meta, en un número oficial, el error trae también meta: el error de Meta tal como lo dio, con code (131047 fuera de la ventana de 24 horas, 190 token inválido, 1380xx en las llamadas…), subcode, type, details y fbtrace_id. Cuando viene del motor de los números por código QR, meta.reason dice por qué: not_on_whatsapp, lines_busy, lines_starting, restarting, not_linked, not_connected, timeout…
{
"error": {
"code": "whatsapp_refused",
"message": "Meta no aceptó la solicitud: Message failed to send because more than 24 hours have passed.",
"meta": {
"code": 131047,
"subcode": null,
"type": "OAuthException",
"details": "Message failed to send because more than 24 hours have passed since the customer last replied to this number.",
"fbtrace_id": "AbCdEf123"
}
}
}
| Estado | Código | Cuándo ocurre |
|---|---|---|
| 401 | invalid_api_key | La clave falta, es inválida o fue revocada. |
| 401 | invalid_webphone_token | El token del teléfono web falta, es inválido o venció. |
| 403 | trial_expired | La prueba gratis terminó: suscríbete a un plan en el panel. |
| 403 | subscription_inactive | La suscripción terminó (cancelada o sin pago): suscríbete de nuevo en el panel. |
| 403 | account_suspended | La cuenta está suspendida. |
| 403 | number_limit_reached | La cuenta ya tiene todos los números del plan. |
| 404 | not_found | La dirección o el recurso no existe (o es de otra cuenta). |
| 404 | call_not_found | La llamada no existe o ya terminó. |
| 405 | method_not_allowed | La dirección existe, pero no con este método HTTP. El encabezado Allow indica cuáles valen. |
| 409 | number_not_connected | El número por código QR no está conectado: escanea el código QR antes de enviar o llamar. |
| 409 | meta_token_invalid | Meta rechazó el token del número oficial (venció, fue revocado o no tiene los permisos). Envía uno nuevo a PUT /v1/numbers/{id}/meta. |
| 409 | not_a_qr_number | La solicitud solo es válida para números por código QR. |
| 409 | not_an_official_number | La solicitud solo es válida para números oficiales. |
| 409 | send_in_progress | Otro envío con la misma Idempotency-Key todavía está en curso. |
| 409 | action_in_progress | Otra acción de llamada con la misma Idempotency-Key todavía está en curso. |
| 409 | call_answered_elsewhere | Otro teléfono web, el agente de voz o su sistema ya atendió esta llamada. |
| 409 | call_not_ready | La llamada todavía se está preparando; inténtalo de nuevo en unos instantes. |
| 413 | media_too_large | El archivo multimedia recibido por un número por código QR superaba los 64 MB y no se guardó. |
| 422 | invalid_request | Los datos enviados son inválidos. El campo details lista los errores de cada campo. |
| 422 | unsupported_type | El tipo de mensaje solo existe en los números oficiales (plantilla, interactivo…). |
| 422 | meta_refused | Meta rechazó las credenciales al conectar un número oficial; el mensaje indica el motivo. |
| 422 | whatsapp_refused | Meta rechazó el mensaje de un número oficial (plantilla inexistente, parámetro inválido…). |
| 422 | not_on_whatsapp | El número llamado no tiene WhatsApp. |
| 422 | call_failed | Meta no aceptó la acción de llamada de un número oficial (sin permiso del contacto, por ejemplo). |
| 429 | rate_limited | El límite de solicitudes de la cuenta (espera los segundos de Retry-After) o el límite de envíos de Meta en un número oficial (envía más despacio). |
| 429 | channel_limit_reached | La cuenta ya está en el límite de llamadas simultáneas del plan. |
| 429 | lines_busy | Todas las líneas del número están en una llamada. |
| 4xx | http_error | Otro error de HTTP, como un cuerpo demasiado grande (413). El estado indica cuál. |
| 500 | server_error | Error interno. Inténtalo de nuevo; si el error continúa, contáctanos. |
| 502 | whatsapp_refused | WhatsApp no aceptó el mensaje de un número por código QR (el destino no tiene WhatsApp, por ejemplo). |
| 502 | call_failed | WhatsApp no aceptó la acción de llamada de un número por código QR. |
| 502 | media_unavailable | WhatsApp no entregó un archivo multimedia recibido por un número por código QR. |
| 503 | number_reconnecting | El número por código QR se está reconectando a WhatsApp. Inténtalo de nuevo en unos instantes. |
| 503 | engine_unavailable | El servicio que mantiene los números por código QR no está disponible. Inténtalo de nuevo en unos instantes. |
| 503 | meta_unavailable | La solicitud no llegó a Meta (sin conexión) o Meta respondió que no está disponible: no se hizo nada. Inténtalo de nuevo en unos instantes. |
| 503 | lines_starting | Las líneas de llamada del número por código QR se están iniciando. Inténtalo de nuevo en unos segundos. |
| 504 | send_timeout | El envío tardó demasiado. Repítelo con la misma Idempotency-Key. |
| 504 | meta_timeout | Meta no respondió a tiempo después de recibir la solicitud (un envío o una acción de llamada): puede haberse hecho o no. Repítela con la misma Idempotency-Key. |
| 504 | call_timeout | El servicio que mantiene los números por código QR tardó demasiado en responder a una acción de llamada. |
Un 503 suele pasar en instantes: inténtalo de nuevo, esperando un poco más cada vez. Para saber si Voza está caída, consulta Estado de Voza.
Límites
- Cada cuenta puede hacer hasta 600 solicitudes por minuto, sumando todas sus claves, o el límite acordado con Voza para la cuenta:
GET /v1/accountmuestra el de la tuya enlimits.requests_per_minute. - Las respuestas incluyen
X-RateLimit-LimityX-RateLimit-Remaining. Por encima del límite, la API responde429conRetry-After. - Cada token del teléfono web puede hacer hasta 240 solicitudes por minuto.
- Cada cuenta tiene hasta 20 claves de API.
- El plan define cuántos números conecta la cuenta y cuántas llamadas ocurren al mismo tiempo. Los mensajes y los minutos no tienen límite en Voza.
- En los números oficiales también se aplican los límites de envío y la calidad que Meta asigna a cada número.
Idempotencia
Al enviar un mensaje, manda el encabezado Idempotency-Key (hasta 128 letras, números y _ . : -). Repetir el envío con la misma clave devuelve la respuesta del primero en lugar de enviar de nuevo. Úsalo siempre que tu sistema vaya a repetir un envío que falló o se quedó sin respuesta.
Idempotency-Key: pedido-4521-aviso-entrega
Voza guarda la primera respuesta durante 24 horas, en los dos tipos de número. Un envío repetido mientras el primero sigue en curso lo espera hasta 30 segundos; después, la respuesta es 409 send_in_progress, y basta con intentarlo de nuevo con la misma clave.
Las acciones de llamada (POST /v1/numbers/{id}/calls) aceptan el mismo encabezado: repetir con la misma clave devuelve la primera respuesta en lugar de repetir la acción (un connect no llama dos veces). Una repetida mientras la primera está en curso recibe 409 action_in_progress.
En un número oficial, 503 meta_unavailable significa que la solicitud no llegó a Meta, y 504 meta_timeout, que llegó, pero Meta no respondió a tiempo: el mensaje puede haberse enviado (o la acción hecho). En los dos casos, repítela con la misma Idempotency-Key: un error no se guarda, así que la repetición va a Meta; Meta no tiene idempotencia propia, y después de un meta_timeout el mensaje puede llegar dos veces al contacto.
Números por código QR
Un número por código QR es un WhatsApp o WhatsApp Business común conectado como dispositivo, como en WhatsApp Web. El celular sigue funcionando normalmente.
El objeto número
{
"id": "01jbk3m8x2c9q4r7t6v5w0y1z3",
"name": "Tienda Centro",
"external_ref": "canal-981",
"type": "qr",
"status": "connected",
"status_reason": null,
"phone": "5511987654321",
"qr_url": "https://www.vozaapi.com/qr/…",
"webhook_url": "https://tu-sistema.com/webhooks/whatsapp",
"agent_mode": "off",
"connected_at": "2026-10-22T15:04:05Z",
"created_at": "2026-10-22T15:01:10Z"
}
| Campo | Descripción |
|---|---|
external_ref | Tu referencia para el número (hasta 100 caracteres), como el id del canal en tu sistema. Vuelve en los webhooks, en metadata.external_ref, para que encuentres el canal sin guardar el id de Voza. |
type | qr (código QR) o meta (oficial). |
status_reason | Por qué el número está en su status actual (ver Status); vacío cuando no hay nada que explicar. |
phone | Los dígitos del número, con el código de país, una vez conectado. |
qr_url | Una página pública con el código QR, para enviarla a quien tiene el celular. Sigue la conexión sola. |
agent_mode | Cuándo atiende el agente de voz del número: off, always o fallback (vea Agentes de voz). |
connected_at | Cuándo se conectó el número, en UTC (ISO 8601). |
Estado
| Estado | Significado |
|---|---|
starting | La conexión está empezando; el código QR aparece a continuación. |
qr | Esperando que se escanee el código QR. |
connected | Conectado: el número envía y recibe mensajes y llamadas. |
failed | Rechazado: por WhatsApp (un número bloqueado, por ejemplo; usa relink) o, en un número oficial, por Meta (consulta meta.error). |
Si alguien desconecta Voza en el celular (Dispositivos vinculados), el número vuelve a starting y luego a qr, con un nuevo código QR. Cada cambio llega al webhook como number.status.
status_reason dice por qué el número está en su status actual, y un cambio solo del motivo también llega al webhook:
status_reason | Significado |
|---|---|
logged_out | El celular desconectó el dispositivo (o el número se volvió a vincular): hay que leer el código QR de nuevo. |
restarting | El motor está reconectando el número, que sigue vinculado al celular: espera, sin hacer nada. |
failed | WhatsApp rechazó el dispositivo. |
meta_refused | Meta rechazó el token o el número oficial (ver meta.error). |
En los demás casos (qr, connected, un número nuevo) el motivo viene vacío.
Crear un número
{
"name": "Tienda Centro",
"external_ref": "canal-981",
"webhook": { "url": "https://tu-sistema.com/webhooks/whatsapp" }
}
Responde 201 con el número en starting. external_ref y webhook son opcionales (también se acepta webhook_url, en lugar de webhook.url). Con un webhook en la solicitud, la respuesta trae también el secreto que firma los eventos, para verificar ya el primero:
{
"id": "01jbk3m8x2c9q4r7t6v5w0y1z3",
"status": "starting",
…
"webhook": { "url": "https://tu-sistema.com/webhooks/whatsapp", "secret": "whsec_…" }
}
La dirección del webhook puede nombrar el número: {id} se convierte en el id del número en Voza y {external_ref}, en tu referencia (codificada para URL; la solicitud debe traer external_ref). El webhook queda definido antes de que el número se conecte, así que no se pierde ningún evento, sin un PUT después.
{ "name": "Tienda Centro", "external_ref": "canal-981", "webhook": { "url": "https://tu-sistema.com/webhooks/voza/{external_ref}?numero={id}" } }
Por encima del límite del plan, responde 403 con number_limit_reached.
Mostrar el código QR
{
"status": "qr",
"qr": "data:image/png;base64,iVBORw0KGgo…",
"qr_url": "https://www.vozaapi.com/qr/…"
}
qr es una imagen PNG lista para un <img src>. El código QR cambia cada 20 segundos: consulta de nuevo cada 3 a 5 segundos hasta que el estado sea connected (entonces qr viene vacío). O envía el qr_url a quien tiene el celular.
Listar, consultar y eliminar
Todos los números de la cuenta, en {"data": [...]}. Con ?external_ref=canal-981, solo los que tienen esa referencia.
El número, con el estado leído en el momento.
Cambia el name o la external_ref del número ("external_ref": null la elimina) y responde con el número.
Desconecta el celular y elimina el número. Responde 204.
Conectar otro celular
Desconecta el celular actual y empieza de nuevo, con un nuevo código QR. Sirve para cambiar de celular o para recuperar un número rechazado.
Números oficiales
Un número oficial es un número de la WhatsApp Cloud API, en tu cuenta de Meta. Voza usa las credenciales de tu app: los mensajes y las llamadas pasan por la API de Meta, y llegan a tu webhook en el mismo formato que los números por código QR.
Antes de conectar, en Meta
- Ten una app de Meta con el producto WhatsApp y un número de teléfono en la cuenta de WhatsApp Business (WABA).
- En WhatsApp › Configuración, suscríbete a los campos
messagesycallsdel webhook de la app (con cualquier dirección de webhook: Voza apunta hacia sí misma solo este número, con una sustitución por número, y los demás eventos de tu app siguen donde están). - Crea un token permanente de un usuario del sistema, con los permisos
whatsapp_business_messagingywhatsapp_business_management. - Anota el ID del número de teléfono y el ID de la cuenta de WhatsApp Business (WhatsApp › Configuración de la API), y la clave secreta de la app (Configuración de la app › Básica).
Conectar
{
"name": "Atención oficial",
"type": "meta",
"webhook_url": "https://tu-sistema.com/webhooks/whatsapp",
"meta": {
"phone_number_id": "109876543210987",
"waba_id": "102030405060708",
"access_token": "EAAG…",
"app_secret": "0123456789abcdef0123456789abcdef"
},
"calling": { "call_icon_visibility": "DISABLE_ALL" }
}
Voza verifica el número y el token en Meta, suscribe tu app a la cuenta, apunta los webhooks messages y calls del número hacia Voza y activa las llamadas. Si todo sale bien, responde 201 con el número ya connected:
{
"id": "01jbk4…",
"type": "meta",
"status": "connected",
"phone": "5511912345678",
"qr_url": null,
"meta": {
"phone_number_id": "109876543210987",
"waba_id": "102030405060708",
"display_phone_number": "+55 11 91234-5678",
"verified_name": "Tu Empresa",
"quality_rating": "GREEN",
"calling": "enabled",
"error": null
}
}
- Si Meta rechaza las credenciales, la respuesta es
422 meta_refusedcon el motivo, y no se guarda nada. calling(opcional) es la configuración de llamadas con la que empieza el número (ver Configuración de llamadas); lo que falte toma el valor por defecto de Voza: llamadas activadas, botón de llamada visible y pedido de devolución de llamada activado."calling": "keep"registra el número sin tocar su configuración de llamadas en Meta (para un número que ya atiende llamadas como lo configuraste). Voza solo la lee, para mostrarmeta.calling.- En la respuesta,
meta.callingesenabled,disabled(desactivadas en la configuración de llamadas) ounavailable(Meta no habilita llamadas para el número; los mensajes funcionan igual). - El token y la clave secreta se guardan cifrados y nunca aparecen en las respuestas.
- Voza verifica el número en Meta como máximo una vez por minuto al consultarlo, y verifica todos los números oficiales cada hora. Un token rechazado (el código 190 de Meta) o un número que Meta ya no tiene, en esa verificación, en un envío de mensaje o en una acción de llamada, lleva el número a
failedconstatus_reason: meta_refused(el motivo enmeta.error), y el webhook recibenumber.status. Cuando Meta vuelve a aceptar el número, vuelve aconnected.
Cambiar el token
{ "access_token": "EAAG…nuevo", "app_secret": "(solo si cambió)" }
Guarda las nuevas credenciales y conecta el número de nuevo. POST /v1/numbers/{id}/relink solo verifica de nuevo, con las credenciales actuales. DELETE devuelve los webhooks del número a tu app y lo saca de Voza (el número sigue en tu cuenta de Meta).
Ni el cambio de token ni el relink tocan la configuración de llamadas: Voza solo la define cuando se crea el número.
Configuración de llamadas
El objeto calling de la configuración del número en Meta, leído y cambiado como en Meta. En el PUT, solo cambian los campos enviados, y la respuesta es {"success": true}.
{
"calling": {
"status": "ENABLED",
"call_icon_visibility": "DEFAULT",
"callback_permission_status": "ENABLED",
"call_hours": {
"status": "ENABLED",
"timezone_id": "America/Sao_Paulo",
"weekly_operating_hours": [{ "day_of_week": "MONDAY", "open_time": "0900", "close_time": "1800" }]
}
}
}
| Campo | Descripción |
|---|---|
status | ENABLED o DISABLED: las llamadas del número. |
call_icon_visibility | DEFAULT o DISABLE_ALL: el botón de llamada en la conversación. |
callback_permission_status | ENABLED o DISABLED: pedir al contacto que llamó permiso para devolverle la llamada. |
call_hours, call_icons | El horario de atención y los países que ven el botón, como en Meta, que los verifica. |
sip queda fuera: las llamadas pasan por Voza. En un número por código QR, la respuesta es 409 not_an_official_number.
Enviar mensajes
El cuerpo es el mismo de la API oficial de WhatsApp. El número debe estar conectado.
curl https://www.vozaapi.com/v1/numbers/NUMBER_ID/messages \ -H "Authorization: Bearer vz_SUA_CHAVE" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: pedido-4521" \ -d '{ "messaging_product": "whatsapp", "to": "5511987654321", "type": "text", "text": { "body": "¡Tu pedido salió para entrega!" } }'
{
"messaging_product": "whatsapp",
"contacts": [{ "input": "5511987654321", "wa_id": "5511987654321" }],
"messages": [{ "id": "3EB0C431D5A1B2C3" }]
}
Tipos de mensaje
| Tipo | Cuerpo | Números |
|---|---|---|
text | {"text": {"body": "…"}} (hasta 4096 caracteres) | todos |
image, video | {"image": {"link": "https://…", "caption": "…"}} | todos |
audio | {"audio": {"link": "https://…"}}; con "voice": true, nota de voz (usa OGG/Opus) | todos |
document | {"document": {"link": "https://…", "filename": "factura.pdf"}} | todos |
template | Una plantilla aprobada: {"template": {"name": "…", "language": {"code": "pt_BR"}, "components": […]}} | oficiales |
interactive | Botones, listas, enlaces y la solicitud de permiso para llamar, como en Meta | oficiales |
sticker, location, contacts, reaction | Como en la API de Meta | oficiales |
- Los enlaces deben ser direcciones
httpspúblicas: el archivo se descarga en el momento del envío. texttambién aceptapreview_url, como en Meta, y los archivos multimedia,mime_type, el tipo del archivo.- Para responder a un mensaje, en un número oficial, envía
"context": {"message_id": "wamid.…"}. - Para enviar a un grupo, use
"recipient_type": "group"y el id del grupo ento. En un número por código QR, el número debe estar en el grupo y el id es el de WhatsApp (120363…, con o sin@g.us); la respuesta viene sincontacts. En un número oficial, vale la API de grupos de Meta, con su id de grupo. - El
idde la respuesta vuelve en los estados de entrega del webhook (sent,delivered,readofailed). - En los números oficiales, Meta verifica el cuerpo de los tipos exclusivos de números oficiales e indica lo que esté mal (whatsapp_refused).
La ventana de 24 horas (números oficiales)
En un número oficial, Meta solo entrega mensajes libres a quienes escribieron al número en las últimas 24 horas. Fuera de esa ventana, inicia la conversación con una plantilla aprobada en WhatsApp Manager:
{
"to": "5511987654321",
"type": "template",
"template": {
"name": "pedido_enviado",
"language": { "code": "pt_BR" },
"components": [{ "type": "body", "parameters": [{ "type": "text", "text": "4521" }] }]
}
}
La API acepta un mensaje libre fuera de la ventana, pero llega al webhook con el estado failed, con el error de Meta.
Plantillas de mensaje
Las plantillas de la cuenta de WhatsApp Business de un número oficial, leídas en Meta, una página a la vez, en la respuesta de Meta. Cada plantilla trae id, name, language, category, status y components, o los campos pedidos en fields.
{
"data": [{
"id": "1234567890",
"name": "pedido_enviado",
"language": "pt_BR",
"category": "UTILITY",
"status": "APPROVED",
"components": [{ "type": "BODY", "text": "Seu pedido {{1}} saiu para entrega." }]
}],
"paging": {
"cursors": { "before": "MAZDZD", "after": "MjQZD" },
"next": "https://www.vozaapi.com/v1/numbers/NUMBER_ID/templates?after=MjQZD"
}
}
limit(1 a 100, 25 por defecto),afterobefore(los cursores depaging.cursors) y los filtros de Meta:name,status(APPROVED…),categoryylanguage.paging.nextypaging.previousapuntan a Voza y solo aparecen cuando hay otra página.- En un número por código QR, la respuesta es
409 not_an_official_number: solo los números oficiales tienen plantillas.
Botones
{
"to": "5511987654321",
"type": "interactive",
"interactive": {
"type": "button",
"body": { "text": "¿Confirmas la entrega mañana a las 10?" },
"action": { "buttons": [
{ "type": "reply", "reply": { "id": "confirm", "title": "Sí" } },
{ "type": "reply", "reply": { "id": "reschedule", "title": "Reprogramar" } }
] }
}
}
La elección del contacto llega al webhook como un mensaje del tipo interactive, con el id del botón.
Archivos multimedia
Los mensajes recibidos con imagen, video, audio, documento o sticker traen el id del archivo (en image.id, audio.id…). Descárgalo con:
- La respuesta son los bytes del archivo, con su
Content-Type. - Números por código QR: los archivos multimedia están disponibles durante 24 horas; los archivos de más de 64 MB no se guardan (
media_too_large), y un archivo que WhatsApp no entregó respondemedia_unavailable. - Números oficiales: Voza busca el archivo en Meta, que lo guarda durante 30 días.
Webhooks
Cada número tiene su propio webhook. Voza le envía los mensajes recibidos, los estados de entrega de los mensajes enviados, los eventos de llamada y los cambios de estado del número.
curl -X PUT https://www.vozaapi.com/v1/numbers/NUMBER_ID/webhook \ -H "Authorization: Bearer vz_SUA_CHAVE" \ -H "Content-Type: application/json" \ -d '{"url": "https://tu-sistema.com/webhooks/whatsapp"}'
{ "url": "https://tu-sistema.com/webhooks/whatsapp", "secret": "whsec_…" }
La dirección debe ser https pública. Envía "url": null para eliminar el webhook y, junto con la url, "rotate_secret": true para cambiar el secreto. GET en la misma dirección muestra la configuración actual.
Mensajes y estados de entrega
Llegan en el mismo sobre de la API oficial, así que el código que lee los webhooks de Meta lee los de Voza:
{
"object": "whatsapp_business_account",
"entry": [{
"id": "42",
"changes": [{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": { "display_phone_number": "5511987654321", "phone_number_id": "NUMBER_ID", "external_ref": "canal-981" },
"contacts": [{ "wa_id": "5521999990000", "profile": { "name": "Bruno" } }],
"messages": [{
"from": "5521999990000",
"id": "ABCD1234",
"timestamp": "1761150000",
"type": "text",
"text": { "body": "Hola, ¿cómo estás?" }
}]
}
}]
}]
}
Un estado de entrega llega en el mismo sobre, en statuses:
"statuses": [{ "id": "3EB0C431D5A1B2C3", "status": "delivered", "timestamp": "1761150030", "recipient_id": "5521999990000" }]
phone_number_ides eliddel número en Voza, en los dos tipos de número, yexternal_ref, tu referencia para él, cuando el número tiene una.- Los estados de entrega llegan en
statuses(sent,delivered,read,failed), con eliddel mensaje enviado. En los números oficiales traen también elpricingy laconversationde Meta, y loserrorsde una falla. - Los
errorsque Meta envía en un cambio (un problema de pago de la cuenta, por ejemplo) llegan envalue.errors, como en Meta, incluso cuando el cambio solo tiene errores. Vale paramessagesycalls. - Tipos de mensaje recibido:
text,image,video,audio(convoice: trueen una nota de voz),document,sticker,location,contactsyunsupported; en los números oficiales, tambiéninteractive,buttonyreaction.
Estado del número
{
"object": "voza",
"event": "number.status",
"number": { "id": "NUMBER_ID", "name": "Tienda Centro", "external_ref": "canal-981", "status": "starting", "status_reason": "logged_out", "phone": null },
"timestamp": 1761150000
}
El evento test, enviado desde el panel, tiene la misma forma. status_reason dice por qué cambió el número (ver Status).
Encabezados y firma
| Encabezado | Contenido |
|---|---|
X-Voza-Signature | sha256= y el HMAC-SHA256 del cuerpo, con el secreto del webhook. El mismo valor va en X-Hub-Signature-256, como en Meta. |
X-Voza-Delivery | El id de la entrega, igual en todos los intentos: úsalo para ignorar repeticiones. |
X-Voza-Event | messages, calls, number.status o test |
User-Agent | Voza-Webhooks/1.0 |
Verifica la firma con el cuerpo sin procesar, antes de leer el JSON:
// PHP $body = file_get_contents('php://input'); $expected = 'sha256=' . hash_hmac('sha256', $body, getenv('VOZA_WEBHOOK_SECRET')); if (! hash_equals($expected, $_SERVER['HTTP_X_VOZA_SIGNATURE'] ?? '')) { http_response_code(401); exit; } // Node.js (body es el Buffer sin procesar de la solicitud) const expected = 'sha256=' + crypto.createHmac('sha256', process.env.VOZA_WEBHOOK_SECRET).update(body).digest('hex'); const given = req.headers['x-voza-signature'] || ''; const valid = given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected)); # Python (body son los bytes sin procesar de la solicitud) expected = 'sha256=' + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() valid = hmac.compare_digest(expected, request.headers.get('X-Voza-Signature', ''))
Respuestas y reintentos
- Responde con cualquier estado
2xxen hasta 10 segundos. Procesa el evento después, en una cola, si tarda. - Sin un 2xx, Voza lo intenta de nuevo después de 1, 5, 15 y 30 minutos, 1, 2 y 4 horas, y cada 8 horas hasta completar 24 horas.
- Una cuenta puede tener un plazo mayor, acordado con Voza, de hasta 7 días:
GET /v1/accountmuestra el de la tuya enwebhook_retry_hours. - Los eventos de llamada tienen una cola propia, delante de los mensajes. Sin un 2xx, Voza reintenta después de 5, 15 y 30 segundos. Los de una llamada que está sonando (el connect y el estado RINGING) desisten a los 2 minutos, porque una llamada no suena más que eso; el fin de una llamada (terminate y los demás estados) se sigue reintentando después, como un evento de mensaje.
- Voza no sigue redirecciones: un
3xxcuenta como fallo. Configura la dirección final. - Un número sin webhook no guarda los eventos: como en Meta, se pierden. Configura el webhook antes de conectar el número.
- El orden de llegada no está garantizado: usa el timestamp de cada mensaje.
- El contenido del evento solo queda en Voza, cifrado, hasta que termina la entrega. El historial del panel guarda 7 días de entregas, sin el contenido.
Teléfono web
La forma más simple de contestar y hacer llamadas: un SDK en JavaScript, sin dependencias, que corre en el navegador de tus agentes. La clave de API queda en tu servidor; el navegador recibe solo un token de corta duración, de un número. Funciona con los dos tipos de número.
1. En tu servidor, pide un token para el agente:
curl https://www.vozaapi.com/v1/numbers/NUMBER_ID/webphone-tokens \ -H "Authorization: Bearer vz_SUA_CHAVE" \ -H "Content-Type: application/json" \ -d '{"label": "Ana", "minutes": 480}'
{ "token": "wp_…", "expires_at": "2026-10-23T01:00:00Z", "sdk_url": "https://www.vozaapi.com/sdk/webphone.js" }
minutes va de 5 a 720 (8 horas por defecto). Usa un token por pestaña del navegador.
2. En la página del agente, el teléfono listo:
<script src="https://www.vozaapi.com/sdk/webphone.js"></script> <voza-phone token="wp_…" lang="es"></voza-phone>
- El elemento habla portugués, inglés y español: el idioma del atributo
lango, sin él, el de la página o el del navegador. Los demás idiomas reciben inglés. - El elemento sigue el tema claro u oscuro de la página; las variables CSS
--voza-phone-background,--voza-phone-colory--voza-phone-radiuscambian los colores. - El SDK habla con Voza en la dirección desde la que se cargó. Si sirves
webphone.jsdesde otro lugar (tu dominio o un bundle), indica dónde está Voza:base-url="https://www.vozaapi.com"en el elemento obaseUrlennew VozaWebphone().
O tu propia interfaz, con la API del SDK:
const phone = new VozaWebphone({ token: 'wp_…' });
phone.on('incoming', (call) => {
// call.contact, call.contactName; call.accept() o call.reject()
});
phone.on('call', (call) => console.log(call.direction, call.state));
phone.on('error', (error) => console.warn(error.code, error.message));
phone.on('disconnected', () => { /* el token venció: pide otro */ });
await phone.connect();
const call = await phone.call('5511987654321');
call.mute(true);
call.hangup();
phone.disconnect(); // deja de seguir las llamadas y cuelga las que están en curso
| Evento | Cuándo ocurre |
|---|---|
ready | El teléfono está listo. Recibe la sesión, con el número (consulta API del teléfono web). |
state | El teléfono cambió de estado: connecting, ready o closed. |
incoming | Una llamada recibida empezó a sonar. |
call | Cualquier cambio en una llamada: un nuevo estado o el silencio. |
ended | Una llamada terminó; call.reason indica por qué. |
monitor | Una supervisión abierta con phone.monitor() cambió de estado (consulta Supervisar una llamada de la IA). |
error | Un error, con error.code: microphone_denied, microphone_unavailable, insecure_context, unsupported_browser, connection_failed, network_error o un código de la API, como not_on_whatsapp. |
disconnected | El token venció o fue rechazado: pide otro y crea un nuevo teléfono. |
- Una llamada tiene
id,direction(incominguoutgoing),contact,contactName,state,reason,answeredBy(quién contestó una llamada entrante:webphone,agentoapi),mutedyduration(segundos desde que se contestó). - Estados de una llamada entrante:
ringing,connecting,active,ended; si la contestó el agente de voz,ringing,agent(con la IA: el elemento muestra “Contestada por la IA”) yended. De una llamada saliente:connecting,dialing,ringing,active,ended.call.reasondice por qué terminó:completed,rejected,missed,no_answer,hangup,answered_elsewhere,answered_by_agent(terminó la llamada con la IA),failedo el código de un error. - La página necesita https (o localhost) y el permiso del micrófono.
- Cuando varios agentes reciben la misma llamada, el primero que contesta se queda con ella; para los demás desaparece.
- Una llamada que contestó la IA sigue en pantalla hasta que termina, pero este teléfono no la contesta ni la cuelga:
call.hangup()no hace nada, y terminarla es la acciónterminatede tu servidor.
Supervisar una llamada de la IA
Un supervisor escucha, en el navegador, una llamada que contestó el agente de voz, y puede tomarla y devolverla a la IA. Entrar solo pide un token corto; tomar, devolver y susurrar necesitan la clave de API, así que el SDK llama a funciones de tu página, y estas le piden a tu servidor.
1. En tu servidor, pide un token de supervisión con POST /v1/numbers/{id}/calls/{call_id}/monitor y pasa {token, ice_servers} al navegador. Cada token entra una vez, en hasta 5 minutos.
2. En el navegador, con la API del SDK:
// tu función que llama a tu servidor const post = (path, body) => fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const monitor = await phone.monitor(callId, { // o VozaWebphone.monitor(), sin teléfono web token: 'mon_…', iceServers, // los ice_servers del token takeover: ({ callId, monitorId }) => post('/supervise/takeover', { callId, monitorId }), release: ({ callId, monitorId }) => post('/supervise/release', { callId, monitorId }), whisper: ({ callId, text }) => post('/supervise/whisper', { callId, text }), }); monitor.on('state', (m) => console.log(m.state)); // listening, takeover, ended await monitor.takeover(); // pide el micrófono y habla con quien llamó await monitor.whisper('Ofrezca el viernes a las 9:30.'); await monitor.release(); // la IA vuelve a hablar monitor.leave();
- En tu servidor, cada función se convierte en una llamada a Voza con la clave de API:
takeoveraPOST …/calls/{call_id}/takeover{"monitor": monitorId},releaseaPOST …/calls/{call_id}/releaseywhisperaPOST …/calls/{call_id}/whisper{"text"}(consulta Supervisión). - Las funciones reciben
{callId, monitorId, monitor}(ytext, al susurrar) y pueden devolver laResponsedelfetch: una respuesta de error rechaza la promesa con elerror.codeque tu servidor reenvíe (por ejemplonot_in_control). Sin la función, el método se rechaza contakeover_unavailable,release_unavailableowhisper_unavailable. - Escuchar no pide el micrófono: solo se pide al tomar la llamada (o al entrar con
mode: "takeover").monitor.mute()silencia al supervisor mientras tiene la llamada. - Estados:
connecting,listening,takeover,ended.monitor.reasondice por qué terminó:left(leave(), que antes devuelve la llamada a la IA si el supervisor la tiene),completed(terminó la llamada) o el código de un error, comoinvalid_monitor_token,token_usedocall_not_with_agent. Eventos:state,erroryended. monitor.leave()avisa a Voza al instante (DELETE /v1/monitor/sessioncon el mismo token, que sirve para salir incluso después de vencer), y el SDK hace lo mismo cuando la página se cierra; así la IA recupera la llamada sin esperar a que se caiga la conexión.- Un token entra una vez: para volver a escuchar después de salir, pide otro. Una llamada en el estado
agenttambién tienecall.monitor({token, …}).
3. O en el teléfono listo: con las funciones en supervisor, una llamada que contestó la IA muestra “Contestada por la IA” y el botón Escuchar; la supervisión tiene Tomar la llamada, Devolver a la IA, Silenciar, Salir y un campo de instrucción para la IA.
const element = document.querySelector('voza-phone'); element.supervisor = { token: (call) => post('/supervise/token', { callId: call.id }), // tu servidor devuelve {token, ice_servers} takeover: ({ callId, monitorId }) => post('/supervise/takeover', { callId, monitorId }), release: ({ callId, monitorId }) => post('/supervise/release', { callId, monitorId }), whisper: ({ callId, text }) => post('/supervise/whisper', { callId, text }), }; // o, para una llamada que elegiste: element.monitor(callId, { token: 'mon_…', iceServers });
- Sin la función
whisper, el campo de instrucción no aparece; sintakeoverorelease, tampoco sus botones. - Sin el atributo
token, el elemento solo supervisa: muestra la supervisión abierta conmonitor().
Llamadas por la API
Para quien prefiere su propio cliente WebRTC: las mismas acciones y eventos de la API de llamadas de Meta. Voza no graba las llamadas.
| Acción | Cuerpo |
|---|---|
connect | Llama a to con la oferta SDP del navegador en session (sdp_type: offer). Responde {"calls": [{"id": "…"}]}; la respuesta SDP llega en el evento connect. |
pre_accept | Para una llamada recibida: la respuesta SDP del navegador (sdp_type: answer), para que el audio se conecte de inmediato. |
accept | Contesta la llamada en WhatsApp. En los números oficiales, envía de nuevo la misma respuesta SDP en session. |
reject, terminate | Rechaza o finaliza la llamada call_id. |
{
"action": "pre_accept",
"call_id": "wacid.ABGG…",
"session": { "sdp_type": "answer", "sdp": "v=0\r\no=- …" }
}
- El SDP no tiene trickle ICE: envía el SDP completo, después de reunir los candidatos.
- Números por código QR: el audio va a los servidores de Voza, que están detrás de NAT y solo se alcanzan por TURN. Usa los servidores de
ice_serversenGET /v1/webphone/session(consulta API del teléfono web) y pídelos de nuevo antes de cada llamada: las credenciales vencen. - Números oficiales: para llamar a un contacto, Meta exige su permiso. Pídelo con un mensaje
interactivedel tipocall_permission_request; la respuesta llega al webhook. - Cada plan tiene una cantidad de llamadas simultáneas. Una llamada recibida por encima del límite se rechaza en el momento: el webhook recibe su fin (
terminate, constatus: REJECTEDyvoza_reason: channel_limit), sin la oferta SDP, y queda en el registro conend_reason: channel_limit. Los teléfonos web no la ven sonar. - Una llamada que el agente de voz rechazó (vea Quién atiende la llamada) termina con
voza_reason: agent_unavailabley el motivo envoza_detail. - Como en Meta, las acciones aceptan
biz_opaque_callback_data(hasta 512 caracteres).
Permiso para llamar
Si el número puede llamar al contacto ahora. En un número oficial, es la respuesta de la consulta call_permissions de Meta; en un número por código QR, siempre {"permission": {"status": "granted"}}, porque no hay permiso que pedir.
{
"messaging_product": "whatsapp",
"permission": { "status": "temporary", "expiration_time": 1761236400 },
"actions": [{ "action_name": "start_call", "can_perform_action": true, "limits": [ … ] }]
}
status es granted, temporary (vence en expiration_time) o no_permission. wa_id es el número del contacto, solo con dígitos.
Eventos
Los eventos llegan al webhook del número con field: calls, en el sobre de Meta: calls[] con event: connect (la oferta SDP de una llamada recibida, o la respuesta de una llamada realizada) y event: terminate (con duration cuando fue contestada), y statuses[] de una llamada realizada (RINGING, ACCEPTED, REJECTED). direction es USER_INITIATED en una llamada recibida y BUSINESS_INITIATED en una realizada.
"value": { "messaging_product": "whatsapp", "metadata": { "display_phone_number": "5511987654321", "phone_number_id": "NUMBER_ID" }, "contacts": [{ "wa_id": "5521999990000", "profile": { "name": "Bruno" } }], "calls": [{ "id": "wacid.ABGG…", "from": "5521999990000", "to": "5511987654321", "event": "connect", "direction": "USER_INITIATED", "timestamp": "1761150000", "session": { "sdp_type": "offer", "sdp": "v=0\r\no=- …" } }] }
Cuando la llamada termina:
"calls": [{ "id": "wacid.ABGG…", "event": "terminate", "direction": "USER_INITIATED", "status": "COMPLETED", "duration": 126, … }]
Registro de llamadas
Las llamadas del número, de las más recientes a las más antiguas: ?limit= de 1 a 200, 50 por defecto.
{
"data": [{
"id": 981,
"call_id": "wacid.ABGG…",
"direction": "incoming",
"contact": "5521999990000",
"contact_name": "Bruno",
"status": "completed",
"end_reason": null,
"end_detail": null,
"started_at": "2026-10-22T15:20:00Z",
"answered_at": "2026-10-22T15:20:04Z",
"ended_at": "2026-10-22T15:22:10Z",
"duration": 126,
"answered_by": "agent",
"agent": { "agent_ref": "agent_42", "session_id": "…", "seconds": 124 }
}]
}
| Campo | Descripción |
|---|---|
call_id | El id de la llamada en WhatsApp, el mismo de los eventos y las acciones. |
status | dialing, ringing, active, completed, missed, rejected, no_answer o failed. |
end_reason | channel_limit: rechazada por superar el límite de llamadas simultáneas. agent_unavailable: rechazada porque el agente de voz no pudo atenderla. lost: estuvo más de 6 horas sin un final y se cerró como failed. Vacío en las demás. |
end_detail | Con agent_unavailable, por qué el agente rechazó la llamada (vea Quién atiende la llamada). Vacío en las demás. |
answered_at | Cuándo fue contestada; vacío en una llamada no contestada. Los horarios están en UTC. |
duration | Los segundos de conversación; vacío en una llamada no contestada. |
answered_by | Quién atendió: webphone, agent (el agente de voz) o api (su sistema); en una llamada saliente, quién llamó. Vacío en una llamada que nadie atendió. |
agent | En una llamada del agente de voz: agent_ref, la sesión en el motor y los segundos de la IA (sin el tiempo en que un supervisor tenía la llamada). |
API del teléfono web
Lo que el SDK usa por debajo, para quien hace su propio teléfono en el navegador. Estas direcciones aceptan el token del teléfono web, no la clave de API, y responden a páginas de cualquier dominio (CORS). El token va en Authorization: Bearer wp_… o, en el flujo de eventos, en ?token=, porque EventSource no envía encabezados.
{
"session_id": "01jbk5…",
"label": "Ana",
"number": { "id": "01jbk3…", "name": "Tienda Centro", "phone": "5511987654321", "status": "connected" },
"ice_servers": [{ "urls": ["turn:turn.cloudflare.com:3478?transport=udp", …], "username": "…", "credential": "…" }],
"expires_at": "2026-10-23T01:00:00Z"
}
ice_servers va directo a new RTCPeerConnection({ iceServers }). Pide la sesión de nuevo antes de cada llamada: las credenciales del TURN vencen.
Las llamadas del número, por Server-Sent Events. El evento calls trae el mismo value del webhook de llamadas (contacts, calls y statuses, con el SDP). El evento claimed indica que alguien atendió una llamada, con call_id, session_id y by (webphone, agent o api); si el session_id no es el suyo, se atendió en otro lugar.
const events = new EventSource('https://www.vozaapi.com/v1/webphone/events?token=wp_…'); events.addEventListener('calls', (event) => { const { calls, statuses } = JSON.parse(event.data); }); events.addEventListener('claimed', (event) => { const { call_id, session_id, by } = JSON.parse(event.data); });
- Cada evento tiene un
id. Al reconectar,EventSourceenvíaLast-Event-IDy el flujo sigue desde el evento siguiente (sinEventSource, usa?after=). Una conexión nueva empieza 20 segundos antes, para mostrar una llamada que ya está sonando. - El flujo se cierra cada 300 segundos, y
EventSourcese reconecta solo; cuando el token vence, se cierra del todo. Cada 15 segundos llega un comentario (: ping), para que la conexión no se caiga.
Las acciones de llamada en el número del token, con el mismo cuerpo de POST /v1/numbers/{id}/calls. Cuando varios teléfonos reciben la misma llamada, el primer pre_accept o accept se queda con ella, y los demás reciben 409 call_answered_elsewhere. Lo mismo vale frente al agente de voz y a su sistema con la clave de API (vea Agentes de voz).
Agentes de voz
Un agente de voz atiende las llamadas de un número con IA: escucha, piensa y habla. Funciona en los números por código QR y en los oficiales, con la misma API. La lógica es de su producto: Voza no guarda prompt ni configuración, solo qué agente de su producto atiende el número y dónde preguntar. Al comienzo de cada llamada, el motor de agentes de Voza le pide la configuración a su producto, y las herramientas (agenda, base de conocimiento, mensajes) también son llamadas a su producto. Una persona siempre puede escuchar, orientar a la IA y tomar la llamada.
Voza no guarda el contenido: la transcripción y el resumen van a su producto en los eventos, y solo quedan en Voza hasta la entrega. El motor de agentes guarda solo el audio de las frases fijas del agente (el saludo, las frases de espera y los mensajes de handoff), para reproducirlas sin esperar la voz sintética; una frase que no suena en 30 días se elimina, y eliminar la cuenta elimina las suyas. Las claves de los proveedores de IA son las de su cuenta.
El agente de un número
{
"mode": "always",
"fallback_after_seconds": 15,
"agent_ref": "agent_42",
"callback_url": "https://tu-sistema.com/voza/agents",
"events_url": "https://tu-sistema.com/voza/events"
}
| Campo | Descripción |
|---|---|
mode | always: el agente atiende toda llamada entrante. fallback: los teléfonos web suenan primero y, si nadie atiende en fallback_after_seconds (de 5 a 120, 15 por defecto), atiende el agente. off: el agente solo atiende las llamadas que su producto le entregue. |
agent_ref | El agente en su producto (hasta 255 caracteres). Voza lo repite en cada solicitud y evento. |
callback_url | Donde el motor pide la configuración (agent.config) y llama a las herramientas (tool.call). https y público. |
events_url | Opcional: adónde van los eventos del agente. Sin ella, van al webhook del número. |
La primera vez, la respuesta trae agent.secret: firma las solicitudes del motor a su producto y los eventos enviados a events_url. Guárdelo: GET en la misma dirección muestra la configuración sin él. POST /v1/numbers/{id}/agent/secret crea uno nuevo (el anterior deja de valer en el acto) y DELETE apaga el agente y olvida el secreto.
{ "agent": { "mode": "always", "fallback_after_seconds": 15, "agent_ref": "agent_42", "callback_url": "https://…/voza/agents", "events_url": "https://…/voza/events", "secret": "agsec_…" } }
Quién atiende la llamada
Una llamada entrante queda con quien atienda primero: un teléfono web, el agente o su sistema con la clave de API (pre_accept o accept en POST /v1/numbers/{id}/calls). Los demás reciben 409 call_answered_elsewhere, y los teléfonos web reciben el evento claimed con by: webphone, agent o api. El registro de llamadas muestra quién atendió en answered_by.
Con el agente en always, en cuanto llega la llamada el agente se queda con ella y el motor responde a la oferta SDP; los teléfonos web la ven sonar y, a continuación, el claimed. El webhook del número recibe los eventos calls como siempre. Si el motor no puede atender (su producto no envió la configuración y no había una guardada, falta una clave de IA…), la llamada se rechaza y queda en el registro con end_reason: agent_unavailable. En fallback, sigue sonando en los teléfonos web.
Tu producto también puede rechazar una llamada: si responde al agent.config con 409 o 410 y {"error": {"code": "agent_unavailable"}}, el motor no usa la configuración guardada y no atiende. En always, la llamada se rechaza (end_reason: agent_unavailable); en fallback, sigue sonando en los teléfonos web; entregada por la API, la respuesta es 409 agent_unavailable.
Una cuenta bloqueada (prueba terminada, suscripción terminada o suspendida) no tiene agente, como no tiene API ni teléfono web: la llamada llega al webhook del número y sigue sonando hasta que quien llama desiste.
Cuando el agente no puede quedarse con una llamada que suena, su producto recibe el evento call.agent.declined, con el motivo en reason, el código del rechazo en detail, el modo del número en mode y, en outcome, qué pasó con la llamada: rejected (rechazada, en always) o ringing (sigue sonando en los teléfonos web, en fallback). Una llamada rechazada queda en el registro con end_reason: agent_unavailable y el motivo en end_detail. Una llamada entregada por la API no genera el evento: su producto recibe el error.
reason | Cuándo ocurre |
|---|---|
product_refused | Tu producto rechazó la llamada (respondió al agent.config con 409 o 410 y agent_unavailable). |
config_unavailable | Su producto no envió una configuración válida a tiempo, y el motor no tenía una guardada. |
missing_provider_key | Falta la clave de un proveedor de IA que usa el agente. |
invalid_offer | El motor no pudo responder a la oferta SDP. |
at_capacity, draining | El motor de agentes está en su límite de llamadas, o reiniciando (Voza vuelve a pedir una vez, dentro del mismo plazo). |
account_limit | La cuenta ya tiene todas las sesiones de agente simultáneas que puede tener (limits.agent_sessions en GET /v1/account). |
runtime_unavailable, runtime_error | El motor de agentes no respondió a tiempo, o falló. |
whatsapp_refused | Meta o el motor de QR rechazó la respuesta del agente (pre_accept). |
error | Otra falla de Voza. |
Cada cuenta tiene un límite de sesiones de agente al mismo tiempo, sumando las llamadas que atienden los agentes y las llamadas de prueba, y un límite menor solo para las llamadas de prueba: limits.agent_sessions y limits.agent_tests en GET /v1/account (10 y 2 por defecto; Voza fija los de una cuenta socia). Por encima de él, una llamada en always se rechaza (account_limit), una en fallback sigue sonando en los teléfonos web, y la API responde 429 agent_limit_reached.
Entrega al agente una llamada entrante que todavía está sonando: su producto decide llamada a llamada. {"agent_ref": "…"} es opcional (otro agente de su producto). Responde {"success": true, "call": {…}}, o 409 call_answered_elsewhere si alguien ya atendió y 409 call_not_ringing si ya terminó. call_id es el id de la llamada en WhatsApp; su id en Voza también vale.
Lo que el motor le pide a su producto
Solicitudes POST a la callback_url, con X-Voza-Event (agent.config o tool.call), X-Voza-Timestamp (segundos Unix) y X-Voza-Signature: sha256=…, el HMAC-SHA256 de {timestamp}.{cuerpo} (el timestamp, un punto y el cuerpo tal como llegó) con el secreto del agente. Comprueba la firma y rechaza un X-Voza-Timestamp a más de 5 minutos de tu reloj: así una solicitud capturada no puede repetirse después.
$expected = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$body, $secret);
$valid = hash_equals($expected, $signature) && abs(time() - (int) $timestamp) <= 300;
Al comienzo de cada llamada, la configuración (responda en hasta 2 segundos):
{ "type": "agent.config", "agent_ref": "agent_42", "call": { "id": "01jbk5…", "direction": "incoming", "test": false }, "number": { "id": "NUMBER_ID", "phone": "5511987654321", "type": "qr" }, "contact": { "wa_id": "5521999990000", "name": "Bruno" } }
{
"version": "42.3",
"language": "pt-BR",
"greeting": { "text": "Hola, habla la asistente virtual de la Clínica Sonrisa. ¿En qué puedo ayudarle?", "interruptible": false },
"instructions": "Agende, reprograme y cancele citas. Confirme la fecha en voz alta.",
"guardrails": ["Nunca dé consejos médicos."],
"voice": { "provider": "elevenlabs", "voice_id": "…", "speed": 1.0 },
"stt": { "provider": "deepgram", "language": "multi" },
"llm": { "provider": "anthropic", "model": "claude-haiku-5-5" },
"turn": { "interruption": "medium", "silence_ms": 600 },
"tools": [{
"name": "find_slots",
"description": "Horarios libres en la agenda",
"parameters": { "type": "object", "properties": { "day": { "type": "string" } }, "required": ["day"] },
"filler": "Un momento, voy a ver la agenda."
}],
"handoff": {
"on_request": true,
"message": "Le paso con el equipo, un momento.",
"timeout_seconds": 45,
"timeout_message": "Nadie del equipo puede atender ahora. ¿Puedo tomar un recado?",
"timeout_action": "resume"
},
"error_message": "Disculpe, tengo un problema técnico. Le paso con el equipo.",
"max_duration_seconds": 900
}
El motor guarda la última configuración de cada agente por su version: si su producto no responde a tiempo, usa la guardada. Cada herramienta se llama con {"type": "tool.call", "call_id", "agent_ref", "tool", "arguments"} y tiene 8 segundos para responder {"result": …}, con say (una frase a decir), end_call: true o handoff: true opcionales. La base de conocimiento es una herramienta como cualquier otra.
stt.language: el idioma de quien llama (pt-BR…) omulti, cuando puede hablar más de uno, incluso en la misma frase.multisolo vale con los modelos de Deepgram que transcriben varios idiomas (nova-3, el predeterminado, onova-2, que solo mezcla inglés y español); con otrostt.model, la configuración se rechaza.languagesigue siendo el idioma en que habla el agente.handoff: después de pasar la llamada al equipo, el agente esperatimeout_seconds(de 10 a 600, 45 de forma predeterminada) a que un supervisor la tome. Si nadie la toma, el eventocall.agent.statetraehandoff_timeout, el agente dicetimeout_messagey, contimeout_action: resume(el predeterminado), vuelve a atender y ofrece tomar un recado (sin mensaje, la IA avisa con sus propias palabras); conend, se despide con el mensaje (obligatorio) y termina la llamada (reason: handoff_timeoutencall.agent.ended). Quien llama nunca queda en silencio hastamax_duration_seconds.max_duration_secondscuenta solo el tiempo de la IA: mientras un supervisor tiene la llamada, el reloj se detiene, y un supervisor que devuelve la llamada pasado ese tiempo la termina (max_duration). Toda llamada termina a las 4 horas, con quien esté en ella (max_call_duration).error_message(opcional): lo que dice el agente cuando un proveedor falla en medio de la llamada (vea Cuando un proveedor falla).
Cuando un proveedor falla
Si la transcripción, la IA o la voz fallan en medio de una llamada (el proveedor rechazó la clave de la cuenta, sigue fallando, o la IA no responde a un turno en 20 segundos), quien llama no queda en silencio: su producto recibe call.agent.error, el agente dice error_message (o, sin ella, el mensaje del handoff; si falló la propia voz, solo una frase ya guardada, o si no un tono) y pasa la llamada al equipo, si el agente tiene handoff.on_request, o la termina (reason: provider_error). La IA no vuelve a atender en esa llamada: si el equipo la devuelve, o nadie la toma, termina.
{ "service": "llm", "provider": "anthropic", "status": 401, "category": "authentication", "action": "handoff" }
service es stt, llm o tts; status, el estado HTTP del proveedor, cuando lo hay; category, el tipo de falla (authentication, rate_limit, quota, server, timeout…); action, handoff o end.
Eventos del agente
Van a la events_url del agente, firmados con el secreto del agente, o al webhook del número, firmados con el secreto del webhook. Tienen object: voza y el nombre del evento en event y en X-Voza-Event. Salen en una cola propia, y los de una llamada salen en orden, uno después del otro: un evento que falló retiene los siguientes de la misma llamada hasta el nuevo intento. Los nuevos intentos son después de 5, 15 y 30 segundos, por hasta 2 minutos; call.agent.ended (con la conversación entera), call.agent.error y call.agent.declined siguen después, por el plazo de los eventos de mensaje.
{
"object": "voza",
"event": "call.transcript",
"event_id": "5f2c…",
"number": { "id": "NUMBER_ID", "name": "Tienda Centro", "external_ref": null, "status": "connected", "status_reason": null, "phone": "5511987654321" },
"call": { "id": "01jbk5…", "call_id": "wacid.ABGG…", "direction": "incoming", "contact": "5521999990000", "contact_name": "Bruno", "agent_ref": "agent_42", "agent_session_id": "…" },
"test": false,
"data": { "speaker": "caller", "text": "Quiero reprogramar la cita del viernes.", "start": 4.2, "end": 6.9 },
"timestamp": 1761150012,
"timestamp_ms": 1761150012345
}
| Evento | Cuándo | En data |
|---|---|---|
call.agent.started | El agente atendió. | call, number, contact, agent_ref y la versión de la configuración (config_version). |
call.agent.state | En cada cambio. | state: listening, thinking, speaking, handoff, handoff_timeout (nadie tomó la llamada a tiempo) o supervised. |
call.transcript | En cada frase completa. | speaker (caller, agent o supervisor), text, start y end en segundos desde que se atendió. |
call.agent.tool_call | En cada herramienta. | name, duration_ms y result (ok, timeout, error); sin los argumentos. |
call.metrics | En cada turno. | Las latencias en milisegundos: stt_ms, llm_ms (hasta la primera palabra), tts_ms y end_to_end_ms. |
call.supervision | Un supervisor entra, cambia de modo, susurra o sale. | action, monitor_id y mode. |
call.agent.error | Un proveedor falló en medio de la llamada. | service, provider, status, category y action (vea Cuando un proveedor falla). |
call.agent.declined | El agente no pudo quedarse con una llamada que sonaba. | reason, detail, mode y outcome (vea Quién atiende la llamada). |
call.agent.ended | El final. | reason, duration_seconds, los segundos de la IA (ai_seconds) y los de un supervisor con la llamada (supervised_seconds), la transcripción completa (transcript), las herramientas usadas (tools) y el uso (usage). |
timestamp es el instante del evento en segundos, y timestamp_ms, el mismo instante en milisegundos: úsalo para ordenar eventos del mismo segundo.
event_id es el mismo en cada intento: úselo para no procesar un evento dos veces. Las llamadas atendidas por el agente y los segundos de la IA entran en el uso (agent_calls y agent_seconds en GET /v1/account/usage).
El reason de call.agent.ended: remote_ended (quien llamó colgó), agent o tool (el agente la terminó), handoff_timeout, max_duration, max_call_duration, idle, media_closed, connect_timeout, accept_failed, provider_error (un proveedor falló), agent_removed, number_removed y account_blocked (se quitó el agente o el número, o se bloqueó la cuenta: la llamada termina en el acto), runtime_lost (el motor de agentes cayó en medio de la llamada: Voza termina la llamada y envía este evento, sin la transcripción) o shutdown.
Supervisión
Quién puede supervisar lo decide su producto; Voza solo verifica la clave de API. Terminar la llamada sigue siendo la acción terminate.
Un token corto (mon_…, 5 minutos, una vez) para el navegador del supervisor, con los ice_servers. El navegador envía su oferta SDP a POST /v1/monitor/session con Authorization: Bearer mon_… y recibe la respuesta del motor y el monitor_id. mode es listen (escucha; el predeterminado) o takeover (entra hablando). Esta dirección acepta páginas de cualquier dominio (CORS).
{ "session": { "sdp_type": "offer", "sdp": "v=0\r\no=- …" }, "mode": "listen" }
{ "monitor_id": "mon_1", "call_id": "wacid.ABGG…", "session": { "sdp_type": "answer", "sdp": "v=0\r\no=- …" } }
| Dirección | Cuerpo | Qué hace |
|---|---|---|
POST …/calls/{call_id}/whisper | {"text": "Ofrezca el viernes a las 9:30."} | El texto entra en el contexto de la IA; quien llamó no lo oye. |
POST …/calls/{call_id}/takeover | {"monitor": "mon_1"} | La IA deja de hablar y el micrófono de ese supervisor pasa a la llamada. |
POST …/calls/{call_id}/release | {} | Devuelve la llamada a la IA. |
DELETE …/calls/{call_id}/monitor/{monitor_id} | Quita a un supervisor de la llamada; si la tenía, la IA vuelve a hablar. |
En el navegador, el SDK del teléfono web hace la parte del supervisor: consulta Supervisar una llamada de la IA.
Llamada de prueba
Converse con un agente desde el navegador, sin WhatsApp: envíe {"agent_ref", "callback_url"} y reciba {id, token, ice_servers, expires_at}. El navegador envía la oferta SDP a POST /v1/agents/test-sessions/connect con Authorization: Bearer agt_… (una vez) y recibe la respuesta del motor. Con number_id, el motor firma con el secreto del agente de ese número, y los eventos salen con "test": true adonde van los del número; sin él, la respuesta trae un secret solo de esta llamada, y los eventos no se envían. DELETE /v1/agents/test-sessions/{id} la termina.
Una llamada de prueba dura como máximo 10 minutos y cuenta en las sesiones de agente de la cuenta: por encima de limits.agent_tests llamadas de prueba al mismo tiempo, connect responde 429 test_session_limit_reached (y 429 agent_limit_reached por encima de limits.agent_sessions). El token puede usarse de nuevo después de un rechazo así.
Voz y claves de IA
Guarda, cifradas, las claves de los proveedores de IA de la cuenta: anthropic, deepgram, elevenlabs y cartesia. Solo cambian los proveedores enviados, y null elimina una clave. Solo una clave de API creada por un propietario de la cuenta puede cambiarlas (si no, 403 owner_only). Nunca se devuelven: la respuesta, como GET, solo dice cuáles existen.
Antes de guardar, Voza verifica cada clave nueva en el propio proveedor, en una dirección que no gasta nada (la lista de modelos, de proyectos o de voces). Si rechaza una clave (401), no se guarda ninguna clave de la solicitud, y la respuesta es 422 invalid_provider_key, con los proveedores en meta.providers. Un proveedor caído, lento, o que acepta la clave sin el permiso de esa lista, no impide la clave.
{ "anthropic": "sk-ant-…", "deepgram": "…" }
{ "anthropic": true, "deepgram": true, "elevenlabs": false, "cartesia": false }
{"provider", "voice_id", "text", "speed"} → el audio (audio/mpeg, o audio/wav con "format": "wav"), con la clave del proveedor guardada en la cuenta. Sirve para escuchar el saludo antes de publicarlo. El texto no queda guardado; una frase fija que una llamada del agente ya guardó (la misma voz y el mismo texto) vuelve sin esperar (X-Voza-Cache: hit).
Las voces de la cuenta en un proveedor (elevenlabs o cartesia), leídas con la clave guardada en la cuenta, así que las voces clonadas también vienen. Cada voz trae id (el voice_id de la configuración y de /v1/tts), name, languages, gender y accent cuando el proveedor lo dice, y preview_url. La lista se guarda una hora, por cuenta y proveedor.
{ "provider": "elevenlabs", "data": [{ "id": "21m00Tcm4TlvDq8ikWAM", "name": "Rachel", "languages": ["en", "pt-BR"], "gender": "female", "accent": "american", "preview_url": "https://…/preview.mp3" }] }
Errores de los agentes
| Estado | Código | Cuándo ocurre |
|---|---|---|
| 401 | invalid_monitor_token, invalid_test_token | El token de supervisión o de la llamada de prueba está ausente, es inválido o venció. |
| 403 | owner_only | Solo una clave de API creada por un propietario guarda las claves de IA. |
| 404 | monitor_not_found | No hay un supervisor con ese id en la llamada. |
| 409 | agent_not_configured | El número no tiene un agente. |
| 409 | agent_unavailable | Tu producto rechazó la llamada (respondió al agent.config con 409 o 410 y agent_unavailable). |
| 409 | call_not_ringing | La llamada ya no está sonando. |
| 409 | call_not_with_agent | La llamada no está con el agente: otra persona la atendió, terminó o todavía no fue atendida. |
| 409 | token_used | El token de supervisión o de la llamada de prueba ya fue usado. |
| 409 | too_many_monitors, not_in_control | La llamada ya tiene todos los supervisores, u otro supervisor la tomó. |
| 422 | missing_provider_key | Falta la clave de un proveedor que usa el agente: guárdela en PUT /v1/account/ai-keys. |
| 422 | invalid_offer | El motor no pudo responder a la oferta SDP. |
| 422 | invalid_provider_key | Un proveedor rechazó la clave enviada en PUT /v1/account/ai-keys. |
| 429 | agent_limit_reached, test_session_limit_reached | La cuenta ya tiene todas las sesiones de agente, o llamadas de prueba, simultáneas que puede tener. |
| 502 | agent_config_unavailable | Su producto no envió la configuración a tiempo, y el motor no tenía una guardada. |
| 502 | tts_failed | El proveedor de voz no generó el audio. |
| 502 | voices_failed | El proveedor de voz no devolvió la lista de voces (una clave rechazada, por ejemplo). |
| 503 | agents_unavailable, agents_busy | El motor de agentes no respondió o está en su límite de llamadas. meta.reason trae el motivo que dio el motor. |
Cuenta, plan y uso
{
"id": 42,
"name": "Tu Empresa",
"status": "active",
"trial_ends_at": null,
"partner": false,
"plan": "pro",
"subscription": { "status": "active", "currency": "brl", "renews_at": "2026-11-23T14:30:00Z", "ends_at": null },
"limits": { "numbers": 5, "channels": 10, "requests_per_minute": 600, "agent_sessions": 10, "agent_tests": 2 },
"webhook_retry_hours": 24
}
| Campo | Descripción |
|---|---|
status | trial (prueba gratis), active (activa), suspended (suspendida) o canceled (suscripción finalizada). |
plan | start, pro, scale, o vacío sin suscripción. |
subscription | La suscripción en Stripe: status (active, past_due cuando un pago falló, canceled…), la moneda y las fechas. Vacío sin suscripción. |
limits | Cuántos números conecta la cuenta, cuántas llamadas simultáneas puede tener, cuántas solicitudes por minuto hace en la API y cuántas sesiones de agente de voz tiene al mismo tiempo (agent_sessions, de las cuales hasta agent_tests llamadas de prueba). |
webhook_retry_hours | Durante cuántas horas se reintenta un evento de mensaje que falló: 24, o el plazo acordado con Voza, hasta 168 (7 días). |
partner | Una cuenta socia, de otro producto, con límites acordados con Voza: sus llamadas no se rechazan por el límite de llamadas simultáneas, porque el producto limita las de sus clientes. |
La suscripción se hace en el panel (Plan y uso), con tarjeta, a través de Stripe. Cuando termina, la API responde subscription_inactive hasta que la cuenta se suscriba de nuevo.
{
"month": "2026-10",
"totals": { "messages_sent": 1840, "messages_received": 2311, "calls": 96, "call_seconds": 20412, "agent_calls": 40, "agent_seconds": 7310 },
"numbers": [
{ "number_id": "01jbk3…", "name": "Tienda Centro", "messages_sent": 1200, "messages_received": 1500, "calls": 60, "call_seconds": 14000, "agent_calls": 40, "agent_seconds": 7310 }
]
}
El uso del mes en UTC, en total y por número (number_id vacío para un número ya eliminado). Sin month, el mes actual. agent_calls y agent_seconds son las llamadas que atendió un agente de voz y los segundos de la IA en ellas (sin el tiempo en que un supervisor tenía la llamada).
OpenAPI y Postman
Toda la API está descrita en OpenAPI 3.1: https://www.vozaapi.com/docs/openapi.json?lang=es. Impórtala en Postman o Insomnia, o genera un cliente en tu lenguaje.
Los textos de la especificación llegan en el idioma de ?lang=: pt-BR, en o es.
Estado de Voza
El estado de Voza en vivo (sitio y API, base de datos, webhooks y correos, nuevos intentos, los números por código QR y los agentes de voz) está en https://www.vozaapi.com/status. Para un monitor de disponibilidad, lo mismo en JSON: https://www.vozaapi.com/status.json, que responde 503 cuando algún servicio está caído o atrasado.
{
"status": "operational",
"checked_at": "2026-10-27T15:00:00Z",
"components": [{ "key": "engine", "name": "Números por código QR", "status": "operational", "message": null }]
}
status es operational, degraded o down. Los números oficiales dependen también de la API de Meta, que esta página no sigue.
Novedades
- 29/10/2026: los eventos de los agentes salen en una cola propia, en orden por llamada, y nunca retrasan los eventos de llamada;
call.agent.declinedyend_detaildicen por qué el agente rechazó una llamada, y suterminatetraevoza_reason: agent_unavailableyvoza_detail;call.agent.erroryerror_messagecuando un proveedor falla en medio de la llamada (provider_error);runtime_lostcuando cae el motor de agentes;max_duration_secondscuenta solo el tiempo de la IA, ycall.agent.endedtraeai_secondsysupervised_seconds; límites de sesiones de agente y de llamadas de prueba por cuenta (limits.agent_sessionsylimits.agent_tests,429 agent_limit_reached); quitar el agente o el número, o bloquear la cuenta, termina las llamadas del agente en curso; las claves de IA se verifican en el proveedor; el fin de una llamada se reintenta después de los 2 minutos; un token rechazado en un envío, en una acción de llamada o en la verificación de cada hora lleva el número oficial ameta_refused. - 29/10/2026:
POST /v1/ttsno guarda el texto (elsayde las herramientas tampoco); el agente no atiende las llamadas de una cuenta bloqueada; el agente acepta la llamada hasta 4 s después delpre_acceptaunque la media no esté conectada, y el saludo espera la media. - 28/10/2026:
"calling": "keep"al conectar un número oficial;{id}y{external_ref}en el webhook de un número nuevo;meta_timeout, separado demeta_unavailable, eIdempotency-Keyen las acciones de llamada;timestamp_msen los eventos del agente;X-Voza-Timestampen la firma de las solicitudes del motor; el producto puede rechazar una llamada (agent_unavailable); el tiempo límite delhandoff;stt.language: multi;GET /v1/voices. - 28/10/2026: el teléfono web supervisa las llamadas que contestó la IA:
monitor(callId, {token})escucha, y tomar, devolver y susurrar pasan por tu servidor; el estadoagent(“Contestada por la IA”),answeredByy el motivoanswered_by_agenten las llamadas del SDK; el botón Escuchar y la supervisión en<voza-phone>. - 28/10/2026: agentes de voz:
PUT /v1/numbers/{id}/agent(el agente de un número, enalwaysofallback), entregar una llamada al agente, supervisión (escuchar, susurrar, tomar y devolver), llamadas de prueba,POST /v1/tts,PUT /v1/account/ai-keys, los eventoscall.agent.*,call.transcript,call.metricsycall.supervision,answered_byen el registro de llamadas yagent_callsyagent_secondsen el uso. Una llamada atendida con la clave de API también queda con quien atendió primero. - 28/10/2026:
GET /v1/numbers/{id}/templates(las plantillas de un número oficial);external_refen los números, en los webhooks (metadata.external_ref) y enPATCH /v1/numbers/{id}; el secreto del webhook en la respuesta dePOST /v1/numbers; idempotencia guardada por Voza también en los números por código QR;limits.requests_per_minuteywebhook_retry_hoursenGET /v1/account. - 28/10/2026: el error de Meta en
error.meta(y el motivo del motor enerror.meta.reason); loserrorsde Meta en los webhooks; eventos de llamada en una cola propia, con nuevos intentos en segundos; la llamada rechazada por el límite llega al webhook;GETyPUT /v1/numbers/{id}/calling;GET /v1/numbers/{id}/call-permissions/{wa_id};status_reasonen los números. - 23/10/2026: la API en portugués, inglés y español: los mensajes de error siguen
Accept-Languageo?lang=(sin ellos, el idioma de quien creó la cuenta), la especificación OpenAPI llega en el idioma pedido y<voza-phone>ganó el atributolang. - 23/10/2026: números oficiales de Meta por la misma API (plantillas, mensajes interactivos, llamadas);
GET /v1/account/usage; plan y suscripción enGET /v1/account; especificación OpenAPI. - 22/10/2026: primera versión: números por código QR, mensajes, archivos multimedia, webhooks, llamadas y teléfono web.
Los cambios que puedan romper tu integración se avisan con anticipación y entran en una nueva versión de la dirección (/v2); la /v1 solo gana campos y eventos nuevos.