Developer Portal
Build on Vouli IQ
A REST API over your organisation's governance data — scores, risks, signals, roadmap, compliance, vendors, talent, incidents and KPIs — that your other systems can also push into: risks, incidents, KPI actuals and vendor inventory accept writes, and webhook subscriptions and the audit export can be managed with a key. Plus signed outbound webhooks. One envelope, cursor pagination, per-key rate limits, and a sandbox you can point CI at.
- Current version
- 2026-09-12
- Base URL
- https://www.vouliiq.com/api/v1
Interactive reference · Postman collection
Authentication
Every request carries an API key as a bearer token. Keys are minted in Dashboard → API keys by an admin, are scoped per resource family, and are stored only as a scrypt hash — the plaintext is shown once.
curl https://www.vouliiq.com/api/v1/risks?limit=10 \
-H "Authorization: Bearer vk_live_XXXXXXXX_your_secret" \
-H "Vouli-Version: 2026-09-12"
vk_live_… reads your real data. vk_test_… reads the sandbox.- Keys minted before the namespace rename begin
bk_ and remain valid indefinitely. - The
x-api-key header still works but is deprecated (sunset 12 Sep 2027). It runs through the identical authentication, scope and rate-limit path — move to Authorization: Bearer. - Rotation: mint a new key, switch your callers, revoke the old one. Revocation takes effect immediately.
- Building an app that other organisations connect? Use OAuth 2.0 instead of a key: its access tokens (
vou_at_…) are accepted everywhere a key is.
Resources and scopes
A key must hold the resource's scope (or read:all). A key without it gets 403 insufficient_scope.
Resources and scopes| Endpoint | Returns | Scope | Description |
|---|
| GET /api/v1/scores | list of score | read:scores | List completed assessment scores, newest first. |
| GET /api/v1/scorecard | scorecard | read:scorecard | Retrieve the most recent completed SOVEREIGN scorecard. |
| GET /api/v1/risks | list of risk | read:risks | List the AI risk register, newest first. |
| GET /api/v1/signals | list of signal | read:signals | List the current signal set. |
| GET /api/v1/roadmap | roadmap | read:roadmap | Retrieve the active transformation roadmap. |
| GET /api/v1/roadmap/milestones | list of milestone | read:roadmap | List milestones of the active roadmap, newest first. |
| GET /api/v1/compliance | compliance_posture | read:compliance | Retrieve the active compliance posture. |
| GET /api/v1/compliance/obligations | list of obligation | read:compliance | List compliance obligations, newest first. |
| GET /api/v1/vendor | vendor_portfolio | read:vendor | Retrieve the active AI vendor portfolio posture. |
| GET /api/v1/vendor/inventory | list of vendor | read:vendor | List the AI vendor inventory, newest first. |
| GET /api/v1/talent | talent_readiness | read:talent | Retrieve the active talent readiness assessment. |
| GET /api/v1/talent/gaps | list of talent_gap | read:talent | List talent gaps, newest first. |
| GET /api/v1/incidents | list of incident | read:incidents | List AI incidents, newest first. |
| GET /api/v1/kpis | list of kpi | read:kpis | List KPIs (key results) with their targets and latest actuals, newest first. |
| GET /api/v1/metrics | list of metric | read:metrics | List approved metric definitions — the governed meaning of each KPI. |
| GET /api/v1/metrics/values | list of metric_value | read:metrics | Read a metric’s values point-in-time: as of a date, as known at a date. |
| GET /api/v1/documents | list of document | read:documents | List knowledge-base documents — metadata only, newest first. |
| GET /api/v1/board/votes | list of board_vote | read:board | List board votes — metadata only. |
| GET /api/v1/board/documents | list of board_document | read:board | List board documents' signature status — never their text. |
| GET /api/v1/board/packs | list of board_pack | read:board | List board packs' status — never their content. |
| GET /api/v1/decisions | list of decision | read:decisions | List the Decision Memory register (Enterprise). |
| GET /api/v1/insights | list of insight | read:insights | List AI insights — metadata and the stored headline, flagged ai_generated. |
Response envelopes
Two success shapes and one error shape, across the whole API. Read your rows from data.
// GET /api/v1/risks
{
"object": "list",
"api_version": "2026-09-12",
"data": [ { "object": "risk", "id": "…", "severity": "HIGH", … } ],
"has_more": true,
"next_cursor": "djF8MjAyNi0wOS0wMVQwOTowMDowMC4wMDBafC4uLg"
}
// GET /api/v1/scorecard (null until the first assessment completes)
{ "object": "scorecard", "api_version": "2026-09-12", "data": { … }, "has_more": false, "next_cursor": null }Pagination
Collections are cursor-paginated (keyset, not offset — pages never skip or repeat a row when data changes underneath you). Pass limit (1–100, default 25) and the previous response's next_cursor. A cursor is opaque: hand it back unchanged. A cursor we did not issue returns 400 invalid_cursor.
# page 1
curl "https://www.vouliiq.com/api/v1/risks?limit=50" -H "Authorization: Bearer $VOULI_API_KEY"
# page 2 — pass next_cursor back
curl "https://www.vouliiq.com/api/v1/risks?limit=50&cursor=$NEXT_CURSOR" -H "Authorization: Bearer $VOULI_API_KEY"
Both SDKs will follow the cursors for you: listAll() in TypeScript, list_all() in Python.
Rate limits
Per-key token bucket: the capacity equals your per-minute limit (so a burst up to it is allowed) and refills at limit/60 per second, with a second rolling 24-hour cap. Limits come from your plan and can be overridden per key. Every response — success or failure — tells you where you stand.
Rate-limit headers| Header | Meaning |
|---|
| X-RateLimit-Limit | Requests allowed per minute for this key. |
| X-RateLimit-Remaining | Requests still available right now. |
| X-RateLimit-Reset | Unix time (seconds) at which the bucket is full again. |
| X-RateLimit-Limit-Day | Requests allowed per rolling 24 hours for this key. |
| Retry-After | On a 429 only: seconds to wait before retrying. |
Errors
Every 4xx and 5xx has the same body. Branch on code — it is stable. The message is for humans and may be reworded. The request_id is also returned as X-Request-Id; quote it in a support request.
{
"error": {
"type": "permission_error",
"code": "insufficient_scope",
"message": "This key does not have the \"read:risks\" scope.",
"request_id": "req_9f2c1d4e8a7b40f1a0c3e5d7b9f10246"
}
}Every code we emit
missing_credential · invalid_api_key · tier_required · subscription_inactive · insufficient_scope · invalid_parameter · invalid_cursor · unsupported_version · rate_limit_exceeded · idempotency_key_reused · idempotency_in_flight · resource_not_found · feature_not_in_plan · idempotency_key_required · invalid_body · unknown_field · forbidden_field · payload_too_large · unsupported_media_type · not_found · resource_conflict · validation_failed · service_unavailable · internal_error
Versioning and deprecation
Additive changes (a new field, a new resource, a new enum member on a field documented as extensible) ship without a version bump — clients must ignore unknown fields. A BREAKING change ships as a new dated version: the operation gains an `x-deprecation` block naming the `sunset` date and its replacement, responses start carrying `Deprecation` and `Sunset` headers, and the old version keeps being served until that date. Pin a version with the `Vouli-Version` request header; omit it and you get the current one.
- Currently served:
2026-09-12. - Omit
Vouli-Version and you get the current version; an unknown value is a 400 unsupported_version. - A deprecated operation carries an
x-deprecation block in the spec and Deprecation / Sunset headers on its responses. - Treat unknown response fields as additive and ignore them — that is what keeps an additive change non-breaking.
Idempotency
Every POST that changes data REQUIRES an Idempotency-Key header (without one it is a 400 idempotency_key_required); PATCH and DELETE accept one. The first response for a key is cached for 24 hours and replayed — with Idempotency-Replayed: true — for any retry carrying the same key, so a network blip never double-creates. The same key with a different request body is a 409 idempotency_key_reused; a retry while the first request is still running is a 409 idempotency_in_flight. Both SDKs generate a key per call and resend it on every automatic retry.
Sandbox
Mint a Test key and it returns a fixed set of synthetic rows for every endpoint — every human-readable value prefixed [SANDBOX], every id in a reserved all-zero UUID range. A test key never reads tenant data, so you can build against it, demo with it and run it in CI. Responses carry Vouli-Environment: test. A write or management call from a test key is answered with a realistic simulated record (marked Vouli-Simulated: true) and writes nothing; a test webhook ping is simulated and sends nothing.
Quickstart — TypeScript
# Not on npm yet: registry publication is pending the first release.
# Until then, build and install it from the repository's sdk/ directory.
(cd sdk/vouli-iq && npm install && npm run build)
npm install ./sdk/vouli-iq
import { VouliIQ } from '@vouli-iq/sdk';
const vouli = new VouliIQ({ apiKey: process.env.VOULI_API_KEY! });
// One page, with the pagination state.
const page = await vouli.risks({ severity: 'HIGH', limit: 50 });
console.log(page.data.length, page.has_more);
// Or let the SDK follow the cursors.
for await (const risk of vouli.listAll('risks', { severity: 'HIGH' })) {
console.log(risk.risk_id);
}Quickstart — Python
# Not on PyPI yet: registry publication is pending the first release.
# Until then, install it from the repository's sdk/ directory.
pip install ./sdk/vouli-iq-python
import os
from vouli_iq import VouliIQ
vouli = VouliIQ(api_key=os.environ["VOULI_API_KEY"])
page = vouli.risks(severity="HIGH", limit=50)
print(len(page["data"]), page["has_more"])
for risk in vouli.list_all("risks", severity="HIGH"):
print(risk["risk_id"])Stdlib-only (no third-party dependencies). Both SDKs' typed surfaces are generated from the OpenAPI document and a CI check fails the build if regeneration produces any diff — so an SDK method, its parameters and its return type cannot drift from the API. The SDKs ship from the repository today; registry publication is pending the first tagged release. Until that release is announced here, do not install a package called vouli-iq or @vouli-iq/sdk from a public registry: it is not ours.
Webhooks
Subscribe an endpoint to receive signed events (HMAC-SHA256 with your endpoint's secret). Deliveries retry with backoff and dead-letter; an admin can replay a dead-lettered delivery from Settings → Webhooks in the dashboard, or through the deliveries endpoint. Every envelope carries api_version. Board, decision, insight and KPI events are opt-in by name: a subscription with no event types never receives them, and the subscribing key needs the scope shown (ids and status only, never content).
Verifying a delivery
Every delivery is signed at the moment it is sent, and each retry is signed again. Verify X-Vouli-Signature-V2: it is t=<unix seconds>,v2=<hex>, where the hex is the HMAC-SHA256 of <t>.<raw body>. Compare in constant time, and refuse a delivery whose t is more than 300 seconds from your clock, so a captured request cannot be replayed later (X-Vouli-Timestamp carries the same time). The older X-Vouli-Signature (sha256=<hex> over the body alone, no timestamp) is still sent for existing receivers; accept it alone only until you have moved, because it does not protect against replay. During a secret rotation each header carries one signature per valid secret; a match on any of them is valid. Use the event id to ignore a delivery you have already processed.
// TypeScript SDK — prefers v2, refuses anything outside the 300 s window
import { verifyWebhookSignature } from '@vouli-iq/sdk';
const ok = await verifyWebhookSignature(rawBody, req.headers, secret, { requireV2: true });
# Python SDK
from vouli_iq import verify_webhook_signature
ok = verify_webhook_signature(raw_body, request.headers, secret, require_v2=True)Webhook events| Event | When | Stability |
|---|
| assessment.completed | A maturity assessment finished and a new SOVEREIGN scorecard is available. | stable |
| verdict.changed | The org’s headline verdict band changed versus the previous assessment. | stable |
| roadmap.updated | The active roadmap or one of its milestones changed. | stable |
| risk.created | A new risk was added to the register. | stable |
| risk.severity_changed | An existing risk’s severity was re-rated. | stable |
| compliance.posture_changed | A new compliance assessment went live and the overall posture, or a regime’s posture, moved versus the previous version (one event per movement; never on the first assessment or when nothing moved). | stable |
| evidence.pack_filed | An Evidence Vault pack was assembled and filed (stored ready with every item). manifest_sha256 is the sha256 of the manifest’s canonical JSON (keys sorted). | stable |
| board.vote_opened | A board or circular resolution was put to a vote. Ids and timing only — never the motion. Committee votes only when the board allows them. Opt-in by name; needs read:board. | beta |
| board.vote_closed | A vote closed or was cancelled. The outcome is included only when the board allows it (and after any embargo); never tallies, names or the motion. Opt-in by name; needs read:board. | beta |
| board.signature_requested | A board document (minutes, a written resolution, a declaration) was approved and sent for signature. The signer count, never who. Opt-in by name; needs read:board. | beta |
| board.pack_published | A board pack was finalised. Ids and scope only — never its title or content. Opt-in by name; needs read:board. | beta |
| decision.recorded | A decision was recorded in Decision Memory. Ids, status and date only. Opt-in by name; needs read:decisions. | beta |
| insight.generated | An AI insight was generated and stored. Ids and the module only — flagged ai_generated. Opt-in by name; needs read:insights. | beta |
| kpi.threshold_breached | A governed metric's current value crossed into RED. The key, version and threshold — the measured value only when the board allows KPI values (include_kpi_values, default off). No name or description. Opt-in by name; needs read:metrics. | beta |
SDKs and integrations: where each one is
One table, from one declared source: an install command appears only once a package or listing is published.
SDKs and directory apps, their publication status, and how to install or build each one| SDK or app | Status | How to get it |
|---|
| TypeScript SDK (npm) | Not yet published | From the repository: sdk/vouli-iq |
|---|
| Python SDK (PyPI) | Not yet published | From the repository: sdk/vouli-iq-python |
|---|
| Zapier app | Not yet published | From the repository: integrations/zapier |
|---|
| Make | Not yet published | From the repository: integrations/make (scenario blueprints) |
|---|
| n8n | Not yet published | From the repository: integrations/n8n (workflow templates) |
|---|
| Power Automate | Not yet published | From the repository: not built yet |
|---|
| MCP Registry | Not yet published | From the repository: docs/store-submissions/mcp-registry/server.json |
|---|
| Slack App Directory | Not yet published | From the repository: docs/slack-app |
|---|
| Microsoft Teams store | Not yet published | From the repository: docs/teams-app |
|---|
API changelog
2026-09-12
Breaking
- One response envelope everywhere. Lists return { object: "list", api_version, data, has_more, next_cursor }; single objects return { object: "<type>", api_version, data }. The old per-resource shapes ({ risks, count }, { scorecard }, { roadmap, milestones }, …) are gone — read `data`.
- One error envelope everywhere: { error: { type, code, message, request_id } }. Branch on `code`, not on `message`.
- Scopes are enforced on every route. /scores and /signals previously accepted any valid key; they now require read:scores and read:signals (read:all still satisfies everything). Re-mint narrow keys with the scopes they need.
- Child collections moved out of their parent object and became paginated resources: /roadmap/milestones, /compliance/obligations, /vendor/inventory, /talent/gaps. The parent objects no longer embed those arrays (they were unbounded and could be truncated).
- The OpenAPI document moved from GET /api/v1 to GET /api/v1/openapi.json. GET /api/v1 is now the resource index.
- Risk `status` filter values are the real column values (Open, InProgress, Mitigated, Accepted, Closed) and `rag` is RED|AMBER|GREEN — the previous spec documented lowercase values the database never held.
Additive
- Cursor pagination on every collection: `limit` (1–100, default 25) and an opaque `cursor`. /scores is no longer capped at 24 rows and /signals no longer at 200.
- X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset and X-RateLimit-Limit-Day on every response; Retry-After on every 429.
- X-Request-Id on every response, and the same id inside every error body.
- Vouli-Version request header to pin the dated version; Vouli-Version and Vouli-Environment on every response.
- Sandbox keys: a vk_test_ key returns a fixed set of clearly-labelled synthetic rows and never reads tenant data.
- New key namespace vk_ for newly minted keys. Keys beginning bk_ remain valid indefinitely.
- Idempotency-Key support for future mutating endpoints, with a 24h replay window.
- Fixed: /scorecard selected a column that does not exist, so it returned null for every organisation.
- Both SDKs are now generated from this document and checked for drift in CI.
- Added 2026-10-03: a read-only MCP server at POST /api/v1/mcp, so an AI assistant can query the same resources with the same key, scopes, plan gate, rate limit and sandbox. Its tools are generated from these resources.
- Added 2026-10-03: writes. POST /risks, PATCH /risks/{id}, POST /incidents, PATCH /incidents/{id}, POST /kpis/checkins (up to 500 data points, all or nothing), POST /vendor/inventory and PATCH /vendor/inventory/{id}, each behind its own write: scope (read:all never grants one), the module's plan gate, a required Idempotency-Key on POST, strict bodies (unknown fields and org_id/owner-id fields refused) and the same service rules as the app. Sandbox keys get a simulated answer and nothing is written.
- Added 2026-10-03: webhook subscriptions machine-to-machine — GET/POST /webhooks/subscriptions, PATCH/DELETE /webhooks/subscriptions/{id} and POST /webhooks/subscriptions/{id}/test (a signed ping) — behind manage:webhooks.
- Added 2026-10-03: GET /audit, the audit-log export (cursor, since, until, json or ndjson), behind read:audit — which read:all does not grant — on the Enterprise plan only.
- Added 2026-10-03: GET /incidents (read:incidents) and GET /kpis (read:kpis) read resources, and an `updated_since` filter on every list resource for incremental sync.
- Added 2026-10-21 (wave 4a): GET /metrics and GET /metrics/values (read:metrics) — governed metric definitions and point-in-time values (as_of, known_at, start/end, updated_since).
- Added 2026-10-21 (wave 4a): POST /events (write:events, or an HMAC-signed request from a push source) — the inbound Event API.
- Added 2026-10-21 (wave 4a): POST /documents, POST /documents/{id}/process, GET /documents (read:documents), GET and DELETE /documents/{id} — the Documents API (write:documents).
- Added 2026-10-21 (wave 4a): GET /board/votes, /board/documents and /board/packs (read:board), GET /decisions (read:decisions) and GET /insights (read:insights) — metadata only.
- Added 2026-10-21 (wave 4a): seven restricted webhook events (board.*, decision.recorded, insight.generated, kpi.threshold_breached), delivered only to a subscription that lists them by name and whose key holds the scope in x-requires-scope. A catch-all subscription (no event types) still receives every core event and never a restricted one.
- Scopes added in wave 4a (read:metrics, read:documents, read:board, read:decisions, read:insights) are granted by name: read:all does not cover them, so no existing key is widened.