disco

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 environment
  • http://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, admin

example:

[
  "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 400 bad_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, admin

example:

[
  "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 unauthorized and 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

parameters
nameinrequiredtypedescription
keyIdpathyestype string · format uuidUUID 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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
fromquerynotype string · format date-timeLower bound (inclusive) on audit-event time, RFC 3339.
toquerynotype string · format date-timeUpper bound (inclusive) on audit-event time, RFC 3339.
actionquerynotype stringFilter to a single audit action (e.g. scan.trigger).
actorquerynotype string · format uuidFilter to a single actor by user UUID.
viaquerynotype string · one of cookie, api_keyFilter by request principal class (cookie session vs API key).
keyquerynotype string · format uuidFilter by minting API key UUID.
cursorquerynotype integer · format int64Return rows with id < cursor. Use before as a synonym.
beforequerynotype integer · format int64Alias for cursor. Setting both with different values returns 400.
limitquerynotype integer · default 1000 · minimum 1 · maximum 10000Maximum 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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
fromquerynotype string · format date-timeLower bound (inclusive) on the evidence bundle's event-time window, RFC 3339.
toquerynotype string · format date-timeUpper 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

parameters
nameinrequiredtypedescription
tenantSlugpathyestype stringURL 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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL 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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL 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, gcp

example:

"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, org

example:

"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_frozen

example:

"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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
accountIdpathyestype string · format uuidUUID 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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
accountIdpathyestype string · format uuidUUID 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_frozen

example:

"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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
accountIdpathyestype string · format uuidUUID 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_frozen

example:

"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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
accountIdpathyestype string · format uuidUUID 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_frozen

example:

"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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL 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:

see AzureGraphConsent

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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
directoryIdpathyestype string · format uuidEntra 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_frozen

example:

"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 aws

example:

"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 azure

example:

"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 gcp

example:

"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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL 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, failed

example:

"complete"
errorMessage

Why the latest report failed, when status is "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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL 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 processing

example:

"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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL 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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
userIdpathyestype string · format uuidUUID 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-admin

example:

"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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
userIdpathyestype string · format uuidUUID 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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
userIdpathyestype string · format uuidUUID 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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL 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-admin

example:

"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_scope even with role: 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-admin

example:

"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_frozen

example:

"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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
invitationIdpathyestype string · format uuidUUID 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_frozen

example:

"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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
limitquerynotype integer · default 100 · minimum 1 · maximum 1000Maximum number of quotas to return.
cursorquerynotype stringOpaque cursor carried over from the previous quota page's nextCursor.
providerquerynotype stringFilter quotas by cloud provider (aws, azure, gcp).
regionquerynotype stringFilter quotas by cloud region. Use global for limits that apply account-wide rather than per region.
accountquerynotype stringFilter quotas to a single cloud account identifier (AWS account number, Azure subscription GUID or GCP project id).
servicequerynotype stringFilter quotas by provider service code, for example ec2, Microsoft.Compute or compute.googleapis.com.
adjustablequerynotype booleanFilter by whether the limit can be raised on request. Omit for both. false selects the limits only the provider can move.
raisedquerynotype boolean · default falseKeep 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.
changedquerynotype boolean · default falseKeep 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 cursor to 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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
quotaIdpathyestype stringStable 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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
quotaIdpathyestype stringStable 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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
limitquerynotype integer · default 100 · minimum 1 · maximum 1000Maximum number of resources to return.
cursorquerynotype stringOpaque cursor from previous response's nextCursor.
providerquerynotype stringFilter resources by cloud provider (aws, azure, gcp).
regionquerynotype stringFilter resources by cloud region.
accountquerynotype stringFilter resources to a single cloud account identifier (AWS account number, Azure subscription GUID, or GCP project id).
sincequerynotype string · format date-timeReturn 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 cursor to 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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL 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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
resourceIdpathyestype stringIdentifier 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 since filter 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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
resourceIdpathyestype stringIdentifier 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

see ResourceArchiveResult

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_frozen

example:

"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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
resourceIdpathyestype stringIdentifier 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

see ResourceArchiveResult

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_frozen

example:

"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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
resourceIdpathyestype stringIdentifier 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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
resourceIdpathyestype stringIdentifier of the resource at the center of the blast-radius graph.
depthquerynotype integer · default 3 · minimum 1 · maximum 10Maximum graph traversal depth from the resource (1-10).
directionquerynotype string · one of out, in, both · default outEdge 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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
limitquerynotype integer · default 100 · minimum 1 · maximum 1000Maximum number of scans to return.
cursorquerynotype stringOpaque cursor from a previous response's nextCursor.
statusquerynotype string · one of pending, running, completed, partial, failedFilter 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 cursor for 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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL 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, gcp

example:

"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. On scan_already_dispatched this is the remaining cooldown; on lock_busy it 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_frozen

example:

"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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
scanIdpathyestype stringIdentifier 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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
userquerynotype string · format uuidScope the listing to one member's sessions.
statusquerynotype string · one of active, expired, all · default activeDefault 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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL 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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
sessionIdpathyestype string · format uuidUUID 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

parameters
nameinrequiredtypedescription
sessionIdpathyestype string · format uuidUUID 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

parameters
nameinrequiredtypedescription
tenantSlugpathyestype stringURL 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

parameters
nameinrequiredtypedescription
tenantSlugpathyestype stringURL 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 · default oidc

example:

"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 false

example:

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 false

example:

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

parameters
nameinrequiredtypedescription
tenantSlugpathyestype stringURL 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

parameters
nameinrequiredtypedescription
tenantSlugpathyestype stringURL 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

parameters
nameinrequiredtypedescription
tenantSlugpathyestype stringURL 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 · default oidc

example:

"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 false

example:

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 false

example:

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

parameters
nameinrequiredtypedescription
tenantSlugpathyestype stringURL 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

parameters
nameinrequiredtypedescription
tenantSlugpathyestype stringURL 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

parameters
nameinrequiredtypedescription
tenantSlugpathyestype stringURL 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, enterprise

example:

"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, tier and addonQty are 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, so addonQty is 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 scheduledCancelAt is 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 active with subscriptionLive true 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 replaces renewsAt rather 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

parameters
nameinrequiredtypedescription
tenantSlugpathyestype stringURL 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, unchanged

example:

"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

parameters
nameinrequiredtypedescription
tenantSlugpathyestype stringURL 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 — active or trialing, 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

parameters
nameinrequiredtypedescription
tenantSlugpathyestype stringURL 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

parameters
nameinrequiredtypedescription
tenantSlugpathyestype stringURL 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

see BillingEmailOutcome

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

parameters
nameinrequiredtypedescription
tenantSlugpathyestype stringURL 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

see BillingEmailOutcome

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

parameters
nameinrequiredtypedescription
tenantSlugpathyestype stringURL 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-admin

example:

"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-admin

example:

"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-admin

example:

"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

parameters
nameinrequiredtypedescription
tenantSlugpathyestype stringURL 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.
invitationIdpathyestype string · format uuidUUID 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

parameters
nameinrequiredtypedescription
tenantSlugpathyestype stringURL 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.
userIdpathyestype string · format uuidUUID 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-admin

example:

"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

parameters
nameinrequiredtypedescription
tenantSlugpathyestype stringURL 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.
userIdpathyestype string · format uuidUUID 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

parameters
nameinrequiredtypedescription
tenantSlugpathyestype stringURL 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.
userIdpathyestype string · format uuidUUID 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

parameters
nameinrequiredtypedescription
tenantSlugpathyestype stringURL 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.
workspaceIdpathyestype string · format uuidUUID 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

parameters
nameinrequiredtypedescription
tenantSlugpathyestype stringURL 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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL 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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL 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, generic

example:

"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_failed

example:

[
  "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_frozen

example:

"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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
webhookIdpathyestype string · format uuidUUID 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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL slug of the workspace. Unique among the caller's live workspaces; a slug the caller cannot see returns 404.
webhookIdpathyestype string · format uuidUUID 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 32

example:

"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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL 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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL 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 32

example:

"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

parameters
nameinrequiredtypedescription
workspaceSlugpathyestype stringURL 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, admin

example:

[
  "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 unauthorized and 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, unchanged

example:

"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

global for 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 None for 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 — 10 with 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 alongside minute. Null whenever periodUnit is 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 accountId names: Account on AWS, Subscription on Azure, Project on 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

global for 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 None for 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 — 10 with 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 alongside minute. Null whenever periodUnit is 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 accountId names: Account on AWS, Subscription on Azure, Project on 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 id which 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 since filter 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 completed scan 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:GetCallerIdentity in prod; the Floci emulator override in dev. Compare against the dispatched connected account's cloud_account_id to 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_failed

example:

"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, failed

example:

"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, failed

example:

"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"
}