Docs/Historial de llamadas

Historial de llamadas por API — AMAI Voice

Consulta tus llamadas por rango de fechas, con el coste de cada una y los totales del periodo. Es lo mismo que descargas en el CSV del panel, pero por API y con más datos: el CSV es un subconjunto de esto.

  • Base URL: https://voice.amai.run
  • Endpoint: GET /api/v1/calls
  • Autenticación: Authorization: Bearer amai_<key>
  • Solo lectura. Esta llamada no modifica nada.

1. Quickstart (30 segundos)

bash
curl -sS -H "Authorization: Bearer $AMAI_API_KEY" \
  "https://voice.amai.run/api/v1/calls?start_date=2026-07-01&end_date=2026-07-31&limit=1"

Respuesta (recortada a una llamada):

json
{
  "data": [
    {
      "id": "call_a1b2c3d4",
      "started_at": "2026-07-23T14:32:11Z",
      "ended_at": "2026-07-23T14:33:17Z",
      "direction": "outbound",
      "from": "+573501234567",
      "to": "+34600111222",
      "destination_label": "Spain Mobile",
      "duration_sec": 66,
      "billed_sec": 66,
      "status": "completed",
      "hangup_cause": "NORMAL_CLEARING",
      "sip_account": "1001",
      "call_origin": "ai_agent",
      "vapi_call_id": "019fd639-899a-755d-b1f8-dadab73ded9c",
      "cost": {
        "currency": "USD",
        "rate_per_minute": 0.012,
        "telephony_usd": 0.0132,
        "ai_usd": 0.0450,
        "total_usd": 0.0582
      }
    }
  ],
  "pagination": { "limit": 1, "offset": 0, "total": 412, "has_more": true },
  "summary": {
    "period": { "start": "2026-07-01T00:00:00Z", "end": "2026-07-31T23:59:59Z" },
    "total_calls": 412,
    "connected_calls": 388,
    "failed_calls": 24,
    "total_billed_seconds": 24870,
    "total_billed_minutes": 414.5,
    "telephony_cost_usd": 8.1000,
    "ai_cost_usd": 4.3402,
    "total_cost_usd": 12.4402,
    "average_cost_per_minute_usd": 0.0300
  }
}

Fíjate en una cosa desde ya: summary es del rango completo, no de la página. Aunque pidas limit=1, los totales siguen siendo los de julio entero. Es el dato que buscas si lo que quieres es el coste del mes.


2. Autenticación y aislamiento

Cabecera en cada petición:

