Docs/Guía de WhatsApp

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)

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

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

code
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

CampoTipoRequeridoDescripción
tostringDestinatario en E.164, ej. +34600111222
typetext | templateNoDefault text
textstringSi type=textCuerpo del mensaje
template_namestringSi type=templateNombre de una plantilla aprobada (ver §5)
template_languagestringNoDefault es
template_componentsarrayNoComponentes de Meta para variables (ver abajo)
connection_idstringNoConexió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)

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

bash
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

javascript
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

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 data

4. 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ámetroDescripción
contactFiltra por número E.164
directioninbound o outbound
connection_idFiltra por conexión
limitMáx. 100 (default 50)
offsetPaginación (default 0)
bash
curl -H "Authorization: Bearer amai_TU_API_KEY" \
  "https://voice.amai.run/api/v1/whatsapp/messages?direction=inbound&limit=20"

Respuesta:

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

HeaderDescripción
X-AMAI-EventNombre del evento (whatsapp.message.received)
X-AMAI-DeliveryID único de entrega (dedupe)
X-AMAI-AttemptNº de intento
X-AMAI-Signaturesha256=<hmac> — HMAC-SHA256 del cuerpo crudo con tu webhook secret

Payload:

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

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

bash
curl -H "Authorization: Bearer amai_TU_API_KEY" \
  "https://voice.amai.run/api/v1/whatsapp/templates?status=APPROVED"

Respuesta:

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

  1. En el envío — Meta rechaza y recibes 422:
    json
    { "error": { "code": "outside_24h_window", "message": "…usa una plantilla…", "meta_code": 131047 } }
  2. En la entrega — Meta acepta el envío (200 + message_id) pero el mensaje no se entrega: su status pasa a failed (lo ves en GET /api/v1/whatsapp/messages o 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):

python
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

code
POST /api/v1/whatsapp/check

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

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

valorqué significa
free_formSu ventana de 24 h está abierta: puedes mandar texto libre
template_requiredVentana cerrada o inexistente: Meta sólo aceptará plantilla aprobada
nullNo 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

reasonqué hacer
invalid_numberArreglar el dato. Si viene sin prefijo, manda default_country
fixed_linePedir un móvil. WhatsApp no llega a un fijo y la plantilla se cobra igual
not_mobileRevisar el dato: numeración personal, gratuita, de tarificación adicional o VoIP
suppressedNo se arregla. Pidió no ser contactado: quítalo de la campaña
no_connectionConectar 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:

json
{ "error": { "code": "<slug>", "message": "<texto>", "meta_code": <número|null> } }
codeHTTPSignificadoAcción
invalid_json400El body no es JSON válidoRevisa el payload
missing_to400Falta toAñade el destinatario E.164
missing_text400type=text sin textAñade el cuerpo
missing_template_name400type=template sin template_nameAñade la plantilla
no_active_connection400El tenant no tiene WhatsApp conectadoConecta el número en el panel
unauthorized401Clave inválidaRevisa/rota la clave
outside_24h_window422Fuera de la ventana de 24hEnvía una plantilla (meta_code 131047)
rate_limited429Demasiadas peticionesReintenta con backoff
meta_error502Error de Meta (upstream)meta_code indica el código de Meta; reintenta
internal_error500Error internoReintenta / contacta soporte

8. Límites y buenas prácticas

  • Rate limit: ~60 peticiones/minuto. Un 429 incluye 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 / RED según bloqueos y reportes. Mantén GREEN: 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)

python
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}, 200

10. Receta: n8n (flows.amai.run)

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

code
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 (type image/audio/video/document) y mensajes interactivos (botones, listas).
  • Serialización por conversación con 409 si 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.