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"
}
}
}codeis stable: write your error handling against this, never againstmessage.messageis human-friendly and may be revised in future releases. Don't string-match it.detailsis optional, schema varies percode. 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 |
| 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:
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, detailsRetry 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 only; the entity endpoints ignore it, so check with GET before retrying a create.
Next
- Rate limits: limits per endpoint class and how to read 429s.
- Authentication: token types and scope semantics.