REST API
Evigauge REST API Reference
On this page
Complete reference for the Evigauge observability API. Every endpoint below is generated from the live OpenAPI schema of the shipped services — methods, paths, parameters, defaults, request bodies and role gates are verbatim from the code.
Companion documents: SDK Reference · MCP & Claude Code Plugin
Naming: Evigauge vs opexia
The product is Evigauge (formerly OpexIA). The rename is a brand change only.
Every identifier on the wire is still opexia / pxcore. Use them exactly as
written — renaming them produces 401, 404, or a silently dropped span.
| Surface | Value — do not rename |
|---|---|
| Auth header | x-opexia-api-key |
| Workspace hint header | x-opexia-workspace-id |
| API host | api.opexia.dev |
| Ingest host | ingest.opexia.dev |
| API path prefix | /v1/observ/* |
| API key prefixes | opx_live_, opx_test_, dr_live_, dr_test_ |
| Python package | opexia-trace (import opexia.trace) |
| Span attributes | opexia.* |
Base URLs
There are two independently deployed services behind two hostnames. Sending a
read query to the ingest host (or a span batch to the API host) returns 404.
| Service | Base URL | Purpose |
|---|---|---|
| Read API | https://api.opexia.dev | Everything in this document except the Ingest section — traces, usage, cost, fleet, settings, org/workspace management. |
| Ingest API | https://ingest.opexia.dev | OTLP span ingestion and SDK bootstrap only. High-volume write path. |
The Read API serves four disjoint path families, all on api.opexia.dev:
| Path family | Contains |
|---|---|
/v1/observ/* | Observability data and per-workspace configuration |
/v1/orgs/* | Organizations, members, invitations |
/v1/workspaces/* | Workspaces and API keys |
/v1/auth/*, /v1/onboarding/* | Identity and first-run setup |
Interactive schema: https://api.opexia.dev/v1/observ/openapi.json ·
Swagger UI at https://api.opexia.dev/v1/observ/docs.
The OpenAPI document is served under the /v1/observ prefix rather than at the
root because the load balancer path-routes that prefix to the Read API.
Authentication
Authentication is unified: one of three credentials satisfies every authenticated endpoint. They are tried in this exact order, first match wins.
1. dr_* bearer key
Authorization: Bearer dr_live_xxxxxxxxxxxxxxxxxxxx
2. opx_* header key
x-opexia-api-key: opx_live_xxxxxxxxxxxxxxxxxxxx
3. Better-Auth JWT (dashboard sessions)
Authorization: Bearer <jwt>
x-opexia-workspace-id: 3f2b1c8e-9a4d-4f11-b7c2-1e5a8d3f6b90
RS256, verified against the configured issuer's JWKS. Use this for
browser/dashboard traffic. x-opexia-workspace-id selects which workspace the
session acts on; omit it and the user's first available membership is used.
Key vs JWT. An API key is bound to exactly one workspace, so it cannot reach another workspace's data regardless of the slug in the URL. A JWT carries a user identity whose reach is determined by their memberships. Use keys for servers and CI; use JWTs for interactive dashboard sessions.
Environments
live and test are separate key environments (opx_live_ / opx_test_).
Both address the same hosts — the key itself determines which environment the
call is attributed to.
Minting a key
Keys are created through the API or the dashboard — see
POST /v1/workspaces/{ws_id}/keys. The secret is
returned exactly once, at creation. It is stored hashed and cannot be
recovered; if lost, revoke it and mint a new one.
Roles and permissions
Four roles, ordered by privilege: viewer < dev < admin < owner.
Each endpoint lists a Role — the minimum required. Higher roles always pass.
| Gate shown on endpoints | Satisfied by |
|---|---|
viewer | viewer, dev, admin, owner |
dev | dev, admin, owner |
admin | admin, owner |
owner | owner |
Read access to observability data is deliberately broad (viewer); configuration
that changes cost or engine behaviour requires dev; anything structural or
destructive — retention periods, workspace deletion, ownership transfer —
requires admin or owner.
Conventions
Path parameters: slugs vs UUIDs
This is the most common integration mistake. The two path families use different identifier types for the same objects.
| Family | Params | Type | Example |
|---|---|---|---|
/v1/observ/orgs/{org}/workspaces/{ws}/... | {org}, {ws} | human slug | /v1/observ/orgs/acme/workspaces/prod/traces |
/v1/orgs/{org_id}/..., /v1/workspaces/{ws_id}/... | {org_id}, {ws_id} | UUID | /v1/workspaces/3f2b1c8e-9a4d-4f11-b7c2-1e5a8d3f6b90/keys |
A slug where a UUID is expected returns 404, not 400. Resolve slugs to UUIDs
with GET /v1/orgs/{org_id}/workspaces.
Response envelope
Most /v1/observ/* endpoints wrap their payload:
{
"data": ...,
"meta": { "request_id": "…", "schema_version": "1.0", "scorer_version": "…" }
}
The payload is under data, not items. meta carries the request ID
(quote it in support requests) and the scorer/schema versions the response was
produced under.
Cursor pagination
Paginated endpoints (/traces, /audit-log) use opaque cursors rather than
offsets — offsets skip rows when new data arrives mid-scan.
GET .../traces?page_size=50
→ { "data": [...], "next_cursor": "eyJ0cyI6...", "meta": {...} }
GET .../traces?page_size=50&cursor=eyJ0cyI6...
→ { "data": [...], "next_cursor": null, "meta": {...} } # null = last page
Treat the cursor as an opaque string. Stop when next_cursor is null.
next_cursorsits in two different places. On/tracesit is at the top level; on/audit-logit is nested insidemeta. Read it asbody.next_cursor ?? body.meta?.next_cursor— the client helpers below do exactly that.
The period parameter
Rollup endpoints take period, accepting day | week | month. Defaults vary
per endpoint and are stated explicitly in each table.
Timestamps
All timestamps are ISO 8601 UTC (2026-09-01T14:23:05Z). Time-valued query
parameters (start_time, end_time) accept the same format.
Response shapes
Endpoints backed by a Pydantic model have a stable, typed response. Most analytics endpoints instead return a computed JSON object that FastAPI does not model; those are marked not schema-modelled.
Read this before typing your frontend against a response.
For not schema-modelled endpoints, the envelope (
data/meta/next_cursor) and any field this document calls out in a table are taken from the handler code and are reliable. The remaining field names inside the JSON examples are illustrative — they show the shape and units, not a verified contract.Confirm the exact keys against one live response before generating types.
/tracesis the exception: its row keys are ClickHouse SELECT aliases, listed explicitly and stable.
Treat unlisted keys as additive — new keys may appear without a breaking change, so parse defensively.
Errors
Standard HTTP status codes with a JSON body.
| Status | Meaning | Typical cause |
|---|---|---|
400 | Bad request | Malformed cursor, invalid enum, unparseable body |
401 | Unauthenticated | Missing, malformed, or revoked credential |
403 | Forbidden | Authenticated but role too low, or key bound to another workspace |
404 | Not found | Unknown org/workspace slug, wrong id type, or trace outside retention |
409 | Conflict | Slug already taken, or a stale confirm_token |
415 | Unsupported media type | Protobuf sent to the OTLP endpoint (JSON only) |
422 | Validation error | Request body failed schema validation |
429 | Rate limited | Per-org limit exceeded — back off and retry |
503 | Unavailable | Downstream store unreachable |
422 bodies carry FastAPI's field-level detail:
{
"detail": [
{ "loc": ["body", "hours"], "msg": "field required", "type": "value_error.missing" }
]
}
All other errors use a flat shape:
{ "detail": "workspace not found" }
Rate limits
Limits apply per organization, per minute. Exceeding one returns 429. Retry
with exponential backoff and jitter — the client helpers below do this for you.
Quickstart
Install
# Python — nothing required for REST; httpx is recommended
pip install httpx
# TypeScript — fetch is built in on Node 18+, no dependency needed
Your first call
Fetch the most recent traces in a workspace.
import httpx
BASE = "https://api.opexia.dev"
API_KEY = "opx_live_xxxxxxxxxxxxxxxxxxxx"
ORG, WS = "acme", "prod"
r = httpx.get(
f"{BASE}/v1/observ/orgs/{ORG}/workspaces/{WS}/traces",
headers={"x-opexia-api-key": API_KEY},
params={"page_size": 10},
timeout=30,
)
r.raise_for_status()
# Payload is under "data", not "items".
for t in r.json()["data"]:
print(t["trace_id"], t.get("grade"), t.get("cost_usd_total"))
const BASE = "https://api.opexia.dev";
const API_KEY = process.env.OPEXIA_API_KEY!;
const ORG = "acme", WS = "prod";
const res = await fetch(
`${BASE}/v1/observ/orgs/${ORG}/workspaces/${WS}/traces?page_size=10`,
{ headers: { "x-opexia-api-key": API_KEY } },
);
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const { data } = await res.json(); // payload is under "data", not "items"
for (const t of data) console.log(t.trace_id, t.grade, t.cost_usd_total);
Client helpers
Every example in this reference uses one of the two clients below. Copy the one for your language once, then each endpoint example becomes a single line.
# evigauge.py
import os, time, random, httpx
class Evigauge:
def __init__(self, api_key=None, org=None, ws=None,
base="https://api.opexia.dev", timeout=30.0):
self.key = api_key or os.environ["OPEXIA_API_KEY"]
self.org = org or os.environ.get("OPEXIA_ORG")
self.ws = ws or os.environ.get("OPEXIA_WORKSPACE")
self.base = base.rstrip("/")
self._c = httpx.Client(timeout=timeout,
headers={"x-opexia-api-key": self.key})
def request(self, method, path, *, retries=3, **kw):
"""Call any endpoint. `path` may contain {org}/{ws} placeholders."""
url = self.base + path.format(org=self.org, ws=self.ws)
for attempt in range(retries + 1):
r = self._c.request(method, url, **kw)
# 429 and 5xx are transient — back off and retry.
if r.status_code in (429, 502, 503, 504) and attempt < retries:
time.sleep((2 ** attempt) + random.random())
continue
if r.status_code >= 400:
raise httpx.HTTPStatusError(
f"{r.status_code} {r.text}", request=r.request, response=r)
return r.json() if r.content else None
raise RuntimeError("unreachable")
def get(self, p, **kw): return self.request("GET", p, **kw)
def post(self, p, **kw): return self.request("POST", p, **kw)
def put(self, p, **kw): return self.request("PUT", p, **kw)
def patch(self, p, **kw): return self.request("PATCH", p, **kw)
def delete(self, p, **kw): return self.request("DELETE", p, **kw)
def paginate(self, path, **params):
"""Yield every row across all pages of a cursor-paginated endpoint."""
cursor = None
while True:
extra = {"cursor": cursor} if cursor else {}
page = self.get(path, params={**params, **extra})
# Payload is under "data", not "items".
yield from (page.get("data") or [])
# /traces puts next_cursor at the top level; /audit-log nests it in meta.
cursor = page.get("next_cursor") or (page.get("meta") or {}).get("next_cursor")
if not cursor:
return
evigauge = Evigauge()
// evigauge.ts
export interface EvigaugeOptions {
apiKey?: string; org?: string; ws?: string;
base?: string; timeoutMs?: number;
}
export class Evigauge {
private key: string; private org: string; private ws: string;
private base: string; private timeoutMs: number;
constructor(o: EvigaugeOptions = {}) {
this.key = o.apiKey ?? process.env.OPEXIA_API_KEY!;
this.org = o.org ?? process.env.OPEXIA_ORG!;
this.ws = o.ws ?? process.env.OPEXIA_WORKSPACE!;
this.base = (o.base ?? "https://api.opexia.dev").replace(/\/$/, "");
this.timeoutMs = o.timeoutMs ?? 30_000;
}
async request<T = any>(
method: string, path: string,
opts: { query?: Record<string, any>; body?: unknown; retries?: number } = {},
): Promise<T> {
const { query, body, retries = 3 } = opts;
const resolved = path.replace("{org}", this.org).replace("{ws}", this.ws);
const url = new URL(this.base + resolved);
for (const [k, v] of Object.entries(query ?? {}))
if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
for (let attempt = 0; ; attempt++) {
const res = await fetch(url, {
method,
signal: AbortSignal.timeout(this.timeoutMs),
headers: {
"x-opexia-api-key": this.key,
...(body ? { "content-type": "application/json" } : {}),
},
...(body ? { body: JSON.stringify(body) } : {}),
});
// 429 and 5xx are transient — back off and retry.
if ([429, 502, 503, 504].includes(res.status) && attempt < retries) {
await new Promise(r =>
setTimeout(r, 2 ** attempt * 1000 + Math.random() * 1000));
continue;
}
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
return res.status === 204 ? (undefined as T) : await res.json();
}
}
get = <T = any>(p: string, query?: Record<string, any>) => this.request<T>("GET", p, { query });
post = <T = any>(p: string, body?: unknown, query?: Record<string, any>) => this.request<T>("POST", p, { body, query });
put = <T = any>(p: string, body?: unknown) => this.request<T>("PUT", p, { body });
patch = <T = any>(p: string, body?: unknown) => this.request<T>("PATCH", p, { body });
del = <T = any>(p: string) => this.request<T>("DELETE", p);
/** Async-iterate every row across all pages of a cursor-paginated endpoint. */
async *paginate<T = any>(path: string, query: Record<string, any> = {}) {
let cursor: string | null = null;
do {
const page = await this.get(path, { ...query, ...(cursor ? { cursor } : {}) });
// Payload is under "data", not "items".
yield* ((page.data ?? []) as T[]);
// /traces puts next_cursor at the top level; /audit-log nests it in meta.
cursor = page.next_cursor ?? page.meta?.next_cursor ?? null;
} while (cursor);
}
}
export const evigauge = new Evigauge();
Set the environment once:
export OPEXIA_API_KEY="opx_live_xxxxxxxxxxxxxxxxxxxx"
export OPEXIA_ORG="acme"
export OPEXIA_WORKSPACE="prod"