Enviar SMS por API — AMAI Voice
Manda un SMS con el remitente de tu cuenta desde cualquier proceso que sepa hacer un POST: un
cron, un demonio de systemd, un flujo de n8n o un agente de IA. Es el mismo envío que el del
panel —mismo proveedor, mismo tarifario, misma fila de auditoría—, solo que la credencial es una
clave de máquina en vez de una sesión de navegador.
- Base URL:
https://voice.amai.run - Endpoints:
POST /api/v1/sms/send·GET /api/v1/sms/history - Autenticación:
Authorization: Bearer amai_<key>— solo clave de API (ver §2) - Solo a móviles. Un fijo se rechaza, y eso incluye todo EE. UU. y Canadá (ver §5)
Si tu cuenta es de marca blanca y entras al panel por un dominio propio, ese mismo dominio sirve esta API: cambia el host y lo demás es idéntico.
1. Quickstart (30 segundos)
curl -sS -X POST https://voice.amai.run/api/v1/sms/send \
-H "Authorization: Bearer $AMAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"recipient": "+34600111222",
"body": "Aviso: a la impresora de recepcion le quedan ~200 hojas."
}'Respuesta:
{
"success": true,
"message_id": "3bf9e260-0b0f-41ae-92d0-b0440b226ac9",
"provider_message_id": "didww-8f21a0"
}message_id es la fila del histórico: con ella consultas después el estado y el coste
(§9). provider_message_id es el identificador que devolvió el operador; guárdalo en tus
registros, es lo que sirve para abrir una incidencia con el proveedor.
El
200no dice que el SMS llegara al teléfono. Dice que el operador lo aceptó. La diferencia importa y está explicada sin adornos en §7.
2. Autenticación
Cabecera en cada petición:
Authorization: Bearer amai_TU_API_KEY- La clave se guarda hasheada y está acotada a un solo tenant: nunca ve datos de otra cuenta.
- Revocable al instante. No caduca por tiempo.
- Se genera en el panel: Ajustes → API Keys (solo el propietario).
Aquí NO vale la cookie de sesión, y es a propósito
POST /api/v1/whatsapp/send acepta la cookie del panel como alternativa a la clave. Esta ruta no,
y no es una asimetría estética.
Una rama de sesión comprueba dos cosas: que hay un usuario autenticado y que el tenant tiene la
capacidad. No comprueba el permiso concreto de esa persona. Es decir: le concedería a cualquier
miembro de la cuenta todo lo que el plan permite, saltándose el plano de permisos por rol. En SMS
eso muerde de verdad — un miembro con rol viewer no tiene el permiso sms.send y el panel le
esconde el botón; un respaldo por cookie le daría por API exactamente lo que el panel le niega.
Para sesiones existe POST /api/app/sms/send, que sí pregunta por el permiso de la persona.
Esta superficie (/api/v1) es para credenciales de máquina, que es lo que un demonio puede
sostener sin que haya nadie delante.
3. La triple puerta
Para que un envío salga tienen que cumplirse tres condiciones independientes. Fallan con códigos distintos a propósito, porque cada una se arregla en un sitio distinto:
| # | Condición | Qué la concede | Si falla |
|---|---|---|---|
| 1 | La clave lleva el scope sms:send | Quien emitió la clave | 403 insufficient_scope |
| 2 | La cuenta tiene la capacidad send_sms | El plan del tenant / tu distribuidor | 403 capability_required |
| 3 | El canal de SMS está contratado y activo, con credenciales propias | AMAI, al dar de alta el canal | 400 channel_unavailable |
Las dos primeras son la puerta común de toda la API — está explicada a fondo en
la guía de inicio, y la diferencia entre los dos 403
en «Los dos 403».
La tercera es específica de SMS: la plataforma busca una configuración de proveedor habilitada
para tu cuenta. Sin ella no hay remitente, ni credenciales, ni tarifario, así que no hay envío
posible. Un canal existente pero desactivado da el mismo channel_unavailable que no tenerlo.
Sobre el scope, dicho sin adornos: la comprobación de scopes está construida pero no
activada globalmente. Hoy cualquier clave válida de una cuenta con el canal y la capacidad puede
enviar, tenga anotado sms:send o no, y por eso insufficient_scope no puede aparecerte
todavía. Empezará a aplicarse el día que se encienda. Marca ya sms:send en tus claves nuevas:
hoy no te protege, pero te ahorra la migración.
4. El cuerpo de la petición
{
"recipient": "+34600111222",
"body": "Texto del mensaje",
"contact_id": null,
"call_id": null
}| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
recipient | string | Sí | Destinatario en E.164. Tiene que ser un móvil (§5). |
body | string | Sí | Texto. Máximo 1530 caracteres (≈10 segmentos GSM de 153). |
contact_id | string (uuid) | No | Contacto de tu agenda al que asociar el envío. |
call_id | string (uuid) | No | Llamada a la que asociar el envío. |
Qué hacen de verdad contact_id y call_id: son atribución, no comportamiento. Se copian
tal cual a las columnas homónimas de la fila del histórico y no cambian ni el destinatario, ni el
remitente, ni el precio, ni la validación. Sirven para que después puedas responder «¿qué SMS le
mandamos a este contacto?» o «¿qué se envió a raíz de esta llamada?» — filtrando el histórico o
cruzándolo con tus propias tablas. Si no los necesitas, omítelos.
Dos detalles que ahorran depuración:
- Los campos que no son
stringse ignoran en silencio. Un"contact_id": 123(número, no cadena) se guarda comonull, sin error. Manda siempre cadenas. recipientybodyse recortan antes de validarse: unbodycon solo espacios cuenta como vacío y damissing_field.
El orden de las validaciones (decide qué error ves)
Las reglas se aplican en este orden, y no es decorativo: cuando incumples dos cosas a la vez, el código que recibes es el de la primera:
- La cuenta tiene canal de SMS activo →
channel_unavailable - Hay
recipienty haybody→missing_field - El
bodycabe →body_too_long - El destino es un móvil →
fixed_line/not_mobile - El país del destino está permitido para tu cuenta →
country_not_supported - El país del destino tiene tarifa en tu cuenta →
pricing_not_configured
Es decir: un mensaje de 2.000 caracteres a un número fijo se rechaza por largo, no por fijo.
El paso 6 es el único que se comprueba con el mensaje ya listo para salir, y aun así corta antes de llamar al operador: un SMS cuyo coste no se puede calcular no se envía. Ver §5 bis.
5. Solo móviles
Es la regla que más va a morderte, así que va explicada entera.
La plataforma clasifica el número contra la numeración mundial y se niega a enviar sin prueba explícita de que es un móvil:
| Lo que la numeración dice del número | Resultado |
|---|---|
| Es móvil | Sigue adelante |
| Es fijo | 400 fixed_line |
| Cualquier otra cosa (no clasificable, VoIP, «fijo o móvil», texto que no es un número) | 400 not_mobile |
No se puede forzar. No hay parámetro, cabecera ni ajuste que lo salte.
Por qué el rechazo por defecto, y no el intento
Un SMS a un fijo se cobra igual y no llega a ninguna parte. Cuando la numeración no puede afirmar que el número es móvil, la alternativa a rechazar es gastar dinero a ciegas y generar una queja. Por eso la ausencia de prueba se trata como una negativa, no como un «probemos».
El caso que te va a sorprender: EE. UU. y Canadá
Todo el rango +1 devuelve not_mobile. No es un fallo ni una laguna de nuestros datos: en
Norteamérica la portabilidad entre operadores fijos y móviles destruyó la distinción, y el prefijo
de un número ya no dice qué tipo de línea es. La numeración mundial devuelve «no clasificable» para
prácticamente todo el país, y nuestra regla lo rechaza.
La fiabilidad sí es alta en Europa (España, Francia, Alemania, Reino Unido ≈85-90 %).
Si necesitas cobertura de EE. UU. o Canadá, la respuesta correcta no es relajar la regla: es una consulta HLR contra el operador, que pregunta por el número concreto en vez de deducirlo del rango. Escríbenos si lo necesitas.
Y un caso raro, pero real: números por satélite
Un número de satélite (+870 de Inmarsat, +881 de las redes móviles globales) sí se clasifica
como móvil, pero no pertenece a ningún país, y el tarifario y los permisos de la cuenta se resuelven
por país. Esos números devuelven 400 invalid_number. Es el único caso en el que verás ese código.
5 bis. Sin tarifa para el país, no se envía
Un país puede estar permitido en tu cuenta y no tener precio. Son dos listas distintas, y nada obliga a que coincidan: basta con habilitar un país nuevo y olvidar su tarifa.
Antes, ese envío salía y el coste se quedaba a null. Ya no: si el país del destino no tiene
tarifa, el mensaje no se envía y recibes 400 pricing_not_configured. Se comprueba antes de
llamar al operador, así que no se gasta nada.
El intento sí queda registrado en el histórico con status: "failed", para que se vea desde
cuándo pasa y cuántos mensajes afectó. Lo arregla tu distribuidor añadiendo el país al tarifario;
reintentar sin eso da siempre lo mismo.
Por qué se cierra en seco en vez de enviar y avisar: el coste de un SMS solo vive en el histórico. Un envío sin coste registrado no baja ningún saldo, no dispara ninguna alarma y desaparece de la factura sin dejar rastro. Un cobro mal hecho se ve; un consumo no registrado no se ve nunca.
5 ter. Comprobar antes de enviar
Todo lo de §5 y §5 bis se puede preguntar por adelantado, sin enviar y sin gastar:
POST /api/v1/sms/checkLe pasas hasta 500 números y te dice, uno por uno, si se les podría mandar un SMS y por qué no cuando no. No envía nada, no cobra nada y no escribe nada en el histórico.
Para qué sirve: un SMS a un fijo se cobra y no lo recibe nadie. Con 5.000 contactos en un CSV, esto separa los buenos de los inservibles en 10 peticiones en vez de 5.000 envíos y 5.000 cargos.
curl -sS -X POST https://voice.amai.run/api/v1/sms/check \
-H "Authorization: Bearer $AMAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"recipients": ["+34600111222", "700 000 000", "912345678", "612345678"],
"default_country": "ES"
}'{
"checked": 4,
"sendable": 2,
"default_country_applied": "ES",
"results": [
{ "input": "+34600111222", "sendable": true, "e164": "+34600111222", "country": "ES",
"line_type": "mobile", "reason": null, "message": null },
{ "input": "700 000 000", "sendable": false, "e164": "+34700000000", "country": "ES",
"line_type": "personal_number", "reason": "not_mobile",
"message": "No se puede demostrar que sea un móvil…" },
{ "input": "912345678", "sendable": false, "e164": "+34912345678", "country": "ES",
"line_type": "fixed_line", "reason": "fixed_line",
"message": "Es un número fijo. Un SMS a un fijo se cobra y no lo recibe nadie…" },
{ "input": "612345678", "sendable": true, "e164": "+34612345678", "country": "ES",
"line_type": "mobile", "reason": null, "message": null }
]
}reason es el MISMO código que te habría devuelto POST /api/v1/sms/send para ese número, así
que puedes tratar el veredicto y el error del envío con la misma rama de código. e164 es el número
normalizado: guárdalo así. line_type te dice por qué, donde reason te dice qué haría el envío.
España: la tabla de prefijos, y las dos trampas
Éstas son las dos que cuestan dinero, y las dos parecen móviles:
| Prefijo | Qué es | ¿Admite SMS? |
|---|---|---|
6XXXXXXXX | Móvil | ✅ |
71X … 74X, 78X | Móvil | ✅ |
70XXXXXXX | Numeración personal — reencamina a otro número | ❌ TRAMPA: empieza por 7 y NO es móvil |
8XXXXXXXX | Fijo (81, 82, 83, 85, 86, 88) | ❌ TRAMPA: el 8 NO es móvil, es fijo |
800XXXXXX, 900XXXXXX | Gratuitos | ❌ |
901, 902 | Coste compartido | ❌ |
806, 807 | Tarificación adicional | ❌ |
9XXXXXXXX | Fijo geográfico | ❌ |
Todos son de nueve dígitos. Si vas a escribir tú la validación en tu lado, el 70 y el 8 son los
dos que casi todo el mundo clasifica mal: la regla de memoria «el 6 y el 7 son móviles, el 9 es
fijo» falla en los dos.
No hace falta que la escribas: esta tabla la mantiene la numeración mundial, no nosotros, y por eso el endpoint la responde también para cualquier otro país.
Formatos sucios
Espacios, guiones, paréntesis, 0034 y el +34 explícito se digieren solos:
"+34 600 111 222" "(+34) 600-111-222" "0034600111222" "34600111222"Un número nacional pelado (600111222) es ambiguo y necesita default_country. Si tu cuenta
tiene un único país habilitado se usa ése automáticamente, y default_country_applied te dice cuál
se aplicó. Un + explícito nunca se reescribe: +34… con default_country: "FR" sigue siendo
España.
La lista de exclusión (DNC) sí se comprueba
Quien pidió no ser contactado sale con motivo propio, suppressed, y no mezclado con los
demás. La distinción importa porque las acciones son opuestas:
| Motivo | Qué significa | Qué haces |
|---|---|---|
invalid_number | El dato está mal escrito | Corriges el teléfono |
fixed_line / not_mobile | El número no admite SMS | Pides un móvil, o descartas |
country_not_supported | Tu cuenta no tiene ese país | Pides que te lo habiliten |
pricing_not_configured | El país no tiene tarifa | Contactas con soporte |
suppressed | Esta persona pidió que no le escribieran | La quitas de la campaña |
suppressed no es un dato que arreglar. Es el único motivo de la tabla que habla de una
decisión de una persona, no de un fallo técnico.
Si la comprobación de exclusión no se puede hacer, la petición entera responde
503 recipient_suppression_unverifiable — no medio lote con veredicto: una respuesta a medias se
lee como «no suprimido», y ése es justo el error que no se puede permitir.
Lo que sendable: true NO promete
Significa: el número es válido, es móvil, su país está habilitado, tiene tarifa y hoy no está suprimido. No significa que el envío vaya a salir.
La supresión puede cambiar entre tu consulta y tu envío —alguien puede darse de baja en ese
intervalo— y el envío la vuelve a comprobar siempre, pudiendo devolver
403 recipient_suppressed. Este veredicto es una foto, no un permiso: no lo caches como si lo
fuera.
Detalles
- Tope: 500 números por petición. Trocear el lote no cambia ningún veredicto: la respuesta es determinista.
- Es
200aunque no sea enviable ninguno. Un lote con fallos es el caso normal, no un error. Los4xxquedan para lo que impide emitir veredicto:missing_field,too_many_recipients,invalid_default_country,channel_unavailable. - Misma puerta que el envío: scope
sms:sendy capacidadsend_sms. Si puedes enviar, puedes comprobar; si no puedes enviar, tampoco comprobar. - Gasta presupuesto de peticiones igual que cualquier otra llamada (§10), pero no gasta dinero: una petición con 500 números cuesta lo mismo que una con 1 — cero.
- Una entrada que no es un teléfono en absoluto (
"N/A", una celda vacía) devuelveinvalid_numberaquí, donde el envío diríanot_mobile. Es a propósito: limpiando un CSV, «arregla el dato» y «descarta el contacto» son acciones distintas.
6. Coste y facturación
Se cobra exactamente igual que un envío hecho desde el panel, porque por debajo es el mismo servicio: no hay una segunda ruta de cobro que pueda desalinearse de la primera.
- El precio sale del tarifario de tu cuenta para el país del destinatario, con tu recargo ya aplicado. Es tu precio, no nuestro coste.
- Se queda escrito en la fila del histórico, en
cost_usd. La respuesta del envío no lo lleva: para conocerlo, consultaGET /api/v1/sms/history(§9). - Medido en producción: $0,0308 por un SMS a un móvil español. El tuyo depende de tu tarifario y de tu recargo.
- Si tu tarifario no tiene precio para ese país, el SMS no sale:
400 pricing_not_configured(§5 bis). Nunca veráscost_usdanullen un envío constatus: "sent".
La fila del histórico es idéntica a la de un envío del panel salvo en la procedencia, que se distingue en las dos columnas que existen justo para eso: el envío queda marcado como hecho por API y con la clave que lo hizo. Así puedes saber después qué consumió cada integración.
7. Estado de entrega
Esto hay que decirlo claro, porque es la trampa más cara de esta API:
status: "sent"significa «el operador aceptó el mensaje y devolvió un identificador». NO significa «llegó al teléfono». Es la diferencia entre haber echado la carta al buzón y saber que el destinatario la tiene en la mano.
Los estados que la plataforma sabe escribir son:
status | Qué ocurrió de verdad |
|---|---|
sent | El operador aceptó el mensaje. Todavía no sabemos si llegó. |
delivered | El operador confirmó la entrega en el teléfono. delivered_at trae la hora. |
failed | No llegó: rechazo del operador, expiración, o fallo de encaminamiento. El motivo, en error. |
rejected | El operador rechazó el mensaje explícitamente. |
El acuse de entrega (DLR): la pieza ya está, falta encenderla en el proveedor.
La ruta que recibe el acuse existe y está desplegada. Lo que queda es un paso que solo puede dar
un humano en el panel del operador: pegar la URL de retorno en el troncal de salida de SMS y pedirle
a DIDWW que active los eventos DLR, que vienen apagados de fábrica. Hasta que ese interruptor
esté puesto, sigue viendo delivered_at a null — no porque no haya nada que lo escriba, sino
porque el operador todavía no está llamando.
Cómo programar contra esto hoy, sin tener que volver a tocar tu código después:
sentno es entrega y no lo será nunca. Sigue siendo la trampa cara de esta API.- Lee
delivered_at:nullsignifica «aún no confirmado», no «no entregado». Trátalo como un dato que puede llegar tarde —minutos u horas después del envío—, nunca como algo que estará disponible en la respuesta del envío. - Un
failedo unrejectedsí son información firme: el operador dijo que no. - Si tu caso de uso exige entrega confirmada (avisos críticos, segundos factores), consúltalo por el histórico algo después del envío en vez de esperarlo en la respuesta.
El día que el operador empiece a llamar, tu código no cambia de sitio: la misma fila que ya lees
pasa a delivered y delivered_at deja de ser null.
8. Errores
Todos los errores usan el mismo sobre:
{ "error": { "code": "<slug>", "message": "<texto>" } }Sin excepciones, y el 429 incluido. Las dos operaciones de esta guía —el envío y el
histórico— devuelven el mismo sobre en todos sus códigos, así que error.code se puede leer sin
comprobar antes de qué tipo es.
| HTTP | code | Cuándo salta |
|---|---|---|
| 400 | invalid_json | El cuerpo no es JSON válido. |
| 400 | channel_unavailable | Tu cuenta no tiene canal de SMS activo (o lo tiene desactivado). |
| 400 | missing_field | Falta recipient o falta body (o llegan vacíos, o no son cadenas). |
| 400 | body_too_long | El body pasa de 1530 caracteres. |
| 400 | fixed_line | El destinatario es un número fijo. |
| 400 | not_mobile | No se puede afirmar que sea móvil. Incluye todo +1 (EE. UU./Canadá) y cualquier cadena que no sea un número válido. |
| 400 | country_not_supported | Tu cuenta tiene una lista de países permitidos y el destino no está en ella. |
| 400 | invalid_number | Móvil por satélite (+870, +881): no pertenece a ningún país. |
| 400 | pricing_not_configured | El país del destino no tiene tarifa en tu cuenta. No se envió nada; queda fila failed en el histórico. |
| 401 | unauthorized | Falta la cabecera Authorization, o la clave no es válida. |
| 403 | insufficient_scope | La clave no lleva sms:send. Hoy no puede aparecer (§3). |
| 403 | capability_required | El plan de la cuenta no incluye send_sms. |
| 403 | recipient_suppressed | El destinatario está en tu lista de exclusión / DNC (o es un contacto con opt-out). Es una regla de contacto, no un fallo del payload: reintentar con el mismo número no lo arregla. Se levanta quitando el número de la exclusión. |
| 404 | not_found | La superficie de escritura pública está apagada. No es que la ruta no exista: es indistinguible a propósito. |
| 429 | rate_limited | Has pasado el presupuesto de peticiones (§10). |
| 502 | provider_error | El operador rechazó el envío. |
| 503 | recipient_suppression_unverifiable | No se pudo comprobar la lista de exclusión (lectura caída, fail-closed). Es transitorio: reintenta en unos segundos. |
El 502 deja rastro, y te lo devuelve
Un provider_error no es un envío que no ocurrió: la fila de auditoría queda escrita con el
fallo, y la respuesta trae su message_id fuera del sobre de error para que puedas cruzarla:
{
"error": { "code": "provider_error", "message": "El proveedor de SMS ha rechazado el envío." },
"message_id": "3bf9e260-0b0f-41ae-92d0-b0440b226ac9"
}El texto crudo del operador no se propaga —es texto de terceros y puede llevar detalle de la
cuenta o de la credencial—, pero sí queda en la fila. Consúltalo con GET /api/v1/sms/history
buscando ese id: el campo error de la fila trae el código y el mensaje del operador.
El 429 también, en las dos operaciones
{ "error": { "code": "rate_limited", "message": "Has superado el límite de peticiones. Reintenta en 42 s." } }Idéntico en POST /api/v1/sms/send y en GET /api/v1/sms/history. Un manejador que lea
error.code sirve para las dos.
Si tocas otras partes de
/api/v1, ten en cuenta que el sobre del429no está unificado en toda la API: algunas operaciones antiguas todavía responden{"error": "Too many requests…", "retryAfter": n}, conerrorcomo cadena. En esas, y solo en esas, hay que mirar el tipo antes de leererror.code. Lo que es fiable en todos los casos es el código HTTP y la cabeceraRetry-After.
9. Consultar el histórico
GET /api/v1/sms/history
Los SMS de tu cuenta, con su estado y su coste. Requiere el scope sms:read (y la misma
capacidad send_sms). A diferencia del envío, esta lectura sí acepta la sesión del panel además
de la clave — es lo que permite probarla desde la consola de /docs/reference.
| Parámetro | Descripción |
|---|---|
start_date | Extremo inicial, ISO-8601. Por defecto, hace 30 días. |
end_date | Extremo final, ISO-8601. Por defecto, ahora. Una fecha desnuda significa el día entero. |
purpose | Filtra por propósito (manual, notification, otp). Los envíos por API son manual. |
status | Filtra por estado (sent, failed, …). |
limit | 1 a 200. Por defecto 50. |
offset | Desplazamiento. Por defecto 0. |
Rango máximo por petición: 366 días. Pasarse devuelve 400 invalid_range.
curl -sS -H "Authorization: Bearer $AMAI_API_KEY" \
"https://voice.amai.run/api/v1/sms/history?start_date=2026-07-01&end_date=2026-07-31&limit=1"{
"data": [
{
"id": "3bf9e260-0b0f-41ae-92d0-b0440b226ac9",
"status": "sent",
"purpose": "manual",
"to": "+34600111222",
"to_type": "mobile",
"sender_id": "AMAI",
"body": "Aviso: a la impresora de recepcion le quedan ~200 hojas.",
"body_length": 55,
"provider_message_id": "didww-8f21a0",
"error": null,
"cost_usd": 0.0308,
"contact_id": null,
"call_id": null,
"created_at": "2026-07-30T09:12:04.318Z",
"sent_at": "2026-07-30T09:12:04.902Z"
}
],
"pagination": { "limit": 1, "offset": 0, "total": 12, "has_more": true }
}Notas sobre lo que devuelve:
cost_usdes tu precio, con tu recargo dentro. Viene redondeado a 4 decimales, y anullcuando el envío no llegó a salir o cuando tu tarifario no cubre ese país.bodysale entero —son 160 caracteres, no un documento— salvo en los SMS de OTP: ahí lo que hay guardado es un texto de sustitución, porque el cuerpo real llevaría el código en claro. Esta operación no puede filtrar un OTP ni queriendo: el dato no está en la fila.- No se publica el volcado crudo de la respuesta del operador.
sent_ates cuándo lo aceptó el operador. No es la hora de entrega (§7).
Paginar
OFFSET=0
while : ; do
RES=$(curl -sS -H "Authorization: Bearer $AMAI_API_KEY" \
"https://voice.amai.run/api/v1/sms/history?limit=200&offset=$OFFSET")
echo "$RES" | jq -c '.data[]'
[ "$(echo "$RES" | jq -r '.pagination.has_more')" = "true" ] || break
OFFSET=$((OFFSET + 200))
doneSube offset en incrementos de limit mientras pagination.has_more sea true. total es el
total del rango, no el de la página.
Errores propios de esta lectura: 400 invalid_date (una fecha que no es ISO-8601),
400 invalid_range (end_date anterior a start_date, o más de 366 días),
400 invalid_field (limit u offset fuera de rango), 401 unauthorized,
403 insufficient_scope, 403 capability_required, 429 rate_limited, 500 internal_error.
10. Límites de peticiones
Hay dos capas, y solo una es tu presupuesto:
- 60 peticiones por minuto y clave. Es el número que cuenta. Se carga a la clave, no a tu IP: el tráfico de otro cliente nunca te consume presupuesto, y quien rote su IP no se compra una cuota nueva.
- 600 peticiones por minuto y dirección IP, antes de mirar la credencial. No es tu límite: es un freno para que una avalancha anónima no llegue a la base de datos de claves. Si lo tocas, es que algo va mal en tu lado.
Ante un 429: respeta Retry-After (viene en segundos) y reintenta con espera creciente. No
reintentes en bucle cerrado.
11. Receta: aviso automático desde un demonio
El caso real: una impresora avisa de que le queda poco papel y quieres un SMS. El patrón sirve para cualquier alarma de máquina.
Las tres reglas que hacen que esto no dé la lata:
- Reintenta solo lo que puede cambiar:
429(conRetry-After) y502. Un400no mejora reintentando — el número seguirá siendo fijo dentro de 30 segundos. - Registra el
provider_message_id. Es lo que te pedirán si hay que reclamar al operador. - No trates el
200como entrega (§7). Si el aviso es crítico, ten un segundo canal.
Node.js
const API = "https://voice.amai.run/api/v1/sms";
const KEY = process.env.AMAI_API_KEY;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Devuelve { message_id, provider_message_id } o lanza. */
async function sendSms(recipient, body, { attempts = 3 } = {}) {
for (let i = 1; i <= attempts; i++) {
const res = await fetch(`${API}/send`, {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ recipient, body }),
});
if (res.ok) return res.json();
// 429: la cabecera manda. El cuerpo trae `error.code = "rate_limited"` (§8).
if (res.status === 429) {
const wait = Number(res.headers.get("Retry-After") ?? 5) * 1000;
console.warn(`[sms] 429, reintento ${i}/${attempts} en ${wait / 1000}s`);
await sleep(wait);
continue;
}
const data = await res.json().catch(() => ({}));
const code = data?.error?.code ?? `http_${res.status}`;
// 502: el operador rechazó. Hay fila de auditoría; su id viene fuera del sobre.
if (res.status === 502 && i < attempts) {
console.warn(`[sms] ${code} (fila ${data.message_id}), reintento ${i}/${attempts}`);
await sleep(2000 * i);
continue;
}
// 400 y 403 no se reintentan: la petición no va a mejorar sola.
throw new Error(`${code}: ${data?.error?.message ?? "sin detalle"}`);
}
throw new Error("sms: agotados los reintentos");
}
// Uso
try {
const r = await sendSms("+34600111222", "Impresora recepcion: quedan ~200 hojas.");
// provider_message_id = lo que te pedira el operador si hay que reclamar
console.log("[sms] aceptado", r.message_id, r.provider_message_id);
} catch (err) {
console.error("[sms] no enviado:", err.message);
}Python
import os, time, logging, requests
API = "https://voice.amai.run/api/v1/sms"
KEY = os.environ["AMAI_API_KEY"]
log = logging.getLogger("sms")
def send_sms(recipient: str, body: str, attempts: int = 3) -> dict:
"""Devuelve {message_id, provider_message_id} o lanza RuntimeError."""
for i in range(1, attempts + 1):
r = requests.post(
f"{API}/send",
headers={"Authorization": f"Bearer {KEY}"},
json={"recipient": recipient, "body": body},
timeout=20,
)
if r.status_code == 200:
return r.json()
# 429: la cabecera manda. El cuerpo trae `error.code = "rate_limited"` (§8).
if r.status_code == 429:
wait = int(r.headers.get("Retry-After", 5))
log.warning("429, reintento %s/%s en %ss", i, attempts, wait)
time.sleep(wait)
continue
data = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
code = (data.get("error") or {}).get("code") if isinstance(data.get("error"), dict) else None
code = code or f"http_{r.status_code}"
# 502: el operador rechazo. Hay fila de auditoria, y su id viene fuera del sobre.
if r.status_code == 502 and i < attempts:
log.warning("%s (fila %s), reintento %s/%s", code, data.get("message_id"), i, attempts)
time.sleep(2 * i)
continue
# 400 y 403 no se reintentan.
msg = (data.get("error") or {}).get("message") if isinstance(data.get("error"), dict) else ""
raise RuntimeError(f"{code}: {msg or 'sin detalle'}")
raise RuntimeError("sms: agotados los reintentos")
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
try:
res = send_sms("+34600111222", "Impresora recepcion: quedan ~200 hojas.")
# provider_message_id = lo que te pedira el operador si hay que reclamar
log.info("aceptado %s %s", res["message_id"], res.get("provider_message_id"))
except RuntimeError as err:
log.error("no enviado: %s", err)Comprobar después qué pasó
curl -sS -H "Authorization: Bearer $AMAI_API_KEY" \
"https://voice.amai.run/api/v1/sms/history?status=failed&limit=50" \
| jq '.data[] | {id, to, error, created_at}'Un cron diario con esta consulta te avisa de que el operador está rechazando tráfico antes de que lo descubra el cliente.
12. Bloque de contexto para tu agente de IA
Mandas SMS con AMAI Voice. Base: https://voice.amai.run
Auth: Authorization: Bearer <AMAI_API_KEY> (env var AMAI_API_KEY; SOLO clave, no cookie)
Enviar: POST /api/v1/sms/send
body: { "recipient":"+34600111222", "body":"texto", "contact_id":null, "call_id":null }
recipient y body obligatorios; body <= 1530 chars; contact_id/call_id son solo atribucion.
200 -> { success:true, message_id, provider_message_id }
Comprobar ANTES (no envia, no cobra, no escribe): POST /api/v1/sms/check
body: { "recipients":["+34600111222","700 000 000"], "default_country":"ES" }
1..500 numeros. default_country solo afecta a los numeros SIN prefijo; un + explicito nunca
se reescribe. Si la cuenta tiene un unico pais habilitado se usa ese (lo dice
default_country_applied en la respuesta).
200 -> { checked, sendable, default_country_applied,
results:[{ input, sendable, e164, country, line_type, reason, message }] }
Es 200 aunque no sea enviable ninguno. reason es el MISMO codigo que devolveria sms/send
para ese numero, asi que se tratan con la misma rama. line_type es la clasificacion cruda
(mobile | fixed_line | personal_number | toll_free | premium_rate | shared_cost | voip |
fixed_line_or_mobile | unknown) y dice POR QUE.
Mismo scope que el envio (sms:send) y misma capacidad (send_sms).
SI comprueba la lista de exclusion (DNC): quien pidio no ser contactado sale con motivo
propio suppressed. No es un dato que arreglar — se quita el contacto de la campana.
Aun asi sendable:true NO garantiza la entrega: la supresion puede cambiar entre la
consulta y el envio, y el envio la vuelve a comprobar siempre (403 recipient_suppressed).
400 invalid_json | missing_field | too_many_recipients | invalid_default_country |
channel_unavailable
503 recipient_suppression_unverifiable (falla el LOTE ENTERO, no numeros sueltos)
SOLO MOVILES. Un fijo da 400 fixed_line; lo no clasificable da 400 not_mobile, y eso incluye
TODO +1 (EE.UU. y Canada) por la portabilidad. No se puede forzar. Satelite (+870/+881) da
400 invalid_number.
ESPANA, LAS DOS TRAMPAS (nueve digitos, y las dos cuestan dinero porque parecen moviles):
6XXXXXXXX ................ MOVIL si
71X-74X, 78X ............. MOVIL si
70XXXXXXX ................ PERSONAL NO <- empieza por 7 y NO es movil (reencamina)
8XXXXXXXX ................ FIJO NO <- el 8 NO es movil, es fijo (81/82/83/85/86/88)
800XXXXXX / 900XXXXXX .... GRATUITO NO
901 / 902 ................ COMPARTIDO NO
806 / 807 ................ ADICIONAL NO
9XXXXXXXX ................ FIJO NO
No la codifiques tu: usa /api/v1/sms/check, que la resuelve para cualquier pais.
Orden de validacion (decide que error ves): canal -> campos -> longitud -> movil -> pais ->
tarifa del pais.
SIN TARIFA NO SE ENVIA. Si el pais del destino no tiene precio en el tarifario de la cuenta,
400 pricing_not_configured y no sale nada (se comprueba antes de llamar al operador). El
intento queda en el historico con status="failed". Reintentar no lo arregla: hay que anadir
el pais al tarifario.
Errores: sobre { error:{ code, message } }.
400 invalid_json | channel_unavailable | missing_field | body_too_long | fixed_line |
not_mobile | country_not_supported | invalid_number | pricing_not_configured
401 unauthorized · 403 insufficient_scope | capability_required · 404 not_found
429 rate_limited · 502 provider_error (trae message_id fuera del sobre; hay fila de auditoria)
SIN EXCEPCIONES: las dos operaciones de SMS usan este sobre en todos sus codigos, el 429
incluido. En OTRAS partes de /api/v1 hay operaciones antiguas cuyo 429 trae error como
CADENA; alli comprueba el tipo antes de leer error.code.
ENTREGA: status="sent" = el operador ACEPTO el mensaje, NO que llegara al telefono. Estados:
sent | delivered | failed | rejected. "delivered" + delivered_at los escribe el acuse (DLR) del
operador, que llega DESPUES del envio (minutos u horas): consultalo por el historico, nunca lo
esperes en la respuesta del envio. La ruta receptora ya existe; queda activar el DLR en el panel
del operador, asi que hoy delivered_at puede seguir a null. null = "aun sin confirmar", NO
"no entregado".
Coste: se cobra al tarifario de la cuenta con su recargo, igual que un envio del panel. La
respuesta del envio NO lleva el importe: se lee en cost_usd del historico. Un envio con
status="sent" nunca trae cost_usd nulo.
Historico: GET /api/v1/sms/history?start_date&end_date&purpose&status&limit(<=200)&offset
-> { data:[{id,status,purpose,to,to_type,sender_id,body,body_length,provider_message_id,
error,cost_usd,contact_id,call_id,created_at,sent_at}],
pagination:{limit,offset,total,has_more} }
Rango maximo 366 dias. Paginar: offset += limit mientras has_more.
Limites: 60 peticiones/minuto por clave (600/min por IP antes de autenticar). Ante 429 respeta
Retry-After. Reintenta solo 429 y 502; un 400 no mejora reintentando.
Aislamiento: la clave solo ve y solo gasta lo de su propio tenant.Soporte
info@amai.solutions · Panel: https://voice.amai.run/app · Referencia interactiva:
/docs/reference
Última actualización: 2026-07-30.