Ir al contenido

Llamadas

El motor ve las llamadas que entran al número: cada señal (la oferta, el corte, la aceptación en otro dispositivo) llega como un evento call.*, y la llamada queda registrada con su estado. Tu app puede rechazar una llamada que está sonando, o la instancia puede rechazarlas sola y mandarle un texto a quien llamó.

Operación Scope
GET /v1/instances/{id}/calls (las llamadas y su estado) calls:manage
POST /v1/instances/{id}/calls/{call_id}/reject (rechazar) calls:manage
POST …/calls/{call_id}/accept y …/offer (siempre 501) calls:manage
PATCH /v1/instances/{id} con config.rechazar_llamadas y config.mensaje_rechazo instances:manage
GET /v1/instances/{id}/events (los eventos call.*) events:read

Cada señal de una llamada entrante es un evento del ledger, deduplicado por llamada y por tipo:

Evento Qué es
call.offer Una llamada entrante (la oferta). Trae video y elementos.
call.offer_notice El aviso de una oferta.
call.preaccept Una señal previa a la aceptación.
call.accept Se atendió en otro dispositivo del número (el teléfono).
call.terminate Terminó: colgaron, nadie atendió o se rechazó en otro lado. motivo es el código que informa WhatsApp.
call.reject WhatsApp informó un rechazo.
call.signal Cualquier otra señal de la llamada.
call.rejected El motor la rechazó: rechazo dice quién lo pidió (app, operador o automatico).
call.offer · data
{
"call_id": "CALL_ID",
"de": { "peer_ref": "4b7d9e01-0000-4000-8000-0000000000b1", "jid": "CONTACTO@lid", "clase": "usuario", "aliases": ["CONTACTO@s.whatsapp.net"] },
"video": false,
"momento": "2026-10-24T15:40:00Z",
"elementos": ["audio", "net"]
}
  • de es quien llama, como un peer.
  • video es true si la oferta trae un elemento video (una videollamada).
  • elementos son solo los nombres de las partes de la oferta, para diagnóstico.
  • En una llamada de grupo, viene además grupo, el peer del grupo.
call.terminate · data
{
"call_id": "CALL_ID",
"de": { "peer_ref": "4b7d9e01-0000-4000-8000-0000000000b1", "jid": "CONTACTO@lid", "clase": "usuario", "aliases": ["CONTACTO@s.whatsapp.net"] },
"video": false,
"motivo": "timeout",
"momento": "2026-10-24T15:42:00Z"
}

GET /v1/instances/{id}/calls lista las llamadas, las más nuevas primero, con su estado: entrante (sonando, o sin una señal de fin), aceptada (en otro dispositivo), terminada o rechazada. Con entrantes=true, solo las que siguen sonando: entrante y llegadas hace menos de 2 minutos.

Ventana de terminal
curl "https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/calls?entrantes=true" \
-H "Authorization: Bearer TU_API_KEY"
200 OK
{
"llamadas": [
{
"call_id": "CALL_ID",
"de": { "peer_ref": "4b7d9e01-0000-4000-8000-0000000000b1", "jid": "CONTACTO@lid", "clase": "usuario", "aliases": ["CONTACTO@s.whatsapp.net"] },
"video": false,
"estado": "entrante",
"recibida_en": "2026-10-24T15:40:00Z",
"actualizada_en": "2026-10-24T15:40:00Z"
}
]
}

POST /v1/instances/{id}/calls/{call_id}/reject acepta un comando durable call.reject y responde 202. No lleva cuerpo ni Idempotency-Key: es idempotente por llamada (rechazar dos veces la misma devuelve el mismo comando). Una llamada que la instancia no conoce es 404.

El rechazo va al dispositivo y al creador exactos con los que llegó la oferta. Cuando sale, el comando queda completado y llega call.rejected con rechazo: app. completado quiere decir que salió: WhatsApp no confirma el rechazo, y el corte en el teléfono de quien llama puede llegar después como call.terminate.

