Skip to main content

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": []
}
}
FieldTypeDescription
error.codestringMachine-readable error code β€” use this for programmatic handling
error.messagestringHuman-readable description of the error
error.detailsarrayOptional additional context, such as which fields failed validation

Error codes​

INVALID_JSON​

HTTP Status400 Bad Request
When it occursThe request body could not be parsed as JSON
How to resolveCheck 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 Status422 Unprocessable Entity
When it occursThe request body is valid JSON but contains invalid field values (wrong type, out-of-range value, invalid enum value, etc.)
How to resolveCheck 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 Status401 Unauthorized
When it occursThe Authorization header is missing, malformed, or contains invalid credentials
How to resolveVerify 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 Status403 Forbidden
When it occursYour credentials are valid, but you are attempting to access a resource that belongs to another client's user
How to resolveVerify 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 Status404 Not Found
When it occursThe 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 resolveCheck 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 Status409 Conflict
When it occursThe 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 resolveIf 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 Status422 Unprocessable Entity
When it occursYou 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 resolveCall 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 Status422 Unprocessable Entity
When it occursYou 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 resolveCall 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 Status422 Unprocessable Entity
When it occursPOST /plan/personalize was called without a personality_type field in the request body.
How to resolveInclude 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 Status429 Too Many Requests
When it occursYour client has sent more requests than your rate limit allows in the current window.
How to resolveWait 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 Status400 Bad Request
When it occursThe 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 resolveEnsure 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 Status500 Internal Server Error
When it occursAn unexpected error occurred on Ozzie's servers. This is Ozzie's fault, not yours.
How to resolveRetry 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 codeRetry?Strategy
INVALID_JSONNoFix the request β€” retrying won't help
VALIDATION_ERRORNoFix the invalid fields and resubmit
UNAUTHORIZEDNoFix credentials β€” check environment variables
FORBIDDENNoYou're accessing the wrong resource
NOT_FOUNDNoThe resource doesn't exist β€” check IDs
CONFLICTNoResource already exists β€” handle idempotently
INTAKE_REQUIREDNoRedirect user to complete intake first
PLAN_REQUIREDNoCall POST /plan first
PERSONALITY_REQUIREDNoFix the request body
RATE_LIMIT_EXCEEDEDYesWait for X-RateLimit-Reset, then retry
VERSION_MISMATCHNoFix the URL
INTERNAL_ERRORYesRetry 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));
}
}
}