DEVELOPER DOCS

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.

SurfaceValue — do not rename
Auth headerx-opexia-api-key
Workspace hint headerx-opexia-workspace-id
API hostapi.opexia.dev
Ingest hostingest.opexia.dev
API path prefix/v1/observ/*
API key prefixesopx_live_, opx_test_, dr_live_, dr_test_
Python packageopexia-trace (import opexia.trace)
Span attributesopexia.*

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.

ServiceBase URLPurpose
Read APIhttps://api.opexia.devEverything in this document except the Ingest section — traces, usage, cost, fleet, settings, org/workspace management.
Ingest APIhttps://ingest.opexia.devOTLP span ingestion and SDK bootstrap only. High-volume write path.

The Read API serves four disjoint path families, all on api.opexia.dev:

Path familyContains
/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

HTTP
Authorization: Bearer dr_live_xxxxxxxxxxxxxxxxxxxx

2. opx_* header key

HTTP
x-opexia-api-key: opx_live_xxxxxxxxxxxxxxxxxxxx

3. Better-Auth JWT (dashboard sessions)

HTTP
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 endpointsSatisfied by
viewerviewer, dev, admin, owner
devdev, admin, owner
adminadmin, owner
ownerowner

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.

FamilyParamsTypeExample
/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:

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

code
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_cursor sits in two different places. On /traces it is at the top level; on /audit-log it is nested inside meta. Read it as body.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. /traces is 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.

StatusMeaningTypical cause
400Bad requestMalformed cursor, invalid enum, unparseable body
401UnauthenticatedMissing, malformed, or revoked credential
403ForbiddenAuthenticated but role too low, or key bound to another workspace
404Not foundUnknown org/workspace slug, wrong id type, or trace outside retention
409ConflictSlug already taken, or a stale confirm_token
415Unsupported media typeProtobuf sent to the OTLP endpoint (JSON only)
422Validation errorRequest body failed schema validation
429Rate limitedPer-org limit exceeded — back off and retry
503UnavailableDownstream store unreachable

422 bodies carry FastAPI's field-level detail:

JSON
{
  "detail": [
    { "loc": ["body", "hours"], "msg": "field required", "type": "value_error.missing" }
  ]
}

All other errors use a flat shape:

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

Shell
# 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"))

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()

Set the environment once:

Shell
export OPEXIA_API_KEY="opx_live_xxxxxxxxxxxxxxxxxxxx"
export OPEXIA_ORG="acme"
export OPEXIA_WORKSPACE="prod"

API index