Códigos de error de la API de Waspy: el sobre, el handler y qué reintentar
El error de la API v2 trae code, message, status y meta.requestId. RATE_LIMITED espera Retry-After. La doc lista INVALID_API_KEY; el handler responde UNAUTHORIZED.
Respuesta corta: un error de https://api.waspytech.com/api/v2 que pasa por el handler de la v2 tiene esta forma: error.code, error.message, error.status y meta.requestId. El mismo id viaja en el header x-request-id. Compará code, no el texto. RATE_LIMITED (429) se espera con Retry-After. SERVICE_WINDOW_EXPIRED es 403, no 400. La tabla pública lista INVALID_API_KEY e INSUFFICIENT_SCOPE; el servidor, hoy, responde 401 UNAUTHORIZED y 403 FORBIDDEN.
La clave pública entra en Scale (US$150 por mes) y Enterprise. Starter y Growth no la incluyen. Los planes están en Planes y el módulo, en la API.
El sobre
{
"error": {
"code": "SERVICE_WINDOW_EXPIRED",
"message": "Service window expired. Only templates can be sent outside the 24h window",
"status": 403
},
"meta": { "requestId": "550e8400-e29b-41d4-a716-446655440000" }
}
| Campo | Para qué |
|---|---|
code | Constante. El switch va acá |
message | Texto para una persona. Puede cambiar |
status | El HTTP, repetido adentro del JSON |
details | Opcional. Si no hay, el handler no manda la clave |
meta.requestId | El id para soporte. También está en x-request-id |
details: {} en el ejemplo. El handler de apps/api/src/routes/v2/errors.ts solo agrega details cuando el error lo trae. Un VALIDATION_ERROR que nace de Zod (el schema de Fastify) responde 400 sin details: el mensaje queda en error.message. Si el código tira V2Error.validationError con un objeto, ahí sí aparece details.
La tabla pública y lo que emite el handler
La tabla y el OpenAPI del repo nombran estos códigos. La columna de la derecha es el HTTP que construye V2Error en el código, salvo donde el handler dice otra cosa.
code en la doc | HTTP en el código | Cuándo |
|---|---|---|
VALIDATION_ERROR | 400 | Body o query que no pasa el schema, o un Idempotency-Key de más de 256 caracteres |
MISSING_REQUIRED_FIELD | 400 | Falta phoneNumberId, to, type o phoneNumber. details.field trae el nombre |
INVALID_PHONE | 400 | El teléfono no queda en E.164. details.field es to o phoneNumber |
INVALID_CHANNEL | 400 | Ese phoneNumberId no es un canal de la cuenta. El mensaje dice does not match any active channel |
INVALID_MESSAGE_TYPE | 400 | type no es text, image, video, audio, document, template ni interactive |
INVALID_CURSOR | 400 | El cursor no tiene el formato de esa lista. No es un “expiró” genérico |
INVALID_FILTER | 400 | Un filtro fuera de los valores del schema, por ejemplo status |
CHANNEL_NOT_CONNECTED | 403 | El canal existe y status no es active |
SERVICE_WINDOW_EXPIRED | 403 | Ventana de 24 horas cerrada. Solo una plantilla |
CONTACT_NOT_FOUND | 404 | No hay ese contacto en la cuenta |
CONVERSATION_NOT_FOUND | 404 | No hay ese hilo, o esta clave no lo puede ver |
MESSAGE_NOT_FOUND | 404 | No hay ese mensaje |
TEMPLATE_NOT_FOUND | 404 | No hay plantilla con ese nombre o ese UUID |
CONTACT_ALREADY_EXISTS | 409 | Ya hay un contacto con ese teléfono |
IDEMPOTENCY_CONFLICT | 409 | La misma Idempotency-Key sigue en proceso |
RATE_LIMITED | 429 | Más de 120 pedidos por minuto en la cuenta |
INTERNAL_ERROR | 500 | El mensaje público es Internal server error |
INVALID_CHANNEL “no existe o no está conectado”. El código los parte: si el UUID no está en la cuenta, 400 INVALID_CHANNEL; si está y el número no está active, 403 CHANNEL_NOT_CONNECTED con el status en el mensaje.
La doc dice que TEMPLATE_NOT_FOUND también cubre una plantilla no aprobada. El GET no filtra por status: devuelve pending o rejected con 200. El rechazo de un envío no aprobado lo da Meta, no este 404. Cómo leer el estado está en consultar por nombre.
Códigos que la tabla no lista y el servidor sí manda
| Qué ves | HTTP | De dónde sale |
|---|---|---|
UNAUTHORIZED | 401 | plugins/auth.ts. Sin header: Missing authorization header. Clave inventada: Invalid API key. Revocada: API key has been revoked. Vencida: API key expired |
FORBIDDEN | 403 | requireScope. Mensaje: API key missing required scope: messages:write (o el scope que falte). Una clave sin lista de scopes entra a todo |
MEDIA_NOT_FOUND | 404 | GET /api/v2/media/:id o /media/by-key si el archivo no es de la cuenta |
SOCIAL_POST_NOT_FOUND | 404 | GET /api/v2/social cuando el post todavía no está sincronizado |
INVALID_API_KEY e INSUFFICIENT_SCOPE están en errors y en la página de scopes. No aparecen en AppError.unauthorized ni en requireScope. Un switch que solo mire la tabla se pierde el 401 y el 403 reales. El 16 de octubre un GET sin header, documentado en la API para desarrolladores, devolvió el sobre con UNAUTHORIZED, status: 401 y meta.requestId.
El 402 de facturación es otro cuerpo. plugins/subscription-gate.ts responde antes, sin meta.requestId:
{
"error": "subscription_required",
"message": "...",
"state": "blocked",
"status": "past_due"
}
error es un string: trial_expired o subscription_required. No es error.code.
Qué se reintenta
code | ¿Reintentar? | Cómo |
|---|---|---|
RATE_LIMITED | Sí | Esperá los segundos de Retry-After. No martilles |
IDEMPOTENCY_CONFLICT | Sí | Unos segundos. El lock son 30. Si el primer pedido falló, el lock ya se borró |
INTERNAL_ERROR | Sí | Backoff. Guardá el requestId |
| El resto | No | Corregí el pedido o el estado |
app.ts: 120 pedidos por minuto por cuenta. La clave de Redis es v2: más el tenantId (si todavía no hay cuenta, la IP). Dos API keys de la misma cuenta comparten la ventana. El texto del OpenAPI dice “per API key”; el keyGenerator usa la cuenta. Los headers de cada respuesta, y en el 429 también Retry-After, son X-RateLimit-Limit, X-RateLimit-Remaining y X-RateLimit-Reset. El cuerpo del 429 lo arma el rate limit, con el mismo sobre: code: "RATE_LIMITED", status: 429 y meta.requestId. El detalle del ritmo está en límites y reintentos.
GET /api/v2/social puede devolver el mismo RATE_LIMITED por el cupo de Meta, con una espera de al menos 60 segundos. No es el contador de 120.
La idempotencia es solo de POST /messages, header Idempotency-Key (máximo 256). Si la primera ya terminó bien, dentro de 24 horas vuelve el mismo 201 con Idempotency-Replayed: true: eso no es un 409. El 409 es el lock, mientras la primera sigue corriendo. El mapa está en idempotencia y scopes.
El switch
La base es el ejemplo de la doc de errores. Abajo están los códigos que el handler emite de verdad, y el 402 aparte.
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': 'pedido-1847-aviso',
},
body: JSON.stringify({
phoneNumberId: process.env.WASPY_CHANNEL_ID,
to: '+5491126032641',
type: 'text',
text: { body: 'Tu pedido 1847 ya salió.' },
}),
});
if (res.status === 402) {
const blocked = await res.json();
// blocked.error es un string, no un objeto
throw new Error(blocked.message || blocked.error);
}
if (!res.ok) {
const { error, meta } = await res.json();
switch (error.code) {
case 'RATE_LIMITED': {
const wait = Number(res.headers.get('retry-after') || 60);
await new Promise((r) => setTimeout(r, wait * 1000));
break;
}
case 'IDEMPOTENCY_CONFLICT':
await new Promise((r) => setTimeout(r, 2000));
break;
case 'SERVICE_WINDOW_EXPIRED':
// Mandá una plantilla approved. No reintentes el texto.
break;
case 'UNAUTHORIZED':
case 'FORBIDDEN':
// Clave, header o scope. Reintentar no lo arregla.
break;
case 'INTERNAL_ERROR':
console.error(error.code, meta.requestId);
break;
default:
console.error(error.code, error.message, meta && meta.requestId);
}
}
SERVICE_WINDOW_EXPIRED se resuelve leyendo serviceWindowExpiresAt antes de mandar texto. La guía está en la ventana por API. Un cursor mal armado es INVALID_CURSOR, no un 500: paginación.
POST /api/v2/templates no existe. Crear una plantilla es el panel o la herramienta MCP create_template (scope templates:write). En REST hay GET de listado, GET por id y GET /by-name/:name.
Diez días de prueba, sin tarjeta, en el registro. Si el 500 se repite, el requestId es lo que hay que pasar.
