API de WhatsApp — AMAI Voice (referencia para desarrolladores)
Integra mensajería de WhatsApp en tu aplicación con la API REST de AMAI Voice. El token de Meta lo custodia y rota AMAI: tú nunca manejas un token de Meta. Multi-tenant: cada cliente tiene su clave y su conexión.
- Base URL:
https://voice.amai.run - Bajo el capó: Meta WhatsApp Cloud API (Graph API v25.0)
- Autenticación:
Authorization: Bearer amai_<key>
1. Quickstart (30 segundos)
curl -X POST https://voice.amai.run/api/v1/whatsapp/send \
-H "Authorization: Bearer amai_TU_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "to": "+34600111222", "type": "text", "text": "Hola desde AMAI Voice" }'Respuesta:
{ "success": true, "message_id": "<connectionId>:wamid.HBg..." }Si estás fuera de la ventana de 24h (el cliente no te ha escrito en las últimas 24h), el texto
libre se rechaza con 422 outside_24h_window — usa una plantilla (ver §6).
2. Autenticación
Header en cada petición:
Authorization: Bearer amai_TU_API_KEY- La clave se guarda hasheada (SHA-256) y está scoped a un solo tenant.
- Revocable al instante. No caduca por tiempo.
- Se genera en el panel: Ajustes → API Keys (solo el propietario). La misma clave sirve para voz y para WhatsApp.
- La cookie de sesión del navegador también se acepta, pero es para el dashboard, no para automatización servidor-a-servidor.
3. Enviar mensajes
POST /api/v1/whatsapp/send
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
to | string | Sí | Destinatario en E.164, ej. +34600111222 |
type | text | template | No | Default text |
text | string | Si type=text | Cuerpo del mensaje |
template_name | string | Si type=template | Nombre de una plantilla aprobada (ver §5) |
template_language | string | No | Default es |
template_components | array | No | Componentes de Meta para variables (ver abajo) |
connection_id | string | No | Conexión concreta; por defecto la activa del tenant |
Éxito (200): { "success": true, "message_id": "<connectionId>:wamid..." }
3.1. Texto (dentro de la ventana de 24h)
curl -X POST https://voice.amai.run/api/v1/whatsapp/send \
-H "Authorization: Bearer amai_TU_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "to": "+34600111222", "type": "text", "text": "Este es un mensaje de prueba" }'3.2. Plantilla con variables
template_components es un array de componentes de Meta. Las variables {{1}}, {{2}}… se
rellenan por posición en el componente body:
curl -X POST https://voice.amai.run/api/v1/whatsapp/send \
-H "Authorization: Bearer amai_TU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+34600111222",
"type": "template",
"template_name": "recordatorio_reunion",
"template_language": "es",
"template_components": [
{ "type": "body", "parameters": [
{ "type": "text", "text": "Juan" },
{ "type": "text", "text": "15 de julio a las 14:30" }
] }
]
}'3.3. Node.js
async function sendWhatsApp(to, text) {
const res = await fetch("https://voice.amai.run/api/v1/whatsapp/send", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.AMAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ to, type: "text", text }),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message}`);
return data; // { success: true, message_id }
}3.4. Python
import os, requests
def send_whatsapp(to, text=None, template_name=None):
payload = {"to": to, "type": "template" if template_name else "text"}
if template_name:
payload["template_name"] = template_name
payload["template_language"] = "es"
else:
payload["text"] = text
r = requests.post(
"https://voice.amai.run/api/v1/whatsapp/send",
headers={"Authorization": f"Bearer {os.environ['AMAI_API_KEY']}"},
json=payload,
)
data = r.json()
if r.status_code != 200:
raise RuntimeError(f"{data['error']['code']}: {data['error']['message']}")
return data4. Recibir mensajes
Dos vías: polling (simple) o webhook push (tiempo real, recomendado en producción).
4.1. Polling — GET /api/v1/whatsapp/messages
| Parámetro | Descripción |
|---|---|
contact | Filtra por número E.164 |
direction | inbound o outbound |
connection_id | Filtra por conexión |
limit | Máx. 100 (default 50) |
offset | Paginación (default 0) |
curl -H "Authorization: Bearer amai_TU_API_KEY" \
"https://voice.amai.run/api/v1/whatsapp/messages?direction=inbound&limit=20"Respuesta:
{
"messages": [
{
"id": "…uuid…",
"connection_id": "…uuid…",
"distributor_id": "…uuid…",
"wamid": "<connId>:wamid.HBg…",
"direction": "inbound",
"from_number": "+34600111222",
"to_number": "+34 912 91 87 50",
"contact_name": "Juan García",
"message_type": "text",
"body": "Hola, necesito ayuda",
"media_url": null,
"media_mime_type": null,
"template_name": null,
"template_language": null,
"status": "delivered",
"timestamp": "2026-07-11T14:23:45+00:00"
}
],
"total": 1,
"limit": 20,
"offset": 0
}status de los salientes evoluciona pending → sent → delivered → read (o failed). Los entrantes
llegan con delivered. Deduplica por id o wamid.
4.2. Webhook push (tiempo real)
Configura una URL de webhook para tu tenant (Ajustes → Webhooks) y suscríbete al evento
whatsapp.message.received. En cada mensaje entrante, AMAI hace POST a tu URL.
Headers que recibes:
| Header | Descripción |
|---|---|
X-AMAI-Event | Nombre del evento (whatsapp.message.received) |
X-AMAI-Delivery | ID único de entrega (dedupe) |
X-AMAI-Attempt | Nº de intento |
X-AMAI-Signature | sha256=<hmac> — HMAC-SHA256 del cuerpo crudo con tu webhook secret |
Payload:
{
"event": "whatsapp.message.received",
"timestamp": "2026-07-11T14:23:45.123Z",
"data": {
"connection_id": "…",
"message_id": "<connId>:wamid…",
"wamid": "wamid…",
"direction": "inbound",
"from_number": "+34600111222",
"to_number": "+34 912 91 87 50",
"contact_name": "Juan García",
"message_type": "text",
"body": "Hola, necesito ayuda",
"media_mime_type": null,
"timestamp": "2026-07-11T14:23:45+00:00"
}
}Verificar la firma (obligatorio) — Node.js:
import crypto from "node:crypto";
// req.body debe ser el buffer CRUDO (express.raw), no el objeto parseado.
function verify(rawBody, header, secret) {
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return header.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}Reintentos: si tu endpoint no responde 2xx, AMAI reintenta hasta 3 veces con backoff
1s → 5s → 30s. Un 4xx corta los reintentos. Responde 2xx rápido (< 30s) y procesa en segundo
plano.
5. Plantillas
GET /api/v1/whatsapp/templates
Lista las plantillas del tenant (útil para saber qué template_name usar). Filtro opcional
?status=APPROVED.
curl -H "Authorization: Bearer amai_TU_API_KEY" \
"https://voice.amai.run/api/v1/whatsapp/templates?status=APPROVED"Respuesta:
{
"templates": [
{
"name": "recordatorio_reunion",
"status": "APPROVED",
"category": "UTILITY",
"language": "es",
"variables": 2,
"body_text": "Hola {{1}}, te recordamos tu reunion con AMAI programada para {{2}}."
}
]
}variables es el número de variables {{n}} del cuerpo (para saber cuántos parameters
mandar en template_components). Las plantillas se crean y aprueban en Meta WhatsApp Manager
(business.facebook.com/wa/manage), no en AMAI; una vez aprobadas, aparecen aquí.
6. La ventana de 24 horas
Regla de Meta (no de AMAI), clave para tu integración:
- Texto libre: solo dentro de las 24h posteriores al último mensaje entrante del cliente.
- Fuera de esa ventana: plantilla aprobada obligatoria.
- Las plantillas no tienen restricción temporal.
La violación de la ventana puede aparecer de dos formas (Meta decide en cada caso), y una integración robusta comprueba las dos:
- En el envío — Meta rechaza y recibes
422:json{ "error": { "code": "outside_24h_window", "message": "…usa una plantilla…", "meta_code": 131047 } } - En la entrega — Meta acepta el envío (
200+message_id) pero el mensaje no se entrega: sustatuspasa afailed(lo ves enGET /api/v1/whatsapp/messageso por el webhook de estado).
Por eso: no basta con mirar el 200 del envío; para texto libre fuera de ventana, confirma también
que el status no acabe en failed. Con plantilla no hay este problema.
Patrón de fallback (usa plantilla ante cualquiera de las dos señales):
res = send_whatsapp(to, text="Gracias por tu mensaje")
if res is None: ... # (según tu wrapper)
# o, capturando el 422:
# si error.code == "outside_24h_window": send_whatsapp(to, template_name="bienvenida_cliente")6 ter. Comprobar antes de enviar
POST /api/v1/whatsapp/checkVerifica hasta 500 números de una vez, sin enviar nada y sin coste. Responde tres cosas por número: si se le puede escribir, por qué no si no, y —la que más importa— si hace falta plantilla o se admite texto libre.
curl -sS -X POST https://voice.amai.run/api/v1/whatsapp/check \
-H "Authorization: Bearer amai_TU_CLAVE" \
-H "Content-Type: application/json" \
-d '{"recipients":["+34612345678","+34912345678","612345678"],"default_country":"ES"}'{
"checked": 3,
"contactable": 2,
"results": [
{
"input": "+34612345678",
"contactable": true,
"line_type": "mobile",
"whatsapp_registered": "unknown",
"send_mode": "template_required",
"window_expires_at": null,
"reason": null
},
{
"input": "+34912345678",
"contactable": false,
"line_type": "fixed_line",
"whatsapp_registered": "unknown",
"send_mode": null,
"reason": "fixed_line",
"message": "Es un número fijo. WhatsApp vive en numeración móvil..."
}
]
}Lo que NO te dice, y conviene que lo sepas antes de construir sobre esto
No dice si el número tiene WhatsApp. No es una carencia nuestra: la Cloud API
de Meta no expone ninguna forma de averiguarlo sin mandar un mensaje. El
endpoint que lo hacía (/contacts) era de la API on-premise y está retirado. La
única señal real llega después de enviar, como error del proveedor
(131026), y para entonces ya has pagado el envío.
Por eso el campo se llama contactable y no has_whatsapp, y por eso cada
resultado trae whatsapp_registered: "unknown" siempre. Está en la respuesta
a propósito: si no apareciera, cada integración lo supondría a su manera, y quien
supusiera «si no dice nada, es que sí» acabaría montando una campaña sobre arena.
send_mode: la respuesta que ahorra dinero
| valor | qué significa |
|---|---|
free_form | Su ventana de 24 h está abierta: puedes mandar texto libre |
template_required | Ventana cerrada o inexistente: Meta sólo aceptará plantilla aprobada |
null | No es contactable, no hay modo que ofrecer |
Es la diferencia entre un envío que sale y un 422 outside_24h_window, y lo
sabes antes de gastar el intento.
Los cinco motivos, y qué hacer con cada uno
reason | qué hacer |
|---|---|
invalid_number | Arreglar el dato. Si viene sin prefijo, manda default_country |
fixed_line | Pedir un móvil. WhatsApp no llega a un fijo y la plantilla se cobra igual |
not_mobile | Revisar el dato: numeración personal, gratuita, de tarificación adicional o VoIP |
suppressed | No se arregla. Pidió no ser contactado: quítalo de la campaña |
no_connection | Conectar un número en Integraciones → WhatsApp |
suppressed tiene código propio a propósito: «este número no existe» y «esta
persona dijo que no» exigen acciones opuestas. La primera se corrige; la
segunda no, y tratar de corregirla es la infracción.
No cuesta nada, y no crece con el lote
Cinco consultas en total, sea el lote de 1 número o de 500. No toca el historial,
no llama a Meta y no mueve saldo. Un checked: 500 cuesta lo mismo que un
checked: 1: cero.
Necesita el scope whatsapp:send —el mismo que el enviador—, no el de
lectura. Un verificador más abierto que el envío sería una fuga: con el scope de
lectura, mucho más repartido, una clave podría enumerar qué números han hablado
con tu cuenta en las últimas 24 horas.
7. Errores
Todos los errores del endpoint de envío usan el mismo envelope:
{ "error": { "code": "<slug>", "message": "<texto>", "meta_code": <número|null> } }code | HTTP | Significado | Acción |
|---|---|---|---|
invalid_json | 400 | El body no es JSON válido | Revisa el payload |
missing_to | 400 | Falta to | Añade el destinatario E.164 |
missing_text | 400 | type=text sin text | Añade el cuerpo |
missing_template_name | 400 | type=template sin template_name | Añade la plantilla |
no_active_connection | 400 | El tenant no tiene WhatsApp conectado | Conecta el número en el panel |
unauthorized | 401 | Clave inválida | Revisa/rota la clave |
outside_24h_window | 422 | Fuera de la ventana de 24h | Envía una plantilla (meta_code 131047) |
rate_limited | 429 | Demasiadas peticiones | Reintenta con backoff |
meta_error | 502 | Error de Meta (upstream) | meta_code indica el código de Meta; reintenta |
internal_error | 500 | Error interno | Reintenta / contacta soporte |
8. Límites y buenas prácticas
- Rate limit: ~60 peticiones/minuto. Un
429incluye cuándo reintentar. - Tier de mensajería (Meta): p. ej.
TIER_250= 250 conversaciones nuevas/día; escala con buen uso y buena calidad. - Calidad:
GREEN/YELLOW/REDsegún bloqueos y reportes. ManténGREEN: envía solo a quien espera el mensaje. - Opt-in: WhatsApp exige consentimiento del destinatario. No envíes a listas frías.
- Buenas prácticas: dedupe por
message_id/wamid; reintentos con backoff exponencial; valida E.164 antes de enviar; verifica la firma del webhook siempre.
9. Receta: bot de WhatsApp (Python, webhook)
import os, json, hmac, hashlib
from flask import Flask, request
import requests
app = Flask(__name__)
API = "https://voice.amai.run/api/v1/whatsapp"
KEY = os.environ["AMAI_API_KEY"]
SECRET = os.environ["AMAI_WEBHOOK_SECRET"]
seen = set()
def verify(raw, sig):
exp = "sha256=" + hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest()
return sig and hmac.compare_digest(sig, exp)
def send(to, text=None, template=None):
p = {"to": to, "type": "template" if template else "text"}
if template: p["template_name"] = template; p["template_language"] = "es"
else: p["text"] = text
return requests.post(f"{API}/send", headers={"Authorization": f"Bearer {KEY}"}, json=p).json()
@app.post("/webhook/whatsapp")
def hook():
raw = request.get_data()
if not verify(raw, request.headers.get("X-AMAI-Signature")):
return {"error": "bad signature"}, 401
dl = request.headers.get("X-AMAI-Delivery")
if dl in seen: return {"ok": True}, 200
seen.add(dl)
p = json.loads(raw)
if p.get("event") == "whatsapp.message.received":
d = p["data"]
txt = (d.get("body") or "").lower()
reply = "En que puedo ayudarte?" if "hola" in txt else "Gracias por tu mensaje."
r = send(d["from_number"], text=reply)
if r.get("error", {}).get("code") == "outside_24h_window":
send(d["from_number"], template="bienvenida_cliente")
return {"ok": True}, 20010. Receta: n8n (flows.amai.run)
[Webhook node] <- recibe whatsapp.message.received
|
[Code] verifica X-AMAI-Signature (HMAC-SHA256 sobre el body crudo)
|
[IF] data.direction == "inbound"
|
[HTTP Request] POST https://voice.amai.run/api/v1/whatsapp/send
headers: Authorization: Bearer amai_TU_API_KEY
body: { "to": "{{ $json.data.from_number }}", "type": "text", "text": "..." }Fuera de la ventana de 24h, cambia el último paso a type=template con una plantilla aprobada.
11. Bloque de contexto para tu agente de IA
Automatizas WhatsApp con AMAI Voice. Base: https://voice.amai.run
Auth: Authorization: Bearer <AMAI_API_KEY> (env var AMAI_API_KEY; no hay token de Meta)
Enviar: POST /api/v1/whatsapp/send
texto: { "to":"+34...", "text":"..." }
plantilla: { "to":"+34...", "type":"template", "template_name":"...", "template_language":"es",
"template_components":[{"type":"body","parameters":[{"type":"text","text":"..."}]}] }
Recibir: GET /api/v1/whatsapp/messages?direction=inbound&limit=20 (dedupe por id)
Plantillas: GET /api/v1/whatsapp/templates (usa los name aprobados)
Push: evento whatsapp.message.received con firma HMAC en X-AMAI-Signature (verifícala).
Errores: envelope { error:{ code, message, meta_code } }. 422 code=outside_24h_window => manda plantilla.
Regla 24h: texto libre solo dentro de 24h del ultimo inbound del cliente; fuera, plantilla.
Limites: ~60 req/min; enviar solo a quien dio opt-in; la clave solo ve tu tenant.12. Roadmap (próximo)
- Media (
typeimage/audio/video/document) y mensajes interactivos (botones, listas). - Serialización por conversación con
409si hay un mensaje en vuelo (evita desorden). - Formato de webhook: opción de payload Meta-crudo además del normalizado.
- SANDBOX / LIVE por número (test sin coste).
- SDK TypeScript que espeja Meta 1:1.
Soporte
info@amai.solutions · Panel: https://voice.amai.run/app
Última actualización: 2026-07-11.