Volver al blog
Desarrolladores

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.

· 12 min
Códigos de error de la API de Waspy: el sobre, el handler y qué reintentar

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" }
}
CampoPara qué
codeConstante. El switch va acá
messageTexto para una persona. Puede cambiar
statusEl HTTP, repetido adentro del JSON
detailsOpcional. Si no hay, el handler no manda la clave
meta.requestIdEl id para soporte. También está en x-request-id
La página de errores muestra 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 docHTTP en el códigoCuándo
VALIDATION_ERROR400Body o query que no pasa el schema, o un Idempotency-Key de más de 256 caracteres
MISSING_REQUIRED_FIELD400Falta phoneNumberId, to, type o phoneNumber. details.field trae el nombre
INVALID_PHONE400El teléfono no queda en E.164. details.field es to o phoneNumber
INVALID_CHANNEL400Ese phoneNumberId no es un canal de la cuenta. El mensaje dice does not match any active channel
INVALID_MESSAGE_TYPE400type no es text, image, video, audio, document, template ni interactive
INVALID_CURSOR400El cursor no tiene el formato de esa lista. No es un “expiró” genérico
INVALID_FILTER400Un filtro fuera de los valores del schema, por ejemplo status
CHANNEL_NOT_CONNECTED403El canal existe y status no es active
SERVICE_WINDOW_EXPIRED403Ventana de 24 horas cerrada. Solo una plantilla
CONTACT_NOT_FOUND404No hay ese contacto en la cuenta
CONVERSATION_NOT_FOUND404No hay ese hilo, o esta clave no lo puede ver
MESSAGE_NOT_FOUND404No hay ese mensaje
TEMPLATE_NOT_FOUND404No hay plantilla con ese nombre o ese UUID
CONTACT_ALREADY_EXISTS409Ya hay un contacto con ese teléfono
IDEMPOTENCY_CONFLICT409La misma Idempotency-Key sigue en proceso
RATE_LIMITED429Más de 120 pedidos por minuto en la cuenta
INTERNAL_ERROR500El mensaje público es Internal server error
La doc mete en 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é vesHTTPDe dónde sale
UNAUTHORIZED401plugins/auth.ts. Sin header: Missing authorization header. Clave inventada: Invalid API key. Revocada: API key has been revoked. Vencida: API key expired
FORBIDDEN403requireScope. Mensaje: API key missing required scope: messages:write (o el scope que falte). Una clave sin lista de scopes entra a todo
MEDIA_NOT_FOUND404GET /api/v2/media/:id o /media/by-key si el archivo no es de la cuenta
SOCIAL_POST_NOT_FOUND404GET /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_LIMITEDSíEsperá los segundos de Retry-After. No martilles
IDEMPOTENCY_CONFLICTSíUnos segundos. El lock son 30. Si el primer pedido falló, el lock ya se borró
INTERNAL_ERRORSíBackoff. Guardá el requestId
El restoNoCorregí el pedido o el estado
El tope de la v2 está en 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.

Tu equipo atendiendo por WhatsApp en minutos

CRM facil pero potente. En 10 minutos tenes a tu equipo trabajando — no necesitas hablar con nadie.