# Error Handling

Error envelope, full code list, and the retry strategies that actually work

Every error response uses the same envelope as a success response — only `success` flips to
`false` and `code` carries a machine-readable reason. Branch on `code` (not the human message) in
your handlers; the code is stable, the message is not.

## Error envelope

```json
{
"success": false,
"status": 400,
"code": "VALIDATION_ERROR",
"message": "Validation failed for fields: website",
"meta": {
  "errors": [
    { "field": "website", "error": "Invalid url", "location": "body" }
  ]
}
}
```

| Field     | Description                                                    |
| --------- | -------------------------------------------------------------- |
| `success` | Always `false` on errors                                       |
| `status`  | HTTP status code, mirrored from the response line              |
| `code`    | Stable machine-readable code — branch on this in your handlers |
| `message` | Human-readable summary — show to end users, do not parse       |
| `meta`    | Structured details: validation errors, required scopes, etc.   |

Each entry in `meta.errors` has `field` (the offending field path), `error` (the message), and `location` (`body`, `query`, or `params`).

## Error code reference

| Code                       | HTTP | Meaning                                                          |
| -------------------------- | ---- | ---------------------------------------------------------------- |
| `VALIDATION_ERROR`         | 400  | One or more request fields failed validation                     |
| `AUTHENTICATION_REQUIRED`  | 401  | No credential on a protected route                               |
| `NO_TOKEN_PROVIDED`        | 401  | Authorization header present but empty                           |
| `INVALID_OR_EXPIRED_TOKEN` | 401  | Session or token is malformed, expired, or revoked               |
| `INVALID_API_KEY`          | 401  | `X-Api-Key` value did not verify                                 |
| `USER_NOT_ACTIVE`          | 401  | Credential is valid but the user account is deactivated          |
| `SUBSCRIPTION_REQUIRED`    | 402  | Action requires an active subscription on the organization       |
| `INSUFFICIENT_PERMISSIONS` | 403  | Credential lacks a required scope (`projects:write`, etc.)       |
| `NO_SCOPES_AVAILABLE`      | 403  | Token carried no scopes at all                                   |
| `NO_PERMISSIONS`           | 403  | Session user has no permissions resolved                         |
| `API_KEY_MISSING_SCOPES`   | 403  | PAT is missing one or more scopes the route requires             |
| `FORBIDDEN`                | 403  | Valid credential but wrong tenant — e.g. org-pinned PAT mismatch |
| `MFA_REQUIRED`             | 403  | Organization requires MFA and the user has not completed it      |
| `TENANT_VIOLATION`         | 403  | Resource belongs to a different organization than the URL        |
| `NOT_ORGANIZATION_MEMBER`  | 403  | User is not a member of the requested organization               |
| `NOT_FOUND`                | 404  | Resource does not exist (or is not visible to this caller)       |
| `ALREADY_EXISTS`           | 409  | Duplicate resource — usually a slug or email collision           |
| `FAILED_CREATE`            | 409  | Create failed due to a constraint violation                      |
| `FAILED_UPDATE`            | 409  | Update failed due to a constraint violation                      |
| `FAILED_DELETE`            | 409  | Delete failed due to a constraint violation                      |
| `RATE_LIMIT_ERROR`         | 429  | Per-IP or per-endpoint rate limit exceeded                       |
| `ERROR`                    | 500  | Unhandled server error — check the trace id when filing a bug    |
| `SERVICE_UNAVAILABLE`      | 503  | A required dependency is temporarily unavailable                 |

## Common error examples

### Validation error

```json
{
"success": false,
"status": 400,
"code": "VALIDATION_ERROR",
"message": "Validation failed for fields: name, website, slug",
"meta": {
  "errors": [
    { "field": "name",    "error": "Required",                "location": "body" },
    { "field": "website", "error": "Invalid url",             "location": "body" },
    { "field": "slug",    "error": "The slug must be unique", "location": "body" }
  ]
}
}
```

Iterate `meta.errors` to surface per-field messages in your UI.

### Missing authentication

```json
{
"success": false,
"status": 401,
"code": "AUTHENTICATION_REQUIRED",
"message": "Authentication required",
"meta": {}
}
```

Send a session cookie, an `X-Api-Key` header, or an `Authorization: Bearer ...` token.

### Subscription or plan limit

```json
{
"success": false,
"status": 402,
"code": "SUBSCRIPTION_REQUIRED",
"message": "An active subscription is required",
"meta": {
  "organizationId": "550e8400-e29b-41d4-a716-446655440000",
  "billingCode": "active_subscription_required"
}
}
```

Billing and plan-limit errors carry `meta.billingCode` — one of `active_subscription_required`,
`feature_not_in_tier`, `seat_cap_exceeded`, or `slot_cap_exceeded` — so you can branch on the
exact limit without parsing the message. Cap errors also include `meta.cap` and `meta.current`.

### Insufficient scope

```json
{
"success": false,
"status": 403,
"code": "INSUFFICIENT_PERMISSIONS",
"message": "Insufficient permissions. Required: projects:write",
"meta": {}
}
```

Use a credential that has `projects:write`, or issue a new PAT with the missing scope.

### Rate limit

```json
{
"success": false,
"status": 429,
"code": "RATE_LIMIT_ERROR",
"message": "Too many requests from this IP, please try again later.",
"meta": {
  "message": "Too many requests from this IP, please try again later."
}
}
```

Read the `Retry-After` header and back off. See [Rate Limits](/docs/get-started/rate-limits).

## Handling errors in code

