Rate limits de la API de Waspy: 120 req/min por cuenta, 429 y reintentos ante 503
La API v2 permite 120 requests por minuto por cuenta, compartidas entre todas las API keys. Un 429 trae Retry-After. Un 503 de Cloud Run puede venir en HTML: se reintenta con Idempotency-Key.
Respuesta corta: la API pública (/api/v2) permite 120 requests por minuto por cuenta. La clave del limitador es v2: más el id de la cuenta, así que todas las API keys de esa cuenta comparten el cupo. No es por canal. Cada respuesta trae X-RateLimit-Limit, X-RateLimit-Remaining y X-RateLimit-Reset. Al pasarte, es 429 RATE_LIMITED y Retry-After.
La página que coincide con el handler es rate limits. Limitaciones dice “120 req/min por API key” y que, para cuotas independientes, hay que tener keys separadas. El handler no parte el cupo por clave: dos keys de la misma cuenta suman al mismo contador. Donde las dos páginas no dicen lo mismo, manda el código.
La API está en Scale y Enterprise: Planes y la API.
Headers y el 429
| Header | Qué es |
|---|---|
X-RateLimit-Limit | 120 |
X-RateLimit-Remaining | Cuántos pedidos quedan en esta ventana |
X-RateLimit-Reset | Segundos hasta que se renueva |
Retry-After | En el 429, segundos hasta poder reintentar |
HTTP/1.1 429 Too Many Requests
Retry-After: 12
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 12
{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Retry after 12 seconds",
"status": 429
},
"meta": { "requestId": "..." }
}
El mensaje sale del servidor con los segundos que faltan. Esperá Retry-After. Un reintento inmediato vuelve a contar y alarga el bloqueo.
Hay otros topes en el mismo proceso (un límite global por IP y otro, más alto, para el panel logueado). El que ve una integración con API key en /api/v2 es este de 120 por cuenta.
503 HTML y 5xx JSON
Waspy corre en Cloud Run. En un deploy, en un arranque en frío o si se corta un momento la base, Cloud Run puede responder 503 con cuerpo text/html, sin el sobre de la API. Desde abril de 2026, un error 5xx de la aplicación se serializa siempre así:
{ "error": { "code": "INTERNAL_ERROR", "message": "Internal server error" } }
Un código interno (un SQLSTATE, por ejemplo) no sale al cliente. Para el reintento, los dos casos se tratan igual: son transitorios. Lo que cambia es el parseo. Si el Content-Type es text/html, no busques error.code.
Reintento, con la misma clave
En POST /api/v2/messages mandá Idempotency-Key (hasta 256 caracteres). Si el primer intento llegó a encolar y la respuesta se perdió, la repetición dentro de las 24 horas devuelve esa respuesta y el header Idempotency-Replayed: true. No sale otro mensaje. Si dos pedidos iguales corren a la vez, el segundo recibe 409 IDEMPOTENCY_CONFLICT: hay un lock de 30 segundos. Esperá y reintentá la misma clave.
Este wrapper sigue el de limitaciones, con el 429 respetando Retry-After y sin reintentar un 4xx que no sea 429 (una ventana cerrada o un teléfono inválido no se arregla esperando):
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
export async function sendWithRetry(body: unknown, idempotencyKey: string) {
const delays = [1000, 2000, 4000, 8000];
for (const delay of [...delays, null] as Array<number | null>) {
const res = await fetch('https://api.waspytech.com/api/v2/messages', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.WASPY_API_KEY,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(body),
});
if (res.ok) return res.json();
if (!RETRYABLE.has(res.status)) return res.json();
if (delay === null) throw new Error('reintentos agotados, status ' + res.status);
const retryAfterSec = parseInt(res.headers.get('Retry-After') || '0', 10);
const wait = retryAfterSec > 0 ? retryAfterSec * 1000 : delay;
const jitter = Math.floor(Math.random() * 250);
await new Promise((r) => setTimeout(r, wait + jitter));
}
throw new Error('reintentos agotados');
}
Después de 4 o 5 fallos seguidos, alertá: ya no es un 503 de paso. Un 403 SERVICE_WINDOW_EXPIRED o un 400 INVALID_PHONE vuelven en el json() del 4xx y no entran al sleep.
El lote y el chat en vivo
POST /api/v2/messages/batch encola hasta 100 mensajes y responde 202. Cuenta como una request de las 120. Los ítems salen con prioridad de campaña, para no pisar el chat en vivo. El POST de a uno (/messages) encola con prioridad de chat. El contrato del lote está en mensajes en lote.
El batch no lee Idempotency-Key. Reenviar el mismo array vuelve a encolar lo que ya estaba queued. Guardá clientRef y no repitas los aceptados. Si cada aviso tiene que ser idempotente solo, mandalo con POST /messages y una clave por operación. Cómo se reconcilia el estado está en clientRef.
Meta, aparte, limita el ritmo de salida del número. Limitaciones lo anota en unos 80 mensajes por segundo por número: es de Meta, no el cupo de 120. Un lote de 100 no son 100 requests a Waspy, y tampoco salen los 100 en el mismo instante. Para una campaña grande, el panel escala el envío. La API de a uno es el chat. El tope de personas distintas fuera de la ventana (250, 2.000, 10.000…) es otro medidor: está en subir el límite antes del CyberMonday.
Un botón o una lista de un chat abierto va por POST /messages con type: "interactive", en mensajes interactivos. Ese POST también entra en los 120.
Diez días de prueba, sin tarjeta, en el registro.
