Skip to content

REST API

The Venturi REST API is the programmatic interface to the attribution graph. It is fully described by a machine-generated OpenAPI 3.1 document, published alongside an interactive reference and a Postman collection, and it backs the typed TypeScript and Python SDKs. Every response is JSON, every list is cursor-paginated, every mutation is idempotent, and every error is a single, documented format.

Base URL

Venturi runs inside your trust boundary, so the base URL is your deployed instance — for example https://<your-venturi-instance>. All paths below are relative to that host. The public API lives under /api/v1; partner integrations use /api/partner/v1 under the same contract.

Authentication

All API calls are authenticated; an unidentifiable caller is rejected before any work is done. Venturi supports several mechanisms — choose the one that matches your integration.

Mechanism Use it for
Bearer JWT Access tokens for any surface; issued by OAuth2 flows below.
API key Long-lived, tenant-scoped keys for simple server-side integrations.
OAuth2 client-credentials Machine-to-machine; exchange client_id/client_secret for a short-TTL token.
OAuth2 authorization-code + PKCE Interactive, user-delegated access for apps acting on behalf of a user.
Authorization: Bearer <jwt>
X-API-Key: <key>

Access tokens are short-lived (≤60 minutes) signed JWTs, validated at every API boundary on iss, aud, exp, nbf, and tenant_id. The SDKs refresh tokens for you automatically.

Obtaining a token (client-credentials)

curl -X POST https://<your-venturi-instance>/api/v1/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=$ARGMIN_CLIENT_ID" \
  -d "client_secret=$ARGMIN_CLIENT_SECRET" \
  -d "scope=read:attribution read:reporting"
{
  "access_token": "eyJhbGci...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "read:attribution read:reporting"
}

The authorization server, its discovery document (GET /.well-known/oauth-authorization-server), and the public signing keys (GET /.well-known/jwks.json) are all served from your instance. Signing keys are kid-rotated; validate against the published JWKS.

Scopes

Every token and every API key carries an explicit scope set. Absence of a scope denies — nothing is granted by default, and no scope is implied by another. API-key scopes are drawn from a closed, least-privilege set:

Scope Grants
read:attribution Read attribution records, coverage, and the explanation surface.
read:reporting Read dashboards, reports, and aggregated metrics.
export:create Create FOCUS-aligned exports and poll their jobs.
admin:config Manage API keys, webhooks, budgets, identity, and tenant settings.

Scope is enforced server-side on every call and fails closed. A read:* key cannot create an export or change configuration. Scopes are chosen at creation and cannot be widened afterward — broadening a key requires rotating and reissuing it, so a key never silently escalates. An out-of-scope call returns 403 INSUFFICIENT_SCOPE.

Least privilege

Issue each integration the narrowest scope set it needs. An export:create key still composes residency rules and k=5 cohort suppression on its output — key scope never bypasses a data-protection control.

API keys

Create, rotate, and revoke keys via the admin API or the admin console. A new key is shown exactly once at creation and stored only as a salted hash; you must select at least one scope (a scopeless key is rejected 422 NO_SCOPE) and may set an optional expiry within your tenant's policy-bounded maximum.

curl -X POST https://<your-venturi-instance>/api/v1/api-keys \
  -H "Authorization: Bearer $ARGMIN_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "chargeback-exporter",
    "scopes": ["read:attribution", "export:create"],
    "expires_at": "2026-12-31T00:00:00Z"
  }'

An expired key is rejected 401 EXPIRED regardless of its lifecycle state, and a revoked key denies immediately even if the secret is replayed. Key creation, rotation, and revocation are audit-logged.

Pagination

Every collection endpoint paginates with opaque, signed, stateless cursors — never offset/limit. Pass ?cursor=<token>&limit=<n>; the default limit is 50 and the maximum is 200. The response envelope is consistent across all collections:

{
  "data": [ ... ],
  "page": {
    "next_cursor": "eyJzb3J0Ijoi...",
    "limit": 50,
    "has_more": true
  }
}

