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)
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):
{
"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:
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ámetro | Valor | Por defecto | Detalle |
|---|---|---|---|
start_date | 2026-07-01 o 2026-07-01T00:00:00Z | hoy − 30 días | Inicio del rango. Inclusivo |
end_date | ídem | ahora | Fin del rango. Inclusivo: pedir el 31 incluye el día 31 entero |
direction | inbound | outbound | — | Entrantes o salientes |
status | completed | failed | timeout | — | Filtro por resultado, igual que el panel |
limit | 1–500 | 50 | Llamadas por página |
offset | ≥ 0 | 0 | Desplazamiento para paginar |
Tres reglas que evitan sustos:
- El rango máximo por petición es de 366 días. Uno mayor devuelve
400 invalid_rangeen vez de tardar una eternidad. - 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. end_datees inclusivo.start_date=2026-07-01&end_date=2026-07-31es julio entero, sin restarle el último día.
4. Qué devuelve
La respuesta tiene siempre tres bloques:
| Bloque | Qué es |
|---|---|
data | Las llamadas de esta página, de la más reciente a la más antigua |
pagination | limit, offset, total (del rango, no de la página) y has_more |
summary | Los agregados de todo el rango filtrado |
Campos de cada llamada:
| Campo | Tipo | Qué es |
|---|---|---|
id | string | Identificador público y estable de la llamada |
started_at / ended_at | ISO-8601 UTC | Inicio y fin. ended_at puede ser null |
direction | inbound | outbound | Dirección |
from / to | E.164 | Origen y destino |
destination_label | string | null | Destino tarifado, p. ej. Spain Mobile |
duration_sec | int | Duración real |
billed_sec | int | Segundos facturados. Es lo que se cobra |
status | completed | failed | no_answer | busy | Resultado normalizado |
hangup_cause | string | null | Causa en crudo de la centralita, si quieres el detalle |
sip_account | string | null | Cuenta SIP implicada |
call_origin | string | null | Origen de la llamada (p. ej. ai_agent) |
vapi_call_id | string | null | Id 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 |
cost | objeto | Desglose: 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)
#!/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.jsonSalida con los datos del ejemplo:
Llamadas descargadas: 412
Coste total USD: 12.4402 Coste por minuto USD: 0.035.2. Node.js (18+, sin dependencias)
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:
Llamadas descargadas: 412
Coste total USD: 12.4402
Coste por minuto USD: 0.035.3. Python (3.9+, requests)
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:
Llamadas descargadas: 412
Coste total USD: 12.4402
Coste por minuto USD: 0.036. 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:
average_cost_per_minute_usd = total_cost_usd / total_billed_minutesCon los datos del ejemplo de arriba:
| Dato | Valor |
|---|---|
total_billed_seconds | 24 870 s |
total_billed_minutes | 24 870 / 60 = 414.5 min |
telephony_cost_usd | 8.1000 USD |
ai_cost_usd | 4.3402 USD |
total_cost_usd | 8.1000 + 4.3402 = 12.4402 USD |
average_cost_per_minute_usd | 12.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:
| Concepto | Cálculo | USD/min |
|---|---|---|
| Telefonía | 8.1000 / 414.5 | 0.0195 |
| Agente de IA (voz, transcripción, modelo) | 4.3402 / 414.5 | 0.0105 |
| Total | 12.4402 / 414.5 | 0.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 sobreduration_sec. La facturación va por incrementos, así quebilled_secpuede 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_usdviene anull, nunca a0. 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 CSV | Campo de la API |
|---|---|
| Fecha | started_at (ISO-8601 UTC en vez de texto localizado) |
| Dirección | direction (inbound/outbound en vez de «Entrante»/«Saliente») |
| Origen | from |
| Destino | to |
| Destino Label | destination_label |
| Duración (seg) | billed_sec |
| Coste (USD) | cost.total_usd |
| Motivo finalización | status (enum estable en vez de texto traducido) |
| Código estado | hangup_cause |
| Cuenta SIP | sip_account |
| Origen llamada | call_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:
{ "error": { "code": "invalid_date", "message": "…" } }| HTTP | code | Qué pasó | Qué hacer |
|---|---|---|---|
| 400 | invalid_date | Una fecha no es ISO-8601 | Usa 2026-07-01 o 2026-07-01T00:00:00Z |
| 400 | invalid_range | El rango supera 366 días | Trocea la consulta por meses |
| 400 | invalid_field | Un filtro tiene un valor no permitido | Revisa direction / status |
| 401 | unauthorized | Falta la clave, o es inválida, revocada o inactiva | Genera una nueva y sustitúyela |
| 403 | insufficient_scope | La clave no tiene permiso de lectura de llamadas | Pide una clave con calls:read (ver §9) |
| 403 | capability_required | El plan de la cuenta no incluye esta función | Habilítala en la cuenta. Una clave nueva no lo arregla |
| 429 | rate_limited | 60 peticiones/minuto agotadas | Espera lo que diga Retry-After y reintenta |
Los dos 403 no se arreglan en el mismo sitio, y confundirlos cuesta una tarde.
insufficient_scopelo arregla quien emite la clave: falta un permiso, se emite otra con él.capability_requiredlo 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/referencecon tu sesión del panel —logueado, con todos tus permisos— también puedes recibircapability_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):
- Crea la clave nueva en el panel. Las dos conviven; ambas funcionan.
- Despliega tu integración con la clave nueva en
AMAI_API_KEY. - Comprueba en el panel que la clave nueva tiene un «último uso» reciente y la vieja no.
- 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:readseguirá 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_scopede 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
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.