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 (served at
GET /openapi.json on your instance), which also generates the interactive
reference and Postman collection that ship with the published API surface, 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
Examples use the placeholder https://<your-venturi-instance>. On the
SaaS tier, substitute https://; self-hosted,
substitute your own instance host (never api.venturi.systems). See
Deployment modes. All paths below are
relative to that base. Tenant data operations use /v1, product
configuration and ingestion use /api/v1, and partner integrations use
/api/partner/v1. A sandbox credential uses the same base URL and reaches
your sandbox tenant (see Sandbox).
Authentication¶
Every tenant data, product, and partner API call is authenticated; an unidentifiable caller is rejected before any work is done. Apart from the separate, anonymous control plane, the only public routes are the OAuth discovery document, the signing-key set (JWKS), and the health probes, and none of them returns tenant data.
The drop-in proxy routes under /api/v1/proxy/ are the one exception to the
Venturi-credential rule. A proxied call carries your provider API key instead,
and Venturi admits it only when that key is registered to your tenant: an
unregistered key is rejected 403 before anything is forwarded. If the lookup
that resolves the key cannot answer in time, the call is forwarded unmodified
under the fail-open rule and carries null attribution. See
Drop-in proxy.
Venturi supports several mechanisms for every other route: 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 | Tenant-scoped keys for simple server-side integrations. A key stays valid until it expires, is rotated, or is revoked, so scope it narrowly and rotate it. |
| 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>/v1/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=$VENTURI_CLIENT_ID" \
-d "client_secret=$VENTURI_CLIENT_SECRET" \
-d "scope=attributions:read exports:read"
{
"access_token": "eyJhbGci...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "attributions:read exports:read"
}
The authorization server, its discovery document
(GET /.), and the public signing keys
(GET /.) 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. Every tenant data, product-configuration, and partner endpoint declares one required scope from this closed catalog of eleven scopes, and the OpenAPI document publishes the same set:
| Scope | Grants |
|---|---|
attributions:read |
Read attribution records, their evidence cards, and the coverage map. |
invocations:read |
Read normalized InvocationEvent records. |
exports:read |
Poll export jobs and download completed exports. |
exports:write |
Create FOCUS exports and other export jobs. |
governance:read |
Read budgets and governance policies. |
governance:write |
Create and change budgets and governance policies. |
webhooks:manage |
Register, verify, rotate, remove, and replay webhook endpoints. |
keys:manage |
Create, rotate, and revoke API keys, and read their usage and rate-limit state. |
connectors:manage |
Configure connectors and read their health. |
admin |
Manage tenant settings, identity, and roles. It does not imply any other scope. |
workforce:read |
Reach adoption cohort surfaces. Only an explicit grant of this scope reaches them; no role, including Admin, implies it. |
Three surfaces sit outside the catalog, and each authenticates in its own way:
- Ingestion routes (
POST /api/v1/invocationsandPOST /api/v1/events). The catalog has no ingestion scope: these routes accept any valid credential issued to your tenant and declare no catalog scope. See Ingestion API authentication. - Drop-in proxy routes (
/api/v1/proxy/...). A proxied call carries your registered provider API key, not a Venturi credential (see Authentication). - SCIM endpoints (
/scim/v2). Your identity provider authenticates with the SCIM bearer token issued underSettings / Admin / Identity / SCIM, not with a scoped Venturi credential. See SCIM.
Scope is enforced server-side on every call and fails closed. A key that
holds only *:read scopes 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 exports:write
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 through the API (with a credential that holds
keys:manage) or in Settings / Admin / Credentials in 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. An API key is a long-lived
credential until it expires, is rotated, or is revoked: give each key an expiry,
keep its scopes narrow, and rotate it on a schedule.
curl -X POST "https://<your-venturi-instance>/api/v1/api-keys" \
-H "Authorization: Bearer $VENTURI_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"name": "chargeback-exporter",
"scopes": ["attributions:read", "exports:read", "exports:write"],
"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": [
{ "id": "attr_01HF8SNWNW4VMC42M411HGMRWC", "cost_usd": "0.0142" }
],
"page": {
"next_cursor": "eyJzb3J0IjoiLWNyZWF0ZWRfYXQifQ.IrYusFGVqRpbbQNLuBAHozf0AEG04Xie",
"limit": 50,
"has_more": true
}
}
Each element of data is a full record; the example abbreviates it to two
fields.
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>/v1/attribution?limit=100" \
-H "Authorization: Bearer $VENTURI_TOKEN"
# Next page: NEXT_CURSOR holds page.next_cursor from the previous response
curl "https://<your-venturi-instance>/v1/attribution?cursor=$NEXT_CURSOR&limit=100" \
-H "Authorization: Bearer $VENTURI_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. Filter and sort keys
are the snake_case REST field names (cost_usd), including when you call through
an SDK. When you send this query with cURL, quote the URL and add
--globoff so the shell and cURL leave the brackets alone. 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 /v1/attribution returns attribution records; GET /
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>/v1/attribution/attr_01HF8SNWNW4VMC42M411HGMRWC" \
-H "Authorization: Bearer $VENTURI_TOKEN"
{
"id": "attr_01HF8SNWNW4VMC42M411HGMRWC",
"invocation_id": "inv_01HF8YKE42NXAT95733Q9N16Q2",
"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": "DIRECT_API",
"output_state": "strongly_inferred",
"interpretation": {
"stage_origin": "stage_b",
"confidence": 0.91,
"confidence_band": "chargeback_ready",
"evidence_basis": "identity_resolved",
"model_version": "rail-2025.11",
"degradation_state": "none",
"freshness": "fresh",
"export_eligibility": "eligible"
}
}
Money, energy, and carbon quantities in API responses and webhook payloads are
decimal strings, to avoid binary floating-point drift. The one numeric money
input is estimated_cost_usd on the Ingestion API, a
capture-time estimate that the controlled InvocationEvent schema types as a
JSON number; Venturi stores it as a fixed-precision decimal, so every amount you
read back is a decimal string. 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 edge-existence inference), or stage_c (allocation). |
confidence |
The operational confidence coper, capped at 0.95 by policy. |
confidence_band |
The canonical band (chargeback_ready / provisional / estimated) for quick decisions. |
evidence_basis |
The strongest evidence that produced the attribution. |
model_version |
When inference was involved, the attribution model version that produced this result. Deterministic Stage A results do not require model inference. |
degradation_state |
none, degraded_serving, disabled, or model_recalled (see below). |
freshness |
fresh, index_stale, or source_stale. |
export_eligibility |
eligible only when the record meets the 0.80 chargeback floor and its degradation_state is none; any other degradation state makes it ineligible whatever its confidence. |
Confidence and chargeback
confidence is coper, capped at 0.95 as a deliberate, auditable policy
ceiling, not an error floor. Internal chargeback and verified-savings
calculations use a single floor of 0.80. A record below it is reported
with its actual confidence band and is not export_eligibility: eligible. A record whose
degradation_state is not none is not eligible either, even at 0.80 or
higher. See confidence and evidence.
When Stage B’s trained edge-existence model is unavailable, the asynchronous
attribution processor falls back to the deterministic heuristic baseline so
attribution processing keeps flowing. A result produced that way carries
degradation_state: degraded_serving and is advisory: surface it, but treat
decisions as constrained. A disabled state means Stage B inference is turned
off for your tenant by configuration, so the heuristic baseline answered;
model_recalled means the prior validated model served the result.
degradation_state is therefore the API form of the result-source marker, and
any value other than none keeps the record out of chargeback exports. The
attribution graph and
attribution pipeline pages explain these states in depth.
Creating exports¶
FOCUS exports are asynchronous. POST /v1/exports/focus enqueues a job and
returns immediately with a job handle; poll the returned status URL until it
completes, then download from the signed, short-TTL result URL.
curl -X POST "https://<your-venturi-instance>/v1/exports/focus" \
-H "Authorization: Bearer $VENTURI_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_01HF9MZDJJPH61083HYVPAASW5",
"status": "queued",
"status_url": "/api/v1/jobs/job_01HF9MZDJJPH61083HYVPAASW5"
}
curl "https://<your-venturi-instance>/api/v1/jobs/job_01HF9MZDJJPH61083HYVPAASW5" \
-H "Authorization: Bearer $VENTURI_TOKEN"
{
"job_id": "job_01HF9MZDJJPH61083HYVPAASW5",
"status": "succeeded",
"result_url": "https://<your-venturi-instance>/downloads/<signed-object-path>",
"expires_at": "2026-06-03T18:30:00Z"
}
Exports compose residency and k=5 suppression. The chargeback file includes only
export-eligible records: those that meet the 0.80 chargeback floor with
degradation_state none. To also review the records that fall below the
floor, add "include_ to the request body; the job’s result is
then a ZIP archive holding the unchanged chargeback file and a separate
provisional review file of the sub-floor records, which is never a chargeback or
billing basis. Subscribe to the export.completed and
export.failed webhook events to avoid polling. See
reporting and exports for the FOCUS
schema.
Idempotency¶
Every mutating tenant data, product, and partner API request (POST, PATCH,
DELETE) requires an Idempotency-Key header (a UUID or ULID you
generate). Three kinds of route are exempt, and Venturi neither requires nor
interprets the header on them:
- the OAuth token endpoint (
POST /v1/oauth/token), a standard RFC 6749 form post; - the drop-in proxy routes (
/api/v1/proxy/...), which forward your provider SDK’s requests unchanged; - the SCIM endpoints (
/scim/v2), which standard SCIM 2.0 clients such as Okta and Microsoft Entra ID call without the header.
For every covered route, 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 covered mutating request returns
400.
The ingestion routes (POST /api/v1/invocations and POST /api/v1/events)
follow the same header rules. On those routes event_id adds a second,
independent guarantee: Venturi deduplicates the invocation itself on
event_id, so an event re-sent under a new Idempotency-Key still produces one
invocation. Keep both values stable when you retry an event; see
Ingestion API idempotency.
KEY=$(uuidgen)
# First call creates the export; a retry with the same KEY returns the same job.
curl -X POST "https://<your-venturi-instance>/v1/exports/focus" \
-H "Authorization: Bearer $VENTURI_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/ 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/problems/insufficient-scope",
"title": "Insufficient scope",
"status": 403,
"detail": "This key lacks the exports:write scope.",
"instance": "/v1/exports/focus",
"error_code": "INSUFFICIENT_SCOPE",
"trace_id": "5f0c2d7e-8a41-4b6f-9c3e-2d71a8b4e019",
"tenant_id": "tnt_01HF774RGJ2YZB7V8TNEXESE0V",
"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_ |
422 | no | Same key reused with a different body. |
NO_SCOPE |
422 | no | A key was created with no scope selected. |
WEBHOOK_ |
422 | no | Endpoint URL failed the public-HTTPS and SSRF checks. |
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 with the OpenAPI document (GET /openapi.json on your instance) and
the changelog, and the SDK typed-error unions are generated from it.
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.
The numeric limits are tenant-specific: the plan entitlements on your order form
set the defaults for each route class, and Venturi can override them for your
tenant. Settings / Admin / Billing shows those entitlements. The SCIM endpoints carry
their own published default limit (see SCIM).
Every authenticated response carries the calling credential’s RateLimit-Limit,
RateLimit-Remaining, and RateLimit-Reset; a throttled call returns 429 and
additionally carries Retry-After. GET /api/v1/rate-limit, which requires
keys:manage, is the authoritative lookup for any credential’s current limit and
remaining budget in your tenant. See
versioning and rate limits for route
classes, header semantics, and backoff guidance.
Coverage¶
GET /api/v1/coverage returns the tenant’s per-pathway coverage map, including
deployed capture layers, missing sources, discovery-only pathways, degradation
state, and each pathway’s capture-feasibility label (FULLY_CAPTURABLE,
PARTIALLY_, INDIRECTLY_, or UNCAPTURABLE). It requires
attributions:read. The map never implies universal visibility, and it never
converts CASB, DNS, firewall, or admin-API discovery into request-level
attribution. Connector health for the same pathways is at GET /api/v1/connectors.
curl "https://<your-venturi-instance>/api/v1/coverage" \
-H "Authorization: Bearer $VENTURI_TOKEN"
Partner tokens and SaaS tenant routing¶
Partner clients use OAuth 2.0 client credentials under /api/partner/v1. A
partner token is a short-lived access token (60 minutes or less, like every
access token) whose scopes come from the same closed catalog, limited
to the scopes registered for that partner’s connector. The token is also bound
to the connector’s source systems, one tenant, and one residency lane. Partner
status grants no implicit admin or cross-tenant access.
On the shared SaaS hostname, every tenant calls https://,
and the credential decides which dedicated data plane a request reaches. A
Bearer JWT routes by its signed tenant_id claim; an API key, which carries no
claim, routes to the tenant that owns the key once Venturi verifies it against
the stored salted hash; a token request routes by its client_id, which is
registered to one tenant. A URL or request-body tenant identifier cannot
override the credential’s tenant, and a mismatch returns 403 TENANT_MISMATCH.
The unauthenticated discovery document, JWKS, and health probes return the same
tenant-free response to every caller. See
tenant routing on the SaaS host
for the full table, including the drop-in proxy.
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.
Sign in to send feedback. Opens in a new tab.
Copy page details
Docs feedback: REST API
Page: https://docs.venturi.systems/developers/api/