Docs/Guía de SMS

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)

bash
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:

json
{
  "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 200 no 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:

code
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).

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ónQué la concedeSi falla
1La clave lleva el scope sms:sendQuien emitió la clave403 insufficient_scope
2La cuenta tiene la capacidad send_smsEl plan del tenant / tu distribuidor403 capability_required
3El canal de SMS está contratado y activo, con credenciales propiasAMAI, al dar de alta el canal400 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

json
{
  "recipient": "+34600111222",
  "body": "Texto del mensaje",
  "contact_id": null,
  "call_id": null
}
CampoTipoRequeridoDescripción
recipientstringDestinatario en E.164. Tiene que ser un móvil (§5).
bodystringTexto. Máximo 1530 caracteres (≈10 segmentos GSM de 153).
contact_idstring (uuid)NoContacto de tu agenda al que asociar el envío.
call_idstring (uuid)NoLlamada 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 string se ignoran en silencio. Un "contact_id": 123 (número, no cadena) se guarda como null, sin error. Manda siempre cadenas.
  • recipient y body se recortan antes de validarse: un body con solo espacios cuenta como vacío y da missing_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:

  1. La cuenta tiene canal de SMS activo → channel_unavailable
  2. Hay recipient y hay bodymissing_field
  3. El body cabe → body_too_long
  4. El destino es un móvilfixed_line / not_mobile
  5. El país del destino está permitido para tu cuenta → country_not_supported
  6. 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úmeroResultado
Es móvilSigue adelante
Es fijo400 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) 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 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:

code
POST /api/v1/sms/check

Le 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.

bash
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"
      }'
json
{
  "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:

PrefijoQué es¿Admite SMS?
6XXXXXXXXMóvil
71X74X, 78XMóvil
70XXXXXXXNumeración personal — reencamina a otro númeroTRAMPA: empieza por 7 y NO es móvil
8XXXXXXXXFijo (81, 82, 83, 85, 86, 88)TRAMPA: el 8 NO es móvil, es fijo
800XXXXXX, 900XXXXXXGratuitos
901, 902Coste compartido
806, 807Tarificación adicional
9XXXXXXXXFijo 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:

code
"+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:

MotivoQué significaQué haces
invalid_numberEl dato está mal escritoCorriges el teléfono
fixed_line / not_mobileEl número no admite SMSPides un móvil, o descartas
country_not_supportedTu cuenta no tiene ese paísPides que te lo habiliten
pricing_not_configuredEl país no tiene tarifaContactas con soporte
suppressedEsta persona pidió que no le escribieranLa 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 200 aunque no sea enviable ninguno. Un lote con fallos es el caso normal, no un error. Los 4xx quedan para lo que impide emitir veredicto: missing_field, too_many_recipients, invalid_default_country, channel_unavailable.
  • Misma puerta que el envío: scope sms:send y capacidad send_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) devuelve invalid_number aquí, donde el envío diría not_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, consulta GET /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ás cost_usd a null en un envío con status: "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:

statusQué ocurrió de verdad
sentEl operador aceptó el mensaje. Todavía no sabemos si llegó.
deliveredEl operador confirmó la entrega en el teléfono. delivered_at trae la hora.
failedNo llegó: rechazo del operador, expiración, o fallo de encaminamiento. El motivo, en error.
rejectedEl 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:

  • sent no es entrega y no lo será nunca. Sigue siendo la trampa cara de esta API.
  • Lee delivered_at: null significa «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 failed o un rejected 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:

json
{ "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.

HTTPcodeCuándo salta
400invalid_jsonEl cuerpo no es JSON válido.
400channel_unavailableTu cuenta no tiene canal de SMS activo (o lo tiene desactivado).
400missing_fieldFalta recipient o falta body (o llegan vacíos, o no son cadenas).
400body_too_longEl body pasa de 1530 caracteres.
400fixed_lineEl destinatario es un número fijo.
400not_mobileNo se puede afirmar que sea móvil. Incluye todo +1 (EE. UU./Canadá) y cualquier cadena que no sea un número válido.
400country_not_supportedTu cuenta tiene una lista de países permitidos y el destino no está en ella.
400invalid_numberMóvil por satélite (+870, +881): no pertenece a ningún país.
400pricing_not_configuredEl país del destino no tiene tarifa en tu cuenta. No se envió nada; queda fila failed en el histórico.
401unauthorizedFalta la cabecera Authorization, o la clave no es válida.
403insufficient_scopeLa clave no lleva sms:send. Hoy no puede aparecer (§3).
403capability_requiredEl plan de la cuenta no incluye send_sms.
403recipient_suppressedEl 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.
404not_foundLa superficie de escritura pública está apagada. No es que la ruta no exista: es indistinguible a propósito.
429rate_limitedHas pasado el presupuesto de peticiones (§10).
502provider_errorEl operador rechazó el envío.
503recipient_suppression_unverifiableNo 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:

json
{
  "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

json
{ "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 del 429 no está unificado en toda la API: algunas operaciones antiguas todavía responden {"error": "Too many requests…", "retryAfter": n}, con error como cadena. En esas, y solo en esas, hay que mirar el tipo antes de leer error.code. Lo que es fiable en todos los casos es el código HTTP y la cabecera Retry-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 acepta la sesión del panel además de la clave — es lo que permite probarla desde la consola de /docs/reference.

ParámetroDescripción
start_dateExtremo inicial, ISO-8601. Por defecto, hace 30 días.
end_dateExtremo final, ISO-8601. Por defecto, ahora. Una fecha desnuda significa el día entero.
purposeFiltra por propósito (manual, notification, otp). Los envíos por API son manual.
statusFiltra por estado (sent, failed, …).
limit1 a 200. Por defecto 50.
offsetDesplazamiento. Por defecto 0.

Rango máximo por petición: 366 días. Pasarse devuelve 400 invalid_range.

bash
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"
json
{
  "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_usd es tu precio, con tu recargo dentro. Viene redondeado a 4 decimales, y a null cuando el envío no llegó a salir o cuando tu tarifario no cubre ese país.
  • body sale 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_at es cuándo lo aceptó el operador. No es la hora de entrega (§7).

Paginar

bash
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))
done

Sube 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:

  1. Reintenta solo lo que puede cambiar: 429 (con Retry-After) y 502. Un 400 no mejora reintentando — el número seguirá siendo fijo dentro de 30 segundos.
  2. Registra el provider_message_id. Es lo que te pedirán si hay que reclamar al operador.
  3. No trates el 200 como entrega (§7). Si el aviso es crítico, ten un segundo canal.

Node.js

javascript
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

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ó

bash
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

code
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.