# Errors

The error codes of the Comvi REST API, the HTTP statuses they arrive with, the response envelope, and the rate-limit headers.

Canonical: https://comvi.io/docs/api/errors

A failed request returns a machine-readable code, a human-readable message, and optional metadata. Branch on `code`; messages can change, codes stay stable.

```json
{ "code": "PROJECT_NOT_FOUND", "message": "Project not found", "meta": { "projectId": 123 } }
```

| Field | Type | Meaning |
|-------|------|---------|
| `code` | `string` | Machine-readable code |
| `message` | `string` | Human-readable description |
| `meta` | `object` | Optional context, varies by error |

## Validation failures

When the body, params or query fail schema validation, the API returns `422` with `code` and `meta` and no `message`. Only the first failing field is reported.

```json
{ "code": "FIELD_REQUIRED", "meta": { "field": "name" } }
```

| Code | Cause | `meta` fields |
|------|-------|---------------|
| `FIELD_REQUIRED` | A required field is missing | `field` |
| `FIELD_TOO_SHORT` | Below the minimum length | `field`, `minLength` |
| `FIELD_TOO_LONG` | Above the maximum length | `field`, `maxLength` |
| `VALIDATION_ERROR` | Wrong type, format, pattern, range, or enum value | `field`, plus one of `expectedType`, `expectedFormat`, `expectedPattern`, `minimum`, `maximum`, `allowedValues` |

## HTTP status codes

| Status | Meaning |
|--------|---------|
| `400` | The request body or parameters are invalid |
| `401` | Missing or invalid authentication |
| `403` | Valid credentials but insufficient permissions |
| `404` | The resource does not exist |
| `409` | The request conflicts with the current state of a resource |
| `413` | An uploaded file exceeds the size limit |
| `422` | Schema validation failed |
| `429` | Rate limit exceeded |
| `500` | Unexpected server error |
| `502` | An upstream provider (billing, LLM) failed |
| `504` | An upstream provider timed out |

## Codes by category

The status column lists every status a code is sent with.

### Authentication

| Code | Status | When |
|------|--------|------|
| `AUTHENTICATION_REQUIRED` | `401` | No valid session or API key |
| `UNAUTHORIZED` | `401` | Authentication was attempted and failed |
| `INVALID_CREDENTIALS` | `401`, `400` | Email or password is incorrect. `400` when confirming your current password |
| `INVALID_OR_EXPIRED_TOKEN` | `400` | A reset or verification token is invalid or expired |
| `API_KEY_INVALID` | `401` | API key is malformed or does not exist |
| `API_KEY_DISABLED` | `401` | API key has been deactivated |
| `API_KEY_EXPIRED` | `401` | API key has passed its expiration date |
| `API_KEY_PERMISSIONS_REVOKED` | `401` | The key's creator no longer holds the permissions it was granted |
| `EMAIL_NOT_VERIFIED` | `403` | Verify the email address before using the API |
| `USER_BLOCKED` | `403` | The account has been blocked |
| `RECENT_SIGN_IN_REQUIRED` | `403` | Sign in again before performing this action |

### Permissions

| Code | Status | When |
|------|--------|------|
| `FORBIDDEN` | `403` | Access denied. The most common permission failure |
| `INSUFFICIENT_PERMISSION` | `403` | The role lacks the permission this action requires |
| `NO_PROJECT_ACCESS` | `403`, `404` | No access to this project. `404` when the user is not assigned to it |
| `NOT_ORGANIZATION_MEMBER` | `403`, `404`, `400` | Not a member of the organization. `403` when it is the caller, `404` when it is the target user |
| `ROLE_HIERARCHY_VIOLATION` | `403`, `400` | Cannot assign or modify a role above your own. `400` when a project role would exceed the organization role |
| `INVALID_ROLE` | `403` | The role is not valid for this action |
| `IMPERSONATION_FORBIDDEN` | `403` | Not allowed while impersonating |