Iterate by following next_cursor until has_more is false. Exhaustive iteration returns every record exactly once — no duplicates, no gaps — even under concurrent writes, because each cursor encodes the sort key, your tenant scope, and a freshness watermark.

# First page
curl "https://<your-venturi-instance>/api/v1/attribution?limit=100" \
  -H "Authorization: Bearer $ARGMIN_TOKEN"

# Next page
curl "https://<your-venturi-instance>/api/v1/attribution?cursor=eyJzb3J0Ijoi...&limit=100" \
  -H "Authorization: Bearer $ARGMIN_TOKEN"

Cursors are tenant-bound and tamper-evident

A cursor is HMAC-signed and expires. A cursor tampered in a single byte is rejected with 400; a cursor minted for one tenant returns 403 if replayed against another. The SDKs expose this as a transparent async iterator, so you rarely handle cursors by hand.

Filtering and sorting

Collections accept a constrained, allowlisted filter and sort grammar over indexed fields declared in the OpenAPI document:

GET /api/v1/attribution?filter[provider][eq]=openai&filter[cost_usd][gte]=0.01&sort=-cost_usd

Operators are eq, ne, gt, gte, lt, lte, in, and contains. Sort ascending with sort=field or descending with sort=-field. A filter or sort on a non-allowlisted field returns 400. Filters never cross tenant boundaries, and adoption/cohort surfaces enforce k=5 suppression regardless of how selective your filter is — a query that would isolate a sub-five-person cohort returns the parent rollup, never an individual.

Reading attribution

GET /api/v1/attribution returns attribution records; GET /api/v1/attribution/{id} returns one record with its full explanation. Each record carries the cost, energy, and carbon attributed to an invocation, its output state, and an interpretation evidence card.

curl https://<your-venturi-instance>/api/v1/attribution/attr_01HF8... \
  -H "Authorization: Bearer $ARGMIN_TOKEN"
{
  "id": "attr_01HF8...",
  "invocation_id": "inv_01HF8...",
  "provider": "openai",
  "served_model": "gpt-4o",
  "service": "support-copilot",
  "owner": "team-cx",
  "cost_usd": 0.0142,
  "energy_wh": 0.83,
  "carbon_gco2e": 0.31,
  "pathway_category": "application_direct",
  "output_state": "attributed",
  "interpretation": {
    "stage_origin": "stage_b",
    "confidence": 0.91,
    "confidence_band": "high",
    "evidence_basis": "identity_resolved",
    "model_version": "rail-2025.11",
    "degradation_state": "none",
    "freshness": "fresh",
    "export_eligibility": "eligible"
  }
}

The interpretation block is the evidence card present on every result:

Field Meaning
stage_origin Which pipeline stage produced the result: stage_a (deterministic), stage_b (trained inference), or stage_c (allocation).
confidence The operational confidence coper, capped at 0.95 by policy.
confidence_band A coarse band (high / medium / low) for quick decisions.
evidence_basis The strongest evidence that produced the attribution.
model_version The attribution model version that produced this result.
degradation_state none, degraded_serving, disabled, or model_recalled — see below.
freshness fresh, index_stale, or source_stale.
export_eligibility Whether the record meets the chargeback floor and is eligible for export.

Confidence and chargeback

confidence is coper, capped at 0.95 as a deliberate, auditable policy ceiling — not an error floor. Chargeback and savings-share billing use a single floor of 0.80: a record below it is reported honestly and is not export_eligibility: eligible. See confidence and evidence.

When attribution is served by the fail-open heuristic baseline instead of the trained model, degradation_state is degraded_serving and the result is advisory — surface it, but treat decisions as constrained. A disabled state means Stage-B inference is turned off for your tenant by configuration; model_recalled means the prior validated model served the result. The attribution graph and HRE pipeline pages explain these states in depth.

Creating exports

Exports are asynchronous. POST /api/v1/exports enqueues a job and returns immediately with a job handle; poll the job until it completes, then download from the signed, short-TTL result_url.

