Errors
All Ozzie API errors follow a consistent shape. When a request fails, the response body always contains an error object with a machine-readable code, a human-readable message, and an optional details array with additional context.
Error response formatβ
{
"error": {
"code": "ERROR_CODE",
"message": "A human-readable description of what went wrong.",
"details": []
}
}
| Field | Type | Description |
|---|---|---|
error.code | string | Machine-readable error code β use this for programmatic handling |
error.message | string | Human-readable description of the error |
error.details | array | Optional additional context, such as which fields failed validation |
Error codesβ
INVALID_JSONβ
| HTTP Status | 400 Bad Request |
|---|---|
| When it occurs | The request body could not be parsed as JSON |
| How to resolve | Check that your Content-Type header is application/json and that the body is valid JSON. Common issues: trailing commas, unquoted keys, single quotes instead of double quotes. |
{
"error": {
"code": "INVALID_JSON",
"message": "The request body could not be parsed as JSON.",
"details": []
}
}
VALIDATION_ERRORβ
| HTTP Status | 422 Unprocessable Entity |
|---|---|
| When it occurs | The request body is valid JSON but contains invalid field values (wrong type, out-of-range value, invalid enum value, etc.) |
| How to resolve | Check the details array β each entry identifies the specific field and the validation failure. |
{
"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"
}
]
}
}
UNAUTHORIZEDβ
| HTTP Status | 401 Unauthorized |
|---|---|
| When it occurs | The Authorization header is missing, malformed, or contains invalid credentials |
| How to resolve | Verify that your token is correctly encoded as base64(client_id:client_secret). Check that you haven't rotated your secret without updating your environment variables. Ensure the header format is exactly Authorization: Bearer <token>. |
{
"error": {
"code": "UNAUTHORIZED",
"message": "Invalid or missing authorization credentials.",
"details": []
}
}
FORBIDDENβ
| HTTP Status | 403 Forbidden |
|---|---|
| When it occurs | Your credentials are valid, but you are attempting to access a resource that belongs to another client's user |
| How to resolve | Verify that the user_id in the path belongs to a user you created under your client_id. You cannot access users created by other API clients. |
{
"error": {
"code": "FORBIDDEN",
"message": "You do not have permission to access this resource.",
"details": []
}
}
NOT_FOUNDβ
| HTTP Status | 404 Not Found |
|---|---|
| When it occurs | The requested resource does not exist. Common causes: a user ID that doesn't exist, a goal that hasn't been set yet, a money move ID that's invalid. |
| How to resolve | Check that the ID in your URL path is correct and that the resource has been created before you try to retrieve it. |
{
"error": {
"code": "NOT_FOUND",
"message": "The requested resource could not be found.",
"details": [
{
"resource": "user",
"id": "usr_invalid_id"
}
]
}
}
CONFLICTβ
| HTTP Status | 409 Conflict |
|---|---|
| When it occurs | The request conflicts with the current state of a resource. Most commonly: trying to create a user with an external_user_id that already exists under your client. |
| How to resolve | If you're creating a user, use GET /users?external_user_id=... to check if they already exist before creating. Alternatively, treat this as a no-op and use the existing user ID. |
{
"error": {
"code": "CONFLICT",
"message": "A user with this external_user_id already exists.",
"details": [
{
"field": "external_user_id",
"existing_id": "usr_4f8a1b2c3d"
}
]
}
}
INTAKE_REQUIREDβ
| HTTP Status | 422 Unprocessable Entity |
|---|---|
| When it occurs | You called an endpoint that requires a financial intake to exist (e.g., POST /plan, POST /chat/messages), but no intake has been submitted for this user. |
| How to resolve | Call POST /users/{user_id}/financial-intake first. The intake captures the user's income, expenses, and goal, which is the foundation for all downstream features. |
{
"error": {
"code": "INTAKE_REQUIRED",
"message": "This user does not have a financial intake. Submit an intake before calling this endpoint.",
"details": []
}
}
PLAN_REQUIREDβ
| HTTP Status | 422 Unprocessable Entity |
|---|---|
| When it occurs | You called an endpoint that requires a plan to exist (e.g., POST /money-moves/generate, POST /plan/personalize), but no plan has been generated for this user. |
| How to resolve | Call POST /users/{user_id}/plan first. Note that plan generation itself requires a financial intake β see 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β
| HTTP Status | 422 Unprocessable Entity |
|---|---|
| When it occurs | POST /plan/personalize was called without a personality_type field in the request body. |
| How to resolve | Include personality_type in the request body. Valid values: "optimizer", "planner", "avoider", "spender". |
{
"error": {
"code": "PERSONALITY_REQUIRED",
"message": "The personality_type field is required for this endpoint.",
"details": []
}
}
RATE_LIMIT_EXCEEDEDβ
| HTTP Status | 429 Too Many Requests |
|---|---|
| When it occurs | Your client has sent more requests than your rate limit allows in the current window. |
| How to resolve | Wait until the time specified in the X-RateLimit-Reset response header, then retry. Implement exponential backoff if you're hitting limits regularly. Contact commercial@ozzieapp.com if you need a higher limit for your 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"
}
]
}
}
Response headers when rate-limited:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1746489600
Retry-After: 47
VERSION_MISMATCHβ
| HTTP Status | 400 Bad Request |
|---|---|
| When it occurs | The API version in your URL does not match a supported version, or a resource was created under a different API version than what you're requesting it with. |
| How to resolve | Ensure your base URL includes /v1/. If you're migrating between versions, check the Changelog for breaking changes. |
{
"error": {
"code": "VERSION_MISMATCH",
"message": "The requested API version is not supported.",
"details": [
{
"requested_version": "v0",
"supported_versions": ["v1"]
}
]
}
}
INTERNAL_ERRORβ
| HTTP Status | 500 Internal Server Error |
|---|---|
| When it occurs | An unexpected error occurred on Ozzie's servers. This is Ozzie's fault, not yours. |
| How to resolve | Retry the request after a short delay. These errors are typically transient. If the error persists for more than a few minutes, check the Ozzie status page or contact support. |
{
"error": {
"code": "INTERNAL_ERROR",
"message": "An unexpected error occurred. Please try again.",
"details": []
}
}
Retry strategy recommendationsβ
Not all errors should be retried. Use this guide to decide when to retry and when to surface the error to the user or log it for investigation:
| Error code | Retry? | Strategy |
|---|---|---|
INVALID_JSON | No | Fix the request β retrying won't help |
VALIDATION_ERROR | No | Fix the invalid fields and resubmit |
UNAUTHORIZED | No | Fix credentials β check environment variables |
FORBIDDEN | No | You're accessing the wrong resource |
NOT_FOUND | No | The resource doesn't exist β check IDs |
CONFLICT | No | Resource already exists β handle idempotently |
INTAKE_REQUIRED | No | Redirect user to complete intake first |
PLAN_REQUIRED | No | Call POST /plan first |
PERSONALITY_REQUIRED | No | Fix the request body |
RATE_LIMIT_EXCEEDED | Yes | Wait for X-RateLimit-Reset, then retry |
VERSION_MISMATCH | No | Fix the URL |
INTERNAL_ERROR | Yes | Retry with exponential backoff |
Exponential backoff exampleβ
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));
}
}
}