### Resources

| Code | Status | When |
|------|--------|------|
| `NOT_FOUND` | `404` | Generic resource not found |
| `USER_NOT_FOUND` | `404` | User does not exist |
| `ORGANIZATION_NOT_FOUND` | `403`, `404` | Organization does not exist. `403` when the caller is not a member |
| `PROJECT_NOT_FOUND` | `403`, `404` | Project does not exist. `403` when the caller has no access to it |
| `API_KEY_NOT_FOUND` | `404` | API key does not exist |
| `LOCALE_NOT_FOUND` | `400`, `404` | Locale does not exist. `400` when it arrives as an invalid input code |
| `TRANSLATION_KEY_NOT_FOUND` | `404` | Translation key does not exist |
| `NAMESPACE_NOT_FOUND` | `404` | Namespace does not exist |
| `WEBHOOK_NOT_FOUND` | `404` | Webhook does not exist |
| `WEBHOOK_DELIVERY_NOT_FOUND` | `404` | Webhook delivery does not exist |
| `INVITATION_NOT_FOUND` | `404` | Invitation does not exist or has already been used |
| `SUBSCRIPTION_NOT_FOUND` | `404` | No subscription found |
| `PLAN_NOT_FOUND` | `404` | The billing plan does not exist |

### Conflicts

Most "already exists" conditions are `400`, not `409`.

| Code | Status | When |
|------|--------|------|
| `CONFLICT` | `409` | The request conflicts with current state |
| `TRANSACTION_CONFLICT` | `409` | The database rolled the transaction back. Retry the request as-is |
| `EMAIL_ALREADY_USED` | `409` | An account with this email already exists |
| `LOCALE_ALREADY_EXISTS` | `409` | The locale is already added to the project |
| `SUBSCRIPTION_MANUALLY_MANAGED` | `409` | The plan is admin-managed, so self-serve billing changes are refused |
| `SUBSCRIPTION_ALREADY_EXISTS` | `409` | The organization already has a live subscription. Fix or cancel it instead of starting a new checkout |
| `UNIQUE_CONSTRAINT` | `400` | A name that must be unique is already taken |
| `USER_ALREADY_IN_ORGANIZATION` | `400` | User is already a member of this organization |
| `INVITATION_ALREADY_SENT` | `400` | An invitation has already been sent to this email |
| `TRANSLATION_KEY_ALREADY_EXISTS` | `400` | A key with this name already exists in the namespace |
| `PASSWORD_ALREADY_SET` | `400` | Password has already been set for this account |

### Validation

| Code | Status | When |
|------|--------|------|
| `VALIDATION_ERROR` | `422`, `400`, `409` | Schema validation at `422`. Business-rule failures use `400`; a concurrent-edit conflict uses `409` |
| `FIELD_REQUIRED` | `422` | A required field is missing |
| `FIELD_TOO_SHORT` | `422` | Below the minimum length |
| `FIELD_TOO_LONG` | `422` | Above the maximum length |
| `INVALID_INPUT` | `400` | Input data is invalid |
| `SYNTAX_ERROR` | `400` | Request body is not valid JSON |

### Import and export

| Code | Status | When |
|------|--------|------|
| `FILE_TOO_LARGE` | `413` | Uploaded file exceeds the size limit |
| `FILE_TYPE_NOT_ALLOWED` | `400` | File format is not supported |
| `INVALID_FILE_STRUCTURE` | `400` | File content does not match the expected structure |
| `TOO_MANY_KEYS` | `400` | Import exceeds the maximum number of translation keys |
| `ZIP_BOMB_DETECTED` | `400` | Compressed file detected as potentially malicious |
| `JSON_BOMB_DETECTED` | `400` | JSON file detected as potentially malicious |
| `IMPORT_SESSION_EXPIRED` | `401` | The import session timed out. Start the import again |
| `IMPORT_SESSION_MISMATCH` | `403` | Import confirmation does not match the session that started it |

