ProductOSDocs

API

Errors

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

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

Envelope

{
  "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

HTTPcodeWhenFix
400bad_requestMalformed JSON body, missing required headersCheck your Content-Type, validate JSON before sending
401unauthorizedMissing, expired, revoked, or invalid bearer token / cookieRe-fetch your Supabase session; verify the Bearer prefix; confirm the token wasn't revoked or auto-revoked by a role change
403forbiddenThe 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
404not_foundResource doesn't exist, OR is in a different workspaceVerify the ID; confirm the token is for the correct workspace
404unsupported_entity, unsupported_junctionThe entity slug or junction name in the path isn't one the API servesCheck the slug against the entity reference (GET /api/v1/health lists them)
409conflictThe request clashes with current state: a Feedback API Idempotency-Key reused with a different body, a duplicate pending invitation, a sync already runningRead the message. Linking the same junction twice is not a conflict, it's an idempotent upsert
422validation_failedBody doesn't match the entity schemaThe message names the field (the Feedback API also returns details.field); refer to the entity reference
429rate_limitedYou exceeded the rate limit for this endpoint classHonour details.retryAfterSeconds; back off; see Rate limits
500internal_errorUnexpected server errorRetry once; if it persists, contact support with the time and request path
500workspace_requiredThe caller's account isn't a member of any workspaceAccept your workspace invitation, or sign in as a member

Handling errors

Two patterns, depending on language:

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);
  }
}
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

codeSafe to retry?Strategy
rate_limitedYesSleep details.retryAfterSeconds, then retry once
internal_errorOnceRetry once; if it fails again, don't loop
unauthorizedAfter refreshRefresh the token, retry once. Don't loop.
Everything 4xx elseNoFix 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 only; the entity endpoints ignore it, so check with GET before retrying a create.

Next

Last reviewed 2026-09-22