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 (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://api.venturi.systems; 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.
HTTP
Authorization: Bearer <jwt>
HTTP
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)

Bash
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"
JSON
{
  "access_token": "eyJhbGci...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "attributions:read exports:read"
}

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. 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/invocations and POST /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 under Settings / 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.

Bash
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:

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

Bash
# 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:

Text Only
GET /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. 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 /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.

Bash
curl "https://<your-venturi-instance>/v1/attribution/attr_01HF8SNWNW4VMC42M411HGMRWC" \
  -H "Authorization: Bearer $VENTURI_TOKEN"
JSON
{
  "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.

Bash
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"]
  }'
JSON
{
  "job_id": "job_01HF9MZDJJPH61083HYVPAASW5",
  "status": "queued",
  "status_url": "/api/v1/jobs/job_01HF9MZDJJPH61083HYVPAASW5"
}
Bash
curl "https://<your-venturi-instance>/api/v1/jobs/job_01HF9MZDJJPH61083HYVPAASW5" \
  -H "Authorization: Bearer $VENTURI_TOKEN"
JSON
{
  "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_provisional": true 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.

Bash
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/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.

JSON
{
  "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_CONFLICT 422 no Same key reused with a different body.
NO_SCOPE 422 no A key was created with no scope selected.
WEBHOOK_URL_REJECTED 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_CAPTURABLE, INDIRECTLY_CAPTURABLE, 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.

Bash
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://api.venturi.systems, 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.
Give feedback on this page

Sign in to send feedback. Opens in a new tab.

Copy page details
Docs feedback: REST API
Page: https://docs.venturi.systems/developers/api/