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. |
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:
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:
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"]
}'
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, DELETE — requires 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.