disco-saas API
- version
- 1.0.0
- contact
- disco-saas — https://disco.icearp.net
- license
- Proprietary
Machine-friendly HTTP surface for disco-saas. All routes require an authenticated session cookie (disco_session) or a workspace-scoped API key (Authorization: Bearer disco_…); the /v1/openapi.yaml document itself is anonymous.
Responses use a stable envelope: list endpoints return {"items": [...], "nextCursor": "…"?}; single-resource endpoints return the object directly. Errors are {"error": {"code": "snake_case", "message": "…"}}.
Every request carries an X-Request-Id response header; errors echo the same id under error.requestId when available.
Bearer API keys carry scope tiers; each operation's x-required-scope names the tier a bearer caller needs: read (every GET), write (mutations), or admin (tenant/member/account/webhook policy ops). Tiers nest — admin ⊇ write ⊇ read; an empty-scope key is denied everywhere. Insufficient scope returns 403 scope_required. Cookie sessions ignore scopes — the per-workspace role gates constrain them instead.
Servers
https://app.dev.disco.icearp.net— dev environmenthttp://localhost:8080— local dev
Default authentication: sessionCookie or apiKey
Tags
- account
- The authenticated caller's own profile, preferences, and data export.
- apiKeys
- The caller's machine credentials for bearer-authenticated /v1 access.
- audit
- Audit-log reads (tenant and workspace scope) and evidence-bundle export.
- cloudAccounts
- Cloud provider accounts connected to a workspace for scanning.
- connectors
- Anonymous connector-template downloads for onboarding a cloud account.
- cost
- FOCUS cost-report upload and per-workspace cost summaries.
- members
- Workspace membership — invitations, roles, and per-account scope.
- meta
- Machine-readable API description.
- quotas
- Per-account service-quota limits reported by cloud providers, and their change history.
- resources
- Scanned cloud resources, their history, and blast-radius graph.
- scans
- Dispatch scans and read their status within a workspace.
- sessions
- Active cookie sessions — the caller's own and, for admins, a workspace's.
- sso
- Per-tenant single sign-on configuration (OIDC and SAML).
- tenant
- Tenant administration — settings, people, invitations, and audit.
- webhooks
- Outbound event delivery for a workspace, and the payloads disco sends.
- workspaces
- Create, read, update, and delete workspaces within the caller's tenant.
account
PATCH /v1/account/display-preferences — Update the caller's display preferences (font, timezone)
Mirrors the HTML /account display-preferences card. Every field is optional; supply only the fields you want to change. Sending the request with all fields absent returns 400 no_fields. time_zone accepts any IANA zone embedded in the binary (see internal/timezones); pass an empty string to clear and revert to UTC display. Theme is not an account preference — it is a per-browser choice carried by the disco_saas_theme cookie, set via the header theme toggle.
required scope: write
Request body (required)
Display preferences to update.
application/json
type object · at least 1 property
useDyslexicFont-
type boolean
example:
true showLocalTime-
type boolean
example:
true timeZone-
IANA timezone name; empty string clears the override.
type string
example:
"America/New_York"
no additional properties
example:
{
"useDyslexicFont": true,
"showLocalTime": true,
"timeZone": "America/New_York"
}example:
{
"useDyslexicFont": true,
"showLocalTime": true,
"timeZone": "America/New_York"
}Responses
429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Display preferences updated.
application/json
type object
useDyslexicFont-
type boolean
example:
false showLocalTime-
type boolean
example:
true timeZone-
type string | null
example:
"America/New_York"
example:
{
"useDyslexicFont": false,
"showLocalTime": true,
"timeZone": "America/New_York"
}example:
{
"useDyslexicFont": false,
"showLocalTime": true,
"timeZone": "America/New_York"
}400
Validation failure
application/json
see Error
example:
{
"error": {
"code": "invalid_slug",
"message": "workspace slug must be 3-32 characters of lowercase letters, numbers, and hyphens, starting and ending with a letter or number"
}
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}POST /v1/account/display-preferences/reset — Reset the caller's display preferences to schema defaults
Idempotent — POST so the verb matches the HTML reset button's intent ("perform reset action") rather than DELETE (which would imply soft-deleting the user's preferences row).
required scope: write
Responses
429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Reset
application/json
type object
ok-
type boolean
example:
true
example:
{
"ok": true
}example:
{
"ok": true
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}GET /v1/account/export — Export all of the caller's own data (DSAR)
Returns a single JSON document with everything the service stores about the caller: profile, active sessions, API keys (prefixes and scopes only — never secrets), workspace memberships, the caller's own audit footprint, and each control-plane record that names them as a person — SSO verifications they performed, billing-address proposals they made, and Microsoft Graph directory consents they granted and are still the recorded grantor of. Served as a downloadable attachment. The export is itself audited (account.export).
required scope: read
Responses
200
The caller's data bundle
application/json
type object
generatedAt-
type string · format date-time
example:
"2026-07-17T18:00:00Z" user-
type object
example:
{ "id": "11111111-1111-1111-1111-111111111111", "email": "user@example.com", "memberSince": "2026-01-04T12:00:00Z", "timeZone": "America/New_York", "showLocalTime": true, "useDyslexicFont": false } sessions-
type array
array items:
type object
example:
[ { "id": "c1d2e3f4-0000-4000-8000-000000000010", "createdAt": "2026-07-17T09:00:00Z", "expiresAt": "2026-08-16T09:00:00Z", "lastSeenAt": "2026-07-17T17:45:00Z", "userAgent": "Mozilla/5.0", "ip": "203.0.113.42", "current": true } ] apiKeys-
type array
array items:
type object
example:
[ { "id": "a1b2c3d4-0000-4000-8000-000000000001", "name": "ci-deploy", "keyPrefix": "ab12cd34", "scopes": [ "read", "write" ], "createdBy": "11111111-1111-1111-1111-111111111111", "createdAt": "2026-07-17T18:00:00Z", "lastUsedAt": "2026-07-17T18:30:00Z" } ] memberships-
type array
array items:
type object
example:
[ { "workspaceId": "22222222-2222-2222-2222-222222222222", "name": "Acme Prod", "slug": "acme-prod", "role": "admin" } ] ssoProofs-
type array
array items:
type object
example:
[ { "idpType": "oidc", "verifiedAt": "2026-05-02T11:15:00Z" } ] billingEmailProposals-
type array
array items:
type object
example:
[ { "email": "ap@example.com", "createdAt": "2026-07-10T09:00:00Z", "expiresAt": "2026-07-17T09:00:00Z" } ] azureGraphConsents-
type array
array items:
type object
example:
[ { "workspaceId": "22222222-2222-2222-2222-222222222222", "directoryId": "44444444-4444-4444-4444-444444444444", "consentedAt": "2026-06-11T14:02:00Z" } ] auditEvents-
type array
array items:
type object
example:
[ { "createdAt": "2026-07-17T18:00:00Z", "action": "account.export", "targetType": "user", "targetId": "11111111-1111-1111-1111-111111111111" } ] truncated-
type boolean
example:
false
example:
{
"generatedAt": "2026-07-17T18:00:00Z",
"user": {
"id": "11111111-1111-1111-1111-111111111111",
"email": "user@example.com",
"memberSince": "2026-01-04T12:00:00Z",
"timeZone": "America/New_York",
"showLocalTime": true,
"useDyslexicFont": false
},
"sessions": [
{
"id": "c1d2e3f4-0000-4000-8000-000000000010",
"createdAt": "2026-07-17T09:00:00Z",
"expiresAt": "2026-08-16T09:00:00Z",
"lastSeenAt": "2026-07-17T17:45:00Z",
"userAgent": "Mozilla/5.0",
"ip": "203.0.113.42",
"current": true
}
],
"apiKeys": [
{
"id": "a1b2c3d4-0000-4000-8000-000000000001",
"name": "ci-deploy",
"keyPrefix": "ab12cd34",
"scopes": [
"read",
"write"
],
"createdBy": "11111111-1111-1111-1111-111111111111",
"createdAt": "2026-07-17T18:00:00Z",
"lastUsedAt": "2026-07-17T18:30:00Z"
}
],
"memberships": [
{
"workspaceId": "22222222-2222-2222-2222-222222222222",
"name": "Acme Prod",
"slug": "acme-prod",
"role": "admin"
}
],
"ssoProofs": [
{
"idpType": "oidc",
"verifiedAt": "2026-05-02T11:15:00Z"
}
],
"billingEmailProposals": [
{
"email": "ap@example.com",
"createdAt": "2026-07-10T09:00:00Z",
"expiresAt": "2026-07-17T09:00:00Z"
}
],
"azureGraphConsents": [
{
"workspaceId": "22222222-2222-2222-2222-222222222222",
"directoryId": "44444444-4444-4444-4444-444444444444",
"consentedAt": "2026-06-11T14:02:00Z"
}
],
"auditEvents": [
{
"createdAt": "2026-07-17T18:00:00Z",
"action": "account.export",
"targetType": "user",
"targetId": "11111111-1111-1111-1111-111111111111"
}
],
"truncated": false
}example:
{
"generatedAt": "2026-07-17T18:00:00Z",
"user": {
"id": "11111111-1111-1111-1111-111111111111",
"email": "user@example.com",
"memberSince": "2026-01-04T12:00:00Z",
"timeZone": "America/New_York",
"showLocalTime": true,
"useDyslexicFont": false
},
"sessions": [
{
"id": "c1d2e3f4-0000-4000-8000-000000000010",
"createdAt": "2026-07-17T09:00:00Z",
"expiresAt": "2026-08-16T09:00:00Z",
"lastSeenAt": "2026-07-17T17:45:00Z",
"userAgent": "Mozilla/5.0",
"ip": "203.0.113.42",
"current": true
}
],
"apiKeys": [
{
"id": "a1b2c3d4-0000-4000-8000-000000000001",
"name": "ci-deploy",
"keyPrefix": "ab12cd34",
"scopes": [
"read",
"write"
],
"createdBy": "11111111-1111-1111-1111-111111111111",
"createdAt": "2026-07-17T18:00:00Z",
"lastUsedAt": "2026-07-17T18:30:00Z"
}
],
"memberships": [
{
"workspaceId": "22222222-2222-2222-2222-222222222222",
"name": "Acme Prod",
"slug": "acme-prod",
"role": "admin"
}
],
"ssoProofs": [
{
"idpType": "oidc",
"verifiedAt": "2026-05-02T11:15:00Z"
}
],
"billingEmailProposals": [
{
"email": "ap@example.com",
"createdAt": "2026-07-10T09:00:00Z",
"expiresAt": "2026-07-17T09:00:00Z"
}
],
"azureGraphConsents": [
{
"workspaceId": "22222222-2222-2222-2222-222222222222",
"directoryId": "44444444-4444-4444-4444-444444444444",
"consentedAt": "2026-06-11T14:02:00Z"
}
],
"auditEvents": [
{
"createdAt": "2026-07-17T18:00:00Z",
"action": "account.export",
"targetType": "user",
"targetId": "11111111-1111-1111-1111-111111111111"
}
],
"truncated": false
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}DELETE /v1/account — Delete the caller's own account
Marks the caller's account for deletion, deletes all of their sessions, and signs them out. Access is cut immediately — sessions removed; bearer keys and login denied while the account is marked — and the caller is emailed that their account is scheduled for deletion. Nothing else is torn down yet: the account, its API keys, and its memberships are hard-deleted by the nightly purge 14 days later. A tenant admin can restore the account from the tenant people page until then. No request body — the UI confirms before calling. Denied with hard_delete_blocked when the tenant has disabled self-service deletion, or last_admin when the caller is the sole admin of a workspace or the sole tenant admin.
required scope: admin
Responses
429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Account deleted; the caller is signed out
application/json
type object
ok-
type boolean
example:
true
example:
{
"ok": true
}example:
{
"ok": true
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
hard_delete_blocked — the tenant disabled self-service deletion
409
last_admin — caller is the sole admin of a workspace or the tenant. Or lock_busy — another admin mutation in this organization held the tenant-wide admin lock when the server's lock wait expired. Nothing is written on lock_busy and it is safe to retry.
apiKeys
GET /v1/account/api-keys — List the caller's api keys across all workspaces
API keys are user-scoped: a bearer minted by user U grants /v1/* access to every workspace U currently belongs to. This listing returns every key (active + revoked) the caller has ever minted, regardless of which workspace the row records as the minting context.
required scope: read
Responses
200
API-key list
application/json
type object
items-
type array
array items:
see APIKeySummary
example:
[ { "id": "a1b2c3d4-0000-4000-8000-000000000001", "name": "ci-deploy", "keyPrefix": "ab12cd34", "scopes": [ "read", "write" ], "createdBy": "11111111-1111-1111-1111-111111111111", "createdAt": "2026-07-17T18:00:00Z", "lastUsedAt": "2026-07-17T18:30:00Z" } ]
example:
{
"items": [
{
"id": "a1b2c3d4-0000-4000-8000-000000000001",
"name": "ci-deploy",
"keyPrefix": "ab12cd34",
"scopes": [
"read",
"write"
],
"createdBy": "11111111-1111-1111-1111-111111111111",
"createdAt": "2026-07-17T18:00:00Z",
"lastUsedAt": "2026-07-17T18:30:00Z"
}
]
}example:
{
"items": [
{
"id": "a1b2c3d4-0000-4000-8000-000000000001",
"name": "ci-deploy",
"keyPrefix": "ab12cd34",
"scopes": [
"read",
"write"
],
"createdBy": "11111111-1111-1111-1111-111111111111",
"createdAt": "2026-07-17T18:00:00Z",
"lastUsedAt": "2026-07-17T18:30:00Z"
}
]
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}POST /v1/account/api-keys — Mint a new user-scoped api key
The plaintext token is in the response under token and is shown ONCE. Persist or rotate immediately; the server never reveals it again. The token reads every workspace the caller currently belongs to — there is no per-workspace mint surface.
required scope: write
Request body (required)
The API key to mint (name and scopes).
application/json
type object
name(required)-
Label for the key. Need not be unique — reusing the name of an existing, expired or revoked key is accepted.
type string
example:
"ci-deploy" scopes(required)-
At least one scope is required. Admin subsumes write, write subsumes read. An empty array is rejected with 400
scope_required.type array · min items 1
array items:
type string · one of
read,write,adminexample:
[ "read", "write" ] expiresAt-
Optional RFC3339 expiry. Defaults to 30 days from mint when omitted. Maximum 365 days from mint; values beyond that return 400
expires_at_too_far. Values in the past return 400bad_expires_at. Past this instant the key stops authenticating; it is not revoked, and the response echoes the expiry that was stored.type string · format date-time
example:
"2026-08-16T18:00:00Z"
no additional properties
example:
{
"name": "ci-deploy",
"scopes": [
"read",
"write"
],
"expiresAt": "2026-08-16T18:00:00Z"
}example:
{
"name": "ci-deploy",
"scopes": [
"read",
"write"
],
"expiresAt": "2026-08-16T18:00:00Z"
}Responses
429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}201
API key created; the response carries the one-time plaintext token.
application/json
The mint response is a SUBSET of APIKeySummary plus the one-time token: id, name, scopes, createdAt and expiresAt. keyPrefix, createdBy, lastUsedAt and revokedAt are not emitted here — read them from GET /v1/account/api-keys.
combines:
all fields of APIKeySummary:
An API key's public metadata; never includes the secret.
type object
id-
type string · format uuid
example:
"66666666-6666-6666-6666-666666666666" name-
Human-readable label. Not unique and not an identifier — two live keys may share one; address a key by id.
type string
example:
"ci-deploy" keyPrefix-
Public prefix; pair with stored hash for forensic correlation.
type string
example:
"ab12cd34" scopes-
Always non-empty — the database refuses an empty scope set.
type array · min items 1
array items:
type string · one of
read,write,adminexample:
[ "read", "write" ] createdBy-
type string · format uuid
example:
"44444444-4444-4444-4444-444444444444" createdAt-
type string · format date-time
example:
"2026-07-01T12:00:00Z" lastUsedAt-
When the key last authenticated a request. Absent until it is first used — the field is omitted, not null.
type string · format date-time
example:
"2026-07-17T18:00:00Z" revokedAt-
When an admin revoked the key. A live key omits this field entirely rather than reporting a null.
type string · format date-time
example:
"2026-07-20T09:15:00Z" expiresAt-
When the key stops authenticating. Every key minted through this API carries one — omitting expiresAt on mint stores a 30-day default rather than a key that never expires. Past this instant the key is refused with code
unauthorizedand a message beginning "api key expired"; it is NOT revoked, and it keeps whatever name it was minted with.type string · format date-time
example:
"2026-08-16T18:00:00Z"
example:
{
"id": "66666666-6666-6666-6666-666666666666",
"name": "ci-deploy",
"keyPrefix": "ab12cd34",
"scopes": [
"read",
"write"
],
"createdBy": "44444444-4444-4444-4444-444444444444",
"createdAt": "2026-07-01T12:00:00Z",
"lastUsedAt": "2026-07-17T18:00:00Z"
}type object
token-
Plaintext bearer; shown once.
type string
example:
{
"id": "a1b2c3d4-0000-4000-8000-000000000001",
"name": "ci-deploy",
"scopes": [
"read",
"write"
],
"token": "disco_ab12cd34_d3e5a1c9b7f20486e1a3c5d7f9b0a2c4",
"createdAt": "2026-07-17T18:00:00Z",
"expiresAt": "2026-08-16T18:00:00Z"
}example:
{
"id": "a1b2c3d4-0000-4000-8000-000000000001",
"name": "ci-deploy",
"scopes": [
"read",
"write"
],
"token": "disco_ab12cd34_d3e5a1c9b7f20486e1a3c5d7f9b0a2c4",
"createdAt": "2026-07-17T18:00:00Z",
"expiresAt": "2026-08-16T18:00:00Z"
}400
Validation failure
application/json
see Error
example:
{
"error": {
"code": "invalid_slug",
"message": "workspace slug must be 3-32 characters of lowercase letters, numbers, and hyphens, starting and ending with a letter or number"
}
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}DELETE /v1/account/api-keys/{keyId} — Revoke a user-scoped api key
Idempotent — revoking an already-revoked key is a no-op. Returns 404 if the key is not owned by the caller; user A cannot revoke user B's keys.
required scope: write
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
keyId | path | yes | type string · format uuid | UUID of the API key. |
Responses
429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
User-scoped API key revoked.
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}audit
GET /v1/workspaces/{workspaceSlug}/audit — Stream workspace audit log (NDJSON or CSV)
This is a bulk EXPORT, not a paginated {items:[...]} collection read like the other /v1 list endpoints: the body streams one audit row per line (NDJSON) or CSV, it is member-floor gated, its download is itself audited (audit.export), and it takes filter params rather than an opaque cursor envelope. Do not expect a JSON object wrapper — parse it line by line.
Default content-type is application/x-ndjson; pass Accept: text/csv to receive CSV. The response carries a Digest: sha-256=<hex> trailer over the streamed body — clients verify integrity by recomputing the hash after the body terminator.
The export covers this workspace's own audit rows only. Global pre-authentication events (login.attempt, verify.fail, SSO failures) carry no workspace and are NOT included on this surface, even when they name one of the workspace's members: they belong to a different hash chain, so unioning them in made the export unorderable and its cursor incoherent. They remain visible on the tenant-wide audit view, which requires tenant-admin access — a workspace-scoped auditor cannot reach them from any workspace surface. Consequently no row is an omitted placeholder — that column is always false here.
chainKey names the hash chain a row belongs to and is what a verifier must group by before following prevHash links. Every row in this export carries __tenant__, so the export is a single chain a verifier can follow end to end. Group by chainKey, never by workspaceId: the tenant chain deliberately spans every workspace in the tenant, so grouping by workspace would split one sound chain into several that each look broken.
required scope: read
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
from | query | no | type string · format date-time | Lower bound (inclusive) on audit-event time, RFC 3339. |
to | query | no | type string · format date-time | Upper bound (inclusive) on audit-event time, RFC 3339. |
action | query | no | type string | Filter to a single audit action (e.g. scan.trigger). |
actor | query | no | type string · format uuid | Filter to a single actor by user UUID. |
via | query | no | type string · one of cookie, api_key | Filter by request principal class (cookie session vs API key). |
key | query | no | type string · format uuid | Filter by minting API key UUID. |
cursor | query | no | type integer · format int64 | Return rows with id < cursor. Use before as a synonym. |
before | query | no | type integer · format int64 | Alias for cursor. Setting both with different values returns 400. |
limit | query | no | type integer · default 1000 · minimum 1 · maximum 10000 | Maximum number of rows to return. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}200
Streaming export
application/x-ndjson
type string
example:
"{ # expanded
"action": "scan.trigger",
"actorEmail": "user@example.com",
"actorUserId": "11111111-1111-1111-1111-111111111111",
"canonicalText": "7b3de2...|22222222-2222-2222-2222-222222222222|11111111-1111-1111-1111-111111111111|scan.trigger|scan|9f8e7d6c-5b4a-3210-fedc-ba9876543210|{}|2026-07-17 18:00:00",
"chainKey": "__tenant__",
"controls": [
{
"controlId": "CC7.1",
"framework": "SOC2"
},
{
"controlId": "A.8.16",
"framework": "ISO27001"
}
],
"createdAt": "2026-07-17T18:00:00Z",
"id": 4821,
"ip": "203.0.113.42",
"metadata": {},
"prevHash": "7b3de2...",
"rowHash": "9c1a7f...",
"targetId": "9f8e7d6c-5b4a-3210-fedc-ba9876543210",
"targetType": "scan",
"userAgent": "Mozilla/5.0",
"workspaceId": "22222222-2222-2222-2222-222222222222"
}"text/csv
type string
example:
"id,createdAt,action,targetType,targetId,metadata,workspaceId,chainKey,actorUserId,actorEmail,ip,userAgent,rowHash,prevHash,canonicalText,controls,omitted\\n4821,2026-07-17T18:00:00Z,scan.trigger,scan,9f8e7d6c-5b4a-3210-fedc-ba9876543210,{},22222222-2222-2222-2222-222222222222,__tenant__,11111111-1111-1111-1111-111111111111,user@example.com,203.0.113.42,Mozilla/5.0,9c1a7f...,7b3de2...,7b3de2...|22222222-2222-2222-2222-222222222222|...,\"[{\"\"framework\"\":\"\"SOC2\"\",\"\"controlId\"\":\"\"CC7.1\"\"},{\"\"framework\"\":\"\"ISO27001\"\",\"\"controlId\"\":\"\"A.8.16\"\"}]\",false"GET /v1/workspaces/{workspaceSlug}/evidence — Stream an evidence tar (admin or auditor)
Returns an uncompressed tar with manifest.json, audit.ndjson (+ its audit.ndjson.sha256 digest), scans.ndjson, resources.ndjson, accounts.json, webhooks.json, members.json. The whole body's sha256 lands on the Digest: sha-256=<hex> trailer; clients verify integrity by recomputing it after the body terminator. Resources are capped at 50_000 rows per bundle; manifest.json.resource_row_cap reports the cap so truncation is detectable.
Role gate: admin or auditor (compliance-admin also passes). Member and viewer get 403 insufficient_role with requiredRole: auditor. External read-only auditors should be granted the auditor role; admins keep full powers and add the auditor role for third-party attestation runs.
Each in-scope audit.ndjson row carries canonical_text — the exact byte string the BEFORE-INSERT trigger fed into sha256 to produce row_hash. External verifiers rehash the chain without reproducing Postgres serialization quirks. scans.ndjson rows carry triggered_by_email joined from public.users for chain-of-custody attribution.
Workspace cross-row hash chain continuity is preserved via per-row stubs in audit.ndjson; manifest.json.chain_complete reports whether the exported window is gap-free. Per-account scoping (members.scoped_account_ids) is honored: scans / resources / accounts are filtered to the caller's scope, and manifest.json.account_scope records the active filter.
required scope: read
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
from | query | no | type string · format date-time | Lower bound (inclusive) on the evidence bundle's event-time window, RFC 3339. |
to | query | no | type string · format date-time | Upper bound (inclusive) on the evidence bundle's event-time window, RFC 3339. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}200
Evidence tar
application/x-tar
type string · format binary
GET /v1/tenant/{tenantSlug}/audit — Read the tenant-wide audit log (tenant-admin only)
Returns the newest audit rows across every workspace in the caller's tenant, plus the tenant-level events (tenant.delete, tenant.policy.update, user lifecycle, invitations, SSO config, api-key scope denials) whose rows carry a null workspace_id and never appear on a per-workspace audit surface. Capped at 500 rows, newest first. This is a read-only viewer; bulk export stays on the per-workspace /v1/workspaces/{workspaceSlug}/audit endpoint.
Metadata on the tenant-level rows may carry emailRef instead of email. That is a keyed, non-reversible reference to an address, not a corrupt one: the pre-authentication rows of the global ledger store no address at all, and two rows naming the same address carry the same reference so they can still be correlated. There is no way to recover the address from it.
required scope: read
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
tenantSlug | path | yes | type string | URL slug of the caller's home tenant. A user belongs to exactly one tenant, so this must match the caller's own tenant slug; any other value returns 404. |
Responses
200
Tenant-wide audit rows, newest first
application/json
type object
items-
type array
array items:
type object
createdAt-
type string · format date-time
example:
"2026-07-17T18:00:00Z" actorEmail-
type string
example:
"admin@example.com" action-
type string
example:
"tenant.invite" targetType-
type string
example:
"invitation" targetId-
type string
example:
"33333333-3333-3333-3333-333333333333" workspace-
Slug of the workspace this row belongs to. Empty for tenant-level events (null workspace_id). A bare UUID when the workspace row has already been purged.
type string
example:
"" metadata-
type object
additional properties allowed
example:
{ "via": "api_key", "email": "user@example.com", "tenantRoles": [ "billing-admin" ], "workspaceRoles": 1 } controls-
type array
array items:
type string
example:
"SOC2:CC6.3"example:
[ "SOC2:CC6.3", "ISO27001:A.5.18" ]
example:
{ "createdAt": "2026-07-17T18:00:00Z", "actorEmail": "admin@example.com", "action": "tenant.invite", "targetType": "invitation", "targetId": "33333333-3333-3333-3333-333333333333", "workspace": "", "metadata": { "via": "api_key", "email": "user@example.com", "tenantRoles": [ "billing-admin" ], "workspaceRoles": 1 }, "controls": [ "SOC2:CC6.3", "ISO27001:A.5.18" ] }example:
[ { "createdAt": "2026-07-17T18:00:00Z", "actorEmail": "admin@example.com", "action": "tenant.invite", "targetType": "invitation", "targetId": "33333333-3333-3333-3333-333333333333", "workspace": "", "metadata": { "via": "api_key", "email": "user@example.com", "tenantRoles": [ "billing-admin" ], "workspaceRoles": 1 }, "controls": [ "SOC2:CC6.3", "ISO27001:A.5.18" ] } ]
example:
{
"items": [
{
"createdAt": "2026-07-17T18:00:00Z",
"actorEmail": "admin@example.com",
"action": "tenant.invite",
"targetType": "invitation",
"targetId": "33333333-3333-3333-3333-333333333333",
"workspace": "",
"metadata": {
"via": "api_key",
"email": "user@example.com",
"tenantRoles": [
"billing-admin"
],
"workspaceRoles": 1
},
"controls": [
"SOC2:CC6.3",
"ISO27001:A.5.18"
]
}
]
}example:
{
"items": [
{
"createdAt": "2026-07-17T18:00:00Z",
"actorEmail": "admin@example.com",
"action": "tenant.invite",
"targetType": "invitation",
"targetId": "33333333-3333-3333-3333-333333333333",
"workspace": "",
"metadata": {
"via": "api_key",
"email": "user@example.com",
"tenantRoles": [
"billing-admin"
],
"workspaceRoles": 1
},
"controls": [
"SOC2:CC6.3",
"ISO27001:A.5.18"
]
}
]
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}cloudAccounts
GET /v1/workspaces/{workspaceSlug}/accounts — List active connected cloud accounts
required scope: read
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}200
Connected account list
application/json
type object
items-
type array
array items:
see ConnectedAccount
example:
[ { "id": "3f8c1e42-9a7b-4c3d-8e1f-2a4b6c8d0e2f", "cloud": "aws", "cloudAccountId": "123456789012", "name": "Production", "principal": "arn:aws:iam::123456789012:role/disco-scanner", "createdAt": "2026-07-01T14:22:00Z", "lastScanAt": "2026-07-17T06:00:00Z" } ]
example:
{
"items": [
{
"id": "3f8c1e42-9a7b-4c3d-8e1f-2a4b6c8d0e2f",
"cloud": "aws",
"cloudAccountId": "123456789012",
"name": "Production",
"principal": "arn:aws:iam::123456789012:role/disco-scanner",
"createdAt": "2026-07-01T14:22:00Z",
"lastScanAt": "2026-07-17T06:00:00Z"
}
]
}POST /v1/workspaces/{workspaceSlug}/accounts — Add a cloud account (manual; admin only)
Manual add — for the AWS CFN-driven wizard, see /workspaces/{wid}/connectors/aws/setup (HTML only). The server generates the AWS cross-account external_id and returns it in the response (for AWS accounts); clients must not supply it. Put the returned value in your role's trust policy sts:ExternalId condition. GCP accounts get the same treatment under a different name: the server mints bindingId, and the connector template must be applied with it as the disco_binding_id variable or scans of that account are refused at the token exchange.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
Request body (required)
The cloud account to connect.
application/json
Provider-specific. The required fields vary by cloud: * aws — cloudAccountId, name, principal (role ARN). The server mints externalId and returns it. * azure — subscriptionId, customerTenantId (name optional). The MSP service principal + tenant are sourced from deployment config, never the body. * gcp — scope (project|org), targetId, serviceAccountEmail, projectNumber (name optional). The WIF pool/provider and the derived audience are sourced from deployment config, never the body. The server mints bindingId and returns it; apply the connector template with it as the disco_binding_id variable.
type object
cloud(required)-
type string · one of
aws,azure,gcpexample:
"aws" name-
type string
example:
"Production" cloudAccountId-
aws only — 12-digit account id.
type string
example:
"123456789012" principal-
aws only — IAM role ARN the scanner assumes.
type string
example:
"arn:aws:iam::123456789012:role/disco-scanner" subscriptionId-
azure only — subscription GUID.
type string
example:
"d290f1ee-6c54-4b01-90e6-d701748f0851" customerTenantId-
azure only — customer directory (tenant) GUID.
type string
example:
"72f988bf-86f1-41af-91ab-2d7cd011db47" scope-
gcp only.
type string · one of
project,orgexample:
"project" targetId-
gcp only — project id or org id.
type string
example:
"my-project-123" serviceAccountEmail-
gcp only — impersonated service account email.
type string
example:
"disco-scanner@my-project-123.iam.gserviceaccount.com" projectNumber-
gcp only — WIF pool host project number.
type string
example:
"123456789012"
no additional properties
example:
{
"cloud": "aws",
"name": "Production",
"cloudAccountId": "123456789012",
"principal": "arn:aws:iam::123456789012:role/disco-scanner"
}Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}201
Cloud account connected to the workspace.
application/json
see ConnectedAccount
example:
{
"id": "3f8c1e42-9a7b-4c3d-8e1f-2a4b6c8d0e2f",
"cloud": "aws",
"cloudAccountId": "123456789012",
"name": "Production",
"principal": "arn:aws:iam::123456789012:role/disco-scanner",
"createdAt": "2026-07-01T14:22:00Z",
"externalId": "d7b3f9a14c2e4b8a9f1d6e3c5a7b9d0f1a2b3c4d5e6f7081"
}400
Validation failure
application/json
see Error
example:
{
"error": {
"code": "invalid_slug",
"message": "workspace slug must be 3-32 characters of lowercase letters, numbers, and hyphens, starting and ending with a letter or number"
}
}409
The cloud account is already connected to this workspace. A workspace holds at most one connection per (cloud, cloud_account_id), live or pending deletion. Codes: * account_exists — a live connection already exists. Use it, or connect a different account. * account_pending_deletion — the connection was removed and is pending deletion until the date in message. Restore it via POST /v1/workspaces/{workspaceSlug}/accounts/{accountId}/restore, or wait for the nightly purge to reclaim the id.
application/json
see Error
example:
{
"error": {
"code": "account_exists",
"message": "This cloud account is already connected to the workspace."
}
}423
Workspace is frozen (incident-response containment). Envelope carries code: workspace_frozen; ask a workspace admin to unfreeze before retrying.
application/json
type object
error-
type object
code-
type string · one of
workspace_frozenexample:
"workspace_frozen" message-
type string
example:
"this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
example:
{ "code": "workspace_frozen", "message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it" }
example:
{
"error": {
"code": "workspace_frozen",
"message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
}
}example:
{
"error": {
"code": "workspace_frozen",
"message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
}
}503
connector_unconfigured — Azure only: this deployment carries no Azure connector identity (managing tenant + principal), so no Azure subscription can be connected here. Only an operator can clear it; a retry cannot. Azure is the only cloud that reaches this status. The message on this response is blank outside development (5xx bodies are masked), so branch on the code.
application/json
see Error
example:
{
"error": {
"code": "connector_unconfigured",
"message": ""
}
}500
The server could not complete the request for a reason it cannot classify. Envelope carries code: internal_error and a requestId; the message is generic outside development, so quote the request id rather than branching on the copy.
Freeze-gated routes answer this when the freeze check itself fails — a dial failure, an exhausted pool, a cancelled request. It is NOT "no such workspace", which is the 404: these routes stopped folding every freeze-check error into that answer, because the caller has already passed the membership gate and a fault says nothing about their own data. The freeze check runs before the route's own work, so on that path nothing was written and one retry is worth trying.
Every other 500 on these routes is a different class and carries no such promise — an addAccount or createWorkspaceInvitation whose insert failed ambiguously may well have written, and the retry then answers 409. The envelope does not separate them; quote the requestId.
application/json
see Error
example:
{
"error": {
"code": "internal_error",
"message": "something went wrong on our end — try again, or contact an operator with this request id",
"requestId": "3f1a9c42-8b7e-4d15-9f0c-2a6b5e8d71c3"
}
}GET /v1/workspaces/{workspaceSlug}/accounts/{accountId} — Fetch a single connected cloud account
Returns the same projection as the list endpoint, filtered to the requested account_id. Every non-admin member sees only accounts inside their scope, and a member granted none sees no account at all — out-of-scope ids return 404, identical to unknown ids.
required scope: read
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
accountId | path | yes | type string · format uuid | UUID of the connected cloud account to read, update, or remove. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}200
Connected account
application/json
see ConnectedAccount
example:
{
"id": "3f8c1e42-9a7b-4c3d-8e1f-2a4b6c8d0e2f",
"cloud": "aws",
"cloudAccountId": "123456789012",
"name": "Production",
"principal": "arn:aws:iam::123456789012:role/disco-scanner",
"createdAt": "2026-07-01T14:22:00Z",
"lastScanAt": "2026-07-17T06:00:00Z"
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}PATCH /v1/workspaces/{workspaceSlug}/accounts/{accountId} — Update a connected account's principal or name (admin only)
Both fields are optional but at least one must be provided. principal is the IAM role ARN (AWS), client ID (Azure), or service account email (GCP) the SaaS scanner assumes; rotating it takes effect on the next scan dispatch (in-flight scans keep the old role). name is the human label shown in lists. cloud and cloud_account_id are immutable. To change those, remove the account and connect the new one — note that the removed (cloud, cloud_account_id) pair stays reserved until the nightly purge reclaims it, so it cannot be re-connected in the meantime. Restore the removed account if you want it back.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
accountId | path | yes | type string · format uuid | UUID of the connected cloud account to read, update, or remove. |
Request body (required)
Connected-account fields to update.
application/json
type object · at least 1 property
principal-
type string
example:
"arn:aws:iam::123456789012:role/disco-scanner" name-
type string
example:
"Production (us-east-1)"
no additional properties
example:
{
"principal": "arn:aws:iam::123456789012:role/disco-scanner",
"name": "Production (us-east-1)"
}Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Connected account updated.
application/json
see ConnectedAccount
example:
{
"id": "3f8c1e42-9a7b-4c3d-8e1f-2a4b6c8d0e2f",
"cloud": "aws",
"cloudAccountId": "123456789012",
"name": "Production",
"principal": "arn:aws:iam::123456789012:role/disco-scanner"
}400
Validation failure. Codes: * principal_invalid — malformed for cloud, or AWS ARN account-id segment does not match the row's cloud_account_id. * name_invalid — empty after trim, longer than 100 chars, or contains control characters. * no_fields — neither principal nor name was supplied. * bad_json / unknown_field / bad_field_type — request body shape errors.
application/json
see Error
example:
{
"error": {
"code": "principal_invalid",
"message": "The role ARN's account id does not match this account's cloud_account_id."
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}423
Workspace is frozen (incident-response containment). Envelope carries code: workspace_frozen; ask a workspace admin to unfreeze before retrying.
application/json
type object
error-
type object
code-
type string · one of
workspace_frozenexample:
"workspace_frozen" message-
type string
example:
"this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
example:
{ "code": "workspace_frozen", "message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it" }
example:
{
"error": {
"code": "workspace_frozen",
"message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
}
}example:
{
"error": {
"code": "workspace_frozen",
"message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
}
}500
The server could not complete the request for a reason it cannot classify. Envelope carries code: internal_error and a requestId; the message is generic outside development, so quote the request id rather than branching on the copy.
Freeze-gated routes answer this when the freeze check itself fails — a dial failure, an exhausted pool, a cancelled request. It is NOT "no such workspace", which is the 404: these routes stopped folding every freeze-check error into that answer, because the caller has already passed the membership gate and a fault says nothing about their own data. The freeze check runs before the route's own work, so on that path nothing was written and one retry is worth trying.
Every other 500 on these routes is a different class and carries no such promise — an addAccount or createWorkspaceInvitation whose insert failed ambiguously may well have written, and the retry then answers 409. The envelope does not separate them; quote the requestId.
application/json
see Error
example:
{
"error": {
"code": "internal_error",
"message": "something went wrong on our end — try again, or contact an operator with this request id",
"requestId": "3f1a9c42-8b7e-4d15-9f0c-2a6b5e8d71c3"
}
}DELETE /v1/workspaces/{workspaceSlug}/accounts/{accountId} — Soft-delete a connected account (admin only)
Marks the account for deletion — it stops scanning and its resources drop out of every listing immediately, but the row is kept so it can be restored. The nightly purge hard-deletes it (and its scan data) 14 days later. Until then the (cloud, cloud_account_id) pair stays reserved: re-connecting the same cloud account returns 409 account_pending_deletion. Restore via POST /v1/workspaces/{workspaceSlug}/accounts/{accountId}/restore.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
accountId | path | yes | type string · format uuid | UUID of the connected cloud account to read, update, or remove. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Connected account removed (soft-deleted, restorable within the retention window).
423
Workspace is frozen (incident-response containment). Envelope carries code: workspace_frozen; ask a workspace admin to unfreeze before retrying.
application/json
type object
error-
type object
code-
type string · one of
workspace_frozenexample:
"workspace_frozen" message-
type string
example:
"this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
example:
{ "code": "workspace_frozen", "message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it" }
example:
{
"error": {
"code": "workspace_frozen",
"message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
}
}example:
{
"error": {
"code": "workspace_frozen",
"message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
}
}500
The server could not complete the request for a reason it cannot classify. Envelope carries code: internal_error and a requestId; the message is generic outside development, so quote the request id rather than branching on the copy.
Freeze-gated routes answer this when the freeze check itself fails — a dial failure, an exhausted pool, a cancelled request. It is NOT "no such workspace", which is the 404: these routes stopped folding every freeze-check error into that answer, because the caller has already passed the membership gate and a fault says nothing about their own data. The freeze check runs before the route's own work, so on that path nothing was written and one retry is worth trying.
Every other 500 on these routes is a different class and carries no such promise — an addAccount or createWorkspaceInvitation whose insert failed ambiguously may well have written, and the retry then answers 409. The envelope does not separate them; quote the requestId.
application/json
see Error
example:
{
"error": {
"code": "internal_error",
"message": "something went wrong on our end — try again, or contact an operator with this request id",
"requestId": "3f1a9c42-8b7e-4d15-9f0c-2a6b5e8d71c3"
}
}POST /v1/workspaces/{workspaceSlug}/accounts/{accountId}/restore — Restore an account pending deletion (admin only)
Clears deleted_at on an account marked for deletion, returning it to service with its scan data visible again — nothing was torn down at removal. Works only within the 14-day window before the nightly purge hard-deletes the row: past that deadline the account's scan data may already be gone, so restore refuses (409 purge_deadline_passed) and the account must be connected again from scratch. Refuses with 409 not_deleted if the account is not currently marked for deletion at all. Requires an admin-scoped key for bearer callers.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
accountId | path | yes | type string · format uuid | UUID of the connected cloud account to restore. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Connected cloud account restored.
application/json
see ConnectedAccount
example:
{
"id": "3f8c1e42-9a7b-4c3d-8e1f-2a4b6c8d0e2f",
"cloud": "aws",
"cloudAccountId": "123456789012",
"name": "Production",
"principal": "arn:aws:iam::123456789012:role/disco-scanner"
}409
not_deleted — the account is not marked for deletion; or purge_deadline_passed — it is past the 14-day restore window and is being permanently removed.
423
Workspace is frozen (incident-response containment). Envelope carries code: workspace_frozen; ask a workspace admin to unfreeze before retrying.
application/json
type object
error-
type object
code-
type string · one of
workspace_frozenexample:
"workspace_frozen" message-
type string
example:
"this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
example:
{ "code": "workspace_frozen", "message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it" }
example:
{
"error": {
"code": "workspace_frozen",
"message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
}
}example:
{
"error": {
"code": "workspace_frozen",
"message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
}
}500
The server could not complete the request for a reason it cannot classify. Envelope carries code: internal_error and a requestId; the message is generic outside development, so quote the request id rather than branching on the copy.
Freeze-gated routes answer this when the freeze check itself fails — a dial failure, an exhausted pool, a cancelled request. It is NOT "no such workspace", which is the 404: these routes stopped folding every freeze-check error into that answer, because the caller has already passed the membership gate and a fault says nothing about their own data. The freeze check runs before the route's own work, so on that path nothing was written and one retry is worth trying.
Every other 500 on these routes is a different class and carries no such promise — an addAccount or createWorkspaceInvitation whose insert failed ambiguously may well have written, and the retry then answers 409. The envelope does not separate them; quote the requestId.
application/json
see Error
example:
{
"error": {
"code": "internal_error",
"message": "something went wrong on our end — try again, or contact an operator with this request id",
"requestId": "3f1a9c42-8b7e-4d15-9f0c-2a6b5e8d71c3"
}
}GET /v1/workspaces/{workspaceSlug}/azure-consents — List Microsoft Graph directory consents (admin only)
Lists every Entra directory this workspace holds a recorded consent for, newest grant first — including stale ones, whose current is false and which grant no access. One entry per directory rather than per connected account: several Azure subscriptions can share one directory, and one consent covers all of them.
Granting is a browser redirect through Entra's admin-consent endpoint and has no API equivalent — this surface lists and revokes what that flow recorded. current reports whether a consent still describes the live configuration; it goes false when disco's Entra application or the requested permission set changes, and in a deployment that carries no Entra application at all.
A current consent is what authorises scanning an Azure subscription at all, not only the reading of directory objects. Without one, a scan of every subscription in that directory is refused with directory_not_consented — no Lighthouse collection either — until an administrator consents again. The exception is a deployment carrying no Entra application at all, where current is false for a different reason and the scan answers 503 connector_unconfigured.
An entry can also disappear without anyone revoking it: the retention sweep deletes a workspace's consent for a directory once the last connected account naming that directory is permanently deleted at the end of its recovery window. Removing an account does not do it — the account is restorable until then.
required scope: read
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}200
Directory consents held by this workspace.
application/json
type object
items(required)-
Directory consents held by this workspace, newest grant first. Empty rather than absent when the workspace holds none.
type array
array items:
example:
[ { "customerTenantId": "72f988bf-86f1-41af-91ab-2d7cd011db47", "consentedAt": "2026-08-20T14:03:11Z", "consentedBy": "8c2d1e42-9a7b-4c3d-8e1f-2a4b6c8d0e2f", "current": true } ]
no additional properties
example:
{
"items": [
{
"customerTenantId": "72f988bf-86f1-41af-91ab-2d7cd011db47",
"consentedAt": "2026-08-20T14:03:11Z",
"consentedBy": "8c2d1e42-9a7b-4c3d-8e1f-2a4b6c8d0e2f",
"current": true
}
]
}DELETE /v1/workspaces/{workspaceSlug}/azure-consents/{directoryId} — Forget a Microsoft Graph directory consent (admin only)
Deletes disco's record of the grant, so every Azure subscription in that directory stops being scanned from the next scan onward — the whole subscription, not only its directory objects. Later scans are refused with directory_not_consented.
This does NOT withdraw the grant in Entra. Only a directory administrator can do that, from the Azure portal. What this guarantees is that disco stops acting on it. Answers 404 when the workspace holds no consent for that directory, so a repeated call is distinguishable from the first.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
directoryId | path | yes | type string · format uuid | Entra directory (tenant) GUID whose consent to forget. Case-insensitive — canonicalised to lowercase before the lookup. |
Responses
400
The directory id is not a GUID.
application/json
see Error
example:
{
"error": {
"code": "bad_directory_id",
"message": "that is not a directory id — copy the Entra directory (tenant) GUID from the consents list"
}
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}409
Another change to this consent is in progress. Nothing was deleted; retry.
application/json
see Error
example:
{
"error": {
"code": "lock_busy",
"message": "another change to this is still in progress — nothing was saved. try again in a moment."
}
}429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}423
Workspace is frozen (incident-response containment). Envelope carries code: workspace_frozen; ask a workspace admin to unfreeze before retrying.
application/json
type object
error-
type object
code-
type string · one of
workspace_frozenexample:
"workspace_frozen" message-
type string
example:
"this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
example:
{ "code": "workspace_frozen", "message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it" }
example:
{
"error": {
"code": "workspace_frozen",
"message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
}
}example:
{
"error": {
"code": "workspace_frozen",
"message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
}
}200
Directory consent forgotten.
application/json
type object
ok(required)-
Always true; the consent record is gone.
type boolean
example:
true
no additional properties
example:
{
"ok": true
}500
The server could not complete the request for a reason it cannot classify. Envelope carries code: internal_error and a requestId; the message is generic outside development, so quote the request id rather than branching on the copy.
Freeze-gated routes answer this when the freeze check itself fails — a dial failure, an exhausted pool, a cancelled request. It is NOT "no such workspace", which is the 404: these routes stopped folding every freeze-check error into that answer, because the caller has already passed the membership gate and a fault says nothing about their own data. The freeze check runs before the route's own work, so on that path nothing was written and one retry is worth trying.
Every other 500 on these routes is a different class and carries no such promise — an addAccount or createWorkspaceInvitation whose insert failed ambiguously may well have written, and the retry then answers 409. The envelope does not separate them; quote the requestId.
application/json
see Error
example:
{
"error": {
"code": "internal_error",
"message": "something went wrong on our end — try again, or contact an operator with this request id",
"requestId": "3f1a9c42-8b7e-4d15-9f0c-2a6b5e8d71c3"
}
}connectors
GET /v1/connectors/aws/template-digest — SHA-256 of the AWS CFN connector template (anonymous)
Lets a customer hash the CFN body they actually launched and compare to what disco-saas advertises. Mismatch = template was tampered before reaching the CloudFormation console.
Authentication: none (anonymous)
Responses
200
SHA-256 digest of the current AWS CloudFormation connector template.
application/json
type object
cloud-
type string · one of
awsexample:
"aws" digest-
SHA-256 hash of the AWS CloudFormation template, formatted "sha256:<hex>".
type string
example:
"sha256:0f5c1e3d9b8a7c6f4e2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d"
example:
{
"cloud": "aws",
"digest": "sha256:0f5c1e3d9b8a7c6f4e2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d"
}GET /v1/connectors/aws/template — Rendered AWS CloudFormation connector template (anonymous)
Serves the static CFN template body disco-saas advertises, so a customer can fetch it by URL and hash it themselves instead of copying it out of the wizard. The body is the exact bytes the template-digest endpoint hashes — the digest binds to a byte-verifiable artifact, mirroring the Azure and GCP template endpoints. Unlike those, the AWS template is static (identical for every tenant, no per-deployment config), so it never returns 501.
Authentication: none (anonymous)
Responses
200
CloudFormation template document
application/x-yaml
CloudFormation YAML creating the read-only cross-account IAM role disco's scanner assumes.
type string
GET /v1/connectors/azure/template-digest — SHA-256 of the Azure Lighthouse connector template (anonymous)
Lets a customer hash the Lighthouse ARM offer they are about to deploy and compare to what disco-saas advertises. The offer grants disco's service principal the built-in Reader role on one subscription — credential-less, no secret stored. Returns 501 when the Azure connector is not configured on this deployment.
Authentication: none (anonymous)
Responses
200
SHA-256 digest of the current Azure Lighthouse connector template.
application/json
type object
cloud-
type string · one of
azureexample:
"azure" digest-
SHA-256 hash of the Azure Lighthouse template, formatted "sha256:<hex>".
type string
example:
"sha256:7b1e0a4c2d8f6b3e9a5c1d7f0e2b4a6c8d0f1e3b5a7c9d1e2f4b6a8c0d2e4f60"
example:
{
"cloud": "azure",
"digest": "sha256:7b1e0a4c2d8f6b3e9a5c1d7f0e2b4a6c8d0f1e3b5a7c9d1e2f4b6a8c0d2e4f60"
}501
The Azure connector is not configured on this deployment; no template digest is available. The message is blank outside development (5xx bodies are masked), so branch on the code.
application/json
see Error
example:
{
"error": {
"code": "connector_unconfigured",
"message": ""
}
}GET /v1/connectors/azure/template — Rendered Azure Lighthouse ARM template (anonymous)
Serves the Lighthouse delegation ARM template this deployment advertises, so the wizard's "Deploy to Azure" button can hand the Azure portal a fetchable template URI. The body is the same bytes the template-digest endpoint hashes. Sent with an Access-Control-Allow-Origin header for portal.azure.com because the portal fetches the URI from the browser. Returns 501 when the Azure connector is not configured on this deployment.
Authentication: none (anonymous)
Responses
200
ARM template document
application/json
Azure Resource Manager subscription-deployment template.
type object
example:
{
"$schema": "https://schema.management.azure.com/schemas/2019-08-01/subscriptionDeploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"mspOfferName": {
"type": "string",
"defaultValue": "disco cloud asset discovery"
},
"managedByTenantId": {
"type": "string",
"defaultValue": "11111111-2222-3333-4444-555555555555"
},
"principalId": {
"type": "string",
"defaultValue": "66666666-7777-8888-9999-aaaaaaaaaaaa"
},
"rbacRoleId": {
"type": "string",
"defaultValue": "acdd72a7-3385-48ef-bd42-f606fba81ae7"
}
},
"resources": [
{
"type": "Microsoft.ManagedServices/registrationDefinitions",
"apiVersion": "2019-09-01",
"name": "[variables('mspRegistrationName')]"
},
{
"type": "Microsoft.ManagedServices/registrationAssignments",
"apiVersion": "2019-09-01",
"name": "[variables('mspAssignmentName')]"
}
]
}501
The Azure connector is not configured on this deployment; no template can be rendered. The message is blank outside development (5xx bodies are masked), so branch on the code.
application/json
see Error
example:
{
"error": {
"code": "connector_unconfigured",
"message": ""
}
}GET /v1/connectors/gcp/template-digest — SHA-256 of the GCP Workload Identity Federation template (anonymous)
Lets a customer hash the WIF Terraform module they are about to apply and compare to what disco-saas advertises. The module creates a workload-identity pool trusting disco's own AWS scanner identity plus a read-only service account disco impersonates — keyless, no secret stored. Returns 501 when the GCP connector is not configured on this deployment.
Authentication: none (anonymous)
Responses
200
SHA-256 digest of the current GCP Workload Identity Federation connector template.
application/json
type object
cloud-
type string · one of
gcpexample:
"gcp" digest-
SHA-256 hash of the GCP WIF Terraform module, formatted "sha256:<hex>".
type string
example:
"sha256:c4a2e6081d3f5b7900a2c4e6081d3f5b7900a2c4e6081d3f5b7900a2c4e60813"
example:
{
"cloud": "gcp",
"digest": "sha256:c4a2e6081d3f5b7900a2c4e6081d3f5b7900a2c4e6081d3f5b7900a2c4e60813"
}501
The GCP connector is not configured on this deployment; no template digest is available. The message is blank outside development (5xx bodies are masked), so branch on the code.
application/json
see Error
example:
{
"error": {
"code": "connector_unconfigured",
"message": ""
}
}GET /v1/connectors/gcp/template — Rendered GCP Workload Identity Federation Terraform module (anonymous)
Serves the WIF Terraform module this deployment advertises, so a customer can fetch it by URL instead of copying it out of the wizard. The body is the same bytes the template-digest endpoint hashes. Returns 501 when the GCP connector is not configured on this deployment.
Authentication: none (anonymous)
Responses
200
Terraform module document
text/plain
Terraform HCL creating the workload-identity pool, AWS provider, read-only service account, and IAM bindings.
type string
501
The GCP connector is not configured on this deployment; no template can be rendered. The message is blank outside development (5xx bodies are masked), so branch on the code.
application/json
see Error
example:
{
"error": {
"code": "connector_unconfigured",
"message": ""
}
}cost
GET /v1/workspaces/{workspaceSlug}/cost-summary — Read the workspace's cost allocation summary
The API mirror of the Cost allocation tab — any workspace member may read it (unlike the upload endpoint, which is admin-gated). Reflects only the latest status: "complete" FOCUS report; older reports are never surfaced (not a report history/browse API — see the upload endpoint's description).
A member whose cloud-account scope is restricted sees accounts filtered to that scope; total_billed_cost/unattributed_cost are workspace-wide report totals and are null for such a member rather than being (mis)filtered per account.
required scope: read
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}200
Cost summary for the workspace's latest report
application/json
type object
status-
"none" when no report has ever been uploaded; otherwise the latest report's status regardless of whether it's the one the other fields below describe (those always describe the latest COMPLETE report, which may be an older one than the latest upload).
type string · one of
none,processing,complete,failedexample:
"complete" errorMessage-
Why the latest report failed, when
statusis "failed"; null on every other status. Safe to show to the uploader: a file the uploader can correct is described specifically (a missing FOCUS column, a mixed BillingCurrency, a malformed cell), while a server-side failure collapses to a generic sentence rather than leaking internal detail.type string | null
example:
null currency-
type string | null
example:
"USD" totalBilledCost-
type string | null
example:
"1234.56" unattributedCost-
type string | null
example:
"78.90" accounts-
type array
array items:
type object
accountId-
type string
accountName-
type string
billedCost-
type string
example:
[ { "accountId": "123456789012", "accountName": "prod-payer", "billedCost": "1155.66" } ]
example:
{
"status": "complete",
"errorMessage": null,
"currency": "USD",
"totalBilledCost": "1234.56",
"unattributedCost": "78.90",
"accounts": [
{
"accountId": "123456789012",
"accountName": "prod-payer",
"billedCost": "1155.66"
}
]
}POST /v1/workspaces/{workspaceSlug}/focus-reports — Upload a FOCUS cost report (workspace admin or tenant admin only)
Accepts a FOCUS 1.0 through 1.4 CSV export (optionally gzip-compressed — detected by magic bytes, not filename or Content-Type) as a multipart file field named report. The file is parsed and matched against the workspace's already-scanned resources in the background; this endpoint returns as soon as the upload is accepted, with status: "processing". A future release adds a GET .../cost-summary endpoint to poll for the result; until then, check back via the workspace's cost-allocation UI.
Only the latest successfully-processed report feeds every cost view (resource detail, per-account summary, unattributed total) — this is not a report history/browse API. Gated to a workspace admin OR the caller's tenant admin (unlike most admin-only routes, which require the workspace role specifically) — see the combined gate note in .claude/rules/api-and-auth.instructions.md if that changes.
A 429 here can carry either rate_limited (the shared per-IP/user request-rate limiter — retry after the Retry-After header) or ingest_busy (every concurrent-ingestion slot is in use — no Retry-After; just try again shortly).
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
Request body (required)
The FOCUS cost report file to ingest.
multipart/form-data
type object
report(required)-
FOCUS CSV export, optionally gzip-compressed.
type string · format binary
example:
"BilledCost,BillingCurrency,ResourceId,ServiceName"
example:
{
"report": "BilledCost,BillingCurrency,ResourceId,ServiceName\n1.23,USD,arn:aws:s3:::acme-logs,AmazonS3"
}Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}413
The request body is larger than the route's ceiling. Envelope carries code: body_too_large when the request as a whole was refused, or report_too_large when the uploaded file itself exceeded the size a single report may be. Split the export into smaller files and upload them one at a time.
application/json
see Error
example:
{
"error": {
"code": "report_too_large",
"message": "the report is larger than a single upload may be — split the export into smaller files and upload them one at a time"
}
}429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}503
The server has too little free disk to accept an uploaded file right now. Envelope carries code: storage_unavailable; the condition is transient and the request is safe to retry. The message is blank in production (like every 5xx) — the code is the signal.
application/json
see Error
example:
{
"error": {
"code": "storage_unavailable",
"message": ""
}
}201
Upload accepted; ingestion started in the background
application/json
type object
id-
type string · format uuid
example:
"11111111-1111-1111-1111-111111111111" status-
type string · one of
processingexample:
"processing"
example:
{
"id": "11111111-1111-1111-1111-111111111111",
"status": "processing"
}400
Validation failure
application/json
see Error
example:
{
"error": {
"code": "invalid_slug",
"message": "workspace slug must be 3-32 characters of lowercase letters, numbers, and hyphens, starting and ending with a letter or number"
}
}members
GET /v1/workspaces/{workspaceSlug}/members — List members of the workspace
required scope: read
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}200
Member list
application/json
type object
items-
type array
array items:
see Member
example:
[ { "userId": "11111111-1111-4111-8111-111111111111", "email": "user@example.com", "role": "member", "joinedAt": "2026-06-15T09:30:00Z", "scopedAccountIds": [ "3f8c1e42-9a7b-4c3d-8e1f-2a4b6c8d0e2f" ] } ]
example:
{
"items": [
{
"userId": "11111111-1111-4111-8111-111111111111",
"email": "user@example.com",
"role": "member",
"joinedAt": "2026-06-15T09:30:00Z",
"scopedAccountIds": [
"3f8c1e42-9a7b-4c3d-8e1f-2a4b6c8d0e2f"
]
}
]
}PATCH /v1/workspaces/{workspaceSlug}/members/{userId}/role — Change a member's role (admin only)
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
userId | path | yes | type string · format uuid | UUID of the workspace member whose role is being changed. |
Request body (required)
The role to assign to the member.
application/json
type object
role(required)-
type string · one of
admin,member,viewer,auditor,compliance-adminexample:
"member"
no additional properties
example:
{
"role": "member"
}example:
{
"role": "member"
}Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Member role updated.
application/json
type object
userId-
type string · format uuid
example:
"44444444-4444-4444-4444-444444444444" oldRole-
type string
example:
"viewer" newRole-
type string
example:
"member"
example:
{
"userId": "44444444-4444-4444-4444-444444444444",
"oldRole": "viewer",
"newRole": "member"
}example:
{
"userId": "44444444-4444-4444-4444-444444444444",
"oldRole": "viewer",
"newRole": "member"
}400
Validation failure — the role is not one of the allowed values.
409
last_admin — demotion is blocked because the member is the only admin who can sign in; or target_deleted — promotion to admin is blocked because the member's account is pending deletion. Or lock_busy — another admin mutation in this organization held the tenant-wide admin lock when the server's lock wait expired. Nothing is written on lock_busy and it is safe to retry.
PATCH /v1/workspaces/{workspaceSlug}/members/{userId}/scope — Set per-account scoping for a workspace member (admin only)
Sets the member's per-account scope. An empty scopedAccountIds array means the member sees NO accounts (empty = no access, the post-invite default until an admin grants specific accounts) — it does NOT grant all. Pass the connected-account UUIDs the member should see; the admin role bypasses scoping entirely. Every id must belong to the workspace; mismatches return 400.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
userId | path | yes | type string · format uuid | UUID of the workspace member whose cloud-account scope is being set. |
Request body (required)
The connected-account scope to assign to the member.
application/json
type object
scopedAccountIds(required)-
type array
array items:
type string · format uuid
example:
[ "3f8c1e42-9a7b-4c3d-8e1f-2a4b6c8d0e2f", "a2b3c4d5-6789-4abc-8def-0123456789ab" ]
no additional properties
example:
{
"scopedAccountIds": [
"3f8c1e42-9a7b-4c3d-8e1f-2a4b6c8d0e2f",
"a2b3c4d5-6789-4abc-8def-0123456789ab"
]
}Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Member cloud-account scope updated.
application/json
type object
userId-
type string · format uuid
example:
"11111111-1111-4111-8111-111111111111" scopedAccountIds-
type array
array items:
type string · format uuid
example:
[ "3f8c1e42-9a7b-4c3d-8e1f-2a4b6c8d0e2f" ]
example:
{
"userId": "11111111-1111-4111-8111-111111111111",
"scopedAccountIds": [
"3f8c1e42-9a7b-4c3d-8e1f-2a4b6c8d0e2f"
]
}400
Validation failure
application/json
see Error
example:
{
"error": {
"code": "invalid_slug",
"message": "workspace slug must be 3-32 characters of lowercase letters, numbers, and hyphens, starting and ending with a letter or number"
}
}DELETE /v1/workspaces/{workspaceSlug}/members/{userId} — Remove a member from the workspace (admin only)
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
userId | path | yes | type string · format uuid | UUID of the workspace member to remove. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Member removed from the workspace.
409
last_admin — removal is blocked because the member is the only admin who can sign in. Or lock_busy — another admin mutation in this organization held the tenant-wide admin lock when the server's lock wait expired. Nothing is written on lock_busy and it is safe to retry.
POST /v1/workspaces/{workspaceSlug}/invitations — Invite a user to this workspace (admin only)
Invites an email to THIS workspace with one role. The workspace is taken from the path and cannot be overridden by the body, and the invitation grants no organization-level role — a workspace admin seats people in their own workspace and nothing else.
Who may be invited: normally an address that already has a user account in the caller's organization, which a tenant admin creates by inviting them at the organization level. The one exception is an organization with single sign-on configured, where an address whose domain is listed in that organization's allowed sign-in domains may be seated before it has ever signed in — the identity provider is what admits people there. Any other address is refused 400 unknown_user.
A pre-placed single-sign-on grant carries no acceptance token: the invitee is emailed a link to their identity provider, and the grant is applied the next time they sign in. Its expiresAt is 90 days out rather than 7, because nothing prompts the invitee to act on it.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
Request body (required)
The workspace invitation to create.
application/json
type object
email(required)-
type string · format email
example:
"user@example.com" role(required)-
type string · one of
admin,member,viewer,auditor,compliance-adminexample:
"member" scopedAccountIds-
Connected-account UUIDs the invitee can see in this workspace on first sign-in. Omit or pass an empty array for "no accounts" — for the four scoped roles that is a denial, not a grant of everything, and it is the default.
The admin role bypasses scoping entirely, so an admin sees every account in the workspace whatever you send here. Ids are still VALIDATED for every role, so one outside this workspace is refused 400
bad_scopeeven withrole: admin; but for an admin they are then dropped rather than stored, and the 201 echoes back the empty list that was actually recorded.type array
array items:
type string · format uuid
example:
"22222222-2222-2222-2222-222222222222"example:
[ "22222222-2222-2222-2222-222222222222" ]
no additional properties
example:
{
"email": "user@example.com",
"role": "member",
"scopedAccountIds": [
"22222222-2222-2222-2222-222222222222"
]
}example:
{
"email": "user@example.com",
"role": "member",
"scopedAccountIds": [
"22222222-2222-2222-2222-222222222222"
]
}Responses
429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds.
Three caps reach this status on this operation and they bound different things. One is the ordinary /v1 mutation limiter, per principal, which every write on this API shares. The second is per CALLER on invitations specifically, and refuses because you have sent many invitations recently. The third is per INVITED ADDRESS and is shared across every organization, because it exists to bound how much mail one person's inbox receives from us — so it can refuse an invitation you have not sent before, on account of sends nobody in your organization made.
The address cap is spent only when an invitation is about to be written, so a refusal on any other ground costs nothing against it. Nothing is written and nothing is emailed when any of the three refuses.
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many attempts — wait 900s then try again"
}
}201
Workspace invitation created
application/json
type object
id-
type string · format uuid
example:
"33333333-3333-3333-3333-333333333333" email-
type string · format email
example:
"user@example.com" role-
type string · one of
admin,member,viewer,auditor,compliance-adminexample:
"member" scopedAccountIds-
type array
array items:
type string · format uuid
example:
"22222222-2222-2222-2222-222222222222"example:
[ "22222222-2222-2222-2222-222222222222" ] tokenless-
True when the invitation carries no acceptance token because the invitee signs in through the organization's identity provider.
type boolean
example:
false expiresAt-
type string · format date-time
example:
"2026-08-20T18:00:00Z"
example:
{
"id": "33333333-3333-3333-3333-333333333333",
"email": "user@example.com",
"role": "member",
"scopedAccountIds": [
"22222222-2222-2222-2222-222222222222"
],
"tokenless": false,
"expiresAt": "2026-08-20T18:00:00Z"
}example:
{
"id": "33333333-3333-3333-3333-333333333333",
"email": "user@example.com",
"role": "member",
"scopedAccountIds": [
"22222222-2222-2222-2222-222222222222"
],
"tokenless": false,
"expiresAt": "2026-08-20T18:00:00Z"
}400
bad_email (not a single well-formed address), bad_role (not a workspace role), bad_scope (an account id outside this workspace), or unknown_user (the address has no account in this organization and no single-sign-on domain covers it). Also bad_json, unknown_field or bad_field_type when the body itself is malformed, carries a property this operation does not define, or types one wrongly.
application/json
see Error
example:
{
"error": {
"code": "unknown_user",
"message": "we can only invite someone who already has an account in your organization. ask an organization admin to invite them first."
}
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}409
already_member — the invited email already holds a role in this workspace, so the grant could never be applied.
application/json
see Error
example:
{
"error": {
"code": "already_member",
"message": "user@example.com is already a member of this workspace — change their role in the roster above."
}
}423
Workspace is frozen (incident-response containment). Envelope carries code: workspace_frozen; ask a workspace admin to unfreeze before retrying.
application/json
type object
error-
type object
code-
type string · one of
workspace_frozenexample:
"workspace_frozen" message-
type string
example:
"this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
example:
{ "code": "workspace_frozen", "message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it" }
example:
{
"error": {
"code": "workspace_frozen",
"message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
}
}example:
{
"error": {
"code": "workspace_frozen",
"message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
}
}500
The server could not complete the request for a reason it cannot classify. Envelope carries code: internal_error and a requestId; the message is generic outside development, so quote the request id rather than branching on the copy.
Freeze-gated routes answer this when the freeze check itself fails — a dial failure, an exhausted pool, a cancelled request. It is NOT "no such workspace", which is the 404: these routes stopped folding every freeze-check error into that answer, because the caller has already passed the membership gate and a fault says nothing about their own data. The freeze check runs before the route's own work, so on that path nothing was written and one retry is worth trying.
Every other 500 on these routes is a different class and carries no such promise — an addAccount or createWorkspaceInvitation whose insert failed ambiguously may well have written, and the retry then answers 409. The envelope does not separate them; quote the requestId.
application/json
see Error
example:
{
"error": {
"code": "internal_error",
"message": "something went wrong on our end — try again, or contact an operator with this request id",
"requestId": "3f1a9c42-8b7e-4d15-9f0c-2a6b5e8d71c3"
}
}DELETE /v1/workspaces/{workspaceSlug}/invitations/{invitationId} — Revoke a pending workspace invitation (admin only)
Revokes an invitation that grants exactly this workspace and no organization-level role. An organization-level invitation, or one for another workspace, answers 404 — a workspace admin has no power over either, and 404 rather than 403 keeps this from reporting whether such an invitation exists.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
invitationId | path | yes | type string · format uuid | UUID of the pending workspace invitation. |
Responses
429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Workspace invitation revoked.
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
No pending invitation this workspace's admins may revoke. That covers an unknown or malformed id, one already accepted or revoked, one carrying an organization-level role, one granting a different workspace, and one granting this workspace ALONGSIDE another — the last belongs to whoever could grant both, so a workspace admin has no power over it. 404 rather than 403 throughout, so this cannot report whether such an invitation exists.
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "invitation not found or already finalized"
}
}423
Workspace is frozen (incident-response containment). Envelope carries code: workspace_frozen; ask a workspace admin to unfreeze before retrying.
application/json
type object
error-
type object
code-
type string · one of
workspace_frozenexample:
"workspace_frozen" message-
type string
example:
"this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
example:
{ "code": "workspace_frozen", "message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it" }
example:
{
"error": {
"code": "workspace_frozen",
"message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
}
}example:
{
"error": {
"code": "workspace_frozen",
"message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
}
}500
The server could not complete the request for a reason it cannot classify. Envelope carries code: internal_error and a requestId; the message is generic outside development, so quote the request id rather than branching on the copy.
Freeze-gated routes answer this when the freeze check itself fails — a dial failure, an exhausted pool, a cancelled request. It is NOT "no such workspace", which is the 404: these routes stopped folding every freeze-check error into that answer, because the caller has already passed the membership gate and a fault says nothing about their own data. The freeze check runs before the route's own work, so on that path nothing was written and one retry is worth trying.
Every other 500 on these routes is a different class and carries no such promise — an addAccount or createWorkspaceInvitation whose insert failed ambiguously may well have written, and the retry then answers 409. The envelope does not separate them; quote the requestId.
application/json
see Error
example:
{
"error": {
"code": "internal_error",
"message": "something went wrong on our end — try again, or contact an operator with this request id",
"requestId": "3f1a9c42-8b7e-4d15-9f0c-2a6b5e8d71c3"
}
}meta
GET /v1/openapi.yaml — This API specification
Authentication: none (anonymous)
Responses
200
OpenAPI 3.1 document
application/yaml
type string
quotas
GET /v1/workspaces/{workspaceSlug}/quotas — List service quotas (cursor-paginated)
Returns the current row of each quota's version chain. Quotas are limit values rather than inventory, so they are not returned by the resources endpoints.
AWS quota collection is opt-in per scan (serviceQuotas on the scan trigger); Azure and GCP quotas arrive with every scan. A workspace whose only connected accounts are AWS and has never opted in returns an empty list rather than an error.
Scoping matches the resources endpoints: quotas belonging to a cloud account outside the caller's member scope, or to a disconnected account, are not returned.
required scope: read
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
limit | query | no | type integer · default 100 · minimum 1 · maximum 1000 | Maximum number of quotas to return. |
cursor | query | no | type string | Opaque cursor carried over from the previous quota page's nextCursor. |
provider | query | no | type string | Filter quotas by cloud provider (aws, azure, gcp). |
region | query | no | type string | Filter quotas by cloud region. Use global for limits that apply account-wide rather than per region. |
account | query | no | type string | Filter quotas to a single cloud account identifier (AWS account number, Azure subscription GUID or GCP project id). |
service | query | no | type string | Filter quotas by provider service code, for example ec2, Microsoft.Compute or compute.googleapis.com. |
adjustable | query | no | type boolean | Filter by whether the limit can be raised on request. Omit for both. false selects the limits only the provider can move. |
raised | query | no | type boolean · default false | Keep only quotas whose applied value differs from the provider default. On an adjustable quota that is an increase the customer requested; on a non-adjustable one it means the provider moved a hard ceiling. Never matches a GCP quota: Cloud Quotas publishes no default, so defaultValue is null on every GCP row. |
changed | query | no | type boolean · default false | Keep only quotas that have held more than one value. Distinct from raised, which compares the current value against the default: a limit can sit above its default without ever moving, and can move repeatedly while landing back on it. Combine with adjustable=false to list the hard ceilings the provider has moved. Only changes observed since quotas moved to their own table are counted: limits carried over from the earlier storage arrived as a single version, so changed=false means "has not moved since then", not "has never moved". |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}200
Quota page
application/json
type object
items-
type array
array items:
see Quota
example:
[ { "id": "3773e0c1a243fe744bbdbe43b8107f2b", "provider": "aws", "accountId": "123456789012", "region": "us-east-1", "serviceCode": "ec2", "quotaCode": "L-1216C47A", "name": "Running On-Demand Standard instances", "value": 1152, "defaultValue": 5, "unit": "None", "adjustable": true, "dimensionKey": "", "subAccountType": "Account", "discoveredAt": "2026-07-17T18:00:00Z" } ] nextCursor-
Send as
cursorto continue paging quotas; absent once the last page has been returned.type string
example:
"eyJpZCI6ImExYjJjM2Q0In0"
example:
{
"items": [
{
"id": "3773e0c1a243fe744bbdbe43b8107f2b",
"provider": "aws",
"accountId": "123456789012",
"region": "us-east-1",
"serviceCode": "ec2",
"quotaCode": "L-1216C47A",
"name": "Running On-Demand Standard instances",
"value": 1152,
"defaultValue": 5,
"unit": "None",
"adjustable": true,
"dimensionKey": "",
"subAccountType": "Account",
"discoveredAt": "2026-07-17T18:00:00Z"
}
],
"nextCursor": "eyJpZCI6ImExYjJjM2Q0In0"
}GET /v1/workspaces/{workspaceSlug}/quotas/{quotaId} — Get a single service quota (current version)
Returns the current row of the quota's version chain. Scoping mirrors the list endpoint: an out-of-scope quota returns 404 so its existence is not leaked.
required scope: read
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
quotaId | path | yes | type string | Stable quota identifier — the hash of provider, account, region, service code and quota code, shared by every version of the limit. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}200
Quota detail
application/json
see Quota
example:
{
"id": "3773e0c1a243fe744bbdbe43b8107f2b",
"provider": "aws",
"accountId": "123456789012",
"accountName": "acme-prod",
"region": "us-east-1",
"serviceCode": "ec2",
"serviceName": "Amazon Elastic Compute Cloud (Amazon EC2)",
"quotaCode": "L-1216C47A",
"name": "Running On-Demand Standard instances",
"value": 1152,
"defaultValue": 5,
"unit": "None",
"adjustable": true,
"dimensionKey": "",
"subAccountType": "Account",
"discoveredAt": "2026-07-17T18:00:00Z",
"attributes": {
"QuotaArn": "arn:aws:servicequotas:us-east-1:123456789012:ec2/L-1216C47A"
}
}404
Quota not found in this workspace, or out of caller scope.
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "quota not found in this workspace"
}
}GET /v1/workspaces/{workspaceSlug}/quotas/{quotaId}/history — List every recorded version of one service quota
Walks the quota's full version chain in chronological order, root first. Each row is a limit the provider reported at a point in time.
A new version is recorded only when the limit itself changes — value, default, adjustability, unit or applied level — so an unchanged quota adds no rows however often it is scanned. A value change on a non-adjustable quota is the provider having moved a hard ceiling, which is not otherwise announced.
required scope: read
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
quotaId | path | yes | type string | Stable quota identifier whose version chain to walk. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}200
Version chain, oldest first
application/json
type object
items-
type array
array items:
see QuotaVersion
example:
[ { "id": "3773e0c1a243fe744bbdbe43b8107f2b", "provider": "aws", "accountId": "123456789012", "region": "us-east-1", "serviceCode": "ec2", "quotaCode": "L-1216C47A", "name": "Running On-Demand Standard instances", "value": 5, "adjustable": true, "dimensionKey": "", "discoveredAt": "2026-05-02T11:00:00Z", "versionRowId": "0197f0a2-1c3d-7e00-9a1b-2c3d4e5f6a7b", "rootId": "3773e0c1a243fe744bbdbe43b8107f2b", "supersededBy": "0198aa31-9b2c-7d00-8e1f-3a4b5c6d7e8f" } ]
example:
{
"items": [
{
"id": "3773e0c1a243fe744bbdbe43b8107f2b",
"provider": "aws",
"accountId": "123456789012",
"region": "us-east-1",
"serviceCode": "ec2",
"quotaCode": "L-1216C47A",
"name": "Running On-Demand Standard instances",
"value": 5,
"adjustable": true,
"dimensionKey": "",
"discoveredAt": "2026-05-02T11:00:00Z",
"versionRowId": "0197f0a2-1c3d-7e00-9a1b-2c3d4e5f6a7b",
"rootId": "3773e0c1a243fe744bbdbe43b8107f2b",
"supersededBy": "0198aa31-9b2c-7d00-8e1f-3a4b5c6d7e8f"
}
]
}404
No quota with this identifier is readable by the caller, so it has no history to walk.
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "quota not found in this workspace"
}
}resources
GET /v1/workspaces/{workspaceSlug}/resources — List tenant resources (cursor-paginated)
required scope: read
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
limit | query | no | type integer · default 100 · minimum 1 · maximum 1000 | Maximum number of resources to return. |
cursor | query | no | type string | Opaque cursor from previous response's nextCursor. |
provider | query | no | type string | Filter resources by cloud provider (aws, azure, gcp). |
region | query | no | type string | Filter resources by cloud region. |
account | query | no | type string | Filter resources to a single cloud account identifier (AWS account number, Azure subscription GUID, or GCP project id). |
since | query | no | type string · format date-time | Return only resources discovered (scan-observed) at or after this RFC 3339 time. |
Responses
400
A query parameter did not parse. limit outside [1,1000], or a cursor that is not a token this endpoint issued — including one whose offset exceeds the range the database accepts.
application/json
see Error
example:
{
"error": {
"code": "bad_query",
"message": "cursor invalid"
}
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}200
Resource page
application/json
type object
items-
type array
array items:
see Resource
example:
[ { "id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "type": "aws:s3:bucket", "region": "us-east-1", "accountId": "123456789012", "provider": "aws", "discoveredAt": "2026-07-17T18:00:00Z" } ] nextCursor-
Pass this back as
cursorto fetch the next page; absent on the last page.type string
example:
"eyJpZCI6ImExYjJjM2Q0In0"
example:
{
"items": [
{
"id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"type": "aws:s3:bucket",
"region": "us-east-1",
"accountId": "123456789012",
"provider": "aws",
"discoveredAt": "2026-07-17T18:00:00Z"
}
],
"nextCursor": "eyJpZCI6ImExYjJjM2Q0In0"
}GET /v1/workspaces/{workspaceSlug}/resources/summary — Aggregate rollups for the workspace's resource inventory
Returns counts grouped by type/region/account/provider plus total and untaggedCount. Tag hygiene is "tags IS NULL OR tags is not a JSON object OR tags = {}". Both counts include provider-managed resources, so untaggedCount covers a wider population than the tagging coverage the web UI reports.
required scope: read
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}200
Summary
application/json
see ResourceSummary
example:
{
"total": 128,
"untaggedCount": 12,
"byType": [
{
"key": "aws:s3:bucket",
"count": 40
}
],
"byRegion": [
{
"key": "us-east-1",
"count": 96
}
],
"byAccount": [
{
"key": "123456789012",
"count": 128
}
],
"byProvider": [
{
"key": "aws",
"count": 128
}
]
}GET /v1/workspaces/{workspaceSlug}/resources/{resourceId} — Get a single resource (current version)
Returns the current row of the resource's version chain plus decoded tags map and attributes JSON. Scoping mirrors the list endpoint: out-of-scope resources return 404 so existence is not leaked.
required scope: read
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
resourceId | path | yes | type string | Identifier of the resource to read or archive. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}200
Resource detail
application/json
combines:
all fields of Resource:
A scanned cloud resource.
type object
id-
type string
type-
type string
region-
type string
accountId-
type string
provider-
type string
discoveredAt-
Scan time the current row was observed. The
sincefilter param maps to this column.type string · format date-time
additional properties allowed
example:
{
"id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"type": "aws:s3:bucket",
"nativeId": "arn:aws:s3:::acme-prod-assets",
"region": "us-east-1",
"accountId": "123456789012",
"provider": "aws",
"discoveredAt": "2026-07-17T18:00:00Z"
}type object
tags-
type object
additional properties:
type string
attributes-
type object
additional properties allowed
discoveredBy-
type string | null
billedCost-
Summed billed cost across every line the latest complete FOCUS report matched to this resource. Null when no completed report exists or none of its lines matched this resource's root id — the UI omits the cost section entirely rather than showing a zeroed-out one.
type string | null
effectiveCost-
Same matching as billed_cost; the FOCUS EffectiveCost sum (reflects commitment-discount amortization).
type string | null
example:
{
"id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"type": "aws:s3:bucket",
"region": "us-east-1",
"accountId": "123456789012",
"provider": "aws",
"discoveredAt": "2026-07-17T18:00:00Z",
"tags": {
"env": "prod",
"team": "platform"
},
"attributes": {
"versioning": "Enabled"
},
"discoveredBy": "9f8e7d6c-5b4a-3210-fedc-ba9876543210",
"billedCost": "42.17",
"effectiveCost": "39.80"
}404
The resource does not exist in this workspace, or is outside the caller's account scope.
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "resource not found in this workspace"
}
}DELETE /v1/workspaces/{workspaceSlug}/resources/{resourceId} — Archive a resource (manual tombstone)
Marks the resource archived (a version-chain tombstone) so it drops out of the default inventory list. Use it for a resource known to be deleted in the cloud. Idempotent: archiving an already-archived resource returns 200 with changed:false. A later scan that still finds the resource restores it automatically. Out-of-scope or cross-workspace ids return 404 (no existence oracle).
required scope: write
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
resourceId | path | yes | type string | Identifier of the resource to read or archive. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
The resource to archive does not exist in this workspace, or is outside the caller's account scope.
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "resource not found in this workspace"
}
}429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Resource archived (or already archived).
application/json
example:
{
"id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"archived": true,
"changed": true
}423
Workspace is frozen (incident-response containment). Envelope carries code: workspace_frozen; ask a workspace admin to unfreeze before retrying.
application/json
type object
error-
type object
code-
type string · one of
workspace_frozenexample:
"workspace_frozen" message-
type string
example:
"this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
example:
{ "code": "workspace_frozen", "message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it" }
example:
{
"error": {
"code": "workspace_frozen",
"message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
}
}example:
{
"error": {
"code": "workspace_frozen",
"message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
}
}500
The server could not complete the request for a reason it cannot classify. Envelope carries code: internal_error and a requestId; the message is generic outside development, so quote the request id rather than branching on the copy.
Freeze-gated routes answer this when the freeze check itself fails — a dial failure, an exhausted pool, a cancelled request. It is NOT "no such workspace", which is the 404: these routes stopped folding every freeze-check error into that answer, because the caller has already passed the membership gate and a fault says nothing about their own data. The freeze check runs before the route's own work, so on that path nothing was written and one retry is worth trying.
Every other 500 on these routes is a different class and carries no such promise — an addAccount or createWorkspaceInvitation whose insert failed ambiguously may well have written, and the retry then answers 409. The envelope does not separate them; quote the requestId.
application/json
see Error
example:
{
"error": {
"code": "internal_error",
"message": "something went wrong on our end — try again, or contact an operator with this request id",
"requestId": "3f1a9c42-8b7e-4d15-9f0c-2a6b5e8d71c3"
}
}POST /v1/workspaces/{workspaceSlug}/resources/{resourceId}/restore — Restore an archived resource
Clears the archive tombstone so the resource returns to the default inventory list. Idempotent: restoring a live resource returns 200 with changed:false. Out-of-scope or cross-workspace ids return 404.
required scope: write
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
resourceId | path | yes | type string | Identifier of the resource to restore. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
The resource to restore does not exist in this workspace, or is outside the caller's account scope.
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "resource not found in this workspace"
}
}429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Resource restored (or already live).
application/json
example:
{
"id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"archived": false,
"changed": true
}423
Workspace is frozen (incident-response containment). Envelope carries code: workspace_frozen; ask a workspace admin to unfreeze before retrying.
application/json
type object
error-
type object
code-
type string · one of
workspace_frozenexample:
"workspace_frozen" message-
type string
example:
"this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
example:
{ "code": "workspace_frozen", "message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it" }
example:
{
"error": {
"code": "workspace_frozen",
"message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
}
}example:
{
"error": {
"code": "workspace_frozen",
"message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
}
}500
The server could not complete the request for a reason it cannot classify. Envelope carries code: internal_error and a requestId; the message is generic outside development, so quote the request id rather than branching on the copy.
Freeze-gated routes answer this when the freeze check itself fails — a dial failure, an exhausted pool, a cancelled request. It is NOT "no such workspace", which is the 404: these routes stopped folding every freeze-check error into that answer, because the caller has already passed the membership gate and a fault says nothing about their own data. The freeze check runs before the route's own work, so on that path nothing was written and one retry is worth trying.
Every other 500 on these routes is a different class and carries no such promise — an addAccount or createWorkspaceInvitation whose insert failed ambiguously may well have written, and the retry then answers 409. The envelope does not separate them; quote the requestId.
application/json
see Error
example:
{
"error": {
"code": "internal_error",
"message": "something went wrong on our end — try again, or contact an operator with this request id",
"requestId": "3f1a9c42-8b7e-4d15-9f0c-2a6b5e8d71c3"
}
}GET /v1/workspaces/{workspaceSlug}/resources/{resourceId}/history — List a resource's version history
Returns the full version chain for one resource id, in reverse-discovery order. Each version row carries superseded_by (null on the current row); discovered_by is a scan id. Out-of-scope seeds 404.
required scope: read
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
resourceId | path | yes | type string | Identifier of the resource whose version history is listed. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}200
Version chain
application/json
type object
items-
type array
array items:
type object
additional properties allowed
example:
[ { "id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "provider": "aws", "accountId": "123456789012", "type": "aws:s3:bucket", "nativeId": "arn:aws:s3:::acme-prod-assets", "discoveredBy": "9f8e7d6c-5b4a-3210-fedc-ba9876543210" } ]
example:
{
"items": [
{
"id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"provider": "aws",
"accountId": "123456789012",
"type": "aws:s3:bucket",
"nativeId": "arn:aws:s3:::acme-prod-assets",
"region": "us-east-1",
"discoveredAt": "2026-07-17T18:00:00Z",
"discoveredBy": "9f8e7d6c-5b4a-3210-fedc-ba9876543210"
}
]
}GET /v1/workspaces/{workspaceSlug}/resources/{resourceId}/graph/blast — Blast-radius graph (out-edges by default)
Returns the blast-radius walk from the seed resource. Defaults mirror disco graph blast: depth 3, direction=out, excludes contains from the kind allowlist. IAM-principal seeds auto-fall back to both-directions when out-only yields nothing. Scoping mirrors the resource detail handler.
required scope: read
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
resourceId | path | yes | type string | Identifier of the resource at the center of the blast-radius graph. |
depth | query | no | type integer · default 3 · minimum 1 · maximum 10 | Maximum graph traversal depth from the resource (1-10). |
direction | query | no | type string · one of out, in, both · default out | Edge direction to follow from the resource (out, in, both). |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}200
Graph
application/json
type object
additional properties allowed
example:
{
"seedId": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"nodes": [
{
"resource": {
"id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"provider": "aws",
"accountId": "123456789012",
"type": "aws:s3:bucket",
"nativeId": "arn:aws:s3:::acme-prod-assets",
"region": "us-east-1",
"discoveredAt": "2026-07-17T18:00:00Z",
"discoveredBy": "9f8e7d6c-5b4a-3210-fedc-ba9876543210"
},
"depth": 0
},
{
"resource": {
"id": "c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8",
"provider": "aws",
"accountId": "123456789012",
"type": "aws:iam:role",
"nativeId": "arn:aws:iam::123456789012:role/app",
"region": null,
"discoveredAt": "2026-07-17T18:00:00Z",
"discoveredBy": "9f8e7d6c-5b4a-3210-fedc-ba9876543210"
},
"depth": 1
}
],
"edges": [
{
"fromId": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"toId": "c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8",
"kind": "grants"
}
]
}404
Seed resource not found in this workspace, or out of caller scope.
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "resource not found in this workspace"
}
}scans
GET /v1/workspaces/{workspaceSlug}/scans — List scans
required scope: read
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
limit | query | no | type integer · default 100 · minimum 1 · maximum 1000 | Maximum number of scans to return. |
cursor | query | no | type string | Opaque cursor from a previous response's nextCursor. |
status | query | no | type string · one of pending, running, completed, partial, failed | Filter scans by status. Matched literally against the stored value, so a status outside this enum returns an empty page rather than an error. |
Responses
400
A query parameter did not parse: limit outside [1,1000], or a cursor that is not a two-part token this endpoint issued. An unrecognised status is NOT a 400 — it matches no row and returns an empty page.
application/json
see Error
example:
{
"error": {
"code": "bad_query",
"message": "cursor invalid"
}
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}200
Scan list
application/json
type object
items-
type array
array items:
see Scan
example:
[ { "id": "9f8e7d6c-5b4a-3210-fedc-ba9876543210", "status": "completed", "startedAt": "2026-07-17T18:00:00Z", "finishedAt": "2026-07-17T18:03:12Z", "resourceCount": 128, "error": null, "accountId": "123456789012", "regions": "us-east-1,us-west-2", "services": "s3,ec2", "principalArn": "arn:aws:iam::123456789012:role/disco-scanner", "scannerVersion": "v0.16.0", "triggeredBy": "11111111-1111-1111-1111-111111111111", "triggeredByEmail": "user@example.com" } ] nextCursor-
Repeat the request with this value as
cursorfor the following page of scans; omitted when no further scans remain.type string
example:
"eyJpZCI6IjlmOGU3ZDZjIn0"
example:
{
"items": [
{
"id": "9f8e7d6c-5b4a-3210-fedc-ba9876543210",
"status": "completed",
"startedAt": "2026-07-17T18:00:00Z",
"finishedAt": "2026-07-17T18:03:12Z",
"resourceCount": 128,
"error": null,
"accountId": "123456789012",
"regions": "us-east-1,us-west-2",
"services": "s3,ec2",
"principalArn": "arn:aws:iam::123456789012:role/disco-scanner",
"scannerVersion": "v0.16.0",
"triggeredBy": "11111111-1111-1111-1111-111111111111",
"triggeredByEmail": "user@example.com"
}
],
"nextCursor": "eyJpZCI6IjlmOGU3ZDZjIn0"
}POST /v1/workspaces/{workspaceSlug}/scans — Trigger a scan
provider is required and is matched exactly — one of aws, azure, gcp, with no surrounding whitespace. cloudAccountId identifies which connected account to scan; omit it only when the workspace has exactly one account for the provider. When omitted and the workspace has multiple accounts, the handler returns 400 account_id_required with the availableAccounts list — pick one and resend.
Every account named here is bounded by the caller's own cloud-account grant: a member granted a subset of the workspace's accounts can trigger scans for those and no others, and "multiple accounts" above counts only the ones they were granted.
required scope: write
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
Request body (required)
Scan options, including which connected account to scan.
application/json
type object
provider(required)-
type string · one of
aws,azure,gcpexample:
"aws" regions-
Regions to scan. Omit or leave empty to scan all regions the provider supports — for AWS this is expanded server-side to the full region set (an empty list otherwise scans only us-east-1); Azure and GCP already cover their whole footprint.
type array
array items:
type string
example:
[ "us-east-1", "us-west-2" ] services-
type array
array items:
type string
example:
[ "s3", "ec2" ] cloudAccountId-
cloud-side account identifier (e.g. AWS 12-digit account id) — matches connected_accounts.cloud_account_id.
type string
example:
"123456789012" serviceQuotas-
AWS only — also scan account service quotas (aws:servicequotas), which disco skips by default because the catalogue is large and slow to walk. Ignored for Azure and GCP, which scan quotas on every scan. Quotas are recorded as limits, readable under /v1/workspaces/{workspaceSlug}/quotas; they are not resources and do not appear in the resources endpoints or in a scan's resource count.
type boolean
example:
false
no additional properties
example:
{
"provider": "aws",
"regions": [
"us-east-1",
"us-west-2"
],
"services": [
"s3",
"ec2"
],
"cloudAccountId": "123456789012",
"serviceQuotas": false
}Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}202
Scan accepted
application/json
see ScanTriggered
example:
{
"scanId": "9f8e7d6c-5b4a-3210-fedc-ba9876543210",
"taskArn": "arn:aws:ecs:us-east-1:123456789012:task/disco-saas-dev/1a2b3c4d5e6f7890",
"workspaceId": "11111111-1111-1111-1111-111111111111"
}400
Validation failure. Codes: * missing_provider — provider field absent or empty. * provider_invalid — provider is non-empty and not exactly one of aws, azure, gcp. * region_invalid — a regions entry is not a known region for the provider. * service_invalid — a services entry is not shaped like a service filter. The check is on characters, not on a catalogue: lowercase letters, digits, :, _ and -, at least one of them. A well-formed name for a service that does not exist is accepted here and passed to the scanner. Refused before a scan id is reserved, so nothing is recorded: no scan row, no account health change, no audit row. * scan_request_invalid — the request was rejected at dispatch, after a scan id was reserved. Every field this route takes from the request body is checked before dispatch, so what remains are faults in the connected account's stored configuration — a subscription id, role ARN, binding id or audience that does not parse. Nothing the caller sends produces it; an operator or admin fixes the account, and the message on this response names the field that did not parse. Unlike the codes above, this one records: the reserved scan is marked failed with the reason, the connected account's health is stamped, and a scan.dispatch.failed audit row is written. * account_id_required — cloudAccountId was omitted and more than one account for provider is available to the caller (every account in the workspace for an admin, the caller's granted subset for a scoped member); resend with cloudAccountId set to one of availableAccounts. * no_accounts_configured — workspace has zero connected accounts; connect one via POST /v1/workspaces/{workspaceSlug}/accounts first. * directory_not_consented — azure only. The subscription's Microsoft Entra directory holds no current admin consent for this workspace, so nothing has proved the workspace administers the directory the subscription was delegated out of. A directory administrator must grant access from the workspace's accounts page in a browser; granting has no API equivalent. A consent also lapses when disco's own Entra application is rotated, in which case every azure scan answers this until each directory is granted again. * directory_unknown — azure only. The connected account records no customerTenantId, so the subscription cannot be attributed to a directory at all. Remove the account and add it again with customerTenantId set; the consent flow cannot fix this one. * bad_json — request body did not parse as JSON (empty body or syntax error). * unknown_field — request body contains a key not in the schema. * bad_field_type — a field's JSON type does not match the schema.
application/json
type object
error-
type object
code-
type string · one of
missing_provider,provider_invalid,region_invalid,service_invalid,scan_request_invalid,account_id_required,no_accounts_configured,directory_not_consented,directory_unknown,bad_json,unknown_field,bad_field_type message-
type string
availableAccounts-
present when code = account_id_required
type array
array items:
type string
example:
{ "code": "account_id_required", "message": "this workspace has multiple aws accounts; resend with cloudAccountId", "availableAccounts": [ "123456789012", "210987654321" ] }
example:
{
"error": {
"code": "account_id_required",
"message": "this workspace has multiple aws accounts; resend with cloudAccountId",
"availableAccounts": [
"123456789012",
"210987654321"
]
}
}404
No connected account the caller may scan matches the workspace, provider and (when sent) cloudAccountId (account_not_found).
A member whose cloud-account grant excludes the account gets this same answer, rather than 403, so the response does not distinguish an account they may not scan from one that does not exist. An account outside the grant is not listed in availableAccounts either. Note the 400 no_accounts_configured above still counts every account in the workspace, so it does say whether the workspace holds any at all.
409
scan_already_dispatched — a pending/running scan for this account was dispatched within the last 30 seconds, so the duplicate was suppressed (scan dedup gate). Poll the in-flight scan instead, or retry after the cooldown elapses. Or lock_busy — another trigger for the same account held the dispatch lock when the server's lock wait expired, which is the same situation caught a moment earlier; nothing was dispatched and the request is safe to retry.
headers:
Retry-After— Seconds to wait before retrying. Onscan_already_dispatchedthis is the remaining cooldown; onlock_busyit is the full 30-second dedup window, because the trigger that won the lock is about to register a scan that would suppress an earlier retry anyway.
application/json
type object
error-
type object
code-
type string · one of
scan_already_dispatched message-
type string
inflightScanId-
id of the already-dispatched scan.
type string
retryAfterSeconds-
approximate remaining cooldown before a new scan may start.
type integer
example:
{ "code": "scan_already_dispatched", "message": "a scan for this account was dispatched moments ago", "inflightScanId": "9f8e7d6c-5b4a-3210-fedc-ba9876543210", "retryAfterSeconds": 22 }
example:
{
"error": {
"code": "scan_already_dispatched",
"message": "a scan for this account was dispatched moments ago",
"inflightScanId": "9f8e7d6c-5b4a-3210-fedc-ba9876543210",
"retryAfterSeconds": 22
}
}423
Workspace is frozen (incident-response containment). Envelope carries code: workspace_frozen; ask a workspace admin to unfreeze before retrying.
application/json
type object
error-
type object
code-
type string · one of
workspace_frozenexample:
"workspace_frozen" message-
type string
example:
"this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
example:
{ "code": "workspace_frozen", "message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it" }
example:
{
"error": {
"code": "workspace_frozen",
"message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
}
}example:
{
"error": {
"code": "workspace_frozen",
"message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
}
}500
query_failed — resolving the account for this scan failed for a reason this API cannot classify. It is NOT "no such account", which is the 404 above: this surface stopped folding every lookup error into that answer, so an infrastructure fault now reports as one. No scan was dispatched. One retry is worth trying; a condition that persists is an operator's to clear (a tenant schema behind on migrations produces exactly this). query_failed is the server-side class name for this outcome and does NOT reach the wire: the handler answers through writeInternalError, whose envelope carries internal_error, and the generic message outside development, with the class on the operator's log line only. Quote the requestId rather than branching on the code. The same status also answers a scan_claim_failed — the idempotency claim, written after the account resolves — and a scope_lookup_failed, which fires BEFORE the account is resolved and means the caller's own cloud-account grant could not be read. All three are indistinguishable here for the same reason. The scope fault is not degraded in either direction: reading it as no grant would refuse a member their own account, and reading it as an unrestricted one would dispatch outside the grant. A fourth class, workspace_freeze_gate, joins them: the freeze check itself failed, which is likewise not "no such workspace".
application/json
see Error
example:
{
"error": {
"code": "internal_error",
"message": "something went wrong on our end — try again, or contact an operator with this request id",
"requestId": "3f1a9c42-8b7e-4d15-9f0c-2a6b5e8d71c3"
}
}502
trigger_failed — the scanner platform rejected the run for one of four reasons, and the response does not say which. Three are transient and a retry clears them: a throttle, a capacity shortfall, and a transient AWS error. The fourth is a rejection the classifier could not place at all — an unrecognised platform error code — which may be transient and may equally be an operator fault that no amount of retrying will clear. So bound the retries rather than treating this status as a promise that one will eventually succeed. The scan id this call reserved is marked failed as the response is produced, so it is not left pending and it does not deduplicate the retry — a fresh trigger is accepted immediately. That recording is best-effort: if it fails the failure is logged and this response is still sent, and the reserved scan is then left pending until a background sweep closes it out — hours later, since the stale-scan reaper's fast path reads a table written only after a dispatch SUCCEEDS, and the sweep that reads scans directly runs on a much longer bound. Do not treat a scan still pending past that as live: it ends as failed with a reason naming the dispatch. An operator-fault rejection answers 503 scanner_unavailable instead, and a retry cannot clear that one. The message on this response is blank outside development (5xx bodies are masked), so branch on the code — the error body carries no scan id. The failed scan row carries the specific reason in its error field, and a scan.dispatch.failed audit row records the category and the raw platform code.
application/json
see Error
example:
{
"error": {
"code": "trigger_failed",
"message": ""
}
}503
connector_unconfigured — azure only. This deployment's Azure credential configuration is missing or malformed, so dispatch was refused before the scanner was reached. An AWS or GCP deployment whose scanner configuration is missing answers scanner_unavailable instead; one whose stored account configuration does not parse answers 400 scan_request_invalid. For azure this also covers a deployment carrying no Entra application registration, which makes every directory unconsentable and so refuses every azure scan in every workspace — it is reported here rather than as directory_not_consented because no customer action can clear it. Nothing the caller sends clears it; an operator must set the deployment's connector variables, and a client retry cannot succeed however long it waits. The message is blank outside development (5xx bodies are masked), so the code is all a caller has to go on. This route answers 503 with two other codes. scan_trigger_disabled means the deployment runs with scan dispatch switched off entirely, for every cloud, rather than being unconfigured for one. scanner_unavailable means the dispatch was rejected for a reason only an operator can clear, so retrying achieves nothing however long the caller waits. Two shapes reach it. This deployment registered no scanner task definition for the requested provider, refused before the scanner platform is called at all. Or the platform itself rejected the run: an unassumable scanner role, a missing cluster, an unsupported Fargate platform version, or a task definition it will not accept. The response does not say which. As with trigger_failed, the reserved scan id is marked failed as the response is produced and the specific reason is recorded on that scan row and in a scan.dispatch.failed audit row; that recording is best-effort, so a failure to write it is logged and this response is still sent. connector_unconfigured has two sources and they differ in what they leave behind. Refused before a scan id is reserved — a deployment carrying no Entra application registration — it records nothing. Refused at dispatch — no Azure federation configured — it records exactly as scanner_unavailable does above.
application/json
see Error
example:
{
"error": {
"code": "connector_unconfigured",
"message": ""
}
}GET /v1/workspaces/{workspaceSlug}/scans/{scanId} — Get a single scan
required scope: read
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
scanId | path | yes | type string | Identifier of the scan. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}200
Scan
application/json
see Scan
example:
{
"id": "9f2c8a1b-4d6e-4f37-a5c9-b2e1d8f4a6c0",
"status": "completed",
"startedAt": "2026-07-17T18:00:00Z",
"finishedAt": "2026-07-17T18:04:12Z",
"resourceCount": 1428,
"error": null,
"errors": [],
"accountId": "123456789012",
"regions": "us-east-1,us-west-2",
"services": "aws:s3,aws:ec2,aws:iam",
"principalArn": "arn:aws:iam::123456789012:role/disco-scanner",
"scannerVersion": "v0.16.0",
"triggeredBy": "11111111-1111-1111-1111-111111111111",
"triggeredByEmail": "user@example.com"
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}sessions
GET /v1/workspaces/{workspaceSlug}/sessions — List active sessions across workspace members (admin only)
required scope: read
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
user | query | no | type string · format uuid | Scope the listing to one member's sessions. |
status | query | no | type string · one of active, expired, all · default active | Default active returns only unexpired sessions; expired returns only expired; all returns both. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}200
Sessions across all members of the workspace, filtered by the status query parameter.
application/json
type object
items-
type array
array items:
type object
sessionId-
type string · format uuid
userId-
type string · format uuid
email-
type string · format email
createdAt-
type string · format date-time
expiresAt-
type string · format date-time
example:
{ "sessionId": "c1d2e3f4-0000-4000-8000-000000000020", "userId": "33333333-3333-3333-3333-333333333333", "email": "member@example.com", "createdAt": "2026-07-17T09:00:00Z", "expiresAt": "2026-08-16T09:00:00Z" }example:
[ { "sessionId": "c1d2e3f4-0000-4000-8000-000000000020", "userId": "33333333-3333-3333-3333-333333333333", "email": "member@example.com", "createdAt": "2026-07-17T09:00:00Z", "expiresAt": "2026-08-16T09:00:00Z" } ]
example:
{
"items": [
{
"sessionId": "c1d2e3f4-0000-4000-8000-000000000020",
"userId": "33333333-3333-3333-3333-333333333333",
"email": "member@example.com",
"createdAt": "2026-07-17T09:00:00Z",
"expiresAt": "2026-08-16T09:00:00Z"
}
]
}example:
{
"items": [
{
"sessionId": "c1d2e3f4-0000-4000-8000-000000000020",
"userId": "33333333-3333-3333-3333-333333333333",
"email": "member@example.com",
"createdAt": "2026-07-17T09:00:00Z",
"expiresAt": "2026-08-16T09:00:00Z"
}
]
}DELETE /v1/workspaces/{workspaceSlug}/sessions — Revoke every workspace member session except the caller's (incident response)
Admin only. Revokes every session belonging to a member of this workspace except the caller's current session. To revoke one specific session, use DELETE /v1/workspaces/{workspaceSlug}/sessions/{sessionId}. No request body — the breadth is the whole point, and the UI confirms before calling.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Summary of the workspace members' sessions that were revoked.
application/json
type object
revoked-
type integer
example:
5 userIds-
type array
array items:
type string · format uuid
example:
[ "33333333-3333-3333-3333-333333333333", "44444444-4444-4444-4444-444444444444" ] includedSelf-
Always false on this path — the caller's current session is spared.
type boolean
example:
false
example:
{
"revoked": 5,
"userIds": [
"33333333-3333-3333-3333-333333333333",
"44444444-4444-4444-4444-444444444444"
],
"includedSelf": false
}example:
{
"revoked": 5,
"userIds": [
"33333333-3333-3333-3333-333333333333",
"44444444-4444-4444-4444-444444444444"
],
"includedSelf": false
}DELETE /v1/workspaces/{workspaceSlug}/sessions/{sessionId} — Revoke one workspace member session (incident response)
Admin only. Revokes the single session named in the path, provided it belongs to a member of this workspace — an id outside the workspace returns 404.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
sessionId | path | yes | type string · format uuid | UUID of the workspace member session to revoke. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
The named workspace member session was revoked.
application/json
type object
revoked-
type integer
example:
1 userIds-
type array
array items:
type string · format uuid
example:
[ "33333333-3333-3333-3333-333333333333" ] includedSelf-
True when the revoked session was the caller's own (another device).
type boolean
example:
false
example:
{
"revoked": 1,
"userIds": [
"33333333-3333-3333-3333-333333333333"
],
"includedSelf": false
}example:
{
"revoked": 1,
"userIds": [
"33333333-3333-3333-3333-333333333333"
],
"includedSelf": false
}404
No such session belongs to a member of this workspace.
application/json
see Error
example:
{
"error": {
"code": "session_not_in_workspace",
"message": "no such session belongs to a member of this workspace — check the session id and try again"
}
}GET /v1/account/sessions — List the caller's own active cookie sessions
Cookie-auth only. Returns every unexpired row in public.sessions belonging to the caller. The session used to make the request is marked current: true so clients can hide the revoke control on the row that would log them out.
required scope: read
Responses
200
The caller's own active sessions.
application/json
type object
items-
type array
array items:
type object
sessionId-
type string · format uuid
createdAt-
type string · format date-time
expiresAt-
type string · format date-time
userAgent-
type string
ip-
type string
current-
type boolean
example:
{ "sessionId": "c1d2e3f4-0000-4000-8000-000000000010", "createdAt": "2026-07-17T09:00:00Z", "expiresAt": "2026-08-16T09:00:00Z", "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", "ip": "203.0.113.42", "current": true }example:
[ { "sessionId": "c1d2e3f4-0000-4000-8000-000000000010", "createdAt": "2026-07-17T09:00:00Z", "expiresAt": "2026-08-16T09:00:00Z", "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", "ip": "203.0.113.42", "current": true } ]
example:
{
"items": [
{
"sessionId": "c1d2e3f4-0000-4000-8000-000000000010",
"createdAt": "2026-07-17T09:00:00Z",
"expiresAt": "2026-08-16T09:00:00Z",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
"ip": "203.0.113.42",
"current": true
}
]
}example:
{
"items": [
{
"sessionId": "c1d2e3f4-0000-4000-8000-000000000010",
"createdAt": "2026-07-17T09:00:00Z",
"expiresAt": "2026-08-16T09:00:00Z",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
"ip": "203.0.113.42",
"current": true
}
]
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}DELETE /v1/account/sessions — Revoke all of the caller's own sessions except the current one
Cookie-auth only (bearer rejected to prevent a stolen api key from killing the human's other sessions). Revokes every session except the one making the request. To revoke one specific session, use DELETE /v1/account/sessions/{sessionId}. No request body.
required scope: write
Responses
429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Summary of the caller's sessions that were revoked.
application/json
type object
ok-
type boolean
example:
true revoked-
type integer
example:
3
example:
{
"ok": true,
"revoked": 3
}example:
{
"ok": true,
"revoked": 3
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Bearer auth rejected on a self-revoke
application/json
see Error
example:
{
"error": {
"code": "bearer_not_allowed",
"message": "self-session revoke must come from the cookie session — api keys cannot revoke other sessions"
}
}DELETE /v1/account/sessions/{sessionId} — Revoke one of the caller's own sessions
Cookie-auth only (bearer rejected). Revokes the single session named in the path, provided it belongs to the caller and is not the current request's session — otherwise 404.
required scope: write
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
sessionId | path | yes | type string · format uuid | UUID of the caller's own session to revoke. |
Responses
429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
The named session was revoked.
application/json
type object
ok-
type boolean
example:
true revoked-
type integer
example:
1
example:
{
"ok": true,
"revoked": 1
}example:
{
"ok": true,
"revoked": 1
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Bearer auth rejected on a targeted self-revoke
application/json
see Error
example:
{
"error": {
"code": "bearer_not_allowed",
"message": "self-session revoke must come from the cookie session — api keys cannot revoke other sessions"
}
}404
Session id not found among caller's sessions
application/json
see Error
example:
{
"error": {
"code": "session_not_found",
"message": "no such session belongs to you, or it is your current session and cannot be revoked here"
}
}sso
GET /v1/tenant/{tenantSlug}/sso — Read the tenant's OIDC single sign-on config (tenant-admin only)
Returns the caller's tenant OIDC SSO configuration. disco registers with the identity provider as a public PKCE client, so there is no client secret in the config. The read is NOT plan-gated — it mirrors the HTML settings page, which renders read-only on every plan and grays out the controls. The entitled field reports whether the tenant's plan (Pro or Enterprise) includes SSO; the mutating routes (PUT / DELETE / discover) still fail-close with 403 sso_not_entitled when it is false. A tenant that has never configured SSO gets 404 sso_not_configured.
required scope: read
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
tenantSlug | path | yes | type string | URL slug of the caller's home tenant. A user belongs to exactly one tenant, so this must match the caller's own tenant slug; any other value returns 404. |
Responses
200
Current SSO config
application/json
see TenantSSOConfig
example:
{
"configured": true,
"entitled": true,
"enabled": true,
"idpType": "oidc",
"issuer": "https://idp.example.com",
"clientId": "disco-saas",
"scopes": [
"openid",
"email",
"profile"
],
"allowedEmailDomains": [
"example.com"
],
"verified": true,
"verifiedAt": "2026-08-12T09:14:22.481Z"
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
SSO not configured for this tenant (sso_not_configured)
PUT /v1/tenant/{tenantSlug}/sso — Create or replace the tenant's OIDC SSO config (tenant-admin only)
Idempotent upsert of the tenant OIDC config. No client secret is accepted or stored (public PKCE client). A just-in-time provisioned user lands with no workspace access; grant it with an invitation, which is applied on every sign-in. Requires an admin-scoped key for bearer callers.
enabled: true is refused with sso_enable_unverified unless a real sign-in has already been completed through the exact configuration in this request — see the verified field on the response. Store the configuration with enabled: false, complete the sign-in from /tenant/{tenantSlug}/sso, then send it again with enabled: true. Turning SSO off is never gated.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
tenantSlug | path | yes | type string | URL slug of the caller's home tenant. A user belongs to exactly one tenant, so this must match the caller's own tenant slug; any other value returns 404. |
Request body (required)
The OIDC SSO provider configuration to store.
application/json
idp_type selects the protocol (default oidc). OIDC uses issuer + client_id; SAML uses entity_id + sso_url + certificates. No client secret or SP private key is accepted or stored.
type object
idpType-
type string · one of
oidc,saml· defaultoidcexample:
"oidc" issuer-
OIDC provider discovery URL (https only).
type string · format uri
example:
"https://idp.example.com" clientId-
OIDC application/client id.
type string
example:
"disco-saas" scopes-
OIDC scopes.
type array
array items:
type string
example:
"openid"example:
[ "openid", "email", "profile" ] trustUnverifiedEmail-
OIDC: accept a token whose email_verified claim is absent (e.g. Microsoft Entra). An explicit email_verified=false is still rejected.
type boolean · default
falseexample:
false entityId-
SAML IdP entity id (issuer).
type string
example:
"https://idp.example.com/saml" ssoUrl-
SAML IdP SingleSignOnService URL (https only).
type string · format uri
example:
"https://idp.example.com/saml/sso" certificates-
SAML IdP signing certificate(s), PEM. A list allows a key-rotation overlap window.
type array
array items:
type string
example:
"MIIDXTCCAkWgAwIBAgIJA...=="example:
[ "MIIDXTCCAkWgAwIBAgIJA...==" ] allowedEmailDomains-
type array
array items:
type string
example:
"example.com"example:
[ "example.com" ] enabled-
type boolean · default
falseexample:
true
no additional properties
example:
{
"idpType": "oidc",
"issuer": "https://idp.example.com",
"clientId": "disco-saas",
"scopes": [
"openid",
"email",
"profile"
],
"trustUnverifiedEmail": false,
"allowedEmailDomains": [
"example.com"
],
"enabled": true
}example:
{
"idpType": "oidc",
"issuer": "https://idp.example.com",
"clientId": "disco-saas",
"scopes": [
"openid",
"email",
"profile"
],
"trustUnverifiedEmail": false,
"allowedEmailDomains": [
"example.com"
],
"enabled": true
}Responses
429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Stored config
application/json
see TenantSSOConfig
example:
{
"configured": true,
"entitled": true,
"enabled": true,
"idpType": "oidc",
"issuer": "https://idp.example.com",
"clientId": "disco-saas",
"scopes": [
"openid",
"email",
"profile"
],
"allowedEmailDomains": [
"example.com"
],
"verified": false
}400
Validation failure (sso_issuer_invalid / sso_issuer_host_blocked / sso_client_id_required / sso_email_domains_required / saml_entity_id_required / saml_sso_url_invalid / saml_cert_empty / saml_cert_not_pem / saml_cert_parse), or a refused enable (sso_enable_unverified)
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}DELETE /v1/tenant/{tenantSlug}/sso — Disable the tenant's single sign-on (tenant-admin only)
Turns SSO off (enabled=false) without deleting the stored settings, so it can be re-enabled without re-entering everything. Requires an admin-scoped key for bearer callers.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
tenantSlug | path | yes | type string | URL slug of the caller's home tenant. A user belongs to exactly one tenant, so this must match the caller's own tenant slug; any other value returns 404. |
Responses
429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Disabled
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}POST /v1/tenant/{tenantSlug}/sso/discover — Discover SAML IdP config from a metadata URL (tenant-admin only)
Fetches the IdP's SAML metadata over the SSRF-guarded client and returns the entity id, HTTP-Redirect sign-on URL, and signing certificate(s) to prefill the SAML config form. Does not store anything. Requires an admin-scoped key for bearer callers.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
tenantSlug | path | yes | type string | URL slug of the caller's home tenant. A user belongs to exactly one tenant, so this must match the caller's own tenant slug; any other value returns 404. |
Request body (required)
The SAML metadata URL to discover configuration from.
application/json
type object
metadataUrl(required)-
IdP SAML metadata URL (https only).
type string · format uri
example:
"https://idp.example.com/saml/metadata"
no additional properties
example:
{
"metadataUrl": "https://idp.example.com/saml/metadata"
}example:
{
"metadataUrl": "https://idp.example.com/saml/metadata"
}Responses
429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Discovered config (not stored)
application/json
type object
entityId-
type string
example:
"https://idp.example.com/saml" ssoUrl-
type string · format uri
example:
"https://idp.example.com/saml/sso" certificates-
type array
array items:
type string
example:
"MIIDXTCCAkWgAwIBAgIJA...=="example:
[ "MIIDXTCCAkWgAwIBAgIJA...==" ]
example:
{
"entityId": "https://idp.example.com/saml",
"ssoUrl": "https://idp.example.com/saml/sso",
"certificates": [
"MIIDXTCCAkWgAwIBAgIJA...=="
]
}example:
{
"entityId": "https://idp.example.com/saml",
"ssoUrl": "https://idp.example.com/saml/sso",
"certificates": [
"MIIDXTCCAkWgAwIBAgIJA...=="
]
}400
saml_metadata_url_invalid / saml_metadata_fetch_failed
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}POST /v1/tenant/{tenantSlug}/sso/test — Check an SSO config against the identity provider (tenant-admin only)
Validates the supplied configuration and then contacts the identity provider it names, over the SSRF-guarded client. For OIDC it fetches the discovery document, compares the issuer the provider declares against the one supplied, and reads the advertised signing keys; for SAML it parses the signing certificates, builds a service provider, and (when metadataUrl is given) compares what the provider publishes against what was supplied. Nothing is stored and no sign-in is completed, so a pass means the provider exists and describes itself consistently — it does NOT prove a person can sign in. Only a real sign-in through /auth/sso/start proves that.
A check that RAN answers 200 with ok reporting the verdict; 400 is reserved for a configuration too malformed to check. Requires an admin-scoped key for bearer callers.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
tenantSlug | path | yes | type string | URL slug of the caller's home tenant. A user belongs to exactly one tenant, so this must match the caller's own tenant slug; any other value returns 404. |
Request body (required)
The SSO provider configuration to check. Same shape as the PUT body.
application/json
idpType selects the protocol (default oidc). OIDC uses issuer + clientId; SAML uses entityId + ssoUrl + certificates. No client secret or SP private key is accepted or stored.
type object
idpType-
Which protocol to check. Decides whether the OIDC or the SAML fields are read.
type string · one of
oidc,saml· defaultoidcexample:
"oidc" issuer-
The issuer to contact. Its discovery document is fetched and the issuer it declares is compared against this value.
type string · format uri
example:
"https://idp.example.com" clientId-
OIDC client id. Present so the config checked is the config you would save; the check cannot exercise it.
type string
example:
"disco-saas" scopes-
OIDC scopes to record. Not exercised by the check.
type array
array items:
type string
example:
"openid"example:
[ "openid", "email", "profile" ] trustUnverifiedEmail-
OIDC email_verified opt-out. Recorded, not exercised: no token is verified by a check.
type boolean · default
falseexample:
false entityId-
SAML IdP entity id to compare against the provider's published metadata.
type string
example:
"https://idp.example.com/saml" ssoUrl-
SAML sign-on URL to compare against the provider's published metadata (https only).
type string · format uri
example:
"https://idp.example.com/saml/sso" certificates-
SAML signing certificate(s), PEM. Each is parsed and its expiry checked; an expired one fails.
type array
array items:
type string
example:
"MIIDXTCCAkWgAwIBAgIJA...=="example:
[ "MIIDXTCCAkWgAwIBAgIJA...==" ] metadataUrl-
SAML only. When given, what the IdP publishes there is compared against the supplied entityId, ssoUrl and certificates.
type string · format uri
example:
"https://idp.example.com/saml/metadata" allowedEmailDomains-
Sign-in domain allowlist. Required when enabled is true, exactly as on save; no check contacts it.
type array
array items:
type string
example:
"example.com"example:
[ "example.com" ] enabled-
Read only to apply the same validation a save would; the check never stores anything or turns SSO on.
type boolean · default
falseexample:
true
no additional properties
example:
{
"idpType": "oidc",
"issuer": "https://idp.example.com",
"clientId": "disco-saas",
"scopes": [
"openid",
"email",
"profile"
],
"allowedEmailDomains": [
"example.com"
]
}example:
{
"idpType": "oidc",
"issuer": "https://idp.example.com",
"clientId": "disco-saas",
"scopes": [
"openid",
"email",
"profile"
],
"allowedEmailDomains": [
"example.com"
]
}Responses
429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
The check ran; ok reports the verdict
application/json
type object
ok(required)-
Whether the identity provider answered consistently. A pass is not proof a person can sign in.
type boolean
example:
false code-
Diagnostic code naming what the provider did. Absent when ok is true.
type string
example:
"sso_discovery_unreachable" message(required)-
One-sentence explanation of the verdict.
type string
example:
"we couldn't reach that identity provider — check the URL, and that the provider is reachable from the public internet"
example:
{
"ok": false,
"code": "sso_discovery_unreachable",
"message": "we couldn't reach that identity provider — check the URL, and that the provider is reachable from the public internet"
}example:
{
"ok": false,
"code": "sso_discovery_unreachable",
"message": "we couldn't reach that identity provider — check the URL, and that the provider is reachable from the public internet"
}400
The configuration is too malformed to check (sso_issuer_invalid / sso_issuer_host_blocked / sso_client_id_required / sso_email_domains_required / saml_entity_id_required / saml_sso_url_invalid / saml_cert_empty / saml_cert_not_pem / saml_cert_parse / bad_json / unknown_field)
application/json
see Error
example:
{
"error": {
"code": "sso_client_id_required",
"message": "enter the client id your identity provider issued for disco"
}
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}tenant
GET /v1/tenant/{tenantSlug}/settings — Read tenant policy settings (tenant-admin only)
Returns the tenant-wide policy values a tenant admin can change: the self-service hard-delete block and the audit-log retention window.
required scope: read
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
tenantSlug | path | yes | type string | URL slug of the caller's home tenant. A user belongs to exactly one tenant, so this must match the caller's own tenant slug; any other value returns 404. |
Responses
200
Current tenant policy
application/json
type object
blockHardDelete-
type boolean
example:
false auditRetentionDays-
type integer · minimum 30 · maximum 36500
example:
365
example:
{
"blockHardDelete": false,
"auditRetentionDays": 365
}example:
{
"blockHardDelete": false,
"auditRetentionDays": 365
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
No tenant of that slug is visible to the caller. The gate compares the slug against the caller's own session tenant, so an unknown slug and another tenant's slug are indistinguishable — there is no existence oracle.
application/json
see Error
PATCH /v1/tenant/{tenantSlug}/settings — Update tenant policy settings (tenant-admin only)
Updates tenant-wide policy. Send either field or both; sending neither is no_fields. Requires an admin-scoped key for bearer callers.
blockHardDelete, when true, disables self-service account hard-deletion for every user in the tenant (DELETE /v1/account returns hard_delete_blocked); account removal then goes through a tenant admin.
auditRetentionDays is how long the tenant's audit ledger is kept before a nightly purge deletes the expired oldest rows. It is tenant-wide, not per-workspace: the tenant has ONE hash chain spanning every workspace, so a per-workspace window would delete from the middle of that chain and leave it permanently unverifiable. **Reducing it destroys audit history**, so a reduction is refused with 409 retention_reduce_requires_confirm unless confirmRetentionReduction is true. Raising it needs no confirmation.
Both fields are validated before anything is written and are then applied in a single statement, so a rejected auditRetentionDays leaves blockHardDelete unchanged too — a refused request changes nothing.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
tenantSlug | path | yes | type string | URL slug of the caller's home tenant. A user belongs to exactly one tenant, so this must match the caller's own tenant slug; any other value returns 404. |
Request body (required)
Tenant policy settings to update.
application/json
type object
blockHardDelete-
Block self-service account hard-deletion tenant-wide.
type boolean
example:
true auditRetentionDays-
Days of audit history to keep, tenant-wide.
type integer · minimum 30 · maximum 36500
example:
90 confirmRetentionReduction-
Required (true) when auditRetentionDays is lower than the current value, acknowledging that audit history will be deleted.
type boolean
example:
true
no additional properties
example:
{
"blockHardDelete": true
}example:
{
"auditRetentionDays": 90,
"confirmRetentionReduction": true
}example:
{
"blockHardDelete": true
}Responses
429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Policy updated. Echoes both current values.
application/json
type object
blockHardDelete-
type boolean
example:
true auditRetentionDays-
type integer
example:
365
example:
{
"blockHardDelete": true,
"auditRetentionDays": 365
}example:
{
"blockHardDelete": true,
"auditRetentionDays": 365
}400
no_fields — include blockHardDelete or auditRetentionDays; retention_floor_violation — auditRetentionDays outside 30..36500.
application/json
see Error
example:
{
"error": {
"code": "retention_floor_violation",
"message": "auditRetentionDays must be between 30 and 36500."
}
}409
retention_reduce_requires_confirm — the value is legal but lower than the stored window, and the reduction was not confirmed; or lock_busy — another tenant-admin write held the settings row when the server's lock wait expired. Nothing is written on lock_busy and it is safe to retry.
application/json
see Error
example:
{
"error": {
"code": "retention_reduce_requires_confirm",
"message": "reducing the retention window deletes audit history. resubmit with confirmRetentionReduction set to true to proceed."
}
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
No tenant of that slug is visible to the caller — see the GET twin. Nothing is written on this path.
application/json
see Error
GET /v1/tenant/{tenantSlug}/billing — Read the tenant's subscription and billing address (tenant-admin only)
Returns the plan tier, the cloud-account entitlement and how much of it is in use, the subscription's status where one exists, and the billing address.
accountCap is null when the tier is unlimited. On Pro it is the included allowance plus one per add-on the tenant is still entitled to — which is addonQty, or entitlementFloorAddonQty while a reduction is waiting for the paid period to end. So it follows from neither tier nor addonQty alone; read it, do not derive it.
tierWithheldAt says the first two are not being updated from the payment provider at all. It is absent in the ordinary case and nothing else on this body distinguishes the two, so a client that trusts tier or addonQty without reading it can report an entitlement the tenant is no longer being billed for.
billingEmail is always an address that has been PROVEN — either it belongs to a user of this tenant or somebody opened a mailed confirmation link. A proposed address that is still waiting on that confirmation appears as pendingEmail and never as billingEmail, because everything that invoices reads the latter. Both may be present at once: invoices keep going to the confirmed address until the pending one is confirmed.
activating reports that a completed checkout has not been folded onto the subscription yet. Answering it means asking the payment provider about the outstanding transaction, so this operation may make one outbound call; a provider that cannot be reached answers false rather than failing the read.
canManage reports whether the CALLER holds billing-admin, which is what the two write operations below require. Every tenant-admin may read this. It is a statement about the caller's ROLE and not a promise that a write will be accepted — billingEnabled carries the other half, and a client offering a subscribe or add-on control needs both.
required scope: read
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
tenantSlug | path | yes | type string | URL slug of the caller's home tenant. A user belongs to exactly one tenant, so this must match the caller's own tenant slug; any other value returns 404. |
Responses
200
Current subscription and billing address
application/json
type object
tier-
The tenant's subscription tier.
type string · one of
free,pro,enterpriseexample:
"pro" accountsUsed-
Distinct cloud accounts the tenant currently holds. One cloud account connected to two workspaces counts once.
type integer
example:
12 accountCap-
Cloud accounts the tier entitles, or null when unlimited.
type integer | null
example:
13 addonQty-
Cloud-account add-ons the subscription BILLS, each raising accountCap by one. A reduction takes effect at the end of the paid period, so while entitlementFloorAddonQty is present and higher, this is the smaller of the two and accountCap follows the floor rather than this figure.
type integer
example:
3 tierWithheldAt-
When the payment provider first delivered a subscription this build could not map onto a tier. While present,
tierandaddonQtyare FROZEN against anything the provider sends: a change made at the provider is billed and is not applied here. Changes made through this API still take effect, soaddonQtyis not necessarily stale — what is frozen is provider-delivered state. Absent when the last delivery applied, which is the ordinary state, and no other field distinguishes the two.type string · format date-time
example:
"2026-08-19T11:02:00Z" entitlementFloorAddonQty-
Add-ons still owed for the rest of the paid period after a reduction. Absent when no reduction is outstanding.
type integer
example:
5 entitlementFloorExpiresAt-
When the outstanding reduction takes effect and accountCap drops to what addonQty entitles. Absent with the floor.
type string · format date-time
example:
"2026-09-08T00:00:00Z" status-
The payment provider's subscription status. Absent when the tenant has never subscribed, and deliberately not an enum — the provider adds statuses.
type string
example:
"active" renewsAt-
End of the current billing period, when the subscription renews. Absent without a subscription, and absent whenever
scheduledCancelAtis present — a cancelling subscription does not renew, so the two are mutually exclusive and never both appear.type string · format date-time
example:
"2026-09-08T00:00:00Z" scheduledCancelAt-
When a scheduled cancellation takes effect. Present only while one is outstanding, and the ONLY signal that one is: a subscription cancelled at the end of its period stays
activewithsubscriptionLivetrue until this date arrives, so neither of those says the subscription is ending. On that date the tier drops to free and accountCap with it. It replacesrenewsAtrather than accompanying it, and carries the same instant that field would have.A cancellation made through this API is recorded here in the same request, so it is readable immediately. One made anywhere else — the customer portal, the provider's own dashboard — arrives on the payment provider's webhook and can take a few seconds to appear.
type string · format date-time
example:
"2026-09-08T00:00:00Z" subscriptionLive-
A subscription exists and is not canceled. Not the same question as a paid tier — a paused subscription entitles free while still being live.
type boolean
example:
true activating-
A completed checkout is waiting to be folded onto the subscription.
type boolean
example:
false canManage-
The caller holds billing-admin. A statement about the CALLER, so read it together with
billingEnabled, which is about the deployment.type boolean
example:
true billingEnabled-
Whether this deployment performs billing writes at all. False refuses all of them, for every caller however privileged, so a client should offer none of those controls. Every billing write this API exposes — add-ons in either direction, cancelling, calling a cancellation off, and both billing-address verbs — then answers 409 with
billing_disabled; starting a subscription and opening the customer portal are refused too, but they have no operation here. It changes no entitlement and ends nothing: an existing subscription is untouched, doing whatever it was doing before, and webhook deliveries are still applied. Always present.type boolean
example:
true billingEmail-
The confirmed address invoices go to. Absent until one is set.
type string · format email
example:
"accounts@acme.example" billingEmailVerifiedAt-
When billingEmail was proven.
type string · format date-time
example:
"2026-08-01T09:14:00Z" pendingEmail-
A proposed address whose confirmation link is still outstanding.
type string · format email
example:
"accounts@acme.example" pendingEmailExpiresAt-
When the outstanding confirmation link expires.
type string · format date-time
example:
"2026-08-15T09:14:00Z"
example:
{
"tier": "free",
"accountsUsed": 2,
"accountCap": 3,
"addonQty": 0,
"subscriptionLive": false,
"activating": false,
"canManage": true,
"billingEnabled": true,
"pendingEmail": "accounts@acme.example",
"pendingEmailExpiresAt": "2026-08-15T09:14:00Z"
}example:
{
"tier": "free",
"accountsUsed": 2,
"accountCap": 3,
"addonQty": 0,
"subscriptionLive": false,
"activating": false,
"canManage": true,
"billingEnabled": false
}example:
{
"tier": "pro",
"accountsUsed": 12,
"accountCap": 13,
"addonQty": 3,
"status": "active",
"subscriptionLive": true,
"activating": false,
"canManage": true,
"billingEnabled": true,
"tierWithheldAt": "2026-08-19T11:02:00Z"
}example:
{
"tier": "pro",
"accountsUsed": 12,
"accountCap": 13,
"addonQty": 3,
"status": "active",
"renewsAt": "2026-09-08T00:00:00Z",
"subscriptionLive": true,
"activating": false,
"canManage": true,
"billingEnabled": true,
"billingEmail": "accounts@acme.example",
"billingEmailVerifiedAt": "2026-08-01T09:14:00Z"
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
No tenant of that slug is visible to the caller. The gate compares the slug against the caller's own session tenant, so an unknown slug and another tenant's slug are indistinguishable.
application/json
see Error
POST /v1/tenant/{tenantSlug}/billing/addons — Set the number of cloud-account add-ons (billing-admin only)
Sets how many cloud-account add-ons the subscription carries. Requires billing-admin on top of tenant-admin, and an admin-scoped key for bearer callers.
addonQty is the ABSOLUTE number wanted, not a delta, so re-sending the same request is a no-op rather than a second purchase.
An INCREASE applies immediately and is charged pro rata for the remainder of the current billing period — except for any part of it that a still-outstanding reduction already covers, which is restored at no charge rather than billed a second time. A DECREASE does not take effect until that period ends: the tenant keeps the entitlement they have already paid for, so accountCap does not move and the response carries entitlementFloorAddonQty with the date it lapses. Nothing is refunded, and no credit is issued.
A decrease that would leave the tenant over the cap it will have once the floor lapses is REFUSED rather than scheduled — see addon_decrease_over_cap. Remove the surplus accounts first.
Both directions need the subscription to be in a billing period, so a paused or canceled subscription is refused with addon_deferral_unavailable whichever way the quantity moves.
outcome reports which of the three happened: increased, decreased, or unchanged.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
tenantSlug | path | yes | type string | URL slug of the caller's home tenant. A user belongs to exactly one tenant, so this must match the caller's own tenant slug; any other value returns 404. |
Request body (required)
The number of add-ons the subscription should carry.
application/json
type object
addonQty(required)-
Cloud-account add-ons wanted in total, each entitling one cloud account beyond the plan's included allowance. Zero removes them all and keeps the plan itself.
type integer · minimum 0 · maximum 1000
example:
5
no additional properties
example:
{
"addonQty": 5
}example:
{
"addonQty": 5
}Responses
429
addon_change_rate_limited — this organization has attempted too many add-on changes. Nothing was changed and nothing was charged.
headers:
Retry-After— Seconds to wait before retrying. Absent when the limiter cannot name a wait.
application/json
see Error
example:
{
"error": {
"code": "addon_change_rate_limited",
"message": "this organization has changed its add-ons too many times just now. nothing was changed — try again shortly."
}
}200
The subscription now carries this many add-ons. An increase has already been charged for whatever part of it was not already paid for; a decrease has not been billed at all.
application/json
type object
addonQty-
Add-ons the subscription now bills.
type integer
example:
5 accountCap-
Cloud accounts the tenant may hold right now, or null when unlimited. After a decrease this still reflects the floor, not addonQty. ABSENT when the change was applied but the new cap could not be read back — the change still happened; read it from GET billing.
type integer | null
example:
15 outcome-
What the request changed.
type string · one of
increased,decreased,unchangedexample:
"increased" entitlementFloorAddonQty-
Add-ons still owed for the rest of the paid period. Present only while a reduction is outstanding.
type integer
example:
8 entitlementFloorExpiresAt-
When the outstanding reduction takes effect.
type string · format date-time
example:
"2026-09-08T00:00:00Z"
example:
{
"addonQty": 2,
"accountCap": 18,
"outcome": "decreased",
"entitlementFloorAddonQty": 8,
"entitlementFloorExpiresAt": "2026-09-08T00:00:00Z"
}example:
{
"addonQty": 5,
"accountCap": 15,
"outcome": "increased"
}400
addon_qty_invalid — addonQty is missing, negative, or above the per-subscription ceiling.
application/json
see Error
example:
{
"error": {
"code": "addon_qty_invalid",
"message": "enter a whole number of extra cloud accounts between 0 and 1000."
}
}409
* addon_change_not_subscribed — the tenant has no live paid subscription, so there is nothing to add on to. Subscribe first. * addon_decrease_over_cap — the tenant holds more cloud accounts than the reduced entitlement would allow once it takes effect. Nothing was changed and nothing was charged. * addon_deferral_unavailable — the payment provider reports no current billing period (a paused or canceled subscription). A reduction cannot be held until a period that does not exist, and an increase has no remainder to prorate, so the change is refused in either direction. Nothing was changed. Resume the subscription first. * addon_change_locked — the payment provider has the subscription temporarily locked while it works on it, which it does around a renewal, while a payment is being processed, and for the whole of a mandated consent-review period. Nothing was changed and nothing was charged. This one IS worth retrying unchanged, but how long the lock lasts depends on which of those it is: the renewal and processing locks clear within about half an hour, a consent review runs until the subscription's next billing date. * addon_change_pending — the subscription already carries a scheduled change, and items cannot be updated until that change is settled. Nothing was changed and nothing was charged. Call the scheduled change off, or wait for it to take effect. * addon_change_refused — the payment provider DECIDED against this change, most often a declined card. Nothing was changed and nothing was charged. Retrying the same request will be refused the same way; fix the payment method first. * billing_disabled — this deployment performs no billing writes at present, so add-ons cannot be bought or reduced here in either direction. Nothing was changed and nothing was charged, and the message names support as the exit — a refused REDUCTION leaves the organization paying for add-ons it asked to drop, which support can change at the payment provider. Retrying will be refused the same way until the deployment re-enables them; billingEnabled on GET /v1/tenant/{tenantSlug}/billing reports the state. This refusal is decided BEFORE the body is validated and before the rate limit is consulted, so while it is in force a malformed addonQty answers this rather than addon_qty_invalid, and no request reaches addon_change_rate_limited.
application/json
see Error
example:
{
"error": {
"code": "addon_decrease_over_cap",
"message": "this organization has 14 cloud accounts connected and 12 would be allowed after this reduction. remove 2 before reducing add-ons."
}
}502
addon_change_provider — the payment provider could not be reached, or answered in a way that leaves the outcome unknown. The request is safe to retry: an increase that did land is idempotent (addonQty is absolute, not a delta), and one that did not is applied by the retry. Read GET billing if the retry also fails.
application/json
see Error
example:
{
"error": {
"code": "addon_change_provider",
"message": "we could not reach our payment provider, so your add-ons were not changed. please try again shortly."
}
}503
addon_change_unavailable — the configured price ids are not usable at the payment provider, so this change cannot be built without billing the wrong price. Nothing was changed and nothing was charged. This is a fault in our configuration and it is permanent until it is corrected, so the request is NOT safe to retry and no Retry-After is sent.
application/json
see Error
example:
{
"error": {
"code": "addon_change_unavailable",
"message": "add-on changes are unavailable while we correct a payment configuration problem on our side. nothing was changed and nothing was charged — please contact support."
}
}500
internal_error — the change could not be applied for a reason this API cannot classify. Nothing is known to have been charged; read GET billing before retrying.
application/json
see Error
example:
{
"error": {
"code": "internal_error",
"message": "something went wrong on our end — try again, or contact an operator with this request id",
"requestId": "01J9Z8Y7X6W5V4U3T2S1R0Q9P8"
}
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
No tenant of that slug is visible to the caller — see the billing read. No subscription was changed and nothing was charged.
application/json
see Error
POST /v1/tenant/{tenantSlug}/billing/cancel — Cancel the subscription at the end of the paid period (billing-admin only)
Schedules the subscription to end when the period already paid for runs out. Requires billing-admin on top of tenant-admin, and an admin-scoped key for bearer callers. There is no request body.
Nothing is cancelled immediately and nothing is refunded: the tenant keeps every entitlement it has paid for until scheduledCancelAt, so status comes back unchanged by this call and accountCap does not move. That makes status useless as evidence — scheduledCancelAt is the only field that says the subscription is ending.
On that date the tenant drops to the free tier and its cloud-account allowance drops with it, to accountCapAfterCancellation. A tenant holding more cloud accounts than that will be held read-only until it removes the surplus, so the response reports both figures rather than leaving the caller to look them up.
Nothing accumulates on repeat: this endpoint holds no queue and schedules nothing of its own, it asks the payment provider to end the subscription at the period boundary. So a repeat either reports the cancellation the subscription already carries, or comes back cancel_refused with nothing changed.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
tenantSlug | path | yes | type string | URL slug of the caller's home tenant. A user belongs to exactly one tenant, so this must match the caller's own tenant slug; any other value returns 404. |
Responses
429
cancel_rate_limited — this organization has changed its subscription too many times just now. Nothing was changed.
headers:
Retry-After— Seconds to wait before scheduling the cancellation again. Absent when the limiter cannot name a wait.
application/json
see Error
example:
{
"error": {
"code": "cancel_rate_limited",
"message": "this organization has changed its subscription too many times just now. nothing was changed — try again shortly."
}
}200
The subscription is scheduled to end. It stays live, and billed nothing further, until that date — which is reported in scheduledCancelAt whenever the provider named one.
application/json
type object
status-
The payment provider's subscription status, unchanged by this call —
activeortrialing, the two this endpoint accepts. Deliberately not an enum: the provider adds statuses.type string
example:
"active" scheduledCancelAt-
When the subscription ends and the tier drops to free. ABSENT in the one case the provider accepts the cancellation without naming a date; the cancellation still stands, and GET billing reports the date once the provider has one.
type string · format date-time
example:
"2026-09-08T00:00:00Z" accountsUsed-
Distinct cloud accounts the tenant holds right now. One cloud account connected to two workspaces counts once. ABSENT, with its companion below, when the cancellation was scheduled but the count could not be read back — the cancellation still stands; read it from GET billing.
type integer
example:
12 accountCapAfterCancellation-
Cloud accounts the tenant is entitled to once the cancellation takes effect. Anything above this must be disconnected before the cancellation date. NULL when that allowance is unlimited, which is the enterprise case — a cancellation there leaves the tier in place. Absent with accountsUsed, which it is only meaningful beside.
type integer | null
example:
3
example:
{
"status": "active",
"scheduledCancelAt": "2026-09-08T00:00:00Z",
"accountsUsed": 2,
"accountCapAfterCancellation": 3
}example:
{
"status": "active",
"scheduledCancelAt": "2026-09-08T00:00:00Z",
"accountsUsed": 12,
"accountCapAfterCancellation": 3
}409
* billing_disabled — this deployment performs no billing writes at present, so a subscription cannot be ended here. Nothing was changed, so any subscription this organization has is exactly as it was. Retrying will be refused the same way until the deployment re-enables them; billingEnabled on GET /v1/tenant/{tenantSlug}/billing reports the state. This refusal is decided BEFORE the rate limit is consulted, so while it is in force no request reaches cancel_rate_limited. * cancel_not_subscribed — the tenant has no live subscription, so there is nothing to cancel. Nothing was changed. * cancel_not_cancellable — the subscription is live but in a state this endpoint cannot schedule a change against, such as past_due or paused, or it already carries a scheduled change the provider will not schedule another against. Nothing was changed. End it from the customer portal instead. * cancel_locked — the payment provider has the subscription temporarily locked while it works on it, which it does around a renewal, while a payment is being processed, and for the whole of a mandated consent-review period. Nothing was changed. This one IS worth retrying unchanged, but how long the lock lasts depends on which of those it is: the renewal and processing locks clear within about half an hour, a consent review runs until the subscription's next billing date. * cancel_refused — the payment provider DECIDED against this change. Nothing was changed and the subscription still renews. Retrying the same request will be refused the same way.
application/json
see Error
example:
{
"error": {
"code": "cancel_not_subscribed",
"message": "this organization has no live paid subscription, so there is nothing to cancel."
}
}502
cancel_provider — the payment provider could not be reached, or answered in a way that leaves the outcome unknown. Retrying is safe: nothing accumulates here, so a retry either schedules the cancellation or reports the one already scheduled. Read GET billing if the retry also fails.
application/json
see Error
example:
{
"error": {
"code": "cancel_provider",
"message": "we could not confirm this with our payment provider, so we do not know whether the change took effect. read your subscription's current state before trying again."
}
}500
internal_error — the cancellation could not be scheduled for a reason this API cannot classify. Read GET billing before retrying.
application/json
see Error
example:
{
"error": {
"code": "internal_error",
"message": "something went wrong on our end — try again, or contact an operator with this request id",
"requestId": "01J9Z8Y7X6W5V4U3T2S1R0Q9P8"
}
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
No tenant of that slug is visible to the caller — see the billing read. Nothing was scheduled and the subscription still renews.
application/json
see Error
DELETE /v1/tenant/{tenantSlug}/billing/cancel — Call off a scheduled cancellation (billing-admin only)
Removes the subscription's pending cancellation, so it renews as normal. The inverse of the POST above, and the same gate: billing-admin on top of tenant-admin, an admin-scoped key for bearer callers, no request body.
This is NOT the payment provider's "resume", which restarts a PAUSED subscription — a state this product never puts a subscription in.
A subscription with nothing scheduled is not refused here. Whether one is outstanding can only be learned by reading first, and a caller racing the cancellation date would otherwise be told its request was malformed when it was merely late. The response reports what the subscription carries after the call, so an absent scheduledCancelAt means nothing is pending whichever way it got there.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
tenantSlug | path | yes | type string | URL slug of the caller's home tenant. A user belongs to exactly one tenant, so this must match the caller's own tenant slug; any other value returns 404. |
Responses
429
cancel_rate_limited — this organization has changed its subscription too many times just now. Nothing was changed, and any scheduled cancellation still stands.
headers:
Retry-After— Seconds to wait before calling the cancellation off again. Absent when the limiter cannot name a wait.
application/json
see Error
example:
{
"error": {
"code": "cancel_rate_limited",
"message": "this organization has changed its subscription too many times just now. nothing was changed — try again shortly."
}
}200
The call was applied. The subscription renews as normal unless scheduledCancelAt is present, which reports a cancellation the provider still has scheduled and a caller must read as not-undone.
application/json
type object
status-
The payment provider's subscription status. Deliberately not an enum — the provider adds statuses.
type string
example:
"active" scheduledCancelAt-
Absent on success, which is what makes the call an undo. Present only if the provider reports a cancellation still scheduled, which a caller must treat as not-undone.
type string · format date-time
example:
"2026-09-08T00:00:00Z"
example:
{
"status": "active"
}example:
{
"status": "active"
}409
* billing_disabled — this deployment performs no billing writes at present, so a scheduled ending cannot be called off here. Nothing was changed, so any scheduled ending this organization has still stands. Retrying will be refused the same way until the deployment re-enables them; billingEnabled on GET /v1/tenant/{tenantSlug}/billing reports the state. This refusal is decided BEFORE the rate limit is consulted, so while it is in force no request reaches cancel_rate_limited. * cancel_not_subscribed — the tenant has no live subscription, so there is no cancellation to call off. Nothing was changed. * cancel_revoke_not_cancellable — the subscription is live but this endpoint cannot act on it. Either its status admits no scheduled change (past_due, paused), or it has a pending change of some OTHER kind — a pause or a resume, which only the provider's own surfaces create. This endpoint removes a scheduled cancellation and nothing else, so a change it did not make is left alone rather than cleared. Nothing was changed either way. This is the same condition the scheduling direction reports as cancel_not_cancellable, under its own code because the advice is opposite: that one sends the caller to the customer portal to END the subscription, which is what a caller here is trying to stop. * cancel_revoke_locked — the payment provider has the subscription temporarily locked while it works on it, which it does around a renewal, while a payment is being processed, and for the whole of a mandated consent-review period. Nothing was changed and the scheduled cancellation still stands. Unlike the same lock met while SCHEDULING a cancellation, a later retry is not the right advice here: the cancellation takes effect at the renewal, so waiting out a renewal lock lets it execute and the retry is then answered cancel_not_subscribed. Keep the subscription from the customer portal instead. * cancel_refused — the payment provider DECIDED against this change. Any scheduled cancellation still stands. Retrying the same request will be refused the same way.
application/json
see Error
example:
{
"error": {
"code": "cancel_refused",
"message": "our payment provider would not apply this change, so your subscription was not changed and nothing was charged. contact support if it keeps happening."
}
}502
* cancel_unreadable — the subscription could not be READ from the payment provider, so no change was attempted. Nothing was changed and nothing was charged; any scheduled cancellation still stands. Safe to retry immediately. * cancel_provider — the change WAS attempted and the provider could not be reached, or answered in a way that leaves the outcome unknown. Also safe to retry — calling off a cancellation twice calls off one — but read GET billing first, because the first attempt may have taken effect.
application/json
see Error
example:
{
"error": {
"code": "cancel_provider",
"message": "we could not confirm this with our payment provider, so we do not know whether the change took effect. read your subscription's current state before trying again."
}
}500
internal_error — the cancellation could not be called off for a reason this API cannot classify. Read GET billing before retrying.
application/json
see Error
example:
{
"error": {
"code": "internal_error",
"message": "something went wrong on our end — try again, or contact an operator with this request id",
"requestId": "01J9Z8Y7X6W5V4U3T2S1R0Q9P8"
}
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
No tenant of that slug is visible to the caller — see the billing read. Any scheduled cancellation still stands.
application/json
see Error
POST /v1/tenant/{tenantSlug}/billing/email — Propose a billing address (billing-admin only)
Proposes the address invoices should go to. Requires billing-admin on top of tenant-admin, and an admin-scoped key for bearer callers.
This never redirects invoices on its own. The address has to be proven first, and outcome says how that went:
* proof_sent — a single-use confirmation link was emailed to the proposed address and expires in seven days. The proposed address is not invoiced until somebody opens it. * adopted — the address already belongs to a user of this tenant, so it needed no round trip and is in force now. * proposal_canceled — the address proposed is the one already in force, and an outstanding confirmation link was retired. This is how a pending change is called off. * unchanged — the address proposed is already in force and nothing was outstanding.
Proposing a DIFFERENT address retires any confirmation link already outstanding, so at most one address can ever be pending.
Confirmation is deliberately not required to subscribe: an admin may have authority to buy while company policy sends invoices to a department that confirms later.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
tenantSlug | path | yes | type string | URL slug of the caller's home tenant. A user belongs to exactly one tenant, so this must match the caller's own tenant slug; any other value returns 404. |
Request body (required)
The address to propose.
application/json
type object
billingEmail(required)-
The address to invoice. It need not belong to any user of this tenant — a shared accounts-payable mailbox is the ordinary case.
type string · format email
example:
"accounts@acme.example"
no additional properties
example:
{
"billingEmail": "accounts@acme.example"
}example:
{
"billingEmail": "accounts@acme.example"
}Responses
429
Too many requests. Two different caps reach this status and they mean different things.
rate_limited is the ordinary /v1 mutation limiter, per principal.
billing_email_rate_limited is a send cap on confirmation mail. It bounds how often this tenant may aim a message at somebody's inbox, how often ANY tenant may aim one at the same address, and how often this tenant may ask the payment provider whether an address is already somebody's customer. Nothing was emailed and nothing was written.
Both name the wait in the Retry-After header, in seconds.
application/json
see Error
example:
{
"error": {
"code": "billing_email_rate_limited",
"message": "too many confirmation emails for this organization. try again shortly."
}
}200
The proposal was decided. Nothing is charged and no invoice moves on proof_sent.
application/json
example:
{
"billingEmail": "accounts@acme.example",
"outcome": "proof_sent"
}400
billing_email_invalid — billingEmail is not a single valid address.
application/json
see Error
example:
{
"error": {
"code": "billing_email_invalid",
"message": "enter one email address, like accounts@example.com."
}
}409
The request was well-formed but the tenant's current state refuses it.
* billing_disabled — this deployment performs no billing writes at present, so the billing address cannot be changed or re-confirmed here. Nothing was written, so whatever address is stored — if any — is unchanged. Retrying will be refused the same way until the deployment re-enables them; billingEnabled on GET /v1/tenant/{tenantSlug}/billing reports the state. A confirmation link already emailed is unaffected — the public redemption route is deliberately outside this switch, so a recipient holding one can still confirm. * billing_email_changed — the stored address moved while this request was in flight. Nothing was written; re-read and retry. * billing_email_no_proof — nothing is outstanding to re-send. * billing_email_taken — the payment provider already holds this address on another account. Permanent until a different address is chosen; retrying will not clear it. * billing_email_archived — the payment provider holds this address on an archived customer, which only an operator there can release. * billing_email_refused — the payment provider declined the change for some other reason of its own (an account setting, a key without the scope). The proposed address was not applied and no confirmation was emailed, and the same request will get the same answer until somebody changes that setting. * lock_busy — another billing write held the tenant's row when the server's lock wait expired. Nothing was written; safe to retry.
application/json
see Error
example:
{
"error": {
"code": "billing_email_changed",
"message": "the billing address changed while you were editing it. reload and try again."
}
}502
billing_email_provider — the payment provider could not be reached. Nothing was charged and the billing address was not changed. This is an outage rather than a refusal, so it is worth retrying; a provider that REFUSED the address answers 409 instead.
application/json
see Error
example:
{
"error": {
"code": "billing_email_provider",
"message": "we could not reach our payment provider, so the billing address was not changed. please try again shortly."
}
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
No tenant of that slug is visible to the caller — see the billing read. Nothing is written on this path.
application/json
see Error
POST /v1/tenant/{tenantSlug}/billing/email/resend — Re-send the outstanding billing-address confirmation (billing-admin only)
Emails a fresh confirmation link for the address already pending, for a recipient who never got the first one. Takes no request body.
Earlier links for the SAME address stay valid — the recipient may be looking at the first mail — and opening any one of them retires the rest. There is nothing to resend once the address is confirmed, or if no proposal is outstanding; that is billing_email_no_proof.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
tenantSlug | path | yes | type string | URL slug of the caller's home tenant. A user belongs to exactly one tenant, so this must match the caller's own tenant slug; any other value returns 404. |
Responses
429
Too many requests. Two different caps reach this status and they mean different things.
rate_limited is the ordinary /v1 mutation limiter, per principal.
billing_email_rate_limited is a send cap on confirmation mail. It bounds how often this tenant may aim a message at somebody's inbox, how often ANY tenant may aim one at the same address, and how often this tenant may ask the payment provider whether an address is already somebody's customer. Nothing was emailed and nothing was written.
Both name the wait in the Retry-After header, in seconds.
application/json
see Error
example:
{
"error": {
"code": "billing_email_rate_limited",
"message": "too many confirmation emails for this organization. try again shortly."
}
}200
A fresh confirmation link was emailed to the pending address.
application/json
example:
{
"billingEmail": "accounts@acme.example",
"outcome": "proof_sent"
}409
The request was well-formed but the tenant's current state refuses it.
* billing_disabled — this deployment performs no billing writes at present, so the billing address cannot be changed or re-confirmed here. Nothing was written, so whatever address is stored — if any — is unchanged. Retrying will be refused the same way until the deployment re-enables them; billingEnabled on GET /v1/tenant/{tenantSlug}/billing reports the state. A confirmation link already emailed is unaffected — the public redemption route is deliberately outside this switch, so a recipient holding one can still confirm. * billing_email_changed — the stored address moved while this request was in flight. Nothing was written; re-read and retry. * billing_email_no_proof — nothing is outstanding to re-send. * billing_email_taken — the payment provider already holds this address on another account. Permanent until a different address is chosen; retrying will not clear it. * billing_email_archived — the payment provider holds this address on an archived customer, which only an operator there can release. * billing_email_refused — the payment provider declined the change for some other reason of its own (an account setting, a key without the scope). The proposed address was not applied and no confirmation was emailed, and the same request will get the same answer until somebody changes that setting. * lock_busy — another billing write held the tenant's row when the server's lock wait expired. Nothing was written; safe to retry.
application/json
see Error
example:
{
"error": {
"code": "billing_email_changed",
"message": "the billing address changed while you were editing it. reload and try again."
}
}502
billing_email_provider — the payment provider could not be reached. Nothing was charged and the billing address was not changed. This is an outage rather than a refusal, so it is worth retrying; a provider that REFUSED the address answers 409 instead.
application/json
see Error
example:
{
"error": {
"code": "billing_email_provider",
"message": "we could not reach our payment provider, so the billing address was not changed. please try again shortly."
}
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
No tenant of that slug is visible to the caller — see the billing read. The gate runs before the outstanding proof is looked up, so no mail is sent and no link is minted.
application/json
see Error
POST /v1/tenant/{tenantSlug}/invitations — Invite a user to the tenant (tenant-admin only)
Invites an email to the caller's home tenant. The invite always associates the email with the tenant; it may optionally grant the tenant-admin role and/or any number of per-workspace role assignments. None are required — a bare invite onboards the user as a plain tenant member with no tenant-admin role and no workspace access. Each referenced workspace must belong to the caller's tenant, each role must be a valid member role, and each scoped_account_ids entry must belong to that workspace.
Refused with 409 already_member when the invited email already belongs to a user holding a role in one of the workspaces the invite grants. Accepting such an invite could not have applied that grant — the per-workspace insert is idempotent and a tenant admin holds no power to overwrite a workspace role — so the accept would have reported success while changing nothing. Drop that workspace from the invite; changing an existing role is an action for an admin of that workspace.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
tenantSlug | path | yes | type string | URL slug of the caller's home tenant. A user belongs to exactly one tenant, so this must match the caller's own tenant slug; any other value returns 404. |
Request body (required)
The invitation to create.
application/json
type object
email(required)-
type string · format email
example:
"user@example.com" tenantRoles-
Tenant-level roles granted on accept. tenant-admin manages organization settings and invitations; billing-admin manages the subscription. The two are separate grants, so neither implies the other.
type array · default
array items:
type string · one of
tenant-admin,billing-adminexample:
"billing-admin"example:
[ "billing-admin" ] workspaceRoles-
Optional per-workspace role grants applied on accept.
type array
array items:
type object
workspaceId(required)-
type string · format uuid
example:
"11111111-1111-1111-1111-111111111111" role(required)-
type string · one of
admin,member,viewer,auditor,compliance-adminexample:
"member" scopedAccountIds-
Connected-account UUIDs the invitee can see in that workspace on first login. Omit or pass an empty array for "no accounts" (the default). Ignored for the admin role, which bypasses scoping.
type array
array items:
type string · format uuid
example:
"22222222-2222-2222-2222-222222222222"example:
[ "22222222-2222-2222-2222-222222222222" ]
no additional properties
example:
{ "workspaceId": "11111111-1111-1111-1111-111111111111", "role": "member", "scopedAccountIds": [ "22222222-2222-2222-2222-222222222222" ] }example:
[ { "workspaceId": "11111111-1111-1111-1111-111111111111", "role": "member", "scopedAccountIds": [ "22222222-2222-2222-2222-222222222222" ] } ]
no additional properties
example:
{
"email": "user@example.com",
"tenantRoles": [
"billing-admin"
],
"workspaceRoles": [
{
"workspaceId": "11111111-1111-1111-1111-111111111111",
"role": "member",
"scopedAccountIds": [
"22222222-2222-2222-2222-222222222222"
]
}
]
}example:
{
"email": "user@example.com",
"tenantRoles": [
"billing-admin"
],
"workspaceRoles": [
{
"workspaceId": "11111111-1111-1111-1111-111111111111",
"role": "member",
"scopedAccountIds": [
"22222222-2222-2222-2222-222222222222"
]
}
]
}Responses
429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}201
Invitation created
application/json
type object
id-
type string · format uuid
example:
"33333333-3333-3333-3333-333333333333" email-
type string · format email
example:
"user@example.com" tenantRoles-
type array
array items:
type string · one of
tenant-admin,billing-adminexample:
"billing-admin"example:
[ "billing-admin" ] workspaceRoles-
type array
array items:
type object
workspaceId-
type string · format uuid
example:
"11111111-1111-1111-1111-111111111111" role-
type string
example:
"member" scopedAccountIds-
type array
array items:
type string · format uuid
example:
"22222222-2222-2222-2222-222222222222"example:
[ "22222222-2222-2222-2222-222222222222" ]
example:
{ "workspaceId": "11111111-1111-1111-1111-111111111111", "role": "member", "scopedAccountIds": [ "22222222-2222-2222-2222-222222222222" ] }example:
[ { "workspaceId": "11111111-1111-1111-1111-111111111111", "role": "member", "scopedAccountIds": [ "22222222-2222-2222-2222-222222222222" ] } ] expiresAt-
type string · format date-time
example:
"2026-07-24T18:00:00Z"
example:
{
"id": "33333333-3333-3333-3333-333333333333",
"email": "user@example.com",
"tenantRoles": [
"billing-admin"
],
"workspaceRoles": [
{
"workspaceId": "11111111-1111-1111-1111-111111111111",
"role": "member",
"scopedAccountIds": [
"22222222-2222-2222-2222-222222222222"
]
}
],
"expiresAt": "2026-07-24T18:00:00Z"
}example:
{
"id": "33333333-3333-3333-3333-333333333333",
"email": "user@example.com",
"tenantRoles": [
"billing-admin"
],
"workspaceRoles": [
{
"workspaceId": "11111111-1111-1111-1111-111111111111",
"role": "member",
"scopedAccountIds": [
"22222222-2222-2222-2222-222222222222"
]
}
],
"expiresAt": "2026-07-24T18:00:00Z"
}400
Validation failure (bad email / bad role / bad scope / workspace not in tenant)
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}409
already_member — the invited email already holds a role in one of the granted workspaces, so that grant could never be applied.
application/json
see Error
example:
{
"error": {
"code": "already_member",
"message": "user@example.com already has a role in workspace acme-prod, and an invitation cannot change it. remove that workspace from this invitation — changing an existing role is done by an admin of that workspace."
}
}DELETE /v1/tenant/{tenantSlug}/invitations/{invitationId} — Revoke a pending tenant invitation (tenant-admin only)
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
tenantSlug | path | yes | type string | URL slug of the caller's home tenant. A user belongs to exactly one tenant, so this must match the caller's own tenant slug; any other value returns 404. |
invitationId | path | yes | type string · format uuid | UUID of the pending invitation. |
Responses
429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Tenant invitation revoked.
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}POST /v1/tenant/{tenantSlug}/users/{userId}/offboard — Offboard a tenant user (tenant-admin only)
Removes all of the user's workspace memberships + tenant-admin role, revokes all their API keys, and revokes all their sessions, in one action. The user's account row is kept (it just has no access). Refuses (409 last_admin) if the user is the sole admin of any workspace, or the only organization administrator who can sign in, and (400 self_offboard) if you target yourself. Requires an admin-scoped key for bearer callers.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
tenantSlug | path | yes | type string | URL slug of the caller's home tenant. A user belongs to exactly one tenant, so this must match the caller's own tenant slug; any other value returns 404. |
userId | path | yes | type string · format uuid | UUID of the tenant user to offboard. |
Responses
429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Offboarded
application/json
type object
workspacesRemoved-
type integer
example:
3 sessionsRevoked-
type integer
example:
2 apiKeysRevoked-
type integer
example:
1 tenantRolesRemoved-
type array
array items:
type string · one of
tenant-admin,billing-adminexample:
"tenant-admin"example:
[ "tenant-admin" ]
example:
{
"workspacesRemoved": 3,
"sessionsRevoked": 2,
"apiKeysRevoked": 1,
"tenantRolesRemoved": [
"tenant-admin"
]
}example:
{
"workspacesRemoved": 3,
"sessionsRevoked": 2,
"apiKeysRevoked": 1,
"tenantRolesRemoved": [
"tenant-admin"
]
}400
self_offboard
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}409
last_admin — offboarding is blocked because the user is the sole admin of one or more workspaces. Or lock_busy — another admin mutation in this organization held the tenant-wide admin lock when the server's lock wait expired. Nothing is written on lock_busy and it is safe to retry.
DELETE /v1/tenant/{tenantSlug}/users/{userId} — Soft-delete a tenant user (tenant-admin only)
Marks the user for permanent deletion by stamping deleted_at and deletes every one of their sessions. Access is cut immediately (sessions removed; bearer keys and login denied while marked), and the user is emailed that their account is scheduled for deletion. The account is hard-deleted by the nightly purge after the 14-day retention window; restore before then to cancel and return full access. Refuses (409 last_admin) if the user is the sole admin of any workspace, or the only organization administrator who can sign in, and (400 self_delete) if you target yourself. Requires an admin-scoped key for bearer callers.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
tenantSlug | path | yes | type string | URL slug of the caller's home tenant. A user belongs to exactly one tenant, so this must match the caller's own tenant slug; any other value returns 404. |
userId | path | yes | type string · format uuid | UUID of the tenant user to soft-delete. |
Responses
429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Marked for deletion
application/json
type object
userId-
type string · format uuid
example:
"44444444-4444-4444-4444-444444444444" email-
type string
example:
"user@example.com" purgeAfter-
type string · format date-time
example:
"2026-08-16T18:00:00Z"
example:
{
"userId": "44444444-4444-4444-4444-444444444444",
"email": "user@example.com",
"purgeAfter": "2026-08-16T18:00:00Z"
}example:
{
"userId": "44444444-4444-4444-4444-444444444444",
"email": "user@example.com",
"purgeAfter": "2026-08-16T18:00:00Z"
}400
self_delete
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}409
last_admin — soft-deletion is blocked because the user is the sole admin of one or more workspaces. Or lock_busy — another admin mutation in this organization held the tenant-wide admin lock when the server's lock wait expired. Nothing is written on lock_busy and it is safe to retry.
POST /v1/tenant/{tenantSlug}/users/{userId}/restore — Restore a soft-deleted tenant user (tenant-admin only)
Clears deleted_at on a user marked for deletion, restoring full access (workspace memberships, role, and login all return) and emailing the user that their account is active again. Sessions were removed at soft-delete and are NOT recreated, so the user must sign in again. Works only within the 14-day window before the nightly purge. Refuses (409 not_deleted) if the user is not currently marked for deletion, or is past the restore window. Requires an admin-scoped key for bearer callers.
By default (deactivateApiKeys omitted or true) every API key the user owns is revoked as part of the restore — the safe default, since a user is usually soft-deleted after a suspected compromise and a live-again account must not silently carry a credential an attacker may hold. Set deactivateApiKeys=false to keep the user's keys.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
tenantSlug | path | yes | type string | URL slug of the caller's home tenant. A user belongs to exactly one tenant, so this must match the caller's own tenant slug; any other value returns 404. |
userId | path | yes | type string · format uuid | UUID of the tenant user to restore. |
Request body
Optional restore options; an empty body applies the defaults.
application/json
type object
deactivateApiKeys-
Whether to revoke all of the user's API keys as part of the restore. Defaults to true (on) when omitted.
type boolean · default
true
no additional properties
example:
{
"deactivateApiKeys": true
}Responses
429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Tenant user restored.
application/json
type object
userId-
type string · format uuid
example:
"44444444-4444-4444-4444-444444444444" email-
type string
example:
"user@example.com" deactivatedApiKeys-
Count of the user's API keys revoked on restore.
type integer
example:
2
example:
{
"userId": "44444444-4444-4444-4444-444444444444",
"email": "user@example.com",
"deactivatedApiKeys": 2
}example:
{
"userId": "44444444-4444-4444-4444-444444444444",
"email": "user@example.com",
"deactivatedApiKeys": 2
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}409
not_deleted — the user is not marked for deletion, or is past the restore window
POST /v1/tenant/{tenantSlug}/workspaces/{workspaceId}/restore — Restore a soft-deleted workspace (tenant-admin only)
Clears deleted_at on a workspace marked for deletion, returning it to service with its members, connected accounts, webhooks, resources and scans intact — deleting a workspace tears nothing down, so a restore needs no rebuild. Works only within the 14-day window before the nightly purge hard-deletes the workspace and everything in it.
Lives under /tenant rather than the workspace routes because a soft-deleted workspace fails every workspace member gate closed, so no workspace-scoped route can reach it.
Refuses with 409 not_deleted when the workspace is already live, and 409 purge_deadline_passed when it is past the window — whether it is still being removed or the nightly purge has already removed it. The distinction from not_deleted matters, because only that one is a no-op. A 404 usually means the workspace id is not in this organization at all; a purge older than the organization's audit-retention window also answers 404, because the record proving it was purged has itself expired. The workspace always returns under its original slug: a soft-deleted workspace keeps that slug reserved, so a restore cannot collide. Requires an admin-scoped key for bearer callers.
liveAdminCount on a successful restore reports how many of the workspace's admins can still sign in. Zero is possible and is not an error: the last-admin guard only protects LIVE workspaces, so a workspace's sole admin can be offboarded while it sits deleted, and it then comes back with nobody able to administer it. Recover by inviting an admin into it — a tenant admin may do that for a workspace they are not a member of.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
tenantSlug | path | yes | type string | URL slug of the caller's home tenant. A user belongs to exactly one tenant, so this must match the caller's own tenant slug; any other value returns 404. |
workspaceId | path | yes | type string · format uuid | UUID of the soft-deleted workspace to restore. The id, not the slug: a deleted workspace fails every slug-resolving member gate closed. |
Responses
429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Workspace restored.
application/json
type object
workspaceId-
type string · format uuid
example:
"22222222-2222-2222-2222-222222222222" name-
type string
example:
"acme-prod" slug-
URL segment the workspace is reachable under again.
type string
example:
"acme-prod" liveAdminCount-
Admins of the restored workspace whose user account is not itself deleted. Zero means nobody can administer it until an admin is invited.
type integer · minimum 0
example:
1
example:
{
"workspaceId": "22222222-2222-2222-2222-222222222222",
"name": "acme-prod",
"slug": "acme-prod",
"liveAdminCount": 1
}example:
{
"workspaceId": "22222222-2222-2222-2222-222222222222",
"name": "acme-prod",
"slug": "acme-prod",
"liveAdminCount": 1
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}409
not_deleted — the workspace is not marked for deletion; purge_deadline_passed — it is past the restore window and is being removed, or has already been removed by the nightly purge; slug_retaken — a live workspace has taken its url slug, so it cannot return under its own url; or lock_busy — another writer (most often the nightly purge, which holds each workspace it is considering) still held the row when the server's lock wait expired. Nothing is written on lock_busy and it is safe to retry, though a retry that keeps losing to the purge will end as purge_deadline_passed.
DELETE /v1/tenant/{tenantSlug} — Delete the caller's home tenant (tenant-admin only)
Marks the caller's home tenant for deletion. Every user in the tenant is signed out immediately and loses access. The per-tenant schema and all control-plane references are permanently removed 14 days later by the retention job; until then an operator can recover the tenant. No request body — the UI confirms before calling. Requires an admin-scoped key for bearer callers.
A paid subscription is ENDED at the payment provider as part of the delete, immediately rather than at the end of the paid period, and the tenant is marked for deletion only once the provider confirms. This covers every status that still exists at the provider — active, trialing, past_due and paused — including one that already has a cancellation scheduled, because a scheduled cancellation leaves the subscription running and the provider still delivering about it long after the tenant row is gone.
Refused with 503 provider_unavailable if the provider refuses or cannot be reached; nothing is written and the call is safe to retry. The 409 subscription_active refusal is no longer raised for a live subscription — only lock_busy remains on that status.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
tenantSlug | path | yes | type string | URL slug of the caller's home tenant. A user belongs to exactly one tenant, so this must match the caller's own tenant slug; any other value returns 404. |
Responses
429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Tenant marked for deletion
application/json
type object
ok-
type boolean
example:
true
example:
{
"ok": true
}401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}409
lock_busy — another write held the tenant's subscription row or the tenant row itself when the server's lock wait expired. Nothing is written and it is safe to retry. subscription_active is still emitted if the tenant's subscription row changes to a different subscription between the provider call and the write, which means a checkout completed mid-delete; retry the delete.
503
provider_unavailable — the subscription could not be ended at the payment provider, so nothing was deleted. Retry; if it persists an operator has to look at the billing configuration.
webhooks
GET /v1/workspaces/{workspaceSlug}/webhooks — List webhooks (admin only)
required scope: read
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}200
Webhook list
application/json
type object
items-
type array
array items:
see Webhook
example:
[ { "id": "7c9e2a14-3b5d-4e6f-8a1b-2c3d4e5f6a7b", "kind": "slack", "url": "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX", "events": [ "scan_failed" ], "createdAt": "2026-07-10T11:00:00Z", "disabled": false, "connectedAccountId": null } ]
example:
{
"items": [
{
"id": "7c9e2a14-3b5d-4e6f-8a1b-2c3d4e5f6a7b",
"kind": "slack",
"url": "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX",
"events": [
"scan_failed"
],
"createdAt": "2026-07-10T11:00:00Z",
"disabled": false,
"connectedAccountId": null
}
]
}POST /v1/workspaces/{workspaceSlug}/webhooks — Add a webhook (admin only)
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
Request body (required)
The webhook to create.
application/json
type object
kind(required)-
type string · one of
slack,genericexample:
"slack" url(required)-
type string · format uri
example:
"https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX" events-
Empty array = subscribe to all events. Otherwise must be a subset of scan_complete, scan_failed.
type array
array items:
type string · one of
scan_complete,scan_failedexample:
[ "scan_failed" ] connectedAccountId-
If set, the webhook fires only for scans against this connected account. Omit or null for workspace-wide.
type string | null · format uuid
example:
null
no additional properties
example:
{
"kind": "slack",
"url": "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX",
"events": [
"scan_failed"
],
"connectedAccountId": null
}Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}201
Webhook subscription created.
application/json
see Webhook
example:
{
"id": "7c9e2a14-3b5d-4e6f-8a1b-2c3d4e5f6a7b",
"kind": "slack",
"url": "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX",
"events": [
"scan_failed"
],
"createdAt": "2026-07-10T11:00:00Z",
"disabled": false,
"connectedAccountId": null
}400
Validation failure
application/json
see Error
example:
{
"error": {
"code": "invalid_slug",
"message": "workspace slug must be 3-32 characters of lowercase letters, numbers, and hyphens, starting and ending with a letter or number"
}
}423
Workspace is frozen (incident-response containment). Envelope carries code: workspace_frozen; ask a workspace admin to unfreeze before retrying.
application/json
type object
error-
type object
code-
type string · one of
workspace_frozenexample:
"workspace_frozen" message-
type string
example:
"this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
example:
{ "code": "workspace_frozen", "message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it" }
example:
{
"error": {
"code": "workspace_frozen",
"message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
}
}example:
{
"error": {
"code": "workspace_frozen",
"message": "this workspace is frozen for incident response; ask a workspace admin to unfreeze it"
}
}500
The server could not complete the request for a reason it cannot classify. Envelope carries code: internal_error and a requestId; the message is generic outside development, so quote the request id rather than branching on the copy.
Freeze-gated routes answer this when the freeze check itself fails — a dial failure, an exhausted pool, a cancelled request. It is NOT "no such workspace", which is the 404: these routes stopped folding every freeze-check error into that answer, because the caller has already passed the membership gate and a fault says nothing about their own data. The freeze check runs before the route's own work, so on that path nothing was written and one retry is worth trying.
Every other 500 on these routes is a different class and carries no such promise — an addAccount or createWorkspaceInvitation whose insert failed ambiguously may well have written, and the retry then answers 409. The envelope does not separate them; quote the requestId.
application/json
see Error
example:
{
"error": {
"code": "internal_error",
"message": "something went wrong on our end — try again, or contact an operator with this request id",
"requestId": "3f1a9c42-8b7e-4d15-9f0c-2a6b5e8d71c3"
}
}POST /v1/workspaces/{workspaceSlug}/webhooks/{webhookId}/test — Fire a synthetic delivery to verify the webhook
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
webhookId | path | yes | type string · format uuid | UUID of the webhook to send a test delivery to. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Delivery result
application/json
type object
ok-
type boolean
example:
true
example:
{
"ok": true
}DELETE /v1/workspaces/{workspaceSlug}/webhooks/{webhookId} — Remove a webhook (admin only)
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
webhookId | path | yes | type string · format uuid | UUID of the webhook to remove. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Webhook subscription removed.
workspaces
GET /v1/workspaces — List workspaces the caller belongs to
required scope: read
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}200
Workspace list
application/json
type object
items-
type array
array items:
see WorkspaceSummary
example:
[ { "id": "11111111-1111-1111-1111-111111111111", "name": "Acme Prod", "slug": "acme-prod", "role": "admin", "createdAt": "2026-07-17T18:00:00Z", "accountCount": 3, "memberCount": 5, "lastScanAt": "2026-07-17T17:45:00Z" } ]
example:
{
"items": [
{
"id": "11111111-1111-1111-1111-111111111111",
"name": "Acme Prod",
"slug": "acme-prod",
"role": "admin",
"createdAt": "2026-07-17T18:00:00Z",
"accountCount": 3,
"memberCount": 5,
"lastScanAt": "2026-07-17T17:45:00Z"
}
]
}POST /v1/workspaces — Create a workspace (organization admin only)
Creates a workspace and seats its first admin.
Only an organization admin may create a workspace: a workspace binds cloud accounts, so minting one is an organization-level act rather than something any member can do for themselves.
The caller always receives an admin membership in the new workspace, and adminUserId seats a second admin alongside them — the person who will run the workspace day to day. That user must be a live member of the caller's own organization.
required scope: admin
Request body (required)
The workspace to create.
application/json
type object
name(required)-
Display name for the new workspace; 200 characters or fewer. Changeable later via PATCH.
type string · min length 1 · max length 200
example:
"Acme Prod" slug(required)-
The new workspace's url identifier, unique within the organization. Reserved words are refused — see the 400 response.
type string · pattern
^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$· min length 3 · max length 32example:
"acme-prod" adminUserId-
Optional. A live user in the caller's organization to seat as an admin of the new workspace, alongside the caller. Omit to leave the caller as its only admin.
type string · format uuid
example:
"22222222-2222-2222-2222-222222222222"
no additional properties
example:
{
"name": "Acme Prod",
"slug": "acme-prod",
"adminUserId": "22222222-2222-2222-2222-222222222222"
}Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}201
Workspace created.
application/json
see WorkspaceSummary
example:
{
"id": "11111111-1111-1111-1111-111111111111",
"name": "Acme Prod",
"slug": "acme-prod",
"role": "admin",
"createdAt": "2026-07-17T18:00:00Z"
}400
bad_name — the name is empty or longer than 200 characters; bad_slug — the slug does not match the pattern; or unknown_user — adminUserId names nobody live in this organization.
application/json
see Error
example:
{
"error": {
"code": "unknown_user",
"message": "we couldn't find that person in your organization — pick someone from the list, or invite them first."
}
}409
slug_taken — a live workspace already uses the slug in this organization; slug_pending_deletion — a soft-deleted workspace still holds the slug until it is purged, and restoring it returns the slug; or account_pending_deletion — the caller's account is soft-deleted, so it cannot become the new workspace's only admin. Or lock_busy — another admin mutation in this organization held the tenant-wide admin lock when the server's lock wait expired. Nothing is written on lock_busy and it is safe to retry.
GET /v1/workspaces/{workspaceSlug} — Workspace detail with aggregate counters
required scope: read
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}200
Workspace detail
application/json
see WorkspaceDetail
example:
{
"id": "11111111-1111-1111-1111-111111111111",
"name": "Acme Prod",
"slug": "acme-prod",
"role": "admin",
"createdAt": "2026-07-17T18:00:00Z",
"accountCount": 3,
"memberCount": 5,
"lastScanAt": "2026-07-17T17:45:00Z",
"webhookCount": 2
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}PATCH /v1/workspaces/{workspaceSlug} — Patch workspace state or identity (admin only)
A partial update: supply only the fields you are changing. At least one of frozen, name, or slug is required. An omitted field is left alone — sending {"name": "..."} does not touch the slug, and vice versa.
**Freeze.** Set frozen: true to freeze writes on the workspace (new scans, account changes, webhook deliveries, invitations return 423 Locked); set frozen: false to resume writes. freezeReason is recorded on the row when freezing and surfaces in the workspace-frozen banner; ignored when unfreezing. A freeze flip to the state the workspace is already in returns 404.
**Rename.** name is the display name; slug is the URL segment, unique per organization across live AND soft-deleted workspaces, so a slug held by a workspace pending deletion returns 409 slug_pending_deletion — distinct from a live collision's 409 slug_taken, because only one of the two is resolvable by restoring. **Changing the slug changes every URL for this workspace**, including the one you just called; the old slug stops resolving immediately.
Renaming is deliberately allowed on a frozen workspace. Freeze is a containment lever for cloud-trust binding, and a name binds nothing.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
Request body (required)
Workspace fields to update. Omitted fields are left unchanged.
application/json
type object
at least one of:
frozen
name
slug
frozen-
type boolean
example:
true freezeReason-
type string
example:
"incident response — suspending writes" name-
Display name shown in the workspace list and breadcrumbs; 200 characters or fewer.
type string · min length 1 · max length 200
example:
"Acme Production" slug-
Lowercase letters, numbers, and hyphens; must start and end with a letter or number.
type string · pattern
^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$· min length 3 · max length 32example:
"acme-production"
no additional properties
example:
{
"name": "Acme Production",
"slug": "acme-production"
}Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Workspace updated. On a rename the body carries the new name and slug.
400
no_fields — no recognized field supplied; bad_name — the name is empty or longer than 200 characters; or bad_slug — the slug does not match the pattern.
application/json
see Error
example:
{
"error": {
"code": "bad_slug",
"message": "the url slug must be 3-32 characters — lowercase letters, numbers, and hyphens, starting and ending with a letter or number."
}
}404
Workspace not found, or the freeze flip was a no-op.
409
The requested slug is taken by a live workspace (slug_taken) or by one pending deletion (slug_pending_deletion); or a concurrent write to the same workspace aborted this one (concurrent_update) or held it past the server's lock wait (lock_busy). Nothing is written on the last two and both are safe to retry.
DELETE /v1/workspaces/{workspaceSlug} — Soft-delete the workspace (admin only)
Marks deleted_at on the workspaces row. Scan data, audit log, and per-tenant schema are retained for the workspace's audit retention window; a daily pg_cron purge then hard-deletes everything matching the window. Reversible during that window by an organization admin, via POST /v1/tenant/{tenantSlug}/workspaces/{workspaceId}/restore — the workspace keeps its members, cloud accounts, inventory, and URL slug until it is purged.
required scope: admin
Parameters
| name | in | required | type | description |
|---|---|---|---|---|
workspaceSlug | path | yes | type string | URL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404. |
Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}429
Too many requests. Envelope carries code: rate_limited; the Retry-After response header names the wait in seconds. Reads (GET/HEAD/OPTIONS) are never throttled — this applies only to mutating /v1 requests, per-principal (API-key id, then session user id, then client IP).
application/json
see Error
example:
{
"error": {
"code": "rate_limited",
"message": "too many requests; retry after the interval named in the Retry-After header"
}
}200
Workspace removed (soft-deleted, restorable within the retention window).
403
Role gate failure. Envelope carries code: insufficient_role and the role the route needs, so clients can branch on that rather than parsing copy. requiredRole is a workspace role (admin, member, auditor) on a workspace-scoped route, and an organization role (tenant-admin, billing-admin) on a route gated at the organization — creating a workspace, tenant settings, invitations and billing.
application/json
see RoleDenial
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}500
The delete could not be completed. Envelope carries code: internal_error and a requestId; the message is generic outside development. Not the shared freeze-gate 500 the other workspace routes document — this route is not freeze-gated — and it is raised after the delete is attempted, so whether the workspace was removed is exactly what the response does not say. Re-read the workspace before retrying.
application/json
see Error
example:
{
"error": {
"code": "internal_error",
"message": "something went wrong on our end — try again, or contact an operator with this request id",
"requestId": "3f1a9c42-8b7e-4d15-9f0c-2a6b5e8d71c3"
}
}webhook events
POST scanComplete — Delivered when a scan reaches a completed/partial terminal state.
required scope: write
Request body
application/json
see WebhookEvent
example:
{
"type": "scan_complete",
"workspaceId": "11111111-1111-1111-1111-111111111111",
"connectedAccountId": "22222222-2222-2222-2222-222222222222",
"occurredAt": "2026-07-17T18:00:00Z",
"data": {
"scanId": "33333333-3333-3333-3333-333333333333",
"status": "completed"
}
}Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}2XX
Any 2xx acknowledges the scan-completed delivery; a non-2xx status is recorded as a failed delivery.
POST scanFailed — Delivered when a scan reaches the failed terminal state.
required scope: write
Request body
application/json
see WebhookEvent
example:
{
"type": "scan_failed",
"workspaceId": "11111111-1111-1111-1111-111111111111",
"connectedAccountId": "22222222-2222-2222-2222-222222222222",
"occurredAt": "2026-07-17T18:00:00Z",
"data": {
"scanId": "33333333-3333-3333-3333-333333333333",
"status": "failed",
"error": "AccessDenied walking aws:s3 in us-east-1",
"healthStatus": "principal_denied"
}
}Responses
401
Missing or invalid session / bearer.
application/json
see Error
example:
{
"error": {
"code": "unauthorized",
"message": "login required — visit /login"
}
}404
Not found
application/json
see Error
example:
{
"error": {
"code": "not_found",
"message": "that resource does not exist or you cannot see it"
}
}2XX
Any 2xx acknowledges the scan-failed delivery; a non-2xx status is recorded as a failed delivery.
Schemas
APIKeySummary
An API key's public metadata; never includes the secret.
type object
id-
type string · format uuid
example:
"66666666-6666-6666-6666-666666666666" name-
Human-readable label. Not unique and not an identifier — two live keys may share one; address a key by id.
type string
example:
"ci-deploy" keyPrefix-
Public prefix; pair with stored hash for forensic correlation.
type string
example:
"ab12cd34" scopes-
Always non-empty — the database refuses an empty scope set.
type array · min items 1
array items:
type string · one of
read,write,adminexample:
[ "read", "write" ] createdBy-
type string · format uuid
example:
"44444444-4444-4444-4444-444444444444" createdAt-
type string · format date-time
example:
"2026-07-01T12:00:00Z" lastUsedAt-
When the key last authenticated a request. Absent until it is first used — the field is omitted, not null.
type string · format date-time
example:
"2026-07-17T18:00:00Z" revokedAt-
When an admin revoked the key. A live key omits this field entirely rather than reporting a null.
type string · format date-time
example:
"2026-07-20T09:15:00Z" expiresAt-
When the key stops authenticating. Every key minted through this API carries one — omitting expiresAt on mint stores a 30-day default rather than a key that never expires. Past this instant the key is refused with code
unauthorizedand a message beginning "api key expired"; it is NOT revoked, and it keeps whatever name it was minted with.type string · format date-time
example:
"2026-08-16T18:00:00Z"
example:
{
"id": "66666666-6666-6666-6666-666666666666",
"name": "ci-deploy",
"keyPrefix": "ab12cd34",
"scopes": [
"read",
"write"
],
"createdBy": "44444444-4444-4444-4444-444444444444",
"createdAt": "2026-07-01T12:00:00Z",
"lastUsedAt": "2026-07-17T18:00:00Z"
}AzureGraphConsent
One Entra directory a workspace holds Microsoft Graph read access to. Keyed by directory rather than by connected account, so subscriptions sharing a directory share its consent.
type object
customerTenantId(required)-
Entra directory (tenant) GUID this workspace may read through Microsoft Graph.
type string · format uuid
consentedAt(required)-
When a directory administrator granted the consent.
type string · format date-time
consentedBy(required)-
UUID of the disco user who completed the grant, or null once that user's account has been erased — the consent itself survives, since it belongs to the workspace rather than to the person who obtained it.
type string | null · format uuid
current(required)-
Whether the consent still describes the live configuration. False means disco's Entra application or its requested permissions have changed since the grant, or that this deployment carries no Entra application at all. While it is false every Azure subscription in that directory is refused at dispatch, not only its directory objects, until an administrator consents again.
True is a statement about the CONSENT, not a guarantee that a scan read the directory: a scan can still withhold Graph for a reason decided after dispatch.
type boolean
no additional properties
BillingEmailOutcome
What a billing-address write decided. billingEmail is the address the decision was ABOUT, which on proof_sent is the address still waiting to be confirmed rather than the one invoices go to — read the billing resource for that.
type object
billingEmail-
The address the decision was about.
type string · format email
example:
"accounts@acme.example" outcome-
proof_sent — a confirmation link was emailed and invoices have not moved; adopted — the address was already proven and is in force now; proposal_canceled — an outstanding confirmation link was retired; unchanged — the address was already in force and nothing was outstanding.
type string · one of
proof_sent,adopted,proposal_canceled,unchangedexample:
"proof_sent"
example:
{
"billingEmail": "accounts@acme.example",
"outcome": "proof_sent"
}ConnectedAccount
A connected cloud account. Different /v1 account endpoints return different subsets of these fields: list and get include createdAt + lastScanAt; add includes createdAt plus the minted externalId (AWS) or bindingId (GCP), and the azureMetadata / gcpMetadata onboarding block (Azure / GCP); restore returns only the identity fields. cloudAccountId is the AWS account id, Azure subscription id, or GCP project id; principal is the AWS role ARN, Azure service-principal id, or GCP service-account email.
type object
id-
type string · format uuid
cloud-
type string · one of
aws,azure,gcp cloudAccountId-
type string
name-
type string
principal-
type string
externalId-
The AWS cross-account external id (plaintext, non-secret, unique per account; it appears in the customer trust policy). Returned by the AWS add response.
type string
bindingId-
The GCP per-account binding id (plaintext, non-secret, unique per account). Disco presents it as the AWS role session name on every scan of this account, and the workload-identity provider condition pins it, so a credential minted for one account is refused by every other account's pool. Apply the connector template with this value as the disco_binding_id variable. Returned by the GCP add response.
type string
example:
"7f3a1c9b2d4e6f8a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e" azureMetadata-
Azure-only. Managing + customer tenant GUIDs and the registration scope. Returned by the Azure add response.
type object
mspTenantId(required)-
type string
customerTenantId(required)-
type string
registrationScopeId(required)-
type string
gcpMetadata-
GCP-only. WIF scope, project number, pool/provider ids, and the derived audience. Returned by the GCP add response.
type object
scope-
type string · one of
project,org projectNumber-
type string
poolId-
type string
providerId-
type string
audience-
type string
createdAt-
type string · format date-time
lastScanAt-
Completion of the most recent scan that ran against this cloud account. Omitted while no scan has finished for it.
type string · format date-time
example:
{
"id": "22222222-2222-2222-2222-222222222222",
"cloud": "aws",
"cloudAccountId": "123456789012",
"name": "prod-aws",
"principal": "arn:aws:iam::123456789012:role/disco-scanner",
"createdAt": "2026-07-01T12:00:00Z",
"lastScanAt": "2026-07-17T18:00:00Z"
}Error
Standard error envelope returned on 4xx and 5xx responses.
type object
error(required)-
type object
code(required)-
type string
message(required)-
type string
requestId-
type string
example:
{
"error": {
"code": "internal_error",
"message": "something went wrong on our end — try again, or contact an operator with this request id",
"requestId": "req_01HZY8ABCDEF"
}
}Member
A workspace member — user, role, and connected-account scope.
type object
userId-
type string · format uuid
email-
type string · format email
role-
type string · one of
admin,member,viewer,auditor,compliance-admin joinedAt-
type string · format date-time
deletedAt-
Set when the member's user account is soft-deleted and pending erasure — they keep this membership so a restore returns it, but cannot sign in. Absent when the account is not soft-deleted, and absent for every member when the caller is not a workspace admin.
type string · format date-time
scopedAccountIds-
Per-account scoping for non-admin members. Empty means no access (the post-invite default until an admin grants); admin role bypasses scoping regardless of this list.
type array
array items:
type string · format uuid
example:
{
"userId": "44444444-4444-4444-4444-444444444444",
"email": "user@example.com",
"role": "member",
"joinedAt": "2026-07-02T09:30:00Z",
"scopedAccountIds": [
"22222222-2222-2222-2222-222222222222"
]
}Quota
A service-quota limit the provider enforces on one account, in one region.
type object
id-
Stable identity across every version of this limit — the hash of provider, account, region, service code, quota code and, when the limit is dimensioned, dimension key.
type string
provider-
type string
accountId-
type string
accountName-
Display name of the cloud account as the provider reported it.
type string | null
region-
globalfor a limit that applies account-wide rather than per region.type string
serviceCode-
Provider service identifier, for example
ec2.type string
serviceName-
Human-readable service name.
type string | null
quotaCode-
Provider quota identifier within the service, for example
L-1216C47A.type string
name-
Human-readable limit name.
type string
description-
type string | null
unit-
Unit the value is counted in. AWS reports the literal
Nonefor unitless limits.type string | null
value-
The limit currently applied. Null when the provider did not report one, which is not the same as a limit of zero.
type number | null
defaultValue-
The provider's published default for this quota. Null when unknown — including on every quota recorded before defaults were collected, and on every GCP quota, since Cloud Quotas publishes no default.
type number | null
adjustable-
Whether the limit can be raised on request. A false value moves only when the provider moves it.
type boolean
dimensionKey-
Provider dimension this row's value belongs to. Empty when the limit is undimensioned, which is the common case; one quota code can carry a different value per dimension set.
type string
periodUnit-
Time unit of the rate window. Null means the limit is a count rather than a rate —
10with no window is ten in total, not ten per second.type string | null · one of
microsecond,millisecond,second,minute,hour,day,week,null periodValue-
How many
periodUnits the window spans, for example 5 alongsideminute. Null wheneverperiodUnitis null.type integer | null
resourceType-
FOCUS ResourceType. What a resource-scoped limit counts, for example
AWS::Connect::Instance. Null means the limit is not resource-scoped.type string | null
availabilityZone-
FOCUS AvailabilityZone. The zone a zone-scoped limit applies in. Null when the limit is not zone-scoped, which is every AWS and Azure quota today; GCP sets it on a zone-dimensioned limit.
type string | null
subAccountType-
FOCUS SubAccountType. What kind of container
accountIdnames:Accounton AWS,Subscriptionon Azure,Projecton GCP.type string | null
attributes-
Provider fields not promoted to a column, stored as reported. Includes the AWS-only GlobalQuota and QuotaAppliedAtLevel fields, which no longer have columns of their own.
type object
additional properties allowed
discoveredAt-
Scan time this version of the limit was first observed.
type string · format date-time
discoveredBy-
Identifier of the scan that first observed this version.
type string
additional properties allowed
example:
{
"id": "3773e0c1a243fe744bbdbe43b8107f2b",
"provider": "aws",
"accountId": "123456789012",
"accountName": "acme-prod",
"region": "us-east-1",
"serviceCode": "ec2",
"serviceName": "Amazon Elastic Compute Cloud (Amazon EC2)",
"quotaCode": "L-1216C47A",
"name": "Running On-Demand Standard instances",
"value": 1152,
"defaultValue": 5,
"unit": "None",
"adjustable": true,
"dimensionKey": "",
"subAccountType": "Account",
"discoveredAt": "2026-07-17T18:00:00Z"
}QuotaVersion
One recorded version of a service quota, with the chain links that order it.
type object
combines:
all fields of Quota:
A service-quota limit the provider enforces on one account, in one region.
type object
id-
Stable identity across every version of this limit — the hash of provider, account, region, service code, quota code and, when the limit is dimensioned, dimension key.
type string
provider-
type string
accountId-
type string
accountName-
Display name of the cloud account as the provider reported it.
type string | null
region-
globalfor a limit that applies account-wide rather than per region.type string
serviceCode-
Provider service identifier, for example
ec2.type string
serviceName-
Human-readable service name.
type string | null
quotaCode-
Provider quota identifier within the service, for example
L-1216C47A.type string
name-
Human-readable limit name.
type string
description-
type string | null
unit-
Unit the value is counted in. AWS reports the literal
Nonefor unitless limits.type string | null
value-
The limit currently applied. Null when the provider did not report one, which is not the same as a limit of zero.
type number | null
defaultValue-
The provider's published default for this quota. Null when unknown — including on every quota recorded before defaults were collected, and on every GCP quota, since Cloud Quotas publishes no default.
type number | null
adjustable-
Whether the limit can be raised on request. A false value moves only when the provider moves it.
type boolean
dimensionKey-
Provider dimension this row's value belongs to. Empty when the limit is undimensioned, which is the common case; one quota code can carry a different value per dimension set.
type string
periodUnit-
Time unit of the rate window. Null means the limit is a count rather than a rate —
10with no window is ten in total, not ten per second.type string | null · one of
microsecond,millisecond,second,minute,hour,day,week,null periodValue-
How many
periodUnits the window spans, for example 5 alongsideminute. Null wheneverperiodUnitis null.type integer | null
resourceType-
FOCUS ResourceType. What a resource-scoped limit counts, for example
AWS::Connect::Instance. Null means the limit is not resource-scoped.type string | null
availabilityZone-
FOCUS AvailabilityZone. The zone a zone-scoped limit applies in. Null when the limit is not zone-scoped, which is every AWS and Azure quota today; GCP sets it on a zone-dimensioned limit.
type string | null
subAccountType-
FOCUS SubAccountType. What kind of container
accountIdnames:Accounton AWS,Subscriptionon Azure,Projecton GCP.type string | null
attributes-
Provider fields not promoted to a column, stored as reported. Includes the AWS-only GlobalQuota and QuotaAppliedAtLevel fields, which no longer have columns of their own.
type object
additional properties allowed
discoveredAt-
Scan time this version of the limit was first observed.
type string · format date-time
discoveredBy-
Identifier of the scan that first observed this version.
type string
additional properties allowed
example:
{
"id": "3773e0c1a243fe744bbdbe43b8107f2b",
"provider": "aws",
"accountId": "123456789012",
"accountName": "acme-prod",
"region": "us-east-1",
"serviceCode": "ec2",
"serviceName": "Amazon Elastic Compute Cloud (Amazon EC2)",
"quotaCode": "L-1216C47A",
"name": "Running On-Demand Standard instances",
"value": 1152,
"defaultValue": 5,
"unit": "None",
"adjustable": true,
"dimensionKey": "",
"subAccountType": "Account",
"discoveredAt": "2026-07-17T18:00:00Z"
}type object
versionRowId-
Row identifier of this version specifically, unlike
idwhich is shared by the whole chain.type string · format uuid
rootId-
Chain identity; equal to
id.type string
previousVersionId-
The version this one replaced. Null on the first version.
type string | null · format uuid
supersededBy-
The version that replaced this one. Null on the current version — exactly one row per chain has it null.
type string | null · format uuid
verifiedAt-
Most recent scan that observed this version unchanged.
type string | null · format date-time
verifiedBy-
Identifier of that scan.
type string | null
example:
{
"id": "3773e0c1a243fe744bbdbe43b8107f2b",
"provider": "aws",
"accountId": "123456789012",
"region": "us-east-1",
"serviceCode": "ec2",
"quotaCode": "L-1216C47A",
"name": "Running On-Demand Standard instances",
"value": 5,
"adjustable": true,
"dimensionKey": "",
"discoveredAt": "2026-05-02T11:00:00Z",
"versionRowId": "0197f0a2-1c3d-7e00-9a1b-2c3d4e5f6a7b",
"rootId": "3773e0c1a243fe744bbdbe43b8107f2b",
"supersededBy": "0198aa31-9b2c-7d00-8e1f-3a4b5c6d7e8f"
}Resource
A scanned cloud resource.
type object
id-
type string
type-
type string
region-
type string
accountId-
type string
provider-
type string
discoveredAt-
Scan time the current row was observed. The
sincefilter param maps to this column.type string · format date-time
additional properties allowed
example:
{
"id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"type": "aws:s3:bucket",
"nativeId": "arn:aws:s3:::acme-prod-assets",
"region": "us-east-1",
"accountId": "123456789012",
"provider": "aws",
"discoveredAt": "2026-07-17T18:00:00Z"
}ResourceArchiveResult
Result of archiving or restoring a resource.
type object
id-
Resource root id acted on.
type string
archived-
True after archive, false after restore.
type boolean
changed-
False when the resource was already in the target state (idempotent no-op).
type boolean
example:
{
"id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"archived": true,
"changed": true
}ResourceSummary
Aggregated resource counts for a workspace.
type object
total-
Resources in scope, provider-managed rows included.
type integer
untaggedCount-
Resources carrying no tags. Counts provider-managed rows, whose tags a customer cannot set, so this is a wider population than the tagging coverage the web UI reports.
type integer
byType-
type array
array items:
see SummaryBucket
byRegion-
type array
array items:
see SummaryBucket
byAccount-
type array
array items:
see SummaryBucket
byProvider-
type array
array items:
see SummaryBucket
example:
{
"total": 1428,
"untaggedCount": 42,
"byType": [
{
"key": "aws:s3:bucket",
"count": 128
}
],
"byRegion": [
{
"key": "us-east-1",
"count": 900
}
],
"byAccount": [
{
"key": "123456789012",
"count": 1428
}
],
"byProvider": [
{
"key": "aws",
"count": 1428
}
]
}RoleDenial
Error envelope for a role or scope denial, naming the required role.
type object
error(required)-
type object
code(required)-
type string · one of
insufficient_role requiredRole(required)-
type string · one of
admin,member,auditor,tenant-admin,billing-admin message(required)-
type string
example:
{
"error": {
"code": "insufficient_role",
"requiredRole": "admin",
"message": "this action requires the admin role; ask a workspace admin"
}
}Scan
A scan and its current status.
type object
id-
type string
status-
type string · one of
pending,running,completed,partial,failed startedAt-
type string | null · format date-time
finishedAt-
type string | null · format date-time
resourceCount-
Resources the scan saw. Null means the scan has not reported yet (it is still pending or running) -- distinct from 0, which is a scan that completed and found nothing.
type integer | null
example:
1428 error-
type string | null
errors-
Structured per-service failure array (raw passthrough of the scanner-written JSON). Omitted when the scan recorded no structured errors; rows written by older scanner builds may carry entries that don't match the documented shape.
type array
array items:
see ScanErrorEntry
warnings-
Structured per-service warning array (raw passthrough of the scanner-written JSON). A warning means the scan finished but skipped something, so a
completedscan carrying warnings is normal and the status is unaffected. Omitted when the scan recorded none, and always empty for scans written by scanner builds older than 0.29.0.type array
array items:
see ScanWarningEntry
accountId-
cloud-side account id observed by the scanner subprocess. Resolved from
sts:GetCallerIdentityin prod; the Floci emulator override in dev. Compare against the dispatched connected account'scloud_account_idto detect misdirection — they should match. F12.type string | null
regions-
Comma-separated regions actually walked.
type string | null
services-
Comma-separated services actually walked.
type string | null
principalArn-
type string | null
scannerVersion-
type string | null
triggeredBy-
type string | null · format uuid
triggeredByEmail-
type string | null · format email
example:
{
"id": "33333333-3333-3333-3333-333333333333",
"status": "completed",
"startedAt": "2026-07-17T17:55:00Z",
"finishedAt": "2026-07-17T18:00:00Z",
"resourceCount": 1428,
"error": null,
"errors": [],
"accountId": "123456789012",
"regions": "us-east-1,us-west-2",
"services": "s3,ec2,iam",
"principalArn": "arn:aws:iam::123456789012:role/disco-scanner",
"scannerVersion": "0.15.0",
"triggeredBy": "44444444-4444-4444-4444-444444444444",
"triggeredByEmail": "user@example.com"
}ScanErrorEntry
One structured scanner failure, scoped to a service walk.
type object
service-
Provider-prefixed service identifier (e.g. aws:s3).
type string
region-
Region being walked. Omitted by older scanner builds.
type string
code-
Provider error code (e.g. AccessDenied).
type string
message-
type string
example:
{
"service": "aws:s3",
"region": "us-east-1",
"code": "AccessDenied",
"message": "not authorized to perform s3:ListAllMyBuckets"
}ScanWarningEntry
One structured scanner warning, scoped to a service walk. Unlike ScanErrorEntry there is no code field — a warning message is usually a whole SDK error string, from which a code scrape returns nothing useful.
type object
service-
Provider-prefixed service identifier (e.g. aws:s3control).
type string
region-
Region being walked, parsed from scope. Empty for globally-scoped walks.
type string
scope-
The scanner's original scope string, kept verbatim so an entry ties back to a scanner log line.
type string
message-
type string
example:
{
"service": "aws:bedrockagentcore",
"region": "us-west-1",
"scope": "123456789012/us-west-1",
"message": "operation error: AccessDeniedException"
}ScanTriggered
Acknowledgement of a newly dispatched scan.
type object
scanId-
type string
taskArn-
type string
workspaceId-
type string · format uuid
example:
{
"scanId": "33333333-3333-3333-3333-333333333333",
"taskArn": "arn:aws:ecs:us-east-1:123456789012:task/disco-saas-dev/abc123def456",
"workspaceId": "11111111-1111-1111-1111-111111111111"
}SummaryBucket
One bucket of a resource summary — a group value and its count.
type object
key-
type string
count-
type integer
example:
{
"key": "aws:s3:bucket",
"count": 128
}TenantSSOConfig
A tenant's single sign-on configuration. idp_type is oidc or saml. No client secret or SP private key is present (disco is a public PKCE client for OIDC and a verify-only SP for SAML; the SAML certificate is the IdP's public signing cert).
type object
configured-
type boolean
entitled-
Whether the tenant's plan (Pro/Enterprise) includes SSO. The read is returned regardless; the mutating routes fail-close when false.
type boolean
enabled-
type boolean
idpType-
type string · one of
oidc,saml issuer-
type string · format uri
clientId-
type string
scopes-
type array
array items:
type string
trustUnverifiedEmail-
type boolean
entityId-
type string
ssoUrl-
type string · format uri
certificates-
type array
array items:
type string
allowedEmailDomains-
type array
array items:
type string
verified-
Whether a sign-in has completed through these exact settings. False both when none ever has and when one has but the settings changed afterwards — a recorded sign-in is bound to a fingerprint of the configuration it was performed against, so editing the configuration retires the proof.
type boolean
verifiedAt-
When that sign-in completed. Absent unless verified is true; a stamp belonging to superseded settings is dropped rather than returned.
type string · format date-time
example:
{
"configured": true,
"entitled": true,
"enabled": true,
"idpType": "oidc",
"issuer": "https://idp.example.com",
"clientId": "disco-saas",
"scopes": [
"openid",
"email",
"profile"
],
"allowedEmailDomains": [
"example.com"
],
"verified": true,
"verifiedAt": "2026-08-11T09:30:00Z"
}Webhook
An outbound webhook subscription for a workspace.
type object
id-
type string · format uuid
kind-
type string · one of
slack,generic url-
type string · format uri
events-
Subscribed event types; empty means "all".
type array
array items:
type string · one of
scan_complete,scan_failed createdAt-
type string · format date-time
disabled-
type boolean
connectedAccountId-
If set, the webhook fires only for scans against this connected account. Null means workspace-wide (default).
type string | null · format uuid
example:
{
"id": "55555555-5555-5555-5555-555555555555",
"kind": "slack",
"url": "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX",
"events": [
"scan_complete",
"scan_failed"
],
"createdAt": "2026-07-01T12:00:00Z",
"disabled": false,
"connectedAccountId": null
}WebhookEvent
Outbound webhook payload (generic webhooks; Slack webhooks receive a flattened text rendering of the same data). HMAC-SHA256 signed via the X-Disco-Signature header when the webhook has a secret.
type object
type(required)-
type string · one of
scan_complete,scan_failedexample:
"scan_complete" workspaceId(required)-
type string · format uuid
example:
"11111111-1111-1111-1111-111111111111" connectedAccountId-
The connected account the scan ran against; omitted when unknown or N/A.
type string · format uuid
example:
"22222222-2222-2222-2222-222222222222" occurredAt(required)-
type string · format date-time
example:
"2026-07-17T18:00:00Z" data(required)-
type object
scanId(required)-
type string · format uuid
example:
"33333333-3333-3333-3333-333333333333" status(required)-
type string · one of
completed,partial,failedexample:
"completed" error-
Failure summary. Present on every scan_failed, and wherever a failure was recorded against the account -- which is exactly when healthStatus is present, so the two keys arrive and depart together outside scan_failed. A completed scan that found the account empty is healthy and carries neither. Present does not promise non-empty: the key carries whatever the scan recorded, and a failure with no recorded text sends an empty string.
type string
example:
"AccessDenied walking aws:s3 in us-east-1" healthStatus-
Classified account health, present whenever the account was recorded unhealthy. principal_denied means the scanning principal cannot reach the cloud account -- it was refused, or it was never granted standing there in the first place. A partial scan that wrote nothing for the account carries this on a scan_complete: the scan reached a terminal state, but the account it scanned is not healthy. Absent on every healthy outcome, and absent whenever no account row took the failure: for example the scan could not be attributed to a connected account, the account was removed between dispatch and finalize, the account has since had a newer scan of either kind, or a concurrent scan of the same account held the rows this one would have written, in which case this scan records nothing and that one reports for the account.
type string · one of
principal_denied,failedexample:
"principal_denied"
example:
{ "scanId": "33333333-3333-3333-3333-333333333333", "status": "completed" }
example:
{
"type": "scan_complete",
"workspaceId": "11111111-1111-1111-1111-111111111111",
"connectedAccountId": "22222222-2222-2222-2222-222222222222",
"occurredAt": "2026-07-17T18:00:00Z",
"data": {
"scanId": "33333333-3333-3333-3333-333333333333",
"status": "completed"
}
}WorkspaceDetail
A single workspace with its full detail.
combines:
all fields of WorkspaceSummary:
A workspace as it appears in list responses.
type object
id-
type string · format uuid
name-
type string
slug-
type string
role-
type string · one of
admin,member,viewer,auditor,compliance-admin createdAt-
type string · format date-time
accountCount-
type integer
memberCount-
type integer
lastScanAt-
Completion of the most recent scan across every account bound to this workspace. Omitted while none has finished.
type string · format date-time
frozenAt-
When an operator froze the workspace. Omitted on a workspace that is not frozen.
type string · format date-time
example:
{
"id": "11111111-1111-1111-1111-111111111111",
"name": "Floci Demo",
"slug": "floci-demo",
"role": "admin",
"createdAt": "2026-07-01T12:00:00Z",
"accountCount": 3,
"memberCount": 5,
"lastScanAt": "2026-07-17T18:00:00Z"
}type object
webhookCount-
type integer
frozenByEmail-
type string | null · format email
frozenReason-
type string | null
example:
{
"id": "11111111-1111-1111-1111-111111111111",
"name": "Floci Demo",
"slug": "floci-demo",
"role": "admin",
"createdAt": "2026-07-01T12:00:00Z",
"accountCount": 3,
"memberCount": 5,
"lastScanAt": "2026-07-17T18:00:00Z",
"webhookCount": 2
}WorkspaceSummary
A workspace as it appears in list responses.
type object
id-
type string · format uuid
name-
type string
slug-
type string
role-
type string · one of
admin,member,viewer,auditor,compliance-admin createdAt-
type string · format date-time
accountCount-
type integer
memberCount-
type integer
lastScanAt-
Completion of the most recent scan across every account bound to this workspace. Omitted while none has finished.
type string · format date-time
frozenAt-
When an operator froze the workspace. Omitted on a workspace that is not frozen.
type string · format date-time
example:
{
"id": "11111111-1111-1111-1111-111111111111",
"name": "Floci Demo",
"slug": "floci-demo",
"role": "admin",
"createdAt": "2026-07-01T12:00:00Z",
"accountCount": 3,
"memberCount": 5,
"lastScanAt": "2026-07-17T18:00:00Z"
}