code
Authorization: Bearer amai_TU_API_KEY
  • La clave se genera en el panel: API Keys (https://voice.amai.run/app/api-keys), y solo el propietario de la cuenta puede crearla.
  • Se guarda hasheada: se ve una única vez, al crearla. Si la pierdes, generas otra.
  • No caduca por tiempo. Es revocable al instante desde el mismo panel.
  • Guárdala en una variable de entorno (AMAI_API_KEY). Nunca en el repositorio ni en código que llegue al navegador.

Los datos que devuelve esta API son única y exclusivamente los de tu cuenta. El propietario de los datos se deduce de la propia clave: no existe ningún parámetro para pedir las llamadas de otro, y una clave nunca ve una llamada que no sea suya. Es la garantía que estás comprando, y no depende de que tu integración pida bien las cosas.


3. Parámetros de consulta

ParámetroValorPor defectoDetalle
start_date2026-07-01 o 2026-07-01T00:00:00Zhoy − 30 díasInicio del rango. Inclusivo
end_dateídemahoraFin del rango. Inclusivo: pedir el 31 incluye el día 31 entero
directioninbound | outboundEntrantes o salientes
statuscompleted | failed | timeoutFiltro por resultado, igual que el panel
limit1–50050Llamadas por página
offset≥ 00Desplazamiento para paginar

Tres reglas que evitan sustos:

  1. El rango máximo por petición es de 366 días. Uno mayor devuelve 400 invalid_range en vez de tardar una eternidad.
  2. Un parámetro mal escrito nunca se ignora. Una fecha inválida devuelve 400 invalid_date. Si se ignorase, recibirías datos de otro periodo y los darías por buenos.
  3. end_date es inclusivo. start_date=2026-07-01&end_date=2026-07-31 es julio entero, sin restarle el último día.

4. Qué devuelve

La respuesta tiene siempre tres bloques:

BloqueQué es
dataLas llamadas de esta página, de la más reciente a la más antigua
paginationlimit, offset, total (del rango, no de la página) y has_more
summaryLos agregados de todo el rango filtrado

Campos de cada llamada:

CampoTipoQué es
idstringIdentificador público y estable de la llamada
started_at / ended_atISO-8601 UTCInicio y fin. ended_at puede ser null
directioninbound | outboundDirección
from / toE.164Origen y destino
destination_labelstring | nullDestino tarifado, p. ej. Spain Mobile
duration_secintDuración real
billed_secintSegundos facturados. Es lo que se cobra
statuscompleted | failed | no_answer | busyResultado normalizado
hangup_causestring | nullCausa en crudo de la centralita, si quieres el detalle
sip_accountstring | nullCuenta SIP implicada
call_originstring | nullOrigen de la llamada (p. ej. ai_agent)
vapi_call_idstring | nullId de la llamada en VAPI. Es el id que conoce una herramienta del agente a mitad de llamada, y PUT /v1/calls/{call_id}/member lo acepta como identificador. null si la llamada no pasó por VAPI
costobjetoDesglose: rate_per_minute, telephony_usd, ai_usd, total_usd

Los importes son números USD con 4 decimales (0.0582), no cadenas ni céntimos. En una llamada sin segundos facturados —una fallida, por ejemplo— cost.rate_per_minute puede venir a null: no hay tarifa que aplicar. Tenlo en cuenta si multiplicas por ese campo en vez de leer total_usd.

Una llamada de agente IA internamente son dos tramos; la API te la devuelve una sola vez, con su coste una sola vez. No tienes que deduplicar nada.


5. Sacar un mes entero paginando

El patrón es siempre el mismo: pides una página, te llevas data, y repites mientras pagination.has_more sea true, sumando limit al offset. El summary viene idéntico en todas las páginas, así que basta con quedarte con el de la primera.

Con limit=500 (el máximo), un mes de 412 llamadas cabe en una sola petición. Aun así, escribe el bucle: el mes que viene puede que no quepa.

5.1. Bash (curl + jq)

bash
#!/usr/bin/env bash
set -euo pipefail

: "${AMAI_API_KEY:?exporta AMAI_API_KEY}"
BASE="https://voice.amai.run/api/v1/calls"
START="2026-07-01"; END="2026-07-31"; LIMIT=500
offset=0
: > llamadas-julio.jsonl

while : ; do
  page=$(curl -sS -H "Authorization: Bearer $AMAI_API_KEY" \
    "$BASE?start_date=$START&end_date=$END&limit=$LIMIT&offset=$offset")

  echo "$page" | jq -c '.data[]' >> llamadas-julio.jsonl
  [ "$offset" -eq 0 ] && echo "$page" | jq '.summary' > resumen-julio.json

  [ "$(echo "$page" | jq -r '.pagination.has_more')" = "true" ] || break
  offset=$(( offset + LIMIT ))
done

echo "Llamadas descargadas: $(wc -l < llamadas-julio.jsonl)"
jq -r '"Coste total USD: \(.total_cost_usd)   Coste por minuto USD: \(.average_cost_per_minute_usd)"' resumen-julio.json

Salida con los datos del ejemplo:

code
Llamadas descargadas: 412
Coste total USD: 12.4402   Coste por minuto USD: 0.03

5.2. Node.js (18+, sin dependencias)

javascript
const BASE = "https://voice.amai.run/api/v1/calls"
const KEY = process.env.AMAI_API_KEY

async function getPage(params) {
  const url = `${BASE}?${new URLSearchParams(params)}`
  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } })
    if (res.status === 429) {
      // Presupuesto de 60 peticiones/minuto agotado: espera y reintenta.
      const wait = Number(res.headers.get("retry-after") || 2) * 1000
      await new Promise((r) => setTimeout(r, wait))
      continue
    }
    const body = await res.json()
    if (!res.ok) throw new Error(`${res.status} ${body.error.code}: ${body.error.message}`)
    return body
  }
  throw new Error("rate limited: agotados los reintentos")
}

