# Errors

> Every error code the ProductOS API returns, what causes it, and how to fix it.

Source: https://productos.ca/documentation/api/v1/errors (last reviewed 2026-09-22)

Every error response from `/api/v1/*` ships in the same envelope. Parse it once; handle it everywhere.

## Envelope

```json title="Error response"
{
  "error": {
    "code": "validation_failed",
    "message": "Field urgency must be one of: critical, high, medium, low",
    "details": {
      "received": "urgent"
    }
  }
}
```

- `code` is **stable**: write your error handling against this, never against `message`.
- `message` is human-friendly and may be revised in future releases. Don't string-match it.
- `details` is optional, schema varies per `code`. See the table below.
- HTTP status maps to the code family (4xx for caller-side errors, 5xx for server-side).

## Error codes

| HTTP | `code` | When | Fix |
| :--- | :--- | :--- | :--- |
| 400 | `bad_request` | Malformed JSON body, missing required headers | Check your `Content-Type`, validate JSON before sending |
| 401 | `unauthorized` | Missing, expired, revoked, or invalid bearer token / cookie | Re-fetch your Supabase session; verify the `Bearer ` prefix; confirm the token wasn't revoked or auto-revoked by a role change |
| 403 | `forbidden` | The caller's role lacks the capability (writes need `member`, permanent deletes `admin`, the activity log `admin`) | Re-issue the token at a higher role, or have an admin promote the owner |
| 404 | `not_found` | Resource doesn't exist, OR is in a different workspace | Verify the ID; confirm the token is for the correct workspace |
| 404 | `unsupported_entity`, `unsupported_junction` | The entity slug or junction name in the path isn't one the API serves | Check the slug against the entity reference (`GET /api/v1/health` lists them) |
| 409 | `conflict` | The request clashes with current state: a Feedback API `Idempotency-Key` reused with a different body, a duplicate pending invitation, a sync already running | Read the message. Linking the same junction twice is not a conflict, it's an idempotent upsert |
| 422 | `validation_failed` | Body doesn't match the entity schema | The message names the field (the Feedback API also returns `details.field`); refer to the entity reference |
| 429 | `rate_limited` | You exceeded the rate limit for this endpoint class | Honour `details.retryAfterSeconds`; back off; see [Rate limits](https://productos.ca/documentation/api/v1/rate-limits) |
| 500 | `internal_error` | Unexpected server error | Retry once; if it persists, contact support with the time and request path |
| 500 | `workspace_required` | The caller's account isn't a member of any workspace | Accept your workspace invitation, or sign in as a member |

## Handling errors

Two patterns, depending on language:

```typescript title="TypeScript (fetch)"
async function callApi<T>(path: string, init?: RequestInit): Promise<T> {
  const res = await fetch(`https://productos.ca${path}`, init);
  if (!res.ok) {
    const body = await res.json().catch(() => null);
    const code = body?.error?.code ?? "unknown";
    const message = body?.error?.message ?? `HTTP ${res.status}`;
    throw new ApiError(code, message, res.status, body?.error?.details);
  }
  return res.json();
}

class ApiError extends Error {
  constructor(
    public code: string,
    message: string,
    public status: number,
    public details?: unknown
  ) {
    super(message);
  }
}
```

```python title="Python"
import httpx

def call_api(path, **kw):
    r = httpx.request(kw.pop("method", "GET"), f"https://productos.ca{path}", **kw)
    if not r.is_success:
        body = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
        err = body.get("error", {})
        raise ApiError(
            code=err.get("code", "unknown"),
            message=err.get("message", r.reason_phrase),
            status=r.status_code,
            details=err.get("details"),
        )
    return r.json()

class ApiError(Exception):
    def __init__(self, code, message, status, details):
        super().__init__(message)
        self.code, self.status, self.details = code, status, details
```

## Retry policy

| `code` | Safe to retry? | Strategy |
| :--- | :--- | :--- |
| `rate_limited` | Yes | Sleep `details.retryAfterSeconds`, then retry once |
| `internal_error` | Once | Retry once; if it fails again, don't loop |
| `unauthorized` | After refresh | Refresh the token, retry once. Don't loop. |
| Everything 4xx else | No | Fix the request before retrying |

Idempotency: `POST /api/v1/{entity}` creates a new row on every call, so two retries create two rows. `PATCH`, `DELETE` and `POST .../restore` are safe to repeat, and linking (`POST .../links/{junction}`) is an upsert, so a retry is a no-op. The `Idempotency-Key` header is honoured by the [Feedback API](https://productos.ca/documentation/api/v1/feedback-api) only; the entity endpoints ignore it, so check with `GET` before retrying a create.

## Next

- [Rate limits](https://productos.ca/documentation/api/v1/rate-limits): limits per endpoint class and how to read 429s.
- [Authentication](https://productos.ca/documentation/api/v1/authentication): token types and scope semantics.
