Errores
Todos los errores de la API de Ozzie siguen un formato consistente. Cuando una solicitud falla, el cuerpo de la respuesta siempre contiene un objeto error con un code legible por máquina, un message legible por humanos y un array details opcional con contexto adicional.
Formato de la respuesta de error
{
"error": {
"code": "ERROR_CODE",
"message": "A human-readable description of what went wrong.",
"details": []
}
}
| Campo | Tipo | Descripción |
|---|---|---|
error.code | string | Código de error legible por máquina — úsalo para el manejo programático |
error.message | string | Descripción legible del error |
error.details | array | Contexto adicional opcional, como qué campos fallaron la validación |
Códigos de error
INVALID_JSON
| Estado HTTP | 400 Bad Request |
|---|---|
| Cuándo ocurre | El cuerpo de la solicitud no pudo ser analizado como JSON |
| Cómo resolver | Verifica que tu encabezado Content-Type sea application/json y que el cuerpo sea JSON válido. Problemas comunes: comas finales, claves sin comillas, comillas simples en lugar de dobles. |
{
"error": {
"code": "INVALID_JSON",
"message": "The request body could not be parsed as JSON.",
"details": []
}
}
VALIDATION_ERROR
| Estado HTTP | 422 Unprocessable Entity |
|---|---|
| Cuándo ocurre | El cuerpo de la solicitud es JSON válido pero contiene valores de campo inválidos (tipo incorrecto, valor fuera de rango, valor de enum inválido, etc.) |
| Cómo resolver | Revisa el array details — cada entrada identifica el campo específico y la falla de validación. |
{
"error": {
"code": "VALIDATION_ERROR",
"message": "One or more fields failed validation.",
"details": [
{
"field": "cadence",
"message": "Must be one of: weekly, biweekly, twice_monthly, monthly"
},
{
"field": "target_amount",
"message": "Must be a positive number"
}
]
}
}
INVALID_CODE
| Estado HTTP | 400 Bad Request |
|---|---|
| Cuándo ocurre | El código OTP enviado a POST /v1/auth/otp/verify no coincide con el hash almacenado, o el código ya fue utilizado. Los códigos son de uso único — una verificación exitosa invalida el código de inmediato. |
| Cómo resolver | Pide al usuario que verifique el código e intente de nuevo. Si el código ya fue utilizado, solicita uno nuevo mediante POST /v1/auth/otp/request. |
{
"error": {
"code": "INVALID_CODE",
"message": "The OTP code is incorrect or has already been used.",
"details": []
}
}
CODE_EXPIRED
| Estado HTTP | 400 Bad Request |
|---|---|
| Cuándo ocurre | El código OTP enviado a POST /v1/auth/otp/verify fue emitido hace más de 10 minutos y ha expirado. |
| Cómo resolver | Solicita un nuevo código mediante POST /v1/auth/otp/request e indica al usuario que lo ingrese rápidamente. |
{
"error": {
"code": "CODE_EXPIRED",
"message": "The OTP code has expired. Please request a new code.",
"details": []
}
}
UNAUTHORIZED
| Estado HTTP | 401 Unauthorized |
|---|---|
| Cuándo ocurre | El encabezado Authorization está ausente, malformado o contiene credenciales inválidas |
| Cómo resolver | Verifica que tu token esté correctamente codificado como base64(client_id:client_secret). Comprueba que no hayas rotado tu secreto sin actualizar las variables de entorno. Asegúrate de que el formato del encabezado sea exactamente Authorization: Bearer <token>. |
{
"error": {
"code": "UNAUTHORIZED",
"message": "Invalid or missing authorization credentials.",
"details": []
}
}
FORBIDDEN
| Estado HTTP | 403 Forbidden |
|---|---|
| Cuándo ocurre | Tus credenciales son válidas, pero intentas acceder a un recurso que pertenece al usuario de otro cliente |
| Cómo resolver | Verifica que el user_id en la ruta pertenezca a un usuario que creaste bajo tu client_id. No puedes acceder a usuarios creados por otros clientes de API. |
{
"error": {
"code": "FORBIDDEN",
"message": "You do not have permission to access this resource.",
"details": []
}
}
NOT_FOUND
| Estado HTTP | 404 Not Found |
|---|---|
| Cuándo ocurre | El recurso solicitado no existe. Causas comunes: un ID de usuario que no existe, una meta que aún no fue definida, un ID de movimiento de dinero inválido. |
| Cómo resolver | Verifica que el ID en la ruta de tu URL sea correcto y que el recurso haya sido creado antes de intentar recuperarlo. |
{
"error": {
"code": "NOT_FOUND",
"message": "The requested resource could not be found.",
"details": [
{
"resource": "user",
"id": "usr_invalid_id"
}
]
}
}
CONFLICT
| Estado HTTP | 409 Conflict |
|---|---|
| Cuándo ocurre | La solicitud entra en conflicto con el estado actual de un recurso. Más comúnmente: intentar crear un usuario con un external_user_id que ya existe bajo tu cliente. |
| Cómo resolver | Si estás creando un usuario, usa GET /users?external_user_id=... para verificar si ya existe antes de crear. Alternativamente, trátalo como una operación sin cambios y usa el ID de usuario existente. |
{
"error": {
"code": "CONFLICT",
"message": "A user with this external_user_id already exists.",
"details": [
{
"field": "external_user_id",
"existing_id": "usr_4f8a1b2c3d"
}
]
}
}
INTAKE_REQUIRED
| Estado HTTP | 422 Unprocessable Entity |
|---|---|
| Cuándo ocurre | Llamaste a un endpoint que requiere que exista un intake financiero (ej., POST /plan, POST /chat/messages), pero no se ha enviado ningún intake para este usuario. |
| Cómo resolver | Llama primero a POST /users/{user_id}/financial-intake. El intake captura los ingresos, gastos y meta del usuario, que es la base para todas las funcionalidades posteriores. |
{
"error": {
"code": "INTAKE_REQUIRED",
"message": "This user does not have a financial intake. Submit an intake before calling this endpoint.",
"details": []
}
}
PLAN_REQUIRED
| Estado HTTP | 422 Unprocessable Entity |
|---|---|
| Cuándo ocurre | Llamaste a un endpoint que requiere que exista un plan (ej., GET /financial-profile), pero no se ha generado ningún plan para este usuario. |
| Cómo resolver | Llama primero a POST /v1/users/{user_id}/plan/generate. Ten en cuenta que la generación del plan requiere un intake financiero — ver INTAKE_REQUIRED. |
{
"error": {
"code": "PLAN_REQUIRED",
"message": "This user does not have a financial plan. Generate a plan before calling this endpoint.",
"details": []
}
}
PERSONALITY_REQUIRED
| Estado HTTP | 422 Unprocessable Entity |
|---|---|
| Cuándo ocurre | Se llamó a un endpoint que requiere personality_type sin proporcionarlo, o con un valor inválido. |
| Cómo resolver | Puntúa la personalidad del usuario usando POST /v1/personality/score, almacena el resultado y pásalo como personality_type en el cuerpo de la solicitud. Valores válidos: HOTSHOT, STOCKPILER, GIVER, INDEPENDENT. |
{
"error": {
"code": "PERSONALITY_REQUIRED",
"message": "The personality_type field is required for this endpoint.",
"details": []
}
}
RATE_LIMIT_EXCEEDED
| Estado HTTP | 429 Too Many Requests |
|---|---|
| Cuándo ocurre | Tu cliente ha enviado más solicitudes de las que permite tu límite de tasa en la ventana actual. |
| Cómo resolver | Espera hasta el tiempo especificado en el encabezado de respuesta X-RateLimit-Reset, luego reintenta. Implementa backoff exponencial si estás alcanzando los límites con regularidad. Contacta commercial@ozzieapp.com si necesitas un límite mayor para tu tier. |
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "You have exceeded the rate limit for this window. Please retry after the reset time.",
"details": [
{
"reset_at": 1746489600,
"limit": 1000,
"window": "1 minute"
}
]
}
}
Encabezados de respuesta cuando se excede el límite de tasa:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1746489600
Retry-After: 47
VERSION_MISMATCH
| Estado HTTP | 400 Bad Request |
|---|---|
| Cuándo ocurre | La versión de la API en tu URL no corresponde a una versión compatible, o un recurso fue creado bajo una versión de API diferente a la que usas para solicitarlo. |
| Cómo resolver | Asegúrate de que tu URL base incluya /v1/. Si estás migrando entre versiones, revisa el Changelog para ver los cambios incompatibles. |
{
"error": {
"code": "VERSION_MISMATCH",
"message": "The requested API version is not supported.",
"details": [
{
"requested_version": "v0",
"supported_versions": ["v1"]
}
]
}
}
INTERNAL_ERROR
| Estado HTTP | 500 Internal Server Error |
|---|---|
| Cuándo ocurre | Ocurrió un error inesperado en los servidores de Ozzie. Este es un error de Ozzie, no tuyo. |
| Cómo resolver | Reintenta la solicitud después de un breve delay. Estos errores son típicamente transitorios. Si el error persiste por más de unos minutos, revisa la página de estado de Ozzie o contacta al soporte. |
{
"error": {
"code": "INTERNAL_ERROR",
"message": "An unexpected error occurred. Please try again.",
"details": []
}
}
Recomendaciones de estrategia de reintento
No todos los errores deben reintentarse. Usa esta guía para decidir cuándo reintentar y cuándo mostrar el error al usuario o registrarlo para investigación:
| Código de error | ¿Reintentar? | Estrategia |
|---|---|---|
INVALID_JSON | No | Corrige la solicitud — reintentar no ayudará |
VALIDATION_ERROR | No | Corrige los campos inválidos y reenvía |
INVALID_CODE | No | Pide al usuario que reingrese o solicite un nuevo OTP |
CODE_EXPIRED | No | Solicita un nuevo código OTP |
UNAUTHORIZED | No | Corrige las credenciales — revisa las variables de entorno |
FORBIDDEN | No | Estás accediendo al recurso equivocado |
NOT_FOUND | No | El recurso no existe — verifica los IDs |
CONFLICT | No | El recurso ya existe — maneja de forma idempotente |
INTAKE_REQUIRED | No | Redirige al usuario a completar el intake primero |
PLAN_REQUIRED | No | Llama primero a POST /plan |
PERSONALITY_REQUIRED | No | Corrige el cuerpo de la solicitud |
RATE_LIMIT_EXCEEDED | Sí | Espera X-RateLimit-Reset, luego reintenta |
VERSION_MISMATCH | No | Corrige la URL |
INTERNAL_ERROR | Sí | Reintenta con backoff exponencial |
Ejemplo de backoff exponencial
async function retryableRequest(method, path, body, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await ozzieRequest(method, path, body);
} catch (err) {
const isRetryable =
err.message.includes('RATE_LIMIT_EXCEEDED') ||
err.message.includes('INTERNAL_ERROR');
if (!isRetryable || attempt === maxRetries) throw err;
const delay = Math.min(1000 * Math.pow(2, attempt), 30000); // Max 30s
console.warn(`Retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
await new Promise(r => setTimeout(r, delay));
}
}
}