async function fetchMonth(start, end) {
  const limit = 500
  let offset = 0
  const calls = []
  let summary = null

  for (;;) {
    const page = await getPage({ start_date: start, end_date: end, limit, offset })
    if (summary === null) summary = page.summary   // idéntico en todas las páginas
    calls.push(...page.data)
    if (!page.pagination.has_more) break
    offset += limit
  }
  return { calls, summary }
}

const { calls, summary } = await fetchMonth("2026-07-01", "2026-07-31")
console.log("Llamadas descargadas:", calls.length)
console.log("Coste total USD:", summary.total_cost_usd)
console.log("Coste por minuto USD:", summary.average_cost_per_minute_usd)

Salida con los datos del ejemplo:

code
Llamadas descargadas: 412
Coste total USD: 12.4402
Coste por minuto USD: 0.03

5.3. Python (3.9+, requests)

python
import os, time, requests

BASE = "https://voice.amai.run/api/v1/calls"
KEY = os.environ["AMAI_API_KEY"]

def get_page(params):
    for _ in range(5):
        r = requests.get(BASE, params=params, headers={"Authorization": f"Bearer {KEY}"})
        if r.status_code == 429:
            # Presupuesto de 60 peticiones/minuto agotado: espera y reintenta.
            time.sleep(float(r.headers.get("Retry-After", 2)))
            continue
        body = r.json()
        if r.status_code != 200:
            raise RuntimeError(f"{r.status_code} {body['error']['code']}: {body['error']['message']}")
        return body
    raise RuntimeError("rate limited: agotados los reintentos")

def fetch_month(start, end, limit=500):
    offset, calls, summary = 0, [], None
    while True:
        page = get_page({"start_date": start, "end_date": end, "limit": limit, "offset": offset})
        if summary is None:
            summary = page["summary"]      # idéntico en todas las páginas
        calls.extend(page["data"])
        if not page["pagination"]["has_more"]:
            return calls, summary
        offset += limit

calls, summary = fetch_month("2026-07-01", "2026-07-31")
print("Llamadas descargadas:", len(calls))
print("Coste total USD:", summary["total_cost_usd"])
print("Coste por minuto USD:", summary["average_cost_per_minute_usd"])

Salida con los datos del ejemplo:

code
Llamadas descargadas: 412
Coste total USD: 12.4402
Coste por minuto USD: 0.03

6. El coste por minuto

Es el número por el que existe este endpoint, así que va explicado entero.

Te lo damos ya calculado en el summary:

code
average_cost_per_minute_usd = total_cost_usd / total_billed_minutes

Con los datos del ejemplo de arriba:

DatoValor
total_billed_seconds24 870 s
total_billed_minutes24 870 / 60 = 414.5 min
telephony_cost_usd8.1000 USD
ai_cost_usd4.3402 USD
total_cost_usd8.1000 + 4.3402 = 12.4402 USD
average_cost_per_minute_usd12.4402 / 414.5 = 0.0300 USD/min

Es decir: 3 céntimos de dólar por minuto, todo incluido.

Ojo al comparar con un operador de telefonía pura. Ese 0.0300 lleva dentro dos cosas distintas:

ConceptoCálculoUSD/min
Telefonía8.1000 / 414.50.0195
Agente de IA (voz, transcripción, modelo)4.3402 / 414.50.0105
Total12.4402 / 414.50.0300

Si estás comparando contra una factura de telefonía a secas —Twilio, por ejemplo—, la cifra comparable es 0.0195 USD/min, no el total: la otra factura no incluye el agente de IA. Y si lo que comparas es el coste completo de atender esas llamadas, entonces sí, la cifra es 0.0300.

