Cómo enviar mensajes de WhatsApp en lote con la API de Waspy
POST /api/v2/messages/batch encola hasta 100 mensajes. Cada ítem vuelve queued, skipped o failed. El scope es messages:write y la respuesta es 202.
Respuesta corta: para mandar hasta 100 WhatsApp en una sola llamada usás POST https://api.waspytech.com/api/v2/messages/batch, con Authorization: Bearer wspy_... y el scope messages:write. La respuesta es 202. Cada ítem vuelve por separado como queued, skipped o failed. Un destinatario inválido no tumba el resto.
Sirve para el cierre del día de una tienda: avisos de despacho con el código de Andreani, o el pedido de reseña a quienes ya recibieron el paquete. La referencia está en messages. Cómo no duplicar un envío suelto está en idempotencia y scopes.
Qué va en el body
| Campo | Obligatorio | Qué hace |
|---|---|---|
messages | Sí | De 1 a 100 ítems. Misma forma que POST /messages: to, type, el objeto del tipo (text, template, image…) y clientRef opcional |
defaultPhoneNumberId | No | UUID del canal, de GET /channels. Lo heredan los ítems que no traen phoneNumberId |
respectOptOut | No | Default true. Quien está en la lista de baja vuelve skipped con reason: "opted_out" |
defaultPhoneNumberId. No es phoneNumberId en la raíz. Por ítem, el campo sí es phoneNumberId y, si lo mandás, pisa el default.
to es E.164 (+54911...). type acepta text, image, video, audio, document, template, interactive o reaction. clientRef es un string de hasta 256 caracteres: tu número de pedido, no un id de Waspy.
Un texto libre, una foto o un audio exigen la ventana de 24 horas. Si está cerrada, ese ítem vuelve failed con SERVICE_WINDOW_EXPIRED. Para escribirle a alguien que no te habló hoy, el tipo es template y la plantilla tiene que estar aprobada. La regla está en la ventana de 24 horas.
Avisos de despacho
El nombre de la plantilla es el que aprobaste en el panel. aviso_despacho acá es un ejemplo: si en tu cuenta se llama distinto, usá ese nombre. Los parámetros tienen que coincidir con el cuerpo aprobado.
curl -sS -X POST https://api.waspytech.com/api/v2/messages/batch \
-H "Authorization: Bearer $WASPY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"defaultPhoneNumberId": "UUID-DEL-CANAL",
"respectOptOut": true,
"messages": [
{
"to": "+5491122334455",
"type": "template",
"template": {
"name": "aviso_despacho",
"language": { "code": "es_AR" },
"components": [
{
"type": "body",
"parameters": [
{ "type": "text", "text": "Sofía" },
{ "type": "text", "text": "360000012345678" }
]
}
]
},
"clientRef": "despacho_1847"
}
]
}'
respectOptOut: false solo tiene sentido en un aviso que la persona ya espera (el código de seguimiento de un pedido que pagó). Una promo no va con el opt-out apagado. El alta de la suscripción está en cómo pedir el opt-in.
Qué vuelve en el 202
{
"data": {
"accepted": 1,
"skipped": 0,
"failed": 0,
"items": [
{
"index": 0,
"clientRef": "despacho_1847",
"result": "queued",
"message": {
"id": "msg-uuid",
"status": "queued",
"clientRef": "despacho_1847",
"waMessageId": null
}
}
]
},
"meta": { "requestId": "..." }
}
result | Qué mirar | Qué pasó |
|---|---|---|
queued | message.id | Aceptado y encolado. waMessageId todavía es null |
skipped | reason | No se envió. Hoy el motivo es opted_out |
failed | error.code y error.message | Ese ítem no se encoló |
data.accepted, skipped y failed son los conteos. items[].index es la posición en el array que mandaste.
Los envíos del lote salen con prioridad de campaña: no le sacan el turno a un chat que el equipo está respondiendo en el inbox.
Errores de un ítem, sin tumbar la llamada
error.code | Cuándo |
|---|---|
MISSING_REQUIRED_FIELD | El ítem no tiene phoneNumberId y tampoco mandaste defaultPhoneNumberId |
INVALID_MESSAGE_TYPE | type no es uno de los ocho |
INVALID_PHONE | to no queda en E.164 |
INVALID_CHANNEL | Ese UUID no es un canal de tu cuenta |
CHANNEL_NOT_CONNECTED | El canal existe y no está activo |
SERVICE_WINDOW_EXPIRED | No es plantilla y la ventana de 24 horas está cerrada |
messages está vacío o pasa de 100, no hay 202: la respuesta es 400 VALIDATION_ERROR para toda la llamada.
La plantilla resena_maps (el ejemplo de la documentación) pasa por un filtro antes de encolarse. Si el chat muestra un reclamo, ese ítem vuelve failed con REVIEW_BLOCKED_BY_SENTIMENT y el mensaje no se guarda. Si no alcanzó el tiempo para leer el chat, el código es REVIEW_PENDING_TRANSCRIPTION: reintentá solo ese ítem. Una plantilla con otro nombre no entra a ese filtro. El caso de la reseña está en pedir reseña de Google por WhatsApp.
clientRef y el webhook
clientRef vuelve igual, carácter por carácter, en cada message.status_changed. El ciclo es queued → sent → delivered → read, o failed. En data.clientRef matcheás contra tu pedido. En data.newStatus ves en qué quedó. Si no mandaste clientRef, llega null. El recorrido del pedido del ERP hasta delivered, con waMessageId en null mientras está queued, está en clientRef para reconciliar estados.
Los eventos pueden llegar fuera de orden. Por cada clientRef quedate con el estado más avanzado (read por encima de delivered por encima de sent) y tratá failed como terminal. Si falló, data.errorCode trae el código de Meta (por ejemplo 131026).
Suscribir el webhook es POST /api/v2/webhooks. El paso a paso está en webhooks en Node y Python. Si preferís consultar, GET /api/v2/messages/:id pide el scope messages:read. El id es el message.id del 202.
Idempotency-Key no va en el lote
La idempotencia aplica a POST /messages. El handler del lote no lee ese header. Si se te corta la red después de un 202 y repetís el mismo JSON, los ítems que ya estaban queued se encolan de nuevo.
La forma de no duplicar es tuya: persistí clientRef + result antes de reintentar, y armá la tanda siguiente solo con los que no llegaron a queued. Para un solo aviso, con reintento automático del ERP, usá POST /messages y el header. Está explicado en idempotencia y scopes.
Rate limit: 120 por minuto, por request
La API permite 120 requests por minuto por cuenta, compartidas entre todas las claves. Un lote de 100 cuenta como una request, no como 100. Los headers de cada respuesta son X-RateLimit-Limit, X-RateLimit-Remaining y X-RateLimit-Reset. Si te pasás, es 429 RATE_LIMITED y el header Retry-After dice los segundos. El detalle está en rate limits.
Eso no es el límite de mensajería de Meta (usuarios distintos contactados fuera de la ventana, por portfolio). Ese otro techo está en límites y bloqueos. La entrega a WhatsApp, una vez encolada, sigue el ritmo de la cuenta: el 202 no la acelera.
Node: una tanda y el desglose
const res = await fetch('https://api.waspytech.com/api/v2/messages/batch', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.WASPY_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
defaultPhoneNumberId: process.env.WASPY_PHONE_NUMBER_ID,
respectOptOut: true,
messages: [
{
to: '+5491122334455',
type: 'template',
template: {
name: 'aviso_despacho',
language: { code: 'es_AR' },
components: [
{
type: 'body',
parameters: [
{ type: 'text', text: 'Sofía' },
{ type: 'text', text: '360000012345678' },
],
},
],
},
clientRef: 'despacho_1847',
},
],
}),
});
const body = await res.json();
if (res.status !== 202) {
throw new Error(body.error.code + ' ' + body.error.message);
}
for (const item of body.data.items) {
console.log(item.index, item.result, item.clientRef, item.error && item.error.code);
}
El mismo JSON entra en un nodo HTTP de n8n, Make o Zapier. Cómo armarlo está en conectar WhatsApp con n8n, Make y Zapier. Claude, Cursor o ChatGPT pueden armar el body por el conector MCP: el envío al cliente sigue siendo esta llamada, o una herramienta con messages:write que alguien del equipo confirma.
Qué cobra Meta, y en qué plan está la API
Encolar no es entregar. Meta cobra el mensaje entregado, según la categoría y el país de quien lo recibe. Para un número de Argentina, desde el 1 de octubre de 2026:
| Categoría | Tarifa |
|---|---|
| Marketing | ARS 89,56 (~US$0,0618) |
| Utilidad y autenticación | ARS 37,68 (~US$0,026) |
| Servicio (texto dentro de las 24 h) | 1.000 por número y por mes; después ~ARS 37,68 |
La clave se crea en Configuración → Claves de API. La API está en Scale (US$150 por mes) y Enterprise. Starter y Growth no crean claves. Diez días de prueba, sin tarjeta, y la API se habilita en Scale: Planes y la página de la API. Si todavía no tenés el número en la API oficial, abrí la cuenta.