curl -X POST https://<your-venturi-instance>/api/v1/exports \
  -H "Authorization: Bearer $ARGMIN_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "format": "focus",
    "period": { "from": "2026-05-01", "to": "2026-05-31" },
    "group_by": ["owner", "provider"]
  }'
{ "job_id": "job_01HF9...", "status": "queued" }
curl https://<your-venturi-instance>/api/v1/exports/job_01HF9... \
  -H "Authorization: Bearer $ARGMIN_TOKEN"
{
  "job_id": "job_01HF9...",
  "status": "completed",
  "result_url": "https://<your-venturi-instance>/downloads/...",
  "expires_at": "2026-06-03T18:30:00Z"
}

Exports compose residency and k=5 suppression and include only records that meet the 0.80 chargeback floor. Subscribe to the export.completed and export.failed webhook events to avoid polling. See reporting and exports for the FOCUS schema.

Idempotency

Every mutating request — POST, PATCH, DELETErequires an Idempotency-Key header (a UUID or ULID you generate). Venturi persists the (tenant, route, key) → result mapping for 24 hours:

  • A replay with the same key and identical body returns the original result and status, with Idempotency-Replayed: true. Exactly one side effect occurs — one export job, one webhook, one key.
  • A replay with the same key but a different body returns 422 (IDEMPOTENCY_CONFLICT).
  • A missing key on a mutating request returns 400.
KEY=$(uuidgen)
# First call creates the export; a retry with the same KEY returns the same job.
curl -X POST https://<your-venturi-instance>/api/v1/exports \
  -H "Authorization: Bearer $ARGMIN_TOKEN" \
  -H "Idempotency-Key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "format": "focus", "period": { "from": "2026-05-01", "to": "2026-05-31" } }'

Idempotency state is tenant-scoped, invisible across tenants, and survives replica restarts — so a network retry never double-charges or double-creates. The SDKs attach an Idempotency-Key to every mutating call automatically.

Errors

All 4xx/5xx responses use application/problem+json per RFC 9457. Every error carries a stable error_code from a single, versioned, published catalog — so you can branch on the code, not parse prose.

{
  "type": "https://venturi.systems/errors/insufficient-scope",
  "title": "Insufficient scope",
  "status": 403,
  "detail": "This key lacks the export:create scope.",
  "instance": "/api/v1/exports",
  "error_code": "INSUFFICIENT_SCOPE",
  "trace_id": "01HF8...",
  "tenant_id": "tnt_01H...",
  "docs_url": "https://docs.venturi.systems/developers/api/#errors"
}

Error bodies never leak stack traces, internal hostnames, another tenant's identifiers, or any prompt/completion content. A 500 carries only a generic INTERNAL_ERROR with a trace_id you can quote to support.

Common codes:

error_code HTTP Retryable Meaning
INVALID_CREDENTIALS 401 no Token or key is invalid.
EXPIRED 401 no The API key has expired.
STEP_UP_REQUIRED 401 no (re-auth) A higher-assurance auth step is required.
INSUFFICIENT_SCOPE 403 no The credential lacks the required scope.
TENANT_MISMATCH 403 no The resource belongs to another tenant.
RESIDENCY_BLOCKED 409 no The operation crosses a data-residency boundary.
IDEMPOTENCY_CONFLICT 422 no Same key reused with a different body.
NO_SCOPE 422 no A key was created with no scope selected.
RATE_LIMITED 429 yes Throttled; honor Retry-After.
INTERNAL_ERROR 500 maybe Unexpected error; quote trace_id to support.

The error_code set is closed and versioned with the API major version — a code is never removed or repurposed within a major version. The full catalog is published as a machine-readable artifact and reflected in the SDK typed-error unions.

Rate limits

Limits are enforced per (tenant, principal, route-class) with a sustained rate plus a burst allowance; one tenant exhausting its bucket never affects another. A throttled call returns 429 with Retry-After, RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers. Read your current budget any time via GET /api/v1/rate-limit. See versioning and rate limits for header semantics and backoff guidance.

Next steps

  • Webhooks — get pushed events instead of polling.
  • SDKs — typed clients that handle auth, pagination, idempotency, and backoff for you.
  • Sandbox — build and test against synthetic data.
  • Versioning & rate limits — stability guarantees and deprecation lifecycle.