Dos detalles que importan:

  • Se factura sobre billed_sec, no sobre duration_sec. La facturación va por incrementos, así que billed_sec puede ser algo mayor que la duración real. Divide siempre por los minutos facturados: es el dinero real.
  • Si en el periodo no hay minutos facturados, average_cost_per_minute_usd viene a null, nunca a 0. Un cero se leería como «sale gratis», y no es lo mismo que «no hubo tráfico».

Para el coste por minuto de un país o un destino concreto, filtra y agrupa por destination_label en tu lado; cost.rate_per_minute te da la tarifa aplicada a cada llamada.


7. Del CSV del panel a la API

Si hoy descargas llamadas_YYYY-MM-DD.csv, esta es la equivalencia columna a columna:

Columna del CSVCampo de la API
Fechastarted_at (ISO-8601 UTC en vez de texto localizado)
Direccióndirection (inbound/outbound en vez de «Entrante»/«Saliente»)
Origenfrom
Destinoto
Destino Labeldestination_label
Duración (seg)billed_sec
Coste (USD)cost.total_usd
Motivo finalizaciónstatus (enum estable en vez de texto traducido)
Código estadohangup_cause
Cuenta SIPsip_account
Origen llamadacall_origin

No se pierde ninguna columna, y la API añade lo que el CSV no tiene: ended_at, duration_sec, el desglose del coste (rate_per_minute, telephony_usd, ai_usd) y el summary del periodo completo.

Un aviso para quien venga del CSV: los campos de texto del CSV están traducidos y localizados («Entrante», «23 jul, 14:32») porque están hechos para leerse. Los de la API son estables y en formato de máquina: no cambian con el idioma del panel, así que puedes construir encima sin miedo.


8. Límites, errores y rotación de claves

Límite: 60 peticiones por minuto y clave. No se cuenta por IP, se cuenta por clave: lo que hagan otros no te consume presupuesto. Con limit=500, 60 peticiones son hasta 30 000 llamadas por minuto: de sobra para descargar un mes.

Y esto vale para toda /api/v1, no solo para este endpoint. La regla es la misma en cualquier operación de la API: el presupuesto se carga siempre a la credencial autenticada —tu clave, o tu usuario si estás autenticando con la sesión del panel—, nunca a la dirección desde la que sales. Da igual que compartas IP con media oficina: el tráfico de otros no te consume cuota, y rotar de IP no compra cuota nueva.

La IP sí gobierna una segunda barrera, 600 peticiones por minuto, anterior a mirar la credencial. No es tu límite —es diez veces el tuyo— y solo existe para que una avalancha sin autenticar no llegue a la tabla de claves. Si la tocas, lo raro está en tu lado.

Al superarlo recibes 429 y una cabecera Retry-After con los segundos que faltan. Lo correcto es esperar ese tiempo y reintentar —los tres ejemplos de arriba ya lo hacen—. Reintentar sin esperar solo consume el siguiente presupuesto.

Todos los errores usan el mismo envoltorio:

json
{ "error": { "code": "invalid_date", "message": "…" } }
HTTPcodeQué pasóQué hacer
400invalid_dateUna fecha no es ISO-8601Usa 2026-07-01 o 2026-07-01T00:00:00Z
400invalid_rangeEl rango supera 366 díasTrocea la consulta por meses
400invalid_fieldUn filtro tiene un valor no permitidoRevisa direction / status
401unauthorizedFalta la clave, o es inválida, revocada o inactivaGenera una nueva y sustitúyela
403insufficient_scopeLa clave no tiene permiso de lectura de llamadasPide una clave con calls:read (ver §9)
403capability_requiredEl plan de la cuenta no incluye esta funciónHabilítala en la cuenta. Una clave nueva no lo arregla
429rate_limited60 peticiones/minuto agotadasEspera lo que diga Retry-After y reintenta

Los dos 403 no se arreglan en el mismo sitio, y confundirlos cuesta una tarde. insufficient_scope lo arregla quien emite la clave: falta un permiso, se emite otra con él. capability_required lo arregla el plan de la cuenta: por muchos scopes que le pongas a una clave nueva, seguirá dando 403 hasta que la función esté habilitada en la cuenta.

