# Códigos de error

> Generado desde el código y el contrato. `pnpm docs:gen`.

Todos los errores tienen esta forma, con el mismo `request_id` que la cabecera `X-Request-Id`:

```json
{
  "error": {
    "code": "window_closed",
    "message": "La ventana de 24 horas de este contacto está cerrada; envía una plantilla.",
    "details": {
      "window_expired_at": "2026-10-11T18:22:00Z"
    },
    "request_id": "req_018f3c5a9b2d7e4f8a1b2c3d4e5f6a7b"
  }
}
```

| HTTP | `code` | Qué significa | ¿Reintentar? | Qué hacer |
| --- | --- | --- | --- | --- |
| 400 | `invalid_request` | JSON mal formado, falta un campo obligatorio o falta `X-Tenant-Id`/`X-Tenant-Ref` con una clave multi-tenant. | No | Corregir la petición. |
| 401 | `unauthorized` | Clave de API o token ausente, inválido, caducado o revocado. | No | Revisar la clave; si es un token de embebido, pedir uno nuevo (son de un solo uso). |
| 402 | `payment_required` | La cuenta de WhatsApp del cliente está bloqueada por un problema de pago en Meta (error `131042`). | Sí, pasados 10 minutos | Avisar al cliente: debe revisar su tarjeta en WhatsApp Manager. Llega `whatsapp.account.payment_blocked`. Tras 10 minutos se deja pasar un envío: si Meta lo acepta, `whatsapp.account.payment_active`. |
| 403 | `forbidden` | Falta el alcance de la clave o el rol del usuario. `details.required_scope` o `details.required_role` lo indican. | No | Crear una clave con el alcance necesario o usar un rol superior. |
| 403 | `license_inactive` | El tenant está suspendido, no tiene licencia, o la licencia está caducada o suspendida. | No, hasta que se reactive | Contactar con quien gestiona las licencias. Suspendida: las claves de API siguen pudiendo leer. |
| 403 | `feature_not_licensed` | La licencia no incluye la función (`details.feature`: chat, templates, api, embed, costs). | No | Ampliar la licencia u ocultar la función en vuestra app (`GET /v1/license`). |
| 403 | `app_not_enabled` | La app no está habilitada para ese tenant (o el tenant no existe; no se distingue a propósito). | No | Habilitarla con `PUT /admin/v1/tenants/{id}/apps/{app_id}`. |
| 403 | `license_limit_reached` | Se alcanzó un límite de la licencia (`details.kind`: max_outbound_per_month, max_numbers, max_agents, max_templates). | No | Ampliar el límite o esperar al mes siguiente (mensajes). |
| 403 | `contact_opted_out` | El contacto se dio de baja (o está bloqueado) y la plantilla es de marketing. | No | No enviar marketing. Si el contacto vuelve a consentir: `POST /v1/contacts/{id}/opt-in`. |
| 403 | `opt_in_required` | El tenant exige consentimiento (`enforce_opt_in`) y el contacto no lo tiene registrado. | No | Registrar el consentimiento con su origen: `POST /v1/contacts/{id}/opt-in`. |
| 404 | `not_found` | El recurso no existe **o es de otro tenant** (nunca se distingue). | No | Revisar el identificador. |
| 409 | `window_closed` | Han pasado más de 24 h desde el último mensaje del contacto: solo se admiten plantillas. | No | Enviar una plantilla aprobada. `details.window_expired_at` indica cuándo se cerró. |
| 409 | `template_not_approved` | La plantilla existe pero no está aprobada (`details.status`). | No | Esperar la aprobación (evento `whatsapp.template.status_changed`) o corregirla si fue rechazada. |
| 409 | `number_not_ready` | No hay número conectado, el número está desconectado o la cuenta está restringida. | No | Conectar un número (`/embed/signup`) o revisar el estado de la cuenta. |
| 409 | `already_assigned` | La conversación ya está asignada a otro agente. | No | Refrescar y decidir; un supervisor puede reasignar. |
| 409 | `invalid_state` | El recurso no está en un estado que admita la operación (p. ej. editar una plantilla en revisión, reintentar un mensaje no fallido, duplicados). | No | Consultar el estado actual y actuar en consecuencia. |
| 409 | `idempotency_conflict` | La misma `Idempotency-Key` ya se usó con un cuerpo distinto. | No | Usar una clave nueva para una operación distinta. |
| 422 | `validation_failed` | Datos inválidos. `details.fields` lista cada campo con su problema. | No | Corregir los campos indicados. |
| 422 | `template_variable_mismatch` | Las variables no coinciden con las de la plantilla (`details.expected`, `missing`, `extra`). | No | Enviar exactamente las variables que espera la plantilla. |
| 429 | `rate_limited` | Se superó el límite de peticiones por minuto de la clave. | Sí, tras `Retry-After` segundos | Reducir el ritmo; el SDK respeta `Retry-After`. |
| 500 | `internal_error` | Error interno del gateway. | Sí, con retroceso, con la misma `Idempotency-Key` | Si persiste, contactar con soporte con el `request_id`. |
| 501 | `not_implemented` | La operación está en el contrato pero aún no está implementada. | No | Consultar el estado del proyecto. |
| 502 | `meta_error` | Meta rechazó la llamada. `details.meta_code` lleva su código. | Depende del código | Ver `details.meta_code`; los errores recuperables ya los reintenta el gateway. |
| 503 | `meta_unavailable` | Meta no respondió. | Sí, con retroceso y la misma `Idempotency-Key` | Repetir más tarde. |

## Errores que llegan en un mensaje (`message.error`)

Un envío aceptado (`202`) puede acabar en `failed`. `error.code` es el código de Meta como texto (`"131047"`) o uno propio:

| `error.code` | Significado | `retryable` |
| --- | --- | --- |
| `131047` | Más de 24 h desde la última respuesta del usuario (la ventana se cerró entre el envío y el worker) | no |
| `131042` | Problema de pago en Meta; la cuenta pasa a `blocked` | no |
| `131048` | Límite por calidad o spam; el número queda en observación | no |
| `131049` | Meta no entregó para mantener la salud del ecosistema (suele pasar con marketing). Si insistís, esperad 24 h y enviadlo como mensaje nuevo | no |
| `131050` | El usuario dejó de recibir marketing; el contacto pasa a `opted_out` | no |
| `131026` | No se puede entregar (no es WhatsApp o no aceptó las condiciones) | no |
| `132015` / `132016` | Plantilla pausada o desactivada por baja calidad | no |
| `outcome_unknown` | El envío se cortó sin respuesta de Meta: **pudo haberse enviado**. No se reintenta solo | sí, a mano |
| `license_inactive` | La licencia se suspendió con el envío en cola | sí, al reactivar |
| `number_not_ready` | El número se desconectó o no hay credencial válida | sí, al reconectar |

Reintento manual: `POST /v1/messages/{id}/retry` (solo si `error.retryable` es `true`). Con `outcome_unknown` comprobad antes en el móvil o con vuestros registros que el mensaje no llegó.
