Organization Management Org CRUD, members, billing, discovery
An organization (org) is a collector of workspaces. It can represent a company, a business unit, a team, or simply a personal collection. Orgs are the billable entity — storage, credits, and member limits are tracked at the org level. Every workspace and share lives under an org.
Profile IDs are 19-digit numeric strings. Most endpoints also accept the org's domain name (e.g., acme) in place of the numeric ID.
Internal vs External Orgs
Agents must call both GET /current/orgs/list/ and GET /current/orgs/list/external/ to discover all orgs they can access.
- Internal orgs (
member: true) — orgs you created or were invited to join as a member. You have org-level access: see all workspaces (subject to permissions), manage settings if admin, appear in the member list. Listed viaGET /current/orgs/list/. - External orgs (
member: false) — orgs you access only through workspace membership. A human invited you to their workspace but not to the org itself. You can see the org's name and basic public info, but cannot manage org settings, see other workspaces, or add org members. Listed viaGET /current/orgs/list/external/.
External orgs are the most common pattern when a human invites an agent to help with a specific project — they add the agent to a workspace but not to the org itself.
If the human later invites the agent to the org itself, it moves from external to internal and gains org-level access.
Org Field Constraints
| Field | Type | Min | Max | Regex / Rules | Default |
|---|---|---|---|---|---|
| domain | string | 2 | 63 | ^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$ Lowercase alphanumeric + hyphens. Must be unique. Must not be reserved. | Required |
| name | string | 3 | 100 | Free text display name | null |
| description | string | 10 | 1000 | Free text | null |
| industry | string | — | — | Must be one of the values from GET /current/orgs/industries/ | null |
| perm_member_manage | string | — | — | 'Member or above', 'Admin or above', 'Owner only' | 'Member or above' |
| perm_workspace_create | string | — | — | 'Member or above', 'Admin or above', 'Only Org Owners'. Enterprise plan only to tighten (raising the minimum role); lowering it or resubmitting it unchanged works on any plan. | 'Member or above' |
| workspace_create_allowlist | string (JSON list of user IDs, sent in this one field) | — | 500 | User IDs allowed to create workspaces regardless of perm_workspace_create. Every ID must be a current member of the org. Enterprise plan only to tighten (removing a current member from the list); adding users or resubmitting it unchanged works on any plan. Returned as an array. | [] |
| sharing_shares | boolean | — | — | Whether members may create shares (Send / Receive / Exchange, including shared folders). Enterprise plan only to tighten (switching it off). | true |
| sharing_file_links | boolean | — | — | Whether members may create single-file share links. Enterprise plan only to tighten (switching it off). | true |
| perm_authorized_domains | string | — | — | Email domain for auto-join (e.g., acme.com) | null |
| billing_email | string (email) | — | — | Valid email with reachable domain | User's email |
| accent_color | string (JSON) | — | — | JSON-encoded color object {"color":"#RRGGBB","opacity":0-100} (both keys required) | null |
| background_color | string (JSON) | — | — | JSON-encoded color object {"color":"#RRGGBB","opacity":0-100} (both keys required) | null |
| background_mode | string | — | — | One of the supported background display modes | null |
Member Roles and Permissions
| Role | Level | Can manage members | Can manage settings | Can manage billing | Can close org | Can transfer ownership |
|---|---|---|---|---|---|---|
| Owner | Highest | Yes | Yes | Yes | Yes | Yes |
| Admin | High | Yes (if perm_member_manage allows) | Yes | Yes | No | No |
| Member | Standard | If perm_member_manage = 'Member or above' | No | No | No | No |
| View | Lowest | No | No | No | No | No |
The perm_member_manage org setting controls the minimum role required to add, remove, or update members.
Organization Security Controls
Organizations on the Enterprise plan can restrict what their members may create. All four settings are read and written on the org (see Org Field Constraints above); they are returned to admins on GET /current/org/{org_id}/details/ and written with POST /current/org/{org_id}/update/.
| Setting | Effect |
|---|---|
perm_workspace_create | Minimum role required to create a workspace in the org. |
workspace_create_allowlist | Named users who may create a workspace regardless of the role threshold. The two are combined with OR. |
sharing_shares | When false, members may not create new shares (Send / Receive / Exchange, including shared folders). |
sharing_file_links | When false, members may not create new single-file share links. |
Both sharing settings also exist on each workspace. The effective answer is the org setting AND the workspace setting, so the org acts as a ceiling: a workspace can switch sharing off for itself, but cannot switch it back on when the org has switched it off.
Turning a sharing setting off blocks new creation only. Shares and links that already exist keep working, and remain editable and deletable.
Reading the effective answer. Do not infer these from the raw settings — a member cannot read them. Read capabilities on GET /current/org/{org_id}/details/ (for workspace creation) and on GET /current/workspace/{workspace_id}/details/ (for sharing), which already combine the role, the plan, and both policy levels for the calling user.
Refusals. A create request that policy forbids returns HTTP 403 with a reason in params: policy_workspace_create_denied or policy_sharing_disabled. These are distinct from a plan-limit refusal, which keeps reporting its own error.
The Enterprise gate is directional. Only a write that tightens one of these four settings needs the Enterprise plan: switching a sharing setting off, raising the role required to create a workspace, or taking a user off the allowlist. Such a write from an organization without the plan returns HTTP 403 with params.reason = plan_required. Resubmitting a setting at the value it already holds, or relaxing one — switching sharing back on, lowering the role, adding a user to the allowlist — returns 200 on any plan, so an organization that changes plan can always unwind what it configured. Reading is never gated.
Audit. A write that changes one of these four settings adds a policy_changes map to the org_updated event: policy_changes.<key> = { before, after } for perm_workspace_create, sharing_shares and sharing_file_links (before is the value that was in force, so a setting never configured reports its default), and policy_changes.workspace_create_allowlist = { added, removed } — counts, not user IDs, because the event is visible to every member while the list itself is admin-only. policy_changes is absent when no setting changed.
Organizations that have never configured these settings behave exactly as they did before they existed: workspace creation is open to members and above, and both sharing settings are on.
Collaboration Policies (External Invites)
Organizations on the Enterprise plan can restrict which members may bring outside people onto Portals, Shared Folders, File Shares and Workspaces. Three org-level settings share one policy envelope shape:
| Setting | Governs |
|---|---|
external_invites_shares | Shared Folders, and File Share grants. |
external_invites_portals | Portals. |
external_invites_workspaces | Workspaces. |
The envelope. Each setting is a JSON object with two role baselines and an optional per-member exception list:
{ "admin": "allowed", "member": "denied", "overrides": { "9876543210987654321": "allowed" } }
adminandmemberare each"allowed"or"denied", applying to owners/admins and to ordinary members respectively (an owner reads theadminbaseline).overridesmaps a user ID to"allowed"or"denied", naming an exception to that user's role baseline. Every key must be a current member of the org — a workspace or share participant who is not an org member always reads thememberbaseline instead and can never be named here. Maximum 100 overrides per policy.- Unconfigured is permissive. An org that has never set one of these three keys behaves exactly as it did before the policy existed — nobody is restricted.
- An unreadable stored value is the one exception to "permissive is the default." It resolves to
"denied"for every caller until an admin resaves it, and it is echoed back as the raw stored string (not an object), so a client can detect and repair it rather than silently showing it as unset.
Reading and writing.
- Write:
POST /current/org/{org_id}/update/— send the whole envelope as a JSON string in the field named after the setting (see Update Organization below). Sending""or"null"clears the policy back to unconfigured; the server also accepts a literalnull. An omitted field leaves the stored value unchanged. Every write replaces the whole value — sendingoverridesas{}clears every existing exception while leaving both baselines untouched; there is no partial merge of overrides. - Read:
GET /current/org/{org_id}/details/echoes all three envelopes raw, admin-only — the stored setting the shared policy editor round-trips. - Effective answer:
capabilities.external_invites_shares/_portals/_workspaceson the same response report the calling user's own resolved answer (their role, or their override) as booleans. These are member-visible, and a client should read them to decide whether to show an invite control rather than recomputing the effective answer from the raw envelope.
Enforcement. The org policy is the first half of the answer; each Portal, Shared Folder and Workspace also carries its own external_invites flag (allowed / denied, absent = inherit the org policy — see the Shares and Workspaces references); a File Share has no flag of its own and follows the org policy only. An object can only ever tighten below the org result, never loosen it. Refusals are HTTP 403 with a params.reason distinguishing which layer denied, so the client can route the user to the right admin:
| Reason | Meaning |
|---|---|
external_invites_denied | The org's policy denies this inviter. |
external_invites_object_denied | The object's own external_invites flag denies it — the remedy is that object's admin, not the org admin. |
plan_required | A write tightening one of the three org settings, or credential_policy, was sent by an org without the Enterprise plan (same directional gate as Organization Security Controls above). |
The check looks only at whether the invitee is external — a current org member, or someone on one of this org's verified SSO domains, is never refused regardless of the policy. It applies wherever external access is created or widened: sending or resending a share, portal, workspace or File Share invitation; adding an outside user directly; accepting a pending invitation (a refusal at acceptance leaves the invitation pending rather than failing it, since the policy or the inviter's own standing can change between issuance and acceptance); and creating a Portal, Shared Folder or File Share with public access, or widening an existing one's public-access setting, including 'Anyone with a registered account' to 'Anyone with the link'. Inviting a member to the org itself is never gated — an org invite is definitionally of a non-member.
POST /current/user/invitations/acceptall/ is partial-success under this policy: a policy-refused invitation is skipped (left pending, no access granted) while every other pending invitation in the batch is still processed — see that endpoint in the Auth reference for the response shape.
Audit. org_updated carries the three keys inside its existing policy_changes map: policy_changes.<key> = { before: {admin, member}, after: {admin, member}, overrides: {added, removed, changed} }. The override numbers are counts, not user IDs — policy_changes renders for every member, while the exception list itself stays admin-only.
Credential Policy
Organizations on the Enterprise plan can cap what an API key or an OAuth grant issued inside the org may hold, and constrain it on every later request, not only at the moment it is issued. One org-level setting, credential_policy, governs two credential families independently:
| Family | Governs |
|---|---|
api_keys | Every API key issued by a member of this org. |
oauth | Every OAuth authorization granted by a member of this org. |
The envelope, twice. credential_policy wraps one ordinary policy envelope (see Collaboration Policies above for the {admin, member, overrides} shape) per family — but here each role's value is itself an object, not a bare string:
{
"api_keys": {
"admin": { "max_mode": "rwa", "scope_types": ["org", "workspace", "share", "fileshare"] },
"member": { "max_mode": "rw", "scope_types": ["workspace", "share"] },
"overrides": { "9876543210987654321": { "max_mode": "r", "scope_types": ["share"] } }
},
"oauth": {
"admin": { "max_mode": "rwa", "scope_types": ["org", "workspace", "share", "fileshare"] },
"member": { "max_mode": "rw", "scope_types": ["workspace", "share"] },
"overrides": {}
}
}
- Each role's value is
{"max_mode": "r"|"rw"|"rwa", "scope_types": [...]}. max_modeis a ceiling: the highest access mode a credential in that family may hold for any one entity governed by this org. It reads the opposite way from an ordinary scope check — a credential is refused here for holding too much, not too little.scope_typesis the subset of entity types a credential in that family may hold a grant for against this org —org,workspace,share,fileshare. (The retiredworkflowtype and thesign_envelopetype are not selectable here. Asign_envelopegrant is never refused byscope_types, but it is still bounded bymax_mode.) Account-only authority (user:*,memory:*,userdetails:*) is never refused byscope_types— an account-wide key is used against every org its holder can reach, so refusing it by type here would disable it everywhere else — but it is still bounded bymax_mode, evaluated locally against whichever org a given request actually touches.overridesmaps a user ID to its own{max_mode, scope_types}value, naming an exception to that user's role baseline. Every key must be a current member of the org. Maximum 100 overrides per family.- Unconfigured is permissive — an org that has never set
credential_policy, or has cleared a family, behaves exactly as it did before the policy existed: every mode, every attributable type. - An unreadable stored value is the one exception to "permissive is the default." It resolves to the restrictive pole — every credential in that family refused — until an admin resaves it, and it is echoed back as the raw stored string so a client can detect and repair it.
Reading and writing.
- Write:
POST /current/org/{org_id}/update/— send the wholecredential_policyobject as a JSON string in thecredential_policyfield (see Update Organization below). An omitted family is left unchanged; a family set tonullis cleared back to unconfigured. Enterprise plan only to tighten — a write that lowers amax_modeor removes ascope_typesentry, for either family, for anyone (admin,member, or an override), is refused with HTTP 403 andparams.reason=plan_requiredon an org without the plan. - Read:
GET /current/org/{org_id}/details/echoescredential_policyraw, admin-only, pluscapabilities.credential_policy_api_keysandcapabilities.credential_policy_oauth— the calling user's own effective{max_mode, scope_types}for each family, so a key-creation form can pre-filter its scope and mode pickers without recomputing the resolution itself.
Enforcement.
- At issuance — creating or updating an API key (see API Keys in the Auth reference), and at OAuth consent (see the OAuth 2.0 reference), the requested (or, on an update, the effective) scopes are checked against the policy of each grant's own owning org.
- At every later request — unlike the org security controls above, which are read-time or create-time only,
credential_policyis re-checked on every authenticated request an API key or OAuth token makes that resolves to a governing org. Tightening the policy can make an already-issued, already-working credential start failing on its very next call, with no revocation event and no grace period.
Refusals are 403 with params.reason:
| Reason | Meaning |
|---|---|
credential_policy_mode | The mode the credential holds for this entity exceeds the org's max_mode. This inverts the ordinary scope error, where the held mode is too low. |
credential_policy_scope | The credential names an entity type this family's scope_types does not allow. |
credential_policy_sso | The credential owner's account is on this org's SSO-enforcing domain and is not exempt — see Credential-request enforcement in the SSO reference. |
- A signed-in browser session is never subject to
credential_policy— it carries noscopesclaim, exactly as it is exempt from the ordinary scope checks in the Auth reference. - A public File Share single-file link carries no API key and no account session; it is not an org-governed credential and is never checked, at issuance or at request time.
- Three collection endpoints that list an account's own orgs and shares in bulk (
orgs/list,orgs/all,shares/all) resolve no single governing org per row and are permanently outside the request-time check — a stated limit, not an oversight. - A lookup failure — the policy could not be read, or the owning org could not be resolved — is
503(temporarily unavailable), never a silent pass and never a401. - Only the authority actually used for a request is checked — the concrete or wildcard grant that satisfied that request's entity, never the credential's whole scope set and never an inherited parent grant. A key holding
org:A:rwaandorg:B:ris checked against org A's policy only while it is acting in org A.
Audit. org_updated carries credential_policy inside its existing policy_changes map, one entry per family: policy_changes.credential_policy = { api_keys: {before, after, overrides: {added, removed, changed}}, oauth: {before, after, overrides: {added, removed, changed}} }.
Cloud Sync Policy
Organizations on the Enterprise plan can restrict whether cloud-sync import runs at all, and whether it may write local changes back to the connected provider. One org-level setting, cloud_sync, is a policy envelope (see Collaboration Policies above for the {admin, member, overrides} frame) whose value is:
{ "enabled": true, "mode": "read_write" }
enabled(bool) — whether cloud sync runs at all for the caller this value resolves to.falsestops sync in both directions; a source parks atstatus: "suspended_policy"and resumes on its own once the policy re-enables it.mode("read"or"read_write") — whether local edits are pushed back to the provider."read"stops only the outbound half; inbound sync continues. Amode-only flip changes no source status.- Unconfigured is permissive —
{enabled: true, mode: "read_write"}. An unreadable stored value is the one exception: it resolves to the restrictive pole (enabled: false) for every caller until an admin resaves it, echoed back raw so a client can detect and repair it. - This is the org half only. The effective answer for a workspace is the org value resolved first, then met with that workspace's own
cloud_sync_modeceiling — see Cloud Sync Policy in the Workspaces reference for the full resolution order, the per-sourceeffective_access_modefields, and how a queued write-back behaves under areadpolicy (deferred, not failed, with a bounded ~5-day hold).
Reading and writing.
- Write:
POST /current/org/{org_id}/update/— send the whole envelope as a JSON string in thecloud_syncfield (see Update Organization below). Sending""/"null"/nullclears the policy back to unconfigured. An omitted field leaves the stored value unchanged. - Read:
GET /current/org/{org_id}/details/echoescloud_syncraw, admin-only. There is no org-level effective-answer field: the effective answer is the workspace'seffective_cloud_sync, because cloud sync is a per-workspace feature and the org value alone cannot say whether a given workspace's own setting narrows it further.
Refusals are 403 with params.reason:
| Reason | Raised at |
|---|---|
cloud_sync_disabled | Identity provision, source create, each provider's OAuth-complete endpoint, and the manual write-back actions (push-writeback, retry-writeback, a keep_local resolve-conflict), while enabled is false somewhere in the org-then-workspace chain. |
cloud_sync_read_only | A manual write-back action (push-writeback, retry-writeback, a keep_local resolve-conflict) while the chain meets at mode: "read". |
mode never gates opening a new connection or inspecting/disconnecting an existing one — only enabled does, and only write-back actions consult mode. When the policy cannot be read at all, the same endpoints answer 1693 (Temporarily Unavailable) → 503 instead — retryable, never a denial.
Audit. org_updated carries cloud_sync inside its existing policy_changes map: policy_changes.cloud_sync = { before: {admin, member}, after: {admin, member}, overrides: {added, removed, changed} } — each baseline is its {enabled, mode} value (null when unconfigured), and overrides are only counted, never named.
Require-2FA Policy
Organizations on the Enterprise plan can require a second factor to sign in. One org-level setting, auth_require_2fa, is a policy envelope (see Collaboration Policies above for the {admin, member, overrides} frame) whose per-role value is one of two words:
{ "admin": "required", "member": "optional", "overrides": { "9876543210987654321": "required" } }
adminandmemberare each"required"or"optional", applying to owners/admins and to ordinary members respectively (an owner reads theadminbaseline).overridesmaps a user ID to"required"or"optional", naming an exception to that user's role baseline. Every key must be a current member of the org. Maximum 100 overrides.- Unconfigured is permissive — an org that has never set this key behaves exactly as it did before the policy existed:
optionalfor everyone. - An unreadable stored value is the one exception to “permissive is the default.” It resolves to the restrictive pole,
"required", for every caller until an admin resaves it, and it is echoed back as the raw stored string so a client can detect and repair it.
Scope — password and social login only. This policy governs the moment a session is minted by those two flows, because they are the only ones where a factor can be demanded at that moment. It has no effect on API keys, OAuth grants, or MCP tokens — none of them is challenged for a factor at request time, regardless of what this policy says. An SSO-minted session is compliant regardless of this or any other org's requirement — the identity provider owns that factor, and the enterprise SSO exchange response never carries a 2factor or enrol_required field. See Interactive Login & Enrolment in the Auth reference for the enrolment flow this policy drives.
Reading and writing.
- Write:
POST /current/org/{org_id}/update/— send the whole envelope as a JSON string in theauth_require_2fafield (see Update Organization below). Sending""or"null"clears the policy back to unconfigured; the server also accepts a literalnull. An omitted field leaves the stored value unchanged. Enterprise plan only to tighten — a write movingoptional/unconfigured torequired, foradmin,member, or an override. - Read:
GET /current/org/{org_id}/details/echoesauth_require_2faraw, admin-only — the stored setting the shared policy editor round-trips. There is nocapabilitiestwin. Every other envelope policy on this page also reports the calling user's own resolved answer undercapabilities; this one does not, because the effective answer is about a person signing in, not a place being viewed, and it is already delivered where it is actionable —enrol_requiredon the login response — rather than by walking every org the reader belongs to on an ordinary org read.
Refusal on write — HTTP 403 with params.reason = two_factor_admin_unenrolled: the endpoint refuses ONLY when the writer's own effective requirement goes optional/unconfigured → required and the writer holds no factor themselves (self-lockout). Tightening the member baseline, or setting an override for someone else, always succeeds even from an unenrolled admin — there is no blanket “enrol before you may require it” rule, and no SSO escape hatch; enrolling is the only remedy for the writer's own lockout.
Two transient refusals, and on both of them NOTHING WAS SAVED. Beside the 403 above, a write of this key can answer 503 twice over, and both are retryable:
- "This policy could not be checked against your own account. Please try again." — the self-lockout test could not be evaluated, so the endpoint established neither a refusal nor a pass and declined to guess.
- "This policy change could not be scheduled. Please try again." — the write tightened the policy, but the session revocation that tightening requires could not be scheduled. The write is then deliberately abandoned: the policy is NOT stored. A tightening that committed with no revocation behind it would leave every newly-required user holding the session they already had, with nothing scheduled to end it and nothing saying so — so the endpoint refuses rather than storing half the change. Do not treat this as “probably applied, refresh later”; re-send the same request.
Side effects of flipping this policy on. The sessions of users who become required and hold no factor are revoked, but asynchronously — there is a window between the policy write returning success and the sweep reaching that user's sessions. Two populations are deliberately left alone by this cut, and are instead challenged at their next login rather than swept immediately:
- A user who is already enrolled is untouched — enrollment already satisfies the new requirement, so revoking them would cost a re-login with no purchase. This also means a session minted before its holder enrolled survives the flip; there is no claim on the token itself that distinguishes it.
- A user who becomes
requiredby joining the org, or by being promoted into a role whose baseline requires it, is not swept — neither is a policy write, so there is nothing for the sweep to hang off; they are challenged at their next login instead.
Removing an optional override is NOT in that group — it IS swept. Dropping an exception that leaves the user under a required baseline is a direct tightening write, and it queues a sweep targeted at exactly that one user rather than the whole org. The rule is: a write that tightens a baseline sweeps org-wide; a write that newly requires a factor of exactly one named user sweeps only them; anything ambiguous falls back to the org-wide sweep.
Audit. org_updated carries auth_require_2fa inside its existing policy_changes map: policy_changes.auth_require_2fa = { before: {admin, member}, after: {admin, member}, overrides: {added, removed, changed} } — the same shape as the collaboration policies above.
Access Policy (Geo / IP Restrictions)
Enterprise plan. An org can restrict which countries and/or IP ranges may reach its content at all — every workspace, share, upload, and read, across every credential type (browser session, API key, OAuth, MCP). Read and write it through the access_policy field on GET /current/org/{org_id}/details/ (admin-only, raw) and POST /current/org/{org_id}/update/ — see the field description under Update Organization above.
Value shape, evaluated separately per role:
{"admin": {"countries": {"mode": "allow", "codes": ["US", "CA"]}, "ips": ["203.0.113.0/24"]},
"member": {"countries": {"mode": "allow", "codes": ["US"]}, "ips": null},
"overrides": {"9876543210987654321": {"countries": null, "ips": null}}}
- Each of
adminandmember(and each entry inoverrides, keyed by a current member's user id, at most 100 entries) is{"countries": null | {"mode": "allow"|"deny", "codes": [...]}, "ips": null | [cidr, ...]}. countries.codes— 1 to 250 entries, any 2-letter code (includingXK), plus the special codesXX(unknown location) andT1(Tor). The server upper-cases and dedupes them.ips— 1 to 100 IPv4/IPv6 addresses or CIDR ranges. A bare address means/32(IPv4) or/128(IPv6). Any prefix length is accepted except/0, which is refused — clear the rule instead of writing it as “unrestricted”. The echo is always canonical CIDR (host bits masked).{"countries": null, "ips": null}(or the whole field""/"null") means unrestricted.- The whole value is replaced on every write — send the complete envelope; overrides are not merged with a prior write.
Who is evaluated: owners and admins read the admin value; members and guests (non-org participants) read member; a per-user entry in overrides wins over either baseline. API keys, OAuth grants and MCP tokens resolve as the user they belong to. A storage download token or preview link resolves as the user who requested it (an older token predating this field reads the member baseline).
Evaluation, for the caller's IP and country:
- Pass if
ipsis set and the IP falls inside any listed range — this bypasses the country rule entirely. - Otherwise pass if
countriesis set and the country passes it: inallowmode the country (orXX/T1) must be listed; indenymode any country not listed passes, and an unknown/Tor caller passes unlessXX/T1is explicitly listed. - Otherwise pass if both
countriesandipsarenull(unrestricted). - Otherwise blocked. An
ips-only rule therefore blocks every IP not on the list, regardless of country. A request with no resolvable IP is always blocked by any restricting rule.
Tightening gate. A write that tightens access_policy — the new value blocks some IP or country the old value allowed — needs the Enterprise plan and is refused with 403 plan_required on a lower plan. Relaxing is always allowed, so a downgraded org can still undo its own restriction.
Self-lockout guard. A write that would take the caller's own current IP/country from pass to block is refused with 403 access_policy_self_lockout (params.ip / params.country). There is no override flag — add your own IP, or an override naming yourself, in the same write. If the result cannot be resolved, the write is refused with 503 access_policy_unavailable (retryable; nothing was saved).
Enforcement. Every org-content request from a blocked caller — any credential type — is refused with 403 geo_restricted (never 401, so a client must never treat this as a sign-out): params: {reason: "geo_restricted", rule: "country"|"ip", org_id, domain} (rule is "ip" for an IP-only rule, or for a missing/unresolvable client IP). The restriction list itself is never echoed to a blocked caller. Suggested copy: “Access to this organization is not permitted from your current location or network.” This includes upload endpoints for an org-governed target — create, chunk, stream, complete, and both upload/{id}/details and web_upload/{id}/details — which refuse a blocked caller with geo_restricted, never a 404; a 404 from a details read still means the session itself is gone, not that it was hidden by this policy. If an upload session's own target cannot itself be read to determine the policy, the call fails 503 access_policy_unavailable (retryable) rather than passing through unchecked. websocket/auth and activity/poll for an org-owned profile (see Activity Polling / WebSocket in the Events reference) return this same structured geo_restricted refusal ahead of any generic invalid-input error, and an MCP-classified request reaching any of these gets 403 mcp_access_denied instead — see AI, Intelligence & MCP Access Policy below. A missing owning org (rare) reports 403 access_policy_org; an unreadable stored policy or membership reports 503 access_policy_unavailable (retryable) — except that an unreadable stored access_policy resolves to blocked for everyone but the owner, so a corrupted policy fails closed rather than open.
Exemptions. Anonymous public access, and a signed-in user reading a share or file link whose access is set to “Anyone with the link” (public means public), are not geo-checked. Anything on that same share that needs actual membership — a write, or any member-only action — is checked. Internal pipeline and platform-agent (Ripley) tokens are also exempt.
Owner break-glass. The org owner always reaches GET org/{org_id}/details/ and POST org/{org_id}/update/, even from a blocked location — otherwise a misconfigured policy could lock an owner out of fixing it. Every other org call the owner makes is geo-checked the same as anyone else; a client can infer “recovery mode” when other org calls return geo_restricted while details keeps succeeding. An event is recorded only when that access actually bypassed a block, and is rate-limited.
Cross-org lists. A row belonging to an org that blocks the caller — by this policy, or by the MCP access policy on an MCP request (see below) — is dropped before paging from orgs/list (including the org's own row), orgs/all, orgs/list/external, workspace and share list/available endpoints, the caller's own share list (GET user/me/list/shares/ — see the Shares reference), upload lists, and events/search without a workspace_id filter. Most of these are dropped before paging so pages and totals stay exact; the one exception is the web_upload list, which is paged in SQL, so a page can come back short of limit while total still counts every row. user/available_profiles is not filtered — it reports booleans about the caller's own relationships, with no org rows to drop.
Known limits. An already-open WebSocket connection is not forcibly closed when a policy changes — it is re-checked the next time its token is minted (on reconnect). A storage download token issued before this feature shipped is checked against the member rule for the remainder of its (short) life. Access through the Fastio MCP is evaluated at the MCP worker's own network egress, which does not necessarily reflect the end user's real location.
AI, Intelligence & MCP Access Policy
Enterprise plan. Alongside access_policy above, an org can independently gate five AI/MCP surfaces and one workspace allowlist, all written through POST /current/org/{org_id}/update/ — see the ai_agent / ai_intelligence / ai_metadata / ai_summaries / mcp_access / ai_workspaces field descriptions under Update Organization above, and the raw/capabilities echo fields under Get Org Details. Unconfigured means allowed for every one of these keys.
| Field | Governs |
|---|---|
ai_agent | Ripley Agent chat (create / send / publish / rename), AI share generation, share auto-title and AI OG image, events summarize, dashboard AI |
ai_intelligence | Turning a workspace's/share's intelligence on, the meaning-based (semantic) search leg, background indexing, and Ripley Agent retrieving from a workspace's index |
ai_metadata | Metadata extraction (single-file, per-folder, compound search) and automatic extraction on ingest |
ai_summaries | Per-file AI summaries at ingest — background only, never an interactive refusal. Denying it also stops new files from being indexed, since indexing needs a file's summary first; existing index data is untouched |
mcp_access | Any request classified as coming from the Fastio MCP |
ai_workspaces | An allowlist narrowing which workspaces Intelligence/metadata background processing (and interactive Intelligence/metadata) applies to; null = every workspace, [] = none |
- Each of
ai_agent/ai_intelligence/ai_metadata/ai_summaries/mcp_accessis the same envelope shape as the other policy families above:{"admin":"allowed"|"denied","member":..., "overrides":{}}.ai_summarieshas no per-user override controls in the editor (background-only). - Tightening —
allowed→denied, adding adeniedoverride, or narrowingai_workspaces— needs the Enterprise plan (403plan_required). Relaxing is always allowed. - Background vs. interactive. Background processing — indexing, automatic metadata extraction on ingest, and per-file summaries — is paused only when the feature is
deniedfor both theadminandmemberbaselines, or when the workspace is off theai_workspaceslist. A single-baseline denial or a per-userdeniedoverride only refuses that caller's interactive use (so forai_summaries, which has no interactive surface, it has no effect). - Turning a feature off does not delete anything. Existing index data, summaries and metadata stay stored and readable, and existing Ripley Agent threads stay readable (list/details/messages). Re-allowing catches up files added or changed during that specific pause, per workspace, from that workspace's own pause window — the catch-up is retried automatically until it completes. A workspace that is merely suspended or locked while paused keeps its owed catch-up rather than losing it, and receives it once the workspace is re-enabled and the policy allows it again; only a closed or deleted workspace's owed catch-up is dropped. Metadata is only re-extracted for files whose extraction the pause actually withheld (a copy or move made during the pause is not re-extracted); a file whose preview was already billed is not billed again. A plan that always generates summaries still catches those up even in a workspace with Intelligence off. While a workspace's indexing is paused, or while its summaries are paused, the background reconciler does not re-ingest its paused files — both must be allowed to run in the background before the reconciler acts on that workspace again. A templated
extract-allsweep or a queued template extraction stops rather than running — its progress reportsstop_reason: "paused_policy"(see Jobs Status (Unified Async Processing) in the AI reference) — and picks back up once the metadata policy allows it again. A sweep that briefly cannot read the org's policy partway through pauses at that point and resumes on its own retry, rather than failing outright; if the policy still can't be confirmed, the sweep stops withstop_reason: "policy_unreadable"— resubmit once the policy is readable again. - The org is a ceiling. These policies never rewrite a workspace's own
intelligence/metadata_extractionswitch; they only gate whether turning either on, or using it, succeeds.
Refusals (branch on params.reason, never on error.code or HTTP status alone):
| Reason | HTTP | Meaning |
|---|---|---|
ai_policy_denied | 403 | params.feature = agent|intelligence|metadata — the caller's resolved policy denies that feature. |
ai_policy_workspace_not_allowed | 403 | params.feature, params.workspace_id — the feature is allowed, but this workspace is not on the ai_workspaces allowlist. |
mcp_access_denied | 403 | params.org — the request was classified as MCP and this org denies MCP access. Cross-org lists drop the org's rows the same way for an MCP request. Account-level (user/*) endpoints are never blocked. |
See the AI reference for exactly which endpoints return these (chat create/send/publish/rename, AI share, share auto-title/OG, metadata extraction, and the search endpoints' semantic leg), and Workspaces (ai_policy_state, can_use_ai_agent) for how a workspace's own details surface the effective, policy-aware answer per feature.
Compact Responses (output=)
Every endpoint that returns one or more org objects (details, list, discovery) accepts an optional output query parameter that selects the response shape. A single detail-level token may be combined with modifier tokens; specifying two detail levels (e.g. ?output=terse,standard) returns HTTP 406. When output= is omitted, responses are full and byte-for-byte unchanged.
| Level | Fields returned on each org (cumulative) |
|---|---|
terse | id, domain, name, logo |
standard | terse + description, plan, user_permission (member-only), user_status, member, closed (member-only), locked (member-only), suspended (member-only), created (member-only), updated (member-only), parent (member-only), capabilities, accent_color, background_mode, background, use_background, background_color, homepage, subscriber (member-only), subscriber_cancel (admin-only), subscriber_trial_until (member-only), payment_state (member-only), payment_failed_at (member-only), access_ends_at (member-only) |
full | standard + subscriber_trial_credits, billing_email, social links (facebook, instagram, twitter, youtube), encryption_key, perm_* blocks (including perm_auth_domains, perm_member_manage, perm_workspace_create), workspace_create_allowlist, sharing_shares, sharing_file_links, external_invites_shares/_portals/_workspaces (admin-only), dmca, owner_defined, platform, storage |
Use terse for org switchers and billing-entity pickers — it includes the ID, URL domain, display name, and logo so the org-switcher sidebar can render entries without falling back to initials. Use standard for org list views, most member-facing dashboards, and branding-aware surfaces — it adds plan, description, lifecycle flags (including the locked and suspended lifecycle/billing chips, emitted to members only), the caller's permission and status, timestamps, hierarchy pointer, plan-gated capabilities, the visual-identity bundle (accent color, background, homepage), and the subscription state fields (subscriber, subscriber_cancel, subscriber_trial_until, payment_state, payment_failed_at, access_ends_at) that list-view subscription chips render. Note that subscriber reports entitlement, not payment health — it stays true while a payment is failing — so read payment_state when you need to know whether billing is actually current. Use full (or omit the parameter) for the org settings screen, billing portal, auth-domain configuration, and any workflow that reads remaining trial credit, permission blocks, social links, or encryption metadata. Unknown tokens are silently ignored. Add the markdown modifier (e.g. ?output=standard,markdown) to receive the response as GitHub-flavored Markdown (Content-Type: text/markdown; charset=UTF-8) instead of JSON — see the cross-cutting ?output= reference for the full contract.
The capabilities object (member-visible) reports plan-gated features the org currently has access to, as booleans. It includes capabilities.signing — whether the org's plan and feature flags currently grant the e-signature surface (e-signature is enabled on every plan, so this is normally true). Read it to decide whether to surface signing UI rather than inferring availability from a denied request.
On GET /current/org/{org_id}/details/ only, capabilities additionally carries can_create_workspace (the effective answer for the calling user, combining their role, the plan, and the org's workspace-create policy), sso, org_controls, and external_invites_shares / external_invites_portals / external_invites_workspaces (see Collaboration Policies). Those are deliberately absent from org list responses, where the answer is not caller-specific enough to be useful; request the org's details when you need them.
Organization CRUD
Create Organization
/current/org/create/
Auth required. Creates a new organization. The authenticated user becomes the owner.
Request parameters
| Name | Type | Required | Description |
|---|---|---|---|
| domain | string | Yes | 2-63 chars, lowercase alphanumeric + hyphens, must be unique and not reserved. Used as the org identifier in URLs. |
| name | string | No | 3-100 chars. Display name for the org. |
| description | string | No | Organization description. |
| industry | string | No | Industry type from predefined list (see GET /current/orgs/industries/). |
| accent_color | string (JSON) | No | Brand accent color as a JSON color object, {"color":"#RRGGBB","opacity":0-100}. |
| background_color | string (JSON) | No | Background color as a JSON color object, {"color":"#RRGGBB","opacity":0-100}. |
| background_mode | string | No | Background display mode. |
| facebook_url | string (URL) | No | Facebook page URL. Must be valid URL. |
| twitter_url | string (URL) | No | Twitter profile URL. Must be valid URL. |
| instagram_url | string (URL) | No | Instagram profile URL. Must be valid URL. |
| youtube_url | string (URL) | No | YouTube channel URL. Must be valid URL. |
| homepage_url | string (URL) | No | Organization website URL. Must be valid URL. |
| perm_member_manage | string | No | Who can manage members. See Org Field Constraints above. |
| perm_authorized_domains | string | No | Authorized email domain for auto-join. |
| billing_email | string (email) | No | Billing contact email. Defaults to user's email. |
A newly created organization must select a paid plan (Starter, Business, or Enterprise) before it can be used; until then it is in an upgrade-only state — the same state as an org that has exhausted its credits. This applies to agent and human accounts alike: agent accounts are ordinary accounts tagged account_type=agent and follow the same paid-plan flow. The legacy free plan is no longer available for new organizations.
New orgs on a monthly plan begin a free trial of up to 30 days, or until the included trial credits are used. Annual plans have no trial — an annual subscription is billed for the full term at signup. A trial also carries a credit allowance (trial_credit_limit), which differs by plan — read each plan's own trial_credit_limit. Reaching it ends the trial early and starts the subscription — so the trial is bounded by usage as well as by time, and a customer who consumes their allowance in three days is billed on day three. The exception is a trial with a cancellation scheduled: it never converts and is never charged; usage is held at the trial's credit allowance instead. Show both limits at checkout. A trial is only available on a user's first organization — if the user owns, or has ever owned, any other organization (including one they later closed), no trial is offered on a later org, no matter how much time has passed. A user is also only ever offered one free trial, ever — once a user has started a free trial on any organization, no later organization is offered another one, no matter how much time has passed. When blocked by either rule, a new org subscribes with immediate payment instead of a trial.
Read the trial length from the plan itself rather than assuming a fixed number of days: each plan reports free_days in its pricing, and free_days: 0 means the plan has no trial and payment is due at checkout.
curl example
curl -X POST "https://api.fast.io/current/org/create/" \
-H "Authorization: Bearer {jwt_token}" \
-d "domain=acme-corp" \
-d "name=Acme Corporation" \
-d "industry=technology"
Response (200 OK) — trial available
{
"result": true,
"org": {
"id": "1234567890123456789",
"domain": "acme-corp",
"name": "Acme Corporation",
"description": null,
"logo": null,
"accent_color": null,
"closed": false,
"suspended": false
},
"has_free_trial": true,
"requires_payment": true,
"is_agent": false
}
When the owner already owns, or has ever owned, another organization, no trial is offered — the org still subscribes but bills immediately. This block is permanent:
{
"result": true,
"org": { "...": "..." },
"has_free_trial": false,
"requires_payment": true,
"is_agent": false,
"no_trial_reason": "Free trials are only available on your first organization."
}
A trial is also blocked once the owner has ever started a free trial on any organization — this is also permanent, no matter how much time has passed:
{
"result": true,
"org": { "...": "..." },
"has_free_trial": false,
"requires_payment": true,
"is_agent": false,
"no_trial_reason": "A free trial has already been used on this account."
}
Both blocks are permanent, so trial_available_at is never returned in any case.
requires_payment is always true for new orgs (there is no free plan). Until a paid plan is selected the org is in an upgrade-only state (gated endpoints return 402). This applies to all accounts, including agent accounts (is_agent: true).
Response fields
| Field | Type | Description |
|---|---|---|
| org | object | Organization resource object |
| org.id | string | 19-digit numeric organization ID |
| org.domain | string | URL-safe subdomain |
| org.name | string/null | Display name |
| org.description | string/null | Description |
| org.logo | string/null | Logo asset URL |
| org.accent_color | object/null | Brand color ({color, opacity}) |
| org.closed | boolean | Whether org is closed |
| org.suspended | boolean | Whether org is suspended |
| has_free_trial | boolean | Whether a trial is available at checkout. true only when this is the owner's first organization and the owner has never started a free trial before; false for any org after the first, or once the owner has ever started a free trial (both permanent). |
| requires_payment | boolean | Whether a paid plan is required before the org can be used. true for new orgs. |
| is_agent | boolean | Whether the creating user is an agent account |
| no_trial_reason | string | Human-readable reason a trial is unavailable (only when has_free_trial is false) |
Error responses
Reading the error tables: the four-digit 16xx/17xx values below are HTTP-status classes, not error.code. The error.code a client actually receives is assigned per endpoint, so use the HTTP status as the gate and a documented error.code — five or six digits, plus the 9661-9669 family — only as a refinement. A 16xx value identifies the status class — useful for telling which kind of failure occurred — but comparing one against error.code will never match. Five- and six-digit codes (and the 9661-9669 family) are real error.code values. If you widen a check from a specific code to a status, widen what you assert with it — a status covers failures the narrower code did not, so a message written for that one code becomes a confident falsehood on the rest.
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | "An invalid org domain was supplied." | Invalid domain format |
1605 (Invalid Input) | 406 | "The supplied org domain name is restricted." | Domain is reserved |
1605 (Invalid Input) | 406 | "The supplied org domain name is already in use." | Domain already taken |
1605 (Invalid Input) | 406 | "An invalid configuration was supplied..." | Metadata validation failed |
1605 (Invalid Input) | 406 | "Invalid JSON provided for {key}." | Malformed JSON in color fields |
1663 (Update Failed) | 500 | "There was an internal error processing your create request." | Org creation failed |
1654 (Internal Error) | 500 | "There was an internal error processing your create request." | Internal error during creation |
1654 (Internal Error) | 500 | "We were unable to create your organization..." | Internal error |
1697 (Geo Restricted) | 452 | Geo restriction message | Request blocked by the geo check |
1680 (Access Denied) | 401 | "Access restricted due to security concerns." | Request blocked by the risk check (no geo block) |
Get Org Details
/current/org/{org_id}/details/
Auth required. Returns full org details. Fields vary by the requesting user's permission level.
{org_id} accepts a 19-digit numeric ID or the org's domain name.
Access levels
| Role | Access | Notes |
|---|---|---|
| Owner | Full access | Full access to all organization settings and security configuration |
| Admin | Extended access | Includes billing info, permissions, subscriber status, credit balance |
| Member | Standard access | Basic org info, plan, subscriber status (boolean only — no credit balance) |
| View | Limited access | Public fields only |
curl example
curl -X GET "https://api.fast.io/current/org/1234567890123456789/details/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"org": {
"id": "1234567890123456789",
"domain": "acme-corp",
"name": "Acme Corporation",
"description": "Leading provider of innovation",
"logo": "https://assets.fast.io/org/logo.png",
"accent_color": {"color": "#0066CC", "opacity": 100},
"closed": false,
"locked": false,
"suspended": false,
"created": "2024-01-15 10:30:00 UTC",
"updated": "2024-06-20 14:45:00 UTC"
}
}
Response fields
| Field | Type | Description |
|---|---|---|
| org.id | string | 19-digit numeric organization ID |
| org.domain | string | URL-safe subdomain |
| org.name | string/null | Display name |
| org.description | string/null | Description |
| org.logo | string/null | Logo asset URL |
| org.accent_color | object/null | Brand color ({color, opacity}) |
| org.closed | boolean | Whether org is closed |
| org.locked | boolean | Whether org is locked |
| org.suspended | boolean | Whether org is suspended |
| org.created | string | Creation timestamp |
| org.updated | string | Last update timestamp |
| org.plan | string | Billing plan identifier (e.g., "starter_monthly", "business_v3_monthly", "enterprise_v2_monthly"). |
| org.subscriber | boolean | Whether the org has an active subscription (for an org without a paid plan, reflects whether its credit allowance is available). Member+ only. |
| org.subscriber_trial_until | integer/null | Unix timestamp when the trial period ends. null for paid plans or if no trial. Member+ only. |
| org.payment_state | string | Payment health: current, past_due, or unpaid. unpaid is terminal — retries have stopped. Read this rather than subscriber to tell whether billing is current: subscriber stays true throughout a failed payment. Unrecognised states report current. Member+ only. |
| org.payment_failed_at | string/null | Start of the billing period whose payment failed, Y-m-d H:i:s UTC. null unless payment_state is past_due or unpaid. Read this as “the period that is unpaid”, NOT as the instant the charge was declined. For a cycle-renewal failure the two coincide, because a past-due subscription's period does not advance while its invoice is unpaid. For a failure on a mid-cycle invoice — a renewal on a subscription with no pending plan change, for instance — the period start can be materially earlier than the failure. (On an account that is current on its billing, an upgrade that needs additional card authentication does not reach past_due: the plan change stays pending on the current plan until authentication completes, and only then does the plan change — see payment_recovery under Create or Update Subscription below. An account already behind on payment, or an older subscription still on the prior billing mechanics, may still become past-due.) Do not render it as “payment failed on {date}”. A formatted string, not a Unix timestamp — unlike subscriber_trial_until above. Member+ only. |
| org.access_ends_at | null | Reserved. Always null: the date access ends is not currently knowable, so none is reported rather than an estimate. Member+ only. |
| org.subscriber_trial_credits | integer/null | Credits remaining in the current billing period. Admin+ only. |
| org.perm_workspace_create | string | Minimum role required to create a workspace. Admin+ only. |
| org.workspace_create_allowlist | array of string | User IDs allowed to create a workspace regardless of the threshold. Admin+ only. |
| org.sharing_shares | boolean | Whether members may create shares. Admin+ only. |
| org.sharing_file_links | boolean | Whether members may create single-file share links. Admin+ only. |
| org.external_invites_shares | object/string/null | Raw collaboration-policy envelope governing Shared Folder and File Share invitations — null when unconfigured, an object ({admin, member, overrides}) when readable, or the raw stored string when unreadable (repair by resaving). Admin+ only. See Collaboration Policies. |
| org.external_invites_portals | object/string/null | Same shape, governing Portal invitations. Admin+ only. |
| org.external_invites_workspaces | object/string/null | Same shape, governing Workspace invitations. Admin+ only. |
| org.credential_policy | object/string/null | Raw caps on the API keys and OAuth grants issued in this org — null when unconfigured, an object ({api_keys, oauth}) when readable, or the raw stored string when unreadable. Admin+ only. See Credential Policy. |
| org.cloud_sync | object/string/null | Raw cloud-sync policy envelope — null when unconfigured, an object ({admin, member, overrides}, each value {enabled, mode}) when readable, or the raw stored string when unreadable (repair by resaving). Admin+ only. No capabilities twin — the effective answer is the workspace's effective_cloud_sync. See Cloud Sync Policy. |
| org.auth_require_2fa | object/string/null | Raw policy envelope requiring a second factor at password/social login — null when unconfigured, an object ({admin, member, overrides}, values "required"/"optional") when readable, or the raw stored string when unreadable. Admin+ only. No capabilities twin — see Require-2FA Policy. |
| org.access_policy | object/string/null | Raw geo/IP access-restriction envelope — null when unconfigured, an object ({admin, member, overrides}) when readable, or the raw stored string when unreadable (repair by resaving). Admin-only. The restriction list itself is never echoed to anyone but an admin. See Access Policy (Geo / IP Restrictions). |
| org.ai_agent / org.ai_intelligence / org.ai_metadata / org.ai_summaries / org.mcp_access | object/string/null | Raw policy envelopes ({admin, member, overrides}) for each AI/MCP surface — null when unconfigured, an object when readable, or the raw stored string when unreadable. Admin-only. See AI, Intelligence & MCP Access Policy. |
| org.ai_workspaces | array/string/null | Raw Intelligence/metadata workspace allowlist — null means every workspace, an array of workspace id strings is the allowlist ([] means none), or the raw stored string when unreadable. Admin-only. |
| org.security_alerts | object/string/null | Raw security-alerts envelope — null means the defaults (every alert on, auditors included), an object ({enabled: [...], include_auditors}) when readable, or the raw stored string when unreadable. Admin-only. See Security Alerts below. |
| org.capabilities.can_create_workspace | boolean | Whether the calling user may create a workspace in this org right now — role, plan and policy combined. Member+ only, details responses only. |
| org.capabilities.sso | boolean | Whether the org's plan includes identity-provider configuration. Member+ only, details responses only. |
| org.capabilities.org_controls | boolean | Whether the org's plan includes the security controls above. Member+ only, details responses only. |
| org.capabilities.external_invites_shares | boolean | Whether the calling user may currently invite an outsider to a Shared Folder or File Share in this org. Member+ only, details responses only. |
| org.capabilities.external_invites_portals | boolean | Same, for Portals. Member+ only, details responses only. |
| org.capabilities.external_invites_workspaces | boolean | Same, for Workspaces. Member+ only, details responses only. |
| org.capabilities.credential_policy_api_keys | object | The calling user's own effective {max_mode, scope_types} for API keys issued in this org, combining role and override. Member+ only, details responses only. |
| org.capabilities.credential_policy_oauth | object | Same, for OAuth grants. Member+ only, details responses only. |
| org.capabilities.can_view_compliance | boolean | Enterprise only. Whether the calling user may open the audit log, audit export, member/sharing reports, credential list and SIEM stream GET — true for admin+ and for a member holding the compliance_auditor flag, AND org_controls. Member+ only, details responses only. See Compliance & Audit below. |
| org.capabilities.can_manage_legal_holds | boolean | Whether the calling user may place/list/release legal holds. true for the org owner on any plan (so a lapsed-Enterprise org can still list/release existing holds), or for an auditor AND org_controls. Member+ only, details responses only. |
| org.capabilities.ai_agent | boolean | Whether the calling user is currently allowed to use Ripley Agent chat, AI share generation, and the other ai_agent-governed surfaces in this org — role, override and policy combined. Member+ only, details responses only. See AI, Intelligence & MCP Access Policy. |
| org.capabilities.ai_intelligence | boolean | Same, for turning a workspace's/share's Intelligence on, the semantic search leg, and retrieval. |
| org.capabilities.ai_metadata | boolean | Same, for metadata extraction. |
| org.capabilities.mcp_access | boolean | Whether a request classified as MCP from the calling user may currently reach this org. |
Error responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1680 (Access Denied) | 401 | "You have not been granted access to this Org." | Insufficient permission |
Get Onboarding Checklist
/current/org/{org_id}/onboarding/
Auth required. Returns the org's onboarding checklist: whether this org is eligible for Fastio's getting-started checklist, whether an app should show it right now, and — only while it should be shown — the recommended next step and the completion status of each checklist item. Read-only — this call never changes anything.
{org_id} accepts a 19-digit numeric ID or the org's domain name.
Access
| Role | Access |
|---|---|
| Owner / Admin / Member | Full response |
| Guest / view-only / non-member | Standard org “not authorized” error |
curl example
curl -X GET "https://api.fast.io/current/org/1234567890123456789/onboarding/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"onboarding": {
"eligible": true,
"visible": true,
"next": "invite_teammate",
"items": [
{"id": "add_files", "status": "done", "completed_at": null},
{"id": "install_desktop", "status": "todo", "completed_at": null},
{"id": "connect_cloud", "status": "started", "completed_at": null},
{"id": "connect_agent", "status": "done", "completed_at": "2026-10-03 14:02:11 UTC"},
{"id": "invite_teammate", "status": "todo", "completed_at": null},
{"id": "create_portal", "status": "todo", "completed_at": null},
{"id": "ask_ripley", "status": "todo", "completed_at": null}
]
}
}
Response fields
| Field | Type | Description |
|---|---|---|
| onboarding.eligible | boolean | Whether this is the first organization its owner has ever owned. Joining someone else's org as a member does not count, so a user who was invited to another org and then creates their own first org is eligible on it. false for an owner's second (or later) org, including when the owner already used their free trial on an earlier org. |
| onboarding.visible | boolean | Whether an app should show the checklist right now: eligible AND the org's subscription is currently in its free trial AND fewer than 14 days have passed since the subscription started. Turns false once the trial converts to paid, the plan ends, or day 14 passes; an org that subscribed directly without a trial is never true. |
| onboarding.next | string/null | The recommended next item's id, in priority order (add_files, invite_teammate, connect_agent, ask_ripley, create_portal, install_desktop, connect_cloud), or null when every item is done, the next item can't currently be determined, or visible is false. |
| onboarding.items | array | Populated only while visible is true: these seven items, in this fixed order. An empty array while visible is false. |
| onboarding.items[].id | string | One of add_files, install_desktop, connect_cloud, connect_agent, invite_teammate, create_portal, ask_ripley. |
| onboarding.items[].status | string | todo, started (evidence the step has begun, e.g. a cloud import mid-sync or a pending invitation), done, or unknown (could not be determined right now — treat as not done; resolves on a later call). |
| onboarding.items[].completed_at | string/null | Completion time, Y-m-d H:i:s UTC, when known; otherwise null — never estimated. |
While visible is true, item statuses can take up to a minute to reflect a change.
Each item, when done: add_files — the org has at least one file; install_desktop — a member has signed in to the Fastio Desktop app; connect_cloud — a cloud import (Dropbox/Google Drive/Box/OneDrive) has completed a sync at least once; connect_agent — a member has connected an AI agent to the org; invite_teammate — someone other than the owner has joined the org; create_portal — the org has created a portal; ask_ripley — Ripley has answered a question in one of the org's workspaces.
Error responses
| HTTP Status | Cause |
|---|---|
| 401/403 | Caller is not an available member of the org |
| 404 | The org is closed or no longer exists |
| 503 | The checklist could not be read right now — retry |
Get Public Org Details
/current/org/{org_id}/public/details/
No authentication required. Returns limited public info about an org (name, domain, assets). IP-rate-limited.
{org_id} accepts a 19-digit numeric ID or the org's domain name.
curl example
curl -X GET "https://api.fast.io/current/org/1234567890123456789/public/details/"
Response (200 OK)
{
"result": true,
"org": {
"id": "1234567890123456789",
"domain": "acme-corp",
"name": "Acme Corporation",
"description": "Leading provider of innovation",
"logo": "https://assets.fast.io/org/logo.png",
"accent_color": {"color": "#0066CC", "opacity": 100}
}
}
Response fields
| Field | Type | Description |
|---|---|---|
| org.id | string | 19-digit numeric organization ID |
| org.domain | string | URL-safe subdomain |
| org.name | string/null | Display name |
| org.description | string/null | Description |
| org.logo | string/null | Logo asset URL |
| org.accent_color | object/null | Brand color ({color, opacity}) |
| org.login_options | object | Which sign-in routes an org-scoped sign-in page may offer — see login_options in the SSO reference. Omitted when it cannot be determined. |
Update Organization
/current/org/{org_id}/update/
Auth required. Admin or above. Updates org details. Only provided fields are modified.
Access levels
| Role | Access |
|---|---|
| Owner | Full access |
| Admin | Full access |
| Member | Denied |
Request parameters (all optional)
| Name | Type | Description |
|---|---|---|
| domain | string | New URL-safe subdomain (2-63 chars, lowercase alphanumeric + hyphens). |
| name | string | Display name (3-100 chars). Cannot be cleared. |
| description | string | Description. Send "null" or "" to clear. |
| industry | string | Industry type from predefined list. |
| accent_color | string (JSON) | Brand accent color as a JSON color object, {"color":"#RRGGBB","opacity":0-100}. Send "null" to clear. |
| background_color | string (JSON) | Background color as a JSON color object, {"color":"#RRGGBB","opacity":0-100}. Send "null" to clear. |
| background_mode | string | Background display mode. |
| use_background | string | Enable/disable background ("true"/"false"). |
| facebook_url | string (URL) | Facebook URL. |
| twitter_url | string (URL) | Twitter URL. |
| instagram_url | string (URL) | Instagram URL. |
| youtube_url | string (URL) | YouTube URL. |
| homepage_url | string (URL) | Organization website URL. |
| perm_member_manage | string | Member management permission level. |
| perm_workspace_create | string | Minimum role required to create a workspace. Enterprise plan only to tighten (raising the minimum role); lowering it or resubmitting it unchanged works on any plan. |
| workspace_create_allowlist | string (JSON) | User IDs that may create a workspace regardless of the threshold. Send the list as a JSON string in this one field, form-encoded or as a query parameter (workspace_create_allowlist=["123","456"]) — a JSON request body is not read. Send [] to clear, which withdraws every exception and is therefore a tightening. Every ID must be a current member. Maximum 500. Enterprise plan only to tighten (removing a current member from the list); adding users or resubmitting it unchanged works on any plan. |
| sharing_shares | string | Whether members may create shares. Send the string "true" or "false", form-encoded or as a query parameter. Enterprise plan only to tighten (switching it off); resubmitting it unchanged or switching it back on works on any plan. |
| sharing_file_links | string | Whether members may create single-file share links. Send the string "true" or "false", form-encoded or as a query parameter. Enterprise plan only to tighten (switching it off); resubmitting it unchanged or switching it back on works on any plan. |
| external_invites_shares | string (JSON) | Collaboration-policy envelope governing Shared Folder and File Share invitations. Send the whole envelope as a JSON string ({"admin":"allowed","member":"denied","overrides":{}}). Send "" or "null" to clear (permissive). Enterprise plan only to tighten. See Collaboration Policies. |
| external_invites_portals | string (JSON) | Same shape, governing Portal invitations. |
| external_invites_workspaces | string (JSON) | Same shape, governing Workspace invitations. |
| credential_policy | string (JSON) | Caps on what API keys and OAuth grants issued in this org may hold — see Credential Policy. Send the whole {api_keys, oauth} object as a JSON string. An omitted family is unchanged; a family set to null clears it. Send "" or "null" to clear the whole key (permissive). Enterprise plan only to tighten. |
| cloud_sync | string (JSON) | Cloud-sync policy envelope — see Cloud Sync Policy. Send the whole envelope as a JSON string ({"admin":{"enabled":true,"mode":"read_write"},"member":{"enabled":true,"mode":"read"},"overrides":{}}). Each value takes exactly enabled (boolean) and mode ("read" or "read_write"). Send "" or "null" to clear (permissive). Enterprise plan only to tighten (switching enabled off, or narrowing read_write to read). |
| auth_require_2fa | string (JSON) | Policy envelope requiring a second factor at password/social login — see Require-2FA Policy. Send the whole envelope as a JSON string ({"admin":"required","member":"optional","overrides":{}}). Send "" or "null" to clear (permissive). Refused with two_factor_admin_unenrolled if it tightens the writer's own requirement and they hold no factor. Enterprise plan only to tighten. |
| access_policy | string (JSON) | Geo / IP access-restriction envelope — see Access Policy (Geo / IP Restrictions). Send the whole value as a JSON string: {"admin":{"countries":...,"ips":...},"member":{...},"overrides":{"{user_id}":{...}}}. Send "" or "null" to clear (unrestricted). The whole value is replaced on every write — overrides are not merged. Enterprise plan only to tighten. |
| ai_agent | string (JSON) | Policy envelope ({"admin":"allowed"|"denied","member":...,"overrides":{}}) governing Ripley Agent chat (create/send/publish/rename), AI share generation, share auto-title/OG image, events summarize, and dashboard AI. Send "" or "null" to clear (allowed). Enterprise plan only to tighten. See AI, Intelligence & MCP Access Policy. |
| ai_intelligence | string (JSON) | Same envelope shape, governing whether the calling user may turn a workspace's/share's intelligence on, use the meaning-based (semantic) search leg, and have Ripley Agent retrieve from an indexed workspace's index. |
| ai_metadata | string (JSON) | Same envelope shape, governing metadata extraction — single-file, per-folder, compound search — and automatic extraction on ingest. |
| ai_summaries | string (JSON) | Same envelope shape, governing per-file AI summaries at ingest. Background only — this key never produces an interactive refusal, and the editor has no per-user override controls for it. Denying it also stops new files from being indexed, because indexing a file needs its AI summary first; already-indexed content is unaffected. |
| mcp_access | string (JSON) | Same envelope shape, governing whether a request classified as coming from the Fastio MCP may reach this org at all. Unconfigured means allowed. |
| ai_workspaces | string (JSON) | Allowlist gating background Intelligence/metadata/summary processing and interactive Intelligence/metadata for specific workspaces. A JSON array of workspace id strings, e.g. ["4123456789012345678"]. Send null or "" to allow every workspace (the default); send [] to allow none. Cap 1000 ids. An id naming a closed, deleted, or foreign (not-this-org) workspace is silently pruned on save rather than rejected — the echo returns the pruned list. Narrowing the list (removing an id, or going from null to a list) is a tightening write, except removing an id that was already pruned-on-save (closed, deleted, or foreign) — that never counts as tightening, since it was never really “the org's” for this purpose. A merely suspended or locked workspace still counts as the org's, so removing one of those IS a tightening. |
| security_alerts | string (JSON) | Per-alert opt-out for the five security alert types plus auditor recipients — see Security Alerts below. Send the whole envelope as a JSON string: {"enabled":["login_new_country","geo_policy_block","mass_delete","mass_download","credential_created"],"include_auditors":true}. include_auditors is optional (default true). Send "" or "null" to clear (the default: every alert on, auditors included). enabled: [] turns every alert off. audit_stream_paused is always on and is not a valid name here. Enterprise plan only to turn an alert on, or to turn auditors on; turning alerts off, removing auditors, or clearing is allowed on any plan. |
| perm_authorized_domains | string | Authorized email domain for auto-join. |
| billing_email | string (email) | Billing contact email. Domain must be reachable. |
| owner_defined | string (JSON) | Custom owner-defined properties. Send "null" or "" to clear. |
curl example
curl -X POST "https://api.fast.io/current/org/1234567890123456789/update/" \
-H "Authorization: Bearer {jwt_token}" \
-d "name=Acme Corp Updated" \
-d "description=Updated description" \
-d "industry=technology"
Response (200 OK)
{
"result": true
}
If no actual changes are detected, returns success immediately.
Error responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | "An invalid org domain was supplied." | Invalid domain format |
1605 (Invalid Input) | 406 | "The supplied org domain name is restricted." | Domain is reserved |
1605 (Invalid Input) | 406 | "The supplied org domain name is already in use." | Domain taken by another org |
1605 (Invalid Input) | 406 | "An invalid configuration was supplied..." | Metadata validation failed |
1605 (Invalid Input) | 406 | "Invalid JSON provided for {key}." | Malformed JSON |
1605 (Invalid Input) | 406 | "The email domain is invalid or cannot receive email." | Bad billing email domain |
1605 (Invalid Input) | 406 | "The workspace-create allowlist must be a list of user ids." | workspace_create_allowlist is not a JSON list of user IDs |
1605 (Invalid Input) | 406 | "Every user on the workspace-create allowlist must be a member of this org." | An allowlist entry is not a current member |
1605 (Invalid Input) | 406 | "The workspace-create allowlist may name at most 500 users." | Allowlist over the cap |
1605 (Invalid Input) | 406 | "A policy must be submitted as a JSON-encoded object." | A collaboration-policy field was not a JSON string |
1605 (Invalid Input) | 406 | "A policy must be an object with "admin" and "member" set to "allowed" or "denied", and an optional "overrides" object of user id to the same values." | Malformed collaboration-policy envelope |
1605 (Invalid Input) | 406 | "A policy may name at most 100 per-user exceptions." | Overrides over the cap |
1605 (Invalid Input) | 406 | "Every user named in a policy exception must be a member of this org." | An override names a non-member |
1605 (Invalid Input) | 406 | "access_policy: " followed by the specific problem — "A policy must be submitted as a JSON-encoded object.", or "A policy must be an object with \"admin\" and \"member\" set to an object with \"countries\" (…) and \"ips\" (…), and an optional \"overrides\" object of user id to the same values." | access_policy failed validation: a countries.codes list outside 1-250 entries or containing something other than a 2-letter code (XX unknown and T1 Tor are accepted, as is any other 2-letter code including XK), an ips list outside 1-100 entries, an unparseable IPv4/IPv6 address or CIDR, or a /0 prefix (refused outright — clear the rule instead of writing it as "unrestricted"). params[].name = access_policy. |
1605 (Invalid Input) | 406 | "ai_workspaces: The AI workspace list must be null, or a JSON list of workspace ids." or "ai_workspaces: The AI workspace list may name at most 1000 workspaces." | ai_workspaces was not a JSON array, contained an entry that is not a valid workspace id, or named more than 1000 workspaces. params[].name = ai_workspaces. (An id for a workspace that is merely closed, deleted, or in another org is pruned automatically, not rejected — see the field description above.) |
1605 (Invalid Input) | 406 | "security_alerts: " followed by the specific problem (e.g. "Unknown security alert name. Allowed: …") | security_alerts failed validation: an unknown alert name in enabled, a non-list enabled, a non-boolean include_auditors, or an unknown field. params[].name = security_alerts. |
1700 (Forbidden) | 403 | "This configuration requires an Enterprise plan." | A security-control write that tightens a setting — including access_policy, ai_agent, ai_intelligence, ai_metadata, ai_summaries, mcp_access, narrowing ai_workspaces, or security_alerts turning an alert on or turning auditors on — was sent by an org without the entitlement. Resubmits and relaxations are not refused. params.reason = plan_required. |
1700 (Forbidden) | 403 | "This change would block your own access from your current location or network. Add your own IP address or a per-user exception in the same change." | An access_policy write would take the caller's own current IP/country from pass to block. params.reason = access_policy_self_lockout, plus params.ip / params.country. There is no override flag to bypass this — add your own IP, or an override for yourself, in the same write. See Access Policy (Geo / IP Restrictions). |
1693 (Temporarily Unavailable) | 503 | "This policy could not be checked against your own access. Please try again." | An access_policy write's self-lockout check could not be resolved (the caller's own role could not be read). params.reason = access_policy_unavailable. Retryable — nothing was saved. |
1693 (Temporarily Unavailable) | 503 | "The workspaces named in this policy could not be verified. Please try again." | An ai_workspaces write named a workspace whose status could not be read, so it could be neither kept nor pruned. Retryable — nothing was saved. |
1700 (Forbidden) | 403 | "This change would require two-factor authentication of your own account, which has none enrolled. Enrol a second factor first." | An auth_require_2fa write tightens the WRITER's own effective requirement from optional to required and the writer holds no factor. params.reason = two_factor_admin_unenrolled. Tightening the member baseline, or an override on another user, is not refused. |
1693 (Temporarily Unavailable) | 503 | "This policy could not be checked against your own account. Please try again." | An auth_require_2fa write could not be evaluated against the writer's own account, so neither the self-lockout refusal above nor a pass could be established. Retryable — nothing was saved. Re-send the same request. |
1693 (Temporarily Unavailable) | 503 | "This policy change could not be scheduled. Please try again." | An auth_require_2fa write TIGHTENED the policy, but the session revocation that tightening requires could not be scheduled. Retryable, and the write was deliberately abandoned — the policy was NOT stored. Never treat this as “probably applied”; re-send the same request. |
1693 (Temporarily Unavailable) | 503 | "This AI policy change could not be recorded. Please try again." | A write that pauses background AI processing — denying ai_intelligence, ai_metadata, or ai_summaries for both the admin and member baselines, or narrowing ai_workspaces — could not be recorded. (ai_agent and mcp_access have no background processing, so a write to them never returns this.) Retryable, and the write was deliberately abandoned — nothing was saved. See AI, Intelligence & MCP Access Policy above. |
1663 (Update Failed) | 500 | "There was an internal error processing your update request." | Internal error |
Close Organization
/current/org/{org_id}/close/
Auth required. Owner only. Soft-deletes the organization. Active subscriptions are automatically cancelled; if the subscription cannot be cancelled, the organization is not closed and the call returns 503 — retry.
Request parameters
| Name | Type | Required | Description |
|---|---|---|---|
| confirm | string | Yes | Must match the org domain name or org numeric ID as confirmation. |
curl example
curl -X POST "https://api.fast.io/current/org/1234567890123456789/close/" \
-H "Authorization: Bearer {jwt_token}" \
-d "confirm=acme-corp"
Response (202 Accepted)
{
"result": true
}
Error responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
120445 | 406 | "The confirm field is required. Pass the org's domain or numeric id as confirm." | confirm was not provided |
10549 | 406 | "The confirm field provided does not match the org's domain or id." | Confirmation does not match domain or ID |
113130 | 409 | "This organization cannot be closed while a legal hold is active. Release all legal holds first." | An active legal hold — see Legal Holds below |
163648 | 503 | "The organization cannot be closed right now. Please try again shortly." | Legal-hold state could not be read; retry |
108611 | 503 | "The organization cannot be closed right now. Please try again shortly." | Billing was busy with another change while cancelling the subscription; nothing was closed — retry |
150073 | 503 | "The subscription could not be cancelled, so the organization was not closed. Please try again shortly." | The subscription could not be cancelled; nothing was closed — retry |
1663 (Update Failed) | 500 | "There was an internal error processing your request." | Failed to close org |
Storage deletion is deferred to the deletion system after a retention period.
Organization Assets
List Available Asset Types
/current/org/assets/
Auth required. Returns available org asset metadata types (e.g., logo, background images).
curl example
curl -X GET "https://api.fast.io/current/org/assets/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"names": ["logo", "background"],
"metadata_scheme": {
"logo": {
"width": {"name": "Image Width", "description": "Image width", "required": true, "type": "int", "min": 1},
"height": {"name": "Image Height", "description": "Image height", "required": true, "type": "int", "min": 1},
"mime": {"name": "Mimetype", "description": "Image mimetype", "required": true, "type": "string"},
"megapixels": {"name": "Image Megapixels", "description": "Image megapixels", "required": true, "type": "int", "min": 0, "max": 48},
"transform": {"name": "transform", "description": "Transform image", "required": false, "type": "image_transformer"}
}
},
"file_types": {"logo": "image", "background": "image"}
}
names lists the asset names the org accepts; file_types maps each to its file kind; metadata_scheme maps each to the metadata properties validated on upload (shown for logo only — background carries the same keys, with width/height capped at 4096 and megapixels at 33).
List Org Assets
/current/org/{org_id}/assets/
Auth required. Any member with at least View permission. Returns assets currently set on the org.
curl example
curl -X GET "https://api.fast.io/current/org/1234567890123456789/assets/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"assets": {
"logo": {
"metadata": {"width": 512, "height": 512, "megapixels": 0, "mime": "image/png"}
}
}
}
Keyed by asset name; each entry carries the stored metadata (image width, height, megapixels, mime). An org with no assets returns "assets": []. Fetch the bytes with Read Org Asset (Raw) below.
Upload Org Asset
/current/org/{org_id}/assets/{asset_name}/
Auth required. Admin or above. Upload as multipart/form-data.
Request parameters
| Name | Type | Required | Description |
|---|---|---|---|
| file | file (multipart) | Yes | The asset file to upload. |
| metadata | string (JSON object) | No | Additional metadata for the asset, sent as a JSON object string (e.g. {}). |
curl example
curl -X POST "https://api.fast.io/current/org/1234567890123456789/assets/logo/" \
-H "Authorization: Bearer {jwt_token}" \
-F "file=@logo.png"
Error responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1691 (File Missing) | 412 | "Asset upload missing" | No file in the request |
100289 | 406 | "metadata must be a JSON object encoded as a string." | metadata is not a JSON object |
Delete Org Asset
/current/org/{org_id}/assets/{asset_name}/
Auth required. Admin or above.
curl example
curl -X DELETE "https://api.fast.io/current/org/1234567890123456789/assets/logo/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true
}
Read Org Asset (Raw)
/current/org/{org_id}/assets/{asset_name}/read/
No authentication required. Returns the raw binary content of an org asset with appropriate Content-Type header. Useful for displaying logos and images directly.
HEAD requests return headers only.
Organization Members
Add or Invite a Member
/current/org/{org_id}/members/{email_or_user_id}/
Auth required. Permission governed by org's perm_member_manage setting. Parameters are form fields (application/x-www-form-urlencoded or multipart/form-data); a JSON request body is refused with a 406 error. You cannot add or invite someone at a role above your own — the ceiling applies to invitations as well as direct adds.
The target is specified as a path parameter:
- Use a user ID (19-digit numeric) to add an existing user directly
- Use an email address to send an invitation
If you pass an email address that belongs to an account which has not verified that address, an invitation is sent to it instead of adding the account directly.
Request parameters (adding existing user by ID)
| Name | Type | Required | Description |
|---|---|---|---|
| permissions | string | Yes | Permission level: "member", "admin". Cannot add as "owner". A value other than admin, member, guest or view (including any) is refused with a 406 error rather than treated as "member". |
| expires | string (datetime) | No | Membership expiration date. |
| notify_options | string | No | Notification preference. |
| notification | string | No | Send force to force the notification email to the added user. |
Request parameters (inviting by email)
| Name | Type | Required | Description |
|---|---|---|---|
| permissions | string | Yes | Permission level for the invitation: "member", "admin". A value other than admin, member, guest or view (including any) is refused with a 406 error rather than treated as "member". |
| message | string | No | Custom invitation message. |
| expires | string (datetime) | No | Expiration of the membership granted when the invitation is accepted. |
| invitation_expires | string (datetime) | No | Deadline for accepting the invitation (must be in the future). |
curl example (add existing user)
curl -X POST "https://api.fast.io/current/org/1234567890123456789/members/9876543210987654321/" \
-H "Authorization: Bearer {jwt_token}" \
-d "permissions=member"
curl example (invite by email)
curl -X POST "https://api.fast.io/current/org/1234567890123456789/members/jane@example.com/" \
-H "Authorization: Bearer {jwt_token}" \
-d "permissions=member" \
-d "message=Welcome to the team!"
Response (200 OK) — direct add
{
"result": true
}
Response (200 OK) — invitation created
{
"result": true,
"invitation": {
"id": "a53no-nj43t-cwoju-ldoi6-nxql4-hm5q",
"invitee_email": "jane@example.com",
"entity_type": "org",
"state": "pending",
"created": "2024-01-15 10:30:00 UTC"
}
}
Abbreviated: the invitation object carries every field of a List Org Invitations entry, plus an org object.
Error responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | "Invalid permission specified." | permissions missing |
1605 (Invalid Input) | 406 | "Invalid permissions value. Valid values are: admin, member, guest, view." | permissions is not one of those role names |
1605 (Invalid Input) | 406 | "This endpoint does not accept a JSON request body..." | Request sent with a JSON body |
1692 (Cannot Add As Owner) | 406 | "Adding a member as an owner is not allowed" | Tried to add as owner (use transfer_ownership) |
127022 | 406 | "You cannot add, update, or delete a membership with a higher permission than your own." | The requested role is above your own — for an invitation as well as a direct add |
1656 (Limit Exceeded) | 413 | Limit message | Member limit exceeded |
Remove a Member
/current/org/{org_id}/members/{user_id}/
Auth required. Permission governed by org's perm_member_manage setting.
The target user ID (19-digit numeric) is a path parameter.
curl example
curl -X DELETE "https://api.fast.io/current/org/1234567890123456789/members/9876543210987654321/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true
}
List Org Members
/current/org/{org_id}/members/list/
Auth required. Any org member. Paginated.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
| limit | integer | 100 | 1-500, number of items to return |
| offset | integer | 0 | Number of items to skip |
curl example
curl -X GET "https://api.fast.io/current/org/1234567890123456789/members/list/?limit=50&offset=0" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"users": [
{
"id": "1234567890123456789",
"account_type": "human",
"email_address": "owner@example.com",
"first_name": "John",
"last_name": "Doe",
"permissions": "owner",
"auth": {
"password": true,
"social": [
{"provider": "google", "last_login": "2026-09-15 10:22:31 UTC"}
],
"sso": [],
"two_factor": {"enabled": true, "method": "totp"}
},
"compliance_auditor": false
},
{
"id": "1234567890123456780",
"account_type": "agent",
"email_address": "bot@example.com",
"first_name": "Service",
"last_name": "Bot",
"permissions": "admin",
"auth": {
"password": false,
"social": [],
"sso": [],
"two_factor": {"enabled": false, "method": null}
},
"compliance_auditor": false
}
],
"pagination": {
"total": 2,
"limit": 50,
"offset": 0,
"has_more": false
}
}
Response fields
| Field | Type | Description |
|---|---|---|
| users | array | Array of member objects |
| users[].id | string | 19-digit numeric user ID |
| users[].account_type | string | "human" or "agent" |
| users[].email_address | string | User's email |
| users[].first_name | string | First name |
| users[].last_name | string | Last name |
| users[].permissions | string | Role: "owner", "admin", "member" |
| users[].auth | object | Sign-in methods for this member. Present only when the caller is an owner, admin, or holder of the org's compliance_auditor flag — owners and admins on every plan; a compliance_auditor holder only while the org has the Enterprise entitlement — absent, not null, for any other viewer, and also absent if a supporting table could not be read. Included at default output and ?output=standard; omitted at ?output=terse. |
| users[].auth.password | boolean | Whether the member has a usable password. |
| users[].auth.social | array | Providers (google, microsoft) the member has signed in with — each entry is {provider, last_login}, where last_login is the most recent sign-in with, or connection of, that provider (a freshly connected provider shows its connection time). |
| users[].auth.sso | array | This org's active SSO identities for the member — each entry is {org_id, protocol, last_login}; protocol is saml/oidc and is null until that identity's first SSO login. Lists only identities in the org being listed, not the member's other orgs. |
| users[].auth.two_factor | object | {enabled, method} — method is totp or phone, null when 2FA is disabled. |
| pagination.total | integer | Total number of members |
| pagination.limit | integer | Requested page size |
| pagination.offset | integer | Current offset |
| pagination.has_more | boolean | Whether more results exist |
Leave Organization (Self)
/current/org/{org_id}/member/
Auth required. Removes the authenticated user from the org. Owners cannot leave — they must transfer ownership or close the org first.
curl example
curl -X DELETE "https://api.fast.io/current/org/1234567890123456789/member/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true
}
Error responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | "You cannot leave an org you are the owner of..." | User is the org owner |
1605 (Invalid Input) | 406 | "You cannot leave an org you are not a member of." | User is not a member |
Get Member Details
/current/org/{org_id}/member/{user_id}/details/
Auth required. Any org member.
curl example
curl -X GET "https://api.fast.io/current/org/1234567890123456789/member/9876543210987654321/details/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"user": {
"id": "9876543210987654321",
"account_type": "human",
"email_address": "jane@example.com",
"first_name": "Jane",
"last_name": "Smith",
"permissions": "admin",
"notify": "Notify me in app",
"member_added_at": "2026-08-29 15:48:29 UTC"
}
}
Response fields
| Field | Type | Description |
|---|---|---|
| user.id | string | 19-digit numeric user ID |
| user.account_type | string | "human" or "agent" |
| user.email_address | string | User's email |
| user.first_name | string | First name |
| user.last_name | string | Last name |
| user.permissions | string | Role: "owner", "admin", "member" |
| user.invite | object | Pending-invitation snapshot (id, created, expires); absent when unset, which is normal for an active member |
| user.notify | string | Notification preference — present only when you read your own membership |
| user.expires | string | Membership expiration (YYYY-MM-DD HH:MM:SS UTC); absent for a permanent membership |
| user.member_added_at | string | When this membership was created (YYYY-MM-DD HH:MM:SS UTC). Absent — not null — when you are not allowed to see it: emitted only to the member themselves or to an admin-or-above of this org, so a peer member gets no key at all. Read it with a presence check on the key |
| user.compliance_auditor | boolean | Whether this member holds the compliance-auditor flag (read-only compliance surfaces plus legal-hold management, granted by the owner — see Compliance & Audit below). Present on every plan — inert (grants nothing) while the org lacks Enterprise. Absent, not false, unless the viewer is the member themselves, an org admin+, or an auditor while the org has Enterprise — a peer member gets no key at all. Also present on org members list (GET /current/org/{org_id}/members/list/) rows under the same visibility rule. |
Error responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | "The membership you specified does not exist." | User is not a member |
Update Member Permissions
/current/org/{org_id}/member/{user_id}/update/
Auth required. Permission governed by org's perm_member_manage setting. Parameters are form fields; a JSON request body is refused with a 406 error.
Request parameters (all optional)
| Name | Type | Description |
|---|---|---|
| permissions | string | New permission level ("member", "admin"). Omitted leaves the role unchanged; "owner" is ignored (use Transfer Org Ownership). A value other than admin, member, guest or view (including any) is refused with a 406 error. |
| expires | string (datetime) | Membership expiration date |
| notify_options | string | Notification preference; omitted leaves it unchanged |
curl example
curl -X POST "https://api.fast.io/current/org/1234567890123456789/member/9876543210987654321/update/" \
-H "Authorization: Bearer {jwt_token}" \
-d "permissions=admin"
Response (200 OK)
{
"result": true
}
Error responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | "The membership you specified does not exist." | User is not a member |
1605 (Invalid Input) | 406 | "Invalid permissions value. Valid values are: admin, member, guest, view." | permissions is not one of those role names |
1605 (Invalid Input) | 406 | "This endpoint does not accept a JSON request body..." | Request sent with a JSON body |
127022 | 406 | "You cannot add, update, or delete a membership with a higher permission than your own." | The resulting role is above your own |
Transfer Org Ownership
/current/org/{org_id}/member/{user_id}/transfer_ownership/
POST only — GET, HEAD, and every other method return 405. Auth required. Owner only. Transfers ownership of the org to the specified member, who must be an enabled, non-phantom org member with role member or above (not guest/viewer). The current owner is demoted to admin.
Fixed behaviour:
- The successor is promoted before the current owner is demoted, under a per-org lock — the org is never left without an owner.
- Retry completes a partial transfer. If a previous call stopped midway (both users end up owners), calling again with the same target finishes it and returns 200.
- A concurrent call on the same org returns 409
transfer_in_progress. - Owned-org lists refresh immediately for both users.
- Transferring a free/unpaid org is refused when the receiver is already at the free-org limit. Paid orgs are unrestricted.
- Billing stays on the org: subscription, Stripe customer and payment method are untouched. If no billing email is set, it follows the new owner.
- SSO: the old owner stays an admin, and admins are exempt from SSO enforcement, so the transfer alone signs no one out. Only a later loss of that admin role can end their sessions if SSO is enforced.
- No email is sent — this is recorded as an event only.
curl example
curl -X POST "https://api.fast.io/current/org/1234567890123456789/member/9876543210987654321/transfer_ownership/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"ownership": {
"profile_id": "1234567890123456789", "profile_type": "org",
"previous_owner": "1111111111111111111", "new_owner": "9876543210987654321",
"transferred_at": "2026-09-23 16:37:29 UTC"
}
}
Error responses (error.params.reason)
| Reason | HTTP | Meaning |
|---|---|---|
successor_is_self | 406 | "You cannot transfer ownership to yourself." — target is the current user |
successor_not_member | 406 | "The membership you specified does not exist." — user is not an org member, or their membership has been removed or has expired |
successor_unavailable | 406 | "The new owner's account is not active, so ownership cannot be transferred to it." — target is closed, suspended, locked, or a phantom member |
successor_role_too_low | 406 | "The new owner must be a member of the organization, not a guest or viewer." — target's role is below member (guest/view) |
successor_org_limit | 406 | "The new owner already owns the maximum number of free organizations." — org is free/unpaid and the receiver is already at the free-org limit |
transfer_in_progress | 409 | "Another ownership transfer of this org is in progress. Please try again shortly." — another transfer of this org is running; retry shortly |
| — | 401 | "Appropriate access is not granted to this Org." / "You are no longer the owner of this org." — caller is not the owner (the second text: ownership changed while the call was waiting) |
scope_admin_required | 403 | The credential is not admin-capable on this org (an API key/OAuth token without rwa) |
| — | 500 | "The ownership transfer did not finish. Transfer to the same member again to complete it." — repeat the call with the same target to complete it |
| — | 503 | "Ownership transfer is temporarily unavailable. Please try again shortly." — retry |
Event: ownership_transferred (audit log; profile_type, from_user, to_user). The two existing membership_updated events (promotion and demotion) still fire.
Bulk Access Transfer (Org)
Move a departing or off-boarding member's entire access footprint in one org to another member — every workspace and share membership, plus the ownerships on them — in one guided flow: preview, execute, poll status.
Scope: org O only — A's org row, A's rows on O's workspaces, and A's rows on shares inside O's workspaces. Personal shares and A's memberships in other orgs are untouched.
Rules:
- For each item, B ends at
max(B_current, A_role)— B is never downgraded. - Workspace/share ownerships A holds move to B; A is demoted to admin on those items.
- Refused when A is the org owner (use Transfer Org Ownership above first), when B is not already an org member, when B is A, or when B is unavailable.
- Who: org admin+ with an admin-scope credential. An admin may not target an A whose role is admin or above — only the owner can.
- A's pending invites are counted, not changed.
- Optional offboard: removes A from the org once every item is done or skipped. It can be blocked by a retention or compliance restriction on A in this org (one in another org does not block it) — the transfer itself still completes either way.
Preview (dry run)
/current/org/{org_id}/member/{user_id}/transfer_access/preview/
{user_id} is A. Body: to_user_id (required, B's user id). No writes.
curl example
curl -X POST "https://api.fast.io/current/org/1234567890123456789/member/9876543210987654321/transfer_access/preview/" \
-H "Authorization: Bearer {jwt_token}" \
-d "to_user_id=1111111111111111111"
Response (200 OK)
{
"result": true,
"preview": {
"from_user": "9876543210987654321", "to_user": "1111111111111111111",
"plan_hash": "sha256:9f2c…",
"complete": true,
"org": {"from_role": "member", "to_role_before": "guest", "to_role_after": "member", "action": "raise"},
"counts": {"workspaces": 12, "shares": 40, "ownerships": 3, "raise": 30, "add": 20, "skipped_b_higher": 2, "invites_sent_by_from": 4},
"items": [
{"type": "workspace", "id": "4123456789012345678", "name": "Legal", "from_role": "owner",
"to_role_before": null, "to_role_after": "owner", "action": "transfer_ownership"}
],
"items_truncated": false,
"blocking": [],
"warnings": ["billable_users_may_increase"],
"offboard_allowed": true, "offboard_blocked_reason": null
}
}
Response fields
| Field | Type | Description |
|---|---|---|
| preview.plan_hash | string | Send this back unchanged with execute |
| preview.complete | boolean | true only when every relationship was provably enumerated. false means counts are lower bounds, and execute is refused with plan_incomplete |
| preview.org | object | The org-level role change; action is add|raise|transfer_ownership|skipped_b_higher|none |
| preview.counts | object | Covers workspace and share items only — the org row is the separate org block above |
| preview.items[].action | string | Same vocabulary as preview.org.action |
| preview.items / items_truncated | array/boolean | Capped at 500 items; counts stay exact even when items are truncated |
| preview.blocking | array of string | Refusal reasons that would stop execute; a non-empty list still returns 200 here — nothing_to_transfer can appear |
| preview.warnings | array of string | Machine codes for client-owned copy: billable_users_may_increase, pending_invites_unchanged |
| preview.offboard_blocked_reason | string/null | null when the offboard is not blocked. restricted — blocked by a retention or compliance restriction on the member (the specific restriction is not disclosed). An org owner or entitled auditor caller may see a more specific value, legal_hold, instead |
Errors: 403 insufficient_permission (caller is not admin+, or an admin targeting an admin — this refuses the preview outright); 406 successor_not_org_member (malformed to_user_id); 406 from_user_not_member (malformed {user_id} path segment); 406 field validation. Every other refusal (from_user_not_member, from_user_is_org_owner, successor_is_source, successor_not_org_member, successor_unavailable, nothing_to_transfer) appears in blocking with HTTP 200 instead of failing the call.
Execute
/current/org/{org_id}/member/{user_id}/transfer_access/
Body: to_user_id (required); plan_hash (required, from the preview); offboard (bool, default false).
The server recomputes the plan and compares hashes — a mismatch means something changed since the preview.
curl example
curl -X POST "https://api.fast.io/current/org/1234567890123456789/member/9876543210987654321/transfer_access/" \
-H "Authorization: Bearer {jwt_token}" \
-d "to_user_id=1111111111111111111" -d "plan_hash=sha256:9f2c…" -d "offboard=true"
Response: HTTP 202
{"result": true, "transfer": {"id": "mt7q…", "status": "queued", "created_at": "2026-09-23 16:40:00 UTC"}}
Errors (error.params.reason), checked in this order: 403 insufficient_permission; the first applicable blocking reason (406); 409 plan_incomplete (preview could not enumerate everything); 409 plan_changed (re-preview); 409 transfer_in_progress (params.transfer_id names the active transfer — show it); 503 (store/queue unavailable, retry); 429 throttled.
Events: org_member_transfer_started (plan counts). Per item, the existing added-member events / membership_updated, plus ownership_transferred, each carrying transfer_id. At the end, org_member_transfer_completed (report counts, complete, offboard_status).
Status + report
/current/org/{org_id}/transfers/{transfer_id}/
Who: org admin+. Query: limit (1-500, default 100), offset over items. Poll every 2-5 s while status is queued or running.
curl example
curl -X GET "https://api.fast.io/current/org/1234567890123456789/transfers/mt7q…/?limit=100&offset=0" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"transfer": {
"id": "mt7q…", "status": "running", "from_user": "9876543210987654321", "to_user": "1111111111111111111",
"calling_user": "1234567890123456780",
"offboard": true, "offboard_status": "not_requested", "offboard_blocked_reason": null, "reason": null,
"counts": {"total": 55, "done": 30, "skipped": 2, "failed": 0},
"items": [{"type": "share", "id": "5123…", "action": "add", "outcome": "done", "reason": null}],
"created_at": "… UTC", "updated_at": "… UTC", "completed_at": null
},
"pagination": {"total": 55, "limit": 100, "offset": 0, "has_more": false}
}
Response fields
| Field | Type | Description |
|---|---|---|
| transfer.status | string | queued|running|completed|completed_with_errors|failed |
| transfer.reason | string/null | null or report_lost — the progress record was lost; the audit log remains the record |
| transfer.offboard_status | string | not_requested|dispatched|blocked|failed. dispatched means A was removed from the org and workspace/share clean-up started in the background. blocked pairs with offboard_blocked_reason |
| transfer.items | array | Includes the org row (type: "org", so counts.total = items + 1). outcome: done|skipped_b_higher|skipped_gone|failed. Item reason on a failed item: commit_failed|read_failed |
| transfer.counts.failed | integer | Also counts memberships whose own org could not be confirmed as this org (a transient read failure) — those never appear in items, since it isn't known that they belong here |
Kept for 30 days; after that, 404 transfer_not_found — the audit log remains the record.
Join Organization
/current/org/{org_id}/members/join/
Auth required. Join an org via invite or domain-based auto-join.
Join methods
- Via invitation: Append the invitation key to the URL path:
.../join/{invitation_key}/optionally followed byacceptordecline. Default isaccept. - Via authorized domain: The org must have
perm_authorized_domainsset, the user's email address must be verified (agent accounts included), and the user's email domain must match. A new user is added as a Member; a current (unexpired) member keeps their role; an expired membership is restored as Member. Onlynotify_optionsis read from input;permissionsis ignored.
curl example (invitation)
curl -X POST "https://api.fast.io/current/org/1234567890123456789/members/join/abc123def456/accept/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true
}
Error responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1680 (Access Denied) | 401 | "This org does not allow you to join automatically..." | Domain auto-join not enabled |
10587 | 401 | "Verify your email address to join this org automatically, or request an invitation." | Domain auto-join attempted by an account whose email address is not verified |
1680 (Access Denied) | 401 | "You are not allowed to join this org automatically..." | User's email domain does not match |
1656 (Limit Exceeded) | 413 | Limit message | Member limit exceeded |
146157 | 406 | "This invitation is not for this resource." | The invitation key is not an invitation to this org ({org_id}) — for example a workspace or share invitation, or another org's. Nothing is accepted or declined |
List Org Invitations
/current/org/{org_id}/members/invitations/list/
Auth required. Any org member. An optional state filter can be appended: .../list/pending/.
curl example
curl -X GET "https://api.fast.io/current/org/1234567890123456789/members/invitations/list/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"invitations": [
{
"id": "a53no-nj43t-cwoju-ldoi6-nxql4-hm5q",
"inviter": "John Doe",
"inviter_actor": {
"user_id": "1234567890123456789",
"kind": "human",
"agent_name": null,
"name_source": null,
"credential_type": "session",
"verified": false
},
"invitee_email": "jane@example.com",
"invitee_uid": "5566778899001122334",
"accepted_uid": null,
"entity_type": "org",
"state": "pending",
"consumed": false,
"created": "2024-01-15 10:30:00 UTC",
"updated": "2024-01-15 10:30:00 UTC",
"expires": "2024-01-18 10:30:00 UTC"
}
]
}
Response fields
| Field | Type | Description |
|---|---|---|
| invitations | array | Array of invitation objects |
| invitations[].id | string | Invitation identifier |
| invitations[].inviter | string | Name of the user who sent the invitation |
| invitations[].inviter_actor | object | Who sent the invitation and whether an agent acted for them: user_id, kind (human, agent, api_key, app, system, unknown), agent_name, name_source, credential_type, verified. Any agent_name other than Fastio’s own verified agent is self-declared. Full reference: Actor Attribution in the Storage reference |
| invitations[].invitee_email | string | Email address of the invitee |
| invitations[].invitee_uid | string/null | User ID (19-digit string) of the invitee's pending-member placeholder; null when there is none |
| invitations[].accepted_uid | string/null | 19-digit user ID of the account that accepted, as a string; null until accepted |
| invitations[].entity_type | string | Always "org" for org invitations |
| invitations[].state | string | Invitation state: "pending", "accepted", "declined" |
| invitations[].consumed | boolean | true once the invitation has been accepted |
| invitations[].created | string | Creation timestamp |
| invitations[].updated | string | Last update timestamp |
| invitations[].expires | string/null | Deadline for accepting the invitation |
Error responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | "An invalid invitation state was supplied." | Invalid state filter |
Update an Invitation
/current/org/{org_id}/members/invitation/{invitation_id}/
Auth required. Permission governed by org's perm_member_manage setting.
{invitation_id} can be the invitation ID or the invitee email address. The invitation must belong to this org; otherwise the request fails with 406 exactly as an unknown invitation does ("Invalid invitation id or Invitation not found." for an ID). Parameters are form fields; a JSON request body is refused with a 406 error.
Request parameters (all optional)
| Name | Type | Description |
|---|---|---|
| state | string | New invitation state: "pending", "accepted", "declined" |
| permissions | string | The role granted when the invitation is accepted. Cannot be "owner" or above your own role; either refusal returns an error and leaves the invitation unchanged |
| notify_options | string | Notification preference applied on acceptance |
| expires | string (datetime) | New deadline for accepting the invitation (must be in the future). It does not set an expiry on the membership the invitation grants. An empty value or null leaves the deadline unchanged |
curl example
curl -X POST "https://api.fast.io/current/org/1234567890123456789/members/invitation/a53no-nj43t-cwoju-ldoi6-nxql4-hm5q/" \
-H "Authorization: Bearer {jwt_token}" \
-d "permissions=admin"
Response (200 OK)
{
"result": true
}
Error responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | "An invalid invitation ID or email was supplied." | Invalid identifier |
1605 (Invalid Input) | 406 | "Invalid invitation id or Invitation not found." | No invitation with that ID, or it belongs to a different org |
1605 (Invalid Input) | 406 | "Invitation is not for an Org" | Wrong entity type |
1605 (Invalid Input) | 406 | "An invalid state was supplied." | Invalid state value |
1605 (Invalid Input) | 406 | "Invalid permissions value. Valid values are: admin, member, guest, view." | permissions is not one of those role names |
1605 (Invalid Input) | 406 | "This endpoint does not accept a JSON request body..." | Request sent with a JSON body |
1692 (Cannot Add As Owner) | 406 | "Adding a member as an owner is not allowed" | permissions=owner |
127022 | 406 | "You cannot add, update, or delete a membership with a higher permission than your own." | The new role is above your own |
1679 (Update Failed) | 500 | "Failed to update invitation." | Internal error |
Delete an Invitation
/current/org/{org_id}/members/invitation/{invitation_id}/
Auth required. Permission governed by org's perm_member_manage setting.
{invitation_id} can be the invitation ID or the invitee email address. The invitation must belong to this org; otherwise the request fails with 406 exactly as an unknown invitation does ("Invalid invitation id or Invitation not found." for an ID).
curl example
curl -X DELETE "https://api.fast.io/current/org/1234567890123456789/members/invitation/a53no-nj43t-cwoju-ldoi6-nxql4-hm5q/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true
}
Error responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1666 (Delete Failed) | 500 | "Failed to delete invitation." | Deletion failed |
Compliance & Audit
Enterprise plan. These endpoints give an org owner or admin, or a member holding the compliance_auditor flag (“auditor”), read access to the org’s credential inventory, per-user and sharing-exposure reports, and a streaming audit-log export — plus the two write actions (credential revoke, force sign-out) that stay admin-only. See org.capabilities.can_view_compliance / can_manage_legal_holds above to decide whether to show this UI without guessing from role alone.
The auditor flag adds no write power. It is a boolean on a normal org membership (member.compliance_auditor, granted only by the owner — see Grant/Revoke the Auditor Flag below). An auditor keeps their existing role and workspaces; the flag only opens the read-only surfaces on this page plus legal-hold management. It is inert while the org lacks the Enterprise plan.
Credential rule for these reads. A signed-in browser session always passes. An API key needs an admin-scope (rwa) grant on the org.
Common refusals across this whole section — branch on error.params.reason, never on error.code or on HTTP status alone:
| Reason | HTTP | Meaning |
|---|---|---|
compliance_access_required | 403 | Caller is neither admin+ nor an auditor |
scope_admin_required | 403 | An API-key credential without an admin-capable (rwa) grant on the org (a browser session always passes) |
plan_required | 403 | Org is not on the Enterprise plan |
member_not_found | 404 | {user_id} is not a live org member |
Credential Reach Classification
Every API key and OAuth grant is classified relative to the org being viewed:
| Reach | Meaning |
|---|---|
org_only | Every scope on the credential targets this org's entities. |
user_wide | Legacy/NULL scopes claim, user:*:*, or any wildcard scope (org:*, workspace:*, share:*, fileshare:*, sign_envelope:*). |
mixed | Concrete grants on this org plus other orgs or personal entities. |
null | Reach could not be resolved. Listed, but never revocable. |
| (not listed) | no_reach credentials (nothing on this org) are omitted entirely; a revoke attempt against one returns 404 credential_not_found. |
Revoke eligibility (who may act, and on what):
org_onlycredentials are always revocable by an eligible actor.user_wideandmixedcredentials are revocable only when the credential owner's email is on one of this org's verified SSO domains — otherwise 409credential_spans_other_orgs(remedy copy: “Remove this member from the organization, or tighten the credential policy”).- An eligible actor is an org admin+ whose role is strictly above the target's. The owner can never be a target. Auditors never revoke, regardless of the flag.
- Revoking an OAuth session stops refreshes immediately, but an already-issued access token keeps working for up to 1 hour. Say so in any revoke/sign-out confirmation copy.
List Org Credentials
/current/org/{org_id}/credentials/
List every API key and OAuth grant that reaches this org, across all members. Auth: admin+ or auditor. Enterprise.
Query parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| user_id | string | No | — | 19-digit numeric ID. Restrict to one member. A non-member id returns an empty list. |
| type | string | No | all | api_key | oauth | all |
| reach | string | No | — | org_only | user_wide | mixed |
| limit | integer | No | 50 | 1–50. Counts members per page, not credentials — each member contributes up to 100 credentials (see credentials_capped). |
| cursor | string | No | — | Opaque keyset cursor from pagination.next_cursor. |
curl example
curl -X GET "https://api.fast.io/current/org/1234567890123456789/credentials/?type=oauth&reach=mixed" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"credentials": [
{
"type": "api_key", "id": "k8x2mq4rt7", "user_id": "9876543210987654321",
"label": "CI deploy", "agent_name": null, "client_id": null, "device_name": null,
"token_chars": "x9Qz", "scopes": ["workspace:4123456789012345678:rw"], "other_scopes": 0,
"access_mode": "rw", "admin": false, "legacy": false, "mcp": false,
"reach": "org_only", "created": "2026-09-01 10:00:00 UTC", "expires": null,
"last_used": "2026-09-23 07:10:00 UTC", "last_ip": "203.0.113.7", "last_country": "US",
"revocable": true, "revocable_reason": null
},
{
"type": "oauth", "id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "user_id": "9876543210987654321",
"label": null, "agent_name": "Claude", "client_id": "cimd:https://claude.ai/…",
"device_name": "MacBook", "token_chars": null, "scopes": ["workspace:4123456789012345679:rw"], "other_scopes": 2,
"access_mode": "rw", "admin": false, "legacy": false, "mcp": true, "reach": "mixed",
"created": "2026-09-10 12:00:00 UTC", "expires": "2026-10-10 12:00:00 UTC",
"last_used": "2026-09-22 18:00:00 UTC", "last_ip": "198.51.100.4", "last_country": "DE",
"revocable": false, "revocable_reason": "credential_spans_other_orgs"
}
],
"pagination": {"has_more": true, "next_cursor": "…", "page_size": 50, "credentials_capped": false}
}
Response fields
| Field | Type | Description |
|---|---|---|
| credentials[].type | string | api_key | oauth |
| credentials[].id | string | Alphanumeric id (API-key id or 32-hex OAuth session id) |
| credentials[].user_id | string | The credential owner |
| credentials[].scopes | array or null | Only wildcards and this org's own scopes, verbatim; null for a legacy full-access credential. A concrete scope owned by another org, no org, or an unresolvable owner is withheld — see other_scopes |
| credentials[].other_scopes | integer | Count of concrete scopes withheld from scopes because they belong to another org (or could not be resolved). Present on every row; 0 when nothing was withheld. reach still accounts for the withheld scopes |
| credentials[].reach | string or null | See Credential Reach Classification above |
| credentials[].last_used / last_ip / last_country | string/null | Written periodically, not on every request |
| credentials[].mcp | boolean | true for an OAuth grant whose audience is the MCP server |
| credentials[].revocable | boolean | Computed for the caller — an auditor or a non-eligible admin sees false with a reason |
| credentials[].revocable_reason | string or null | null | credential_spans_other_orgs | cannot_act_on_member |
| pagination.credentials_capped | boolean | true when at least one member on this page had more than 100 credentials |
Token secrets and hashes are never returned. Expired API keys and revoked/expired OAuth sessions are not listed.
Error responses
| Reason | HTTP |
|---|---|
compliance_access_required / scope_admin_required / plan_required | 403 |
| validation | 406 |
Revoke a Credential
/current/org/{org_id}/member/{user_id}/credentials/{type}/{credential_id}/
Revoke one credential belonging to {user_id}. Auth: an eligible actor, admin-scope credential required. Enterprise.
{user_id} = the credential's owner (a live org member); {type} = api_key | oauth. No body.
curl example
curl -X DELETE "https://api.fast.io/current/org/1234567890123456789/member/9876543210987654321/credentials/api_key/k8x2mq4rt7/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{"result": true, "revoked": {"type": "api_key", "id": "k8x2mq4rt7", "user_id": "9876543210987654321"}}
Error responses
| Reason | HTTP | Meaning |
|---|---|---|
credential_not_found | 404 | Gone, no_reach, or does not belong to {user_id} |
member_not_found | 404 | {user_id} is not a live org member |
credential_spans_other_orgs | 409 | user_wide/mixed credential and the owner is not on a verified SSO domain |
cannot_act_on_member | 403 | Target is the owner, a peer, or above the caller's rank |
compliance_access_required / scope_admin_required / plan_required | 403 | — |
Events: org_credential_revoked (audit log). The existing api_key_deleted also fires on the member's own trail, with the calling user as the admin.
Force Sign-Out a Member
/current/org/{org_id}/member/{user_id}/sign-out/
Invalidate every session everywhere for this member, and revoke whatever of their credentials the revoke rule allows. Auth: an eligible actor, admin-scope credential required. Enterprise. No body.
curl example
curl -X POST "https://api.fast.io/current/org/1234567890123456789/member/9876543210987654321/sign-out/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true, "sessions_invalidated": true,
"revoked": [{"type": "api_key", "id": "k8x2mq4rt7"}, {"type": "oauth", "id": "a1b2…"}],
"skipped": [{"type": "oauth", "id": "c3d4…", "reason": "credential_spans_other_orgs"}]
}
Error responses
| Reason / Cause | HTTP |
|---|---|
member_not_found | 404 |
cannot_act_on_member | 403 |
Throttled — honour Retry-After | 429 |
compliance_access_required / scope_admin_required / plan_required | 403 |
A 500 does not by itself tell you whether sessions were invalidated: it can occur before invalidation (retry) or after invalidation when one or more credential revokes then failed. Retrying is always safe (idempotent): the session bump repeats harmlessly and already-revoked credentials are unaffected.
Event: org_member_signed_out.
Confirmation copy must say: this signs the person out of every Fastio org and personal use, not only this one, and OAuth apps may keep working for up to 1 hour.
Get Member Compliance Report
/current/org/{org_id}/member/{user_id}/report/
A best-effort compliance snapshot of one member: identity, org/workspace membership, credential and login counts, last activity. Auth: admin+ or auditor. Enterprise.
Target must be a live org member (404 member_not_found). Each section is independently best-effort — see partial/truncated.
curl example
curl -X GET "https://api.fast.io/current/org/1234567890123456789/member/9876543210987654321/report/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"report": {
"user": {
"id": "9876543210987654321", "email_address": "a@example.com", "first_name": "A", "last_name": "B",
"created": "2025-01-15 10:30:00 UTC", "two_factor": true, "password_set": true,
"sso": {"enforced": true, "exempt": false, "org_domain": "example.com"},
"managed_account": true, "locked": false, "suspended": false
},
"org_membership": {"permission": "admin", "compliance_auditor": false, "member_added_at": "2026-01-02 09:00:00 UTC"},
"workspaces": [{"id": "4123456789012345678", "name": "Legal", "permission": "member", "member_added_at": "2026-01-03 09:00:00 UTC"}],
"credentials": {"api_keys": 3, "oauth_sessions": 2},
"logins": {
"last_login": "2026-09-23 07:10:00 UTC", "window_days": 90, "count": 41,
"recent": [{"created": "2026-09-23 07:10:00 UTC", "method": "sso", "ip": "203.0.113.7", "country": "US", "user_agent": "Mozilla/5.0 ..."}]
},
"last_activity": "2026-09-23 07:12:00 UTC"
},
"partial": [], "truncated": []
}
Response fields
| Field | Type | Description |
|---|---|---|
| report.user.managed_account | boolean | true when the email is on a verified org SSO domain — predicts credential revocability above |
| report.org_membership.compliance_auditor | boolean | Whether this member holds the auditor flag |
| report.workspaces | array | This org's workspaces only, from the member's own workspace memberships |
| report.credentials | object | Counts only (api_keys, oauth_sessions) — reach org_only|user_wide|mixed only. For rows, call List Org Credentials with user_id= |
| report.logins.recent | array | Newest 20. For full history, call events/search with event=user_login&calling_user_id= — see the Compliance & Audit events reference |
| report.last_activity | string or null | Excludes legal-hold events unless the viewer can manage holds |
| partial / truncated | array of string | Section names (user|workspaces|credentials|logins|last_activity) that failed, or were built from a capped read |
Error responses
| Reason | HTTP |
|---|---|
member_not_found | 404 |
compliance_access_required / scope_admin_required / plan_required | 403 |
Get Sharing Exposure Report
/current/org/{org_id}/reports/sharing/
Which workspaces have public shares/links or external (non-member, non-verified-domain) participants. Auth: admin+ or auditor. Enterprise.
Query parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| workspace_id | string | No | — | Switches to detail mode for that one workspace |
| only_exposed | string | No | false | true | false (literal strings — any other value is a 406). List mode only: skip workspaces whose summary counts are all zero |
| limit | integer | No | 20 | 1–20 workspaces per page (list mode) |
| cursor | string | No | — | Opaque keyset cursor |
| counts | string | No | "true" | "true" | "false" (literal strings). List mode only, ignored in detail mode. "false" skips the per-workspace exposure walk entirely — see below. |
counts=false (list mode only). Returns workspace rows only (the same workspace block — id, name) with no summary block and no per-workspace exposure walk; only_exposed is ignored. Same auth, and the same limit/cursor paging, as the default. This is meant for building an admin-wide workspace picker — for example the ai_workspaces allowlist editor above — cheaply at org scale (a large org can have on the order of a thousand workspaces).
{"result": true,
"workspaces": [{"workspace": {"id": "4123456789012345678", "name": "Legal"}}],
"pagination": {"has_more": true, "next_cursor": "…", "page_size": 20}}
Response (200 OK) — list mode, default counts=true
{
"result": true,
"workspaces": [{
"workspace": {"id": "4123456789012345678", "name": "Legal"},
"summary": {"public_shares": 2, "public_file_links": 5, "external_members": 7, "pending_external_invites": 1}
}],
"pagination": {"has_more": true, "next_cursor": "…", "page_size": 20}
}
List mode returns counts only (no per-share/link/member rows). It also enforces an internal per-page read budget, so a page can come back shorter than limit — or even with a single workspace — while pagination.has_more is still true; keep paging on has_more rather than assuming a short page means the org is exhausted.
Detail mode (?workspace_id=…) adds shares[], file_links[], external_members[], pending_external_invites[] to the one workspace row:
{
"workspace": {"id": "4123456789012345678", "name": "Legal"},
"summary": {"public_shares": 1, "public_file_links": 1, "external_members": 1, "pending_external_invites": 2},
"shares": [{
"id": "5123456789012345678", "name": "Q3 data room", "category": "portal", "share_type": "send",
"access_options": "Anyone with the link", "password_set": true, "expires": null,
"creator": {"id": "9876543210987654321", "email_address": "creator@example.com"},
"owner": {"id": "9876543210987654322", "email_address": "owner@example.com"},
"member_count": 12,
"external_members": [{"user_id": "9876543210987654323", "email_address": "vendor@example.com", "permission": "guest", "added_at": "2026-08-01 09:00:00 UTC"}],
"pending_external_invites": [{"email_address": "invitee@example.com", "permission": "guest", "created": "2026-09-01 09:00:00 UTC"}]
}],
"file_links": [{
"id": "6123456789012345678", "access_option": "anyone_with_link", "password_set": false,
"expires": null, "creator": {"id": "9876543210987654321", "email_address": "creator@example.com"},
"node_id": "2ltsuq4mjacuv7pgc5ydlxnsjwee4"
}],
"external_members": [{"user_id": "9876543210987654323", "email_address": "vendor@example.com", "permission": "member", "added_at": "2026-08-01 09:00:00 UTC"}],
"pending_external_invites": [{"email_address": "invitee2@example.com", "permission": "member", "created": "2026-09-02 09:00:00 UTC"}]
}
| Field | Description |
|---|---|
| shares[].id / name / category / share_type | The share's own identity fields |
| shares[].access_options | The existing share API's own text value — Only members of the Share or Workspace (default), Members of the Share, Workspace or Org, Anyone with a registered account, or Anyone with the link (a public link, counted in summary.public_shares). Not a machine token. |
| shares[].password_set | boolean — the password itself is never echoed |
| shares[].creator / owner | May differ after an ownership transfer; both are reported |
| shares[].member_count | Total member count on the share (org and external combined) |
| shares[].external_members[] / shares[].pending_external_invites[] | External participants/invites scoped to this one share |
| file_links[].id / node_id | The file-link id and the file node (opaque id) it points to |
| file_links[].access_option | anyone_with_link | any_registered | named_people |
| file_links[].password_set / expires / creator | Same meaning as on a share |
| external_members[] / pending_external_invites[] (workspace-level) | A non-org-member whose email is not on a verified org domain, at the workspace (not per-share) level |
Error responses
| Reason | HTTP |
|---|---|
workspace_not_found (not in this org) | 404 |
compliance_access_required / scope_admin_required / plan_required | 403 |
| invalid cursor | 406 |
| A share, member, link or invitation read failed, or a person's external status cannot be determined — retryable; no partial page is returned and the cursor does not advance | 500 |
Export the Audit Log
/current/org/{org_id}/audit/export/
Stream the org's audit log as a file. Auth: admin+ or auditor, admin-scope credential. Enterprise.
Replaces any client-side paging loop over events/search. Not a 5,000-row export: it streams until the range is exhausted or a per-request row cap is hit (then hands back a cursor to continue).
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| from / to | string | Yes | YYYY-MM-DD, inclusive, UTC. Range ≤ 366 days. |
| format | string | No | csv (default) | jsonl |
| category, event, workspace_id | string | No | Same names/meanings as events/search |
| user_id | string | No | The event's user (same meaning as events/search) |
| calling_user_id | string | No | The actor |
| cursor | string | No | Opaque value from a truncation trailer — bound to org, filters, range and format; send with identical other parameters. Valid 7 days. |
curl example
curl -X GET "https://api.fast.io/current/org/1234567890123456789/audit/export/?from=2026-08-01&to=2026-08-31&format=csv" \
-H "Authorization: Bearer {jwt_token}" -o audit.csv
Response: a streamed file, not a JSON envelope. Content-Type: text/csv; charset=UTF-8 or application/x-ndjson. Content-Disposition: attachment; filename="audit-<org>-<from>-<to>.<ext>".
Columns / JSONL keys: event_id, created, event, category, sub_category, calling_user_id, calling_user_email, event_user_id, workspace_id, share_id, object_id, ip, country, severity, metadata (metadata is a JSON string; every id is a string). CSV cells starting with = + - @ are prefixed with ' (formula-injection guard). Row visibility follows the caller's own audit-log rules (hold events owner/auditor only).
Stream end markers — the last line is always one of:
- Complete: CSV
# complete; rows=12345/ JSONL{"_complete":true,"rows":12345}. - Truncated (per-request row cap): CSV
# truncated; next_cursor=…/ JSONL{"_truncated":true,"next_cursor":"…"}. Re-request withcursor=to continue — a continuation response's CSV has no header row (concatenate parts as-is); JSONL has no header either way. - Neither marker — the stream was cut (network/timeout); the file is incomplete. Retry from the last
next_cursoryou have, or from scratch.
Error responses (before the stream starts, normal JSON envelope)
| Reason | HTTP | Meaning |
|---|---|---|
| (none) | 406 | from/to not a valid date, from later than to, or another parameter failed validation |
range_too_large | 406 | params.max_days = 366 |
range_before_retention | 406 | params.earliest_date — refused, not clamped |
cursor_invalid | 406 | Tampered, expired (>7 days), or parameters changed |
workspace_not_found | 404 | workspace_id not in this org |
export_in_progress | 429 | One export per org at a time. Always carries Retry-After (seconds until the held slot expires — worst case 30 min); released sooner on finish, failure, or a detected client disconnect. |
compliance_access_required / scope_admin_required / plan_required | 403 | — |
Event: org_audit_exported.
Grant/Revoke the Auditor Flag
/current/org/{org_id}/member/{user_id}/compliance-auditor/
Auth: the org owner only — not admins.
Request parameters
| Name | Type | Required | Description |
|---|---|---|---|
| enabled | boolean | Yes | — |
curl example
curl -X POST "https://api.fast.io/current/org/1234567890123456789/member/9876543210987654321/compliance-auditor/" \
-H "Authorization: Bearer {jwt_token}" \
-d "enabled=true"
Response (200 OK)
{"result": true, "user_id": "9876543210987654321", "compliance_auditor": true}
Enabling needs the Enterprise plan (on a current subscription); disabling always works, on any plan and on a lapsed subscription. Setting the current value, or targeting the owner (who already has every auditor power), is an idempotent 200 with no change. Removing the member from the org clears the flag; a plan lapse leaves it stored but inert; reviving an expired membership also clears it, so the flag never returns without the owner re-granting it.
Error responses (checked in this order)
| Reason | HTTP | Meaning |
|---|---|---|
owner_required | 403 | Checked before the target is parsed or read, so a non-owner learns nothing about the target |
scope_admin_required | 403 | Non-admin-scope credential |
member_not_found | 404 | — |
plan_required | 403 | Enable only — also needs a current subscription |
successor_role_too_low | 406 | Enabling for a guest/view-rank member |
membership_changed | 409 | The membership row changed while this request was in flight (e.g. a concurrent demotion) — re-read the member and retry |
Events: org_compliance_auditor_changed, plus the existing membership_updated.
Legal Holds (Enterprise plan)
A legal hold preserves a workspace's or a member's content against destruction while litigation, an investigation, or another compliance need is active. Holds are managed by the org owner (any plan) or by an auditor (Enterprise) — org admins cannot manage holds, even though they can manage everything else (403 legal_hold_access_required). Placing a hold needs the Enterprise plan; listing, viewing and releasing an existing hold work on any plan, so a lapsed org can still lift its own holds. All four endpoints also need an admin-capable credential — a login session, or an API key/OAuth token with rwa scope on the org; any other credential gets 403 scope_admin_required.
What a hold does. A hold preserves, not freezes — members keep working normally. Deleting, moving, emptying trash, and closing a workspace or share all keep working exactly as before. What changes is permanent destruction: purge of trashed content, version pruning, automatic share expiry, and profile/account deletion are all held back for anything the hold covers, and quietly resume once the hold is released. A user never sees an error because of a hold — purging held content still reports success; the content is retained instead of destroyed. Held content, including deleted-but-retained bytes, keeps counting toward the org's billed storage.
Scope. A workspace hold covers the workspace itself, every one of its shares, and its e-sign envelopes — including ones created after the hold is placed. A person hold covers content the member authored across the org's workspaces, plus their own account. Person-hold coverage has two limits: files a cloud-sync import brought in with no resolvable owner, and copies of the person's files made by someone else, are attributed to the importer/copier and are not covered by a person hold — a workspace hold covers both of those regardless of who authored them.
Confidentiality. The legal_hold_created / legal_hold_released audit events never carry the hold's reason, and — unlike the rest of the audit log — they are visible only to the org owner or an entitled auditor, even in export and summarize, and only when the request itself carries a login session or an API key/OAuth token with an org admin-capable (rwa) scope on that same org — a user:*:rw-scoped key, or a key scoped to a different org, never sees them, whoever owns it. A search filtered to one of these two event types by a caller who does not qualify comes back as an empty page rather than an error — event names are matched exactly, so filtering on a case, whitespace, or accent variant of a hold-event name, or on any event value that is not itself a plain lowercase name, also returns an empty page rather than a broader match. They are never sent to a configured SIEM stream.
Hold object
{
"id": "sabdqahjioknnh36puhwmjy5nluifp",
"org_id": "1234567890123456789",
"status": "active",
"target_type": "workspace",
"target": {"id": "4123456789012345678", "name": "Legal", "closed": false},
"name": "Matter 2026-114",
"reason": "Litigation hold per counsel",
"created_by": {"id": "9876543210987654321", "email_address": "owner@example.com"},
"created": "2026-09-23 16:37:29 UTC",
"released_by": null,
"released": null
}
For a person hold, target is {"id": "…", "email_address": "…", "first_name": "…", "last_name": "…"}. A target / created_by / released_by profile that no longer loads keeps its id with the display fields null (a workspace's closed reads true in that case) — a hold outlives the workspace it covers, since it can be placed on one that is already closed.
Place a Hold
/current/org/{org_id}/legal-holds/
Auth: the org owner, or an auditor. Enterprise plan required.
Request parameters
| Name | Type | Required | Description |
|---|---|---|---|
| target_type | string | Yes | workspace | user |
| target_id | string | Yes | 19-digit numeric id of the workspace or member to hold |
| name | string | Yes | 1-255 characters, trimmed |
| reason | string | No | Up to 2000 characters. Never echoed to the audit-log event |
A workspace target may already be closed, as long as it has not been purged. A person target must be a current org member. Several holds may target the same workspace or person at once.
curl example
curl -X POST "https://api.fast.io/current/org/1234567890123456789/legal-holds/" \
-H "Authorization: Bearer {jwt_token}" \
-d "target_type=workspace" -d "target_id=4123456789012345678" \
-d "name=Matter 2026-114" -d "reason=Litigation hold per counsel"
Response (200 OK)
result: true and legal_hold — the new hold, in the hold object shape shown above.
Error responses
| Reason | HTTP | Meaning |
|---|---|---|
| (field validation) | 406 | Missing or malformed target_type, target_id, name or reason |
legal_hold_target_not_found | 404 | The workspace/member id is not in this org |
legal_hold_target_not_member | 406 | A user target is not a current org member |
legal_hold_access_required | 403 | Caller is neither the owner nor an entitled auditor |
plan_required | 403 | Org is not on the Enterprise plan |
(no reason) | 503 | Retryable — the hold store could not be reached. Nothing was placed. |
Event: legal_hold_created (never carries reason; visible only to the owner/auditors).
List Holds
/current/org/{org_id}/legal-holds/
Auth: the org owner, or an auditor. Any plan — a lapsed org can still see its holds.
Query parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| status | string | No | active | active | released | all |
| limit | integer | No | 100 | 1-500 |
| offset | integer | No | 0 | — |
Response (200 OK)
{
"result": true,
"legal_holds": [
{
"id": "sabdqahjioknnh36puhwmjy5nluifp",
"org_id": "1234567890123456789",
"status": "active",
"target_type": "workspace",
"target": {"id": "4123456789012345678", "name": "Legal", "closed": false},
"name": "Matter 2026-114",
"reason": "Litigation hold per counsel",
"created_by": {"id": "9876543210987654321", "email_address": "owner@example.com"},
"created": "2026-09-23 16:37:29 UTC",
"released_by": null,
"released": null
}
],
"pagination": {"total": 3, "limit": 100, "offset": 0, "has_more": false}
}
Errors: legal_hold_access_required (403).
Hold Detail + Impact
/current/org/{org_id}/legal-holds/{hold_id}/
Auth: the org owner, or an auditor. Any plan.
Adds impact to the hold object. For a workspace hold: {"bytes": …, "files": …, "folders": …, "shares": …}, totalled across the workspace, its shares (a folder share that shares the workspace's own storage is not double-counted), and its e-sign envelopes — trashed and pending-deletion content included, the same figures billing uses. impact_available is true only when every part of the count succeeded; otherwise impact is null (retry later). A person hold always reports "impact": null, "impact_available": false. There is no separate preview endpoint — this is also where to check a hold's footprint right after placing it.
Response (200 OK)
{
"result": true,
"legal_hold": {
"id": "sabdqahjioknnh36puhwmjy5nluifp",
"org_id": "1234567890123456789",
"status": "active",
"target_type": "workspace",
"target": {"id": "4123456789012345678", "name": "Legal", "closed": false},
"name": "Matter 2026-114",
"reason": "Litigation hold per counsel",
"created_by": {"id": "9876543210987654321", "email_address": "owner@example.com"},
"created": "2026-09-23 16:37:29 UTC",
"released_by": null,
"released": null,
"impact": {"bytes": 123456789, "files": 1200, "folders": 80, "shares": 4},
"impact_available": true
}
}
Errors: legal_hold_not_found (404 — an unknown id and another org's id answer identically); legal_hold_access_required (403).
Release a Hold
/current/org/{org_id}/legal-holds/{hold_id}/release/
Auth: the org owner, or an auditor. Never plan-gated — a lapsed org's owner can always lift a hold. No body.
curl example
curl -X POST "https://api.fast.io/current/org/1234567890123456789/legal-holds/sabdqahjioknnh36puhwmjy5nluifp/release/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"legal_hold": {
"id": "sabdqahjioknnh36puhwmjy5nluifp",
"org_id": "1234567890123456789",
"status": "released",
"target_type": "workspace",
"target": {"id": "4123456789012345678", "name": "Legal", "closed": false},
"name": "Matter 2026-114",
"reason": "Litigation hold per counsel",
"created_by": {"id": "9876543210987654321", "email_address": "owner@example.com"},
"created": "2026-09-23 16:37:29 UTC",
"released_by": {"id": "9876543210987654321", "email_address": "owner@example.com"},
"released": "2026-09-24 10:02:00 UTC"
}
}
Releasing an already-released hold returns the same 200 idempotently and emits nothing further.
Errors: legal_hold_not_found (404); legal_hold_access_required (403).
Event: legal_hold_released (only on the release that actually changes the hold's status).
The hold is lifted lazily, not instantly — deferred destruction of the content it covered resumes automatically on its own schedule. There is nothing else to call; do not treat a hold that still shows released items pending cleanup as a bug.
Refusals Elsewhere Caused by a Hold
POST /current/org/{org_id}/close/while any hold on the org is active refuses409 legal_hold_active— "This organization cannot be closed while a legal hold is active. Release all legal holds first." Release every hold, then retry. See Close Organization above.POST /current/user/close/for an account covered by a person hold in any org refuses409 account_close_blocked— "This account cannot be closed right now. Please contact your organization." This wording is deliberately generic: it never confirms or names a legal hold, since the account holder may not be entitled to know one exists. See Auth & Users.- Deleting, trashing, moving a workspace or share, and removing a member, are never refused by a hold — only permanent destruction is held back.
Read org.capabilities.can_manage_legal_holds (see Get Org Details above) to decide whether to show this UI at all, rather than guessing from role alone.
SIEM Audit Stream (Enterprise plan)
One generic HMAC-signed HTTPS webhook per org, streaming the org's audit-log event set (the same events visible through Compliance & Audit above) to your own SIEM (Splunk, Datadog, or any HTTPS receiver). At-least-once delivery, batched, approximately ordered. Legal-hold events are never streamed — they stay owner/auditor-only in the audit log, the same restriction as everywhere else.
Semantics:
- Delivery is at-least-once, in batches of up to 500 events (fewer when the batch would exceed about 900 KB of JSON; a single larger event is sent on its own). Ordering is approximate (by event time); a late-recorded event can arrive in a later batch. Dedupe on
event_idon your side. - Each delivery attempt has its own
delivery_id; a retry may regroup events differently. - Latency is at least a couple of minutes. An event recorded well after its own timestamp may not make it into the stream at all — it still appears in the audit log and in Audit Export above.
- The stream starts at “now” when created — there is no backfill. Use Audit Export for history.
- A stream that fails continuously for a sustained period auto-pauses (
state: "paused_failing") and raises anaudit_stream_pausedsecurity alert (see Security Alerts below); re-enable it to retry. - If the org itself is later deleted, its stream configuration is removed automatically — there is nothing to clean up on your side.
- If the signing secret is briefly unreadable on our side, delivery pauses and retries automatically; a secret rotation issued during that window takes effect on the next retry, not immediately.
- Splunk/Datadog presets are a future release; this is a generic webhook today.
Who:
GET: admin+, or an entitled compliance auditor.- Writes: admin+ with an admin-scope credential.
- Enterprise is required for every call except
GET, disabling (enabled=false), andDELETE, which work on any plan. On a non-Enterprise org,GETreturns the stored config withstate: "paused_plan"instead of refusing. Creating, enabling, rotating the secret, and testing still need Enterprise.
Get Audit Stream
/current/org/{org_id}/audit/stream/
curl example
curl -X GET "https://api.fast.io/current/org/1234567890123456789/audit/stream/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"stream": {
"enabled": true, "state": "active",
"url": "https://siem.example.com/fastio", "secret_masked": "****a1b2",
"created": "… UTC", "updated": "… UTC", "updated_by": "…",
"delivered_through": "2026-09-23 16:30:00 UTC", "events_delivered": 120394,
"last_attempt_at": "… UTC", "last_success_at": "… UTC", "last_status_code": 200,
"last_error_class": null, "consecutive_failures": 0, "gap_from": null, "gap_to": null
}
}
Response fields
| Field | Type | Description |
|---|---|---|
| stream.state | string | active|paused_failing|paused_plan. A paused stream keeps enabled: true — show “paused” plus the reason |
| stream.last_error_class | string/null | network|timeout|ssrf_blocked|http_4xx|http_5xx|dns_transient|plan_required|null |
| stream.gap_from / gap_to | string/null | Set when events aged out of retention while the stream was paused — there is a gap in what was ever delivered |
| stream.secret_masked | string | The signing secret is never echoed in full — only the last 4 characters |
Errors: 404 stream_not_configured (show the “Set up” state); 403 compliance_access_required.
Create or Update Audit Stream
/current/org/{org_id}/audit/stream/
Request parameters
| Name | Type | Required | Description |
|---|---|---|---|
| url | string | Required on create | HTTPS only, public host, no credentials in the URL, ≤2048 chars |
| enabled | boolean | No | — |
Create: returns the config plus "secret": "whsec_…" — shown once. Store it immediately; it cannot be retrieved again (only rotated). The stream starts delivering from “now”.
Update: secret is absent from the response. Re-enabling a paused or disabled stream resets the failure count and retries immediately.
curl example
curl -X POST "https://api.fast.io/current/org/1234567890123456789/audit/stream/" \
-H "Authorization: Bearer {jwt_token}" \
-d "url=https://siem.example.com/fastio" -d "enabled=true"
Error responses
| Reason | HTTP | Meaning |
|---|---|---|
stream_url_rejected | 406 | — |
stream_exists | 409 | A concurrent create raced you |
stream_secret_unavailable | 503 | Nothing was stored — retry later |
plan_required | 403 | — |
| (field validation) | 406 | No url on create, or nothing to change |
Event: org_audit_stream_updated.
Delete Audit Stream
/current/org/{org_id}/audit/stream/
Removes the stream configuration.
Response (200 OK)
{"result": true}
Errors: 404 stream_not_configured. Event: org_audit_stream_deleted.
Rotate Signing Secret
/current/org/{org_id}/audit/stream/rotate-secret/
Issues a new signing secret, effective immediately — there is no grace window, so update your receiver before or right after calling this.
Response (200 OK)
{"result": true, "secret": "whsec_…"}
Errors: same as Create or Update Audit Stream above. Event: org_audit_stream_updated with change: "secret_rotated".
Send a Test Delivery
/current/org/{org_id}/audit/stream/test/
Sends a single test delivery to the configured URL right now.
Response (200 OK)
{"result": true, "delivered": false, "status_code": 502, "error_class": "http_5xx", "latency_ms": 840}
— a failed delivery is a result, not an error; check delivered. Throttled to a small number of calls per org — 429 with Retry-After when you call it too soon after the last one.
The test delivery uses the same wire format and signature as a real one (below) and carries one synthetic record with event: "audit_stream_test". It is never stored as an event and never moves the stream's delivery position — have your receiver accept it (2xx) and otherwise ignore it.
Wire Format
POST <your url>
Content-Type: application/json
{"stream": "fastio.audit", "org_id": "…", "delivery_id": "…", "sent_at": "… UTC", "events": [ … ]}
Headers: X-Fastio-Delivery-Id; X-Fastio-Timestamp (unix seconds); X-Fastio-Signature: v1=<hex HMAC-SHA256(secret, "<timestamp>.<raw request body>")>.
Verifying the signature: recompute HMAC-SHA256(your_secret, timestamp + "." + raw_body) over the raw (unparsed) request body, hex-encode it, and compare to the value after v1= using a constant-time comparison. Reject the request if the timestamp is more than 5 minutes old (also guards against replay). Do not trust an unsigned or mis-signed delivery.
Event record fields: event_id, event, category, sub_category, severity, created, org_id, workspace_id, share_id, actor_user_id, subject_user_id, object_id, description, metadata, ip, country (every id is a string). An org_security_alert record carries no event user and no actor, so its top-level subject_user_id and actor_user_id are both null — read the alert's subject from metadata.subject_user_id instead.
Your receiver should: verify the signature over the raw body; reject stale timestamps; dedupe on event_id (delivery is at-least-once and a retry can regroup events); treat ordering as approximate; respond with any 2xx to acknowledge (redirects are not followed — respond directly).
Security Alerts (Enterprise plan)
Proactive notification — an audit-log event plus an email — when one of a handful of security-shaped patterns happens in the org: a new-country sign-in, a geo/IP policy block, an unusually large burst of deletions or downloads by one person, a new credential that can reach the org, or the audit stream above pausing itself. Enterprise plan only.
Setting: the security_alerts field on Update Organization above (admin+). Unset means every alert is on and compliance auditors are included as recipients — the default for an Enterprise org that has never configured this. enabled: [] turns every alert off; clear with "" to return to the default.
Alert types:
| Alert | Fires when |
|---|---|
login_new_country | A member signs in from a country with no prior sign-in in the org's retained history (never for an unresolvable location) |
geo_policy_block | A request was refused by the org's geo/IP access policy (see Access Policy (Geo / IP Restrictions) above) |
mass_delete | One member deletes, purges, or empties trash on an unusually large number of files/folders in a short window |
mass_download | One member downloads, zips, or directly reads an unusually large number of files in a short window |
credential_created | A new API key or OAuth grant is created that can reach this org (any wildcard-scoped credential counts) |
audit_stream_paused | The SIEM audit stream (see above) auto-pauses after sustained delivery failure — always on, cannot be disabled |
Repeated alerts of the same kind are throttled so a single ongoing pattern does not flood you with duplicates, and each alert type is capped per org per day — once the cap is hit, further alerts of that kind are suppressed for the rest of the day and the next alert's notification says so.
Output:
- An
org_security_alertaudit-log event (severity high) — visible the same way as the rest of Compliance & Audit, throughevents/search. It carries no event user — the subject of the alert (if any) rides only insubject_user_id, so a member is never shown an alert about themselves in their own personal activity. It also carries nocalling_userand noactor: the alert is raised by the platform, not by the request that tripped it — render its actor as “System”. - An email to the org owner and admins, plus compliance auditors when
include_auditorsis on (default). Each recipient gets at most one email per alert, capped per day so a burst cannot flood an inbox.
In-app: GET /current/events/search/?org_id=…&visibility=external_audit_log&event=org_security_alert (add &acknowledged=false for an unread badge). Acknowledge with POST /current/event/{event_id}/ack/. Acknowledgement is per viewer — one admin acknowledging an alert does not clear it for other admins or auditors. A compliance auditor who is not an admin may open event/{event_id}/details/ and acknowledge an org_security_alert row the same as an admin can.
Errors: 406 params[] {name: "security_alerts"} for an unknown alert name in the setting write; 403 plan_required when the write would turn an alert on, or turn auditors on, on a non-Enterprise org (turning alerts off, removing auditors, or clearing is allowed on any plan).
Event: org_security_alert per alert. org_updated's policy_changes also gains security_alerts ({before, after}) when the setting itself changes.
Organization Discovery
List Internal Orgs
/current/orgs/list/
Auth required. Lists orgs where the user is a direct member (member: true). Paginated: optional limit (1-500, default 100) and offset query parameters; the response carries pagination (total, limit, offset, has_more).
Non-admin/non-owner members only see orgs with active subscriptions; admins and owners always see their orgs.
curl example
curl -X GET "https://api.fast.io/current/orgs/list/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"orgs": [
{
"id": "1234567890123456789",
"domain": "acme-corp",
"name": "Acme Corporation",
"description": "Leading provider of innovation",
"logo": "https://assets.fast.io/org/logo.png",
"accent_color": {"color": "#0066CC", "opacity": 100},
"closed": false,
"suspended": false,
"subscriber": true,
"user_status": "joined",
"member": true
}
],
"pagination": {"total": 1, "limit": 100, "offset": 0, "has_more": false}
}
Response fields
| Field | Type | Description |
|---|---|---|
| orgs | array | Array of organization objects |
| orgs[].id | string | 19-digit numeric organization ID |
| orgs[].domain | string | URL-safe subdomain |
| orgs[].name | string/null | Display name |
| orgs[].description | string/null | Description |
| orgs[].logo | string/null | Logo asset URL |
| orgs[].accent_color | object/null | Brand color ({color, opacity}) |
| orgs[].closed | boolean | Whether org is closed |
| orgs[].suspended | boolean | Whether org is suspended |
| orgs[].subscriber | boolean | Whether org has an active subscription |
| orgs[].user_status | string | "joined" or "available" |
| orgs[].member | boolean | Always true for this endpoint |
Subscription filtering
| User Role | Behavior |
|---|---|
| Owner | Always sees the org |
| Admin | Always sees the org |
| Member | Only sees the org if it has an active subscription |
List External Orgs
/current/orgs/list/external/
Auth required. Lists orgs where the user has access only through workspace membership (member: false). Paginated: optional limit (1-500, default 100) and offset query parameters; the response carries pagination (total, limit, offset, has_more).
curl example
curl -X GET "https://api.fast.io/current/orgs/list/external/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"orgs": [
{
"id": "1234567890123456780",
"domain": "partner-corp",
"name": "Partner Corporation",
"description": "External partner organization",
"logo": null,
"accent_color": {"color": "#FF6600", "opacity": 100},
"closed": false,
"suspended": false,
"subscriber": true,
"user_status": "available",
"member": false
}
],
"pagination": {"total": 1, "limit": 100, "offset": 0, "has_more": false}
}
Response fields
| Field | Type | Description |
|---|---|---|
| orgs | array | Array of external organization objects |
| orgs[].user_status | string | Always "available" for external orgs |
| orgs[].member | boolean | Always false for this endpoint |
List All Orgs
/current/orgs/all/
Auth required. Lists all accessible orgs (joined + invited). Paginated: optional limit (1-500, default 100) and offset query parameters; the response carries pagination (total, limit, offset, has_more).
curl example
curl -X GET "https://api.fast.io/current/orgs/all/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"orgs": [
{
"id": "1234567890123456789",
"domain": "acme-corp",
"name": "Acme Corporation",
"description": "Leading provider of innovation",
"logo": "https://assets.fast.io/org/logo.png",
"accent_color": {"color": "#0066CC", "opacity": 100},
"closed": false,
"suspended": false,
"user_status": "joined"
}
],
"pagination": {"total": 1, "limit": 100, "offset": 0, "has_more": false}
}
Response fields
| Field | Type | Description |
|---|---|---|
| orgs[].user_status | string | "joined" (already a member) or "available" (pending invitation) |
List Available Orgs
/current/orgs/available/
Auth required. Lists orgs available to join (not yet joined). Excludes orgs the user is already a member of.
curl example
curl -X GET "https://api.fast.io/current/orgs/available/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"orgs": [
{
"id": "1234567890123456782",
"domain": "new-company",
"name": "New Company",
"description": "An org you can join",
"logo": null,
"accent_color": {"color": "#FF6600", "opacity": 100},
"closed": false,
"suspended": false
}
]
}
Check Domain Availability
/current/orgs/check/domain/{domain_name}
Auth required. Checks if an org domain name is available for use.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
| {domain_name} | string | Yes | The domain name to check for availability. |
curl example
curl -X GET "https://api.fast.io/current/orgs/check/domain/acme-corp" \
-H "Authorization: Bearer {jwt_token}"
Response (202 Accepted) — domain available
{
"result": true
}
Error responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | "An invalid name was supplied." | Domain format is invalid |
1658 (Not Acceptable) | 406 | "The supplied name is restricted." | Domain is reserved |
1658 (Not Acceptable) | 406 | "The supplied name is already in use." | Domain is taken |
List Industries
/current/orgs/industries/
Auth required. Returns available industry types for org profiles.
curl example
curl -X GET "https://api.fast.io/current/orgs/industries/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"unspecified": {
"title": "Unspecified",
"description": "No specific industry or sector."
},
"technology": {
"title": "Technology",
"description": "Companies that develop or provide software, hardware, and IT services."
},
"healthcare": {
"title": "Healthcare",
"description": "Organizations providing medical services, healthcare management, and patient care."
},
"financial": {
"title": "Financial Services",
"description": "Businesses offering banking, investment, and financial management services."
}
}
Response fields
| Field | Type | Description |
|---|---|---|
| {key} | object | Keyed by the machine-readable industry identifier (use this key in create/update requests). Every industry is returned, starting with unspecified. |
| {key}.title | string | Human-readable display name |
| {key}.description | string | Brief description of the industry category |
Billing
The unsubscribed tier is identified as unpaid. It was previously
reported as free; clients should accept both for now and treat them as the same tier. Where a plan
identifier is accepted as INPUT, free is still accepted and resolves to unpaid.
unpaid is not a purchasable plan: it never appears in
GET /current/org/billing/plan/list/, and subscribing to it is refused.
Where the identifier appears for an org with no active subscription, and where it does not:
| Surface | What an unsubscribed org returns |
|---|---|
The org's raw plan field (org details and the org list) | "unpaid" |
POST /current/org/{org_id}/billing/ → billing_status.current_plan | The full plan object, with name: "unpaid", title: "Unpaid", category: "unpaid" |
GET /current/org/{org_id}/billing/details/ → billing_status.current_plan | {} — an empty object, not a plan named unpaid. This endpoint fills current_plan only for an org whose subscription is currently active |
billing_status.previous_plan | The full plan object, with name: "unpaid", when the previous plan was the unsubscribed tier |
Do not read an empty current_plan as "no plan" or as an error. On
GET .../billing/details/ it is the normal shape for an org that is not a current subscriber. To learn
which tier such an org is on, read the org's plan field rather than this object.
Preview a Plan Change
/current/org/{org_id}/billing/preview/?billing_plan={plan}
Auth required. Admin or above. Returns what a plan change will cost before it is made. Read-only: creates no invoice and changes no subscription.
Use this before POST /current/org/{org_id}/billing/ whenever the org already has a subscription. A change that increases committed spend — a higher tier, or monthly to annual on the same tier — is invoiced immediately rather than at the next cycle, so the card is charged in the same interaction the customer confirms. Show them this figure first.
Request parameters
| Name | Type | Required | Description |
|---|---|---|---|
| billing_plan | string | Yes | Target plan ID (a valid, currently-offered paid plan) |
curl example
curl -X GET "https://api.fast.io/current/org/1234567890123456789/billing/preview/?billing_plan=enterprise_v2_monthly" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"preview": {
"amount_due_cents": 20132,
"currency": "usd",
"proration_date": 1756670400,
"source_plan": "business_v3_monthly",
"target_plan": "enterprise_v2_monthly",
"spend_increasing": true,
"ends_trial": false
}
}
| Field | Type | Description |
|---|---|---|
| preview.amount_due_cents | integer | Total due today, in cents, including tax. Divide by 100 before display. |
| preview.currency | string | ISO currency code |
| preview.proration_date | integer | Unix timestamp this quote was priced at. Pass it back on the change request so the swap prices the same instant — otherwise the customer can be charged a different figure from the one they agreed to, simply because time passed between the two calls. |
| preview.source_plan | string | Plan the subscription currently holds |
| preview.target_plan | string | Plan being previewed |
| preview.spend_increasing | boolean | true when the change increases committed spend and will therefore invoice immediately |
| preview.ends_trial | boolean | true when confirming also ends a running trial. The copy must say so — “this ends your trial and charges $X today” reads very differently from “this charges $X today”. |
Errors
| Error Code | HTTP Status | Cause |
|---|---|---|
10176 | 406 | billing_plan missing or not a currently-offered plan |
10737 | 429 | The billing system is busy with another change for this org — retry shortly |
10764 | 406 | No preview is available — most commonly because the org has no subscription yet, and a first subscription is not a “change” to price. ⚠ Do NOT assume money is due. A first subscription charges $0 today when the plan offers a trial (pricing.free_days > 0) and the org is eligible (billing_status.free_trial_eligible); it charges the plan's list price only when neither holds. Decide from those two fields — not from the absence of a preview, and not from a local upgrade/downgrade classifier, which will read free → paid as a spend increase and tell a customer starting a trial that they are being charged. |
Create or Update Subscription
/current/org/{org_id}/billing/
Auth required. Admin or above. Creates or updates the org's billing subscription.
Changing plan while a cancellation is scheduled cancels the pending cancellation: the subscription continues on the new plan. (A plan change still awaiting card authentication cancels it once the payment completes.) A plan change that may charge immediately may be refused with error 10777 when the scheduled cancellation takes effect within the next day: reactivate the subscription first, then change plan.
Request parameters
| Name | Type | Required | Description |
|---|---|---|---|
| billing_plan | string | No | Target plan ID (must be a valid, currently-offered paid plan, e.g., "starter_monthly", "business_v3_monthly", "enterprise_v2_monthly"). Each plan also has an annual variant (e.g., "business_v3_annual"). Plan IDs that are not currently offered are rejected here (existing orgs on them are unaffected). |
| proration_date | integer | No | The preview.proration_date from a prior GET .../billing/preview/ call. Pass it whenever you showed the customer a figure, so the invoice prices the instant they were quoted rather than drifting by however long they spent on the confirm dialog — a spend-increasing change collects immediately, so that drift is a real charge. Validated server-side. A quote in the future, or older than one billing period, is REFUSED with error 10765 rather than silently ignored — you showed the customer a figure, so repricing it quietly would charge them something they never agreed to. Recovery: request a fresh preview and confirm again, which shows them the correct current amount. |
| checkout | string | No | true (also 1, yes, on) to start a new subscription on a Stripe-hosted checkout page instead of confirming a SetupIntent in your own card form. Any other value, or omitting it, keeps the card-form flow. Only applies to an org with no active paid subscription and no unpaid subscription; a plan change or an unpaid-subscription recovery responds exactly as without it. See Hosted checkout below. |
| success_url | string | With checkout=true | Where the checkout page sends the browser after the card is accepted. Must be an https URL on the Fastio web app's own site (fast.io), with no user-info, backslashes or whitespace, at most 1024 characters. Existing query parameters and the fragment are kept; a session_id query parameter is added (replacing any you sent) and filled with the checkout session id on redirect. |
| cancel_url | string | With checkout=true | Where the checkout page sends the browser if the customer backs out. Same rules as success_url; nothing is added to it. |
curl example
curl -X POST "https://api.fast.io/current/org/1234567890123456789/billing/" \
-H "Authorization: Bearer {jwt_token}" \
-d "billing_plan=business_v3_monthly"
Response (201 Created) — new subscription
{
"result": true,
"billing_status": {
"active": false,
"free_trial_eligible": true,
"current_plan": { "...": "..." },
"customer": { "...": "..." },
"subscription": { "...": "..." },
"setup_intent": {
"id": "{setup_id}",
"client_secret": "{setup_id}_secret",
"status": "requires_payment_method"
},
"payment_intent": [],
"payment_recovery": [],
"public_key": "{public_key}"
}
}
Every field on this response is nested under billing_status — there is no root-level subscription, is_active, or is_trial_eligible.
This response shape is the same whether or not a trial applies. Confirm the billing_status.setup_intent.client_secret with the customer's card. No money moves at that moment — the SetupIntent is a $0 card capture that also performs any 3-D Secure authentication up front.
- Trial applies — the subscription starts in
trialingand the card is charged when the trial ends. - No trial (all annual plans, and any org that has subscribed before) — the subscription is created and charged immediately once the card is captured.
The card is inspected before any charge: a card refused by the funding rules (see payment-method/precheck/) does not produce a subscription, and the customer's billing address is taken from the card, so the first invoice is taxed correctly.
Hosted checkout (checkout=true) — Response (201 Created)
Send checkout=true with success_url and cancel_url to let a Stripe-hosted page take the card instead of your own form:
curl -X POST "https://api.fast.io/current/org/1234567890123456789/billing/" \
-H "Authorization: Bearer {jwt_token}" \
-d "billing_plan=business_v3_monthly" \
-d "checkout=true" \
-d "success_url=https://fast.io/billing/done" \
-d "cancel_url=https://fast.io/billing"
{
"result": true,
"billing_status": {
"active": false,
"free_trial_eligible": true,
"current_plan": { "...": "..." },
"customer": { "...": "..." },
"subscription": { "...": "..." },
"setup_intent": [],
"payment_intent": [],
"payment_recovery": [],
"public_key": "{public_key}",
"checkout": {
"id": "{checkout_session_id}",
"url": "https://{payment_provider_checkout_host}/...",
"expires_at": "2026-10-01 14:03:22 UTC",
"trial": true,
"trial_days": 30
}
}
}
| Field | Type | Description |
|---|---|---|
| billing_status.checkout.id | string | Checkout session id. The same value is substituted into success_url's session_id parameter on the way back. |
| billing_status.checkout.url | string | Redirect the browser here. |
| billing_status.checkout.expires_at | string | When the checkout page stops accepting the card (YYYY-MM-DD HH:MM:SS UTC). |
| billing_status.checkout.trial | boolean | Whether this checkout starts a free trial. |
| billing_status.checkout.trial_days | integer | Trial length in days; 0 when no trial applies. |
setup_intent is empty in this response — there is nothing to confirm in your own page. checkout.trial and checkout.trial_days state whether this checkout grants a free trial (an annual plan, for example, has none even when free_trial_eligible is true); when the org owner's trial eligibility cannot be verified, no trial is offered.
The hosted page accepts cards only and requires a billing address. Beside the submit button it states either the free trial (up to the plan's trial days, or until the included trial credits are used; nothing is charged to start it; then the plan price per month plus usage and applicable tax, unless cancelled) or the amount charged today (the plan price plus applicable tax, billed monthly or annually, with usage billed monthly). No fixed trial end date is promised.
Starting checkout again for the same plan, trial and return URLs while the open page still has at least 10 minutes left returns that same session, so a double-submit is safe. Any other start replaces it: earlier open checkout pages are closed and a new one is created. Starting the card-form flow (without checkout) also closes any open checkout page, so an old checkout tab cannot complete.
Neither this request nor the redirect back creates the subscription. It is created shortly afterwards, once the card is confirmed — exactly as in the card-form flow, including the trial/no-trial charging rules and the card funding rules. After the browser returns to success_url, poll GET /current/org/{org_id}/billing/details/:
billing_status.current_plan.nameequals the plan you started → done.billing_status.payment_recoveryis non-empty → send the customer topayment_recovery.invoice.hosted_invoice_urlto complete payment.billing_status.refusal_reasonis non-null → the card was refused and no subscription was created; let the customer start checkout again with a different card.- Otherwise keep polling, up to your own timeout.
Response (202 Accepted) — subscription updated. A change that settles right away (a downgrade, or an upgrade that needs no additional authentication) returns 202 with no body.
Response (202 Accepted) — payment action required. When a plan change may raise a charge and the card needs additional authentication (3-D Secure), the org's subscription stays on its current plan and the response carries a payment_recovery descriptor for the pending change:
{
"result": true,
"billing_status": {
"current_plan": { "name": "business_v3_monthly", "...": "..." },
"payment_recovery": {
"recoverable": true,
"status": "active",
"plan": "enterprise_v2_monthly",
"invoice": {
"id": "{invoice_id}",
"status": "open",
"amount_due": 20132,
"currency": "usd",
"hosted_invoice_url": "https://{payment_provider_host}/i/.../{invoice_id}"
},
"payment_intent": {
"id": "{payment_intent_id}",
"client_secret": "{payment_intent_id}_secret",
"status": "requires_action",
"requires_action": true
}
},
"payment_intent": { "...": "same object as billing_status.payment_recovery.payment_intent" }
}
}
billing_status.current_plan still names the plan the org is on today; payment_recovery.plan is the target of the pending change. payment_recovery.status here reports the subscription's own live status (active or trialing, not a fixed “pending” value) — recognize a pending upgrade as payment_recovery whose status is active or trialing, together with plan (the target). An unpaid subscription's recovery instead carries incomplete, past_due or unpaid. Confirm payment_recovery.payment_intent.client_secret with Stripe.js (a new card may be used) — the plan itself changes only once payment settles, moments later and asynchronously. payment_recovery disappearing on a later read is NOT itself a success or failure signal — it clears the instant the charge succeeds, before the plan changes. After a successful confirm, poll GET .../billing/details/ until billing_status.current_plan.name equals the plan you confirmed; that is the actual promotion. If the challenge is abandoned, the pending change expires after up to about 23 hours (sooner if the current billing period ends first). On an account that is current on its billing, abandoning the challenge simply leaves the org on its existing plan — no past-due status, no dunning. An account that is already behind on payment, or on an older subscription still on the prior billing mechanics, may still become past-due if the authentication is abandoned.
Re-posting the same target plan while a change is pending returns the same payment_intent rather than starting a new one — get a fresh GET .../billing/preview/ quote first (or omit proration_date), since a stale quote is refused. Posting a different plan, a downgrade, or the org's current plan first cancels the pending change; posting the current plan on its own just cancels it and leaves the subscription as it was.
Response (200 OK) — payment recovery: if the org already has an UNPAID subscription (status incomplete, past_due, or unpaid — e.g. a prior card was declined), no new subscription or setup intent is created. The response instead carries a non-empty billing_status.payment_recovery object (see Get Billing Details below for its shape); confirm its nested payment_intent.client_secret with a (new) card to pay the existing open invoice. While a subscription is in this state a plan switch is not applied — the outstanding invoice must be settled first, after which the plan can be changed once the subscription is active. (This is a genuinely unpaid subscription, distinct from the pending-upgrade case above — a pending upgrade's own invoice does not block a further plan change; posting a different plan simply cancels it, per the previous paragraph.)
Error responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | "An invalid plan was supplied." | Plan ID not recognized |
1605 (Invalid Input) | 406 | "That plan is not available for self-service subscription. Contact sales." | The plan is a contact-sales plan, not offered for self-service subscription |
1605 (Invalid Input) | 406 | "Cannot create subscription for the unpaid plan. Please select a paid plan." | Tried to subscribe to the unpaid plan (an org with no active subscription) |
1658 (Not Acceptable) | 406 | "An error occurred updating your subscription..." | Subscription update failed |
1658 (Not Acceptable) | 406 | "Your subscription is scheduled to cancel soon..." | A plan change that may charge immediately may be refused while the subscription is scheduled to cancel within the next day (error code 10777). Reactivate the subscription first, then change plan. |
1658 (Not Acceptable) | 406 | "An error occurred creating the payment intent..." | Intent creation failed |
1605 (Invalid Input) | 406 | "A valid success_url and cancel_url on this site are required." | checkout=true with a missing or invalid success_url / cancel_url (error code 143365) |
1658 (Not Acceptable) | 406 | "An error occurred starting checkout, please try again." | The hosted checkout page could not be started (error code 196606) |
Schedule Subscription Cancellation
/current/org/{org_id}/billing/
Auth required. Owner only. Schedules the org's subscription to cancel at the end of the current billing period. The customer keeps full access until cancel_at. Use PUT (below) to reverse the schedule before cancel_at is reached. A trial is scheduled to end at its trial end, so it is never charged.
One exception: a trial whose trial period has already ended but that has not yet been billed is cancelled immediately instead of at period end. Nothing is charged and nothing is prorated, access ends at once, and the response is already_cancelled with no cancel_at. It cannot be reversed with PUT; a new subscription is required.
Request Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| reason | string | No | Why the customer is cancelling: one of price, not_working, missing_feature, other. Passed on to the payment provider with the cancellation; not stored or returned by this API. |
curl example
curl -X DELETE "https://api.fast.io/current/org/1234567890123456789/billing/" \
-H "Authorization: Bearer {jwt_token}"
Optionally include reason to tell us why:
curl -X DELETE "https://api.fast.io/current/org/1234567890123456789/billing/?reason=price" \
-H "Authorization: Bearer {jwt_token}"
Response (202 Accepted)
{
"result": true,
"status": "scheduled_cancellation",
"message": "Your subscription is scheduled to end at the close of the current billing period.",
"cancel_at": 1735689600,
"cancel_at_period_end": true,
"closed": false
}
If a cancellation has already been scheduled (or already executed), or a trial whose trial period had already ended was just cancelled immediately:
{
"result": true,
"status": "already_cancelled",
"message": "Subscription is already cancelled"
}
Response fields
| Field | Type | Description |
|---|---|---|
| status | string | "scheduled_cancellation" or "already_cancelled" |
| message | string | Human-readable status message |
| cancel_at | integer/null | Unix timestamp when access will end. null only if the subscription record could not be re-read after scheduling. |
| cancel_at_period_end | boolean | Always true on a successful schedule |
| closed | boolean | Always false for the scheduled-cancel flow — the org remains open until cancel_at |
Notes
- The customer retains full subscriber access (and continues to count against billing) until
cancel_at. A trial cancelled after its trial period has already ended is the exception: it ends immediately, with no charge. current_period_endandcancel_atare also reflected onGET /current/org/{org_id}/billing/details/so UIs can render an "ends on YYYY-MM-DD" affordance.
Error responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1683 (Resource Missing) | 404 | "No subscription was found to cancel." | Org is not a subscriber |
1654 (Internal Error) | 500 | "Your subscription failed to be canceled..." | Cancellation failed |
1605 (Invalid Input) | 400 | "An invalid cancellation reason was supplied. Use one of: price, not_working, missing_feature, other." | reason was supplied but is not one of the allowed values |
Reactivate Subscription
/current/org/{org_id}/billing/
Auth required. Owner only. Reactivates a subscription whose cancellation was scheduled via DELETE /current/org/{org_id}/billing/ but has not yet executed. Clears cancel_at_period_end so the subscription renews normally.
curl example
curl -X PUT "https://api.fast.io/current/org/1234567890123456789/billing/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"status": "reactivated",
"message": "Your subscription has been reactivated and will renew at the end of the current billing period.",
"current_period_end": 1735689600,
"cancel_at_period_end": false
}
Response fields
| Field | Type | Description |
|---|---|---|
| status | string | Always "reactivated" on success |
| message | string | Human-readable status message |
| current_period_end | integer/null | Unix timestamp of the next renewal |
| cancel_at_period_end | boolean | Always false on success |
Notes
- Calling
PUTon a subscription that is not currently scheduled to cancel is a successful no-op. - Once
cancel_athas passed and the subscription has terminated, the org is no longer a subscriber andPUTreturns 404. UsePOST /current/org/{org_id}/billing/to start a new subscription instead.
Error responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1683 (Resource Missing) | 404 | "No active subscription was found to reactivate." | Org is not currently a subscriber |
1654 (Internal Error) | 500 | "Your subscription could not be reactivated, please contact support." | Reactivation failed |
Get Billing Details
/current/org/{org_id}/billing/details/
Auth required. Admin or above. Returns subscription/billing details.
curl example
curl -X GET "https://api.fast.io/current/org/1234567890123456789/billing/details/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"billing_status": {
"active": true,
"free_trial_eligible": false,
"current_plan": { "...": "..." },
"customer": { "...": "..." },
"subscription": { "...": "..." },
"setup_intent": { "...": "..." },
"payment_intent": { "...": "..." },
"payment_recovery": { "...": "..." },
"public_key": "{public_key}",
"refusal_reason": null
}
}
Every field on this response is nested under billing_status — there is no root-level subscription, is_active, or is_trial_eligible. billing_status.previous_plan is present only when the subscription is cancelled (see below).
Response fields
| Field | Type | Description |
|---|---|---|
| billing_status.subscription | object | Payment provider subscription details |
| billing_status.customer | object | Payment provider customer details |
| billing_status.setup_intent | object, or [] when none | Active setup intent if exists |
| billing_status.setup_intent.trial | boolean | Whether the intent was minted with a trial. Capped to false whenever billing_status.free_trial_eligible is false — including an intent minted earlier while the org was still eligible — so it never advertises a trial the org is not currently eligible for. |
| billing_status.payment_intent | object, or [] when none | Active payment intent if exists |
| billing_status.payment_recovery | object, or [] when empty | Present (non-empty) either when an existing subscription's invoice is unpaid — status incomplete, past_due, or unpaid (e.g. a declined first payment) — or when an active/trialing subscription has a plan change pending additional card authentication. For a pending plan change, status reports the subscription's own live status (active or trialing, not a fixed “pending” value) and plan is the pending target — read the two together to recognize this case. A third case: a trialing subscription whose early trial end (the usage-triggered conversion to paid) is waiting on card authentication or a successful charge — status is trialing and plan is the org’s CURRENT plan; completing the payment ends the trial and starts the paid period, and if it is not completed within about 23 hours the trial simply continues. Contains recoverable (bool), status, plan, subscription {id,status}, invoice {id,status,amount_due,currency,hosted_invoice_url}, and payment_intent {id,client_secret,status,requires_action}. invoice.hosted_invoice_url is populated in every case — a Stripe-hosted page where the customer can pay or complete authentication without signing in. The client completes payment by confirming that PaymentIntent's client_secret with a (new) card. On a reload mid-pending-change, this same object lets the client finish the challenge in place. An empty array [] when there is nothing to recover. |
| billing_status.active | boolean | Whether subscription is currently active |
| billing_status.free_trial_eligible | boolean | Whether a trial is available for this org. false once this org has ever subscribed, once this org is not the owner's first organization (permanent — a trial is only ever available on a user's first org, so an owner can still be true on that first org even while owning others), or once the owner has ever started a free trial on any organization (permanent, lifetime — one trial per user, ever). |
| billing_status.current_plan | object | Full plan-details object for the org's current plan. Filled only while the subscription is ACTIVE — an org that is not a current subscriber returns {} here, NOT a plan named unpaid. That is the normal shape for an unsubscribed org, not an error; read the org's plan field to learn its tier. (The POST /current/org/{org_id}/billing/ response differs: it returns the full object with name: "unpaid".) |
| billing_status.previous_plan | object | Full plan-details object for the org's previous plan. Present only when the subscription is cancelled. Reports name: "unpaid" when the previous plan was the unsubscribed tier. |
| billing_status.public_key | string | Payment provider publishable key |
| billing_status.refusal_reason | string/null | Why the most recent card submitted was refused, when it was refused by the card funding rules and no subscription was created: "prepaid_card_refused" (prepaid cards) or "debit_card_amount_refused" (debit cards on plans charging more than $250). null otherwise, and always null while the org has an active paid subscription. Kept for about an hour; a refusal from an earlier attempt is not reported once a new hosted checkout is started. Returned by this endpoint only. |
Get Credit Usage
/current/org/{org_id}/billing/usage/limits/credits/
Auth required. Admin or above. Returns credit consumption and limits.
curl example
curl -X GET "https://api.fast.io/current/org/1234567890123456789/billing/usage/limits/credits/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"credit_limits_enabled": false,
"in_trial": false,
"trial_auto_convert_at": 0,
"free_credits_tracking": true,
"free_org_mode": false,
"org_id": "1234567890123456789",
"plan": "starter_monthly",
"over_free_allowance": false,
"usage": {
"credits_used": 1200,
"free_credit_allowance": 100000,
"credits_remaining": 98800,
"usage_percentage": 1.2
},
"period": {
"start": "2025-01-15 10:00:00 UTC",
"end": "2025-02-14 10:00:00 UTC",
"days_total": 30,
"days_elapsed": 5,
"days_remaining": 25
},
"renewal": {
"interval_days": 30,
"next_renewal": "2025-02-14 10:00:00 UTC"
},
"run_rate": null
}
The example is a paid plan outside its trial. Paid plans bill usage beyond the monthly allowance as overage, so credit_limits_enabled is false and credits_remaining tracks the included allowance rather than a ceiling. During a trial, or on a legacy plan that stops at its allowance, credit_limits_enabled is true and usage is held at the monthly allowance. A trial with a cancellation scheduled never converts to paid: usage is held at the trial's own credit allowance instead (reported as usage.free_credit_allowance), trial_auto_convert_at is 0, and in_trial stays true past the trial's end until the plan ends (unless the trial was already billed for its first term). Orgs without a paid plan, and legacy plans with a fixed credit cap, receive a capped shape instead: over_limit, usage.credit_limit and trial in place of the allowance fields.
Response fields
| Field | Type | Description |
|---|---|---|
| credit_limits_enabled | boolean | true when usage is held at an allowance (a trial, a legacy plan that stops at its allowance, or a capped plan); false when overage is billed |
| in_trial | boolean | Paid plans: whether the org is in its free trial |
| trial_auto_convert_at | integer | Paid plans in a trial: the credit usage at which the trial converts to paid; 0 otherwise, including a trial with a cancellation scheduled (it never converts) |
| free_credits_tracking | boolean | Paid plans: true; usage is tracked against the included allowance |
| free_org_mode | boolean | true for orgs without a paid plan (unpaid credit model, including new unsubscribed orgs); does not imply credits are available |
| over_free_allowance | boolean | Paid plans: whether usage has reached the allowance in usage.free_credit_allowance (the included monthly allowance, or the trial's credit allowance for a trial with a cancellation scheduled) |
| over_limit | boolean | Capped plans only: whether the org has exceeded its credit limit |
| usage.credits_used | integer | Credits consumed in the current period |
| usage.free_credit_allowance | integer | Paid plans: credits included per period; for a trial with a cancellation scheduled, the trial's credit allowance, where usage stops |
| usage.credit_limit | integer | Capped plans only: total credits available per period |
| usage.credits_remaining | integer | Credits remaining in the current period |
| usage.usage_percentage | number | Percentage of credits used |
| period.start | string | Start of the current billing period (YYYY-MM-DD HH:MM:SS UTC) |
| period.end | string | End of the current billing period (YYYY-MM-DD HH:MM:SS UTC) |
| period.days_total | integer | Total days in the period |
| period.days_elapsed | integer | Days elapsed since period start |
| period.days_remaining | integer | Days remaining until renewal |
| renewal.interval_days | integer | Days between credit renewals |
| renewal.next_renewal | string/null | Next credit renewal timestamp (YYYY-MM-DD HH:MM:SS UTC), or null |
| run_rate | object/null | Usage rate projections (shown after 25% of period or credits used) |
| trial | object/null | Capped plans only: trial info if applicable |
Credit costs: storage (150/GB), bandwidth (400/GB), AI tokens (1/100 tokens), document ingestion (10/page), video ingestion (5/sec), image ingestion (5/image), file conversions (25/each), cloud sync (1 per 1,000 objects scanned per sync, minimum 1 per sync), AI index (100 per 1,000 indexed vectors, sampled daily and charged on the period average, so a corpus you keep for a month costs 100 per 1,000 vectors for that month, not per day).
List Billable Members
/current/org/{org_id}/billing/usage/members/list/
Auth required. Admin or above. Paginated.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
| limit | integer | 100 | 1-500 |
| offset | integer | 0 | Items to skip |
curl example
curl -X GET "https://api.fast.io/current/org/1234567890123456789/billing/usage/members/list/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"billable_members": {
"1234567890123456789": {
"id": "1234567890123456789",
"account_type": "human",
"email_address": "user@example.com",
"parents": {
"9876543210987654321": {
"permission": "member",
"date_joined": "2024-01-15 10:30:00 UTC"
}
}
}
},
"pagination": {"total": 1, "limit": 100, "offset": 0, "has_more": false}
}
Response fields
| Field | Type | Description |
|---|---|---|
| billable_members | object | Billable member objects keyed by user ID (an object, not an array); [] when there are none |
| billable_members.{user_id}.id | string | 19-digit user ID |
| billable_members.{user_id}.account_type | string | "human" or "agent" |
| billable_members.{user_id}.email_address | string | User's email |
| billable_members.{user_id}.parents | object | Map of workspace IDs to {permission, date_joined} |
| pagination | object | total, limit, offset, has_more |
Get Usage Meters
/current/org/{org_id}/billing/usage/meters/list/
Auth required. Admin or above. Returns detailed usage breakdown by meter.
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| meter | string | Yes | — | Meter type. One of: storage, bandwidth, users, tokens, credits, doc_pages_ingested, images_ingested, video_seconds_ingested, audio_seconds_ingested, video_seconds_converted, audio_seconds_converted, conversions, signatures, cloud_sync, ai_index. Any other value is refused with 406 |
| start_time | string (datetime) | No | 30 days ago | Start of time range, YYYY-MM-DD HH:MM:SS, read as UTC. Send no zone suffix — a trailing UTC is refused with 406 |
| end_time | string (datetime) | No | Now | End of time range, same format as start_time |
| workspace_id | string | No | — | Filter by workspace (19-digit ID) |
| share_id | string | No | — | Filter by share (19-digit ID) |
Only one of workspace_id or share_id can be specified at a time.
Usage history retention. Usage history is kept at three resolutions, decided by each reading's own timestamp: a reading less than 45 days old is kept hour by hour; from 45 days to one year old it is kept as one point per UTC day; older than one year, as one point per calendar month, kept indefinitely. A total is identical at every resolution when the range is aligned to the resolution kept for that age: whole UTC days for readings 45 days to a year old, whole calendar months for readings older than a year. Ask for whole days over a range that reaches past a year and the month-resolution part of it still contributes whole months, because there is no finer detail left to divide — and the same is true of a range starting mid-day more than 45 days ago, which includes that entire UTC day.
Reading times are the exception, and they stay exact: the timestamp and value of the latest reading in each kept period are preserved, so a "last value" answer is always a real reading rather than the start or end of a day or a month.
Resolution of a long range. A range that reaches into a coarser resolution is answered at that resolution, and interval_hours reports the width the points are actually at — which can be wider than the range alone implies. Calendar-month points have no fixed width, so interval_hours is negative for them and counts months per point: -1 is one month, -3 a quarter. Several months are grouped into one point when a range is long enough that one point per month would exceed the point ceiling. Every point carries its own start_time and end_time, so read a point's span from those rather than assuming a fixed width.
History before the policy. This policy took effect on 2026-09-19 and did not recreate history that had already been discarded: nothing older than 120 days existed at that point. A range reaching further back returns zeros for the part with no history rather than an error, so a flat or empty early portion of a long range means there is no detail to show, not that there was no usage. Invoices and billed totals for closed periods are unaffected — they are kept independently of this detail. Pull and store anything you need at a finer resolution than the policy keeps.
curl example
curl -X GET "https://api.fast.io/current/org/1234567890123456789/billing/usage/meters/list/?meter=storage&start_time=2026-08-01+00:00:00&end_time=2026-08-31+23:59:59" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"usage": {
"meter": "storage",
"total": 1073741824,
"cost": 0.50,
"credits": 500,
"start_time": "2026-08-01 00:00:00 UTC",
"end_time": "2026-08-31 23:59:59 UTC",
"interval_hours": 24,
"workspace_id": null,
"share_id": null,
"data_points": [
{
"start_time": "2026-08-01 00:00:00 UTC",
"end_time": "2026-08-02 00:00:00 UTC",
"value": 536870912,
"cost": 0.25,
"credits": 250
}
]
}
}
Response fields
| Field | Type | Description |
|---|---|---|
| usage.meter | string | The meter type queried |
| usage.total | number | Total usage value for the period |
| usage.cost | number | Total cost in USD |
| usage.credits | number/null | Total credits consumed (null for direct-billed meters) |
| usage.start_time | string | Start of the queried range |
| usage.end_time | string | End of the queried range |
| usage.interval_hours | integer | Hours per data point, chosen automatically so a range returns about 30 points (one more when the range begins part-way through a point, which is taken whole). Widened to a multiple of 24 for ranges starting more than 45 days ago. A negative value means calendar months per point (-1 one month, -3 a quarter) for ranges starting more than a year ago, where no hour count describes a point; read each point's own start_time and end_time for its exact span |
| usage.data_points | array | Time-series data with value, cost, and credits per interval |
Error responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | "Must be one of the valid meter types" | Invalid meter type |
1605 (Invalid Input) | 406 | "Only one of workspace_id or share_id can be specified." | Both filters provided |
1605 (Invalid Input) | 406 | "Start time must be before end time." | Invalid time range |
1605 (Invalid Input) | 406 | "Time range must be at least 1 day." | Range too short |
1654 (Internal Error) | 500 | "Failed to retrieve usage data." | Internal error |
List Available Plans
/current/org/billing/plan/list/
Auth required. Returns the paid plans available to select when activating or upgrading an organization. Only these currently-offered paid plans are returned for selection.
- All accounts (agent and human) see the same paid plans, whether activating a new organization or switching an existing one: Starter, Business, and Enterprise. Each plan is offered in a monthly and an annual interval (e.g.,
starter_monthly/starter_annual,business_v3_monthly/business_v3_annual,enterprise_v2_monthly/enterprise_v2_annual). - The free plan and legacy plans are not offered to new organizations; existing organizations on those plans keep their current plan.
New organizations choose a paid plan to activate; until then the org is in an upgrade-only state.
curl example
curl -X GET "https://api.fast.io/current/org/billing/plan/list/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
Each entry in plans is the full plan definition. The example below is abbreviated: it shows one of the six entries, trims its meters, features and limits objects, and omits a few presentation fields. The real response lists every meter, feature flag and limit.
{
"result": true,
"results": 6,
"defaults": {
"pro": "starter_monthly",
"business": "business_v3_monthly"
},
"plans": [
{
"name": "enterprise_v2_monthly",
"title": "Enterprise",
"desc": "For scaling teams that need room to grow",
"category": "business",
"pricing": {
"price_base": 199.99,
"coupon": null,
"meters": {
"credits": {
"price_per_unit": 0.01,
"unit": 100,
"free_units": 3000000,
"unit_desc": "credit",
"meter_type": "direct",
"aggregation_type": "last"
},
"users": {
"price_per_unit": 1,
"unit": 1,
"unit_desc": "seats",
"free_units": 30,
"meter_type": "direct",
"aggregation_type": "average"
}
},
"billing_threshold": 450,
"billed": "monthly",
"free": false,
"free_days": 30,
"free_cooldown": 5184000,
"discount": null
},
"legacy_billing": false,
"credit_overage_behavior": "metered_overage",
"trial_credit_limit": 150000,
"seat_limit": 200,
"available": true,
"show_upgrade_msg": true,
"features": {
"sso": true,
"org_controls": true,
"content_ai": true,
"ai_agent": true
},
"limits": {
"event_retention_days": 365,
"workspaces": { "limit": 200, "members": 30 }
}
}
]
}
Response fields
| Field | Type | Description |
|---|---|---|
| results | integer | Number of available plans (each monthly and annual interval is its own entry) |
| defaults | object | Default plan identifiers per category (pro, business) |
| plans | array | Array of plan detail objects |
| plans[].name | string | Plan identifier — pass this as billing_plan when subscribing (e.g., enterprise_v2_monthly) |
| plans[].title | string | Display name ("Starter", "Business", "Enterprise") |
| plans[].desc | string | Short marketing description |
| plans[].category | string | Plan category: "pro" (Starter), "business" (Business or Enterprise) |
| plans[].pricing.price_base | number | Flat plan fee in US dollars for one billing period (e.g., 199.99 = $199.99; an annual plan's value is the yearly fee) |
| plans[].pricing.billed | string | Billing interval: "monthly" or "annual" |
| plans[].pricing.free_days | integer | Trial length in days; 0 means payment is due at checkout |
| plans[].pricing.billing_threshold | number | Usage amount in US dollars at which an interim invoice is issued |
| plans[].pricing.meters | object | Usage meters keyed by meter name. direct meters bill in dollars (price_per_unit per unit, after free_units included); credits meters consume credits (credits_per_unit per unit) |
| plans[].credit_overage_behavior | string | What happens when included credits run out (e.g., "metered_overage") |
| plans[].trial_credit_limit | integer | Credit allowance during a trial |
| plans[].seat_limit | integer | Maximum billable seats |
| plans[].features | object | Plan feature flags (booleans), e.g. sso, org_controls, content_ai, ai_agent |
| plans[].limits | object | Plan limits (storage, workspaces, shares, uploads, metadata, cloud import, and so on) |
List Invoices
/current/org/{org_id}/billing/invoices/
Auth required. Admin or above. Returns a paginated list of invoices with hosted payment links.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
| limit | integer | 10 | Number of invoices to return (1-100) |
| starting_after | string | — | Invoice ID cursor for pagination |
curl example
curl -X GET "https://api.fast.io/current/org/1234567890123456789/billing/invoices/?limit=10" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"invoices": [
{
"id": "in_1234567890",
"status": "paid",
"currency": "usd",
"amount_due": 2900,
"amount_paid": 2900,
"subtotal": 2900,
"total": 2900,
"paid": true,
"description": "Subscription creation",
"hosted_invoice_url": "https://{payment_provider_host}/i/.../{invoice_id}",
"invoice_pdf": "https://{payment_provider_host}/invoice/.../{invoice_id}.pdf",
"period_start": "2026-03-01 00:00:00 UTC",
"period_end": "2026-04-01 00:00:00 UTC",
"created": "2026-03-01 00:00:00 UTC"
}
],
"has_more": false
}
Response fields
| Field | Type | Description |
|---|---|---|
| invoices | array | Array of invoice objects |
| invoices[].id | string | Invoice identifier (use as starting_after cursor) |
| invoices[].status | string | "draft", "open", "paid", "void", "uncollectible" |
| invoices[].currency | string | Three-letter ISO currency code (e.g., "usd") |
| invoices[].amount_due | integer | Amount due in cents |
| invoices[].amount_paid | integer | Amount paid in cents |
| invoices[].subtotal | integer | Subtotal before tax in cents |
| invoices[].total | integer | Total after tax in cents |
| invoices[].paid | boolean | Whether the invoice has been paid |
| invoices[].description | string/null | Invoice description |
| invoices[].hosted_invoice_url | string/null | URL to view and pay the invoice |
| invoices[].invoice_pdf | string/null | Direct PDF download URL |
| invoices[].period_start | string/null | Billing period start (YYYY-MM-DD HH:MM:SS UTC) |
| invoices[].period_end | string/null | Billing period end (YYYY-MM-DD HH:MM:SS UTC) |
| invoices[].created | string/null | Invoice creation timestamp (YYYY-MM-DD HH:MM:SS UTC) |
| has_more | boolean | Whether more invoices are available for pagination |
Amounts are in the smallest currency unit (cents for USD). Use hosted_invoice_url to link users to their invoices. Use starting_after with the last invoice id for pagination.
Create Workspace (from Org)
/current/org/{org_id}/create/workspace/
Auth required. Member or above. Creates a workspace within the org. Subject to plan feature availability, workspace creation limits, and the org's workspace-create policy (see Organization Security Controls). Read capabilities.can_create_workspace on the org's details response to know in advance whether the calling user may create one.
Request parameters
| Name | Type | Required | Description |
|---|---|---|---|
| folder_name | string | Yes | URL-safe folder name for the workspace. Must be globally unique across all workspaces. |
| name | string | Yes | Display name. |
| description | string | No | Workspace description. |
| perm_join | string | Yes | Who can auto-join from the org. Values: 'Member or above', 'Admin or above', 'Only Org Owners', 'No one can join automatically' (direct members only). |
| perm_member_manage | string | Yes | Who can manage workspace members. Values: 'Member or above', 'Admin or above'. |
| intelligence | string | No | Enable AI features ("true"/"false"). Defaults to "true" when omitted. Forced off on plans lacking content_ai + ai_agent, which never fails the create. |
| metadata_extraction | string | No | Automatic metadata extraction for new uploads ("true"/"false"). Defaults to "true" when omitted. Effective only while intelligence is on and the plan includes metadata. |
| accent_color | string (JSON) | No | Accent color as JSON. |
| background_color1 | string (JSON) | No | Primary background color as JSON. |
| background_color2 | string (JSON) | No | Secondary background color as JSON. |
curl example
curl -X POST "https://api.fast.io/current/org/1234567890123456789/create/workspace/" \
-H "Authorization: Bearer {jwt_token}" \
-d "folder_name=project-alpha" \
-d "name=Project Alpha" \
-d "perm_join=Member or above" \
-d "perm_member_manage=Admin or above"
Response (200 OK)
{
"result": true,
"workspace": {
"id": "1234567890123456780",
"folder_name": "project-alpha",
"intelligence": true
}
}
Response fields
| Field | Type | Description |
|---|---|---|
| workspace.id | string | 19-digit numeric workspace ID |
| workspace.folder_name | string | URL-safe folder name |
| workspace.intelligence | boolean | Intelligence as actually applied: false when you sent intelligence=false, or when the plan lacks content_ai or ai_agent (forced off whatever you sent) |
Error responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1685 (Feature Limit) | 412 | "Workspace creation is not available on your current plan." | Feature disabled |
1700 (Forbidden) | 403 | "Your organization does not permit you to create workspaces." | The caller is below the org's perm_workspace_create threshold and is not on its allowlist. params.reason = policy_workspace_create_denied. |
1685 (Feature Limit) | 412 | "You have reached your workspace creation limit." | Limit exceeded |
1658 (Not Acceptable) | 406 | "The supplied workspace folder name is already in use." | Duplicate folder name |
1605 (Invalid Input) | 406 | "An invalid workspace folder name was supplied." | Invalid folder name |
1605 (Invalid Input) | 406 | "An invalid configuration was supplied..." | Metadata validation failed |
List Workspaces in Org
/current/org/{org_id}/list/workspaces/
Auth required. Lists accessible workspaces within the org.
Query parameters
| Name | Type | Default | Description |
|---|---|---|---|
| archived | string | "false" | "true" to show archived workspaces, "false" for active |
| limit | integer | 100 | 1-500, number of items to return |
| offset | integer | 0 | Number of items to skip |
Access levels
| Role | Access | Notes |
|---|---|---|
| Owner | Full access | Sees all workspaces |
| Admin | Full access | Sees all workspaces except those restricted to perm_join = 'Only Org Owners' (unless directly a member) |
| Member | Filtered | Sees workspaces matching join permission level |
| External | Filtered | Sees only workspaces where they are a direct member |
curl example
curl -X GET "https://api.fast.io/current/org/1234567890123456789/list/workspaces/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"workspaces": [
{
"id": "1234567890123456780",
"folder_name": "project-alpha",
"name": "Project Alpha",
"description": "Main project workspace"
}
],
"pagination": {"total": 1, "limit": 100, "offset": 0, "has_more": false}
}
Response fields
| Field | Type | Description |
|---|---|---|
| workspaces | array | Array of workspace objects |
| workspaces[].id | string | 19-digit numeric workspace ID |
| workspaces[].folder_name | string | URL-safe folder name |
| workspaces[].name | string | Display name |
| workspaces[].description | string/null | Workspace description |