Ventana de terminal
curl -X POST https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/calls/CALL_ID/reject \
-H "Authorization: Bearer TU_API_KEY"
202 Accepted
{
"command_id": "5c2e7a10-0000-4000-8000-0000000000c7",
"instance_id": "8a1d2c3e-0000-4000-8000-0000000000a1",
"idempotency_key": "rechazo-CALL_ID",
"type": "call.reject",
"generation": 1,
"status": "aceptado",
"created_at": "2026-10-24T15:40:05Z",
"updated_at": "2026-10-24T15:40:05Z"
}
call.rejected · data
{
"call_id": "CALL_ID",
"de": { "peer_ref": "4b7d9e01-0000-4000-8000-0000000000b1", "jid": "CONTACTO@lid", "clase": "usuario", "aliases": ["CONTACTO@s.whatsapp.net"] },
"video": false,
"rechazo": "app",
"momento": "2026-10-24T15:40:06Z"
}

El sobre de call.rejected trae el command_id del rechazo. Si el rechazo no pudo salir, el comando termina fallido con command.failed: sin_sesion (la instancia no estaba conectada), operacion_fallida, sin_vincular o instancia_archivada.

Cada instancia puede rechazar sola toda llamada entrante, y opcionalmente mandarle un texto a quien llamó. Se configura con PATCH /v1/instances/{id} (scope instances:manage):

  • config.rechazar_llamadas: true lo enciende.
  • config.mensaje_rechazo (hasta 1000 caracteres) es el texto. Si el PATCH no lo trae, no cambia; null o "" lo borra.

Al llegar una oferta nueva, el motor acepta en la misma transacción el rechazo (call.reject) y, si hay texto, un message.text a quien llamó, por el mismo outbox que cualquier envío. Llega call.rejected con rechazo: automatico. El texto no se manda en una llamada de grupo, y si la cola de envíos de la instancia está llena, el rechazo sale igual y el texto se omite.

Ventana de terminal
curl -X PATCH https://motor.ejemplo.com/v1/instances/INSTANCIA_ID \
-H "Authorization: Bearer TU_API_KEY" \
-H "Content-Type: application/json" \
-d '{"config": {"rechazar_llamadas": true, "mensaje_rechazo": "Ahora no puedo atender llamadas. Escríbeme por aquí."}}'

Para apagarlo, manda {"config": {"rechazar_llamadas": false, "mensaje_rechazo": ""}}. El cuerpo es cerrado: un campo de más es 422, y con version_esperada vieja, 409.

HTTP / estado Código Cuándo
404 — La llamada no existe en esta instancia, o la instancia no es de tu app.
501 llamadas_no_soportadas …/accept u …/offer: el motor no atiende ni hace llamadas.
403 — La key no tiene calls:manage.
422 — Un PATCH con un campo de más o un mensaje_rechazo que no es texto.
fallido sin_sesion, operacion_fallida, sin_vincular, instancia_archivada El rechazo no pudo salir.

Lo comprobado con un iPhone llamando a instancias reales (caso A33, octubre de 2026):

  • Rechazo automático: con un texto configurado, la llamada de voz se cortó sola en el iPhone en menos de 1,5 s desde la oferta, y el texto llegó a quien llamó a los 0,3 s (21/10/2026). Tras un rechazo del motor no llegó call.terminate: el rechazo es su final.
  • Rechazo desde la app o el panel: voz y videollamada se cortaron en el iPhone al rechazar (22/10/2026). Antes, un rechazo dirigido al alias equivocado (el PN en vez del LID de la oferta) quedaba completado y la llamada seguía sonando; se corrigió para usar el creador exacto de la oferta (falla F33, cerrada el 22/10/2026). La repetición de control por la API después de esa corrección quedó pendiente; antes de ella, el rechazo por la API ya cortaba (21/10/2026), con un call.terminate de motivo rejected_elsewhere.
  • Videollamadas: el primer intento las marcaba con video: false; ahora video sale del elemento video de la oferta y una videollamada llega con video: true (22/10/2026).
  • Sin atender: una llamada que nadie atiende termina con call.terminate y motivo: timeout (a unos 2 minutos en una de las pruebas, 21/10/2026).
  • Llamadas sin fin: una llamada de la que nunca llegó una señal de fin queda entrante en la lista. Por eso entrantes=true solo muestra las de los últimos 2 minutos; rechazar una llamada vieja sale completado sin efecto en el teléfono.