La capacidad se comprueba sobre la cuenta, no sobre quien llama. Por eso, si pruebas un endpoint desde la consola de /docs/reference con tu sesión del panel —logueado, con todos tus permisos— también puedes recibir capability_required. No es que la API esté rota ni que tu sesión esté mal: es que esa función no entra en el plan. La API respeta el mismo modelo de planes que el panel.

Si una clave se revoca, todas sus peticiones pasan a devolver 401 de inmediato; no hay periodo de gracia. Revocar es irreversible: esa clave no vuelve.

Rotar sin cortar el servicio (tu integración no se entera):

  1. Crea la clave nueva en el panel. Las dos conviven; ambas funcionan.
  2. Despliega tu integración con la clave nueva en AMAI_API_KEY.
  3. Comprueba en el panel que la clave nueva tiene un «último uso» reciente y la vieja no.
  4. Entonces, y solo entonces, revoca la vieja.

Hacerlo al revés —revocar primero— deja tu integración caída entre el paso 1 y el 2.


9. Scopes de la clave

Al crear una clave desde el panel puedes dejar anotado a qué debería poder acceder, marcando permisos concretos (calls:read es el de este endpoint).

Lo que eso hace hoy, dicho sin adornos: nada. La comprobación de permisos está construida pero no está activada. Ahora mismo cualquier clave válida llega a toda la API, tenga marcados los permisos que tenga: una clave con solo calls:read puede, hoy, mandar un correo o un WhatsApp en tu nombre. Trátala como lo que es —una credencial con acceso total— y guárdala en consecuencia.

No está activada por una razón concreta: encenderla de golpe dejaría sin servicio, a la vez, a todas las integraciones creadas antes de que esto existiera, porque ninguna tiene permisos anotados.

Qué cambiará el día que se active:

  • La clave que ya nació con calls:read seguirá leyendo este endpoint sin que toques nada, y ahí sí dejará de poder mandar correos.
  • Una clave sin permisos anotados dejará de funcionar hasta que se le asignen.
  • El 403 insufficient_scope de la tabla de arriba solo puede aparecer a partir de entonces.

Recomendación práctica: marca ya los permisos que tus claves nuevas necesitan. Hoy no te protege, pero te ahorra la migración el día que se encienda.


10. Bloque de contexto para tu agente de IA

code
Consultas el historial de llamadas de AMAI Voice. Base: https://voice.amai.run
Auth: Authorization: Bearer <AMAI_API_KEY>   (env var AMAI_API_KEY)
Endpoint: GET /api/v1/calls
Parámetros: start_date, end_date (ISO-8601, AMBOS inclusivos), direction=inbound|outbound,
            status=completed|failed|timeout, limit<=500 (default 50), offset (default 0).
            Rango máximo 366 días por petición.
Respuesta: { data:[Call], pagination:{limit,offset,total,has_more}, summary:{...} }
Call: id, started_at, ended_at, direction, from, to, destination_label, duration_sec, billed_sec,
      status(completed|failed|no_answer|busy), hangup_cause, sip_account, call_origin,
      cost:{currency,rate_per_minute,telephony_usd,ai_usd,total_usd}
summary = TODO el rango, no la página: total_calls, connected_calls, failed_calls,
      total_billed_seconds, total_billed_minutes, telephony_cost_usd, ai_cost_usd, total_cost_usd,
      average_cost_per_minute_usd (= total_cost_usd/total_billed_minutes; null si 0 minutos).
Coste por minuto solo de telefonía = telephony_cost_usd / total_billed_minutes.
Paginar: repetir subiendo offset += limit mientras pagination.has_more sea true.
Errores: { error:{ code, message } }. 400 invalid_date|invalid_range|invalid_field,
      401 unauthorized, 403 insufficient_scope, 429 rate_limited (respeta Retry-After).
Límite: 60 peticiones/minuto por clave. La clave solo ve los datos de su propio tenant.

Soporte

info@amai.solutions · Panel: https://voice.amai.run/app · Referencia interactiva: /docs/reference

Última actualización: 2026-07-25.