### Rate limiting

| Code | Status | When |
|------|--------|------|
| `TOO_MANY_REQUESTS` | `429` | Any limit the rate-limit plugin enforces: the global limit, every per-route override, or repeated failed authentication. From the global limiter, `meta.expiresIn` is a human-readable wait such as `"1 minute"`. From the failed-authentication limiter, `meta.retryAfter` is a number of seconds and there is no `expiresIn` |
| `RATE_LIMIT_EXCEEDED` | `429` | A route-specific limit the handler enforces itself, such as type generation or the import session bucket |

### Billing

| Code | Status | When |
|------|--------|------|
| `READ_ONLY_MODE` | `403` | The organization is in read-only mode. Every write endpoint can return this |
| `LIMIT_EXCEEDED` | `403` | Plan limit reached, such as max keys, projects, or members. `meta` carries `metric`, `current`, and `limit` |
| `FEATURE_NOT_AVAILABLE` | `403` | Feature not available on the current plan |
| `ACTIVE_SUBSCRIPTION_REQUIRED` | `400` | A paid subscription is still linked, so this change is refused |
| `CANNOT_CANCEL_FREE_PLAN` | `400` | Cannot cancel a free plan |
| `BILLING_NOT_INITIALIZED` | `500` | The organization has no billing record |

### Organizations

| Code | Status | When |
|------|--------|------|
| `CANNOT_REMOVE_LAST_OWNER` | `400` | Cannot remove or demote the last owner |
| `OWNED_FREE_ORG_LIMIT_EXCEEDED` | `403` | Free plan users can own at most one organization |
| `INVITATION_EXPIRED` | `400` | The invitation has expired |
| `INVALID_INVITATION` | `403` | The invitation was sent to a different email address |
| `CANNOT_DELETE_LAST_PROVIDER` | `400` | Cannot remove the only authentication provider |

### Projects

| Code | Status | When |
|------|--------|------|
| `CANNOT_REMOVE_DEFAULT_LOCALE` | `400` | Cannot remove the source language from a project |
| `LOCALE_NOT_ENABLED` | `400` | The locale is not enabled for this project |
| `NOT_ENOUGH_KEYS` | `422` | Too few keys to generate an AI context draft |
| `FAILED_TO_CREATE_PROJECT` | `500` | Internal error during project creation |

### Webhooks

| Code | Status | When |
|------|--------|------|
| `WEBHOOK_URL_NOT_ALLOWED` | `400` | The target URL is not a permitted destination |
| `WEBHOOK_RETRY_NOT_ALLOWED` | `400` | Only deliveries with `failed` status can be retried |

### Server

| Code | Status | When |
|------|--------|------|
| `INTERNAL_ERROR` | `500`, `502`, `504` | Unexpected internal error. `502` when an upstream provider failed, `504` when it timed out |
| `INTERNAL_SERVER_ERROR` | `500` | General server error |
| `CACHE_INVALIDATION_FAILED` | `500` | A permission change was refused because its cache flush failed |
| `UNKNOWN` | `500`, `413` | An error Comvi did not raise itself, with the status the server gave it: `413` for a body over the route's limit, otherwise `500`. In production a `5xx` message is replaced with `Internal server error` |

## Rate-limit headers

| Header | Meaning |
|--------|---------|
| `Retry-After` | Seconds to wait before retrying |
| `X-RateLimit-Limit` | Maximum requests in the window |
| `X-RateLimit-Remaining` | Requests remaining in the window |

Every limit the rate-limit plugin enforces sends these headers: the global limit and every per-route override, including machine translation, CDN deploys, AI context, and the auth and account routes. `RATE_LIMIT_EXCEEDED`, raised by handlers, and the `429` from repeated failed authentication carry the wait in `meta` instead.

## Next steps

- [REST API](/docs/api) for conventions and pagination
- [Authentication](/docs/api/authentication) for key and session rules