### Branch on `code`, not `status`

**JavaScript**

```javascript
async function call(url, options) {
const res = await fetch(url, options);
const envelope = await res.json();

if (envelope.success) return envelope.data;

switch (envelope.code) {
case 'VALIDATION_ERROR':
throw new ValidationError(envelope.meta?.errors ?? []);
case 'AUTHENTICATION_REQUIRED':
case 'INVALID_OR_EXPIRED_TOKEN':
case 'INVALID_API_KEY':
throw new AuthError(envelope.message);
case 'INSUFFICIENT_PERMISSIONS':
case 'FORBIDDEN':
case 'NOT_ORGANIZATION_MEMBER':
throw new PermissionError(envelope.message);
case 'NOT_FOUND':
return null;
case 'RATE_LIMIT_ERROR':
throw new RateLimitError(Number(res.headers.get('Retry-After')) || 60);
default:
throw new ApiError(envelope.code, envelope.message, envelope.meta);
}
}
```

**Python**

```python
def call(method, url, **kwargs):
res = session.request(method, url, **kwargs)
envelope = res.json()

  if envelope.get('success'):
      return envelope.get('data')

  code = envelope.get('code')
  if code == 'VALIDATION_ERROR':
      raise ValidationError(envelope.get('meta', {}).get('errors', []))
  if code in {'AUTHENTICATION_REQUIRED', 'INVALID_OR_EXPIRED_TOKEN', 'INVALID_API_KEY'}:
      raise AuthError(envelope.get('message'))
  if code in {'INSUFFICIENT_PERMISSIONS', 'FORBIDDEN', 'NOT_ORGANIZATION_MEMBER'}:
      raise PermissionError(envelope.get('message'))
  if code == 'NOT_FOUND':
      return None
  if code == 'RATE_LIMIT_ERROR':
      raise RateLimitError(int(res.headers.get('Retry-After', 60)))
  raise ApiError(code, envelope.get('message'), envelope.get('meta'))
```

### Retry with exponential backoff

Retry only idempotent requests on `429` and transient `5xx`. Never retry a `4xx` (except `429`) — retries on `401`, `403`, and `404` will always fail.

**JavaScript**

```javascript
async function withRetry(fn, { retries = 3 } = {}) {
for (let attempt = 0; attempt <= retries; attempt++) {
  try {
    return await fn();
  } catch (err) {
    const retriable = err.code === 'RATE_LIMIT_ERROR' || (err.status >= 500 && err.status < 600);
    if (!retriable || attempt === retries) throw err;

    const wait = err.retryAfter ?? Math.pow(2, attempt);
    await new Promise((r) => setTimeout(r, wait * 1000));
  }

}
}
```

**Python**

```python
import time

def with_retry(fn, retries=3):
for attempt in range(retries + 1):
try:
return fn()
except (RateLimitError, ServerError) as err:
if attempt == retries:
raise
wait = getattr(err, 'retry_after', pow(2, attempt))
time.sleep(wait)
```

## User-facing messages

Translate error codes to friendly copy at the UI boundary — never pass the raw `message` straight through to end users in production.

```javascript
const MESSAGES = {
'VALIDATION_ERROR': 'Please check the highlighted fields and try again.',
'AUTHENTICATION_REQUIRED': 'Your session has expired. Sign in again to continue.',
'INVALID_OR_EXPIRED_TOKEN': 'Your session has expired. Sign in again to continue.',
'INSUFFICIENT_PERMISSIONS': "You don't have permission to do that.",
'SUBSCRIPTION_REQUIRED': 'This action needs an active subscription.',
'NOT_FOUND': "We couldn't find what you were looking for.",
'ALREADY_EXISTS': 'That name is already taken.',
'RATE_LIMIT_ERROR': "You're doing that too fast. Please wait a moment.",
'ERROR': 'Something went wrong on our end. Please try again shortly.',
};

function friendly(envelope) {
return MESSAGES[envelope.code] ?? 'An unexpected error occurred.';
}
```

## Debugging tips

> **Info:** **Every response carries a trace id.** The `X-Trace-Id` response header identifies the exact
> request — quote it when filing a bug and support can find it instantly.

> **Warning:** **Never log full error payloads that contain request bodies.** Validation errors reference the
> offending field names; the user-supplied values may be sensitive.

## Best Practices

- **Branch on `code`, display `message`:** Code is stable across versions; message is copy that may change.
- **Treat `401` and `403` as distinct:** `401` means "reauthenticate"; `403` means "this credential will never work — do not retry."
- **Surface validation errors per field:** `meta.errors` is an array of `{ field, error, location }` objects — render them next to the offending input.
- **Store `X-Trace-Id` with your error logs:** It is the fastest path to a resolution when filing a bug.
- **Do not swallow `NOT_FOUND` unconditionally:** For writes, a 404 often means the preceding read was stale. Investigate instead of silently retrying.
- **Write tests for the failure paths:** Contract tests that assert on `code` catch drift faster than end-to-end happy-path tests.

## Next Steps

1. **Plan for 429s:** Read [Rate Limits](/docs/get-started/rate-limits) for backoff and header details.
2. **Lock down scopes:** Check [Scopes & Permissions](/docs/get-started/scopes) to avoid `INSUFFICIENT_PERMISSIONS` in production.
3. **Configure SSO:** If you administer an organization, set up [Enterprise SSO](/docs/get-started/enterprise-sso).
4. **Browse endpoints:** Start integrating from the [API Reference](/docs/api-reference/api-keys).