# Fastio > Put your files to work — workspaces for agentic teams. Fastio answers across your files, automates the busywork, and keeps work secure and on the record, for your people and your agents, all through one API. Storage is step zero. > > Base URL: https://api.fast.io/current/ > Version: 1.0 > Request format: `application/x-www-form-urlencoded` (most POST bodies) or query string (GET). Some endpoints accept `application/json` bodies — notably comments and endpoints with nested/array parameters. > Response format: JSON > File uploads: `multipart/form-data` > Document version: 2.3 > Last updated: 2026-08-10 > Full reference (single file): https://api.fast.io/current/llms/full/ > Agent integration guide: available at the `/current/agents/` endpoint on the connected API server > MCP Server (AI Agents): connect via the Model Context Protocol for tool access > MCP endpoints (Streamable HTTP, OAuth sign-in): https://mcp.fast.io/mcp/tools (named tools), https://mcp.fast.io/mcp/code (code mode for coding agents), https://mcp.fast.io/mcp/operations (ChatGPT) > MCP earlier endpoints (still supported): https://mcp.fast.io/mcp, https://mcp.fast.io/sse (legacy SSE) > MCP Skills & Tool Definitions: available at the `/skill.md` endpoint on the connected MCP server Fastio provides workspaces for agentic teams — where agents collaborate with other agents and with humans. Upload outputs, create branded portals, ask questions about documents using built-in AI, and hand everything off to a human when the job is done. No infrastructure to manage. **For AI agents:** Fastio accounts — for humans and AI agents alike — require an email address. Sign up, create an organization, and choose a paid plan to get started, then build workspaces for your team (agents and humans) and query documents with built-in RAG. The optional `agent=true` flag tags your account as an agent account (`account_type=agent`) for identification. See the [Agent Integration Guide](https://api.fast.io/current/agents/) for workflows and examples. **For MCP-enabled agents:** Connect via the Model Context Protocol to interact with Fastio workspaces, shares, files, and storage directly. The URL fixes the tool set: `https://mcp.fast.io/mcp/tools` (Claude and most chat clients) serves Named Mode — per domain, a read tool (e.g. `storage`) and a `_manage` write tool (e.g. `storage_manage`), both with action-based routing; `https://mcp.fast.io/mcp/code` (Claude Code, Cursor, Codex and other coding agents) serves Code Mode, a smaller set of streamlined tools; `https://mcp.fast.io/mcp/operations` (ChatGPT) serves one tool per operation (e.g. `storage_list`). These sign in with OAuth in the browser; they take no passwords, API-key secrets or verification codes and do not handle billing — manage those in the Fastio web app. The earlier URLs `https://mcp.fast.io/mcp` and `https://mcp.fast.io/sse` (legacy SSE) keep working and still accept earlier tool and action names. All tools are annotated with MCP hints (`title`, `readOnlyHint`, `destructiveHint`). Resources (`skill://guide`, `session://status`) are also available. The MCP server provides a `/skill.md` endpoint with available tools and skill definitions. ## Detailed API References This file is a concise overview. For complete endpoint documentation with parameters, response fields, and examples, see the category-specific references: | Category | URL | What It Covers | |----------|-----|----------------| | **Auth & Users** | https://api.fast.io/current/llms/auth/ | Authentication methods, user CRUD, getting started patterns | | **OAuth 2.0** | https://api.fast.io/current/llms/oauth/ | PKCE flow, token exchange, session management, DCR, resource indicators | | **Organizations** | https://api.fast.io/current/llms/orgs/ | Org CRUD, members, billing, discovery | | **Single Sign-On** | https://api.fast.io/current/llms/sso/ | Organization identity-provider configuration (OIDC and SAML), DNS domain verification, configuration checks, sign-in modes | | **Workspaces** | https://api.fast.io/current/llms/workspaces/ | Workspace CRUD, members, assets, discovery | | **Storage** | https://api.fast.io/current/llms/storage/ | File/folder operations, locking, previews, transforms | | **Shares** | https://api.fast.io/current/llms/shares/ | Share types, storage modes, members, branding, durable File Share (single-file links; supersedes the deprecated QuickShare) | | **Upload** | https://api.fast.io/current/llms/upload/ | Chunked upload flow, web upload, status polling | | **AI & Chat** | https://api.fast.io/current/llms/ai/ | RAG chat, Deep Indexing, notes, saved metadata filters & extraction | | **How-To** | https://api.fast.io/current/llms/howto/ | Single-call natural-language "how do I…" product guidance (answer or clarifying question) | | **Events & Activity** | https://api.fast.io/current/llms/events/ | Event search, activity polling, WebSocket (with permission-scoped enriched event push), realtime | | **Comments** | https://api.fast.io/current/llms/comments/ | Threading, mentions, reactions, JSON body format | | **Signing / E-Signature** | https://api.fast.io/current/llms/signing/ | SignEnvelopes, recipients, fields, signer surface (OTP, consent, sign/decline), audit chain + certificate, PAdES-LT signing, plan-gated availability; sign templates (reusable configurations — create/list/details/update/delete/instantiate) | | **Dashboard** | https://api.fast.io/current/llms/dashboard/ | Per-workspace actionable card feed (mentions, file activity, and pending signatures) with optional AI overlay (urgency, summaries, synthesis), dismiss/snooze, and Ripley Agent seed handoff | | **Agent Intents** | https://api.fast.io/current/llms/intents/ | Workspace-scoped, short-lived "what am I doing" declarations for coordinating agents — allocate/fill/browse/expand/release, credential-keyed slots with user-scoped ownership, compare-and-set writes | | **Bug Reports** | https://api.fast.io/current/llms/bug-reports/ | Report a Fastio platform bug to the Fastio team — one call, no read-back | | **Full Reference** | https://api.fast.io/current/llms/full/ | All endpoints in a single file | ## What Fastio Does Storage is step zero. Fastio is the workspace platform for agentic teams — where agents work with other agents and with humans — organized around three things you do with your files: | Pillar | What It Does | |--------|--------------| | **Intelligence** | Ask across all your files and get cited answers (RAG chat); turn documents and images into structured data (AI metadata extraction, organised with saved metadata filters); research, analyze, and draft with the built-in agent, Ripley; semantic search by meaning | | **Automation** | Automate work on your files with event or schedule triggers — run agents (Ripley or your own, over MCP) against documents, route files, and collect sign-offs | | **Secure collaboration** | Files belong to the project, not the person — org/workspace-owned storage, Send/Receive/Exchange shares and branded portals, granular permissions and scoped agent tokens, an append-only audit log, and a per-workspace dashboard of what needs attention | The workspace and share **Deep Indexing** setting was formerly called Intelligence; its API field keeps the name `intelligence`. **Building blocks:** | Capability | What It Does | |-----------|-------------| | **Workspaces** | Shared workspaces for agentic teams with file versioning, search, and AI chat | | **Shares** | Purpose-built spaces for agent-human exchange with two storage modes: **Portal** (independent portal with passwords, expiration, guest access, download security levels) or **Shared Folder** (live-synced workspace folder). Three share types: Send, Receive, Exchange. Download security: `high` (disabled), `medium` (nonce-gated), `off` (unrestricted). | | **Built-in AI (Ripley)** | RAG-powered document Q&A with citations, semantic search (vector retrieval without LLM), auto-summarization, metadata extraction | | **File Preview** | Inline rendering for PDF, images, video, audio, spreadsheets, code — no download needed | | **Activity Tracking** | Full audit trail with AI-powered natural language summaries | ## Plans New organizations choose a paid plan: **Starter** ($9.99/mo or $99/yr — 100,000 credits/month, 250 GB storage, 3 seats), **Business** ($49.99/mo or $499/yr — 600,000 credits/month, 5 TB storage, 10 seats) or **Enterprise** ($199.99/mo or $1,999/yr — 3,000,000 credits/month, 25 TB storage, 30 seats). Every plan meters credit overage beyond its monthly allowance. Plans offered before these remain in place for their existing subscribers as legacy plans (titled with a "(legacy)" suffix); they can no longer be selected, and a subscriber on one can move to any current plan. A newly created organization must select a paid plan 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 — gated endpoints return HTTP 402). Each plan includes a monthly credit allowance; credits cover: storage (150/GB), bandwidth (400/GB), AI tokens (1/100 tokens), document ingestion (10/page), video ingestion (5/sec), audio ingestion (1 per 2 sec), image ingestion (5/image), file conversions (25/each), e-signatures (100 per billable recipient, at send), 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). **To activate or upgrade an org:** Direct the user to `https://fast.io` or use `POST /current/org/{org_id}/billing/` to select a paid plan. See [Organizations reference](https://api.fast.io/current/llms/orgs/) for details. **Enterprise** is a paid plan at $199.99/mo ($1,999/yr), bought through the normal checkout like any other tier, that adds org-level Single Sign-On, SCIM directory provisioning and the org security controls — including the option to enforce SSO as the only sign-in path for the org's verified domains — on top of 3,000,000 credits and 30 users per month included (then $1/user/mo above 30, up to 200 seats). See [Single Sign-On reference](https://api.fast.io/current/llms/sso/) for details. ## Profile Hierarchy User (Type 2) → Organization (Type 3) → Workspace (Type 4) / Share (Type 5) Users own organizations. An organization is a collector of workspaces — it can represent a company, a business unit, a team, or simply a personal collection. Organizations own workspaces and shares. Users can also directly own shares. All profile IDs are 19-digit numeric strings (e.g., "2234567890123456789"). The leading digit encodes the profile type — `2` = user, `3` = org, `4` = workspace, `5` = share. Most endpoints also accept a custom name in place of the numeric ID — see ID Formats below. Account types: `human` or `agent` — visible in all user objects via `account_type` field. ## Authentication All authenticated endpoints require: `Authorization: Bearer {jwt_token}` Ways to get a token: - **Browser sign-in (email + password, recommended):** the Fastio web app calls `POST /current/user/auth/login/start/`, sends the browser to the returned `login_url` (the Fastio sign-in page, which handles password and 2FA), and exchanges the one-time callback code with PKCE at `POST /current/user/auth/login/exchange/` for a session. Returns only to Fastio's own web origins. → [Auth reference](https://api.fast.io/current/llms/auth/) - **Signup:** `POST /current/user/` returns a session (`auth_token`) when it creates a new account; an already-registered email gets `result: true` with no `auth_token` (check your email). - **Basic Auth → JWT (deprecated, will be retired):** `GET /current/user/auth/` with HTTP Basic Auth still returns a JWT today. Use browser sign-in, OAuth PKCE, or API keys instead. - **OAuth 2.0 PKCE:** For desktop/mobile apps, CLIs and MCP agents — the recommended way for anything but the Fastio web app to act for a password user. S256 only. `GET /current/oauth/authorize/?response_format=json` returns a `login_url` to open verbatim; `display_code=true` gives a copy-paste code; native clients may use a `127.0.0.1` loopback redirect on any port (RFC 8252). Supports Dynamic Client Registration (RFC 7591), Client ID Metadata Document (CIMD) for URL-based client_id, and Resource Indicators (RFC 8707). See [OAuth reference](https://api.fast.io/current/llms/oauth/). - **API Keys:** Long-lived tokens. Same Bearer header. Create via `POST /current/user/auth/key/`. Keys optionally support scoped permissions (`scopes`), agent names (`agent_name`), and expiration (`expires`). Update via `POST /current/user/auth/key/{id}/` (POST, not PUT). A scope is `entity_type:entity_id:access_mode`, and there are three access modes: `r` (read), `rw` (read and write) and `rwa` (read, write and **administer**). `rwa` implies `rw` implies `r`; there is no `ra`; and admin is re-checked per request against the human's live role. A key created without `scopes` now stores the explicit `["user:*:rw"]` — whole-account read and write, but **no administration and no account settings**; those need `rwa` scopes and the `userdetails:*:rw` scope respectively, and a credential cannot widen itself. → [Auth reference](https://api.fast.io/current/llms/auth/) - **2FA:** Handled on the sign-in page for browser sign-in and OAuth. On the deprecated Basic Auth login: limited-scope token → full token after `POST /current/user/auth/2factor/auth/{token}/`. **Choose your access pattern:** 1. **Human's account** — Human creates API key, gives it to you. You operate as them. → [Auth reference](https://api.fast.io/current/llms/auth/) 2. **Your own account** — Sign up (email + password, optionally `agent=true`) — the signup response carries your session — then use that session to create an org and select a paid plan (a default API key cannot do admin billing operations); create an API key afterwards for ongoing access and work independently. → [Auth reference](https://api.fast.io/current/llms/auth/) 3. **Collaboration** — Sign up, then a human invites you to their org/workspace. → [Auth reference](https://api.fast.io/current/llms/auth/) 4. **PKCE browser login** — Secure, no password sharing, supports SSO. → [OAuth reference](https://api.fast.io/current/llms/oauth/) ## Response Envelope Success (data fields at root level): ```json {"result": true, ...} ``` Error: ```json {"result": false, "error": {"code": 195654, "text": "Human-readable message", "documentation_url": "https://api.fast.io/llms.txt", "resource": "POST /current/user/"}} ``` Validation error (HTTP 406) with structured per-parameter detail: ```json {"result": false, "error": {"code": 10022, "text": "email: This value should not be blank.", "documentation_url": "https://api.fast.io/llms.txt", "resource": "POST /current/user/email/", "params": [{"name": "email", "kind": "missing", "message": "This value should not be blank.", "code": 10022}]}} ``` - `result`: boolean — `true` on success, `false` on error - `error.code`: Unique error identifier (integer) for debugging - `error.text`: Human-readable error message. Advisory; clients should prefer `params` for programmatic handling. Retained byte-identically for compatibility. - `error.documentation_url`: Link to error documentation (string or null) - `error.resource`: The endpoint that produced the error - `error.params`: Array of `{name, kind, message, code, expected_type?, received_alias?}`. Present on validation errors (HTTP 406) from endpoints that use structured parameter validation, and on some conflicts (HTTP 409). Not every 406 carries it, so always fall back to `error.text`. On some refusals — certain 406s, and the 403 / 503 refusals described under *Error Codes* below — `params` is instead an **object** carrying `reason`; check `Array.isArray(params)` before reading it. Aggregates every failed parameter so callers see all problems in one round trip. `kind` is one of `missing` (required parameter omitted), `invalid` (value failed a constraint), `type_mismatch` (value could not be decoded into the expected type), `unknown_parameter` (the parameter is not part of the accepted set for this request — stop sending it rather than correcting its value), or `conflict` (the value was well formed, but the state it referred to has since changed — see below). Omitted when empty. **Treat `kind` as open-ended: handle an unrecognised value as a generic failure rather than rejecting the response.** - **Conflict entries (HTTP 409).** A `conflict` entry names the parameter whose precondition no longer holds and carries a `reason` naming the cause — currently `conflict_version_mismatch`, meaning the resource changed since the version you supplied. Branch on `params[].reason` — the only field that names the cause. `params[].name` + `params[].kind` is a fallback for clients receiving only the four standard fields: it identifies *a* failed precondition on that parameter, not which one, so treat it as a generic conflict. Do **not** branch on the HTTP status (`409` reports several unrelated conditions) or on the numeric code (assigned per call site, so it differs between endpoints reporting the same cause). Re-read the resource and re-apply your change against the current state; resending the same request unchanged cannot succeed. **That last point is about `conflict` entries, not about `409` in general** — a `409` carrying no `params[]` can report a condition that clears on its own, and the cloud-sync write-back queue has one (see Cloud Sync below). Every `reason` value is lowercase snake_case, so a client can normalise once over the field rather than special-casing each condition. Note the asynchronous upload session reports the same condition through `session.status_message` as `CONFLICT_VERSION_MISMATCH:{current_version_id}` — upper-case, and a prefix rather than a whole value. The two are matched by different code and are never compared, so the casing difference is deliberate rather than an inconsistency to normalise away. OAuth (`/current/oauth/token/`, `/current/oauth/revoke/`, `/current/oauth/register/`) and the cloud-storage / billing webhook receivers retain RFC-compliant bare-JSON error envelopes (RFC 6749 §5.2 / RFC 7591). The `params` field is **not** emitted by those endpoints. ## OPTIONS Introspection `OPTIONS` introspection is currently supported on roughly 40% of public endpoints. Supported endpoints respond with a JSON description of their accepted parameters — source (query / body / path / header), required vs optional flag, expected type, and a summary of declared constraints (length bounds, choice set, range, equality). Use this to fetch parameter requirements before issuing a call rather than learning them from a validation-error round trip. ```bash curl -X OPTIONS "https://api.fast.io/current/{endpoint}/" -H "Authorization: Bearer {jwt_token}" ``` Unsupported endpoints (most OAuth endpoints, webhook receivers, and a substantial set of legacy endpoints not yet migrated) return `405 Method Not Allowed` for `OPTIONS`. OAuth authorization-server discovery (`/.well-known/oauth-authorization-server/`) does support it. Coverage is being expanded incrementally — check via `OPTIONS` first; fall back to the documented schema when you receive a 405. ## Error Codes **Reading this list:** 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. Codes shown as five or six digits (and the `9661`-`9669` family) ARE `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. Client errors: - 1605 (Invalid Input) → 406 Not Acceptable - 1622 (Duplicate Entry) → 406 Not Acceptable - 1650 (Authentication Invalid) → 401 Unauthorized - 1651 (Invalid Request Type) → 405 Method Not Allowed - 1609 (Not Found) → 404 Not Found - 1652 (Resource Not Found) → 404 Not Found - 1653 (User Not Found) → 404 Not Found - 1701 (Gone) → 410 Gone — the endpoint was retired by decision. Distinct from 404 on purpose: 404 means "no such thing" and is answered by re-checking the id or your access, whereas 410 means the path itself is finished. Do not retry it, do not vary the id, and do not treat it as an outage — remove the call and use the replacement named in the error message. - 1656 (Limit Exceeded) → 413 Payload Too Large - 1667 (Max Limit) → 429 Too Many Requests - 1685 (Feature Limit) → 412 Precondition Failed - 1658 (Not Acceptable) → 406 Not Acceptable - 1660 (Conflict) → 409 Conflict - 1669 (Already Exists) → 409 Conflict - 1670 (Restricted) → 406 Not Acceptable - 1680 (Access Denied) → 401 Unauthorized - 1671 (Rate Limited) → 429 Too Many Requests — the rate-limit `error.code` on the wire is `10368` (see *Rate Limiting*) - 1677 (Locked) → 423 Locked - 1700 (Forbidden) → 403 Forbidden — the credential is valid but this request is not permitted (see *Credential scope errors* and *Organization access policy refusals* below). Never a reason to sign out. - 1697 (Geo Restricted) → 452 — account signup or org creation from a geo-restricted location (see the [Auth reference](https://api.fast.io/current/llms/auth/)). Billing errors: - 1688 (Subscription Required) → 402 Payment Required (org has no active subscription (for an org without a paid plan, also when its credit allowance is exhausted)) - 1695 (Upgrade Required) → 402 Payment Required (feature requires a higher-tier plan) - 1696 (Credit Limit Exceeded) → 402 Payment Required (credit limit exceeded; error message includes usage details) Server errors: - 1654 (Internal Error) → 500 - 1664 (Datastore Error) → 500 - 1686 (Not Implemented) → 501 - 1693 (Temporarily Unavailable) → 503 Service Unavailable — something the request needs is briefly busy or out of reach, so the request was not carried out at all. Unlike the client errors above it **is retryable, and a retry is the correct response**: wait a moment and send the same request again, unchanged. Do not rewrite the request, do not vary its inputs, and do not report it as a settled refusal. **The exception is a maintenance window:** a `503` whose `params.reason` starts with `maintenance_` will keep failing until the window ends, so do not retry it on a short loop — see *Maintenance windows* below. ### Credential scope errors Four codes report a credential that was verified and is too narrow for what it attempted. **All four are HTTP 403, never 401**, and on them `error.params` is an **object** (not the validation array) carrying `reason`, `entity_type`, `entity_id`, `required_access_mode`, `current_access_mode` and `credential_type`, plus `credential_id` and `credential_label` for an API-key caller. Branch on `params.reason`, not the number. - `10767` (`scope_admin_required`) — an administrative operation with a credential that is not admin-capable. Administrative **reads** count: org billing details, invoices, usage, credits, plan preview, payment method, and the events audit log all require an admin-capable credential. - `10768` (`scope_exceeds_issuer`, or `access_mode_exceeds_initiate` on an OAuth consent) — the requested scopes or access mode are broader than the credential making the request, or broader than the authorization was initiated with. - `10769` (`userdetails_scope_required`) — an account-settings operation (changing `password` or `email_address`, 2FA enrolment and its verification, `invalidate-all`) without the `userdetails:*:rw` scope. - `10770` (`scope_write_required`) — a non-`GET` method on an account-anchored route, attempted by a credential that holds no write-capable grant anywhere. A `user:*:r` credential reads the whole account and writes nothing: no exempt route, `sign-out/` included, so such a client ends its session by discarding the credential locally or calling `POST /current/oauth/revoke/`. Full detail: [Auth reference](https://api.fast.io/current/llms/auth/). ### Organization access policy refusals An organization on the Enterprise plan can restrict access to its content by country and IP range, and can switch AI features and MCP access off per member and per workspace. These refusals can come from any endpoint that reaches that org's content — org, workspace, share, storage, upload, events, and realtime — for every credential type. They are **HTTP 403, never 401** (the credential is still valid, so never sign out or discard it on them), `error.params` is an **object**, and you branch on `params.reason`: - `geo_restricted` — the caller's network location is blocked by the owning org's access policy. `params` also carries `rule` (`"country"` or `"ip"`), `org_id` and `domain`. Retrying from the same location will not succeed. - `mcp_access_denied` — the request came through the Fastio MCP and the org does not allow MCP access for this user. `params.org` = `{id, domain}`. Account-level (`user/*`) endpoints are never blocked. - `ai_policy_denied` / `ai_policy_workspace_not_allowed` — the org has turned off the AI feature named in `params.feature` for this caller, or for this workspace. A `503` (`1693`) with `params.reason` = `access_policy_unavailable` means the policy could not be evaluated — retry it unchanged. List endpoints that span several orgs omit the rows of an org that blocks the caller rather than failing. Full detail: *Access Policy (Geo / IP Restrictions)* and *AI, Deep Indexing & MCP Access Policy* in the [Orgs reference](https://api.fast.io/current/llms/orgs/). ### Maintenance windows Fastio occasionally schedules maintenance. A window has a `mode`: `read_only` (changes are refused, reads keep working), `no_transfer` (every file-content operation is refused — uploads, downloads, previews, transforms, file imports, notes, asset files and e-signature documents — and so is cloud sync, while folder browsing and listing keep working), or `downtime` (the whole service is offline). - **Advance notice.** From the time the warning starts until the window ends, API responses carry an `x-ve-maintenance` header: `x-ve-maintenance: id={window_id}; mode=read_only; starts="2026-10-20 06:00:00 UTC"; ends="2026-10-20 08:00:00 UTC"`. `GET /current/system/status/` (no auth) returns the same window as `maintenance.window` — `null` when none is scheduled, otherwise `{id, mode, state, warn_at, starts_at, planned_end_at, message}`; `planned_end_at` is always set and `message` may be empty. Times use `YYYY-MM-DD HH:MM:SS UTC`; compare them against the current time yourself. - **During the window**, a refused call returns HTTP `503` (status class `1693`) with `error.params` = `{reason, planned_end_at}`, and may carry a `Retry-After` header. `reason` is `maintenance_read_only`, `maintenance_no_transfer` or `maintenance_downtime`. During `read_only`, sign-in and token refresh keep working. - **How to react.** These refusals are not transient faults and not settled refusals: the request was not carried out, and it can be retried unchanged once the window ends. Tell the user that Fastio is under maintenance, wait, and poll `GET /current/system/status/` until `maintenance.window` is `null` before resending. `planned_end_at` is an estimate — a window ends only when it is explicitly ended, which can be earlier or later. ### Retired per-field codes As of 2026-05-05, error codes `136957`, `249170`, `279705`, `295625` are retired. The equivalent field-level failures now surface inside `error.params[]` with `kind: 'invalid'` (and a per-field `code` and `message` describing the specific violation). Clients that previously switched on those exact integers should switch on `params[].name` + `params[].kind` instead. ## Rate Limiting Headers: `x-ve-limit-avail` (requests remaining), `x-ve-limit-max` (window cap), `x-ve-limit-expires` (Unix-time the window resets). When exceeded: HTTP 429 with `error.code` `10368`. Back off until `x-ve-limit-expires`. Some endpoints also apply their own throttle on top of this and may return a `429` with a `Retry-After` header giving a number of seconds — wait at least that long before retrying; when the header is absent, back off using the `x-ve-limit-*` headers instead. ## Pagination ### Offset Pagination (List Endpoints) Most list endpoints support offset-based pagination: - `limit`: 1-500 (default: 100) — number of items to return - `offset`: 0+ (default: 0) — number of items to skip - Response: `pagination.total`, `pagination.limit`, `pagination.offset`, `pagination.has_more` ### Keyset Pagination (Cursors) Some endpoints page with an opaque, forward-only cursor instead of (or as well as) an offset. On every one of them the same rule applies: **a page may contain fewer items than you asked for — or none at all — while `has_more` is `true`.** Rely on `has_more` / `next_cursor` to decide whether to continue, never on page fullness. Cursors are signed so tampering is detected, but they are not secret; pass one back exactly as received, and treat it as valid only for the same user and the same filter set that produced it. **Storage listing** (`GET /current/workspace/{id}/storage/{parent_id}/list/`, etc.): - `sort_by`: name | updated | created | type (default: name) - `sort_dir`: asc | desc (default: asc) - `page_size`: 100 | 250 | 500 (default: 100) - `cursor`: opaque string from previous response - Response: `pagination.has_more`, `pagination.next_cursor`, `pagination.page_size` **Events search** (`GET /current/events/search/`) — offset paging still works and is unchanged; the cursor is additive and is the efficient way to walk a long result set such as an audit-log export: - `limit`: 1-250 (default: 100) - `cursor`: opaque string from previous response; cannot be combined with a non-zero `offset` or with `parent_event_id` - Response: `pagination.has_more`, `pagination.next_cursor`, `pagination.page_size` — returned in offset mode too - Events you may not see are removed after the page is read, so short and empty pages are normal mid-walk - Paging by `parent_event_id` is offset-only: `next_cursor` is always `null` there ## Compact Responses (`output=`) Most list and detail endpoints accept an optional `output` query parameter that selects a response shape tuned to how much detail the caller actually needs. This is useful for agents and clients that want to minimize payload size and token usage. - **Syntax:** `?output=` or `?output=,` — comma-separated list of tokens. - **Detail-level tokens (mutually exclusive — pick at most one per request):** - `terse` — the smallest useful shape: identifiers, primary labels, and the handful of fields needed to navigate between resources (types, parent linkage, share/target references, a ready/not-ready flag for previews). Best for tree traversal, picker UIs, autocomplete, and any workflow that will follow up with a detail call only on user interest. - `standard` — `terse` plus the operational context most list and detail views actually render: timestamps, lifecycle flags (closed/archived/suspended/deleted), short descriptions, plan/status fields, creator/owner refs, member status, and short summaries. Recommended default for most agent list/detail workflows. - `full` — the complete resource shape; equivalent to omitting `?output=` entirely. Use when you need branding, capability matrices, permission blocks, long-form AI summaries, metadata, feature flag blocks, or other rarely-read detail. - **Modifier tokens (orthogonal — may be combined with any detail level):** - `markdown` — switches the response encoding from JSON to GitHub-flavored Markdown. Response `Content-Type` becomes `text/markdown; charset=UTF-8`. Works on every endpoint that returns a JSON envelope, including error responses — a 406 with `?output=...,markdown` renders the error envelope as markdown too. Arrays of same-shaped records become GFM pipe tables; associative maps become `- **key:** value` bullet lists; `error` envelopes are promoted to a leading `# Error` section. String values are made inert before they are emitted: a value that looks like HTML or spans multiple lines is wrapped in a code fence sized so the value cannot close it, and every other value is backslash-escaped CommonMark-style so that links, emphasis, headings and table breaks inside caller-supplied text (file names, memos, labels) do not activate. The escaping is lossless — un-escaping `\X` for ASCII punctuation returns the original bytes, so a value that reads `\[Example\]` in the raw body displays as `[Example]` — and values containing nothing markdown-active are emitted unchanged. This is markdown escaping, not HTML sanitization: consumers that render markdown as HTML MUST still sanitize the output. - **Default behavior:** When `output=` is absent, responses are `full` JSON and byte-for-byte unchanged from previous API versions — existing clients require no changes. - **Combining level tokens:** Specifying more than one detail level in the same request (e.g. `?output=terse,standard`) is an error and returns **HTTP 406**. Combine a level with modifiers only (e.g. `?output=standard,markdown`). - **Combining markdown with a level:** `?output=terse,markdown`, `?output=standard,markdown`, `?output=full,markdown`, and `?output=markdown` (defaulting to `full`) are all valid. Token order does not matter — `?output=markdown,terse` is equivalent to `?output=terse,markdown`. - **Markdown with validation errors:** Malformed combinations such as `?output=terse,standard,markdown` still render the HTTP 406 response body as markdown — the encoding flip happens before validation. - **Related (`?format=md`):** A small set of endpoints (imports) additionally support an endpoint-specific `?format=md` parameter that renders domain-shaped markdown (status tables). The generic `?output=markdown` modifier works on every envelope response and renders the JSON envelope as markdown; `?format=md` is specific to those endpoints and produces a different, purpose-built shape. - **Unknown tokens:** Silently ignored for forward compatibility. New tokens may be added without bumping the API version. - **Cumulative fields:** `standard` is a superset of `terse`, and `full` is a superset of `standard`. Nothing disappears as you move up a tier. - **Scope:** Applies transparently to nodes (files/folders/notes/links), events, users, workspaces, orgs, and shares wherever they appear — including when nested inside other resources. Per-category field lists for `terse` and `standard` are documented in the relevant category reference (storage, events, orgs, workspaces, shares, auth/users). ## ID Formats - **Profile IDs** (user, org, workspace, share, file share): 19-digit numeric string - **Node IDs** (files, folders, notes): OpaqueId — see canonical OpaqueId format below. Node IDs are fully opaque; clients MUST NOT parse a resource type from the id prefix. Files, folders, and notes are not distinguishable by their id. - **Upload IDs / Web Upload IDs / Quickshare tokens / Chat IDs / etc.**: OpaqueId — same canonical format as node IDs. - **Special folder aliases**: `"root"` for storage root, `"trash"` for trash folder. ### Canonical OpaqueId Format The API emits OpaqueIds in a 34-character hyphenated form: 29 alphanumeric characters split into 5 groups of 5 plus a final group of 4, separated by hyphens (e.g., `2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4`). Some id families (for example comment and sign-template ids) are 30 characters, rendered as 6 groups of 5 (35 characters). Treat the entire string as opaque — do not parse a resource type from any part of it, including the leading character. - **API id fields use the hyphenated form, but which form you get is a property of the field** — a few values carry the raw, un-hyphenated id (for example the id inside an upload session's `status_message`). Persist whatever you receive. - **API input accepts either form.** The router strips hyphens before validation, so an un-hyphenated id (`2ltsuq4mjacuv7pgc5ydlxnsjwee4`) routes identically to its hyphenated equivalent. - **Equality:** Compare ids by stripping hyphens and lowercasing both sides — do not assume the wire form is normalized across all clients. - **No mixing rule:** Do not invent your own hyphenation. If a client receives an id, persist it byte-for-byte and pass it back unchanged. **Custom names as identifiers:** Most endpoints that accept a profile ID also accept a custom name: | Profile Type | Custom Name | |-------------|-------------| | Workspace | Folder name (e.g., `my-project`) | | Share | URL name (e.g., `q4-financials`) | | Organization | Domain name (e.g., `acme`) | | User | Email address (e.g., `user@example.com`) | ## Field Constraints Profile fields (org, workspace, share) have validation rules enforced server-side. | Entity | Field | API Key | Min | Max | Regex | Required | Nullable | |--------|-------|---------|-----|-----|-------|----------|----------| | Org | domain | `domain` | 2 | 63 | `^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$` | Yes (create) | No | | Org | name | `name` | 3 | 100 | No control chars (`\p{Cc}`) | No (defaults to the domain) | No | | Org | description | `description` | 10 | 1000 | No control chars (`\p{Cc}`) | No | Yes | | Workspace | folder_name | `folder_name` | 4 | 80 | `^[\p{L}\p{N}-]+$` (unicode letters, digits, hyphens) | Yes (create) | No | | Workspace | name | `name` | 2 | 100 | No control chars (`\p{Cc}`) | Yes | No | | Workspace | description | `description` | 10 | 1000 | No control chars (`\p{Cc}`) | No | Yes | | Share | custom_name | `custom_name` | 4 | 80 | `^[\p{L}\p{N}\-_]+$` (unicode letters, digits, hyphens, underscores) | No (auto-generated when omitted) | No | | Share | custom_url | `custom_url` | 10 | 100 | — | No (default `null`) | Yes | | Share | title | `title` | 2 | 80 | No control chars (`\p{Cc}`) | No | Yes | | Share | description | `description` | 10 | 500 | No control chars (`\p{Cc}`) | No | Yes | **Notes:** - "No control chars" means the field rejects Unicode control characters (category `\p{Cc}`); other `\p{C}` subcategories such as format characters (`\p{Cf}`: bidi marks, ZWJ, etc.) are allowed - Org domain must be lowercase alphanumeric with optional hyphens, cannot start/end with hyphen - Workspace folder_name allows Unicode letters, digits, and hyphens - Share custom_name allows Unicode letters, digits, hyphens, and underscores; it is optional on create — when omitted, the server auto-generates a random URL name - Description max length for shares is 500, not 1000 like org/workspace ## Agent Workflows ### Upload Files Small files (< 4MB): single-request upload. Large files: chunked upload with parallel chunks. → Full details: [Upload reference](https://api.fast.io/current/llms/upload/) ### Query Documents with AI Create an agent chat with `POST /current/workspace/{id}/ai/agent/`, send messages, and stream responses via SSE. Use `/storage/search` for both keyword and semantic search — when workspace Deep Indexing is enabled, results automatically include ranked text snippets with relevance scores. Add `details=true` to include the full node resource (previews, AI state, metadata, size) for each result; default limit drops to 10 when details enabled. Search is better for retrieval/lookup; chat is better for synthesis/analysis. Choose what a search matches with the optional `search_in=filename|content|both` (default `both`). `filename` is the predictable, `find`-style surface: pair it with `name_match=exact|prefix|contains|glob` (default `auto`) and `case_sensitive` to match whole filenames — `name_match=glob` with `Quarterly*.pdf` finds `Quarterly Report.pdf`, spaces and all. `content` matches the AI's understanding of a file — its AI-generated summary and its meaning-based index — and is **not** a text scan of the file's bytes. Those are two separate channels, and Deep Indexing gates only the meaning-based one: summaries indexed earlier stay searchable after AI features are switched off, so `content` can still return hits there. Read `search_metadata.semantic_available` for what a given request actually got. Omit all three parameters and behavior is exactly as before. → Full details: [AI reference](https://api.fast.io/current/llms/ai/) ### Share Files with Humans Create Send/Receive/Exchange shares with Portal or Shared Folder storage modes. → Full details: [Shares reference](https://api.fast.io/current/llms/shares/) ### Monitor Usage `GET /current/org/{org_id}/billing/usage/limits/credits/` — credit consumption `GET /current/org/{org_id}/billing/usage/meters/list/` — detailed breakdown `GET /current/events/search/` — activity feed → Full details: [Events reference](https://api.fast.io/current/llms/events/) ### Activity Polling Long-poll for changes instead of looping on individual endpoints: `GET /current/activity/poll/{entityId}?wait=95&lastactivity={timestamp}` → Full details: [Events reference](https://api.fast.io/current/llms/events/) ## Endpoint Summary ### System - `GET /current/ping/` — Health check (no auth) - `GET /current/system/status/` — System status (no auth); `maintenance.window` reports a scheduled or active maintenance window (see *Maintenance windows*) - `GET /current/llms/` — This reference file (no auth) - `GET /current/agents/` — Agent integration guide (no auth) - `GET /.well-known/oauth-authorization-server/` — OAuth authorization server metadata (RFC 8414, no auth) - `GET /.well-known/oauth-protected-resource/` — OAuth protected resource metadata (RFC 9728, no auth) ### Users & Auth → [Auth reference](https://api.fast.io/current/llms/auth/) User CRUD, authentication (browser sign-in, API keys, 2FA, deprecated Basic Auth), email validation, password reset, user search. - `GET /current/auth/scopes/` — Token scope introspection. Returns auth_type (jwt_v2, jwt_v1, api_key, api_key_scoped), scopes array, scopes_detail (hydrated with entity names/domains/parents, each entry carrying `admin`), is_agent, agent_name, full_access, `admin` and `legacy`. `full_access` means account-wide **and may write** (`user:*:rw` or `user:*:rwa`) — `user:*:r` is `false`; `admin` means the credential can administer (a browser login session, or any `rwa` scope); `legacy` means the credential declares no scopes claim at all, which includes every browser login session; a sign-in session that declared `agent_name` reports `auth_type: "jwt_v2"`, `is_agent: true` and its `agent_name`, and stays `legacy: true`. For scoped API keys, returns `auth_type: "api_key_scoped"` with hydrated scope details, agent name, and expiration. Requires auth. - `POST /current/user/auth/login/start/` — Begin browser sign-in (email + password). Body: `return_origin`, `return_path=/signin/callback`, `code_challenge`, `code_challenge_method=S256`, `state`, optional `login_hint`, `break_glass`. Returns `login_url` on the Fastio sign-in page - `POST /current/user/auth/login/exchange/` — Exchange the callback `code` + `code_verifier` + `state` for a session (`auth_token`, `expires_in`, `2factor`, `enrol_required`, `email`). Refusals carry `error.params.reason`: `code_invalid`, `code_already_used`, `verifier_mismatch`, `state_mismatch` (400), `account_unavailable` (403), `login_unavailable` (503). Optional `x-ve-session-cookie` header sets the HttpOnly session cookie - `GET /current/user/auth/` — **Deprecated (will be retired):** Basic Auth → JWT exchange. Optional `x-ve-session-cookie` request header additionally delivers the token in an HttpOnly browser session cookie - `POST /current/user/auth/bootstrap/` — Browser only: returns the session token held in the HttpOnly cookie, as-is (the one endpoint that authenticates from a cookie; POST-only, same-origin, no `Authorization` header). Call it once per page load to bring the token into memory - `POST /current/user/` — Create user account; a new account gets a session (`auth_token`) in the response - `POST /current/user/update/` — Update user profile - `GET /current/user/{user_id}/details/` — User details - `POST /current/user/auth/key/` — Create API key - `GET /current/user/auth/keys/` — List API keys - `POST /current/user/auth/2factor/auth/{token}/` — 2FA verification (the limited-scope token is a required path segment) - `POST /current/user/email/validate/` — Email verification - `POST /current/user/email/reset/` — Password reset request - `GET /current/users/search/` — Search users - User Apps: `GET /current/user/apps/` (list installed apps), `POST /current/user/apps/install/`, `POST /current/user/apps/uninstall/`, `POST /current/user/apps/heartbeat/` — manage the caller's installed apps ### OAuth 2.0 → [OAuth reference](https://api.fast.io/current/llms/oauth/) PKCE authorization flow, token exchange/refresh, session management, Dynamic Client Registration (RFC 7591/7592), Client ID Metadata Document (CIMD), Resource Indicators (RFC 8707), scoped access tokens (JWT v2.0). - `GET /.well-known/oauth-authorization-server/` — Authorization server metadata (RFC 8414, no auth) - `GET /.well-known/oauth-protected-resource/` — Protected resource metadata (RFC 9728, no auth) - `POST /current/oauth/register/` — Dynamic client registration (no auth, rate limited) - `GET /current/oauth/authorize/` — Start the PKCE flow: 302 to the sign-in/consent page, or with `response_format=json` a JSON body whose `login_url` the client opens verbatim. Optional `display_code=true` for copy-paste code mode - `PUT /current/oauth/register/` — Update client registration (localhost-only clients) - `POST /current/oauth/token/` — Token exchange (authorization_code, refresh_token) - `POST /current/oauth/revoke/` — Revoke token - `GET /current/oauth/sessions/` — List OAuth sessions - `GET /current/oauth/authorize/info/` — Authorization request info ### Organizations → [Organizations reference](https://api.fast.io/current/llms/orgs/) Org CRUD, member management, billing/subscriptions, org discovery. - `POST /current/org/create/` — Create organization - `GET /current/org/{org_id}/details/` — Org details - `POST /current/org/{org_id}/update/` — Update org - `GET /current/org/{org_id}/members/list/` — List members - `POST /current/org/{org_id}/create/workspace/` — Create workspace - `GET /current/org/{org_id}/billing/details/` — Billing info - `GET /current/org/{org_id}/billing/invoices/` — List invoices with hosted payment links An Enterprise-plan org can also restrict which members may invite outside people onto its shares, portals and workspaces via three policy-envelope keys on org update/details (`external_invites_shares` / `_portals` / `_workspaces`) — see *Collaboration Policies* in the Organizations reference for the envelope shape and the two refusal reasons (`external_invites_denied`, `external_invites_object_denied`). An Enterprise-plan org can also cap what API keys and OAuth grants issued inside it may hold, checked again on **every later request**, not only when a credential is minted — see *Credential Policy* in the Organizations reference and *Org Credential Policy* in the Auth reference for the `credential_policy` setting and its three refusal reasons (`credential_policy_mode`, `credential_policy_scope`, `credential_policy_sso`), plus the lookup outcomes `credential_policy_unavailable` (`503`, retry), `credential_policy_unreadable` and `credential_policy_org` (both `403`, permanent). An Enterprise-plan org can also restrict whether cloud sync runs at all, and whether it may write local changes back to the connected provider — org `cloud_sync` (`{enabled, mode}`), met at runtime with each workspace's own `cloud_sync_mode` ceiling. See *Cloud Sync Policy* in the Organizations reference and *Cloud Sync Policy* in the Workspaces reference for the resolution order, the `effective_cloud_sync` / `effective_access_mode` fields, and the two refusal reasons (`cloud_sync_disabled`, `cloud_sync_read_only`). An Enterprise-plan org can also require a second factor at sign-in — org `auth_require_2fa`, a policy envelope of `{admin, member, overrides}` whose every value is `required` or `optional`, not a single scalar. It governs password and social login only, not API keys, OAuth or MCP tokens, and an SSO-minted session is always compliant. `GET /current/user/auth/` and the social sign-in callback both gain an always-present `enrol_required` boolean; when `true`, the issued token is a narrow enrolment credential rather than a session. See *Require-2FA Policy* in the Organizations reference and *Interactive Login & Enrolment* in the Auth reference. ### Workspaces → [Workspaces reference](https://api.fast.io/current/llms/workspaces/) Workspace CRUD, member management, assets, Deep Indexing setting, discovery. - `GET /current/workspace/{workspace_id}/details/` — Workspace details - `POST /current/workspace/{workspace_id}/update/` — Update workspace - `GET /current/workspace/{workspace_id}/members/list/` — List members - `POST /current/workspace/{workspace_id}/create/share/` — Create share - `GET /current/workspace/{workspace_id}/list/shares/` — List shares ### Storage → [Storage reference](https://api.fast.io/current/llms/storage/) File/folder CRUD (both workspace and share), locking, previews, transforms, download tokens, recently modified files. - `GET /current/workspace/{id}/storage/{parent}/list/` — List folder contents - `GET /current/workspace/{id}/storage/{node}/details/` — Node details - `GET /current/workspace/{id}/storage/{node}/read/` — Download file - `POST /current/workspace/{id}/storage/{parent}/addfile/` — Add uploaded file - `POST /current/workspace/{id}/storage/{parent}/createfolder/` — Create folder - `POST /current/workspace/{id}/storage/{node}/lock/` — Acquire file lock - `POST /current/workspace/{id}/storage/{node}/lock/heartbeat/` — Renew lock - `DELETE /current/workspace/{id}/storage/{node}/lock/` — Release lock - `POST /current/workspace/{id}/storage/{node}/lock/override/` — Take over a lock held by someone else (write access required); returns YOUR new `lock_token`. Takes the lock only — a stale `if_version_id` still conflicts; this is not "force save" - `GET /current/workspace/{id}/storage/search/` — Search files. Optional `search_in` (`filename`/`content`/`both`), `name_match` (`auto`/`exact`/`prefix`/`contains`/`glob`), and `case_sensitive` - `GET /current/workspace/{id}/metadata/search/` — Search nodes by metadata field values; each result names the field(s) that matched (workspace-only) - `GET /current/workspace/{id}/search/` — Unified search: files, metadata, and comments in one call, grouped into per-type buckets with independent pagination. The same `search_in` / `name_match` / `case_sensitive` parameters shape the `files` bucket - `GET /current/share/{id}/search/` — Unified search across a share (files and comments buckets) ### Shares → [Shares reference](https://api.fast.io/current/llms/shares/) Share CRUD, types (Send/Receive/Exchange), storage modes (Portal/Shared Folder), members, branding, durable File Share (single-file links), anonymous file drop (guest auth). Shares support `download_security` levels: `off` (default), `medium` (guest downloads require a short-lived nonce), `high` (downloads disabled for guests). - `POST /current/workspace/{id}/create/share/` — Create share - `GET /current/share/{share_id}/details/` — Share details - `POST /current/share/{share_id}/update/` — Update share - `GET /current/share/{share_id}/members/list/` — List members - `POST /current/share/{share_id}/auth/guest/` — Guest authentication - `POST /current/share/{share_id}/auth/password/` — Password authentication ### File Share (durable single-file links) → [Shares reference](https://api.fast.io/current/llms/shares/) A **File Share** is a durable, link-shareable view of ONE file from a workspace. It is the successor to the deprecated QuickShare: it is durable by default (an optional expiry can be set), and access is governed by three tiers (`anyone_with_link`, `any_registered`, `named_people`) plus optional per-user grants (`view` / `download` / `edit`) and an optional link password (passed via the `x-ve-password` request header, never the URL). A holder of an `edit` grant can replace the shared file's content by targeting the File Share id from an upload session. - `POST /current/workspace/{id}/create/fileshare/` — Create a File Share bound to a file node - `GET /current/workspace/{id}/list/fileshares/` — List a workspace's File Shares - `GET /current/fileshare/{fileshare_id}/details/` — Public viewer metadata - `GET /current/fileshare/{fileshare_id}/storage/read/` — Download the bound file - `GET /current/fileshare/{fileshare_id}/storage/preview/{preview_type}/read/` — Preview the bound file - `GET /current/fileshare/{fileshare_id}/storage/versions/` — List the bound file's version history - `POST|PATCH /current/fileshare/{fileshare_id}/update/` — Update title / access tier / password - `GET|POST|DELETE /current/fileshare/{fileshare_id}/grants/` — List, grant, or revoke per-user capabilities - `DELETE /current/fileshare/{fileshare_id}/delete/` — Delete the File Share - `GET /current/fileshare/{fileshare_id}/storage/readnote/{note_id}/` — Read a shared note's content; `POST /current/fileshare/{fileshare_id}/storage/updatenote/{note_id}/` — Update a shared note (requires an edit grant) - `GET /current/fileshare/{fileshare_id}/realtime/note-auth/{note_id}/` — Mint a note-scoped realtime token for collaborative editing of the shared note > **QuickShare is deprecated.** Creating or extending a QuickShare (`POST /current/workspace/{id}/storage/{node}/quickshare/`) now returns **403** (`10756 (Quickshare Deprecated)`) with a directed message; create a durable File Share instead. Existing QuickShare links can still be viewed, downloaded, and revoked during the drain. ### Upload → [Upload reference](https://api.fast.io/current/llms/upload/) Chunked upload flow, web upload (URL import), session management. - `POST /current/upload/` — Create upload session - `POST /current/upload/{id}/chunk/` — Upload chunk - `POST /current/upload/{id}/complete/` — Finalize upload - `GET /current/upload/{id}/details/` — Check upload status - `POST /current/web_upload/` — Import file from URL ### AI & Agent (formerly Chat) → [AI reference](https://api.fast.io/current/llms/ai/) **v3.5 path rename:** the AI agent endpoints moved from `/ai/chat/` to `/ai/agent/`. The legacy `/ai/chat/` paths have been **retired and removed** — they no longer respond, and `/ai/agent/` is now the only path. If you have an older integration still calling `/ai/chat/...`, update it to the equivalent `/ai/agent/...` path. The core request and response shapes carry over, but **file attachment changed**: attach a file or folder by including it as a reference item (`{type, id}`, files may add `file_details.version_id`) in the `references` / `content_parts` / `subjects` arrays — the retired `files_attach` / `files_scope` / `folders_scope` string params are gone, and a file that can't be attached now returns an error instead of being silently ignored. See the AI reference for the full migration note. RAG chat, general chat, semantic search, notes, saved metadata filters, AI extraction, SSE streaming. Saved metadata filters are available on all plan tiers, with per-filter node caps that scale by plan (lower tiers have small caps; a filter's node listing returns a `scope` object naming every way the answer was bounded, so a short list is never mistaken for a complete one). Two plan features gate AI: `content_ai` (master switch for all AI surfaces) and `ai_agent` (interactive agentic flows — create chat, send message, enable the `intelligence` indexing toggle). All current plans (Starter, Business, Enterprise) include both. See [AI reference: Plan Requirements](https://api.fast.io/current/llms/ai/#plan-requirements) for the matrix. - `POST /current/workspace/{id}/ai/agent/` — Create AI chat - `POST /current/workspace/{id}/ai/agent/{chat_id}/message/` — Send message - `GET /current/workspace/{id}/ai/agent/{chat_id}/message/{msg_id}/read/` — Stream response (SSE) - `GET /current/workspace/{id}/metadata/eligible/` — List nodes (files and notes) eligible for metadata extraction, each with its extracted metadata values inline - `GET /current/workspace/{id}/metadata/fields/` — List the workspace's metadata field vocabulary (names, types, constraints, aliases, `file_count`) - `POST /current/workspace/{id}/metadata/fields/merge/` — Fold one metadata field into another, retiring the source's name into the target's aliases (workspace admin; irreversible). **`confirm` is a BOOLEAN here, not the resource-name string it is on delete-style endpoints** — omit it, or send `false`, for a pre-flight that writes nothing - `POST /current/workspace/{id}/storage/{node}/metadata/extract/` — Enqueue async metadata extraction (returns HTTP 202 + job_id; poll `/jobs/status/`) - `POST /current/workspace/{id}/storage/{node}/metadata/extract-all/` — Folder-level batch metadata extraction across a folder's eligible nodes (`root` for the whole workspace; optional `fields` scopes the run to named fields) - `GET /current/workspace/{id}/metadata/filters/` — List the workspace's saved metadata filters - `POST /current/workspace/{id}/metadata/filters/` — Create a saved metadata filter - `GET /current/workspace/{id}/metadata/filters/{filter_id}/` — Saved filter details - `PUT /current/workspace/{id}/metadata/filters/{filter_id}/` — Update a saved filter - `DELETE /current/workspace/{id}/metadata/filters/{filter_id}/` — Delete a saved filter - `GET /current/workspace/{id}/metadata/filters/{filter_id}/nodes/` — Run a saved filter and list the nodes it matches - `POST /current/workspace/{id}/metadata/compound-search/` — Compound search: a metadata `filters` predicate intersected with a semantic `content_query`, in one call. Workspace-only; needs Member, the `metadata` **and** `content_ai` plan features, and Deep Indexing enabled on the workspace. Returns `items` plus a mandatory `scope` object naming every way the answer was bounded. See *Compound Search* in the [Storage reference](https://api.fast.io/current/llms/storage/) **Metadata templates have been retired and removed.** Every `/metadata/templates/...`, `/metadata/template/...`, `/storage/{node}/metadata/template_select/` and `/storage/{node}/metadata/templates/` path is finished. The workspace-scoped ones were deleted, so they no longer route and answer `9992` — indistinguishable from a mistyped path. The two node-scoped ones still have a handler and answer `410 Gone` — `error.code` `122209` for `/storage/{node}/metadata/template_select/` and `131380` for `/storage/{node}/metadata/templates/` — which says explicitly that the path will not return. Neither is worth a retry. Structured metadata is now organised by **saved metadata filters** — a stored predicate over the workspace's field vocabulary plus an optional ordered projection — listed above. A filter's membership is computed from its predicate, so files are no longer added to or removed from a set by hand, and there is no folder-level assignment. Field names, types and constraints come from the workspace field vocabulary (`/metadata/fields/`), which is discovered from the metadata that extraction and metadata writes produce rather than declared up front. ### How-To → [How-To reference](https://api.fast.io/current/llms/howto/) Single-call natural-language product help: ask a "how do I…" question about Fastio and get a grounded answer (or a clarifying question) back. Top-level endpoint; **free** — any authenticated user may call it, no org required, no entity is ever charged (cached or live), bounded only by a per-user rate limit. - `POST /current/how-to/` — Ask a how-to question (`question` required ≤2000 chars; optional `context` ≤8000 chars). Returns HTTP 200 with `status:"answer"` (`answer`, `escalated`, `topics_used`) or `status:"needs_clarification"` (`questions`). ### Events & Activity → [Events reference](https://api.fast.io/current/llms/events/) Event search/filtering, activity polling, WebSocket realtime. - `GET /current/events/search/` — Search events - `GET /current/events/search/summarize/` — AI event summary - `GET /current/event/{event_id}/details/` — Event details - `POST /current/event/{event_id}/ack/` — Acknowledge event - `GET /current/org/{org_id}/events/changes/` — Org-wide storage change feed (cursor-based) - `GET /current/activity/poll/{profile_id}/` — Long-poll for changes - `GET /current/realtime/auth/validate/` — Validate realtime token - `GET /current/realtime/note-auth/{profile_id}/{note_id}` — Mint a note-scoped realtime token for collaborative note editing (900s TTL) - `GET /current/realtime/note-auth/validate/` — Validate a note-scoped realtime token - `GET /current/websocket/auth/{profile_id}` — WebSocket auth ### Comments → [Comments reference](https://api.fast.io/current/llms/comments/) Comment CRUD (JSON body), threading, mentions, reactions, reference anchoring. - `POST /current/comments/{entity_type}/{parent_id}/` — Create comment - `GET /current/comments/{entity_type}/{parent_id}/` — List comments - `GET /current/comments/{comment_id}/details/` — Comment details - `POST /current/comments/{comment_id}/update/` — Edit comment by ID - `POST /current/comments/{comment_id}/reactions/` — Add reaction - `DELETE /current/comments/{comment_id}/delete/` — Delete comment ### Signing / E-Signature → [Signing reference](https://api.fast.io/current/llms/signing/) Audit-archive SignEnvelopes holding up to 20 PDFs sent to recipients for electronic signature. Internal PAdES-LT cryptographic signing with downloadable audit certificate. Two surfaces: sender/admin (bearer JWT) and signer (path-token JWT). The org's billing plan gates the whole surface (exposed as `capabilities.signing`). **Sender / Admin (workspace-parented):** - `POST /current/workspace/{workspace_id}/sign_envelopes/create/` — Create a draft envelope - `GET /current/workspace/{workspace_id}/sign_envelopes/list/` — List envelopes - `GET /current/workspace/{workspace_id}/sign_envelopes/{envelope_id}/details/` — Get envelope - `POST|PATCH /current/workspace/{workspace_id}/sign_envelopes/{envelope_id}/update/` — Update draft - `POST /current/workspace/{workspace_id}/sign_envelopes/{envelope_id}/send/` — Send (Draft → Sent) - `POST /current/workspace/{workspace_id}/sign_envelopes/{envelope_id}/void/` — Void (reason required) - `GET /current/workspace/{workspace_id}/sign_envelopes/{envelope_id}/documents/{document_id}/download/` — Stream the source PDF bytes (application/pdf; Bearer-authed) - `GET /current/workspace/{workspace_id}/sign_envelopes/{envelope_id}/documents/{document_id}/preview/` — Stream the source PDF for inline preview (application/pdf) - `GET /current/workspace/{workspace_id}/sign_envelopes/{envelope_id}/documents/{document_id}/signed/download/` — Stream the signed PDF bytes (application/pdf; 404 until the document completes) - `GET /current/workspace/{workspace_id}/sign_envelopes/{envelope_id}/audit/download/` — Stream the audit certificate (JSON evidence record; 404 until the envelope is terminal) - `GET /current/workspace/{workspace_id}/sign_envelopes/{envelope_id}/audit/pdf/download/` — Stream the audit certificate as a rendered PDF (application/pdf; 404 until the envelope is terminal) - `POST /current/workspace/{workspace_id}/sign_envelopes/{envelope_id}/my_sign_link/` — Mint the caller's own signing link for the envelope; returns actionable/blocked/terminal/reauth decision (requires write-scope token) **Signer surface (public, path-token JWT):** - `GET /current/sign_envelopes/signer/{token}/view/` — Landing (envelope state, documents, fields, consent) - `GET|POST /current/sign_envelopes/signer/{token}/authenticate/` — Issue or verify OTP - `POST /current/sign_envelopes/signer/{token}/sign/` — Submit consent + field values (queues async signing) - `GET /current/sign_envelopes/signer/{token}/status/` — Poll signing pipeline (adaptive `next_poll_seconds`) - `POST /current/sign_envelopes/signer/{token}/decline/` — Decline (cascades envelope to Declined) **Plan availability:** Signing availability depends on your organization's plan. See the [Signing reference](https://api.fast.io/current/llms/signing/) for details. Lifecycle: `draft` → `sent` → `in_progress` → `completed` | `declined` | `voided` | `expired` | `failed`. Activity events: `sign_envelope_drafted`, `sign_envelope_sent`, `sign_envelope_voided`, `sign_envelope_viewed`, `sign_envelope_recipient_signed`, `sign_envelope_recipient_declined`, `sign_envelope_document_signed`, `sign_envelope_completed`, `sign_envelope_expired`, `sign_envelope_failed`. ### Dashboard → [Dashboard reference](https://api.fast.io/current/llms/dashboard/) Per-workspace, per-member feed of ranked, paginated actionable cards. Cards are drawn from @mentions, file activity, and pending signatures. When the workspace plan includes AI features, an AI overlay adds urgency scores (0–100), summaries, and suggested actions to each card; cross-item synthesis cards appear at the end. Each card carries a `ripley_seed` for pre-focused Ripley Agent conversations. Dismiss/snooze is per-member and out-of-band (never advances the underlying item). - `GET /current/workspace/{workspace_id}/dashboard/` — Get ranked, paginated card feed (`limit` 1–200, default 50; response: `cards`, `pagination`, `dismissed_recent_count`) - `POST /current/workspace/{workspace_id}/dashboard/cards/{card_key}/dismiss/` — Dismiss or snooze a card (`snooze_until` optional; URL-encode `card_key` which contains `:`) - `DELETE /current/workspace/{workspace_id}/dashboard/cards/{card_key}/dismiss/` — Undismiss (restore card to feed) - `POST /current/workspace/{workspace_id}/sign_envelopes/{envelope_id}/my_sign_link/` — Mint signing link for a dashboard signature card; returns actionable/blocked/terminal/reauth decision (requires write-scope token) ### Agent Intents → [Agent Intents reference](https://api.fast.io/current/llms/intents/) Workspace-scoped, short-lived declarations of what an agent is doing, so peers see a collision before it happens rather than after. Allocation is content-free (an unfilled slot is occupancy, not an incomplete write); filling a slot is the only heartbeat (no separate renewal call) and is a compare-and-set on `version`. Slot identity is credential-keyed — `(workspace, node, user, agent_name)`, so five agents of one operator hold five slots — but **ownership is scoped to the user**: any credential authenticated as the holder may fill or release a slot, so sibling agents of one operator can pick up each other's, while another user's slot answers `404`. Rows carry `node` beside `node_id` and `locker` beside `locker_uid` — each resolved at read time and legitimately `null`. Workspace Member permission required; workspace-only, no share variant. - `POST /current/workspace/{workspace_id}/intents/` — Allocate a slot (no `topic`/`message` — content-free by design) - `GET /current/workspace/{workspace_id}/intents/` — Browse the workspace's live intents, topics only, ordered by allocation (`cursor` for paging) - `POST /current/workspace/{workspace_id}/intents/{intent_id}/` — Fill or refine a slot (requires `version`; also pushes `expires_at` forward) - `GET /current/workspace/{workspace_id}/intents/{id1},{id2}/` — Expand one or more intents in one call (full `message` included) - `DELETE /current/workspace/{workspace_id}/intents/{intent_id}/` — Release a slot (idempotent) ### Bug Reports → [Bug Reports reference](https://api.fast.io/current/llms/bug-reports/) Report a Fastio platform bug straight to the Fastio team, in one call. Requires a signed-in, verified, write-capable credential (a read-only key is refused); no org or plan is required. `idempotency_key` makes a retry safe. There is no endpoint to list or read a report back. - `POST /current/bug-reports/` — Report a bug (`category`, `title`, `description` required; `request_id`, `endpoint`, `http_status`, `error_code`, `occurred_at`, `blob`, `idempotency_key` optional). Returns `report: {id, status: "received", created}` ### Cloud Sync (External Storage Sync) Cloud Sync connects external cloud storage providers to workspace storage. The provider set is `google_drive`, `dropbox`, `box` and `onedrive_business`, but **which of them a given workspace may actually connect varies — read the providers endpoint below rather than assuming any particular one is on offer.** On opt-in read-write sources, local edits/adds/deletes are pushed back to the provider (two-way sync). Requires `cloud_import` feature enabled on the workspace. Identities are **per-user and per-organization**: each member connects their own account once (owned by them, up to 4 active per member per organization), and that identity is usable in every workspace of the organization where they are a Member — provisioning from another workspace returns the existing identity. A different organization has its own identities. The account address and other sensitive identity fields are masked for everyone but the owner, workspace admins included. **Feature Toggle (workspace admin):** - `POST /current/workspace/{workspace_id}/cloud-import/enable/` - Enable cloud sync - `POST /current/workspace/{workspace_id}/cloud-import/disable/` - Disable cloud sync **Available Providers:** - `GET /current/cloudsync/workspace/{workspace_id}/providers/` - List providers available on workspace's plan **Provider Identities:** - `GET /current/cloudsync/workspace/{workspace_id}/identities/` - List provider identities: the caller's own in the organization, plus those this workspace's sources sync through - `POST /current/cloudsync/workspace/{workspace_id}/identities/provision/` - Provision your own identity. **Every provider** returns an `authorize_url` for the browser OAuth-connect flow: the owner consents in a browser and the connection is finished by the completion endpoint below, so polling alone never activates them, and there is no address to share a folder with — the connected account IS the access. **Provisioning requires workspace Member on every provider** — a Viewer is refused, because the grant is only usable by someone who can create a source. Which providers a workspace may connect at all depends on more than one thing — the plan is only part of it, and a provider can be unavailable to a workspace whose plan grants it — so read the providers endpoint above rather than assuming all four. **Share with a level that can WRITE if the source will be `read_write`** (for Box, `Editor` rather than `Viewer`): nothing verifies the level you granted at connect time — the provider is the only thing that ever checks it, and it does so at transfer time — so a read-only grant connects and syncs happily and then fails the first write-back permanently. - `GET /current/cloudsync/workspace/{workspace_id}/identities/{identity_id}/` - Identity details - `POST /current/cloudsync/workspace/{workspace_id}/identities/{identity_id}/revoke/` - Revoke identity (**owner only**, from any workspace of the organization; needs a sign-in session or an organization-scoped credential — a workspace-scoped key gets `175899` → 403, a non-owner `196524` → 403). Parks the identity's sources in every workspace of the organization - `POST /current/cloudsync/oauth/{provider}/complete/` - Finish a browser OAuth connect (`provider` is `dropbox`, `onedrive`, `box` or `google_drive`). The provider redirects the browser to a route on the **app** origin (`https://go.{host}/imports/oauth/{provider}/callback`); that route posts what it was handed here, on the user's session. Body: `{"code": "…", "state": "…"}`, or `{"error": "…", "state": "…"}` when the user declines — **post the decline too**, or the identity is left `provisioning` with nothing coming to finish it. Requires the caller to BE the user who started the connection: anyone else gets `1680 (Access Denied)`, which is what stops a forwarded `authorize_url` from attaching somebody else's cloud account to your identity. Returns the `identity`, `connected`, and `connect_error_code` (`null` on success; otherwise `declined`, `exchange_failed`, `account_read_failed` (sign-in worked but reading the connected account failed — retry), `credential_store_failed`, `activation_failed`, `account_mismatch` (a reconnect completed against a different cloud account than the identity held before), `scope_not_granted` (Google Drive and OneDrive: the consent left out a permission the connection needs — connecting again and approving it fixes it), or — OneDrive only — `insufficient_scope` (a tenant administrator must consent), which also carries an `admin_consent_url`). A `1609 (Not Found)` here is settled — the identity is gone and the flow must start again. `1664 (Datastore Error)` means the identity could not be READ, and **the message says whether to retry**: "Failed to load the connection being completed." is raised before the authorization is redeemed and should be **posted again with the same `code` and `state`**, while "The connection could not be verified after authorization; start a new connection." is raised after it has been redeemed — the link is spent, so start again from `provision` rather than reposting. There is deliberately no public callback endpoint to call instead. **Drive Catalog (OneDrive for Business only):** A OneDrive identity is one connected Microsoft account — **work or school, or personal**. A work or school account can usually reach several document libraries — its own OneDrive, plus whatever SharePoint it has access to — with no meaningful default among them. A personal account has exactly one drive and no SharePoint at all. So which library an import uses is chosen **per source** and must be picked from the identity's own catalog. - `GET /current/cloudsync/workspace/{workspace_id}/identities/{identity_id}/drives/` - List the libraries this identity can reach (**identity owner only** — a workspace admin is refused, because this catalog is one person's own Microsoft content; admins can still disconnect or delete sources). Reads stored rows only — it never contacts the provider. Optional `site_path` filters to one SharePoint site. Returns `drives[]` plus `drives_state`, `drives_refreshed_at`, `drives_error` and `requires_site_path`. - `POST /current/cloudsync/workspace/{workspace_id}/identities/{identity_id}/drives/refresh/` - Re-enumerate the catalog from the provider (**identity owner only**, same rule as the list endpoint). Asynchronous: it returns immediately with `drives_state: "refreshing"` and no job id — poll the drives endpoint until the state leaves `refreshing`. Optional `site_path` enumerates one named site and replaces only that site's rows, which is how an account whose sites cannot be enumerated builds a catalog one site at a time. One refresh at a time per identity; a second call while one is genuinely in flight is rejected. `drives_state` is the field to branch on, because an EMPTY list is the normal state on a first connection — nothing is enumerated until a refresh is asked for — so `drives: []` alone cannot say why: | `drives_state` | Meaning | What the caller does | |---|---|---| | `never_refreshed` | Nobody has enumerated this identity yet | Call the refresh endpoint | | `refreshing` | An enumeration is queued or running | Poll until it changes | | `ready` | The catalog holds at least one library | Let the user pick one | | `empty` | The enumeration completed and found none | The connected account reaches no libraries; check the account, or name a site | | `requires_site_path` | The enumeration could not prove it saw everything reachable, and found nothing to offer. **Not a permission denial** | Refresh again with `site_path` | | `permission_denied` | Something the enumeration NAMED was refused. A site path will not fix it | The account needs access, or reconnect with fuller consent | | `failed` | The enumeration itself failed (provider outage, token error) | Retry; `drives_error` carries the detail | `drives_error` is normally null on success, with ONE exception: a `ready` catalog that was cut off at an internal ceiling carries a truncation note. The libraries listed are real and selectable — the list is just incomplete — so present it as a warning beside a usable list, not as a failure. **A partial consent is not an error.** A user who grants access to their files but withholds the SharePoint permission connects successfully — the catalog simply covers their own OneDrive and no site libraries. That is a working connection with a smaller catalog, not a failed one. **Import Sources:** - `GET /current/cloudsync/workspace/{workspace_id}/sources/` - List import sources. Every source object carries `remote_folder_web_url`, a browser link to the folder at the provider, built from the folder's own id so it survives a rename. **Null means exactly one thing — no valid stored ID-to-URL mapping can be built**: no id is stored yet (a source acquires one on a sync, so an existing source can gain a link later without anything being done to it), the provider publishes none, the provider has no URL that addresses a folder by id (Dropbox and OneDrive for Business are always null; Google Drive and Box return a link), the stored id is not shaped like that provider's ids, or the connected account could not be read. Never build your own link out of `remote_path`: it breaks the moment the folder is renamed. Every source object also carries `conflict_count` — write-backs on that source awaiting a `resolve` decision. It was previously on the details response only, so telling which of a workspace's grafts needed attention meant opening each one; it is computed for the whole page in one grouped query, so reading the list is not more expensive per source than it was. - `POST /current/cloudsync/workspace/{workspace_id}/sources/discover/` - Discover remote folders (**the identity owner only, on every provider** — a non-owning workspace admin gets `1680`, because discovery browses that member's own cloud account. The same gate covers reading the discovery job's results). **OneDrive for Business requires `drive_id`** — the folders discovered are the ones inside that library. Optional `remote_path` enumerates the folders one level below that remote folder instead of the provider root (max 2048 characters); browse a tree by passing each folder back on the next call. Each entry carries `remote_id`, `name`, `remote_path`, `type`, `size` and `already_imported`. **`already_imported` follows the FOLDER, not the path**: TWO keys are checked and a match on EITHER sets it — `remote_id` against the folder id of a source **on the same drive**, or `remote_path` against a source's path (empty never matches empty on either). So a connected folder renamed or moved at the provider is still reported as already imported. The id key is drive-scoped because provider item ids are unique within a drive, not across them — an unscoped one would block you from importing a folder you never imported; the path key is unscoped, which is long-standing behaviour. - `POST /current/cloudsync/workspace/{workspace_id}/sources/create/` - Create import source (**only the owner of the connected cloud account can import its folders — a workspace admin cannot do it on someone else's behalf**, though an admin can still remove any user's import afterwards; that owner must additionally be a Member of the workspace, so **guests cannot create a source**: it opens a standing, unattended sync channel into workspace storage, so it gates above the level an ordinary one-off storage write needs); `access_mode` = `read_only` (default) or `read_write`. **OneDrive for Business requires `drive_id`**, taken from the drives endpoint above; it is validated against that identity's catalog, and the library's type and name are recorded server-side rather than accepted from the request. The other providers do not take it. Optional `destination_node_id` chooses the workspace folder the import lands under — it must be an existing FOLDER in this workspace, and one that is not already part of an import (rejected at create as `destination_nested`); omit it for the `Imports` system folder at the storage root, or send `root` to place the import directly at the top level of the workspace. Read the outcome back as a **pair**: the source carries `destination_node_id` (what was chosen) and `destination_fallback_at` (set when that folder could not be used at graft time and the import landed in `Imports` instead) — the first alone reads as a successful placement even when the import actually fell back. Optional `remote_id` — the value from the chosen discovery entry — is **advisory** and never stored: it can only make the duplicate check refuse a folder it would otherwise have admitted, which is how a folder renamed or moved since it was connected gets recognised. The check ORs the id and path keys, both drive-scoped **on this endpoint** (discovery's path key is not, so the same path in two libraries can read as already imported there and still be accepted here), so a `remote_id` matching nothing cannot be used to slip a duplicate past the path comparison. A folder this workspace already has is refused with "This folder is already connected to this workspace"; the same folder on a **different drive** is a different folder and is allowed. **Source Details:** - `GET /current/cloudsync/details/{source_id}/` - Source details (adds `provider_name`, `owner_user_id`, `is_owner`; `conflict_count` is on the list response too, so a badge does not need this call) - `POST /current/cloudsync/details/{source_id}/update/` - Update settings incl. `access_mode` (owner or admin). `drive_id` is **create-time only** and is rejected here: the library a source syncs is part of what the source IS, and repointing it at another one would make every already-imported file's origin wrong. Create a second source instead. `destination_node_id` is rejected here for the same reason (`destination_immutable`) — move the imported folder in the workspace instead; the sync follows it. - `POST /current/cloudsync/details/{source_id}/delete/` - Soft-delete source (owner or admin) - `POST /current/cloudsync/details/{source_id}/disconnect/` - Disconnect (owner or admin). `action: "keep"` leaves the imported files as ordinary workspace content; `action: "delete"` moves them to the trash. **`delete` is refused with `403` when the imported folder holds anything this source did not import** — something a member added or moved in, an item belonging to another connected folder, or a structure too deeply nested or looping to check — and a refusal changes **nothing at all**: no file is trashed and the source stays connected, so it can be retried once the cause is cleared. `keep` is not a blanket-safe fallback: it releases every imported item in the folder, including one belonging to another connected folder. A cleanup that cannot be proved to have happened is an error, never a `200` — `data_deleted` in the success body is `null` for `keep` and always `true` for `delete` - `POST /current/cloudsync/details/{source_id}/refresh/` - Trigger immediate sync (owner-or-admin) **Import Jobs:** - `GET /current/cloudsync/details/{source_id}/jobs/` - List sync jobs - `GET /current/cloudsync/details/{source_id}/jobs/{job_id}/` - Job details with progress - `POST /current/cloudsync/details/{source_id}/jobs/{job_id}/cancel/` - Cancel job (workspace admin) **Write-back (Read-Write Sources):** reading the queue is member-level, but every write operation requires the **member who owns the source's identity, or a workspace admin** — the transfer runs under that owner's cloud credential, so a push, a retry, or a `keep_local` resolve writes into their personal cloud account. A member below that level gets `1680 (Access Denied)`. Each write operation also has a **state precondition**, and a request that violates one is answered `1660 (Conflict)` → **409**, never a 5xx: `retry` needs a job in `failed`, `resolve` needs one in `conflict`, `cancel` needs one in `pending` or `conflict`, and `push` needs the source's `access_mode` to be `read_write` **and a `{node_id}` naming an imported FILE or NOTE**. That last one is worth spelling out: a folder inside a synced folder is an imported node just as much as a file is, so a folder's id is a plausible thing to send — it is refused at request time with the same `1660 (Conflict)` → **409**, rather than accepted with a `200` and a queued job that can only fail later. A state-precondition refusal is a settled client-side condition, not a fault to wait out — **resending the same request unchanged can never succeed**. Re-read the job (or the source) and act on the state it is actually in. **The other refusals on these endpoints are the opposite — transient — and must not be read the same way.** `push`, `retry`, `resolve` and `cancel` each take a short exclusive turn on the node they act on, so two write-back actions for one file cannot interleave, and **three distinct things can go wrong with that turn — each answering `1693 (Temporarily Unavailable)` → 503, and on each of them nothing was written.** (1) The turn could not be **taken**: "Another write-back action for this node is in progress" — another action is holding it right now; all four endpoints. (2) The turn was taken but the already-covered check **could not be carried out**: "Could not check whether this node already has a write-back in progress" — the request was never evaluated on its merits; `push`, `retry` and `resolve` (`cancel` has no such check, because it removes a live job rather than creating one, so coverage is irrelevant to it). (3) The turn was taken and **held, then lapsed before the change was applied**: "The write-back lock for this node expired before the change was applied" — the turn is short-lived and is re-proved immediately before the write, it had expired by then, and the request stopped there; all four endpoints. These are three different facts, not three phrasings of one — *could not start*, *started but could not look*, *started and lost the turn before writing* — and only the third got as far as being ready to write, which is exactly why it is worth saying that it still wrote nothing: re-sending it cannot queue, re-queue or cancel anything twice. Back off briefly and send the same request again, unchanged; these are the write-back outcomes a plain retry fixes. Separately, `push`, `retry` and `resolve` refuse a node already covered by **another** live write-back — one sitting in `pending`, `uploading` or `conflict`, for the same `operation` — with `1660 (Conflict)` → **409** and the message "This node already has a write-back in progress". **That 409 is NOT settled**: it clears on its own once the covering job finishes. Re-read the queue and wait for that job to reach a terminal status; do not repost in a tight loop, and do not treat it as permanent. On `resolve` the check **excludes the job being resolved** — a `conflict` job is itself live, so counting it would refuse every resolve — meaning a resolve is never blocked by its own row, only by a *different* live write-back that appeared on the same node while the conflict waited for a decision. On `retry` and `resolve`, tell the two 409s apart by re-reading the job — still in the state the action requires means the refusal was the transient one, any other state means it was the settled one. On `push` there is no job id to re-read, so the refusal itself is the discriminator: an access-mode or not-an-imported-file-or-note refusal is settled, and only the already-covered one clears as the queue drains. On `cancel` there is only one 409, the state precondition, and it is settled. In short: **`1693` → 503, retry it; a `1660` saying the node is already covered, re-read and wait; a `1660` on a state precondition, settled — never retry.** - `GET /current/cloudsync/details/{source_id}/writebacks/` - List write-back jobs (any member) - `POST /current/cloudsync/details/{source_id}/writebacks/push/{node_id}/` - Force-push one imported file or note (owner or admin; a folder's id is refused `409` with `error.code` `166884`, and a node another live write-back already covers gets a transient `409` with `error.code` `104972` — wait rather than repost) - `GET /current/cloudsync/details/{source_id}/writebacks/{writeback_id}/` - Write-back details (any member) - `POST /current/cloudsync/details/{source_id}/writebacks/{writeback_id}/retry/` - Retry a failed job (owner or admin; also answers `1660 (Conflict)` when another live write-back already covers the node — that one is transient, so wait rather than repost) - `POST /current/cloudsync/details/{source_id}/writebacks/{writeback_id}/resolve/` - Resolve a conflict (`resolution`: `keep_local` force-push, or `keep_remote` pull remote over local) (owner or admin; also answers the transient `1660 (Conflict)` when a *different* live write-back already covers the node — the check excludes the job being resolved, so a conflict is never blocked by its own row) - `POST /current/cloudsync/details/{source_id}/writebacks/{writeback_id}/cancel/` - Cancel a pending/conflict job (owner or admin) Providers: `google_drive`, `dropbox`, `box`, `onedrive_business` (which of these a workspace may connect varies — read the providers endpoint). **All four** connect a real per-user account through a browser OAuth consent, so each identity carries that account's own address. Google Drive and Box previously used centrally held service credentials provisioned in the background; that model was removed — it could only ever reach content someone had shared with a robot in Fastio's own tenant. Source statuses: pending, discovering, syncing, synced, error, paused, disconnect_pending, disconnecting, disconnected, suspended_workspace (the workspace was deleted or its org closed; files and graft are kept, only scheduling stops — see `llms/workspaces.txt`), suspended_plan (the workspace's plan no longer includes cloud sync; files and graft are kept, only scheduling stops, and the source resumes by itself once the plan covers cloud sync again), suspended_policy (the org or workspace cloud-sync policy has `enabled: false`; same "nothing to repair, resumes on its own" behavior as `suspended_plan` — see *Cloud Sync Policy* in `llms/workspaces.txt`). A `mode: "read"` policy changes no source status — it stops only outbound write-back, and a queued push under it is deferred (never failed), retiring at a bounded ~5-day ceiling with `properties.terminal_reason: "policy_hold_expired"` if the policy never widens. Every source object also carries `effective_access_mode` / `effective_access_mode_reason` (the source's own `read_only`/`read_write` vocabulary, composed from the acting caller's cloud-sync policy; `null` means undetermined, never permissive). Write-back is per-source opt-in and conflict-guarded (remote-mtime baseline at job creation; deletes fail closed if the remote mtime is unavailable); `keep_local` force-pushes, `keep_remote` pulls remote over local. The import graft folder is movable/renamable but cannot be nested inside another import folder; trashing it disconnects the source. Imported storage nodes carry an `import_state` block (`is_root`, `provider`, `access_mode`, `status`, `synced_at`, `source_id`, `graft_root_id`; `remote_path` omitted) with a legacy `import_metadata` block kept for compatibility. `graft_root_id` is present on **every** node of a grafted tree so a client can tell it is inside a graft, and which folder is the root, without walking parents. `grafted_by` (`{user_id}` — the workspace member who connected the source, never the cloud account) is returned on the graft ROOT only, and **only in workspace context**: it is omitted from share-served responses, where the recipient may be external or anonymous. Change delivery is webhook-driven, with polling as the reconciliation pass behind it: a source whose webhook subscription is live (and does not expire before the next poll) is polled every 6 hours instead of hourly, and one with no live subscription keeps the hourly cadence. An explicitly configured `sync_interval` is always honoured exactly — the 6-hour back-off applies only to sources left on the 1-hour default. The source record carries `provider` and `is_owner` (whether the CALLER owns the bound cloud connection), so a client does not have to join sources against the identity list to gate owner-only controls — source create is identity-owner-only, with no workspace-admin override; pause/resume, disconnect and access-mode changes are open to the identity owner or a workspace admin. Sources with >1000 files use async disconnect; when one of those cannot complete its cleanup it normally settles in `disconnect_pending` — not `disconnected` — with the reason in the source's `error_message`, syncing and writing back in neither direction, and accepts the disconnect being sent again; that is the usual outcome rather than a guarantee, so read the source's current `status` instead of assuming it. Import GET endpoints support `format=md`. **Membership governs an import for its whole life, not only at create time.** Sync and write-back re-check, immediately before contacting the provider, that the identity owner still holds a live workspace membership at Member or above and that the workspace and its parent org are still available — so a demotion, a lapsed membership, a removal, or a closed org stops an import instead of leaving it running on a credential nobody is entitled to use any more. When a member leaves or is removed from a workspace, each source in that workspace that syncs through an account they connected flips to `read_only` and `disconnect_pending` and is then disconnected in `keep` mode: inbound sync and outbound write-back both stop. Their connected account is **not** revoked — it keeps serving their other workspaces of the org. Leaving or being removed from the **org** does this in every workspace of the org and also revokes the accounts they connected in it; so does leaving a workspace that has no org. Content already imported is kept, and remains in the workspace as ordinary storage. `GET .../sources/?owner=me` (or `?owner={user_id}` for an admin) lists exactly what leaving or removal from that workspace will disconnect — see *When a Member Leaves or Is Removed* in `llms/workspaces.txt`. Two write-back outcomes worth planning for. **A refusal from the provider's permission system is permanent, not retried** — the job fails with a message naming the remedy (grant the connected account edit/write permission on the folder at the provider), which is the failure a read-only grant produces; fix the grant, then `retry`. And **renaming or moving an imported file inside the workspace pushes the content to the new remote path but leaves the object at the old path where it is**, so the user ends up with a duplicate in their own cloud (and the old path can be re-imported on the next sync). Deleting the old object is deliberately not attempted: nothing records a trustworthy pre-rename path, so such a delete could remove a file the user has since put there. ## Common Patterns - List endpoints return arrays with consistent pagination - All profile operations require membership with sufficient permissions - Owner > Admin > Member > Guest permission hierarchy - `"me"` can be used as user_id to reference the authenticated user - Profile path parameters accept either a 19-digit numeric ID or a custom name - Storage operations (workspace and share) follow identical patterns - AI chat endpoints (workspace and share) follow identical patterns - Member management endpoints (org, workspace, share) follow identical patterns - Long-polling supported on activity/poll and upload/details endpoints - Most POST endpoints use `application/x-www-form-urlencoded` bodies; comments use `application/json` - Attributable records (storage nodes, versions, locks, intents, events, comments, invitations, metadata facts) carry one `actor` object — `user_id`, `kind` (`human`/`agent`/`api_key`/`app`/`system`/`unknown`), `agent_name`, `name_source`, `credential_type`, `verified`. Only Fastio's own built-in agent is `verified: true`; every other `agent_name` is self-declared. See *Actor Attribution* in the [Storage reference](https://api.fast.io/current/llms/storage/) ## Additional Resources - Full single-file reference: https://api.fast.io/current/llms/full/ - Agent integration guide: available at `/current/agents/` - MCP Server: `https://mcp.fast.io/mcp/tools` (named tools), `https://mcp.fast.io/mcp/code` (code mode) or `https://mcp.fast.io/mcp/operations` (ChatGPT); earlier endpoints `https://mcp.fast.io/mcp` and `https://mcp.fast.io/sse` (legacy SSE) still work - MCP Skills: available at `/skill.md` on the MCP server > Part of the Fastio API Reference. Overview: https://api.fast.io/current/llms/ # Authentication & User Management Base URL: `https://api.fast.io/current/` Request format: `application/x-www-form-urlencoded` (POST bodies) or query string (GET) Response format: JSON --- ## Authentication Methods All authenticated endpoints require: `Authorization: Bearer {token}` The token can be a JWT (from browser sign-in, OAuth, signup, or the deprecated Basic Auth login), an API key, or a 2FA-upgraded JWT. ### Signing in with email and password: browser sign-in (recommended) Email-and-password sign-in happens on the Fastio sign-in page (`login.fast.io`), never inside your own form. The page runs the whole ceremony — password, the 2FA code, or enrolling a first factor when an organization requires one — and then hands the browser back to the web app with a one-time code that the app exchanges for a session, bound with PKCE (S256). No password or half-finished 2FA token ever reaches the app. ``` 1. start POST /current/user/auth/login/start/ -> login_url 2. sign in navigate the top-level window to login_url; the user signs in on the Fastio sign-in page 3. callback the page redirects the browser to {return_origin}/signin/callback?code={code}&state={state} 4. exchange POST /current/user/auth/login/exchange/ code + code_verifier + state -> session ``` The flow returns only to Fastio's own web origins (`https://go.fast.io`, or an organization's own `https://{org_domain}.fast.io`). **Other apps, CLIs and agents use OAuth 2.0 authorization code + PKCE** (Method 3 — its browser step signs in through the same page) **or an API key** (Method 2). See [Browser Sign-In](#browser-sign-in-email-and-password) below for both endpoints. ### Method 1: Basic Auth to JWT (deprecated) **Deprecated — will be retired.** HTTP Basic password sign-in still works today, but it is being retired. Sign web users in with the browser sign-in flow above, and give programmatic or agent access through OAuth 2.0 authorization code + PKCE or an API key. When it is retired, a Basic sign-in on this endpoint will answer `403` with `error.params.reason` = `password_login_browser_required`. Send HTTP Basic Auth (`email:password`) to get a JWT. ``` GET /current/user/auth/ Authorization: Basic {base64(email:password)} ``` Returns `auth_token` (JWT). If the account has 2FA enabled, the returned token has limited scope until 2FA verification is completed. **Optional `revocable=true`** — marks a JWT as revocable. `POST /current/user/auth/sign-out/` ends the calling session's own token whether or not it was minted with `revocable` — the flag no longer changes what sign-out does. `revocable` instead controls whether the token is also reachable by the account-wide revocation paths that specifically target revocable sessions (including the rare fallback described under `sign-out/` below). Every login token — with or without `revocable` — can still be killed via `POST /current/user/auth/invalidate-all/`, which terminates every login session for the user. See [Session Termination](#session-termination) below. **Optional `agent_name` query parameter** (string, max 128 characters) — declares the agent (e.g. an MCP client's name) this session acts for: `GET /current/user/auth/?agent_name=Dobby`. Actions taken with the returned token are attributed with `actor.kind: "agent"`, `name_source: "session_declared"`, and `verified: false` (see *Actor Attribution* in the [Storage reference](https://api.fast.io/current/llms/storage/)). It is attribution only — it grants and changes no permissions. Leading and trailing whitespace is trimmed; a name that is blank after trimming is treated as absent. A name is rejected with an invalid-input error when it is longer than 128 characters, contains control or formatting characters or line/paragraph separators, is reserved for the platform, or is too large once encoded (very long names in non-Latin scripts may be refused even under 128 characters). On a 2FA-enabled account the declared agent carries through to the full token issued by the 2FA step automatically. Omit it and actions are attributed to the person (`kind: "human"`). **Optional `x-ve-session-cookie` request header** — browser clients only. Send `x-ve-session-cookie: 1` (or `true`/`yes` — the same vocabulary as `revocable`) to ask the server to ALSO deliver the issued token in an HttpOnly, Secure, `SameSite=Lax` cookie scoped to the site's registrable domain (e.g. `fast.io`), so it is available across subdomains on that domain and the session never has to be stored anywhere JavaScript can read it between page loads. The token is still returned in the response body as usual. The browser then calls `POST /current/user/auth/bootstrap/` on each page load — the only endpoint that reads the cookie — which returns that same token so the app can bring it into memory. **The opt-in is only honoured as a request header; it is deliberately not accepted as a URL or body parameter.** Omit the header (the default) and no cookie is set. On a 2FA-enabled account, sign-in returns a pre-2FA token and NO cookie; the cookie is issued by `POST /current/user/auth/2factor/auth/{token}/` instead. ### Method 2: API Keys Long-lived tokens for service-to-service communication. Created via the API or the web UI. Used with the same `Authorization: Bearer {api_key}` header format as JWTs. Keys optionally support scoped permissions (`scopes`), agent names (`agent_name`), and expiration (`expires`). Scoped keys are enforced using the same scope system as v2.0 JWT tokens. **Scope formats:** a scope string is `entity_type:entity_id:access_mode` — exactly three colon-separated parts (e.g. `org:1234567890123456789:rw`). Entity types are `user`, `org`, `workspace`, `share`, `sign_envelope`, `fileshare`, `memory` and `userdetails`. The entity id is either `*` (wildcard) or a numeric id. OAuth *authorization requests* additionally accept named scope words (`user`, `org`, `workspace`, `all_orgs`, `all_workspaces`, `all_shares`, `all_sign_envelopes`); API responses always return the `entity_type:entity_id:access_mode` form. See the [OAuth 2.0 reference](https://api.fast.io/current/llms/oauth/) for full scope format details. **Access modes.** There are three, and they nest: | Mode | Grants | |------|--------| | `r` | read | | `rw` | read and write | | `rwa` | read, write and **administer** | `rwa` implies `rw` implies `r`. **There is no `ra`** — administration always includes write. `rwa` is accepted at issuance on the ordinary entity types (`user`, `org`, `workspace`, `share`, `sign_envelope`) but not on `fileshare`, which has no administrative verb (`fileshare:{id}:rwa`, and any `fileshare:*:` form, are refused at grant time). `userdetails` is narrower still: the ONLY issuable form is the exact `userdetails:*:rw` — `userdetails:*:r`, `userdetails:*:rwa` and any numeric id are all refused at grant time. **Admin is always capped by the human.** `rwa` never grants more than the person who owns the credential currently holds — it is re-checked on every request against their live role on the entity. If they lose admin on an org or workspace, a credential carrying `rwa` for it stops administering it. **The `user:*` grants:** | Scope | Meaning | |-------|---------| | `user:*:r` | whole account, **read-only** — reads every entity the human can read, and **writes nothing**. Entity-anchored writes are refused by the entity's own scope check; account-anchored ones (creating an org, updating your account, revoking an OAuth session or all of them, minting or editing an API key) are refused with `403` + `10770`, `params.reason` = `scope_write_required`. There is **no exempt route** — `sign-out/` included, so a read-only client ends its session by discarding the credential locally, or by calling `POST /current/oauth/revoke/`. Login, the 2FA login challenge and the OAuth token exchange are unaffected, but only because none of them runs on a scoped bearer in the first place | | `user:*:rw` | whole account, read **and write** — **not** admin, **not** account settings | | `user:*:rwa` | whole account, read, write **and** admin (still capped by the human's live role) | A `user:*` grant matches every ordinary entity type. It does **not** match `userdetails`, which is explicit-only. **The `userdetails` entity type.** `userdetails:*:rw` is the **only** valid form — `userdetails:*:r`, `userdetails:*:rwa` and any numeric id are refused at grant time. It is an entity type that appears inside a credential's `scopes` list; it is not a scope word in the OAuth authorize flow and is never advertised in `scopes_supported`. It gates the account-settings operations listed under [Scope errors](#scope-errors-administration-and-account-settings) below. No `user:*` grant, at any access mode, satisfies it: it must be held explicitly, or the caller must be an interactive web login session. **Credentials with no scopes ("legacy").** A credential that declares no `scopes` claim at all is legacy. That covers an API key created before scoped keys existed, an OAuth session minted before scoped tokens existed, and **every browser login session** — so "legacy" is not a synonym for "old API key". - A **legacy API key or legacy OAuth grant** behaves as `user:*:rw`: whole-account read and write, **no admin**, **no account settings**. - A **browser login session** is different — it is unbounded: admin-capable, and it passes the account-settings gate, because it is the human acting interactively. Newly issued credentials are never legacy: a key created without `scopes`, or updated with `scopes` cleared (`""` or `"null"`), now stores the explicit `["user:*:rw"]` (an update that leaves `scopes` out keeps the stored value), and an OAuth grant for `scope=user` is stored explicitly the same way. **Containment — no credential may mint something broader than itself.** A credential may only create or edit another credential whose scopes it already covers. Covering compares access-mode rank (`rwa` >= `rw` >= `r`), and a wildcard id covers any id of that type while a numeric id covers only itself: `org:*:rw` covers `org:123:r`; `org:123:rw` does not cover `org:*:r`. There is **no hierarchy walk** — `org:1:rwa` does not cover `workspace:5:rw`. `userdetails:*:rw` must be held exactly to be propagated. An empty scope set is refused everywhere it can be submitted. A browser login session is unbounded and may mint anything the human may grant. ### Method 3: OAuth 2.0 PKCE For desktop/mobile apps, CLIs and MCP-connected agents — the recommended way for anything other than the Fastio web app to act for a person who signs in with email and password. No password passes through the agent: open the `login_url` from `GET /current/oauth/authorize/?response_format=json` and the user signs in on the Fastio sign-in page. Native clients may use a loopback redirect URI on `127.0.0.1` with any port (RFC 8252); headless clients can ask for `display_code=true` and have the user paste the code back. Access tokens last 1 hour; refresh tokens are long-lived. S256 challenge method only. See the [OAuth 2.0 reference](https://api.fast.io/current/llms/oauth/) for the full flow. ### Method 4: 2FA When 2FA is enabled on an account, the browser sign-in page and the OAuth sign-in step ask for the code themselves. On the deprecated Basic Auth login, the call returns a limited-scope JWT. Complete authentication via `POST /current/user/auth/2factor/auth/{token}/` with the 2FA code. The response contains a full-scope JWT. ### Scope errors: administration and account settings Five reasons, across four codes, report a credential that is too narrow for the operation it attempted. **Every one is HTTP `403` — never `401`.** A `401` means the credential could not be verified; these mean it *was* verified and is insufficient, so refreshing or re-minting the same credential will not clear them. | Code | `params.reason` | When | |------|-----------------|------| | `10767` | `scope_admin_required` | an administrative operation called with a credential that is not admin-capable | | `10768` | `scope_exceeds_issuer` | the requested scopes are broader than the credential making the request (API key create/update, OAuth session narrowing) | | `10768` | `access_mode_exceeds_initiate` | an OAuth consent asked for a broader access mode, or for account settings, than the authorization was initiated with | | `10769` | `userdetails_scope_required` | an account-settings operation without `userdetails:*:rw` | | `10770` | `scope_write_required` | a non-`GET` method on a route anchored on the account itself, called with a credential that holds no write-capable grant anywhere — `user:*:r`, or a set every entry of which is `:r` | On these refusals `error.params` is an **object** (a map), not the validation-error array documented under [Response Envelope](#response-envelope). Six fields are always present; two more appear when the caller is an API key: | Field | Always? | Value | |-------|---------|-------| | `reason` | Yes | one of the five strings above | | `entity_type` | Yes | e.g. `org`, `workspace`, `share`, `user`, `userdetails` | | `entity_id` | Yes | the entity id as a **string**, or `null` for an account-wide refusal. It is quoted because profile ids are 19 digits and exceed the range a JSON number survives in a client that parses numbers as doubles | | `required_access_mode` | Yes | e.g. `rwa`, `rw` | | `current_access_mode` | Yes | what the credential holds for that entity, or `null` | | `credential_type` | Yes | `api_key`, `oauth` or `session` | | `credential_id` | No | present for an API-key caller | | `credential_label` | No | present when the key has an agent name | On the consent-time `10768` (`access_mode_exceeds_initiate`) refusals, `current_access_mode` instead reports the access mode the consent **requested**, and `required_access_mode` reports the ceiling the authorization was initiated with. **Example (403):** ```json { "result": false, "error": { "code": 10767, "text": "Your credential is not authorized for administrative operations on this Workspace.", "params": { "reason": "scope_admin_required", "entity_type": "workspace", "entity_id": "1234567890123456789", "required_access_mode": "rwa", "current_access_mode": "rw", "credential_type": "api_key", "credential_id": "aB3dE5fG7hJ9kL1mN2pQ", "credential_label": "my-agent" } } } ``` Branch on `params.reason`, never on the numeric code. **Operations that require admin (`rwa`).** Every endpoint that requires org, workspace or share admin or owner authority refuses a credential that is not admin-capable with `403` + `10767`. That includes administrative **reads** — org billing details, invoices, usage, credits, plan preview and payment method are all admin-level reads, as is the events audit log — so a monitoring or reporting agent that only reads still needs an admin-capable credential for them. Share administration (archive, unarchive, update, delete, purge and restore storage, member invitations, ownership transfer, sign-envelope retry and void) is included. Admin-capable means a browser login session, or a credential holding `rwa` on the entity or on an ancestor that confers admin, capped by the human's live role. **A `user:*:rw` credential, and every legacy API key or legacy OAuth grant, is not admin-capable** (a browser login session is legacy but unbounded, and is admin-capable). **Operations that require `userdetails:*:rw`.** | Endpoint | Operation | |----------|-----------| | `POST /current/user/update/` | changing `password` or `email_address` only | | `GET /current/user/auth/2factor/` | reading 2FA status | | `POST /current/user/auth/2factor/{channel}/` | enrolling in 2FA | | `POST /current/user/auth/2factor/verify/{token}/` | verifying 2FA enrolment | | `DELETE /current/user/auth/2factor/{token}/` | removing 2FA | | `POST /current/user/auth/invalidate-all/` | invalidating every session on the account | | `POST /current/user/auth/social/{provider}/link/start/` | beginning a social sign-in connection | | `POST /current/user/auth/social/{provider}/link/` | completing a social sign-in connection | | `POST /current/user/auth/social/{provider}/unlink/` | disconnecting a social sign-in | Passing credentials: a browser login session, or a credential explicitly holding `userdetails:*:rw`. `user:*:rw`, `user:*:rwa`, and every legacy API key or legacy OAuth grant are refused with `403` + `10769`. The other fields on `POST /current/user/update/` (names, phone, `owner_defined`) are **not** gated, and neither are the 2FA **login challenge** endpoints (`POST /current/user/auth/2factor/auth/{token}/` and the 2FA code-delivery endpoints) or `POST /current/user/auth/sign-out/`. **A third kind of credential also passes the enrolling and verifying 2FA rows above: an enrolment token.** When an org's Require-2FA policy forces enrolment at login (`enrol_required: true` — see *Interactive Login & Enrolment* below), the resulting token is narrower than `userdetails:*:rw` in every other way but is admitted specifically on `POST /current/user/auth/2factor/{channel}/` and `POST /current/user/auth/2factor/verify/{token}/`, plus the three `2factor/send/*` code-delivery endpoints. It is refused everywhere else, including `GET`/`DELETE /current/user/auth/2factor/`, `POST /current/user/auth/2factor/auth/{token}/`, `POST /current/user/update/`, and `GET /current/user/auth/check/`. ### What this changes for existing credentials - An existing read-write API key or OAuth grant keeps reading and writing, but **no longer performs administrative operations** (org, workspace and share administration, including administrative reads such as billing details) and **no longer changes account settings** (password, email, reading, enrolling in or removing 2FA, invalidate-all). - To restore administration, update the key with `rwa` scopes, or reconnect the application asking for `access_mode=rwa`. - To restore account settings, add `userdetails:*:rw` to the key, or reconnect the application with `account_settings=1`. - **Widening a credential can only be done from a signed-in web session — a credential cannot widen itself.** - `user:*:r` is now a real whole-account read-only grant: it returns full read results where it previously returned empty lists on some endpoints. - Clients validating `access_modes_supported` against `{r, rw}` must accept `rwa`. --- ### Org Credential Policy Organizations on the Enterprise plan can cap what API keys and OAuth grants issued inside the org may hold, and constrain them on **every later request**, not only when they are issued. This is the org's `credential_policy` setting — see *Credential Policy* in `llms/orgs.txt` for the envelope shape, the org update/details fields, and `capabilities.credential_policy_api_keys` / `_oauth`. This section documents what it changes for API keys. **These refusal reasons carry the ordinary scope-error params** (`entity_type`, `entity_id`, `required_access_mode`, `current_access_mode`, `credential_type`, and `reason`). Most are `403`; `credential_policy_unavailable` is always `503`, and `credential_policy_sso` can be either, depending on which of the two things it is reporting: **BRANCH ON `params.reason`, NEVER ON THE NUMERIC `code`.** A code identifies the individual place the refusal was raised, not the reason it carries — one reason is raised from several places and therefore has several codes, and more can appear at any time without notice. The codes below are examples for correlating a single response with a support request; they are not an enumeration, and matching on them will miss refusals your client must handle. | `params.reason` | Example code | HTTP | When | |---|---|---|---| | `credential_policy_mode` | `169015`, `133221` | 403 | The access mode the credential **holds** for an entity exceeds the org's configured ceiling (`max_mode`) for that credential family. This inverts the ordinary scope error above, where the *held* mode is too low — here it is too *high*. Do not render it as "insufficient scope." | | `credential_policy_scope` | `169015`, `133221` | 403 | The credential names an entity type the org's policy does not allow that family to hold at all (`scope_types`). These two reasons share their codes: the same check reports whichever of them applies. | | `credential_policy_sso` | `117677`, `187061` | 403 | Single sign-on is required for the organization and the credential's owner is not exempt. | | `credential_policy_sso` | `167474`, `130525` | 503 | The organization's sign-on policy could not be read — the check itself failed, not the sign-on requirement. Retry. | | `credential_policy_unavailable` | `142426`, `114359`, `139632`, `171492`, `145667`, `120029` | 503 | The organization's credential policy could not be read. Transient — the one case where retrying is the correct client behaviour. | | `credential_policy_unreadable` | `138730`, `177544` | 403 | The organization's stored credential policy is corrupt. Permanent — retrying will not help; an administrator must rewrite the policy. | | `credential_policy_org` | `155387`, `127015`, `131925` | 403 | The resource's owning organization no longer exists. Permanent — retrying will never come good. | Note the same `credential_policy_sso` reason on two HTTP statuses: the status is what separates "sign on through your organization" (403) from "we could not tell, try again" (503). Read both together. **`required_access_mode` and `current_access_mode` can both be `null`** on `credential_policy_unavailable`, `credential_policy_unreadable`, and `credential_policy_org` — when the org's policy could not be read, or the stored policy will not parse, there is no ceiling left to quote. Treat `null` as *unknown*, never as a mode, and never respond to it by re-minting the credential: on these three reasons the credential was never the problem. **A refusal of an account-wide grant is reported against the organization that refused it.** When the credential holding the refused grant is `user:*:*`, `entity_type` is `org` and `entity_id` is the id of the organization whose policy refused it — not `entity_type` `user` with a null `entity_id`. Read it as "this organization's ceiling is lower than your account-wide grant," never as "your credential is refused everywhere." **Where these are raised.** - **At issuance** — `POST /current/user/auth/key/` and `POST /current/user/auth/key/{key_id}/` check the requested (or, on update, the effective) scopes against the calling user's governing org's `api_keys` policy, as a **fourth** issuance check, run after the three in *API Keys* below. Refusal: `403`, `params.reason` = `credential_policy_mode` or `credential_policy_scope`, "The requested scopes exceed this organization's credential policy." Three other outcomes are possible at issuance, and each carries its OWN reason rather than a scope verdict: a corrupt stored policy refuses issuance entirely (`403`, `credential_policy_unreadable`); a grant naming an organization that no longer exists is refused permanently (`403`, `credential_policy_org`); and a policy that could not be READ answers `503` (`credential_policy_unavailable`) rather than a silent pass — the only one of the four worth retrying. - **At every later request** — unlike the checks in *API Keys* below, which run once at mint, the org's `credential_policy` is re-checked on **every** authenticated request an API key or OAuth token makes that resolves to a governing organization. A key that was valid when it was minted can start failing this check the moment an admin tightens the org's policy (`credential_policy_mode` / `credential_policy_scope`). A stored policy that is corrupt refuses every request in that family against that org (`credential_policy_unreadable`), and a resource whose owning organization no longer exists is refused the same way (`credential_policy_org`) — both permanent, and retrying will not help either one. A policy that could not be read at all is the transient case (`credential_policy_unavailable`) and answers `503` rather than a `401` — the one case worth retrying. See the table above for the code and HTTP status each reason carries. **This reaches credentials that already exist.** A stored API key or OAuth grant that predates a policy, or predates a tightening of one, can begin returning `403` on its very next call with no change to the key itself, no revocation event, and no grace period — the policy is evaluated live, against the credential's currently-held authority. Treat `credential_policy_mode` / `credential_policy_scope` the way any other scope refusal is treated: surface it as "this credential no longer meets your organization's policy," route to key management, and never treat it as a sign-in failure. A signed-in browser session is never affected — it carries no `scopes` claim and is exempt from `credential_policy` by construction, the same way it is exempt from the ordinary scope checks above. **What is evaluated is only the authority actually used for the request** — the concrete or wildcard grant that satisfied this request's check, never the credential's whole scope set and never an inherited parent grant. A key holding `org:A:rwa` and `org:B:r` is checked against org A's policy only when it acts in org A. **What is not covered by the request-time check.** 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 result and are permanently outside this check. A public File Share single-file link carries no API key and no account session, so it is not an org-governed credential and is never subject to `credential_policy`, at issuance or at request time. A small number of late-loaded cloud write-back endpoints are checked, if at all, at their own endpoint rather than on this shared path. **Enterprise SSO enforcement.** When the credential owner's organization has SSO set to `mode=required` and the owner is not exempt, a separate request-time check can also refuse with `reason` = `credential_policy_sso` — see *Credential-request enforcement* in `llms/sso.txt`. A lookup failure on that check is `503`, never a silent pass and never a `401`. --- ## Getting Started ### Option 1: Use a Human's Existing Account (API Key) A human creates an API key and gives it to you. You operate as that user with their permissions, org, and billing. **Human instructions:** "Go to Settings > Devices & Agents > API Keys and click Create API Key. Optionally enter a memo to label the key (e.g., 'Agent access'), then click Create. Copy the key immediately -- it is only displayed once. Direct link: https://go.fast.io/settings/api-keys" Once you have the key: `Authorization: Bearer {api_key}`. No further steps needed. ### Option 2: Create Your Own Account (Autonomous) Create your own account to work independently. Humans and AI agents follow the same flow; an email address is required either way. 1. `POST /current/user/` with `email_address`, `password`, `tos_agree=true` (optionally `agent=true` to tag the account as an agent account, and `agent_name={name}` so the returned session is attributed to your agent). A newly created account gets a session in the same response — use its `auth_token` as `Authorization: Bearer {auth_token}`. A response without `auth_token` means check your email to finish: either the address already has an account, or the new account's session could not be issued — see [User Creation](#user-creation). 2. Keep access beyond that session: use the session token through email verification, org creation and plan selection (a default `user:*:rw` API key cannot manage billing), then create an API key with `POST /current/user/auth/key/` and use it for unattended work, or sign in again later with OAuth 2.0 PKCE. Do not build on the deprecated Basic Auth login. 3. Verify email (agent accounts may skip this step only if they will not accept invitations by ID, list invitations by email, join an org through its authorized email domain, or file bug reports — all of these require a verified address): - `POST /current/user/email/validate/` with `email` -- sends verification code - `POST /current/user/email/validate/` with `email` and `email_token` -- validates the code 4. `POST /current/org/create/` with `domain` (required, 2-63 chars lowercase alphanumeric + hyphens) 5. Select a paid plan to activate the org via `POST /current/org/{org_id}/billing/` with `billing_plan` (e.g. `starter_monthly`). Until a paid plan is selected, the org is in an upgrade-only state and cannot consume resources. 6. `POST /current/org/{org_id}/create/workspace/` with `folder_name`, `name`, `perm_join`, `perm_member_manage` New organizations require a paid plan (Starter, Business, or Enterprise). ### Option 3: Account Invited to a Human's Org 1. Create your own account (steps 1-2 from Option 2) 2. Give the human your account's email address 3. Human invites you to their org or workspace 4. Accept: list your invitations with `GET /current/user/invitations/list/` and accept one with `POST /current/user/invitation/{invitation_id}/accept/` (both match the invitation to you by your verified email address, so verify it first — step 3 of Option 2), or accept with the invitation key carried in the invitation email's join link: `POST /current/{org|workspace}/{id}/members/join/{invitation_key}/accept/`. The bare `members/join/` endpoint (no key) is authorized-domain self-join, not invitation acceptance. 5. You now operate within their resources with granted permissions ### Option 4: PKCE Browser Login (No Password Sharing) Most secure option. Works with SSO. No credentials pass through the agent. 1. Agent initiates PKCE flow via `GET /current/oauth/authorize/?response_format=json` with `code_challenge`, `code_challenge_method=S256`, `client_id`, `redirect_uri`, `response_type=code`, `state` (add `display_code=true` when the agent cannot receive a redirect) 2. User opens the returned `login_url` in a browser (verbatim), signs in on the Fastio sign-in page, and approves access 3. The browser is redirected to `redirect_uri` with the authorization code -- or, with `display_code=true`, displays the code for the user to copy to the agent 4. Agent calls `POST /current/oauth/token/` with `grant_type=authorization_code`, `code`, `code_verifier` 5. Access tokens last 1 hour; refresh via `POST /current/oauth/token/` with `grant_type=refresh_token` **Which option to choose:** - Human wants you to manage *their* account -> Option 1 (API key) - You're building something independently -> Option 2 (own account + own org on a paid plan) - You need to work within a human's existing org -> Option 3 (own account + invitation) - Human wants to authorize agent without sharing credentials -> Option 4 (PKCE) --- ## Compact Responses (`output=`) Every endpoint that returns user objects — including your own profile (`/current/user/details/`), other users' profiles, and member listings on workspaces, orgs, and shares — 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 user (cumulative) | |-------|-------------------------------------------| | `terse` | `id`, `account_type`, `first_name`, `last_name`, `profile_pic` | | `standard` | terse + `email_address`, `is_anonymous`, `status`, `permissions`, `created`, `member_added_at` (membership responses only), `updated`, `invite`, `expires`, `auth` (member listings that report sign-in methods only), `locked`, `suspended`, `closed`, `sso` (last four visible to self/managers only) | | `full` | standard + `country_code`, `phone_country`, `phone_number`, `2factor`, `notify`, `password_set`, `sync_profile`, `tos_agree`, `valid_email`, `valid_phone`, `apps`, `owner_defined`, `parents` | Use `terse` for mention pickers, avatar lists, creator cells, and message-author headers — it carries the identifier, display name, account type (human/agent), and profile picture, which is everything the avatar/name cells render. `email_address` is intentionally excluded from `terse` to keep PII out of the smallest shape. Use `standard` for member list views and account-settings summaries — it adds `email_address`, the caller-relative `permissions` role, active/pending `status`, invitation details for pending members, and account `created`/`updated` timestamps (now visible at `standard` for every user the caller can see, not just self/managers). Membership responses — the member-detail endpoint for an org, workspace, or share — also carry `member_added_at` at `standard`: the date the membership was created, distinct from `created`, which is the date the user's own account was created. Fastio returns it to the member themselves and to admins (and owners) of the containing org, workspace, or share; for any other caller the key is absent from the response rather than null, and it is omitted for every caller when the membership has no recorded date. It is formatted like every other response timestamp, e.g. `2026-08-26 14:03:11 UTC`. Admin member-list UIs also receive the lock/suspend/close account-status chips at `standard`; these three fields are gated server-side to the self-view or manager-view of the target user, so non-privileged callers never see them at any tier. Use `full` (or omit the parameter) for the user profile screen, account settings, admin audits, and any workflow that reads phone, 2FA, TOS, anonymous-guest detection, or account-validity fields. 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 in `llms.txt` for the full contract. --- ## User Creation ### POST /current/user/ Create a new user account. **Auth:** None (IP-throttled) **Request Parameters:** | Parameter | Type | Required | Constraints | Description | |-----------|------|----------|-------------|-------------| | `email_address` | string | Yes | Valid email format; domain must accept email; must be unique | User's email address. Tags (e.g., `+tag`) are stripped for storage and uniqueness checks, but the original is preserved. | | `password` | string | Yes | Must pass password validity checks | Account password. | | `tos_agree` | string | Yes | Must be `"true"` | Must be `"true"` to accept Terms of Service. | | `agent` | string | No | `"true"` or `"false"` | Set `"true"` to tag the account as an AI agent account. Sets `account_type` to `"agent"` permanently. An email address is still required, and agent accounts follow the same organization and paid-plan flow as everyone else. | | `agent_name` | string | No | Max 128 characters; same rules as sign-in's `agent_name` | Declares the agent (e.g. an MCP client's name) the session returned for a newly created account acts for; it rides that session exactly like `agent_name` on `GET /current/user/auth/` (attribution only — no permissions). Same rules: trimmed, blank means absent, and rejected with an invalid-input error when longer than 128 characters, containing control/formatting characters or line/paragraph separators, reserved for the platform, or too large once encoded. Validated before anything else happens, so an invalid name is refused whether or not the address already has an account. | | `first_name` | string | No | 2–45 characters; refused if it contains a URL or `scheme:` prefix, a `www.` or domain-like token (`example.com`), an IP address, or `<`/`>` | User's given/first name. | | `last_name` | string | No | 2–45 characters; refused if it contains a URL or `scheme:` prefix, a `www.` or domain-like token (`example.com`), an IP address, or `<`/`>` | User's family/last name. | | `phone_country` | string | No | Numeric country calling code | Phone country code. Required if `phone_number` is provided. | | `phone_number` | string | No | Numeric phone number | Phone number. Required if `phone_country` is provided. | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/" \ -d "email_address=jane.doe@example.com" \ -d "password=$PASSWORD" \ -d "tos_agree=true" \ -d "first_name=Jane" \ -d "last_name=Doe" \ -d "agent=true" ``` **Request Headers:** | Header | Type | Required | Default | Description | |--------|------|----------|---------|-------------| | `x-ve-session-cookie` | boolean | No | (absent) | Browser clients only. When truthy (`1`, `true` or `yes`) and a new account is created, the issued session token is ALSO set in the HttpOnly browser session cookie, exactly as on `GET /current/user/auth/`; bring it back into memory with `POST /current/user/auth/bootstrap/`. Honoured only as a request header. | **Success Response (200 OK) — new account created:** ```json { "result": true, "expires_in": 2592000, "auth_token": "{jwt_token}", "2factor": false, "enrol_required": false } ``` **Success Response (200 OK) — no session issued** (the address already has an account, or a session could not be issued): ```json { "result": true } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `result` | boolean | `true` on success | | `auth_token` | string | Present only when this call created the account and a session was issued. A normal signed-in session token (the same kind a sign-in issues) — send it as `Authorization: Bearer {auth_token}`. It is revocable: `POST /current/user/auth/sign-out/` and `POST /current/user/auth/invalidate-all/` both end it. | | `expires_in` | integer | Seconds until `auth_token` expires. Present with `auth_token`. | | `2factor` | boolean | Present with `auth_token`; always `false` for a new account. | | `enrol_required` | boolean | Present with `auth_token`; always `false` for a new account. | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10025` | 406 | "An invalid email was supplied." | Email format invalid | | `10025` | 406 | "The email domain is invalid or cannot receive email." | Email domain validation failed | | `162057` | 406 | "Accounts cannot be registered with this email domain." | The email domain, or a parent of it, is reserved and cannot be used to register an account | | `10026` | 406 | "An invalid password was supplied." | Password does not meet requirements | | `10394` | 406 | "An invalid `tos_agree` value was create." | TOS value not a valid boolean string | | `10395` | 406 | "You declined to accept the terms of service." | TOS set to `"false"` | | `10027` | 406 | "An invalid first name was supplied to create." | First name fails validation | | `10027` | 406 | "An invalid last name was supplied to create." | Last name fails validation | | `10163` | 406 | "An invalid phone country code was supplied." | Invalid phone country code | | `10029` | 406 | "An invalid phone number was supplied." | Invalid phone number | | `10165` | 406 | "An invalid phone number or country code was supplied." | Full phone number validation failed | | `10354` | 401 | "Your attempt to create an account was not accepted." | Risk/fraud check failed | | `137409` | 452 | "Access from your location is restricted." | App error code `1697` — the request is from a geo-restricted location and the email has no live pending invitation to any org/workspace/share | | `10032` | 500 | "We were unable to create your user account..." | Internal processing failure | **Notes:** - **Geo-restricted locations may still sign up to accept a pending invitation.** A signup from a geo-restricted location is refused UNLESS `email_address` has a live (pending, not expired) invitation to any org, workspace, or share — the resulting account still cannot create an org (`GET /current/user/me/allowed/` governs org creation separately). - **A new account is signed in by the signup itself.** Use the returned `auth_token` straight away — there is no separate sign-in step after signup, and no password to re-enter. - **An address that already has an account gets `result: true` and no `auth_token`.** Signup never returns an "already in use" error: it sends the existing account an email so the owner can sign in or reset their password, and creates no duplicate account. Tell the user to check their email. The geo-restriction refusal above applies before this, identically for registered and unregistered addresses. - **A response without `auth_token` can also mean the account was created but a session could not be issued.** Treat every `result: true` without `auth_token` the same way: ask the user to check their email, then sign in. - Email addresses are normalized by stripping tag extensions (e.g., `user+tag@example.com` becomes `user@example.com`) for storage and uniqueness lookup; the original email is preserved separately. - Country code is detected from the client IP and stored automatically. - `agent=true` is permanent and cannot be changed after account creation. It tags the account as an agent account (`account_type=agent`) for identification; it does not change the plan or signup requirements. - All accounts, human or agent, require an email address. Verify the email via `POST /current/user/email/validate/` (see Getting Started). Sign-in works as soon as the account exists. --- ## Session Termination ### POST /current/user/auth/sign-out/ Ends the calling session: the presented login token is revoked server-side and rejected on every later request, until it expires. Works for any interactive login session token, whether or not it was minted with `revocable=true`. Other sessions and devices of the same user are not affected. **Auth:** Required (JWT, scope: `user` or `admin`; or an unscoped API key for the user). **This is session-scoped, not account-wide.** It revokes only the presented token; other sessions, browsers and devices belonging to the same user stay signed in. An **entity-scoped API key** is therefore refused with `403` and `10175` (`invalidate-all/` uses the stricter `10769` account-settings gate). Called with an **unscoped API key**, there is no login session to end, so the call succeeds without effect. An **OAuth/PKCE access token** or an **AI-chat token** is not a login session either — depending on its scopes, the call either succeeds without effect or is refused by the scope gate. In rare cases where the server cannot record the per-session revocation, sign-out instead ends the account's other sessions too — its other revocable sessions when the calling token was itself minted `revocable=true`, or every other login session on the account when it was not — so the calling session is always ended either way. **Sign-out is NOT one of the account-settings operations.** It does not require `userdetails:*:rw` and never returns `10769`. `POST /current/user/auth/invalidate-all/` — its superset — does require that scope; see [Scope errors](#scope-errors-administration-and-account-settings). **Request Parameters:** None. **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/auth/sign-out/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true } ``` **Notes:** - Works for any interactive login session token, whether or not it was minted with `revocable=true` — the flag no longer determines whether sign-out applies. An unscoped API key has no login session to end, so calling sign-out with one succeeds without effect. OAuth/PKCE access tokens and AI-chat tokens are not login sessions either and are not signed out by this endpoint — depending on scope, the call succeeds without effect or is refused by the scope gate. - Ends only the calling session — other browsers and devices of the same user stay signed in. To revoke a specific OAuth session, use the OAuth session endpoints instead. - **The HttpOnly session cookie IS cleared by this call.** A browser that opted into it with the `x-ve-session-cookie` header loses it here, so its next `POST /current/user/auth/bootstrap/` returns `401` instead of silently signing it back in. Nothing else the client stored is cleared — the client remains responsible for the rest of its local credential state after sign-out (e.g. `Clear-Site-Data` on the redirect, or removing the token from app storage). - To terminate ALL of a user's sessions — including logins that did NOT opt into `revocable` — use `POST /current/user/auth/invalidate-all/` below instead. --- ### POST /current/user/auth/invalidate-all/ Invalidate EVERY login session for the calling user — ends every session, where sign-out ends only the caller's. Kills interactive logins (browser, 2FA, password-reset, SSO) regardless of whether they opted into `revocable`. Any token issued before this call is rejected on its next request. Use this for "sign me out of all devices" or a suspected account compromise — i.e. account-security actions, as opposed to a per-browser logout button (use sign-out for that). A **password change or reset already invalidates other sessions on its own**, so calling this afterwards is unnecessary and will additionally invalidate the replacement `auth_token` that the password change just returned, signing you out of the session you are using. Call it after a password change only when you deliberately want every session gone, including your own. **Auth:** Required. This is an **account-settings** operation: it needs a browser login session, or a credential that explicitly holds `userdetails:*:rw`. This kills more than `sign-out/` does: sign-out ends only the caller's session, while this reaches every session on the account, plus tokens carrying `gsv`. It is not, however, gated identically: invalidating every credential on the account is an account-credential action, so the account-settings gate applies here. A browser login session passes, and so does a credential explicitly holding `userdetails:*:rw`. **Every other credential is refused with `403` + error `10769`** (`params.reason` = `userdetails_scope_required`) — including a `user:*:rw` key, a `user:*:rwa` key, an entity-scoped key, and a legacy/unscoped key: no `user:*` grant satisfies `userdetails` at any access mode. The limited `twofactor`-scope token from a half-completed 2FA sign-in is refused as before. See [Scope errors](#scope-errors-administration-and-account-settings). **Request Parameters:** None. **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/auth/invalidate-all/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true } ``` **Notes:** - Kills interactive logins (browser, 2FA, password-reset, SSO) whether or not they opted into `revocable`. Enforcement is opt-in per token, so tokens that never carried the marker — OAuth/PKCE access tokens, e-sign signer tokens, anonymous share-guest tokens, AI assistant sessions, short-lived realtime/websocket and FileShare resource tokens, and API keys — are NOT affected and keep their own revocation paths (revoke the OAuth grant, delete the API key, etc.). - Anonymous share-guest sessions and suspended/locked/closed/abuse-flagged accounts are rejected (nothing to invalidate / already terminated). - It does NOT clear client-side storage, and — unlike sign-out — it does not clear the HttpOnly session cookie either. The client must clear its own credential state and re-authenticate afterward. - Rate limited per user. - A platform-wide "log everyone out" also exists: all tokens can be invalidated platform-wide by a Fastio operator action. It has no API. --- ## User Management Endpoints ### POST /current/user/update/ Update the current authenticated user's profile information. **Auth:** Required (JWT) **Request Parameters:** All fields are optional. Only provided fields are updated. | Parameter | Type | Required | Constraints | Description | |-----------|------|----------|-------------|-------------| | `email_address` | string | No | Valid email format; unique; domain must accept email | New email address. You MUST also send `current_password`. An account with no password yet (`password_set: false`, SSO-only) is refused with `10766` — set a password through the email reset flow first. Does not take effect immediately: a confirmation link is emailed to the new address and the change applies only after it is confirmed via `/current/user/email/change/`. Your current email stays active and verified until then. | | `password` | string | No | Must pass validity checks; POST-only | New password for an account that already has one — you MUST also send `current_password`. An account with no password yet (`password_set: false` on `GET /current/user/details/`, i.e. SSO-only) cannot set its first password here: the request is refused with `10766` and the first password is set through the email reset flow (`POST /current/user/email/reset/` then `POST /current/user/password/{code}/`). Must be sent in the POST body — a copy in the query string is rejected, not ignored. | | `current_password` | string | Conditional | Must match the account's current password; POST-only | Required to change the `password` OR `email_address` of an account that already has a password (proves you know the current password). Must be sent in the POST body, never the query string. | | `first_name` | string | No | 2–45 characters; refused if it contains a URL or `scheme:` prefix, a `www.` or domain-like token (`example.com`), an IP address, or `<`/`>` | Updated given/first name. | | `last_name` | string | No | 2–45 characters; refused if it contains a URL or `scheme:` prefix, a `www.` or domain-like token (`example.com`), an IP address, or `<`/`>` | Updated family/last name. | | `phone_country` | string | No | Numeric country code; 2FA must be disabled first | Updated phone country code. Pass `"null"` or empty to clear. | | `phone_number` | string | No | Numeric phone number; 2FA must be disabled first | Updated phone number. Pass `"null"` or empty to clear. | | `owner_defined` | string (JSON) | No | Must be valid JSON if provided | Custom owner-defined properties. Pass `null` or empty to clear. | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/update/" \ -H "Authorization: Bearer {jwt_token}" \ -d "first_name=Jane" \ -d "last_name=Smith" ``` **Success Response (200 OK):** ```json { "result": true, "sessions_invalidated": true, "auth_token": "{jwt_token}" } ``` Both extra fields are optional and appear only when the `password` was changed: | Field | When present | Meaning | |-------|--------------|---------| | `sessions_invalidated` | Always, when the password changed | Other sessions on this account have been signed out. Your own credential may be among them — see `auth_token`. | | `auth_token` | When the password changed **and** you authenticated with a sign-in session token | A replacement session token. Your previous one is no longer valid; use this for subsequent requests. | | `email_send_failed` | A combined password + `email_address` change whose confirmation email could not be sent | The password change still succeeded; the email change did not start. | If `sessions_invalidated` is `true` but no `auth_token` is returned, you authenticated with a credential that was **not** invalidated (an API key or an OAuth access token), so no replacement is needed and you can keep using it. Do not treat a missing `auth_token` as a failure. **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10025` | 406 | "An invalid email was supplied to update." | Invalid email format, or the email domain cannot receive email | | `10025` | 409 | "The email you specified is not available." | Email already in use | | `20544` | 500 | "We could not send the confirmation email; please try again." | The email-change confirmation could not be sent, so the email change was not started; any other fields in the same request were still applied | | `10164` | 406 | "You must disable 2-Factor before updating your phone." | 2FA enabled when trying to change phone | | `10026` | 406 | "An invalid password was supplied to update." | Invalid password | | `10026` | 406 | "The password must be sent in the POST body, not the query string." | `password` was supplied as a query parameter | | `10770` | 403 | "Your credential is read-only and is not authorized to make changes." | The calling credential holds no write-capable scope anywhere — `user:*:r`, or a set every entry of which is `:r` (`params.reason` = `scope_write_required`) — see [Scope errors](#scope-errors-administration-and-account-settings) | | `10769` | 403 | "This operation changes account credentials and requires the \"userdetails:*:rw\" scope." | `password` or `email_address` sent with a credential that does not hold `userdetails:*:rw` (see the note below). `error.params` is an object — see [Scope errors](#scope-errors-administration-and-account-settings) | | `10766` | 406 | "This account has no password yet. Set the first password through the email reset flow: request a code with POST /current/user/email/reset/ and complete it with POST /current/user/password/{code}/." | `password` sent for an account that has no password (SSO-only) — the first password is set through the email reset flow, never through a signed-in session | | `10766` | 406 | "This account has no password yet. Set a password through the email reset flow first (POST /current/user/email/reset/, then POST /current/user/password/{code}/), then change the email with current_password." | A changed `email_address` sent for an account that has no password (SSO-only) — set a password first, then change the email | | `10759` | 403 | "Your current password is required and must be correct to change your password or email." | Changing the password or email of a password-having account without a valid `current_password` | | `10027` | 406 | "An invalid first name was supplied to update." | Invalid first name | | `10027` | 406 | "An invalid last name was supplied to update." | Invalid last name | | `10731` | 406 | "Owner-defined properties must be valid JSON." | Invalid JSON in `owner_defined` | | `10354` | 406 | "Your request was not accepted." | The new email address failed a risk check. The message is deliberately non-specific and there is nothing to correct in the request itself — this is the same code as the signup-time risk rejection, in a different context | **Notes:** - **Order of the credential checks** when `password` or a *changed* `email_address` is sent: the credential-breadth gate runs **first** (`10769`), then a passwordless account is refused (`10766`), then the `current_password` proof (`10759`). For `password`, validation of the value (`10026`) runs before the gates; for `email_address`, an unchanged address is a no-op that runs none of them, and a changed address passes the gates before it is validated (`10025`). - **Changing the password or the email requires the `userdetails:*:rw` scope.** Both are account credentials, so a request carrying `password` or a changed `email_address` passes only for a browser login session or a credential that explicitly holds `userdetails:*:rw`. **Everything else is refused with `403` + `10769`** — a `user:*:rw` key, a `user:*:rwa` key, an entity-scoped key, an OAuth/MCP access token narrowed to one workspace or org, and a legacy/unscoped key alike: no `user:*` grant satisfies `userdetails` at any access mode. This covers setting the *initial* password on an SSO-only account too. The same bar applies to `POST /current/user/auth/invalidate-all/` and to 2FA enrolment. The other fields on this endpoint (names, phone, `owner_defined`) are **not** gated. - **Changing an existing password OR email requires the current password.** If the account already has a password, a `password` change or an `email_address` change must include a valid `current_password` (proof of the current one, POST-only) or it is rejected with `1700`/403/`10759` — this prevents a session-only attacker (e.g. a stolen JWT) from overwriting the password or hijacking the email. Use the `password_set` field on `GET /current/user/details/` to tell which case applies. - **An account with no password (SSO-only) sets its FIRST password through the email reset flow, not here.** A signed-in session alone must not be able to mint a durable credential for the account, so `password` on a passwordless account is refused with `10766`. Call `POST /current/user/email/reset/` with the account's email, open the emailed link, and complete `POST /current/user/password/{code}/` — that proves ownership of the email and signs every session out (including the current one), after which the user signs in with email and password. The same applies to `email_address`: a passwordless account cannot change its email on a bare session (the new address would only have to be confirmed by whoever supplied it) — set a password first, then change the email with `current_password`. - Changing the email address starts a confirmation flow rather than changing it immediately: a confirmation link is sent to the new address (and a notification to the current address), and the change applies only after the link is confirmed via `/current/user/email/change/`. The current email remains active and verified until then. When a change is pending, the response includes `"email_change_pending": true`. - **Changing the password signs out the account's other browsers and devices.** Existing sign-in sessions stop working on their next request and must sign in again with the new password. The session that made this request is kept alive by the `auth_token` returned above — store it and use it in place of the token you sent, or your next call will be rejected too. If the request carried the `x-ve-session-cookie` header, the HttpOnly session cookie is also refreshed to this replacement token, so a cookie-backed browser session keeps working through the change. - AI assistant sessions are NOT signed out. They are short-lived and delegated, so a routine password change leaves an in-progress assistant session running rather than interrupting it mid-task. - **Not affected:** OAuth-connected applications, API keys, e-signature links, guest share access, file-share links and open collaborative-editing connections (user, organization and workspace activity WebSocket connections end within about an hour, once their token expires). These are revoked separately — remove the connected application or delete the API key. - Sessions created before account-wide revocation was introduced do not carry the markers this check relies on, and are therefore refused outright: any such sign-in session is already signed out and must sign in again. Current sign-in sessions, OAuth tokens, API keys and the other credentials listed above are not affected. - Phone number changes require 2FA to be disabled first. - If no fields have changed, the endpoint returns success without making changes. --- ### POST /current/user/close/ Close (soft-delete) the current user's account. **Auth:** Required (JWT) **Request Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `email_address` | string | Yes | Must match the user's current email address (confirmation). | | `dryrun` | string | No | If truthy, checks eligibility without closing the account. | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/close/" \ -H "Authorization: Bearer {jwt_token}" \ -d "email_address=jane.doe@example.com" ``` **Success Response (202 Accepted):** ```json { "result": true } ``` **Dry Run Response (Cannot Close, 202 Accepted):** ```json { "result": false } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10024` | 404 | "User not found to close." | User object invalid | | `10025` | 406 | "An invalid email was supplied to close account." | Invalid email format | | `10025` | 406 | "An incorrect email was supplied to close account." | Email does not match user's email | | `159788` | 406 | "Cannot close user account that owns active organizations..." | User owns active organizations | | `102455` | 409 | "This account cannot be closed right now. Please contact your organization." | The account is covered by a legal hold in some org — see *Legal Holds* in `llms/orgs.txt` | | `138096` | 503 | "This account cannot be closed right now. Please try again shortly." | Legal-hold state could not be read; retry | **Notes:** - 2FA verification is required if 2FA is enabled on the account. - Users who own active organizations must close or transfer ownership first. - The `dryrun` parameter checks closure eligibility without actually closing the account. - On closure: subscriptions are cancelled, SSO connections are removed, the account is flagged as closed. - **The `409` wording above is deliberately generic and never confirms or names a legal hold** — the account holder may not be entitled to know one exists. `dryrun` returns `{"result": false}` (the same as any other "cannot close" case) rather than surfacing the `409`. --- ### POST /current/user/email/ **Deprecated.** This endpoint previously reported whether an email was already registered, which let anyone enumerate Fastio accounts. It no longer does any account lookup: for any well-formed email it returns a uniform `202 Accepted` / `result: true`. It is retained only so existing callers keep receiving a success response. To handle an already-registered email, just call signup (`POST /user/`) — it notifies the existing account and returns `result: true` without an `auth_token` (a new account also gets a session). **Auth:** None (IP-throttled) **Request Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `email` | string | Yes | Email address (format-validated only; not looked up). | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/email/" \ -d "email=jane.doe@example.com" ``` **Response (202 Accepted) — always, regardless of whether the email is registered:** ```json { "result": true } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10022` | 406 | "You provided an invalid email to check." | Invalid email format or missing | **Notes:** - The response does not reveal whether the email is registered (account enumeration is intentionally not possible). --- ### POST /current/user/email/reset/ Request a password reset email. **Auth:** None (IP-throttled) **Request Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `email` | string | Yes | Email address of the account. | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/email/reset/" \ -d "email=jane.doe@example.com" ``` **Success Response (202 Accepted):** ```json { "result": true } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10022` | 406 | "You provided an invalid email to check." | Invalid email format | | `20544` | 500 | "We were unable to send a verification email." | Email send failure | **Notes:** - For security, this endpoint always returns success regardless of whether the email exists in the system. --- ### POST /current/user/email/validate/ Send or validate an email verification code. Two-step flow. **Auth:** Required (JWT) **Mode 1: Send Verification Code** When `email_token` is NOT provided, sends a new validation code to the user's email. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `email` | string | Yes | Must match the authenticated user's email address. | **Mode 2: Validate Code** When `email_token` IS provided, validates the code and marks the email as verified. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `email` | string | Yes | Must match the authenticated user's email address. | | `email_token` | string | Yes | Verification code received via email. | **Request Example (Send Code):** ```bash curl -X POST "https://api.fast.io/current/user/email/validate/" \ -H "Authorization: Bearer {jwt_token}" \ -d "email=jane.doe@example.com" ``` **Request Example (Validate Code):** ```bash curl -X POST "https://api.fast.io/current/user/email/validate/" \ -H "Authorization: Bearer {jwt_token}" \ -d "email=jane.doe@example.com" \ -d "email_token=123456" ``` **Success Response (202 Accepted):** ```json { "result": true } ``` **Side Effects:** - On successful validation, any pending invitations addressed to this email remain pending. The user accepts them explicitly via the invitation accept endpoints (list pending invitations with `GET /current/user/invitations/list/`, then accept with `POST /current/user/invitation/{invitation_id}/accept/`). **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10011` | 401 | "Your credentials were not supplied or invalid." | User not authenticated | | `10037` | 406 | "Your email address is already verified." | Email already verified | | `10023` | 409 | "Your credentials do not match the email you provided." | Email mismatch with authenticated user | | `10033` | 406 | "You provided an invalid or expired token to validate email." | Invalid or expired code | | `10199` | 401 | "Provided code has expired, get a new code and try again." | Code expired | --- ### POST /current/user/email/change/ Confirm a pending email change. Consumes the one-time confirmation token from the link that was emailed to the new address when the change was requested via `/current/user/update/`, and applies the new email. **Auth:** Required (JWT). The signed-in user must be the account the change was requested for. **Request Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `token` | string | Yes | The one-time confirmation token from the confirmation link. | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/email/change/" \ -H "Authorization: Bearer {jwt_token}" \ -d "token={confirmation_token}" ``` **Success Response (202 Accepted):** ```json { "result": true } ``` **Side Effects:** - On success the new email becomes the account email and is marked verified. Any pending invitations addressed to the new email remain pending; the user accepts them explicitly via the invitation accept endpoints (list with `GET /current/user/invitations/list/`, then accept with `POST /current/user/invitation/{invitation_id}/accept/`). **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10755` | 406 | "There is no pending email change to confirm." | No pending change exists for this account | | `10033` | 401 | "The confirmation link is invalid or has expired." | Token invalid, expired, or already used | | `10023` | 401 | "This confirmation link does not belong to the signed-in account." | Token belongs to a different account than the one signed in | | `10025` | 409 | "That email address is no longer available." | The pending email was claimed by another account before confirmation | | `113424` | 403 | "Single sign-on is required for this organization." | Single sign-on is enforced for the address, so the change cannot be applied (`params.reason` = `sso_required`) | | `176840` | 503 | "This change could not be completed right now. Please try again shortly." | The single sign-on check could not be completed (`params.reason` = `internal`); retry | | `10032` | 500 | "There was an internal error applying your email change." | The change could not be applied | **Notes:** - The confirmation token is single-use and time-limited; once used or expired, request the change again via `/current/user/update/`. --- ### POST /current/user/password/{code}/ Set a new password using a password reset code. **Auth:** None (code-based authentication) **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{code}` | string | Yes | Password reset code from the reset email. | **Request Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `password1` | string | Yes | New password. | | `password2` | string | Yes | New password confirmation. Must match `password1`. | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/password/abc123def456/" \ -d "password1=NewSecureP@ss" \ -d "password2=NewSecureP@ss" ``` **Success Response (202 Accepted):** ```json { "result": true } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10197` | 401 | "An invalid code was provided, cannot reset password." | Invalid code format | | `10198` | 401 | "Provided code was not found or expired, cannot reset password." | Code not found or wrong type | | `10199` | 401 | "Provided code has expired, get a new code and try again." | Code expired | | `10200` | 409 | "Provided code belongs to another user account and cannot be used." | Code/user mismatch | | `10201` | 404 | "The provided code belongs to an invalid user." | User not found for code | | `10202` | 409 | "The provided passwords don't match." | `password1` and `password2` differ | | `10204` | 406 | "Both password fields must be provided and match." | Missing password fields | | `166164` | 406 | "An invalid password was supplied." | The new password does not meet the password requirements | | `10203` | 500 | "The provided password could not be processed..." | Encryption failure | | `10204` | 500 | "The provided password could not be processed..." | The new password could not be written | | `10204` | 500 | "The provided password could not be processed..." | The reset code could not be claimed because the datastore did not answer; nothing was consumed, retry | | `142953` | 500 | "The provided password could not be processed, please request a new password reset and try again." | The account's email address was unverified and the credentials issued before the reset could not all be revoked (see the notes); the code has already been used — request a new password reset. | **Notes:** - ⚠️ **`10204` is overloaded and the HTTP status is what separates the two meanings.** At `406` it is a caller mistake (a password field is missing — mismatched passwords are `10202`) and is worth reporting to the user; at `500` it is a server-side failure writing the password, carries a completely different message, and the user did nothing wrong. Branch on the status, not on the code alone. `10203` is only ever the `500`. - The reset code is consumed by an atomic claim immediately before the new password is written, so a replayed or concurrent request carrying the same code is refused with the same error as an unknown code. If the password write itself fails after the claim, the code is already spent and a new reset must be requested. - **Completing a reset signs out the account's other browsers and devices.** Any existing sign-in session stops working on its next request. This endpoint does not return a token — sign in normally with the new password afterwards to obtain one. OAuth-connected applications, e-signature links, guest share access and open collaborative-editing connections (user, organization and workspace activity WebSocket connections end within about an hour, once their token expires) are **not** affected; revoke those separately. - **Resetting the password of an account whose email address was never verified** also revokes everything issued to the account before the reset — every API key, OAuth grant and already-issued OAuth or agent access token, connected Google/Microsoft sign-in, session, two-factor enrolment, stored phone number and pending email change — and marks the address verified, since the reset code proves the mailbox. This happens only after the reset code has been claimed, so a concurrent request with the same code cannot undo a completed reset. --- ### GET /current/user/password/{code}/details/ Get details of a password reset code (check if valid/expired). **Auth:** None (code-based) **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{code}` | string | Yes | Password reset code to check. | **Request Example:** ```bash curl -X GET "https://api.fast.io/current/user/password/abc123def456/details/" ``` **Success Response (200 OK):** ```json { "result": true, "email": "jane.doe@example.com" } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `email` | string | The email address associated with the reset code. | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10197` | 401 | "An invalid code was provided, cannot reset password." | Invalid code format | | `10198` | 401 | "Provided code was not found or expired, cannot reset password." | Code not found | | `10199` | 401 | "Provided code has expired, get a new code and try again." | Code expired | | `10200` | 409 | "Provided code belongs to another user account..." | Code/user mismatch | | `10201` | 404 | "The provided code belongs to an invalid user." | User not found for code | | `10207` | 423 | "The account has been restricted and cannot be updated." | Account locked/suspended/closed | --- ### GET /current/user/phone/{country_code}-{phone_number}/ Validate a phone number and country code combination. **Auth:** Required (JWT) **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{country_code}-{phone_number}` | string | Yes | Country code and phone number separated by a hyphen (e.g., `1-5551234567`). | **Request Example:** ```bash curl -X GET "https://api.fast.io/current/user/phone/1-5551234567/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (202 Accepted):** ```json { "result": true } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10022` | 406 | "You provided an invalid phone number to check." | Invalid format | | `10163` | 406 | "An invalid phone country code was supplied." | Invalid country code | | `10029` | 406 | "An invalid phone number was supplied." | Invalid phone number | | `10165` | 406 | "An invalid phone number or country code was supplied." | Full number validation failed | --- ### GET /current/user/pin/ Get the user's support PIN and identity verification hash. **Auth:** Required (JWT) **Request Example:** ```bash curl -X GET "https://api.fast.io/current/user/pin/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true, "supportcode": "1234", "intercom": "a1b2c3d4e5f6..." } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `supportcode` | string | 4-digit support PIN. Defaults to `"0000"` if not set. | | `intercom` | string | HMAC-SHA256 identity-verification hash of your user ID, for authenticating you to the support widget. | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10023` | 404 | "Unable to fetch the user details." | User not found | | `10541` | 500 | "Internal temporary error, please try again later." | Failed to load credentials | --- ### GET|POST /current/user/sso/signin/{provider}/ SSO (Single Sign-On) authentication flow. **Auth:** None (IP-throttled) **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{provider}` | string | Yes | SSO provider name: `google` or `microsoft`. | **GET: Get SSO Redirect URL** Returns the OAuth2 authorization URL for the specified provider. ```bash curl -X GET "https://api.fast.io/current/user/sso/signin/google/" ``` **Optional Query Parameters (GET only):** | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `revocable` | boolean | `false` | When `true`, the JWT issued at the end of the SSO flow is additionally reachable by the account-wide revocation paths that target revocable sessions. `POST /current/user/auth/sign-out/` ends the calling session regardless of this flag. The flag travels through the OAuth round-trip inside the HMAC-signed state token. Recommended for browser SSO logins. | | `return_url` | string | (unset) | Optional `https://` URL to redirect to after the SSO callback completes. Must be a trusted Fastio domain. | **GET Response (200 OK):** ```json { "result": true, "provider": "google", "redirect_url": "https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=...", "return_url": "https://fast.io/sso/callback/google" } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `provider` | string | The provider name. | | `redirect_url` | string | URL to redirect the user to for SSO authentication. | | `return_url` | string | Callback URL the provider will redirect back to. | **POST: Process SSO Callback** Processes the OAuth2 callback with the authorization code from the provider. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `code` | string | Yes | Authorization code from the SSO provider. | | `state` | string | Yes | State token for CSRF protection. | **POST Response (200 OK):** ```json { "result": true, "provider": "google", "email": "user@example.com", "token": "{jwt_token}", "2factor": false, "enrol_required": false, "account_created": true } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `provider` | string | The provider that authenticated the user. | | `email` | string | The email address on the authenticated account. | | `token` | string | The issued JWT. Send it as `Authorization: Bearer {token}`. | | `2factor` | boolean | Whether the account has 2FA enabled, OR `enrol_required` is `true`. When `true`, complete the 2FA step before the token has full access. | | `enrol_required` | boolean | **Always present**, exactly like the password-login field of the same name — see *Interactive Login & Enrolment*. `true` only when an org this account belongs to requires 2FA and the account holds none; `token` is then an enrolment token, not a session. | | `account_created` | boolean | Present and `true` only when this exchange CREATED the account — i.e. a first-time SSO signup. **Omitted entirely for a returning sign-in**, so treat a missing field as `false`. | | `redirect_after_login` | string | Only present when a `return_url` was supplied on the GET step; the URL to send the user to after login completes. | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10041` | 406 | "An invalid provider name was supplied." | Invalid provider name format | | `10226` | 406 | "An unknown provider name was supplied." | Provider not in allowed list | | `10530` | 406 | "Cookies must be enabled and passed to this API." | Missing state cookie | | `10260` | 401 | "Permission was not granted by the provider." | OAuth error returned from provider | | `10230` | 406 | "Invalid or missing input in a required field was received." | Missing code or state | | `10227` | 406 | "The state token provided was not valid." | The `state` token is invalid or has expired; start the sign-in again | | `145237` | 401 | "Accounts cannot be registered with this email domain." | First-time SSO signup only: the provider-asserted email is at a reserved domain, or a subdomain of one, so no account is created. An account that already exists on such a domain can still sign in | | `137409` | 452 | "Access from your location is restricted." | App error code `1697` — first-time SSO signup only: the request comes from a geo-restricted location and the provider-asserted email has no live pending invitation to any org/workspace/share. Signing in to an existing account is unaffected | | `182141` | 503 | "Your organization's sign-in policy could not be read. Please try again." | An org's Require-2FA policy governing this account could not be resolved, so the sign-in was neither completed nor refused. **Retryable, and NOT a credential failure** — identical to the password-login case; see the note below | | `126164` | 500 | "There was an internal error signing you in. Please try again." | The sign-in matched an existing account whose email address was never verified, and the credentials issued before this sign-in could not all be revoked (see the note below). Retry | | `2411` (Microsoft), `199017` (Google) | 409 | "An account with this email address already exists. Sign in with your password, or verify your email address or reset your password first, then connect this provider." | The sign-in matched an existing account whose email address was never verified, and the provider did not assert that the address is verified. Nothing on the account changed (see the note below) | **Notes:** - **This is the personal social sign-in, not enterprise SSO.** It authenticates an individual against Google or Microsoft and needs no organization configuration. To sign a user in against *their organization's own* identity provider, see *Enterprise SSO Sign-In* below. - Supported providers: `google`, `microsoft`. (Requesting any other provider — including `apple` — is rejected with the `10226` "An unknown provider name was supplied." error below.) - GET generates a state token (requires cookies) and returns the redirect URL. - POST exchanges the authorization code for tokens and creates/links the user account. - **Telling a first-time signup from a returning sign-in:** the POST response carries `account_created: true` only when that exchange created the account. It is omitted for every returning sign-in, so a missing field means "existing account" — use it to send a brand-new user into org creation rather than the normal post-login landing. - **Geo restriction applies only to first-time account creation** via this flow — same rule and exception (a live pending invitation) as `POST /current/user/`. A returning user signing in to an existing account is never blocked by it. - **Signing in to an existing account whose email address was never verified.** When a provider sign-in is matched to an existing account by email (the first time that provider is used with it) and the account's address is unverified, the sign-in proceeds only if the provider asserts that the address is verified. Otherwise it is refused with `409` and nothing on the account changes — sign in with the password, or verify the address (or reset the password) first and then connect the provider. Microsoft never asserts a verified address, so a Microsoft sign-in is always refused here; Google asserts it when the Google account's address is verified. When the provider does assert it, everything issued to the account before this sign-in is revoked before the new session is issued — every API key, OAuth grant and already-issued OAuth or agent access token, connected Google/Microsoft sign-in, session, two-factor enrolment, stored phone number and pending email change — any password is replaced with an unknown one (set a new one through password reset), and the address is marked verified. If the revocation cannot complete, the sign-in is refused with `126164` (500) — retry. - **Browser session cookie:** send the `x-ve-session-cookie` header on the POST callback request to also receive the issued token in an HttpOnly cookie, exactly as on `GET /current/user/auth/`. Because only code the page itself runs can set a header, the opt-in takes effect when the app makes the callback request; where the identity provider posts the browser to the callback directly, no cookie is issued and the app uses the token in the response body as it does today. - **This is subject to a org's Require-2FA policy exactly like password login.** `enrol_required: true` behaves identically to the password-login case — see *Interactive Login & Enrolment* below. - **So is the `503`.** When that policy cannot be read, this callback answers `503` ("Your organization's sign-in policy could not be read. Please try again.") rather than issuing a token. **It is retryable and is not an authentication failure** — the provider already authenticated the user. Do not report it as a rejected sign-in, and do not let it count toward any attempt or lockout counter. Only an account holding no second factor can see it. **To retry, start the provider sign-in again from the beginning** — call `GET /current/user/sso/signin/{provider}/` for a fresh redirect and send the user back through the provider. **Do NOT re-send the same callback:** the authorization code in it is single-use and was already exchanged, so replaying it fails on the code rather than retrying the policy read. --- ### GET /current/user/assets/ List available user asset metadata types (e.g., profile photo specifications). **Auth:** None **Request Example:** ```bash curl -X GET "https://api.fast.io/current/user/assets/" ``` **Notes:** - Returns the schema/specifications for available asset types, not actual assets. --- ### GET /current/user/available_profiles/ Check what profile types (orgs, workspaces, shares) the current user has access to. **Auth:** Required (JWT) **Request Example:** ```bash curl -X GET "https://api.fast.io/current/user/available_profiles/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true, "has_orgs": true, "has_workspaces": true, "has_shares": false, "has_pending_invitations": false } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `has_orgs` | boolean | Whether the user has access to any organizations. | | `has_workspaces` | boolean | Whether the user has access to any workspaces. | | `has_shares` | boolean | Whether the user has access to any shares. | | `has_pending_invitations` | boolean | Whether the user has any pending invitations awaiting their explicit acceptance. Computed for verified-email accounts only (false otherwise). | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10023` | 404 | "Unable to fetch the user details." | User not found | --- ### GET /current/user/{user_id}/details/ Get user profile details. **Auth:** Required (JWT) **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{user_id}` | string | No | 19-digit user ID. If omitted, returns the current user's details. | **Request Example:** ```bash curl -X GET "https://api.fast.io/current/user/1234567890123456789/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true, "user": { "id": "1234567890123456789", "account_type": "human", "email_address": "jane.doe@example.com", "first_name": "Jane", "last_name": "Doe", "status": "active", "profile_pic": "https://assets.fast.io/..." } } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `user.id` | string | 19-digit user ID. | | `user.account_type` | string | `"human"` or `"agent"`. | | `user.email_address` | string | User's email address. | | `user.first_name` | string | Given name. | | `user.last_name` | string | Family name. | | `user.status` | string | `"active"`, or `"pending"` for an account that has been invited but not yet claimed. | | `user.profile_pic` | string | Profile photo URL. | | `user.created` | string | Registration date. | | `user.updated` | string | Last profile update time. | **Self-Only Fields** (included only when viewing your own profile): | Field | Type | Description | |-------|------|-------------| | `2factor` | boolean | Whether 2FA is enabled. | | `auth` | object | Sign-in methods for the account: `password` (bool, has a usable password), `social` (list of `{provider, last_login}` for `google`/`microsoft` providers signed in with — `last_login` is the most recent sign-in with, or connection of, that provider, so a freshly connected provider shows its connection time even before any sign-in with it), `sso` (list of `{org_id, protocol, last_login}` for every ACTIVE org SSO identity across ALL of the account's orgs — `protocol` is `saml`/`oidc`, null until that identity's first SSO login), `two_factor` (`{enabled, method}` — `method` is `totp`/`phone`, null when disabled). Self view only. 🔴 **Omitted — not null — if a supporting table could not be read.** Distinct from the `sso` block documented below, which is the caller's SSO enforcement state, not their sign-in identities. Example: `{"password": true, "social": [{"provider": "google", "last_login": "2026-09-15 10:22:31 UTC"}], "sso": [{"org_id": "9876543210987654321", "protocol": "saml", "last_login": "2026-09-20 08:00:00 UTC"}], "two_factor": {"enabled": true, "method": "totp"}}`. To add or remove a `social` entry, see *Connecting or Disconnecting a Personal Google / Microsoft Sign-In* below. | | `closed` | boolean | Whether the account is closed. | | `country_code` | string | Country of residence. | | `locked` | boolean | Whether the account is locked. | | `password_set` | boolean | Whether the account has a password set (`false` = SSO-only). Self/manager view only. | | `phone_country` | string | Phone country code. | | `phone_number` | string | Phone number. | | `sso` | object | Enterprise SSO enforcement state for the caller: `enforced`, `exempt`, `org_domain`. Absent when it could not be determined — treat an absent block as not enforced. See *Enterprise SSO Enforcement*. | | `suspended` | boolean | Suspension status. | | `tos_agree` | string | ToS agreement date. | | `valid_email` | boolean | Email verified status. | | `valid_phone` | boolean | Phone verified status. | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10023` | 404 | "Unable to fetch the user details." | User not found | --- ### GET /current/user/me/autosync/{state}/ Enable or disable profile auto-synchronization from social sign-in providers (Google, Microsoft). While enabled, each sign-in through a provider refreshes the account's email address, first and last name and profile photo from that provider. `disable` stops this; uploading or deleting a profile photo also disables it. Both states return 200 with `{"result": true}`, including when the state is already in effect. The current state is reported as `sync_profile` on the full user details (`true` = enabled). **Auth:** Required (JWT) **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{state}` | string | Yes | `"enable"` or `"disable"`. | **Request Example:** ```bash curl -X GET "https://api.fast.io/current/user/me/autosync/enable/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `135405` | 500 | "There was an internal error processing your request..." | Internal processing failure | --- ### GET /current/user/me/allowed/ Check if the user's country (based on IP geolocation) allows creating shares or organizations. **Auth:** None (IP-throttled) **Request Example:** ```bash curl -X GET "https://api.fast.io/current/user/me/allowed/" ``` **Success Response (200 OK):** ```json { "result": true, "allowed": true } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `allowed` | boolean | Whether the user's location allows resource creation. | | `reasons` | array | Array of blocked reason strings. Only present when `allowed` is `false`. | --- ### GET /current/user/me/limits/orgs/ Check organization creation and trial eligibility. **Auth:** Required (JWT) **Request Example:** ```bash curl -X GET "https://api.fast.io/current/user/me/limits/orgs/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true, "can_create_free_org": false, "existing_free_orgs": 0, "cooldown_remaining": 0, "max_free_orgs": 1, "reason": "The free plan is no longer available. Please choose a paid plan.", "free_trial_eligible": true, "trial_days": 14 } ``` The free plan is retired for new organizations, so `can_create_free_org` is `false` and a `reason` is returned. (These field NAMES are unchanged. The plan identifier itself is now reported as `unpaid` rather than `free` — an organization with no active subscription is on the `unpaid` tier.) New organizations select a paid plan (Starter, Business, or Enterprise) instead. While the free plan is closed, `existing_free_orgs` and `cooldown_remaining` are not meaningful (reported as `0`/`null`). The `free_trial_eligible` and `trial_days` fields describe whether a NEW org this user creates can start a trial of a paid plan (vs. having to buy immediately). A trial is only available on a user's first organization, so at this pre-org stage `free_trial_eligible` is `true` only when the user owns no organization at all — once the user owns (or has ever owned) any organization, every later org is permanently ineligible regardless of elapsed time. A trial is also permanently unavailable once the user has ever started a free trial before, no matter how much time has passed — one trial per user, ever. Use these fields to render the plan-selection cards ("Start N-day trial" vs. "Buy now") before any org exists. When `free_trial_eligible` is `false`, a `no_trial_reason` string is also returned explaining why. Both blocks are permanent, so `trial_available_at` is never returned. **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `can_create_free_org` | boolean | Whether the user can create a free organization. The free plan is retired for new orgs, so this is `false`. | | `existing_free_orgs` | integer | Compatibility field. The free plan is retired, so this is reported as `0`. | | `cooldown_remaining` | integer | Seconds remaining before next creation is allowed. | | `max_free_orgs` | integer | Historical free-organization creation limit. The free plan is retired, so no new free organizations can be created. | | `reason` | string | Reason creation is not allowed. Present when `can_create_free_org` is `false`. | | `free_trial_eligible` | boolean | Whether a new org this user creates can start a trial of a paid plan. `true` only when the user owns no organization at all (a trial is only ever available on a user's first org) and has never started a free trial before; `false` and permanent thereafter. | | `trial_days` | integer | Length of the trial in days for the default paid plan. | | `no_trial_reason` | string | Why the user is not trial-eligible. Present only when `free_trial_eligible` is `false` and a reason exists. | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `141088` | 404 | "Unable to fetch the user." | User not found | --- ### GET /current/user/me/list/shares/ List all shares accessible to the current user. **Auth:** Required (JWT) **Query Parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `archived` | string | No | `"false"` | `"true"` to show archived shares, `"false"` to show non-archived. | | `limit` | integer | No | `100` | Page size (1–500). An out-of-range value is rejected as invalid input. | | `offset` | integer | No | `0` | Number of items to skip before the returned page. | **Request Example:** ```bash curl -X GET "https://api.fast.io/current/user/me/list/shares/?limit=50&offset=0" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true, "shares": [ { "id": "1234567890123456789", "name": "Project Files", "type": "send", "archived": false } ], "pagination": { "total": 1, "limit": 50, "offset": 0, "has_more": false } } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `shares` | array | Array of share resource objects for the current page. Each includes parent workspace and org info. | | `pagination` | object | Pagination metadata: `total` (count of all matching shares before paging), `limit`, `offset`, and `has_more` (boolean — `true` when more items remain beyond this page). | **Notes:** - This endpoint is paginated. The `shares` array is a single page (default 100 items). Keep advancing `offset` by `limit` while `pagination.has_more` is `true` to retrieve every share — reading only the first page will silently miss shares beyond the page size. - Shares are gathered from three sources: owned by the user, invited to, and joined. - Duplicates are removed by share ID. - Does NOT include shares from workspaces the user has access to -- only shares with direct user relationships. --- ### GET /current/user/{user_id}/assets/ List set assets (e.g., profile photo) for a user. **Auth:** Required (JWT) **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{user_id}` | string | Yes | 19-digit numeric user ID. | **Request Example:** ```bash curl -X GET "https://api.fast.io/current/user/1234567890123456789/assets/" \ -H "Authorization: Bearer {jwt_token}" ``` --- ### POST|DELETE /current/user/{user_id}/assets/{asset_name}/ Upload or delete a user asset. **Auth:** Required (JWT) **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{user_id}` | string | Yes | 19-digit numeric user ID. | | `{asset_name}` | string | Yes | Asset type name (e.g., `profile_pic`). | **POST: Upload Asset** Multipart form data with exactly one file upload. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | (file) | file | Yes | The asset file. Exactly one file must be included. | | `metadata` | string (JSON object) | No | Optional caller-defined metadata, sent as a JSON-encoded object. Anything else is refused with 406. | **DELETE: Delete Asset** No request body required. **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10586` | 405 | "Only user may modify." | Non-owner attempting to modify | | `10418` | 412 | "Asset upload missing" | No file in POST request | | `156780` | 406 | "metadata invalid" | Invalid metadata parameter | **Notes:** - Only the user themselves can modify their own assets. - Uploading or deleting disables profile photo auto-sync. --- ### GET|HEAD /current/user/{user_id}/assets/{asset_name}/read/ Read the binary content of a user asset. **Auth:** Required (JWT) **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{user_id}` | string | Yes | 19-digit numeric user ID. | | `{asset_name}` | string | Yes | Asset type name (e.g., `profile_pic`). | **Notes:** - Returns raw binary bytes with appropriate content-type headers, not JSON. - HEAD returns headers only. --- ## Installed Apps Track the desktop/mobile apps and agents a user has installed on their account. All four endpoints are user-authenticated and scoped to the calling user; installations are keyed by a caller-defined `app_id`. An installation object has this shape everywhere it is returned: | Field | Type | Description | |-------|------|-------------| | `id` | string | 19-digit installation ID. | | `app_id` | string | Caller-defined app identifier this installation belongs to. | | `app_version` | string or null | App version last reported, or `null` if never provided. | | `platform` | string or null | Platform string last reported (e.g. `macos`, `windows`, `ios`), or `null`. | | `status` | string | `"installed"` or `"uninstalled"`. | | `installed_at` | string | Canonical `Y-m-d H:i:s UTC` timestamp of first install. | | `uninstalled_at` | string or null | Canonical `Y-m-d H:i:s UTC` timestamp of last uninstall, or `null` if currently installed. | | `last_heartbeat` | string or null | Canonical `Y-m-d H:i:s UTC` timestamp of the last heartbeat/install check-in, or `null`. | | `metadata` | object or null | Arbitrary caller-defined JSON metadata, or `null`. | --- ### GET /current/user/apps/ List all app installations for the authenticated user (both installed and previously uninstalled). **Auth:** Required (JWT) **Request Example:** ```bash curl -X GET "https://api.fast.io/current/user/apps/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true, "apps": [ { "id": "akulxqrapav3gqbyulsup5mtna43a", "app_id": "com.example.desktop", "app_version": "1.4.2", "platform": "macos", "status": "installed", "installed_at": "2026-07-07 16:37:29 UTC", "uninstalled_at": null, "last_heartbeat": "2026-07-07 18:02:10 UTC", "metadata": null } ] } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `apps` | array | Array of installation objects (see shape above). Each `id` is an opaque alphanumeric installation id (a string, not a 19-digit numeric id). | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `227759` | 500 | "Internal error initializing app installations." | Backend unavailable | | `218645` | 404 | "No app installations found." | Installation lookup failed | **Notes:** - Also responds to `HEAD` (headers only). --- ### POST /current/user/apps/install/ Register an app installation for the authenticated user. Creates a new record, reinstalls a previously uninstalled app, or updates the version/platform (and refreshes the heartbeat) on an existing installation — all keyed by `app_id`. **Auth:** Required (JWT) **Request Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `app_id` | string | Yes | Caller-defined app identifier. Must not be blank. | | `app_version` | string | No | App version string. | | `platform` | string | No | Platform string (e.g. `macos`, `windows`, `ios`, `android`). | | `metadata` | string (JSON) | No | Arbitrary JSON object of caller-defined metadata. | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/apps/install/" \ -H "Authorization: Bearer {jwt_token}" \ -d "app_id=com.example.desktop" \ -d "app_version=1.4.2" \ -d "platform=macos" ``` **Success Response (200 OK):** ```json { "result": true, "installation": { "id": "akulxqrapav3gqbyulsup5mtna43a", "app_id": "com.example.desktop", "app_version": "1.4.2", "platform": "macos", "status": "installed", "installed_at": "2026-07-07 16:37:29 UTC", "uninstalled_at": null, "last_heartbeat": "2026-07-07 16:37:29 UTC", "metadata": null } } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `installation` | object | The created or updated installation object (see shape above). | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `296856` / `210782` / `224953` / `296358` | 406 | Various | `app_id` blank/missing/too long → `296856`; `app_version` too long → `210782`; `platform` too long → `224953`; `metadata` not valid JSON → `296358` | | `246127` | 500 | "Internal error initializing app installations." | Backend unavailable | | `217449` | 404 | "Failed to save app installation." | Persisting the installation failed | **Notes:** - Idempotent per `app_id`: calling install again for an already-installed app updates version/platform (when provided) and refreshes `last_heartbeat` rather than creating a duplicate. - Calling install for a previously uninstalled `app_id` reinstalls it (status returns to `installed`). - Throttled per user. --- ### POST /current/user/apps/uninstall/ Mark an app installation as uninstalled. **Auth:** Required (JWT) **Request Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `app_id` | string | Yes | Caller-defined app identifier of the installation to uninstall. Must not be blank. | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/apps/uninstall/" \ -H "Authorization: Bearer {jwt_token}" \ -d "app_id=com.example.desktop" ``` **Success Response (200 OK):** ```json { "result": true, "installation": { "id": "akulxqrapav3gqbyulsup5mtna43a", "app_id": "com.example.desktop", "app_version": "1.4.2", "platform": "macos", "status": "uninstalled", "installed_at": "2026-07-07 16:37:29 UTC", "uninstalled_at": "2026-07-07 19:15:44 UTC", "last_heartbeat": "2026-07-07 18:02:10 UTC", "metadata": null } } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `installation` | object | The updated installation object; `status` is now `uninstalled`. | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `234003` | 406 | Various | `app_id` blank/missing or too long | | `226304` | 404 | "No installation record found for this app." | No installation exists for this `app_id` | | `233650` | 409 | "This app is already uninstalled." | Installation already in the uninstalled state | | `237113` | 500 | "Failed to save uninstall status." | Persisting the change failed | --- ### POST /current/user/apps/heartbeat/ Record a periodic liveness check-in from an installed app, updating `last_heartbeat` and optionally the app version. **Auth:** Required (JWT) **Request Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `app_id` | string | Yes | Caller-defined app identifier of the installed app. Must not be blank. | | `app_version` | string | No | Updated app version string. When provided, it replaces the stored version. | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/apps/heartbeat/" \ -H "Authorization: Bearer {jwt_token}" \ -d "app_id=com.example.desktop" \ -d "app_version=1.4.3" ``` **Success Response (200 OK):** ```json { "result": true, "installation": { "id": "akulxqrapav3gqbyulsup5mtna43a", "app_id": "com.example.desktop", "app_version": "1.4.3", "platform": "macos", "status": "installed", "installed_at": "2026-07-07 16:37:29 UTC", "uninstalled_at": null, "last_heartbeat": "2026-07-07 20:41:03 UTC", "metadata": null } } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `installation` | object | The updated installation object with a refreshed `last_heartbeat`. | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `276731` / `272675` | 406 | Various | `app_id` blank/missing/too long → `276731`; `app_version` too long → `272675` | | `245439` | 404 | "No installation record found for this app." | No installation exists for this `app_id` | | `231483` | 409 | "Cannot heartbeat an uninstalled app." | Installation is in the uninstalled state | | `237563` | 500 | "Failed to save heartbeat." | Persisting the change failed | **Notes:** - Only currently-installed apps can heartbeat; reinstall via `POST /current/user/apps/install/` first if the app was uninstalled. - Throttled per user. --- ## Invitations ### GET /current/user/invitation/{invitation_id}/details/ Get details for a specific invitation. **Auth:** Required (JWT). The caller must be the invitee — matched by user ID, or by email address only once that address is verified or when `{invitation_id}` is the invitation key — or a member of the invited entity allowed to view its invitations (org or workspace: the role set by its member-management permission; share: any share member; file share: any member of its workspace). **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{invitation_id}` | string | Yes | Invitation ID (numeric) or invitation key (alphanumeric). | **Request Example:** ```bash curl -X GET "https://api.fast.io/current/user/invitation/1234567890123456789/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true, "invitation": { "id": "1234567890123456789", "invitation_key": "{invitation_key}", "inviter": "Jane Doe", "invitee_email": "newuser@example.com", "entity_type": "workspace", "workspace": { "id": "9876543210987654321", "name": "Marketing Team" }, "state": "pending", "created": "2025-01-15 10:30:00 UTC", "expires": "2025-01-22 10:30:00 UTC" }, "owner": { "id": "1111111111111111111", "account_type": "human", "email_address": "admin@example.com", "first_name": "Admin", "last_name": "User", "profile_pic": "https://assets.fast.io/..." }, "org": { "id": "2222222222222222222", "name": "Example Org" } } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `invitation` | object | Invitation resource. Embeds `entity_type` and the entity object (`org`/`workspace`/`share`). Includes `invitation_key` (this view is authenticated; see **Auth** above). | | `owner` | object | User resource of the profile owner. | | `org` | object or null | Org resource if the invitation is for an org-owned entity. | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10618` | 406 | "Invitation not found." | Malformed ID or key, or no such invitation | | `10545` | 401 | "Appropriate access is not granted to this Org." (or `Workspace` / `Share`) | Caller is neither the invitee nor permitted to view the invited entity's invitations | | `159135` | 500 | "Failed to load the invitation profile or its owner." | Profile or owner load failure | **Notes:** - `invitation_key` is included in this authenticated view. It can be used with the per-entity join endpoints (`POST /current/{org|workspace|share}/{entity_id}/members/join/{invitation_key}/{accept|decline}/`), but prefer the by-id accept/decline endpoints below, which do not require the key. --- ### GET /current/user/invitation/{invitation_id}/public/details/ Get public details for an invitation without authentication. **Auth:** None (IP-throttled) **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{invitation_id}` | string | Yes | Invitation ID (numeric) or invitation key (alphanumeric). | **Request Example:** ```bash curl -X GET "https://api.fast.io/current/user/invitation/1234567890123456789/public/details/" ``` **Success Response (200 OK):** ```json { "result": true, "invitation": { "id": "1234567890123456789", "state": "pending" }, "owner": { "id": "1111111111111111111", "account_type": "human", "first_name": "Admin", "last_name": "User" }, "org": { "id": "2222222222222222222", "name": "Example Org" } } ``` **Notes:** - Returns a more limited view than the authenticated version. - If the profile or owner cannot be loaded, `owner` will be `null`. - The secret `invitation_key` is never included in this unauthenticated view. --- ### POST /current/user/invitation/{invitation_id}/accept/ Accept a single pending invitation by its ID. Self-service: authorized by the authenticated invitee's verified email (or user ID), so the secret invitation key is not required. Use this to act on the invitations returned by `GET /current/user/invitations/list/` — for example from a no-org landing page where the user has no email-link token. **Auth:** Required (JWT, validated email) **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{invitation_id}` | string | Yes | The invitation `id` from the invitations list/details response. | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/invitation/1234567890123456789/accept/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true, "invitation": { "id": "1234567890123456789", "inviter": "Jane Doe", "invitee_email": "newuser@example.com", "entity_type": "workspace", "workspace": { "id": "9876543210987654321", "name": "Marketing Team" }, "state": "accepted", "created": "2025-01-15 10:30:00 UTC", "expires": "2025-01-22 10:30:00 UTC" } } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `invitation` | object | The updated invitation resource; `state` is now `accepted`. | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10618` | 406 | "An invalid invitation ID was supplied." | Malformed ID | | `10631` | 406 | "This invitation has already been accepted." / "This invitation can no longer be accepted." | Already accepted by another user, or declined | | `156620` | 406 | "This invitation has expired." | The invitation has expired | | `10630` | 404 | "Invitation not found." | No such invitation | | `127827` | 401 | "You are not authorized to act on this invitation." | The invitation is not addressed to the authenticated user | | `132808` | 403 | "External invitations are not permitted here by policy." | Re-checked against the collaboration policy at acceptance (see `llms/orgs.txt` — *Collaboration Policies*). `params.reason` = `external_invites_denied` or `external_invites_object_denied`. The invitation stays **pending**, not failed. | | `127351` | 500 | "Unable to verify the external invitation policy. Please try again later." | The collaboration policy could not be evaluated (transient — retry). | **Notes:** - Requires a validated email; ownership is verified by matching the authenticated user's verified email (or user ID) to the invitation's invitee. - On success the user is added as a member of the invitation's entity (org, workspace, or share). - Idempotent: re-accepting an invitation you have already accepted returns success. --- ### POST /current/user/invitation/{invitation_id}/decline/ Decline a single pending invitation by its ID. Self-service counterpart to the accept endpoint; authorized by the authenticated invitee's verified email (or user ID), no invitation key required. **Auth:** Required (JWT, validated email) **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{invitation_id}` | string | Yes | The invitation `id` from the invitations list/details response. | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/invitation/1234567890123456789/decline/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true, "invitation": { "id": "1234567890123456789", "inviter": "Jane Doe", "invitee_email": "newuser@example.com", "entity_type": "workspace", "workspace": { "id": "9876543210987654321", "name": "Marketing Team" }, "state": "declined", "created": "2025-01-15 10:30:00 UTC", "expires": "2025-01-22 10:30:00 UTC" } } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `invitation` | object | The updated invitation resource; `state` is now `declined`. | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10618` | 406 | "An invalid invitation ID was supplied." | Malformed ID | | `10631` | 406 | "This invitation can no longer be declined." | Already accepted | | `10630` | 404 | "Invitation not found." | No such invitation | | `127827` | 401 | "You are not authorized to act on this invitation." | The invitation is not addressed to the authenticated user | **Notes:** - A declined invitation is marked declined and no longer appears in `GET /current/user/invitations/list/`; it will not reappear. - No membership is created. Idempotent: re-declining an already-declined (or expired) invitation returns success. --- ### POST /current/user/invitations/acceptall/ Accept all pending invitations. **Auth:** Required (JWT) **Request Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `invitation_key` | string | No | Optional invitation key. If the user's email is not validated, this key can identify invitations. | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/invitations/acceptall/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true, "accepted_invitations": ["1234567890123456789"], "refused": [] } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `accepted_invitations` | array of string | IDs of invitations that were accepted. | | `refused` | array of object | Invitations skipped by the collaboration policy (see `llms/orgs.txt` — *Collaboration Policies*): `{invitation_id, target_type, target_id, reason}`, `reason` is `external_invites_denied` or `external_invites_object_denied`. Always present; empty when nothing was refused. A refused invitation stays **pending**, not failed — no membership is created for it, and it can be retried later if the policy changes. | | `failed_invitations` | array of object | Present only when something went wrong outside the policy (e.g. the policy could not be evaluated, or an unsupported invitation type): `{invitation_key, error}`. | **Notes:** - If email is validated, all pending invitations matching that email are accepted (subject to the collaboration policy above). - If email is not validated, use `invitation_key` to identify invitations. - This is a **partial-success** endpoint: a policy refusal on one invitation does not stop the rest of the batch from being processed. --- ### GET /current/user/invitations/list/ List all pending invitations for the current user. **Auth:** Required (JWT) **Query Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `invitation_key` | string | Conditional | Required when the account's email is not validated (the invitations are then found through this key). Optional for a validated account. | **Request Example:** ```bash curl -X GET "https://api.fast.io/current/user/invitations/list/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true, "invitations": [ { "id": "1234567890123456789", "invitation_key": "{invitation_key}", "inviter": "Jane Doe", "invitee_email": "newuser@example.com", "entity_type": "workspace", "workspace": { "id": "9876543210987654321", "name": "Marketing Team" }, "state": "pending", "created": "2025-01-15 10:30:00 UTC", "expires": "2025-01-22 10:30:00 UTC" } ] } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `invitations` | array | Array of invitation resource objects. | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `174863` | 406 | "A valid invitation key is required or is invalid." | Email not validated and `invitation_key` missing, or `invitation_key` malformed | | `100766` | 406 | "Invitation not found or invalid." | `invitation_key` is well-formed but matches no invitation | **Notes:** - Returns only pending invitations for the current user. - Each invitation embeds its `entity_type` and the entity object (`org`/`workspace`/`share`) so cards can render without an extra details fetch. - `invitation_key` is included for the invitee's own list. Prefer acting on invitations with the by-id `POST /current/user/invitation/{invitation_id}/{accept|decline}/` endpoints, which do not require the key. --- ## User Authentication Endpoints ### Browser Sign-In (email and password) The recommended way for the Fastio web app to sign a person in with email and password. The credential ceremony runs on the Fastio sign-in page (`login.fast.io`); the app starts a sign-in, sends the browser there, and exchanges the one-time code it gets back for a session. Other apps, CLIs and agents use OAuth 2.0 authorization code + PKCE (whose browser step uses the same page) or an API key. ``` 1. start POST /current/user/auth/login/start/ -> login_url 2. sign in navigate the top-level window to login_url 3. callback {return_origin}/signin/callback?code={code}&state={state} (or ?error={reason}&state={state} — see Callback errors below) 4. exchange POST /current/user/auth/login/exchange/ code + code_verifier + state -> session ``` **PKCE and `state`.** Before `start`, generate a `code_verifier` (43-128 characters from `[A-Za-z0-9-._~]`), derive `code_challenge = base64url(sha256(code_verifier))` with no padding (always 43 characters), and generate a random `state`. Keep the verifier and `state` in the browser tab (for example session storage) — never send the verifier until the exchange. On the callback, compare the returned `state` with the one you stored before exchanging. **What happens on the sign-in page.** The user enters email and password, then the 2FA code if the account has 2FA, or enrols a first factor if an organization they belong to requires one. Wrong passwords, lockouts and 2FA errors are shown on the page itself — the app only ever sees a code or a callback error. The page offers a **"Keep me signed in"** choice: when ticked, the browser session cookie issued at the exchange persists across browser restarts for the life of the session; when not ticked, it is a browser-session cookie that ends when the browser closes. The token and its `expires_in` are the same either way, and the choice never skips 2FA. **Timing.** A sign-in must be completed within 10 minutes of `start`. The code in the callback is valid for 60 seconds and can be exchanged once. **An expired sign-in does not call back:** after the 10 minutes the sign-in page shows its own "This sign-in has expired. Return to the app and start again." page (HTTP `410`) and the browser stays there, so the app must keep its own timeout and its own "start again" path. **Reloads and double submits are safe.** If the browser reloads or re-submits the final step while its code is still unexchanged and within its 60 seconds, the page answers with the same `?code={code}&state={state}` callback again rather than an error. A step that is still being processed shows a short "Signing you in" page that reloads itself; if that processing never finishes, the page hands back `error=login_unavailable` (start the sign-in again). **Callback errors.** When the sign-in does not complete, the browser lands on `{return_origin}/signin/callback?error={reason}&state={state}` with no code: | `error` | What happened | What to do | |---------|---------------|------------| | `cancelled` | The user cancelled on the sign-in page. | Return to the signed-out view. | | `sso_required` | The address is on a domain whose organization requires its own single sign-on. The organization's domain arrives as a separate `org={org_domain}` parameter. | Start the enterprise SSO sign-in for that organization (`GET /current/user/sso/start/?org={org_domain}`). | | `txn_expired` | The browser came back to a sign-in that had already finished — it was cancelled, or it completed and its code was already exchanged or is past its 60 seconds. (A sign-in that runs past 10 minutes does not call back at all — see *Timing* above.) | Start again at `start`. | | `login_unavailable` | Sign-in could not be completed right now. | Offer a retry from `start`. | | `account_unavailable` | The account cannot sign in (for example it is suspended, locked or closed). | Show a generic "this account can't sign in" message and point to support. | Treat any other `error` value as a generic failure. Always check `state` before acting on the callback. --- ### POST /current/user/auth/login/start/ Begin a browser sign-in. Returns the sign-in page URL to navigate to. **Auth:** None (IP-throttled) **Request Parameters** (`application/x-www-form-urlencoded`): | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `return_origin` | string | Yes | Origin the browser returns to: `https://go.fast.io`, or an organization's own `https://{org_domain}.fast.io`. Scheme and host only — no path, query or trailing slash. Any other origin is refused. | | `return_path` | string | Yes | Must be exactly `/signin/callback`. | | `code_challenge` | string | Yes | `base64url(sha256(code_verifier))`, no padding (43 characters). | | `code_challenge_method` | string | Yes | Must be `S256`. | | `state` | string | Yes | Opaque random value, returned unchanged on the callback. 1-512 printable ASCII characters, no spaces. | | `login_hint` | string | No | Email address to pre-fill on the sign-in page. Up to 320 characters. | | `break_glass` | boolean | No | `true`/`1` or `false`/`0` (default `false`). Requests the password path that an organization's owner, its admins and its listed exception addresses keep when the organization requires SSO — see *Enterprise SSO Enforcement*. | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/auth/login/start/" \ -d "return_origin=https://go.fast.io" \ -d "return_path=/signin/callback" \ -d "code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" \ -d "code_challenge_method=S256" \ -d "state={random_state}" \ -d "login_hint=jane.doe@example.com" ``` **Success Response (200 OK):** ```json { "result": true, "login_url": "https://login.fast.io/?txn={transaction_id}" } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `login_url` | string | The sign-in page for this sign-in. Navigate the top-level window to it verbatim (not an iframe or popup). | **Error Responses** (standard envelope; branch on `error.params.reason`): | HTTP Status | `params.reason` | Cause | |-------------|-----------------|-------| | 406 | `invalid_return_origin` | `return_origin` is missing or not an allowed origin (including an organization subdomain that does not exist). | | 406 | `invalid_return_path` | `return_path` is not exactly `/signin/callback`. | | 406 | `invalid_challenge` | `code_challenge_method` is not `S256`, or `code_challenge` is not 43 base64url characters. | | 406 | `invalid_state` | `state` is missing, too long, or contains characters outside printable ASCII. | | 406 | `invalid_request` | Another field is invalid — for example `break_glass` is not `true`/`1`/`false`/`0`, or `login_hint` is too long. | | 503 | `login_unavailable` | Sign-in is temporarily unavailable. Retry shortly. | | 429 | — | Too many requests from this address (HTTP 429, standard rate-limit envelope and `x-ve-limit-*` headers). Carries no `params.reason` — branch on the HTTP status. | **Notes:** - A field sent in array shape (e.g. `code[]=x`) is refused with the generic validation error (HTTP 406, an `error.params[]` entry with `kind: "type_mismatch"`), not this endpoint's own `params.reason` contract. - `start` checks no credentials and reveals nothing about whether an account exists. - Each `start` begins a fresh sign-in; an abandoned one simply expires. --- ### POST /current/user/auth/login/exchange/ Turn the one-time code from the callback into a session. Call it from the page that received the callback, same-origin. **Auth:** None (IP-throttled). The code, the PKCE verifier and `state` are the credentials. Send no `Authorization` header. **Request Parameters** (`application/x-www-form-urlencoded`): | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `code` | string | Yes | The `code` query parameter from the callback. | | `code_verifier` | string | Yes | The PKCE verifier whose challenge was sent to `start`. | | `state` | string | Yes | The `state` sent to `start` (and returned on the callback). | **Request Headers:** | Header | Type | Required | Default | Description | |--------|------|----------|---------|-------------| | `x-ve-session-cookie` | boolean | No | (absent) | Browser clients. When truthy (`1`, `true` or `yes`), the session token is also set in the HttpOnly browser session cookie, exactly as on `GET /current/user/auth/`. The cookie persists across browser restarts only when the user ticked "Keep me signed in"; otherwise it is a browser-session cookie. Honoured only as a request header. | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/auth/login/exchange/" \ -H "x-ve-session-cookie: 1" \ -d "code={code}" \ -d "code_verifier={code_verifier}" \ -d "state={state}" ``` **Success Response (200 OK):** ```json { "result": true, "expires_in": 2592000, "auth_token": "{jwt_token}", "2factor": false, "enrol_required": false, "email": "jane.doe@example.com" } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `auth_token` | string | A full session token (2FA, when required, was already completed on the sign-in page). Send it as `Authorization: Bearer {auth_token}`. Revocable: `POST /current/user/auth/sign-out/` and `POST /current/user/auth/invalidate-all/` both end it. | | `expires_in` | integer | Seconds until the token expires. Read it rather than assuming a lifetime. | | `2factor` | boolean | Always `false` — the sign-in page completes 2FA before issuing a code. Present so clients that read the `GET /current/user/auth/` shape keep working. | | `enrol_required` | boolean | Always `false`, for the same reason. | | `email` | string | The signed-in account's email address. | **Error Responses** (standard envelope; branch on `error.params.reason`): | HTTP Status | `params.reason` | Cause | |-------------|-----------------|-------| | 400 | `code_invalid` | The code is missing, unknown or older than 60 seconds. Start again at `start`. | | 400 | `code_already_used` | The code was already exchanged. Start again at `start`. | | 400 | `verifier_mismatch` | `code_verifier` does not match the `code_challenge` sent to `start`. | | 400 | `state_mismatch` | `state` does not match the one sent to `start`. | | 403 | `account_unavailable` | The account refused the session after the password step: its sessions were revoked (a password change or sign-out everywhere), it turned on 2FA after the code was issued, or it is suspended, locked or closed. Start again at `start`. | | 503 | `login_unavailable` | Sign-in could not be completed right now (a temporary outage). The code may already be spent: start the sign-in again at `start` — never retry the same code. | | 429 | — | Too many requests from this address (HTTP 429, standard rate-limit envelope and `x-ve-limit-*` headers). Carries no `params.reason` — branch on the HTTP status. | **Notes:** - A field sent in array shape (e.g. `code[]=x`) is refused with the generic validation error (HTTP 406, an `error.params[]` entry with `kind: "type_mismatch"`), not this endpoint's own `params.reason` contract. - **A code is single-use and short-lived.** Exchange it as soon as the callback page loads. Any refusal other than a `429` means starting over at `start` — a `503` included: the code is spent before the account is re-checked, so repeating it can only return `code_already_used`. - **The session is a normal revocable account session**, equivalent to one from `GET /current/user/auth/?revocable=true` — sign-out, invalidate-all, OAuth consent and the browser session cookie all work with it unchanged. - **Use `POST /current/user/auth/bootstrap/` on later page loads** to bring the cookie's token back into memory, exactly as for any other cookie-backed session. --- ### GET /current/user/auth/ **Deprecated — will be retired.** Authenticate via HTTP Basic Auth and receive a JWT. This still works today, but email-and-password sign-in is moving to the browser: use [Browser Sign-In](#browser-sign-in-email-and-password) for the Fastio web app, OAuth 2.0 authorization code + PKCE for apps, CLIs and agents, or an API key for unattended access. When it is retired, a Basic sign-in here will answer `403` with `error.params.reason` = `password_login_browser_required`. **Auth:** HTTP Basic Auth (`email:password`) **Query Parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `expires` | integer | No | Server default | Custom JWT expiration time in seconds from now. Capped at one year; a larger value is rejected with `10454`. | **Request Headers:** | Header | Type | Required | Default | Description | |--------|------|----------|---------|-------------| | `x-ve-session-cookie` | boolean | No | (absent) | Browser clients only. When truthy (`1`, `true` or `yes` — the same vocabulary as `revocable`), the issued token is ALSO returned in an HttpOnly, Secure, `SameSite=Lax` cookie scoped to the site's registrable domain (e.g. `fast.io`), with the same lifetime as the token. `auth_token` is still returned in the body. On later page loads, bring the cookie's token back into memory with `POST /current/user/auth/bootstrap/`. The opt-in is only honoured as a request header — there is no URL or body parameter equivalent. On a 2FA-enabled account no cookie is set here — see the notes below. | **Request Example:** ```bash curl -X GET "https://api.fast.io/current/user/auth/" \ -u "jane.doe@example.com:$PASSWORD" ``` **Request Example (browser session cookie):** ```bash curl -X GET "https://api.fast.io/current/user/auth/" \ -u "jane.doe@example.com:$PASSWORD" \ -H "x-ve-session-cookie: 1" \ --cookie-jar cookies.txt ``` **Success Response (200 OK):** ```json { "result": true, "expires_in": 2592000, "auth_token": "{jwt_token}", "2factor": false, "enrol_required": false } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `expires_in` | integer | JWT token expiration time in seconds. | | `auth_token` | string | JWT access token. If 2FA is enabled, has `twofactor` scope (restricted). If an org requires 2FA and the account holds none, has `enrol` scope (restricted to the enrolment surfaces — see *Interactive Login & Enrolment*). Otherwise has `user` scope (full access). | | `2factor` | boolean | `true` if 2FA is enabled, OR if `enrol_required` is `true` — so a client that does not yet read `enrol_required` still shows its existing code-entry screen rather than treating the login as complete. | | `enrol_required` | boolean | **Always present.** `true` only when an org this account belongs to requires 2FA and the account holds none. When `true`, `auth_token` is an enrolment token, not a session — see *Interactive Login & Enrolment*. | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10454` | 405 | "The expires time specified is invalid." | Invalid `expires` parameter | | `10001` | 401 | "Your credentials were not supplied or invalid." | Missing Basic Auth header | | `10004` | 401 | "Username is not valid." | Invalid email format | | `10005` | 401 | "Password is not valid." | Invalid password format | | `10008` | 401 | "Your credentials supplied are invalid." | Wrong password, unrecognized email, or SSO-only account (no password set) — identical response and matched timing prevent account enumeration. Carries `error.params.attempts_remaining` + `attempts_max` when known | | `10105` | 401 | "Your account is suspended..." | Account suspended (only after a correct password) | | `10104` | 401 | "Your account is locked..." | Account locked | | `10106` | 401 | "Your account is suspended due to abuse." | Account flagged for abuse | | `10103` | 401 | "Your account is closed by you." | Account closed | | `10103` | 401 | "This account has not been claimed yet." | The address belongs to a placeholder account created by an invitation that has never been claimed. Same code as "account closed" — **the message is the only thing that distinguishes them**, so branch on the message, not the code. The remedy is different too: this one clears by accepting the invitation, not by contacting support | | `10760` | 429 | "Too many failed sign-in attempts. Try again in N minutes." | Too many consecutive failed sign-in attempts for this account — a temporary, self-clearing lockout | | `170745` | 503 | "Your organization's sign-in policy could not be read. Please try again." | An org's Require-2FA policy governing this account could not be resolved, so the sign-in was neither completed nor refused. **Retryable, and NOT a credential failure** — see the note below | **Notes:** - Email tags (e.g., `user+tag@example.com`) are stripped before lookup. - If 2FA is enabled, complete the 2FA verification flow to upgrade the token. - **A failed sign-in reports how many attempts remain.** The `401` response carries `error.params.attempts_remaining` — the number of further failures this account tolerates before it is temporarily locked — alongside `error.params.attempts_max`, the threshold in force. Use both to render "2 of 5" rather than hard-coding the limit, which can change. **Both fields are absent when the count is unknown** (it could not be recorded); treat absence as "unknown" and show a plain invalid-credentials message rather than assuming zero. Neither value carries an account-existence signal: the counter is keyed on the submitted address, so an unregistered email reports the same countdown as a registered one. - **Repeated failed sign-in attempts temporarily lock the account.** After too many consecutive failures this endpoint returns `429` with `error.code` **`10760`**, and the response body carries `error.params.retry_after_seconds` — the number of seconds until sign-in is accepted again. Wait that long before retrying; further attempts during the lockout do not extend it, but they do not succeed either. A successful sign-in clears the failure count. - **Do not confuse this with "Your account is locked" (`401`).** That one is an administrative lock that a client cannot wait out and requires contacting support; `10760` clears by itself. - **Do not confuse it with the per-IP rate limit**, which also returns `429` but with `error.code` `10368` and reflects request volume from your address rather than failed credentials for one account. - Clients should not retry sign-in automatically on a `429`; an automatic retry consumes the account's remaining attempts without user intervention. - Account enumeration is intentionally not possible: an unknown email, an SSO-only account (no password), and a wrong password all return the identical `401`/`error.code` `10008`/"Your credentials supplied are invalid." response with matched timing. SSO-only accounts must sign in via their provider instead. - **An address on an enforced domain cannot sign in here.** When the address belongs to a domain verified by an organization whose SSO mode is `required`, this endpoint answers `403` with `error.params.reason` = `sso_required` and a `start_path` to send the browser to instead — before the password is examined, and identically for every address on that domain. The organization's owner, its admins, and any address on its enforcement exception list keep a password path at `GET /current/user/auth/?break_glass=true`, where the password *is* examined first and a failure counts toward the lockout above. See *Enterprise SSO Enforcement*. - **The `x-ve-session-cookie` header additionally delivers the token as an HttpOnly cookie.** It is opt-in per request: omit it — which is what every non-browser client does — and the response and behaviour are unchanged in every respect. The cookie carries the SAME token as `auth_token`, not a second credential, and expires when that token does. The opt-in is only honoured as a request header; sending it in the query string or the body does nothing. - **A 2FA-enabled account gets no cookie from this call.** Sign-in returns a pre-2FA token (`"2factor": true`), which is not a completed session and is never put in a cookie. Send `x-ve-session-cookie` again on `POST /current/user/auth/2factor/auth/{token}/`; that call issues the cookie. - **A `503` from this endpoint is NOT a failed credential — never count it as one.** When an org's Require-2FA policy cannot be read, sign-in answers `503` ("Your organization's sign-in policy could not be read. Please try again.") instead of guessing: issuing a session could hand out one the policy forbids, and forcing enrolment would march the user through one they were never subject to. **This is retryable.** Back off briefly and re-send the same request unchanged. The submitted password was never the problem, so this outcome must **not** be rendered as "wrong password", must **not** decrement `attempts_remaining`, and must **not** feed a client-side attempt counter — a client that treats every non-`200` as a bad credential will lock a user out of their own account over a transient condition. Only an account that holds no second factor can ever see it; an already-enrolled account never consults the policy. See *Interactive Login & Enrolment* below. - **An `enrol_required: true` login is a different case from an already-2FA-enabled account**, even though both set `"2factor": true` and neither gets a cookie here. The 2FA-enabled case has a factor to challenge; the enrolment case does not — the account has never enrolled one, and `auth_token` is an `enrol`-scoped token that can only enrol a first factor, not challenge an existing one. See *Interactive Login & Enrolment* below for the flow and what the token can and cannot do. --- ### POST /current/user/auth/bootstrap/ Return the session token held in the browser's HttpOnly cookie. A browser that signed in with the `x-ve-session-cookie` header calls this on page load to bring that token into memory, then uses it in the `Authorization` header for every other request. This is the ONLY endpoint in the API that authenticates from a cookie — every other endpoint still requires `Authorization: Bearer {token}`, unchanged. The point of the arrangement is that the durable session credential never has to live anywhere JavaScript can read it, except in memory once bootstrap hands it back. **Auth:** The HttpOnly session cookie ONLY, sent automatically by the browser. A request that carries an `Authorization` header instead is rejected with `401` — a caller that already holds a token does not need to bootstrap. **Request Parameters:** None. **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/auth/bootstrap/" \ --cookie cookies.txt ``` **Success Response (200 OK):** ```json { "result": true, "id": "1234567890123456789", "auth_token": "{jwt_token}", "expires_in": 2591990, "email_verification_required": false } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `id` | string | The 19-digit numeric user ID the cookie authenticated as. | | `auth_token` | string | The session token held in the cookie — the same credential the cookie carries, not a separately issued one. | | `expires_in` | integer | Seconds of remaining life on the token. Always read this rather than assuming a fixed duration; the lifetime can vary. `0` (or a `401` on a later call) means the session is finished and the user must sign in again. | | `email_verification_required` | boolean | `true` when the account must still verify its email address. Always `false` for agent accounts, which are exempt from the verification gate (listing invitations by email and filing bug reports still need a verified address). The session is valid, but endpoints that require a verified account return `401` with error `10587` until it does — send the user to the verification-code step (`POST /current/user/email/validate/`) instead of into the app. `false` for verified accounts. | | `email` | string | The account's email address. Only present when `email_verification_required` is `true` and an address is on file, so the verification step can resend and submit the code without asking the user to re-enter it. | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `100966` | 401 | "This endpoint requires a browser session cookie." | The request authenticated with an `Authorization` header, or carried no session cookie at all | **Notes:** - **This returns the cookie's own token — nothing is minted.** `auth_token` is the same credential the cookie is already holding, and `expires_in` reports that credential's own remaining life, not a separate short-lived window. - **Call it once per page load.** Since bootstrap hands back the existing token rather than issuing a new one, there is no obligation to call it again before that token expires — bring it into memory once per page load, and call it again after a `401` if you need to confirm whether the underlying session is still valid. - **POST only.** A `GET` returns the standard `405 Method Not Allowed`. POST is required because `SameSite=Lax` still sends cookies on cross-site top-level `GET` navigations, and this response body contains a bearer token. - **Call it same-origin from whatever page is making the request.** The cookie is scoped to the site's registrable domain (e.g. `fast.io`) rather than a single host, so it travels to every subdomain on that domain — including one different from wherever sign-in happened. The API still sends `Access-Control-Allow-Origin: *` and never `Access-Control-Allow-Credentials`, so a browser will not expose this response to a script running on a different origin than the one that made the request. - Nothing else about the API changes: after bootstrapping, send `Authorization: Bearer {auth_token}` on every other call exactly as before. - **An unverified account still bootstraps.** A session whose email is not yet verified gets `200` with `email_verification_required: true` and its `email`, rather than a `10587` error, so a reloaded browser can return the user to the verification-code step. Endpoints that require a verified account still refuse the returned token (`10587`). - Rate limited per user. A browser legitimately calls this once per page load and once per restored tab. - The cookie only exists if the client opted in with the `x-ve-session-cookie` header at sign-in — or, on a 2FA-enabled account, at 2FA completion. Without it there is nothing to bootstrap. - `POST /current/user/auth/sign-out/` clears the cookie, so a signed-out browser gets the `401` above on its next bootstrap. --- ### GET /current/user/auth/check/ Validate current JWT and get user ID. **Auth:** Required (Bearer token) **Request Example:** ```bash curl -X GET "https://api.fast.io/current/user/auth/check/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true, "id": "1234567890123456789" } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `id` | string | The 19-digit numeric user ID. | **Notes:** - Lightweight health-check for token validity. Validates the token and its scope (a pre-2FA `twofactor` token is accepted here), checks that the account is available, and rejects an enrolment token with `403` and a `params.reason`. --- ### GET /current/auth/scopes/ Token scope introspection. Returns information about the current token's scope, auth type, and agent status. **Auth:** Required (Bearer token) **Request Example:** ```bash curl -X GET "https://api.fast.io/current/auth/scopes/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true, "auth_type": "jwt_v2", "scopes": ["org:9876543210987654321:rwa", "workspace:1234567890123456789:rw"], "scopes_detail": [ {"entity_type": "org", "entity_id": "9876543210987654321", "access_mode": "rwa", "admin": true}, {"entity_type": "workspace", "entity_id": "1234567890123456789", "access_mode": "rw", "admin": false} ], "is_agent": true, "agent_name": "My MCP Agent", "full_access": false, "admin": true, "legacy": false } ``` **Response Fields:** `auth_type`, `scopes`, `is_agent`, `agent_name`, `full_access`, `admin`, `legacy` and `scopes_detail` are always present (plus `expires` for an API key that has one). | Field | Type | Description | |-------|------|-------------| | `auth_type` | string | Token type: `"jwt_v2"` (scoped JWT, or a sign-in session that declared `agent_name` — still unscoped, so `legacy` stays `true`), `"jwt_v1"` (legacy JWT, which is what a browser login session without a declared agent is), `"api_key"` (legacy API key that declares no scopes claim), or `"api_key_scoped"` (API key with scopes). An OAuth grant for `scope=user` is now stored explicitly as `["user:*:rw"]` and reports `jwt_v2` — do not branch on `auth_type === "jwt_v1"` to detect it. | | `scopes` | array | Array of scope strings in `entity_type:entity_id:access_mode` format. Empty for a legacy credential (a browser login session — including one that declared `agent_name` — or a legacy API key), which declares no scopes claim at all. | | `scopes_detail` | array | Hydrated scope details with entity information, one entry per scope. Empty when `scopes` is empty. | | `scopes_detail[].entity_type` | string | e.g. `user`, `org`, `workspace`, `share`, `userdetails`. | | `scopes_detail[].entity_id` | string | The entity id, or `*` for a wildcard grant. | | `scopes_detail[].access_mode` | string | `r`, `rw` or `rwa`. | | `scopes_detail[].admin` | boolean | Whether this scope confers administration (`access_mode` is `rwa`), so a client need not re-parse the access mode. Each entry also carries the existing hydrated entity name/label fields. | | `is_agent` | boolean | Whether the token represents an agent. `true` for a sign-in session that declared `agent_name` at `GET /current/user/auth/` (the name is reported in `agent_name`). | | `agent_name` | string or null | Agent display name. `null` if not set or not an agent. | | `full_access` | boolean | Whether the credential is account-wide **and may write** — i.e. `user:*:rw` or `user:*:rwa`, or a legacy credential. **`user:*:r` is `false`**, even though it reads the whole account. `full_access` says nothing about administration; read `admin` for that. | | `admin` | boolean | Whether the credential can perform administrative operations. `true` for a browser login session and for any credential holding an `rwa` scope. Still capped per request by the human's live role on the entity. | | `legacy` | boolean | Whether the credential declares no scopes claim at all — a pre-scopes API key or OAuth session, **and every browser login session**. `legacy` never implies `admin`, and `admin` never implies `legacy`. | **Matrix:** | Credential | `full_access` | `admin` | `legacy` | `auth_type` | |---|---|---|---|---| | `user:*:rwa` | true | true | false | `jwt_v2` / `api_key_scoped` | | `user:*:rw` | true | false | false | `jwt_v2` / `api_key_scoped` | | `user:*:r` | false | false | false | `jwt_v2` / `api_key_scoped` | | scoped, e.g. `org:123:rwa` | false | true | false | `jwt_v2` / `api_key_scoped` | | legacy key with no scopes | true | false | true | `api_key` | | browser login session | true | true | true | `jwt_v1` | | sign-in session that declared `agent_name` | true | true | true | `jwt_v2` | Wildcard scopes are labelled in `scopes_detail`: `user:*:rw` is "Full Access", `user:*:rwa` is "Full Access (Admin)", `user:*:r` is "Entire account (Read Only)", `userdetails:*:rw` is "Account settings", and other wildcards read like "All Organizations (Read/Write/Admin)". **Error Response (401 Unauthorized) — the token could not be verified:** ```json { "result": false, "error": { "code": 154843, "text": "The supplied token could not be verified.", "params": { "reason": "verification_failed" } } } ``` A bearer token that cannot be decoded returns `401` with `error.params.reason` set to `verification_failed`. **This is never answered with a `200`.** Treat it as "this token was not checked", NOT as "this token has no rights" — a successful response always describes a token that was read; it never reports the absence of scopes because verification failed. `reason` carries a single value here on purpose. The underlying cause — an expired credential, a forged or malformed one, or key material being temporarily unavailable — is not distinguishable at this layer, and reporting a guessed cause would be worse than reporting that it is unknown. **Branch on `reason`, never on `error.code`** (the numeric code identifies the call site and is not a stable contract). --- ### API Keys #### POST /current/user/auth/key/ Create a new API key. **Auth:** Required (JWT, scope: `user` or `admin`) **Request Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `memo` | string | No | Label/description for the key. | | `scopes` | string | No | JSON array of scope strings (e.g., `["org:123:rw", "workspace:456:r"]`). See the `scopes` handling table below. Omitting it stores the explicit `["user:*:rw"]` — whole-account read and write, **no admin and no account settings** — so an unscoped key is never created any more. An explicit empty array (`[]`) is refused with `190363`. | | `agent_name` | string | No | Agent or application name for tracking. Max 128 characters. | | `expires` | string | No | Expiration datetime. Accepts any `strtotime`-compatible value; canonical form is `Y-m-d H:i:s UTC` (e.g. `2026-12-31 23:59:59 UTC`). Must be in the future. Omit or `null` for no expiration. | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/auth/key/" \ -H "Authorization: Bearer {jwt_token}" \ -d "memo=CI/CD Pipeline Key" ``` **Request Example (scoped key with expiration):** ```bash curl -X POST "https://api.fast.io/current/user/auth/key/" \ -H "Authorization: Bearer {jwt_token}" \ -d "memo=Workspace Agent" \ -d 'scopes=["workspace:1234567890123456789:rw"]' \ -d "agent_name=my-agent" \ -d "expires=2026-12-31 23:59:59 UTC" ``` **`scopes` handling (create and update):** | Submitted | On create (`POST .../key/`) | On update (`POST .../key/{key_id}/`) | |-----------|-----------------------------|--------------------------------------| | omitted | stored as `["user:*:rw"]` | left as-is | | `""` or `"null"` | stored as `["user:*:rw"]` | **clears** to `["user:*:rw"]` | | `"[]"` | refused, `406` `190363` | refused, `406` `190363` | | malformed JSON, an object, a non-string member, or an invalid scope string | `406` `197558` | `406` `121158` | | a non-empty JSON list of valid scope strings | issuance checks below | issuance checks below | **Issuance checks**, applied in this order to the final effective scope set (the same three on create and on update): 1. **Empty set** → `406` `190363`, "The scopes provided must contain at least one scope." 2. **Broader than the calling credential** → `403` `10768`, `params.reason` = `scope_exceeds_issuer`, "The requested scopes are broader than the credential making this request." A browser login session skips this check — it is unbounded. **A credential cannot widen itself**, so issuing a key with `rwa` or `userdetails:*:rw` requires a signed-in web session or a credential that already holds them. 3. **Not grantable to this user** → `406` `185003` — the human does not hold the entity, or the scope string is not issuable at all (e.g. `userdetails:*:rwa`, `fileshare:*:rw`). 4. **Exceeds the governing org's credential policy** → `403`, `params.reason` = `credential_policy_mode` or `credential_policy_scope`, "The requested scopes exceed this organization's credential policy." Each attributable scope is checked against the policy of **its own** owning org, and a policy that is corrupt, names a deleted organization, or could not be read answers with its own reason instead (`credential_policy_unreadable`, `credential_policy_org`, `credential_policy_unavailable`). See *Org Credential Policy* above. **Success Response (200 OK):** ```json { "result": true, "api_key": "{the raw key, shown only here}", "key": { "id": "{key_id}", "memo": "Workspace Agent", "scopes": "[\"workspace:1234567890123456789:rw\"]", "agent_name": "my-agent", "created": "2026-09-09 14:03:11 UTC", "expires": "2026-12-31 23:59:59 UTC", "admin": false, "legacy": false, "api_key": "****************************abcd" } } ``` **Response Fields:** `api_key` is the secret. `key` is the created row, in the same shape `GET .../key/{key_id}/` and the update call return — including its own **masked** `api_key`. The two are siblings so that reading the secret stays exactly as simple as it was. | Field | Type | Description | |-------|------|-------------| | `api_key` | string | The raw key. **Only shown here, only once** — it is never retrievable again, so store it before you discard the response. | | `key` | object | The created key. Carries `id`, `memo`, `scopes`, `agent_name`, `created`, `expires`, `admin`, `legacy` and a masked `api_key`. | | `key.id` | string | The key id, for the update, read and delete calls. | | `key.scopes` | string | The stored scopes claim, as a JSON list string. | | `key.admin` | boolean | Whether the key's scopes confer administration. A `user:*:rw` key is **not** admin. | | `key.legacy` | boolean | Whether the key predates scoped keys. `legacy` never implies `admin`. | > `key` is a **new** field and the addition is backwards compatible: `api_key` is unchanged and still the raw string. Use `key` when you need the new key's `id`, `created` or `expires` without a second call. > `key` is **best effort**. It is built by reading the stored row back, and on the rare occasion that read fails the response is `{"result": true, "api_key": "…"}` with **no `key` field** — the key is created and the secret is still returned, because losing the one-time secret to a failed convenience lookup would be the worse outcome. Treat `key` as optional. If it is absent and you need the row, list your keys with `GET /current/user/auth/keys/` and match on `created` — the degraded response carries the secret only, so there is no `id` in it to read back by. **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10011` | 401 | "Your credentials were not supplied or invalid." | Missing or invalid JWT | | `10175` | 403 | "The scope of your credentials are not sufficient." | JWT scope not `user` or `admin` | | `10770` | 403 | "Your credential is read-only and is not authorized to make changes." | The calling credential holds no write-capable scope anywhere — `user:*:r`, or a set every entry of which is `:r` (`params.reason` = `scope_write_required`) — see [Scope errors](#scope-errors-administration-and-account-settings) | | `10015` | 429 | "You are at the maximum number of API keys, {max}." | Maximum key limit reached | | `10016` | 406 | "You provided an invalid Memo." | Invalid memo format | | `190363` | 406 | "The scopes provided must contain at least one scope." | `scopes` was `"[]"`, or the effective set resolved to empty | | `197558` | 406 | Various | `scopes` was malformed JSON, an object, contained a non-string member, or contained an invalid scope string | | `115641` / `198032` / `103681` | 406 | Various | `115641` invalid or too-long `agent_name`; `198032` `agent_name` is reserved; `103681` invalid/past `expires` | | `10768` | 403 | "The requested scopes are broader than the credential making this request." | The requested scopes exceed the calling credential (`params.reason` = `scope_exceeds_issuer`) — see [Scope errors](#scope-errors-administration-and-account-settings) | | `185003` | 406 | Various | A requested scope is not grantable to this user — the human does not hold the entity, or the scope string is not issuable | | `158000` | 403 | "The requested scopes exceed this organization's credential policy." | A requested scope exceeds the governing org's `credential_policy` (`params.reason` = `credential_policy_mode` or `credential_policy_scope`) — see [Org Credential Policy](#org-credential-policy) | | `158000` | 403 | "This organization's credential policy could not be read, so no credential may be issued for it." | The governing org's stored `credential_policy` is corrupt (`params.reason` = `credential_policy_unreadable`) — permanent | | `158000` | 403 | "This grant names an organization that no longer exists." | The requested grant names an entity whose owning organization has been deleted (`params.reason` = `credential_policy_org`) — permanent | | `158000` | 503 | "The organization credential policy is temporarily unavailable. Please try again shortly." | The governing org, or its policy, could not be READ (`params.reason` = `credential_policy_unavailable`) — the one case worth retrying | **Notes:** - 2FA verification is required if 2FA is enabled. - The full key value is only returned at creation time. Subsequent reads return masked versions. - **A read-only credential cannot mint or edit a key at all** (`403` `10770`). Containment alone does not stop it — containment refuses a key BROADER than the issuer, and `user:*:r` covers `["user:*:r"]`, so without this a read-only credential could leave behind a long-lived key that outlives revocation of the session that made it. - A key created without `scopes` holds `["user:*:rw"]`: the same authority a legacy key had — whole-account read and write, but **not** administration and **not** account settings. Ask for `rwa` scopes if the key must administer, and add `userdetails:*:rw` if it must change account settings. --- #### GET /current/user/auth/key/{key_id}/ Get details of an API key (key value is masked). **Auth:** Required (JWT, scope: `user` or `admin`) **Scope visibility.** A **write-capable account-wide** credential (`user:*:rw` or `user:*:rwa`), a **legacy** key, and a signed-in web session see every key on the account. Every other credential — one created with entity `scopes`, and also `user:*:r` — sees only the keys **its own grant contains** — the same containment rule `POST /current/user/auth/key/` applies to a mint. A key outside that grant is not visible and answers `403` `10175`, exactly as before. This is what lets a scoped agent inspect and revoke the keys it creates; it never widens what a credential can reach. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{key_id}` | string | Yes | The API key's unique identifier. | **Request Example:** ```bash curl -X GET "https://api.fast.io/current/user/auth/key/aB3dE5fG7hJ9kL1mN2pQ/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true, "api_key": { "id": "aB3dE5fG7hJ9kL1mN2pQ", "api_key": "****************************ab12", "memo": "CI/CD Pipeline Key", "created": "2024-01-15 10:30:00 UTC", "scopes": "[\"workspace:1234567890123456789:rw\"]", "agent_name": "my-agent", "expires": "2026-12-31 23:59:59 UTC", "admin": false, "legacy": false, "last_used": null, "last_ip": null, "last_country": null } } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `api_key.id` | string | Unique key identifier. | | `api_key.api_key` | string | Masked API key (only last 4 characters visible). | | `api_key.memo` | string | Key description/label. | | `api_key.created` | string | Key creation timestamp in UTC. | | `api_key.scopes` | string or null | JSON array of scope strings, or `null` for a legacy key that declares no scopes claim (which behaves as `user:*:rw`). | | `api_key.agent_name` | string or null | Agent/application name, or `null` if not set. | | `api_key.expires` | string or null | Expiration datetime in canonical `Y-m-d H:i:s UTC` format, or `null` for no expiration. | | `api_key.admin` | boolean | Whether the key carries at least one `rwa` scope, i.e. can perform administrative operations (still capped per request by the human's live role). | | `api_key.legacy` | boolean | Whether the key declares no scopes claim at all. `legacy` never implies `admin`, and `admin` never implies `legacy`. | | `api_key.last_used` | string or null | When this key was last used to authenticate a request, `Y-m-d H:i:s UTC`. Written periodically, not on every request. `null` for a key never used since usage tracking began. | | `api_key.last_ip` | string or null | The client IP of that last use. Same write cadence as `last_used`. | | `api_key.last_country` | string or null | ISO-3166 alpha-2 country of that last use (`XX` unknown, `T1` Tor). Same write cadence as `last_used`. | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10019` | 406 | "You provided an invalid Token to get details of." | Invalid key ID format | | *(none)* | 404 | *(no error body — `result: false` only)* | Key does not exist at all — no `error.code` is returned in this case; a key that exists but belongs to a different user instead returns `error.code` **199646** with the same "The API Key was not found." message | | `10175` | 403 | "The scope of your credentials are not sufficient." | The key exists and is yours, but its scopes are not contained in your credential's grant — see **Scope visibility** above | --- #### POST /current/user/auth/key/{key_id}/ Update an existing API key's memo, scopes, agent_name, and/or expires. **The verb is `POST` — there is no `PUT` on this path.** **Auth:** Required (JWT, scope: `user` or `admin`) **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{key_id}` | string | Yes | The API key's unique identifier. | **Request Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `memo` | string | No | Updated label/description for the key. | | `scopes` | string | No | JSON array of scope strings. Send empty string or `"null"` to **clear to `["user:*:rw"]`** — whole-account read and write, no admin and no account settings. Clearing does not restore administration. See the `scopes` handling table under `POST /current/user/auth/key/`. | | `agent_name` | string | No | Agent/application name. Send empty string or `"null"` to clear. Max 128 characters. | | `expires` | string | No | Expiration datetime. Accepts any `strtotime`-compatible value; canonical form is `Y-m-d H:i:s UTC` (e.g. `2026-12-31 23:59:59 UTC`). Must be in the future. Send empty string or `"null"` to clear (no expiration). | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/auth/key/aB3dE5fG7hJ9kL1mN2pQ/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'scopes=["org:1234567890123456789:r"]' \ -d "agent_name=updated-agent" ``` **Success Response (200 OK):** ```json { "result": true, "api_key": { "id": "aB3dE5fG7hJ9kL1mN2pQ", "api_key": "****************************ab12", "memo": "CI/CD Pipeline Key", "created": "2024-01-15 10:30:00 UTC", "scopes": "[\"org:1234567890123456789:r\"]", "agent_name": "updated-agent", "expires": null, "admin": false, "legacy": false } } ``` The returned key object carries the same `admin` and `legacy` booleans documented under `GET /current/user/auth/key/{key_id}/`. **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `187851` or `100527` | 404 | "The API Key was not found." | `187851` when the key does not exist at all; `100527` when it exists but belongs to another user | | `121158` / `107184` / `163622` | 406 | Various | `121158` invalid `scopes` JSON (malformed, an object, a non-string member, or an invalid scope string); `107184` invalid `agent_name`; `163622` invalid/past `expires` | | `192940` / `153354` | 406 | Various | `192940` invalid `memo`; `153354` `agent_name` is reserved | | `181408` | 409 | "The scopes of this API Key changed while this request was in flight. Re-read the key and retry." | The key's scopes were changed by another request between the read and the write; re-read the key and retry | | `190363` | 406 | "The scopes provided must contain at least one scope." | `scopes` was `"[]"`, or the effective set resolved to empty | | `10770` | 403 | "Your credential is read-only and is not authorized to make changes." | The calling credential holds no write-capable scope anywhere — `user:*:r`, or a set every entry of which is `:r` (`params.reason` = `scope_write_required`) — see [Scope errors](#scope-errors-administration-and-account-settings) | | `10768` | 403 | "The requested scopes are broader than the credential making this request." | The key's effective scopes exceed the calling credential (`params.reason` = `scope_exceeds_issuer`) — see [Scope errors](#scope-errors-administration-and-account-settings) | | `185003` | 406 | Various | A scope in the effective set is not grantable to this user — the human does not hold the entity, or the scope string is not issuable | | `158000` | 403 | "The requested scopes exceed this organization's credential policy." | The key's effective scopes exceed the governing org's `credential_policy` (`params.reason` = `credential_policy_mode` or `credential_policy_scope`) — see [Org Credential Policy](#org-credential-policy) | | `158000` | 403 | "This organization's credential policy could not be read, so no credential may be issued for it." | The governing org's stored `credential_policy` is corrupt (`params.reason` = `credential_policy_unreadable`) — permanent | | `158000` | 403 | "This grant names an organization that no longer exists." | The effective scopes name an entity whose owning organization has been deleted (`params.reason` = `credential_policy_org`) — permanent | | `158000` | 503 | "The organization credential policy is temporarily unavailable. Please try again shortly." | The governing org, or its policy, could not be READ (`params.reason` = `credential_policy_unavailable`) — the one case worth retrying | **Notes:** - Only the fields you send are updated; omitted fields remain unchanged. - Send empty string or `"null"` to clear a nullable field. For `scopes`, "clearing" means storing the explicit `["user:*:rw"]`, not removing the scopes claim. - **The containment check runs on EVERY update, including a metadata-only edit** such as changing `expires` or `memo`. The key's stored scopes are the effective set being re-authorized, so a credential that no longer covers them cannot edit the key at all — it is refused with `403` `10768` even though the request did not touch `scopes`. Edit such a key from a signed-in web session, or from a credential that covers its scopes. - The **four** issuance checks (empty set `190363`, broader-than-issuer `10768`, not-grantable `185003`, and the org credential policy — branch on `params.reason`) apply here in the same order as on create. - The org credential-policy check is not limited to this update call — it is re-evaluated on **every** later request the key makes, not only on a scope edit. See *Org Credential Policy* above. - 2FA verification is required if 2FA is enabled. - The update path is taken only when `{key_id}` is a well-formed key identifier, and the regenerate path only on a well-formed `{key_id}/regenerate`. A `POST` whose tail is neither — a malformed id, `regenerate` with no id, or extra path segments — is refused outright with `406` `10019`, "You provided an invalid Token."; it no longer falls through to mint a brand-new key. Always confirm the `{key_id}` you send is valid before treating a call as an update. --- #### POST /current/user/auth/key/{key_id}/regenerate/ Regenerate (rotate) an API key's secret in place. Issues a **new** secret for the **same** key — `id`, `memo`, `scopes`, `agent_name`, `expires`, `created` and the usage fields (`last_used`, `last_ip`, `last_country`) are unchanged. The previous secret stops working immediately. **Auth:** Required (JWT, scope: `user` or `admin`, or an API key) **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{key_id}` | string | Yes | The API key's unique identifier. | No other parameters. If 2FA is enabled on the account, the `token` parameter (2FA code) is required, exactly as for `DELETE /current/user/auth/key/{key_id}/`. **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/auth/key/aB3dE5fG7hJ9kL1mN2pQ/regenerate/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true, "api_key": "{the new raw key, shown only here}", "key": { "id": "aB3dE5fG7hJ9kL1mN2pQ", "api_key": "****************************ab12", "memo": "CI/CD Pipeline Key", "scopes": "[\"user:*:rw\"]", "agent_name": null, "created": "2026-10-05 16:37:29 UTC", "expires": null, "last_used": null, "last_ip": null, "last_country": null, "admin": false, "legacy": false } } ``` `api_key` is the only copy of the new secret. `key` is the same masked key object `GET .../key/{key_id}/` returns, re-read after the rotation; on a rare server-side read-back failure `key` may be absent — the new secret is still valid and already active. See *Response Fields* under `POST /current/user/auth/key/` (create) for the field shapes. **Issuance checks.** A new secret is an issuance of the key's existing grant, so it runs the **same four issuance checks** as an update, re-checked against the key's **stored** scopes — the request itself carries no `scopes`: empty/malformed set (`406` `190363`), broader-than-issuer (`403` `10768`), not grantable to this user any more (`406` `185003` — e.g. the user was removed from a workspace or org the scopes name), and the governing org's `credential_policy` (`158000`). An account under enforced single sign-on is refused the same way key creation refuses it — a new secret is a credential minted outside the identity provider — `403` `122598` with `params.reason` = `sso_required`, or `503` `103107` if the enforcement state could not be read. If the key's scopes or secret change while this request is in flight (a concurrent edit, or a second regenerate racing this one), the rotation is refused rather than silently lost — `409` `142117` — and nothing is changed by this request; re-read the key and retry. A `POST` to this path family whose tail is neither a valid key id nor `{key_id}/regenerate` (a malformed id, `regenerate` with no id, or extra path segments) is refused with `406` `10019` rather than falling through to create a brand-new key. **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10019` | 406 | "You provided an invalid Token to regenerate." | Invalid key ID format in the `{key_id}/regenerate` path | | `10019` | 406 | "You provided an invalid Token." | The path tail is neither a valid key id nor `{key_id}/regenerate` — refused rather than falling through to create | | `10020` | 404 | "You provided a Token that was not found." | Key not found or belongs to another user | | `151797` | 406 | "The API Key has expired. Update its expiration date before regenerating it." | The key has already expired — clear or extend `expires` via a normal update first | | `190363` | 406 | "The scopes provided must contain at least one scope." | The key's stored scope set is empty or unparseable | | `10768` | 403 | "The requested scopes are broader than the credential making this request." | The key's stored scopes exceed the calling credential (`params.reason` = `scope_exceeds_issuer`) | | `185003` | 406 | "The requested scopes are invalid or access is denied." | The user can no longer grant one of the key's stored scopes | | `158000` | 403 / 503 | (as on create/update) | The key's stored scopes exceed the governing org's `credential_policy` | | `122598` | 403 | "Single sign-on is required for this organization." | The account's email is on an SSO-enforcing domain (`params.reason` = `sso_required`) | | `103107` | 503 | "Sign-in is temporarily unavailable. Please try again shortly." | The SSO enforcement state could not be read | | `142117` | 409 | "This API Key changed while this request was in flight. Re-read the key and retry." | The key's scopes or secret changed concurrently — nothing was changed by this request | | `109598` | 500 | "There was an error regenerating the API Key." | Internal error issuing the new secret | **Notes:** - 2FA verification is required if 2FA is enabled. - Only the secret changes — `memo`, `scopes`, `agent_name` and `expires` are untouched. Use `POST /current/user/auth/key/{key_id}/` to change those. - Triggers the same `api_key_updated` event as a metadata update (no separate regenerate event type). - Returns "not found" if the key belongs to a different user (does not reveal ownership). --- #### DELETE /current/user/auth/key/{key_id}/ Delete an API key. **Auth:** Required (JWT, scope: `user` or `admin`) **Scope visibility.** A **write-capable account-wide** credential (`user:*:rw` or `user:*:rwa`), a **legacy** key, and a signed-in web session see every key on the account. Every other credential — one created with entity `scopes`, and also `user:*:r` — sees only the keys **its own grant contains** — the same containment rule `POST /current/user/auth/key/` applies to a mint. A key outside that grant is not visible and answers `403` `10175`, exactly as before. This is what lets a scoped agent inspect and revoke the keys it creates; it never widens what a credential can reach. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{key_id}` | string | Yes | The API key's unique identifier. | **Request Example:** ```bash curl -X DELETE "https://api.fast.io/current/user/auth/key/aB3dE5fG7hJ9kL1mN2pQ/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10019` | 406 | "You provided an invalid Token to Delete." | Invalid key ID format | | `10020` | 404 | "You provided a Token that was not found." | Key not found or belongs to another user | | `10021` | 500 | "There was an error deleting the API Key." | Internal deletion failure | | `10175` | 403 | "The scope of your credentials are not sufficient." | The key exists and is yours, but its scopes are not contained in your credential's grant — see **Scope visibility** above | **Notes:** - 2FA verification is required if 2FA is enabled. - Returns "not found" if the key belongs to a different user (does not reveal ownership). --- #### GET /current/user/auth/keys/ List all API keys for the user. **Auth:** Required (JWT, scope: `user` or `admin`) **Scope visibility.** A **write-capable account-wide** credential (`user:*:rw` or `user:*:rwa`), a **legacy** key, and a signed-in web session list every key on the account. Every other credential — one created with entity `scopes`, and also `user:*:r` — lists only the keys **its own grant contains** — the same containment rule `POST /current/user/auth/key/` applies to a mint. Keys outside that grant are omitted from `api_keys` and are not counted in `results`; a narrowed credential that owns no such key receives `results: 0` and `api_keys: null`, the same answer an account with no keys receives. An **unscoped** key is unaffected. This is what lets a scoped agent enumerate and revoke the keys it creates. It never widens what a credential can reach: a key it could not have minted stays invisible, and a credential whose scopes cannot be read at all is still refused outright with `403` `10175`. **Request Example:** ```bash curl -X GET "https://api.fast.io/current/user/auth/keys/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true, "results": 2, "api_keys": [ { "id": "aB3dE5fG7hJ9kL1mN2pQ", "api_key": "****************************ab12", "memo": "CI/CD Pipeline Key", "created": "2024-01-15 10:30:00 UTC", "scopes": "[\"workspace:1234567890123456789:rw\"]", "agent_name": "my-agent", "expires": "2026-12-31 23:59:59 UTC", "admin": false, "legacy": false, "last_used": "2026-09-23 07:10:00 UTC", "last_ip": "203.0.113.7", "last_country": "US" }, { "id": "Zq8Rt2Yw6Ux4Kp0Hn3Js", "api_key": "****************************cd34", "memo": "Backup Script", "created": "2024-02-20 14:00:00 UTC", "scopes": null, "agent_name": null, "expires": null, "admin": false, "legacy": true, "last_used": null, "last_ip": null, "last_country": null } ] } ``` **No Keys Response (200 OK):** ```json { "result": true, "results": 0, "api_keys": null } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `results` | integer | Number of API keys. | | `api_keys` | array or null | Array of API key objects, or `null` if none exist. | | `api_keys[].id` | string | Unique key identifier. | | `api_keys[].api_key` | string | Masked API key (only last 4 characters visible). | | `api_keys[].memo` | string | Key description/label. | | `api_keys[].created` | string | Key creation timestamp in UTC. | | `api_keys[].scopes` | string or null | JSON array of scope strings, or `null` for a legacy key that declares no scopes claim (which behaves as `user:*:rw`). | | `api_keys[].agent_name` | string or null | Agent/application name, or `null` if not set. | | `api_keys[].expires` | string or null | Expiration datetime in canonical `Y-m-d H:i:s UTC` format, or `null` for no expiration. | | `api_keys[].admin` | boolean | Whether the key carries at least one `rwa` scope, i.e. can perform administrative operations (still capped per request by the human's live role). | | `api_keys[].legacy` | boolean | Whether the key declares no scopes claim at all. `legacy` never implies `admin`, and `admin` never implies `legacy`. | | `api_keys[].last_used` | string or null | When this key was last used to authenticate a request, `Y-m-d H:i:s UTC`. Written periodically, not on every request. `null` for a key never used since usage tracking began. | | `api_keys[].last_ip` | string or null | The client IP of that last use. Same write cadence as `last_used`. | | `api_keys[].last_country` | string or null | ISO-3166 alpha-2 country of that last use (`XX` unknown, `T1` Tor). Same write cadence as `last_used`. | These three fields also appear per credential in an org's compliance credential inventory (`GET /current/org/{org_id}/credentials/`) — see *Compliance & Audit* in `llms/orgs.txt`. --- ### Two-Factor Authentication (2FA) #### GET /current/user/auth/2factor/ Get current 2FA status. **Auth:** Required. This is an **account-settings** operation: it needs a browser login session, or a credential that explicitly holds `userdetails:*:rw`. Every other credential — including a `user:*:rw` or `user:*:rwa` key and a legacy/unscoped key — is refused with `403` + `10769`. See [Scope errors](#scope-errors-administration-and-account-settings). **Request Example:** ```bash curl -X GET "https://api.fast.io/current/user/auth/2factor/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true, "state": "enabled", "totp": false } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `state` | string | 2FA status: `"enabled"` (fully verified), `"unverified"` (added but not verified), or `"disabled"` (not configured). | | `totp` | boolean | Whether the 2FA method is TOTP (Time-based One-Time Password). Omitted when `state` is `disabled`. | --- #### POST /current/user/auth/2factor/{channel}/ Enable 2FA on the account. **Auth:** Required. This is an **account-settings** operation: it needs a browser login session, or a credential that explicitly holds `userdetails:*:rw`. Every other credential — including a `user:*:rw` or `user:*:rwa` key and a legacy/unscoped key — is refused with `403` + `10769`. See [Scope errors](#scope-errors-administration-and-account-settings). **Path Parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `{channel}` | string | No | `sms` | 2FA delivery channel: `sms`, `call`, `whatsapp`, or `totp`. | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/auth/2factor/sms/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response for SMS/Voice/WhatsApp (202 Accepted):** ```json { "result": true } ``` **Success Response for TOTP (202 Accepted):** ```json { "result": true, "binding_uri": "otpauth://totp/fast.io:jane@example.com?secret=ABCDEF..." } ``` **Response Fields (TOTP only):** | Field | Type | Description | |-------|------|-------------| | `binding_uri` | string | TOTP provisioning URI for QR code display. | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10769` | 403 | "This operation changes account credentials and requires the \"userdetails:*:rw\" scope." | Enrolling in 2FA without `userdetails:*:rw` | | `10167` | 409 | "2Factor already added, please remove first." | 2FA already enabled | | `10173` | 406 | "An invalid channel was supplied." | Invalid channel name | | `10168` | 406 | "2Factor cannot be added, you need a valid phone_number and phone_country..." | No phone number configured | **Notes:** - User must have a valid phone number and country code on their account before enabling 2FA (for non-TOTP channels). - After adding 2FA, it enters `unverified` state. Must complete verification via `POST /current/user/auth/2factor/verify/{token}/`. - For TOTP, display the `binding_uri` as a QR code for the user to scan. --- #### POST /current/user/auth/2factor/verify/{token}/ Verify a 2FA setup code to confirm enrollment. Transitions 2FA from `unverified` to `enabled` state. **Auth:** Required. This is an **account-settings** operation: it needs a browser login session, or a credential that explicitly holds `userdetails:*:rw`. Every other credential — including a `user:*:rw` or `user:*:rwa` key and a legacy/unscoped key — is refused with `403` + `10769`. See [Scope errors](#scope-errors-administration-and-account-settings). This is the **enrollment** step, performed from a fully signed-in session, so the limited `twofactor`-scope token issued during a 2FA sign-in is refused here with `10175` — that token belongs to `POST /current/user/auth/2factor/auth/{token}/`, which completes a login rather than confirming enrollment. That **login challenge** endpoint, and the 2FA code-delivery endpoints, are not account-settings operations and are not gated by `userdetails:*:rw`. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{token}` | string | Yes | 2FA verification code (e.g., 6-digit code). | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/auth/2factor/verify/123456/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (202 Accepted):** ```json { "result": true } ``` **Success Response — enrolment token, factor persisted (202 Accepted):** When the inbound token was an `enrol`-scoped enrolment token (see *Interactive Login & Enrolment* below) AND this call actually verified the code and persisted the enrolment, the response carries a full session instead of the plain accepted body: ```json { "result": true, "expires_in": 2592000, "auth_token": "{jwt_token}" } ``` `auth_token` is a complete `user`-scope session — the enrolment interstitial is over. It is also delivered as an HttpOnly cookie when `x-ve-session-cookie` was sent, subject to the same opt-in as `GET /current/user/auth/`. This does **not** happen when the account was already enrolled (that branch returns plain `{"result": true}` without checking the submitted code — see the ordinary success response above). **Nor does it happen when the code verified but the enrolment write did not land** — that arm answers `503` with a machine-readable reason instead, documented immediately below. An ordinary `user`-scoped self-service caller confirming their own 2FA setup is unaffected: it already holds a session and gets the plain response. **Enrolment NOT saved (503 Temporarily Unavailable):** A code the delivery side accepted, whose enrolment then failed to save, is its own outcome and is not the same as a wrong code. Either write behind the confirmation can fail, and **both answer `503` carrying the same `error.params.reason` = `two_factor_enrolment_not_persisted`**: - **The stored factor could not be read or saved** — *"Your code could not be confirmed because the enrolment could not be saved. Request a new code and confirm again."* This one is reachable by **every caller**, including an ordinary `user`-scoped self-service one adding 2FA to their own account. - **The account was not marked as enrolled** — *"Your code was accepted but the enrolment could not be saved. Request a new code and confirm again."* This one is reachable only by a caller upgrading an **enrolment token**, and **no session is minted.** **The correct client action for both is to REQUEST A NEW CODE and confirm again** — not to resubmit the same one. The code was right, and by this point it may already have been consumed on the delivery side, so re-sending it can only fail again, for ever, with the account left short of enrolled. Send `GET /current/user/auth/2factor/send/{channel}/` (or read a fresh code from the authenticator app for `totp`) and call this endpoint again with the new code. This is deliberately distinguishable from the plain verification failure below. That `406` means what it says — the submitted code itself was not accepted — it carries no `reason`, and it is the one failure retried by asking the user to re-enter the code they already have. **Verification Failed (406 Not Accepted):** ```json { "result": false } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10769` | 403 | "This operation changes account credentials and requires the \"userdetails:*:rw\" scope." | Verifying 2FA enrolment without `userdetails:*:rw` (an `enrol` token is separately admitted — see *Interactive Login & Enrolment*) | | `10173` | 406 | "An invalid token was supplied to validate." | Invalid token format | | `10170` | 406 | "2Factor is not enabled." | 2FA not configured | | `192283` | 503 | "Your code could not be confirmed because the enrolment could not be saved. Request a new code and confirm again." | **Every caller.** The stored factor could not be read or saved, so the confirmation did not complete (`params.reason` = `two_factor_enrolment_not_persisted`). **Request a NEW code and confirm again** — this is NOT a wrong code, and the submitted one may already have been consumed | | `168141` | 503 | "Your code was accepted but the enrolment could not be saved. Request a new code and confirm again." | **Enrolment tokens only.** The submitted code verified, but the account write confirming the factor did not land, so no session was minted (`params.reason` = `two_factor_enrolment_not_persisted`). **Request a NEW code and confirm again** — the submitted one has already been consumed and resubmitting it can only fail again | **Notes:** - If 2FA is already in the `enabled` state, returns success without modification — this is also true for an inbound `enrol` token, and it does NOT mint a session, since the caller proved nothing on this call. - This is the final step of the 2FA setup flow, and — for an `enrol` token — also the final step of the enrolment flow. See *Interactive Login & Enrolment* below. - **`two_factor_enrolment_not_persisted` is the one refusal you must not retry as-is.** It covers both enrolment writes and is the whole reason a `503` here is not a `406`: the `406` is retried by asking the user to re-enter the code, this one only by obtaining a **fresh** code first. Branch on `error.params.reason`, never on the numeric code. --- #### POST /current/user/auth/2factor/auth/{token}/ Authenticate with a 2FA code. Upgrades a limited-scope JWT to a full-scope JWT. **Auth:** Required (JWT, scope: `user`, `twofactor`, or `admin`) **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{token}` | string | Yes | Valid 2FA verification code (e.g., 6-digit TOTP or SMS code). | **Request Headers:** | Header | Type | Required | Default | Description | |--------|------|----------|---------|-------------| | `x-ve-session-cookie` | boolean | No | (absent) | Browser clients only. Same meaning and vocabulary (`1`, `true`, `yes`) as on `GET /current/user/auth/`: the full-scope token minted here is ALSO delivered in an HttpOnly, Secure, `SameSite=Lax` cookie scoped to the site's registrable domain (e.g. `fast.io`). Only honoured as a request header. | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/auth/2factor/auth/123456/" \ -H "Authorization: Bearer {twofactor_jwt_token}" ``` **Request Example (browser session cookie):** ```bash curl -X POST "https://api.fast.io/current/user/auth/2factor/auth/123456/" \ -H "Authorization: Bearer {twofactor_jwt_token}" \ -H "x-ve-session-cookie: 1" \ --cookie-jar cookies.txt ``` **Success Response (200 OK):** ```json { "result": true, "expires_in": 2592000, "auth_token": "{jwt_token}" } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `expires_in` | integer | JWT expiration time in seconds. | | `auth_token` | string | New JWT with full `user` scope. | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10173` | 406 | "An invalid token was supplied to authenticate." | Invalid token format | | `10172` | 406 | "2Factor is not enabled on this account." | 2FA not enabled | | `10174` | 406 | "The supplied token failed to authenticate." | Wrong 2FA code | | `10009` | 401 | "Internal Error." | JWT creation failure | **Notes:** - **This is where a 2FA-enabled account gets its session cookie.** Sign-in returns a pre-2FA token and sets no cookie, so a browser client that sent `x-ve-session-cookie` at sign-in must send the same header again here; otherwise there is no cookie for `POST /current/user/auth/bootstrap/` to read. --- #### DELETE /current/user/auth/2factor/{token}/ Disable (remove) 2FA from the account. **Auth:** Required. This is an **account-settings** operation: it needs a browser login session, or a credential that explicitly holds `userdetails:*:rw`. Every other credential — including a `user:*:rw` or `user:*:rwa` key and a legacy/unscoped key — is refused with `403` + `10769`. See [Scope errors](#scope-errors-administration-and-account-settings). **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{token}` | string | Yes | Valid 2FA verification code. Required only if 2FA is in `enabled` (verified) state. | **Request Example:** ```bash curl -X DELETE "https://api.fast.io/current/user/auth/2factor/123456/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10769` | 403 | "This operation changes account credentials and requires the \"userdetails:*:rw\" scope." | Removing 2FA without `userdetails:*:rw` | | `10173` | 406 | "An invalid token was supplied, valid token required to remove 2Factor." | Invalid token format | | `10174` | 406 | "The supplied token failed to authenticate." | Token verification failed | | `10169` | 500 | "2Factor could not be removed, please contact support." | Internal removal failure | **Notes:** - If 2FA is `enabled` (verified), a valid 2FA code is required to remove it. - If 2FA is `unverified`, it can be removed without a code. - If 2FA is already disabled, returns success. --- #### 2FA Code Delivery Endpoints Request a 2FA code via different channels. All require auth (accepts `user`, `twofactor`, `admin`, or `enrol` JWT scope — the last one lets an enrolment token request a code for the factor it is enrolling; see *Interactive Login & Enrolment* below). **GET /current/user/auth/2factor/send/sms/** -- Send code via SMS ```bash curl -X GET "https://api.fast.io/current/user/auth/2factor/send/sms/" \ -H "Authorization: Bearer {jwt_token}" ``` **GET /current/user/auth/2factor/send/call/** -- Send code via voice call ```bash curl -X GET "https://api.fast.io/current/user/auth/2factor/send/call/" \ -H "Authorization: Bearer {jwt_token}" ``` **GET /current/user/auth/2factor/send/whatsapp/** -- Send code via WhatsApp ```bash curl -X GET "https://api.fast.io/current/user/auth/2factor/send/whatsapp/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (202 Accepted):** ```json { "result": true } ``` **Failure Response (406 Not Accepted):** ```json { "result": false } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10011` | 401 | "Your credentials were not supplied or invalid." | Invalid JWT | | `10175` | 403 | "The scope of your credentials are not sufficient." | Wrong JWT scope | | `10170` | 406 | "2Factor is not enabled." | 2FA not configured on account | **Notes:** - 2FA must be enabled (or in unverified state) for codes to be sent. - Returns `result: false` if the code send fails (e.g., invalid phone number). --- ### Complete 2FA Login Flow This flow belongs to the deprecated Basic Auth login. With browser sign-in and OAuth, the Fastio sign-in page asks for the 2FA code itself and the client receives a full session directly. ``` 1. GET /current/user/auth/ - Send email:password via HTTP Basic Auth - Response includes "2factor": true and limited-scope auth_token 2. GET /current/user/auth/2factor/send/sms/ (or /call/ or /whatsapp/) - Request a fresh 2FA code - Uses the limited-scope (twofactor) JWT 3. POST /current/user/auth/2factor/auth/{code}/ - Submit the 2FA code - Receive a new JWT with full "user" scope - Use this token for all subsequent requests ``` ### Complete 2FA Setup Flow ``` 1. POST /current/user/auth/2factor/{channel}/ - Choose channel: sms, call, whatsapp, or totp - Phone number must be configured on account (for non-TOTP) - State becomes "unverified" - For TOTP: receive binding_uri for QR code 2. Receive code via selected channel (or scan QR code for TOTP) 3. POST /current/user/auth/2factor/verify/{token}/ - Submit the verification code - State becomes "enabled" - 2FA is now active on the account ``` ### Complete 2FA Removal Flow ``` 1. DELETE /current/user/auth/2factor/{code}/ - Must provide valid 2FA code if state is "enabled" - Can remove without code if state is "unverified" - 2FA is fully removed from the account ``` --- ## Connecting or Disconnecting a Personal Google / Microsoft Sign-In Three endpoints let a signed-in user add or remove a Google or Microsoft sign-in method on **their own** account. `{provider}` is `google` or `microsoft`. None of the three signs anyone in, signs anyone out, or creates an account — they only change which sign-in methods an already-authenticated account has. This is a different surface from `GET|POST /current/user/sso/signin/{provider}/` (which signs a user in, and can create an account) and from Enterprise SSO (which is org-wide, against an organization's own identity provider). All three are **account-settings operations**: they need `userdetails:*:rw` explicitly on a scoped credential, or a browser login session — otherwise `403` `10769` (see *Scope errors: administration and account settings* above). The two connect endpoints (`.../link/start/` and `.../link/`) also enforce the account's org SSO policy: `403` `122598` if the address is on a domain that requires signing in through that organization's own identity provider instead. ### POST /current/user/auth/social/{provider}/link/start/ Begin connecting a sign-in method. Answers `{result: true, redirect_url}` — send the browser to `redirect_url`, the provider's own consent screen. **Body:** `return_url` (required, an `https://` URL on your registered domain — the provider always returns to this environment's own fixed sign-in page, never to `return_url` directly, so this value is carried through and handed back by `.../link/` for you to bounce the user to afterward), `password` (optional), `token` (optional, a 2FA code). **Re-authentication is required** — adding a sign-in method is a credential change, so an ordinary session is not enough. Satisfy EITHER: `password` (plus `token` when the account has 2FA enabled — fetch a code first from `GET /current/user/auth/2factor/send/{sms|call|whatsapp}/`, or type a TOTP code); OR a login session that was itself started by an interactive sign-in — password, a personal Google/Microsoft sign-in, or org SSO — within the last 10 minutes. Completing a 2FA challenge on that same login does not extend the window, and a session started before this recent-sign-in route existed never qualifies. A password-less (SSO-only) account satisfies the second route by signing out, signing back in, and connecting within 10 minutes. Failing both is `403` `10771` with `params: {reason: "reauth_required", password_set, two_factor_required}`. Other refusals: `406` `10230` (bad or missing `return_url`), `403` `10759` (wrong/missing password when `password` was sent), `406` `10173`/`10174` (bad or missing 2FA code), `406` `10226` (unknown provider — only `google`/`microsoft` are offered). ### POST /current/user/auth/social/{provider}/link/ Finish connecting: `{code, state}`, exactly as returned by the provider to the page `.../link/start/` sent it to. Response: `{result: true, auth, return_url}` — `auth` is the same sign-in-methods object documented under `GET /current/user/{user_id}/details/` above, and `return_url` is the value you originally sent to `.../link/start/`. A newly connected provider gets a best-effort "new sign-in method connected" email; reconnecting an identity you already hold does not. `state` is single-use and expires after 10 minutes; it is bound to your account and to the browser that started the flow. A stale, replayed, or foreign `state` is `406` `10772` — restart at `.../link/start/`. Connecting an identity already bound to a **different** account is `409` `10773`; already having a **different** account of that provider connected, or losing a race to a concurrent connect for the same account, is `409` `10774` (disconnect it first, or simply retry); reconnecting the **same** identity you already have is a no-op `200` — safe to retry. If the sign-in-methods tables can't be read, or the check made just after the write (or its rollback) can't complete, the call answers `503` — retry. ### POST /current/user/auth/social/{provider}/unlink/ Disconnect a sign-in method. No body. Response: `{result: true, auth, password_email_sent}`. If the account has no password left afterward, a set-password email is sent automatically (unless your organization's SSO policy blocks password resets for your address) — `password_email_sent` reports whether it went out, so removing your last social sign-in never locks you out. Signing in again later with the same email reconnects the provider. Refusal `404` `10775` means that provider was not connected in the first place. --- ## Interactive Login & Enrolment (org-required 2FA) An organization can require its members to hold a second factor before they may sign in — see *Require-2FA Policy* in `llms/orgs.txt` for the org-level setting. This section is what a client sees when that policy applies to an account that has not yet enrolled a factor, at the two login surfaces the policy governs. **Scope — password and social login only.** The policy is checked at the moment `GET /current/user/auth/` (Basic Auth) or `POST /current/user/sso/signin/{provider}/` (personal Google/Microsoft sign-in) mints a session, because those are the only two surfaces 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, policy or no policy. Enterprise SSO (`POST /current/user/sso/exchange/`) is unaffected regardless of this or any other org's requirement — see the note on that endpoint above. **Browser sign-in and OAuth enrol on the page.** When the policy applies to an email-and-password sign-in on the Fastio sign-in page (browser sign-in, or the browser step of OAuth), the page walks the user through enrolling a first factor before it issues a code, so the client only ever receives a full session — never an enrolment token. Everything below describes the deprecated Basic Auth login and personal social sign-in. **The signal: `enrol_required`.** Both `GET /current/user/auth/` and the social sign-in callback always carry a boolean `enrol_required` field — present and `false` on every ordinary login. It is `true` only when: the account holds no factor, AND at least one org the account belongs to requires one. When `true`: - `"2factor"` is **also** `true` on the same response, so a client that only reads `2factor` still shows its existing code-entry screen instead of treating the login as complete — it will not be ABLE to enrol from that screen, but it will not silently drop the user into a full session either. - The issued token is an **enrolment token**, not a session: no session cookie is set even when `x-ve-session-cookie` was sent, and the token is scope-limited to exactly the enrolment surfaces below. **What the enrolment token can do — five endpoints, nothing else:** | Endpoint | Purpose | |----------|---------| | `POST /current/user/auth/2factor/{channel}/` | Add a factor — every channel, including `totp` (the only channel available to an account with no stored phone number). | | `POST /current/user/auth/2factor/verify/{token}/` | Confirm the factor. On success this call also upgrades the enrolment token into a full session — see the response documented on that endpoint above. | | `GET /current/user/auth/2factor/send/sms/` | Request a code for the factor being enrolled. | | `GET /current/user/auth/2factor/send/call/` | Same, via voice call. | | `GET /current/user/auth/2factor/send/whatsapp/` | Same, via WhatsApp. | It is refused everywhere else — `GET`/`DELETE /current/user/auth/2factor/`, `POST /current/user/auth/2factor/auth/{token}/`, `POST /current/user/update/`, and `GET /current/user/auth/check/` all reject it with `403` and a `params.reason` (never a `401`). The enrolment interstitial takes the user's identity from the login response, not from any of those calls. **The refusal reason is `two_factor_enrolment_only`, and it is always a `403` — never a `401`.** That distinction is the whole point: the credential is valid, correctly signed and unexpired, and is simply not admitted on the endpoint that was called. A client that reads it as `401` will discard a live enrolment token and strand the user mid-enrolment with no way back but a fresh sign-in. The refusal carries the ordinary scope-error params, with this shape: ```json "params": { "reason": "two_factor_enrolment_only", "entity_type": "userdetails", "entity_id": null, "required_access_mode": null, "current_access_mode": null, "credential_type": "session" } ``` `required_access_mode` and `current_access_mode` are **both `null`**, and that is correct rather than missing data: no access mode would have made this call succeed, because the enrolment token is restricted by *which endpoint* it may call, not by how much authority it holds. Treat `null` as *not applicable* — never re-mint the credential asking for a wider mode. Branch on `reason`, never on the numeric code. The remedy is always the same: finish enrolling the factor, then use the full session the confirmation returns. **The flow — one code, not two:** ``` 1. GET /current/user/auth/ (or the social callback) - Response: "2factor": true, "enrol_required": true, auth_token = enrolment token 2. POST /current/user/auth/2factor/{channel}/ (with the enrolment token) - Choose channel: sms, call, whatsapp, or totp - State becomes "unverified" 3. GET /current/user/auth/2factor/send/{channel}/ (skip for totp — scan the QR code instead) - Request a fresh code 4. POST /current/user/auth/2factor/verify/{token}/ (with the enrolment token) - Submit the code - Response: full session — auth_token, expires_in — not the plain accepted body ``` A client that predates `enrol_required` is not broken by this policy, only stalled: it sees `"2factor": true` exactly as it does for an already-2FA-enabled account and shows its code screen, but has no code to submit — it cannot enrol from that screen. Reading `enrol_required` is required to route such a user into an enrolment UI instead. **What this policy does NOT do — four limits a login client must not assume away:** - **It governs interactive password and social login, and nothing else.** It is never evaluated for an API key, an OAuth grant, or an MCP token: none of them is challenged for a factor at request time, whatever an org requires. - **A session minted by enterprise single sign-on is compliant regardless of this policy**, in this org or any other — the organization's identity provider owns that factor. The exchange response carries neither `2factor` nor `enrol_required`, so there is no field to branch on there. - **Turning the requirement on does not end existing sessions immediately.** The sessions of users who become required and hold no factor are revoked **asynchronously**, so there is a window between the administrator's write succeeding and those sessions ending. A token that was issued before the flip can keep working for a short while afterwards; that is expected, not a bug to work around. - **A session minted before its holder enrolled is never revoked.** An already-enrolled user is left alone by that revocation — enrolment already satisfies the requirement — and nothing on the token distinguishes the session they held *before* they enrolled from one issued after. Do not rely on the flip to invalidate it. See *Require-2FA Policy* in `llms/orgs.txt` for the org-side setting, its refusals, and the populations the revocation covers. --- ## Enterprise SSO Sign-In Enterprise SSO signs a user in against **their organization's own identity provider** instead of against a Fastio password or a personal social account. An org administrator connects one identity provider (OpenID Connect or SAML 2.0) to the org and proves ownership of the email domains it speaks for; see the Enterprise SSO reference for that configuration surface. This section is the **sign-in flow** — how a browser is handed to that provider and comes back holding a JWT. **This is not `GET|POST /current/user/sso/signin/{provider}/`.** That route is the personal Google / Microsoft convenience sign-in available to any individual account, and it needs no org configuration. The routes below are org-wide and organization-configured, even though they sit beside it under `/current/user/sso/`. ### The flow ``` 1. discover POST /current/user/sso/discover/ (optional) an email address -> which org, if any 2. start GET /current/user/sso/start/ -> redirect_url at the organization's provider 3. provider the user authenticates at their own identity provider 4. callback the provider returns the browser to Fastio, which redirects it to {your_origin}/signin/sso and sets a short-lived, single-use handoff cookie 5. exchange POST /current/user/sso/exchange/ empty body, cookies included -> JWT ``` Steps 1, 2 and 5 are calls your application makes. Steps 3 and 4 happen in the browser, and **your application never calls the callback routes itself** — they are the stable URLs the organization's administrator registers inside the identity provider. **Two browser requirements, both non-negotiable:** - **The browser key.** `GET /current/user/sso/start/` requires the browser-key credential the platform already issues (the `ve_br_key` cookie, or the `x-ve-br-key` request header where a client forwards it). The sign-in is bound to it, and `POST /current/user/sso/exchange/` re-checks the binding — which is what makes a stolen handoff useless in a different browser. Absent, `start` refuses; mismatched, the exchange refuses with `browser_mismatch`. - **Credentials on the exchange.** The handoff arrives as an HttpOnly cookie, so the exchange request must be made with credentials included (`credentials: "include"`, or `-b` in curl) from an origin on the same registrable domain as the API host. No `Authorization` header is sent — there is no session yet. **What the handoff is.** On a successful callback, Fastio redirects the browser to `{your_origin}/signin/sso` carrying **nothing in the URL** and sets a `ve_sso_ex` cookie: HttpOnly, Secure, `SameSite=Lax`, `Path=/`, scoped to the registrable domain, and valid for **60 seconds and exactly one use**. Because it never appears in a `Location` header, a URL or browser history, it is not exposed in any of the places a redirect is recorded. Your landing route reads nothing from the URL — it simply posts the exchange. **Failures come back in the fragment.** A callback that cannot complete redirects to `{your_origin}/signin/sso#error={reason}&org={org_domain}` and sets no cookie. A reason string carries nothing sensitive, which is why it is allowed in a URL where the handoff is not. Clear any stale `#error=` fragment when your landing route mounts. **The `org` parameter is present only once the organization is known** — a refusal that happens before that, such as a sign-in record that has expired or already been used, carries the reason alone. **A third outcome lands on the same route: an administrator's test sign-in.** An organization's administrator can run a test sign-in while SSO is still switched off (see the Enterprise SSO reference, *The test sign-in*). It completes the whole protocol and then deliberately stops — no account, no membership, no identity, no session and **no handoff cookie**. The browser arrives at: ``` {your_origin}/signin/sso?sso-test=1&ok=<0|1>&message= ``` in the **query string**, not the fragment. **Check for `sso-test=1` before your sign-in handling runs.** It is neither of the two sign-in outcomes: there is nothing to exchange, and a failure here is a test result rather than a login error. Show the `ok` and `message` values as the result of the test, and let the administrator read the full outcome from `GET /current/org/{org_id}/sso/`. Treat an unrecognised `message` as a generic failure — the values are a closed vocabulary. --- ### POST /current/user/sso/discover/ "I typed this email address — where do I sign in?" Maps an email domain to the organization that federates it, for a platform-wide sign-in page that does not yet know which organization the user belongs to. **Auth:** None (IP-throttled) **Request Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `email` | string | Yes | The address the user typed. Max 320 characters. | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/sso/discover/" \ --data-urlencode "email=user@acme.com" ``` **Success Response — the domain federates (200 OK):** ```json { "result": true, "sso": true, "mode": "optional", "org": { "domain": "acme-corp", "name": "Acme Corporation" }, "start_path": "/user/sso/start/?org=acme-corp&login_hint=user%40acme.com" } ``` **Success Response — the domain does not federate (200 OK):** ```json { "result": true, "sso": false } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `sso` | boolean | Whether this email domain is federated to an organization. | | `mode` | string | Present only when `sso` is `true`: `optional` or `required`. | | `org.domain` | string | The organization's URL-safe domain (slug), for the `org` parameter on `start`. | | `org.name` | string | The organization's display name, for the sign-in button. | | `start_path` | string | The path to begin the sign-in, with the typed address already supplied as `login_hint`. Use it as given rather than composing your own. | **Error Responses:** | HTTP Status | `params.reason` | Cause | |-------------|-----------------|-------| | 429 | — | Over the per-IP throttle. The standard `x-ve-limit-avail`, `x-ve-limit-max` and `x-ve-limit-expires` headers and `error.code` `10368` apply, exactly as documented in the global rate-limiting section. | | 503 | — | The lookup itself could not answer. **Retry** — this is never reported as `sso: false`. | **Notes:** - **`sso: false` is a constant answer covering three different situations** — a domain nobody has claimed, a domain claimed but not yet verified, and a verified domain whose organization has SSO switched off. They are deliberately indistinguishable, so this endpoint cannot be used to map which companies are on the platform. - **It never says anything about whether an account exists.** The answer is a property of the *domain*, not of the address. - **A `503` is not "no SSO".** Treat it as "try again" and keep the user on the SSO path. Rendering an outage as "sign in with your password" would route a federated organization's users around the control their administrator turned on. - The call is optional. An application that already knows which organization it is signing the user into can go straight to `start`. --- ### GET /current/user/sso/start/ Begin an enterprise SSO sign-in for one organization. Returns the URL at that organization's identity provider to send the browser to. **Auth:** None, but the **browser key is required** (`ve_br_key` cookie, or the `x-ve-br-key` request header). IP-throttled. **Query Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `org` | string | Yes | The organization's **domain (slug)** — the same value as `org.domain` on the discovery and public-details responses. This route does not accept a 19-digit numeric org ID. Max 255 characters. | | `return_url` | string | No | An `https://` URL on a Fastio-hosted origin. **Only its origin is kept** — any path, query or fragment is discarded, and the sign-in always lands on `{origin}/signin/sso`. Must be `https`, on the default port, and on an allow-listed platform domain; local development origins may include a port. Not echoed back. Defaults to the organization's own origin. Max 1024 characters. | | `login_hint` | string | No | The address the user typed. Passed to the identity provider so it can pre-fill, and checked up front against the organization's verified domains. Max 320 characters. | **Request Example:** ```bash curl -X GET "https://api.fast.io/current/user/sso/start/?org=acme-corp&return_url=https%3A%2F%2Facme-corp.fast.io&login_hint=user%40acme.com" \ -b "ve_br_key={browser_key}" ``` **Success Response (200 OK):** ```json { "result": true, "provider": "sso", "protocol": "oidc", "redirect_url": "https://idp.example.com/authorize?response_type=code&client_id={client_id}&state={state}" } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `provider` | string | Always `sso`, distinguishing an enterprise sign-in from `google` / `microsoft`. | | `protocol` | string | `oidc` or `saml` — which protocol the organization is configured for. Informational; the client treats both identically. | | `redirect_url` | string | Where to send the browser. **Navigate the top-level window to it** — this is a redirect flow, exactly like the social sign-in. Do not fetch it, and do not open it in an iframe. | **Error Responses:** | HTTP Status | `params.reason` | Cause | |-------------|-----------------|-------| | 406 | `sso_not_configured` | No such organization, or it has no usable identity-provider configuration. The two are deliberately the same answer. | | 406 | `sso_disabled` | The organization has a configuration but its mode is `off`. | | 406 | `domain_not_permitted` | The `login_hint` address is not on a domain the organization has verified. | | 406 | `browser_mismatch` | No browser key was presented. | | 503 | — | Temporary failure opening the sign-in. Retry. | **Notes:** - `return_url` is **origin-only by design**. Keep the destination you want the user to land on in your own client state; the backend has no channel to carry it, and the exchange response does not return one. - A `login_hint` is checked before the round trip to the provider, so a user who types a personal address at an organization's sign-in page is told immediately rather than after authenticating. - The route works from any Fastio-hosted origin — a platform sign-in page or an organization's own subdomain — because the organization comes from the `org` parameter, not from the hostname. - **Signing in is never plan-gated.** An organization whose plan no longer includes Enterprise SSO keeps signing in through the identity provider it already configured; only configuration changes are refused. An administrator who wants to stop using single sign-on steps the mode down. --- ### The identity-provider callbacks These two routes are the URLs an organization's administrator registers **inside their identity provider**. Your application does not call them; the browser arrives at them from the provider. They are described here so client authors recognise what happens between `start` and `exchange`. ``` GET /current/user/sso/oidc/callback/ OIDC redirect URI (code + state) POST /current/user/sso/saml/acs/ SAML assertion consumer service (HTTP-POST binding) ``` **Auth:** None. On the OIDC route the `state` value, and on the SAML route the `RelayState` value, *is* the credential: each names a single-use record Fastio created at `start`, and the organization is resolved from that record alone. Both routes are IP-throttled. Fastio validates what came back (`state`, `nonce` and PKCE for OIDC; signature, audience, `InResponseTo`, `Recipient` and `Destination` for SAML), resolves, links or creates the user's identity, and mints the one-time handoff. **Neither route ever returns a JSON body** — a browser is waiting on the other end of it, so every outcome is a redirect: | Outcome | Redirect | Cookie | |---------|----------|--------| | Success | `302 {your_origin}/signin/sso` | `ve_sso_ex`, HttpOnly, Secure, `SameSite=Lax`, 60 seconds, single use | | Failure | `302 {your_origin}/signin/sso#error={reason}&org={org_domain}` | none | `{your_origin}` is the origin of the validated `return_url` supplied at `start`, defaulting to the organization's own origin. **Notes:** - **SAML is SP-initiated only.** Every sign-in must begin at `start`; a SAML Response that names no live sign-in record is rejected rather than accepted as unsolicited. There is no IdP-initiated ("launch from the provider's app dashboard") path. - There is one shared callback URL per protocol for the whole platform. The organization is never taken from the assertion's issuer, so an administrator registers one stable URL and nothing about it is organization-specific. - The single-use record behind `state` / `RelayState` is short-lived. A user who leaves the provider's page open for a long time and then signs in gets `state_expired` and simply starts again. --- ### GET /current/user/sso/saml/metadata/ Service-provider (SP) metadata for one organization, for SAML identity providers that import a metadata document rather than taking fields one at a time. **Auth:** None. IP-throttled. **Query Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `org` | string | Yes | The organization's domain (slug) or its numeric ID. Max 255 characters. | | `download` | boolean | No | `true` (or `1`) adds `Content-Disposition: attachment` so a browser saves the file, for identity providers that import SP metadata only as an upload (JumpCloud). | **Request Example:** ```bash curl -X GET "https://api.fast.io/current/user/sso/saml/metadata/?org=acme-corp" ``` **Success Response (200 OK):** `application/samlmetadata+xml` — a SAML 2.0 `EntityDescriptor` carrying the SP entity ID, the assertion consumer service URL and its HTTP-POST binding, and the name-ID format (the one a saved SAML configuration chose, otherwise `emailAddress`). **Available before single sign-on is configured.** The document needs nothing from your identity provider, so it is served for any organization — including one with no configuration yet, which is exactly when you need it: while creating the Fastio app at your provider. **Error Responses:** | HTTP Status | Cause | |-------------|-------| | 404 | No such organization. | | 503 | Temporary failure building the document. Retry. | **Notes:** - Public by design. The document contains no secret. It does show that the organization exists, its stable numeric ID (in the entity ID), and the name-ID format if a SAML configuration chose a non-default one. - **The SP entity ID is keyed to the organization's stable numeric ID, not to its domain (slug).** A slug can be changed and re-registered by somebody else; an entity ID must not move with it. Use the document exactly as served. - An administrator reading the configuration surface gets the entity ID and the ACS URL as individual fields (`sp.saml_entity_id`, `sp.saml_acs_url`), alongside `sp.saml_metadata_url` — the fetchable address of this document. **`sp.saml_metadata_url` and `sp.saml_entity_id` are different strings on purpose**: the URL names the organization by its domain (slug), because that is what this route resolves, while the entity ID stays on the numeric ID so a rename cannot move it. See the Enterprise SSO reference. --- ### POST /current/user/sso/exchange/ Turn the 60-second handoff into a session. This is the call that actually signs the user in: the account, its organization membership and any role it inherits are all committed here, so an abandoned sign-in leaves nothing behind. **Auth:** None. The `ve_sso_ex` handoff cookie and the `ve_br_key` browser key are the credentials, so the request **must be sent with credentials included**. Send no `Authorization` header. IP-throttled. **Request Parameters:** None. The body is empty — the handoff and the browser key travel as cookies. **Request Example:** ```bash curl -X POST "https://api.fast.io/current/user/sso/exchange/" \ -b "ve_sso_ex={handoff_cookie}; ve_br_key={browser_key}" ``` The body is empty. From a browser, the same call is a POST with `credentials: "include"` and no body. **Success Response (200 OK):** ```json { "result": true, "provider": "sso", "email": "user@acme.com", "token": "{jwt_token}", "account_created": false, "org": { "id": "1234567890123456789", "domain": "acme-corp" } } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `provider` | string | Always `sso`. | | `email` | string | The address on the signed-in account. | | `token` | string | The issued JWT. Send it as `Authorization: Bearer {token}`. | | `account_created` | boolean | **Always present, always a boolean** — `true` only when this exchange created the account. Note the difference from the social sign-in, where the field is omitted for a returning user. | | `org.id` | string | 19-digit numeric ID of the organization the user signed into. | | `org.domain` | string | That organization's URL-safe domain (slug). Land the user here first; treat anything you read from the account's profile afterwards as secondary. | **Error Responses:** | HTTP Status | `params.reason` | Cause | |-------------|-----------------|-------| | 406 | `code_expired` | No handoff was presented, it had already been used, or its 60 seconds elapsed. | | 400 | `browser_mismatch` | The handoff was presented from a different browser than the one that called `start`. | | 406 | `state_expired` | The sign-in record behind the handoff is gone — or the organization's configuration moved underneath the sign-in while it was in flight. Either way it means "start again". | | 406 | `domain_not_permitted` | The address the provider asserted is not on a domain the organization has verified. | | 406 | `not_provisioned` | The organization provisions through its directory only, and this person has not been provisioned. Nothing was created, linked or claimed. | | 406 | `email_unverified` | The provider did not assert the address as verified, and the address matches an existing account or a pending invitation to this organization. Linking an unverified address to either is how an account takeover is spelled, so it is refused. A brand-new account for an address nobody has invited is still created. | | 406 | `account_conflict` | The address belongs to an account this organization may not link to the federated identity. | | 406 | `deprovisioned` | The identity was deprovisioned by the organization's directory; a sign-in must not step over that. | | 406 | `sso_disabled` / `sso_not_configured` | The configuration changed underneath an in-flight sign-in. | | 406 | `link_confirmation_required` | The address matched an existing Fastio account — or an invited account that has not yet been claimed — not yet linked to this organization's identity provider. A confirmation link was emailed to the account — for an invited account, to the address the invitation went to; no session was created. See *Account-link confirmation* in the Enterprise SSO reference. | | 503 | — or `idp_error` | Temporary failure. Most carry no reason; an unexpected failure while completing the sign-in carries `idp_error`. Most of these have already spent the handoff, so restart the sign-in from `start`. The exception is a storage fault while the handoff is being claimed: nothing was consumed and the cookie is deliberately left in place, so repeating the exchange itself can succeed. Retry the exchange once; if it comes back `code_expired`, start again at `start`. | **Notes:** - **The handoff is cleared on every outcome but one.** On success and on every refusal the cookie goes, so a refused exchange never leaves one waiting to produce a second, more confusing refusal; retrying means starting again at `start`. The single exception is a 503 raised while the handoff is being claimed — nothing was consumed, the cookie stays, and the exchange may be repeated as it stands. - **The session is a normal revocable account session.** It carries the same claims a `revocable=true` password login does, so `POST /current/user/auth/sign-out/` and `POST /current/user/auth/invalidate-all/` both reach it, and every downstream surface — OAuth grants, the browser session cookie — works unchanged. - **There is no `2factor` key on this response, not even `false` — and no `enrol_required` either.** A federated sign-in has already satisfied whatever multi-factor policy the organization's identity provider enforces, and the issued token is fully usable regardless of the org's Require-2FA setting (see *Org Require-2FA Policy* in `llms/orgs.txt`) — the identity provider owns that factor, not Fastio. Do not branch on a field that is not there. - **There is no `redirect_after_login` either.** `return_url` is reduced to an origin at `start`, so the backend has no destination to hand back: use the destination you stored client-side, and fall back to `org.domain`. - **Send `x-ve-session-cookie` here** if you want the issued token mirrored into the browser session cookie, exactly as on `GET /current/user/auth/`. That header governs the resulting *session* cookie only; it is not needed to read the handoff. - The account, its membership and any mapped role are committed **before** this call responds, so the very next request observes them. - **A `link_confirmation_required` refusal is not an error to retry.** It means the address matched an existing account this organization has never linked before, or an invited account nobody has claimed yet — an email was sent, and the sign-in completes only when the user confirms from it via `POST /current/user/sso/link/confirm/`. See *Account-link confirmation* in the Enterprise SSO reference for the confirmation endpoint and the full flow. --- ### Sign-in error reasons Every sign-in refusal — whether it arrives in a callback's `#error=` fragment or as `error.params.reason` on a 4xx from `start` or `exchange` — uses this closed vocabulary and no other value. **Branch on `reason`.** Do not branch on the numeric `error.code` (assigned per endpoint, so the same condition reports different numbers from different routes) and do not branch on the HTTP status alone. | `reason` | What happened | What to show the user | |----------|---------------|-----------------------| | `sso_not_configured` | The organization named has no usable identity-provider configuration — including the case where no such organization exists. | "Single sign-on is not set up for this organization." Offer the other sign-in methods. | | `sso_disabled` | The organization has a configuration but has switched SSO off. | The same message. Offer the other sign-in methods. | | `domain_not_permitted` | The email address is not on a domain the organization has verified. | "That address is not managed by this organization." Do not suggest which addresses would work. | | `idp_error` | The identity provider refused, or returned something unusable. | "Your organization's identity provider could not complete the sign-in." Offer a retry, and point the user at their administrator. | | `email_unverified` | The provider did not assert the address as verified. | "Your identity provider has not verified this email address." This one is the administrator's to fix at the provider. | | `account_conflict` | The address belongs to an account the organization may not link. | "This email address is already in use by another account." Route to support. | | `state_expired` | The sign-in took too long, or the record behind it is gone. It also covers a sign-in that was overtaken by an administrator's change to the organization's configuration — a credential, or the SAML NameID format — while it was at the provider, and a **first** SAML sign-in that could not be completed safely at that moment — the service could not take or keep the protection such a sign-in needs, which implies no administrator action and no completed change. | "That sign-in expired." Send the user back to the start of the flow: restarting the sign-in is the whole remedy, and it picks up whatever the conditions are by then. | | `not_provisioned` | The organization provisions its people through its directory only, and this person has no directory record. This includes a **returning** member who originally arrived through just-in-time creation, if the organization has since switched to directory-only provisioning. | "Your organization provisions access through its directory. Ask an administrator to add you." Do not offer a retry; nothing about retrying changes the answer. | | `code_expired` | The handoff was missing, already used, or older than 60 seconds. | The same — restart the sign-in. | | `browser_mismatch` | The sign-in was completed in a different browser than it started in, or no browser key was presented. | "Finish signing in from the browser you started in." Check that cookies are enabled and that the exchange request includes credentials. | | `deprovisioned` | The organization's directory removed this identity. | "Your access to this organization has been removed." | | `link_confirmation_required` | The address matched an existing account — or an invited account that has not yet been claimed — not yet linked to this organization's identity provider. | "We sent a confirmation link to your email — click it to finish signing in." Do not offer a retry of the sign-in itself; the next step is in their mailbox. | | `link_confirmation_invalid` | The confirmation link is unknown, expired, already used, superseded by a newer sign-in attempt, or the identity provider's credentials or certificate changed since it was sent (switching `mode` or role mapping does not invalidate it). | "That confirmation link can't be used." Send the user back to the start of the sign-in flow. | **Error envelope.** The 4xx refusals follow the standard envelope documented in `llms.txt` — `result: false` and an `error` object carrying `code`, `text` and `params`: ```json { "result": false, "error": { "code": 149000, "text": "This single sign-on session could not be completed.", "params": { "reason": "browser_mismatch" } } } ``` --- ### `login_options`: what an organization's sign-in page may offer An organization-scoped sign-in page needs to know which buttons to draw before anybody has authenticated. `GET /current/org/{org_id}/public/details/` (unauthenticated — see the Organizations reference) carries a `login_options` block **on the `org` object** for exactly that: ```json { "result": true, "org": { "id": "1234567890123456789", "domain": "acme-corp", "name": "Acme Corporation", "login_options": { "password": true, "social": ["google", "microsoft"], "sso": { "enabled": true, "mode": "optional", "protocol": "oidc", "display_name": "Acme SSO", "start_path": "/user/sso/start/?org=acme-corp" }, "signup": false } } } ``` **Fields:** | Field | Type | Description | |-------|------|-------------| | `password` | boolean | Whether an email/password sign-in may be offered for this organization. | | `social` | array | Personal social providers that may be offered. Empty when the organization requires SSO. | | `sso.enabled` | boolean | Whether an enterprise SSO button should be drawn. | | `sso.mode` | string | `off`, `optional` or `required`. Under `required`, present SSO as the only route; under `optional`, present it first, alongside the others. | | `sso.protocol` | string/null | `oidc` or `saml`. Informational. | | `sso.display_name` | string/null | The label to put on the button, chosen by the administrator. | | `sso.start_path` | string/null | The path to begin the sign-in. Use it as given. | | `signup` | boolean | Whether a self-service signup link may be offered. | **Notes:** - **Every value is already resolved for the organization** — you do not combine `mode` with the other flags yourself. Under `required` the block reports `password: false`, `social: []` and `signup: false`, and the server refuses those routes too, so the page never draws a door that is locked. - **The block is omitted entirely when it cannot be determined.** Treat an absent `login_options` as "this backend does not report it" and fall back to your existing behaviour — never as "this organization has no SSO". - **There is no list of the organization's email domains here, and nothing about who is exempt.** Publishing either would hand an attacker the addresses worth phishing, or tell an unauthenticated caller which organizations have an administrator who can still use a password. --- ## Enterprise SSO Enforcement An organization on the Enterprise plan can set its SSO `mode` to `required`. From that point every email address on one of that organization's **verified domains** must authenticate through the organization's identity provider: the routes that would issue a session from a password, create an account, or hand out, rotate or move a password refuse. How an administrator gets there — the plan, the verified domain, the configuration check — is in the Enterprise SSO reference, under *Enforcement*. This section is what a client sees. **Enforcement follows the email domain.** An address on a verified domain of an enforcing organization is enforced whether or not that account has joined the organization, and whether or not an account exists at all. ### What refuses | Surface | Route | |---------|-------| | Password sign-in | `GET /current/user/auth/` | | Browser sign-in | the Fastio sign-in page returns the browser to `{return_origin}/signin/callback?error=sso_required&org={org_domain}&state={state}` (see *Browser Sign-In*) | | Account signup | `POST /current/user/` | | Social sign-in (Google / Microsoft) | `/current/user/sso/signin/{provider}/` | | Password reset request | `POST /current/user/email/reset/` | | Password reset redemption, and setting a password | `POST /current/user/password/{code}/` | | Rotating an existing password | `POST /current/user/update/` | | Changing an email address | `POST /current/user/update/`, `POST /current/user/email/change/` | | Creating a **new** API key | `POST /current/user/auth/key/` | | Regenerating an API key's secret | `POST /current/user/auth/key/{key_id}/regenerate/` | | Connecting a personal Google / Microsoft sign-in | `POST /current/user/auth/social/{provider}/link/start/`, `POST /current/user/auth/social/{provider}/link/` | **The refusal.** Every one of them answers `403` with `reason` = `sso_required` (`POST /current/user/email/change/` carries the `reason` alone, without `org` or `start_path`): ```json { "result": false, "error": { "code": 122598, "text": "Single sign-on is required for this organization.", "params": { "reason": "sso_required", "org": { "domain": "acme-corp" }, "start_path": "/user/sso/start/?org=acme-corp&login_hint=user%40acme.com" } } } ``` | Field | Type | Description | |-------|------|-------------| | `error.params.reason` | string | Always `sso_required` for this refusal. **Branch on this**, not on the numeric `error.code`, which is assigned per endpoint and so differs between the routes above. | | `error.params.org.domain` | string | The organization's login slug — the organization to name as the one managing this account. | | `error.params.start_path` | string | Where to send the browser to sign in instead. It already carries the `org` and a `login_hint` for the address that was submitted. Use it as given; do not compose it yourself. | **What to do with it.** On a sign-in or signup screen, send the browser to `start_path`. In a settings screen — where the user already has a session — show that the account is managed by its organization and disable the control, rather than offering a retry that cannot succeed. **The refusal is identical for every address on the domain.** It does not depend on whether an account exists, nor on whether the account holds a role that is exempt. That is deliberate: a caller cannot use these endpoints to learn whether an address is registered, or which addresses still hold a usable password. Never read an `sso_required` refusal as evidence that an account exists. ### Break-glass for the owner, administrators and listed addresses An organization's owner and its admins keep a password path, so an organization can never lock itself out of its own console. An administrator may additionally name individual addresses on the organization's enforcement exception list; a listed address is exempt **exactly** as an admin is, on the same three surfaces and no others — the break-glass password sign-in; setting or rotating the account password, whether by spending a mailed reset code or by changing an existing password from a signed-in session; and creating a new API key. Everywhere else on the table above — the ordinary password sign-in, signup, the social sign-in, requesting a password reset, and changing an email address — a listed address gets the byte-identical `sso_required` refusal. Requesting a reset code is not exempt, so an exempt account with no password reaches one only through the break-glass route below. Two routes take a `break_glass=true` query parameter: | Route | Behaviour | |-------|-----------| | `GET /current/user/auth/?break_glass=true` | The ordinary password check runs **first**, and only then — on a **correct** password — does the server check whether the account is the organization's owner, an admin, or an address on the organization's enforcement exception list. A non-exempt account gets the identical `403` `sso_required`. | | `POST /current/user/email/reset/?break_glass=true` | Always answers `202`, exactly like an ordinary reset request for an address the platform does not recognize. The email is sent only when the account is the organization's owner, an admin, or an address on its enforcement exception list, and the response never reveals which. | **Failures here count toward the ordinary sign-in lockout.** Unlike the default sign-in surface, where an enforced address is refused before a password is examined, `break_glass=true` examines the password — so a wrong one consumes an attempt and can reach the `429` / `10760` lockout documented under *GET /current/user/auth/*. **Offer it as a deliberate administrator affordance, not as a general fallback.** A user who is not exempt gains nothing from it except a consumed sign-in attempt. ### What enforcement does not withdraw - **Existing API keys keep working, and exempt accounts can still create new ones.** The gate refuses a *new* key for enforced members; it does not revoke keys already issued, and it does not apply to the organization's owner, its admins, or an address on its enforcement exception list. That surface is reached with a live session for the account in question, so exempting it reveals nothing an ordinary member could use. - **The gate itself ends no session.** A session already in hand is not torn down by a request being refused. **Switching an organization to `required` does revoke web sessions.** Every user on the organization's verified domains loses their existing web sessions — except exempt accounts — and gets a `401` on their next call, then signs back in through the identity provider. The same revocation happens when: - a new domain is verified while the mode is already `required`; - a user's email address moves onto an enforced domain; - an administrator loses their admin role (a former owner stays an administrator after transferring ownership, so the transfer alone signs no one out); - an address is taken off the enforcement exception list while the mode is `required`. Only users this organization actually enforces are affected: removing the owner, an administrator, an address on a domain this organization has not verified, or an address with no account is a no-op. Handle it as you already handle any `401`: discard the token and start the sign-in flow again. `login_options` on the organization, or the `sso` block below, tells you which route to offer. ### The `sso` block on `GET /current/user/details/` **Not to be confused with `auth.sso`** (see the Self-Only Fields table above): this `sso` block is the caller's SSO enforcement state for signing in; `auth.sso` is the list of org SSO identities the account has actually signed in with. `GET /current/user/details/` carries an `sso` block **for the caller's own record only**, so a client can draw the right interface before it makes a call that would be refused: ```json "sso": { "enforced": true, "exempt": false, "org_domain": "acme-corp" } ``` | Field | Type | Description | |-------|------|-------------| | `sso.enforced` | boolean | Whether the caller's email domain is enforced. | | `sso.exempt` | boolean | Whether this caller keeps a password path — because they are the organization's owner or an admin, or because an administrator has named their address on the organization's enforcement exception list. The two are the same exemption and reach the same three surfaces. | | `sso.org_domain` | string or null | The organization's login slug — the same value `start_path` uses. `null` when the caller's email domain is not enforced. | **Use it to draw the interface, not to make the decision.** Disable the "create API key" and "set password" controls when `enforced` is `true` and `exempt` is `false`. The server refuses those calls either way; the block only saves the user a round trip that ends in a `403`. **The block may be absent** — on an older backend, or when the state could not be read. **Treat an absent block as "not enforced"** and fall back to your current behaviour; never disable a control on the strength of a field that is not there. **It is never rendered for any user other than the caller.** Reading another user's details never includes it: who is exempt from enforcement is not a fact one user may learn about another. ### When the platform cannot tell If the enforcement state cannot be determined, the endpoints above answer with a **retryable, temporarily-unavailable** error rather than letting a password through. Retry it. **Do not treat it as "not enforced"** — folding an outage into "no enforcement" would, during an incident, invite exactly the password sign-ins that enforcement exists to prevent. --- ## User Search ### GET /current/users/search/ Search for people by name or email across your contacts and the people you share access with (members of orgs, workspaces, and shares you belong to). **Auth:** Required (JWT) **Query Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `search` | string | Yes | Search term. Matches against user names and email addresses. Must not be blank. | **Request Example:** ```bash curl -X GET "https://api.fast.io/current/users/search/?search=john" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true, "contacts": { "john.doe@example.com": "John Doe", "jane.johnson@example.com": "Jane Johnson" }, "users": [ { "id": "1234567890123456789", "email": "john.doe@example.com", "name": "John Doe" }, { "id": null, "email": "jane.johnson@example.com", "name": "Jane Johnson" } ] } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `contacts` | object | Map of email address (key) to display name (value) for each matched user. Unchanged, backward-compatible. | | `users` | array | List of matched people as `{id, email, name}` objects, deduplicated by email. Provides the user `id` that the `contacts` map cannot. | | `users[].id` | string \| null | The matched person's user id when they are reachable through one of your shared spaces (an org / workspace / share you have in common); `null` for a contacts-only match that does not resolve to such an account. | | `users[].email` | string | The matched person's email address. | | `users[].name` | string | The matched person's display name. | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `10011` | 401 | "Authentication required" | Missing or invalid JWT token | | `205516` / `207092` | 406 | "This value should not be blank." | No custom error code is attached to this field — the code is derived from Symfony's validator: `205516` when `search` is omitted entirely, `207092` when present but blank | | `157360` or `120189` | 500 | "Internal error" | `157360` when the contacts search client fails to initialize; `120189` when the user-profile search client fails to initialize | **Notes:** - Searches across two sources: the people you share access with and your contacts. Results are merged and deduplicated by email. - `contacts` is a flat email -> name map kept for backward compatibility. `users` is the richer, id-bearing list — prefer it when you need to act on a specific account. - A `users[].id` is only present for matches reachable through a shared space; pure-contact matches carry `id: null`. When the same email matches both ways, the id-bearing entry wins. --- ## Response Envelope **Success:** ```json {"result": true, ...} ``` **Error:** ```json { "result": false, "error": { "code": 195654, "text": "Human-readable message", "documentation_url": "https://api.fast.io/llms.txt", "resource": "POST /current/user/" } } ``` **Validation error (HTTP 406) with structured per-parameter detail:** ```json { "result": false, "error": { "code": 195654, "text": "The email parameter is required. The password must be at least 8 characters.", "documentation_url": "https://api.fast.io/llms.txt", "resource": "POST /current/user/", "params": [ {"name": "email", "kind": "missing", "message": "The email parameter is required.", "code": 195654}, {"name": "password", "kind": "invalid", "message": "The password must be at least 8 characters.", "code": 195655, "expected_type": "string"} ] } } ``` `error.params` is an array of `{name, kind, message, code, expected_type?, received_alias?}`. It is present on validation errors (HTTP 406) and aggregates every failed parameter so callers see all problems in one round trip. `kind` is one of `missing`, `invalid`, `type_mismatch`, or `unknown_parameter` on a validation error, and `conflict` on a state conflict (HTTP 409). Treat `kind` as open-ended: handle an unrecognised value as a generic failure rather than rejecting the response. The field is **omitted** when empty (non-validation errors). The existing `text` field is retained byte-identically for compatibility and is now advisory — clients should prefer `params` for programmatic handling. **OPTIONS introspection.** Most user/auth endpoints respond to `OPTIONS` with a JSON description of their accepted parameters (source, required vs optional, expected type, declared constraints). Use this to fetch parameter requirements before issuing a call. Endpoints that don't opt in return `405 Method Not Allowed`. ## Common Error Codes These are the platform's internal status classes and the HTTP status each maps to. They are **not** the `error.code` on the wire, which identifies the individual call site — branch on the HTTP status and, where present, `error.params.reason`. | Code | Description | HTTP Status | |------|-------------|-------------| | 1600 | Internal Error | 500 Internal Server Error | | 1605 | Invalid Input | 406 Not Acceptable | | 1658 | Not Acceptable | 406 Not Acceptable | | 1622 | Duplicate Entry | 406 Not Acceptable | | 1669 | Already Exists | 409 Conflict | | 1660 | Conflict | 409 Conflict | | 1609 | Not Found / Resource Missing | 404 Not Found | | 1610 | General Error | 500 Internal Server Error | | 1650 | Authentication Invalid | 401 Unauthorized | | 1651 | Invalid Request Type | 405 Method Not Allowed | | 1653 | User Not Found | 404 Not Found | | 1701 | Gone | 410 Gone — endpoint retired by decision; stop calling the path, do not retry or vary the id | | 1671 | Rate Limited | 429 Too Many Requests (wire `error.code` `10368`) | | 1680 | Access Denied | 401 Unauthorized | | 1700 | Forbidden | 403 Forbidden | | 1693 | Temporarily Unavailable | 503 Service Unavailable | | 1670 | Restricted | 406 Not Acceptable | | 1677 | Locked | 423 Locked | | 1673 | SSO Auth Error | 406 Not Acceptable | ## Rate Limiting Response headers: `x-ve-limit-avail` (requests remaining), `x-ve-limit-max` (window cap), `x-ve-limit-expires` (Unix-time the window resets). When exceeded: HTTP 429 with `error.code` `10368`. Back off until `x-ve-limit-expires`. ## ID Formats - User IDs: 19-digit numeric string (e.g., `"1234567890123456789"`) - `"me"` can be used as user_id in user endpoints to reference the authenticated user - User endpoints also accept email address as an identifier ## Token Types | Type | Format | Lifetime | Use | |------|--------|----------|-----| | JWT (browser sign-in, signup) | RS256-signed JSON Web Token | Platform default (read `expires_in`) | Web app session | | JWT (Basic Auth, deprecated) | RS256-signed JSON Web Token | Configurable (default varies) | General API access | | JWT (OAuth) | RS256-signed JSON Web Token | 1 hour | OAuth-based API access | | Refresh Token | Opaque string | Long-lived | Obtaining new access tokens (OAuth only) | | API Key | Alphanumeric string | Configurable (default: no expiry) | Service-to-service communication. Optionally scoped with permissions, agent name, and expiration. | ## Security Best Practices 1. Always use HTTPS for all API communication. 2. Store refresh tokens and API keys securely (OS keychain, encrypted storage). 3. Never log tokens in client-side logs or analytics. 4. Persist the `refresh_token` from the response; it is long-lived and returned unchanged on refresh (no rotation needed). 5. Verify the `state` parameter in OAuth callbacks to prevent CSRF. 6. Handle 401 responses by attempting a token refresh; if refresh fails, re-authenticate. 7. Revoke tokens on logout by calling the revoke endpoint and clearing local storage. > Part of the Fastio API Reference. Overview: https://api.fast.io/current/llms/ # OAuth 2.0 Base URL: `https://api.fast.io/current/` Request format: `application/x-www-form-urlencoded` (unless noted otherwise) Response format: JSON --- ## Overview Fastio implements OAuth 2.0 with PKCE (Proof Key for Code Exchange) for secure authorization without passing user credentials through the client application. This is the recommended auth method for desktop apps, mobile apps, and MCP-connected agents. **Key characteristics:** - S256 challenge method only (plain not supported) - Access tokens last 1 hour (3600 seconds) - Refresh tokens are long-lived - Authorization codes last 5 minutes - Authorization requests last 10 minutes - Supports SSO (user signs in via browser, supports federated login) - No user password passes through the agent/client -- email-and-password users sign in on the Fastio sign-in page (`login.fast.io`) during the browser step - Native clients may use a loopback redirect URI on `127.0.0.1` with any port (RFC 8252); headless clients can use `display_code=true` (copy-paste code) - Dynamic Client Registration (RFC 7591/7592) for automated client onboarding - Client ID Metadata Document (CIMD) for URL-based client identification - Resource Indicators (RFC 8707) for audience-restricted JWT access tokens - Scoped access tokens (JWT v2.0) with entity-level restrictions and agent identity - Three access modes -- `r` (read), `rw` (read/write) and `rwa` (read/write/administer) - Administrative access and account-settings access are opt-in ceilings the client asks for at initiate - Metadata discovery (RFC 8414, RFC 9728) for automated server/resource configuration **Access modes and opt-in ceilings.** Every granted scope carries one of three access modes -- `r` (read), `rw` (read and write) or `rwa` (read, write and **administer**). `rwa` implies `rw`, which implies `r`; there is deliberately no `ra`, because administration always includes write. A client asks for the ceiling it wants on the **initiate** request (`access_mode`), and asks separately for account-settings access (`account_settings=1`). Both default to off, so **a client that never sends them cannot obtain administrative access or account settings through OAuth at all** -- and the consent screen never offers either unprompted. Consent may narrow a ceiling; it can never widen one. **Rollout note -- what this means for credentials issued earlier.** An existing read-write API key or OAuth grant keeps reading and writing, but **no longer performs administrative operations** (org, workspace and share administration, including administrative reads such as billing details, invoices, usage, credits and the events audit log) and **no longer changes account settings** (password, email address, 2FA enrolment, invalidate-all). - To restore administration, update the API key with `rwa` scopes, or reconnect the application asking for `access_mode=rwa`. - To restore account settings, add `userdetails:*:rw` to the API key, or reconnect the application with `account_settings=1`. Widening a credential can only be done from a signed-in web session -- a credential can never widen itself. - `user:*:r` is now a real whole-account read-only grant: it returns full read results where it previously returned empty lists on some endpoints. - Clients validating `access_modes_supported` against `{r, rw}` must accept `rwa`. **Error envelope note.** The token (`/current/oauth/token/`), revoke (`/current/oauth/revoke/`), and dynamic-registration (`/current/oauth/register/`) endpoints return **bare JSON** error responses with `error` and `error_description` fields per RFC 6749 §5.2 (and RFC 7591 for registration). They do **not** use the standard platform envelope, and the structured `error.params` field documented in the platform error reference is **not** emitted here. Standard OAuth clients (including MCP hosts and Claude.ai Connectors) consume this RFC-compliant shape directly. The other endpoints in this group (authorize / sessions / scopes) use the standard platform envelope, but `error.params` appears only on errors from structured parameter validation -- the authorize endpoint's own request checks (for example a missing `client_id`) return `error.code` and `error.text` with no `params`, so always fall back to `error.text`. **OPTIONS preflight note.** The RFC envelope above applies to GET / POST / PUT / DELETE responses from these endpoints. The authorization-server discovery endpoint (`/.well-known/oauth-authorization-server/`) answers `OPTIONS` with a `200` schema-introspection response plus full CORS preflight headers (`Access-Control-Allow-Origin: *`, allowed methods, and allowed request headers including `MCP-Protocol-Version`), so browser-side clients whose metadata fetch triggers a cross-origin preflight can complete discovery. The protected-resource discovery endpoint (`/.well-known/oauth-protected-resource/`) does not: on public hosts an `OPTIONS` request to it is answered with `405`. An `OPTIONS` request to the other endpoints in this group emits the platform's standard envelope, not the RFC-compliant shape — a low-impact, preflight-only difference: browsers do not read OPTIONS response bodies, and the CORS headers are correct in both cases. --- ## Endpoint Summary | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/.well-known/oauth-authorization-server/` | None | RFC 8414 authorization server metadata | | GET | `/.well-known/oauth-protected-resource/` | None | RFC 9728 protected resource metadata | | POST | `/current/oauth/register/` | None | RFC 7591 dynamic client registration | | PUT | `/current/oauth/register/` | Registration access token (Bearer) | RFC 7592 update client registration | | GET | `/current/oauth/authorize/` | None | Initiate auth flow -- 302 redirect to login page (or JSON with `response_format=json`) | | POST | `/current/oauth/authorize/` | Session (JWT) | Complete authorization, issue code | | GET | `/current/oauth/authorize/info/` | None | Get client info for consent screen | | POST | `/current/oauth/token/` | None | Exchange code for tokens, or refresh tokens | | POST | `/current/oauth/revoke/` | None | Revoke a refresh token | | GET | `/current/oauth/sessions/` | Bearer | List all active sessions | | GET | `/current/oauth/sessions/{id}/` | Bearer | Get session details | | PATCH | `/current/oauth/sessions/{id}/` | Bearer | Update session display names, or narrow the session's granted scopes | | DELETE | `/current/oauth/sessions/{id}/` | Bearer | Revoke a specific session | | DELETE | `/current/oauth/sessions/` | Bearer | Revoke all sessions | | GET | `/current/auth/scopes/` | Bearer | Token scope introspection | --- ## Metadata Discovery ### GET /.well-known/oauth-authorization-server/ RFC 8414 Authorization Server Metadata. Returns server configuration for automated client setup. **Auth:** None ```bash curl -X GET "https://api.fast.io/.well-known/oauth-authorization-server/" ``` **Response (200 OK):** ```json { "issuer": "https://go.fast.io", "authorization_endpoint": "https://go.fast.io/api/current/oauth/authorize", "token_endpoint": "https://go.fast.io/api/current/oauth/token", "revocation_endpoint": "https://go.fast.io/api/current/oauth/revoke", "registration_endpoint": "https://go.fast.io/api/current/oauth/register", "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "refresh_token"], "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["none"], "scopes_supported": ["user", "org", "workspace", "all_orgs", "all_workspaces", "all_shares", "all_sign_envelopes"], "access_modes_supported": ["r", "rw", "rwa"], "client_id_metadata_document_supported": true, "resource_indicators_supported": true, "service_documentation": "https://go.fast.io/api/current/llms/" } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `issuer` | string | Authorization server issuer identifier URL | | `authorization_endpoint` | string | URL of the authorization endpoint. The four endpoint URLs are emitted without a trailing slash. | | `token_endpoint` | string | URL of the token endpoint | | `revocation_endpoint` | string | URL of the token revocation endpoint | | `registration_endpoint` | string | URL of the dynamic client registration endpoint | | `response_types_supported` | array | Supported response types (`code` only) | | `grant_types_supported` | array | Supported grant types (`authorization_code`, `refresh_token`) | | `token_endpoint_auth_methods_supported` | array | Supported auth methods (`none` -- public clients only) | | `code_challenge_methods_supported` | array | Supported PKCE methods (`S256` only) | | `client_id_metadata_document_supported` | boolean | Client ID Metadata Document support (`true`) -- an HTTPS URL may be used as `client_id` (see *Client ID Metadata Document* below) | | `resource_indicators_supported` | boolean | RFC 8707 support (`true`) | | `scopes_supported` | array | OAuth scope type selectors supported by the authorization server (see the scope-type values table under `GET /current/oauth/authorize/`) | | `access_modes_supported` | array | Access modes the authorization server accepts: `["r", "rw", "rwa"]` | | `service_documentation` | string | URL to service documentation | **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. Codes shown as five or six digits (and the `9661`-`9669` family) ARE `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. | Scenario | HTTP Status | Error | |----------|-------------|-------| | Wrong HTTP method | 405 | `1651 (Invalid Request Type)` | **Note on `access_modes_supported`.** This array is emitted from the same list the API validates `access_mode` against, so it is always the authoritative set. It now contains three values, not two -- **a client that validates it against `{r, rw}` and rejects anything else will reject a valid server document.** `scopes_supported` is unchanged: the same seven scope types, and `userdetails` is an entity type that is never advertised there. --- ### GET /.well-known/oauth-protected-resource/ RFC 9728 Protected Resource Metadata. Returns resource server configuration. **Auth:** None ```bash curl -X GET "https://api.fast.io/.well-known/oauth-protected-resource/" ``` **Response (200 OK):** ```json { "result": true, "resource": "https://mcp.fast.io/mcp", "authorization_servers": ["https://go.fast.io"], "bearer_methods_supported": ["header"], "scopes_supported": ["user", "org", "workspace", "all_orgs", "all_workspaces", "all_shares", "all_sign_envelopes"] } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `result` | boolean | `true` (this endpoint answers in the standard platform envelope) | | `resource` | string | Protected resource identifier URL | | `authorization_servers` | array | Authorization server issuer URLs that can issue tokens for this resource | | `bearer_methods_supported` | array | Methods for presenting bearer tokens (`header` -- Authorization header only) | | `scopes_supported` | array | OAuth scopes supported by this resource | **Error Responses:** | Scenario | HTTP Status | Error | |----------|-------------|-------| | Wrong HTTP method | 405 | `1651 (Invalid Request Type)` | --- ## Dynamic Client Registration ### POST /current/oauth/register/ RFC 7591 Dynamic Client Registration. Register a new OAuth client programmatically. **Auth:** None **Content-Type:** `application/json` or `application/x-www-form-urlencoded` **Request Parameters:** | Parameter | Type | Required | Default | Constraints | Description | |-----------|------|----------|---------|-------------|-------------| | `client_name` | string | No | `"Unknown Client"` | Max 128 bytes after trimming; reserved names are refused | Human-readable client name | | `redirect_uris` | JSON array | Yes | -- | 1-10 URIs. HTTPS required (localhost/127.0.0.1 exempt). No fragment (`#`) components. | Allowed redirect URIs | | `token_endpoint_auth_method` | string | No | `"none"` | Must be `"none"` | Auth method (only public clients supported) | | `grant_types` | JSON array | No | `["authorization_code", "refresh_token"]` | Not validated | Requested grant types (echoed in the response; the client can only ever use `authorization_code` and `refresh_token`) | | `response_types` | JSON array | No | `["code"]` | Not validated | Requested response types (echoed in the response; only `code` is supported) | ```bash curl -X POST "https://api.fast.io/current/oauth/register/" \ -H "Content-Type: application/json" \ -d '{ "client_name": "My MCP Client", "redirect_uris": ["http://localhost:3000/callback", "http://127.0.0.1:3000/callback"] }' ``` **Response (200 OK):** ```json { "result": true, "client_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "client_name": "My MCP Client", "redirect_uris": [ "http://localhost:3000/callback", "http://127.0.0.1:3000/callback" ], "token_endpoint_auth_method": "none", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "registration_access_token": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "registration_client_uri": "https://go.fast.io/api/current/oauth/register/" } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `result` | boolean | `true` on success | | `client_id` | string | Assigned client identifier for OAuth flows (32-character lowercase-hex string, no prefix) | | `client_name` | string | Registered client name | | `redirect_uris` | array | Registered redirect URIs | | `token_endpoint_auth_method` | string | Token endpoint auth method (`"none"`) | | `grant_types` | array | The `grant_types` sent in the request (or the default) | | `response_types` | array | The `response_types` sent in the request (or the default) | | `registration_access_token` | string | One-time token for managing registration via PUT (64-character lowercase-hex string, no prefix). Shown **once only** -- store securely. Server stores only a SHA-256 hash. | | `registration_client_uri` | string | URI for managing this client registration | **Error Responses (RFC 7591 format -- bare JSON, no platform envelope):** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `invalid_client_metadata` | 400 | `client_name` exceeds 128 bytes | | `invalid_client_metadata` | 400 | `client_name` is a reserved name | | `invalid_client_metadata` | 400 | `redirect_uris` is not a valid JSON array | | `invalid_client_metadata` | 400 | `token_endpoint_auth_method` is not `"none"` | | `invalid_redirect_uri` | 400 | `redirect_uris` must contain 1-10 entries | | `invalid_redirect_uri` | 400 | A `redirect_uris` entry is not a non-empty string | | `invalid_redirect_uri` | 400 | Redirect URI is not a valid URL | | `invalid_redirect_uri` | 400 | Redirect URI contains a fragment (`#`) | | `invalid_redirect_uri` | 400 | Redirect URI must use HTTPS (except localhost) | | `invalid_request` | 400 | `redirect_uris` is missing | | `invalid_request` | 400 | JSON request body is not a JSON object, or is larger than 64 KB | | `server_error` | 500 | Failed to register client | --- ### PUT /current/oauth/register/ RFC 7592 Dynamic Client Registration Management. Update an existing client registration. Requires the `registration_access_token` from the POST registration response. Only clients whose redirect URIs all point to `localhost`, `127.0.0.1`, `[::1]`, or `::1` may self-update. **Auth:** Registration access token (`Authorization: Bearer {registration_access_token}`) or `registration_access_token` body parameter **Content-Type:** `application/json` or `application/x-www-form-urlencoded` **Request Parameters:** | Parameter | Type | Required | Constraints | Description | |-----------|------|----------|-------------|-------------| | `client_id` | string | Yes | Must match a registered client | Client ID to update | | `client_name` | string | No | Max 128 characters | Updated client name | | `redirect_uris` | JSON array | No | Same validation as POST | Updated redirect URIs (full replacement) | At least one of `client_name` or `redirect_uris` must be provided. ```bash curl -X PUT "https://api.fast.io/current/oauth/register/" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" \ -d '{ "client_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "client_name": "My Updated MCP Client", "redirect_uris": ["http://localhost:8080/callback"] }' ``` **Response (200 OK):** ```json { "result": true, "client_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "client_name": "My Updated MCP Client", "redirect_uris": ["http://localhost:8080/callback"], "token_endpoint_auth_method": "none", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"] } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `result` | boolean | `true` on success | | `client_id` | string | Client identifier (unchanged) | | `client_name` | string | Updated client name | | `redirect_uris` | array | Updated redirect URIs | | `token_endpoint_auth_method` | string | Auth method (unchanged, always `"none"`) | | `grant_types` | array | Grant types (unchanged) | | `response_types` | array | Response types (unchanged) | **Error Responses (RFC 7591 format -- bare JSON):** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `invalid_request` | 400 | `client_id` missing | | `invalid_client_metadata` | 400 | `client_name` is a reserved name | | `invalid_request` | 400 | Neither `client_name` nor `redirect_uris` provided | | `invalid_request` | 400 | Only localhost clients can self-update | | `invalid_request` | 401 | Invalid or missing registration access token | | `invalid_client` | 404 | Client not found or disabled | | `invalid_client_metadata` | 400 | Invalid client metadata | | `invalid_redirect_uri` | 400 | Invalid redirect URIs (same rules as POST) | | `server_error` | 500 | Failed to update registration | **Notes:** - The `grant_types`, `response_types`, and `token_endpoint_auth_method` fields cannot be changed via self-update. - If `redirect_uris` is provided, it fully replaces the existing set. - If only `client_name` is provided, existing redirect URIs are preserved. --- ## Client ID Metadata Document (CIMD) CIMD allows OAuth clients to use an HTTPS URL as their `client_id` instead of pre-registering or using Dynamic Client Registration. The authorization server fetches metadata from the URL to get client information on-the-fly. This is the MCP specification's preferred client registration method. ### How It Works 1. **Detection:** If the `client_id` parameter starts with `https://`, the server treats it as a CIMD URL. 2. **Fetch:** The server fetches the JSON metadata document from the CIMD URL. 3. **Validation:** The document must contain: - `client_id` matching the fetched URL exactly - `redirect_uris` array containing the requested redirect URI, with 1-10 entries that each pass the same redirect-URI rules as dynamic registration - `grant_types` including `authorization_code` - `response_types` including `code` - `token_endpoint_auth_method` set to `none`, or `none` listed in `token_endpoint_auth_methods_supported` (either way the client is treated as a public client: no token-endpoint client authentication, PKCE S256 required for the code exchange) 4. **Caching:** Validated documents are cached to avoid repeated fetches. 5. **Flow:** The CIMD URL is used as the `client_id` throughout the authorization flow (authorize, token exchange, refresh). ### CIMD Document Format The metadata document is a JSON file served at an HTTPS URL with `Content-Type: application/json`: ```json { "client_id": "https://example.com/oauth/client-metadata", "client_name": "Example App", "redirect_uris": ["http://localhost:8080/callback"], "grant_types": ["authorization_code"], "response_types": ["code"], "token_endpoint_auth_method": "none" } ``` Optional fields: `client_uri` (URL to the client's home page), `logo_uri` (URL to the client's logo image). ### Security Constraints | Constraint | Value | |-----------|-------| | Protocol | HTTPS only (HTTP URLs rejected) | | Fetch timeout | 5 seconds | | Max document size | 10 KB | | Cross-domain redirects | Rejected | | Cache duration | 1 hour | ### Using CIMD in the Authorization Flow Use the CIMD URL directly as the `client_id` parameter: ``` GET /current/oauth/authorize/?response_type=code&client_id=https://example.com/oauth/client-metadata&redirect_uri=http://localhost:8080/callback&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&state=xyz123 ``` The token endpoint also uses the CIMD URL as `client_id` -- it matches by string comparison against the value stored during authorization. ### Error Scenarios | Scenario | Error | |----------|-------| | CIMD URL is not HTTPS | `1605 (Invalid Input)` | | Cannot fetch the document (timeout, DNS failure, non-200 response) | `1605 (Invalid Input)` | | Document is not valid JSON | `1605 (Invalid Input)` | | `client_id` in document does not match the URL | `1605 (Invalid Input)` | | Required fields missing or invalid | `1605 (Invalid Input)` | | `redirect_uri` not found in document's `redirect_uris` | `1605 (Invalid Input)` | --- ## Complete PKCE Flow ### Step 0: Discover Server Configuration (Optional) Fetch server metadata for automated setup: ``` 1. CLIENT -> API: Discover authorization server GET /.well-known/oauth-authorization-server/ -> Returns endpoints, supported grant types, PKCE methods 2. CLIENT -> API: Discover protected resource (if needed) GET /.well-known/oauth-protected-resource/ -> Returns resource URL and authorization servers ``` ### Step 0b: Register Client (If Needed) If the client does not have a registered `client_id`, there are two options: **Option A: Use a CIMD URL as client_id** -- If the client publishes a metadata document at an HTTPS URL, use that URL directly as the `client_id`. No registration step needed. **Option B: Use Dynamic Client Registration:** ``` CLIENT -> API: Register client POST /current/oauth/register/ Content-Type: application/json {"client_name": "My Agent", "redirect_uris": ["http://localhost:8080/callback"]} -> Returns client_id, redirect_uris, registration_access_token, etc. ``` ### Step 1: Generate PKCE Parameters (Client-Side) Before initiating the flow, generate the PKCE code verifier and challenge: ``` code_verifier = random_string(43-128 characters, URL-safe: [A-Za-z0-9-._~]) code_challenge = base64url_encode(sha256(code_verifier)) code_challenge_method = "S256" ``` The `code_challenge` will always be exactly 43 characters. ### Step 2: Initiate Authorization #### GET /current/oauth/authorize/ Initiates the authorization flow. By default, issues a `302 Found` redirect to the login/consent page. This is the browser-facing entry point that MCP clients open in the user's browser. **Auth:** None **JSON mode:** Add `response_format=json` to receive a JSON response instead of a redirect (for programmatic callers). **Query Parameters:** | Parameter | Type | Required | Default | Constraints | Description | |-----------|------|----------|---------|-------------|-------------| | `client_id` | string | Yes | -- | -- | Registered OAuth client ID, or an HTTPS URL pointing to a CIMD | | `redirect_uri` | string | Yes | -- | Must match a registered URI for the client | Client callback URI | | `response_type` | string | No | -- | Send `"code"` (the only supported type); the value is not currently checked | OAuth response type | | `code_challenge` | string | Yes | -- | Exactly 43 characters (BASE64URL-encoded SHA-256) | PKCE code challenge | | `code_challenge_method` | string | Yes | -- | Must be `"S256"` | PKCE challenge method | | `state` | string | Yes | -- | -- | Opaque value for CSRF protection, returned unchanged in callback | | `scope` | string | No | `"user"` | See scope values table | Scope type selector | | `access_mode` | string | No | -- (consent then treats the ceiling as `rw`) | `"r"`, `"rw"` or `"rwa"` | Ceiling on the access mode for this authorization | | `account_settings` | string | No | `"0"` | `"1"` or `"0"` | Ceiling for account-settings access | | `resource` | string | No | -- | Must be a valid resource URL (e.g., `https://mcp.fast.io/mcp`) | RFC 8707 resource indicator | | `agent_name` | string | No | -- | Truncated to 128 characters; reserved names are refused with 406 | Display name of the requesting agent | | `response_format` | string | No | -- | `"json"` | Set to `"json"` for JSON response instead of 302 redirect | | `display_code` | string | No | `false` | `"true"` or `"false"` (`"1"` / `"0"` also accepted; anything else is refused with 406) | Copy-paste mode for clients that cannot receive a redirect (headless CLIs, remote agents). After approval the browser shows the authorization code for the user to paste back into the client instead of redirecting to `redirect_uri`. `redirect_uri` is still required, must still be registered, and must be sent unchanged to the token endpoint. Recorded with the request: `GET /current/oauth/authorize/info/` and the consent response then report `redirect_mode: "display_code"`, and the returned `login_url` carries the mode | **Scope type values (`scope` parameter):** These are **named scope strings** used only in the authorization request. They are not the same as the scope format returned in API responses. | Value | Behavior | |-------|----------| | `user` | Full access (default, backward compatible with v1.0 JWT) | | `org` | User picks specific organizations | | `workspace` | User picks specific workspaces | | `all_orgs` | Wildcard access to all user's organizations | | `all_workspaces` | Wildcard access to all user's workspaces | | `all_shares` | All shares the user is a member of | | `all_sign_envelopes` | Wildcard access to all sign envelopes the user can reach | These seven are the complete offered set — they match the `scopes_supported` array in the server's authorization-server metadata (`GET /.well-known/oauth-authorization-server`). Request one of these named values (or omit `scope` for the default `user`). Values are case-sensitive. Words outside this set are ignored only if they are the OpenID Connect words `openid`, `profile`, `email` or `offline_access`; a request made only of those is treated like an omitted `scope` (the default `user` type at the consented access mode). A `scope` that names none of the seven otherwise is refused with a `406` input error (`invalid_scope`); the one exception is the retired `all_workflows` value, which is accepted here but never grants anything (consent fails). If several recognised values are sent space-separated, the broadest one is used. Always read `scopes` in the token response for what was actually granted. **Retired scope — `all_workflows`:** no longer offered. The Workflows feature has been removed, so `all_workflows` is deliberately omitted from `scopes_supported` and must not be requested. The authorization server retains it only as a fail-closed tombstone, so a pre-existing token that still carries it resolves to nothing rather than erroring — it is not a new capability you can request. (This is why the offered set is the seven above, not eight.) **Access mode values (`access_mode` parameter):** | Value | Behavior | |-------|----------| | `r` | Read only | | `rw` | Read and write | | `rwa` | Read, write and administer | `rwa` implies `rw`, which implies `r` -- there is deliberately no `ra`, because administration always includes write. `rwa` is accepted on the ordinary entity types but not on a **file share**, which has no administrative verb: a `rwa` scope naming a file share is refused at grant time. Nor on **account settings**: the only issuable `userdetails` form is the exact `userdetails:*:rw`, and `userdetails:*:r`, `userdetails:*:rwa` and any numeric id are refused at grant time. Administrative authority is always capped by the human who approved the authorization -- it is re-checked against their live role on the entity on every request, so a credential carrying `rwa` for an org or workspace stops administering it the moment that person loses admin there. **`userdetails` is an entity type, not a scope type.** `userdetails:*:rw` can appear in a credential's granted `scopes` list, where it gates account-settings operations -- changing the password or email address, enrolling in two-factor authentication and verifying that enrolment, and invalidating every session on the account. It is **not** one of the scope-type selectors above, is never present in `scopes_supported`, and must never be sent by a client in the consent `scopes` list. It is appended server-side, and only when the authorization was initiated with `account_settings=1`. No `user:*` grant satisfies it at any access mode -- it has to be held explicitly. **Both ceilings are set at initiate, and only at initiate.** `access_mode` and `account_settings` are stored with the authorization request and bound to it. Consent may narrow them but can never widen them, so **a client that does not send them cannot obtain administrative or account-settings access through OAuth at all**, and the consent screen does not offer either unprompted. At initiate, a non-empty `access_mode` value outside `r`/`rw`/`rwa` is refused with `406` and error code `180242`; an empty `access_mode` value is treated as omitted. An `account_settings` value other than `1`, `0`, an empty value, or omission -- including the strings `"true"` and `"false"` -- is refused with `406` and error code `117053`; an empty or omitted `account_settings` initiates with account settings off. **Scope format in responses:** API responses (e.g., `GET /current/auth/scopes/` and `POST /current/oauth/token/`) return scopes as arrays of `entity_type:entity_id:access_mode` strings (e.g., `["org:3814271023567182934:rw"]`). The token endpoint and the OAuth session endpoints (`/current/oauth/sessions/`) return `scopes` as a JSON-encoded string that must be parsed. See the Token Scope Introspection section below for the full response format. ```bash # Standard browser flow (302 redirect) curl -v "https://api.fast.io/current/oauth/authorize/?client_id=my-app&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback&response_type=code&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&state=abc123xyz" # JSON mode (programmatic) curl "https://api.fast.io/current/oauth/authorize/?client_id=my-app&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback&response_type=code&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&state=abc123xyz&response_format=json" ``` **Response (default -- 302 Found):** ``` Location: https://go.fast.io/connect?auth_request_id={auth_request_id} ``` **Response (response_format=json -- 200 OK):** ```json { "result": true, "auth_request_id": "{auth_request_id}", "client_name": "My App", "scope": "user", "login_url": "https://go.fast.io/connect?auth_request_id={auth_request_id}" } ``` **Response Fields (JSON mode):** | Field | Type | Description | |-------|------|-------------| | `result` | boolean | `true` on success | | `auth_request_id` | string | 64-character hex identifier for this authorization request (valid for 10 minutes) | | `client_name` | string | Human-readable name of the OAuth client application | | `scope` | string | The requested scope type as resolved at initiate: the broadest recognised value, or `user` when `scope` was omitted (a value made only of ignored OpenID Connect words is echoed as sent). This is the request, not the grant -- read `scopes` in the token response | | `agent_name` | string | Agent display name (present if `agent_name` was provided) | | `login_url` | string | The URL to open in the user's browser for this authorization -- the same page the default 302 redirects to. **Open it verbatim**; do not build it yourself from `auth_request_id`. The user signs in there (on the Fastio sign-in page when they use email and password) and approves access | **Error Responses (standard platform envelope):** | Scenario | Error Code | HTTP Status | |----------|-----------|-------------| | `client_id` missing | `1605 (Invalid Input)` | 406 | | `redirect_uri` missing | `1605 (Invalid Input)` | 406 | | `code_challenge` missing | `1605 (Invalid Input)` | 406 | | `code_challenge_method` missing | `1605 (Invalid Input)` | 406 | | `code_challenge_method` not `"S256"` | `1605 (Invalid Input)` | 406 | | `code_challenge` not 43 characters | `1605 (Invalid Input)` | 406 | | `state` missing | `1605 (Invalid Input)` | 406 | | `client_id` not found | `1605 (Invalid Input)` | 406 | | Client is disabled | `1605 (Invalid Input)` | 406 | | `redirect_uri` mismatch | `1605 (Invalid Input)` | 406 | | Invalid `resource` indicator | `1605 (Invalid Input)` | 406 | | `scope` names none of the seven scope types and is not made only of ignored OpenID Connect words (`invalid_scope`) | `138232` | 406 | | `agent_name` is a reserved name | `125566` | 406 | | `display_code` not `"true"`, `"false"`, `"1"` or `"0"` | `109495` | 406 | | CIMD `client_id` document could not be validated | `106954` | 406 | | `access_mode` non-empty and not `"r"`, `"rw"` or `"rwa"` | `180242` | 406 | | `account_settings` non-empty and not `"1"` or `"0"` | `117053` | 406 | | Internal storage failure | `1654 (Internal Error)` | 500 | --- #### POST /current/oauth/authorize/ Complete authorization after user login. Issues the authorization code. Called by the web application (not the client directly) with the user's active session. **Auth:** Required (JWT -- website session) **Request Parameters:** | Parameter | Type | Required | Default | Constraints | Description | |-----------|------|----------|---------|-------------|-------------| | `auth_request_id` | string | Yes | -- | 64-character hex | The authorization request ID from the GET request | | `scopes` | string | No | -- | Non-empty JSON array of entity IDs or scope strings | Entity IDs for scoped access (e.g., `"[3814271023567182934, 3829104758203948571]"`) or explicit scope strings (e.g., `'["org:3814271023567182934:rw"]'`) | | `access_mode` | string | No | The value stored at initiate | `"r"`, `"rw"` or `"rwa"` -- never broader than the initiated ceiling | Access level for scoped tokens | | `account_settings` | string | No | `"0"` | `"1"` or `"0"`; `"1"` only when the authorization was initiated with `account_settings=1` | Include account-settings access in the grant | | `agent_name` | string | No | -- | Max 128 characters | Override the agent display name from the GET request | **What consent may and may not do:** - **`access_mode` may narrow, never widen.** Omitting it uses the value stored at initiate (`rw` when the initiate request did not send one). Asking for a broader mode is refused with `403` and error code `10768` (`params.reason` is `access_mode_exceeds_initiate`). A value that is not `r`, `rw` or `rwa` is refused with `406` and error code `161579`. - **`scopes`, when supplied, must be a non-empty JSON list.** `[]`, `{}`, `""`, a scalar, a nested array and malformed JSON are all refused with `406` and error code `168998` (or `128552` when the value parses but yields nothing usable). - **`scopes` is now required for `scope=org` and `scope=workspace`.** Those scope types ask the user to pick entities, so omitting the list is refused with `406` and error code `121987`. The wildcard scope types (`user`, `all_orgs`, `all_workspaces`, `all_shares`, `all_sign_envelopes`) resolve to their own default set at the consented access mode when `scopes` is omitted, and a `scope` made only of ignored OpenID Connect words resolves to the `user` type (any other unrecognised `scope` is refused at initiate). - **For `scope=org` and `scope=workspace`, an explicit `scopes` list may narrow within the initiated scope type but may not name a different entity type** (`406` `149822`). A `scope=org` authorization can be consented to fewer orgs; it cannot be consented to a workspace. A `scope=user` authorization is the whole account, so its list may name any entity type the human holds. - **Every granted scope is capped at the stored access mode.** A scope string asking for more is refused with `403` and error code `10768` (`access_mode_exceeds_initiate`). - **A client-supplied `userdetails` scope is always refused** with the same code and reason. Clients must never put `userdetails:*:rw` in `scopes`. - **`account_settings=1` appends `userdetails:*:rw` server-side**, and only when the initiate request carried `account_settings=1`. Sending it on an authorization that was not initiated with it is refused with `403` and error code `10768` (`access_mode_exceeds_initiate`). - **Each authorization request can be consented to once.** The request is claimed atomically just before the code is issued, so a second -- or concurrent -- consent for the same `auth_request_id` is refused with `404` ("The authorization request is invalid or has expired."), and `GET /current/oauth/authorize/info/` then reports `valid: false`. A consent refused by one of the checks in this list does not use the request up. - **The consent request itself must be made with an account-level credential.** A narrowed credential is refused with `403` and error code `10175`, and the granted set may not exceed what that credential holds -- broader is refused with `403` and error code `10768` (`params.reason` is `scope_exceeds_issuer`). - **The 20-scope cap is re-checked after the server-side append.** More than 20 scopes is refused with `406` and error code `157349`. - **The granted scopes are checked against the governing org's `credential_policy`**, when the human's organization has configured one (Enterprise plan). A scope whose mode exceeds the org's configured ceiling, or whose entity type the org's policy does not allow, is refused with `403` and `params.reason` = `credential_policy_mode` or `credential_policy_scope`. Branch on the reason, never on the numeric code. This runs as soon as the granted scopes have been validated -- after the account-level-credential and `access_mode` checks, but **before** the per-scope ceiling, `account_settings`, 20-scope-cap and scope-exceeds-issuer checks above -- against each granted scope's own owning org — see *Org Credential Policy* in `llms/auth.txt`. Unlike the other consent-time checks, this one is **also** re-evaluated on every later request the resulting token makes; a working OAuth grant can start failing this way if the org tightens its policy after consent. ```bash curl -X POST "https://api.fast.io/current/oauth/authorize/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "auth_request_id={auth_request_id}" ``` **Response (200 OK):** ```json { "result": true, "redirect_uri": "http://localhost:8080/callback?code=abc123def456abc123def456abc123def456abc123def456abc123def456abc123de&state=abc123xyz", "redirect_mode": "redirect" } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `result` | boolean | `true` on success | | `redirect_uri` | string | Full redirect URI with `code` (64 hex chars, valid 5 min, single-use) and `state` query parameters | | `redirect_mode` | string | `"redirect"` for HTTP/HTTPS redirect URIs, `"button"` for custom scheme URIs, `"display_code"` when the authorization was initiated with `display_code=true` (show the `code` from `redirect_uri` for the user to copy instead of navigating) | | `button_label` | string or null | Custom button label for `"button"` redirect mode (if configured) | **Error Responses:** | Scenario | Error Code | HTTP Status | |----------|-----------|-------------| | User not authenticated | `1650 (Authentication Invalid)` | 401 | | `auth_request_id` missing | `1605 (Invalid Input)` | 406 | | Request expired, not found, or already consented to | `1609 (Not Found)` | 404 | | Internal processing error | `1654 (Internal Error)` | 500 | | `agent_name` override is a reserved name | `108127` | 406 | | `access_mode` is not `r`, `rw` or `rwa` | `161579` | 406 | | `access_mode` broader than the initiated ceiling | `10768` (`access_mode_exceeds_initiate`) | 403 | | A granted scope is broader than the stored access mode | `10768` (`access_mode_exceeds_initiate`) | 403 | | `scopes` supplied but not a non-empty JSON list | `168998` or `128552` | 406 | | `scopes` omitted for `scope=org` or `scope=workspace` | `121987` | 406 | | Client-supplied `userdetails` scope | `10768` (`access_mode_exceeds_initiate`) | 403 | | `account_settings=1` on an authorization not initiated with it | `10768` (`access_mode_exceeds_initiate`) | 403 | | Consent made with a narrowed (not account-level) credential | `10175` | 403 | | Granted set broader than the calling credential | `10768` (`scope_exceeds_issuer`) | 403 | | More than 20 scopes after the server-side append | `157349` | 406 | | Scope validation failed | `1605 (Invalid Input)` | 406 | | Granted scope exceeds the governing org's `credential_policy` (`params.reason` = `credential_policy_mode` or `credential_policy_scope`) | `116292` | 403 | | The governing org's stored `credential_policy` is corrupt (`params.reason` = `credential_policy_unreadable`) | `116292` | 403, permanent | | A granted scope names an entity whose owning org no longer exists (`params.reason` = `credential_policy_org`) | `116292` | 403, permanent | | The governing org, or its `credential_policy`, could not be READ (`params.reason` = `credential_policy_unavailable`) | `116292` | 503, retry | --- #### GET /current/oauth/authorize/info/ Validate an authorization request and return client information (app name, requested scope). Used by the web frontend to display a consent screen before the user confirms. **Auth:** None **Query Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `auth_request_id` | string | Yes | The authorization request ID to validate | ```bash curl "https://api.fast.io/current/oauth/authorize/info/?auth_request_id={auth_request_id}" ``` **Response (valid request -- 200 OK):** ```json { "result": true, "valid": true, "client_name": "My App", "scope": "user", "agent_name": "My MCP Agent", "redirect_mode": "redirect", "redirect_uri": "http://localhost:8080/callback", "resource": "https://mcp.fast.io/mcp", "access_mode": "rwa", "account_settings": true } ``` `resource` is the RFC 8707 resource indicator the client supplied at initiate (or `null`), `access_mode` is the requested access mode (`"r"`, `"rw"` or `"rwa"`, or `null` when the client did not supply one), and `account_settings` is a boolean that is **always present** and says whether the client asked for account-settings access. All three are returned server-authoritatively from the stored authorization request so consent screens can render them without trusting URL parameters. Both ceilings are the client's request, not the user's choice: show the administrative wording only for the access mode that was actually initiated, and show the account-settings toggle **only when `account_settings` is `true`**. That toggle defaults to off -- the user opts in to it, and a consent that leaves it off grants no account-settings access. **Response (invalid or expired -- 200 OK):** ```json { "result": true, "valid": false } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `result` | boolean | Always `true` | | `valid` | boolean | `true` if the auth request is valid and client is active | | `client_name` | string | Client name (only when `valid` is `true`) | | `scope` | string | Requested scope (only when `valid` is `true`) | | `agent_name` | string | Agent display name (only when set and `valid` is `true`) | | `redirect_mode` | string | `"redirect"`, `"button"`, or `"display_code"` when the authorization was initiated with `display_code=true` (only when `valid` is `true`) | | `button_label` | string | Custom button label (only when configured and `valid` is `true`) | | `redirect_uri` | string | Client redirect URI (only when `valid` is `true`) | | `resource` | string or null | RFC 8707 resource indicator supplied at initiate, or `null` (only when `valid` is `true`) | | `access_mode` | string or null | Access-mode ceiling requested at initiate: `"r"`, `"rw"`, `"rwa"`, or `null` when the client did not supply one (only when `valid` is `true`) | | `account_settings` | boolean | Always present when `valid` is `true`. `true` when the client asked for account-settings access at initiate | **Notes:** - This endpoint never returns an error for invalid `auth_request_id`. It returns `valid: false` instead, preventing information leakage about authorization request existence. --- ### Step 3: User Approves in Browser The user opens the authorization URL (the `login_url` from JSON mode, or the 302 target), signs in (supports SSO; email-and-password users sign in on the Fastio sign-in page, which also handles 2FA), and approves access. The browser either: - Redirects to `redirect_uri` with `?code={authorization_code}&state={state}` - Displays the authorization code for the user to copy back to the agent (when `redirect_mode` is `"button"` or `"display_code"`) **Loopback redirect URIs (native apps, RFC 8252).** A client registered with an `http` loopback redirect URI such as `http://127.0.0.1:8080/callback` may send the same URI with **any port** at authorize time, so a CLI or desktop app can listen on an ephemeral port. Only the port may differ -- scheme, host, path and query must match the registered URI exactly, and `localhost` and `127.0.0.1` are different hosts. The token request must send exactly the `redirect_uri` used at authorize, including the port. ### Step 4: Exchange Code for Tokens #### POST /current/oauth/token/ Exchange an authorization code for access and refresh tokens, or refresh an existing access token. **Auth:** None **Content-Type:** `application/x-www-form-urlencoded` **Important:** This endpoint returns **bare JSON responses** (RFC 6749 format, no platform envelope) for compatibility with standard OAuth clients, including MCP hosts. ##### Authorization Code Exchange **Request Parameters:** | Parameter | Type | Required | Constraints | Description | |-----------|------|----------|-------------|-------------| | `grant_type` | string | Yes | Must be `"authorization_code"` | Grant type | | `code` | string | Yes | 64-character hex | Authorization code from the callback | | `code_verifier` | string | Yes | 43-128 characters, `[A-Za-z0-9-._~]` | Original PKCE code verifier | | `client_id` | string | Yes | Must match original request | OAuth client ID | | `redirect_uri` | string | Yes | Must match original request | Redirect URI | | `resource` | string | No | Must exactly match the value from the authorize request | RFC 8707 resource indicator URL | | `device_name` | string | No | -- | Human-readable device name for session tracking | | `device_type` | string | No | -- | Device category for session tracking | ```bash curl -X POST "https://api.fast.io/current/oauth/token/" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code&code=abc123def456abc123def456abc123def456abc123def456abc123def456abc123de&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk&client_id=my-app&redirect_uri=http://localhost:8080/callback" ``` **Response (200 OK -- bare JSON, no envelope):** ```json { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "{refresh_token}", "scope": "org", "scopes": "[\"org:3814271023567182934:rw\",\"org:3829104758203948571:rw\"]", "agent_name": "My MCP Agent" } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `access_token` | string | JWT for API requests. Use as `Authorization: Bearer {access_token}`. Expires in 1 hour. | | `token_type` | string | Always `"Bearer"` | | `expires_in` | integer | Token lifetime in seconds (3600 = 1 hour) | | `refresh_token` | string | Long-lived opaque token (64-character hex string) for obtaining new access tokens. Store securely. Returned unchanged on refresh (no rotation). | | `scope` | string | The scope type of the grant, derived from the granted `scopes` (not echoed from the request): `user`, `all_orgs`, `all_workspaces`, `all_shares`, `all_sign_envelopes`, `org` or `workspace`. A grant spanning several types reports the broadest; a grant on specific shares or sign envelopes reports `all_shares` / `all_sign_envelopes`. On refresh it reflects any narrowing. Not a scope list -- persist `scopes`. | | `scopes` | string | JSON-encoded array of granted scope strings in `entity_type:entity_id:access_mode` format. **Emitted for every grant.** This is the field to persist. | | `agent_name` | string | Agent display name (present when set during authorization) | **`scope` and `scopes` are different fields -- persist `scopes`.** `scope` (singular) is a single scope-type word summarising the grant; `scopes` (plural) is the JSON-encoded list of granted entity scopes, and it is the only one that describes what the token can actually do. **`scopes` is now returned for every grant, including a plain `scope=user` grant**, which is stored explicitly as `["user:*:rw"]` instead of carrying no list at all. Two client habits break on this: - **Do not treat a missing `scopes` as "this is a full-access session."** Parse `scopes` and read the access mode. - **Do not branch on `auth_type === "jwt_v1"`.** Those grants now introspect as `jwt_v2` at `GET /current/auth/scopes/`. No `admin` or `legacy` field is added to this response -- it stays the RFC 6749 bare-JSON shape. To find out whether a token can perform administrative operations, call `GET /current/auth/scopes/` and read its `admin` field. ##### Refresh Token Exchange | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `grant_type` | string | Yes | Must be `"refresh_token"` | | `refresh_token` | string | Yes | Current valid refresh token | | `client_id` | string | Yes | Must match the original token request | | `device_name` | string | No | Updated device name for session tracking | | `device_type` | string | No | Updated device type for session tracking | ```bash curl -X POST "https://api.fast.io/current/oauth/token/" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=refresh_token&refresh_token={refresh_token}&client_id=my-app" ``` **Response (200 OK -- bare JSON):** Same format as authorization code exchange. Returns a new `access_token`. The `refresh_token` is long-lived and is returned unchanged (no per-refresh rotation). **Refresh narrows a demoted administrative grant.** A grant that names a **concrete** entity at `rwa` -- for example `org:3814271023567182934:rwa` -- comes back as `org:3814271023567182934:rw` once the person who approved it is no longer an admin of that org. The response's `scopes` echoes the narrowed set, and that narrowed set is then what the session holds, so persist `scopes` from every refresh rather than the value you stored at authorization. Wildcard `rwa` grants (for example `org:*:rwa`) are never narrowed this way -- they are capped live against the person's role on each request instead. Losing access to an entity entirely still revokes the grant, as before. **Error Responses (RFC 6749 SS5.2 format -- bare JSON):** ```json { "error": "invalid_grant", "error_description": "The authorization code is invalid or has expired." } ``` | Scenario | RFC 6749 Error | HTTP Status | |----------|---------------|-------------| | Missing/invalid parameters | `invalid_request` | 400 | | Invalid `grant_type` value | `unsupported_grant_type` | 400 | | `code_verifier` invalid length (not 43-128 chars) | `invalid_request` | 400 | | Unrecognized `resource` indicator | `invalid_request` | 400 | | Invalid, expired or already-used authorization code (codes are single-use: a repeated or concurrent exchange of the same code is refused) | `invalid_grant` | 400 | | PKCE `code_verifier` mismatch | `invalid_grant` | 400 | | `client_id` mismatch | `invalid_grant` | 400 | | `redirect_uri` mismatch | `invalid_grant` | 400 | | Resource indicator mismatch (RFC 8707) | `invalid_grant` | 400 | | Invalid/expired/revoked refresh token | `invalid_grant` | 400 | | `client_id` mismatch on refresh | `invalid_grant` | 400 | | Inactive user account | `invalid_grant` | 400 | | Per-user active-session cap reached on authorization-code exchange (`error_description` begins `"You have reached the maximum number of active connections"`) -- revoke an existing session via `DELETE /current/oauth/sessions/{session_id}/` and retry | `invalid_grant` | 400 | | Internal server failure | `server_error` | 500 | **Resource Indicator Enforcement:** The `resource` value is stored in the authorization code during the authorize step. At token exchange, the value is strictly compared. If they do not match -- including if one is `null` and the other is not -- the exchange fails. This prevents downgrade attacks where a client omits the resource to obtain an unrestricted token. --- ### Step 5: Use the Access Token Include the access token in all API requests: ``` Authorization: Bearer {access_token} ``` When the access token expires (after 1 hour), use the refresh token to get a new one without requiring user interaction. Refresh proactively before expiration (5-minute buffer recommended) to avoid interruptions. --- ## Token Revocation ### POST /current/oauth/revoke/ Revoke a refresh token (logout). Implements RFC 7009 (OAuth 2.0 Token Revocation). Always returns success to prevent token enumeration attacks. **Auth:** None **Content-Type:** `application/x-www-form-urlencoded` **Request Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `token` | string | Yes | The refresh token to revoke | ```bash curl -X POST "https://api.fast.io/current/oauth/revoke/" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "token={refresh_token}" ``` **Response (200 OK):** ```json { "result": true } ``` **Error Responses (RFC 6749 format -- bare JSON):** | Scenario | Error | HTTP Status | |----------|-------|-------------| | `token` parameter missing | `invalid_request` | 400 | **Notes:** - Per RFC 7009, this endpoint always returns success regardless of whether the token was found, was already revoked, or never existed. - Always call this endpoint on user logout and clear local token storage regardless of the response. --- ## Session Management OAuth sessions represent active token grants. Each authorization code exchange creates a session. Sessions have a stable `session_id` (32-character hex string) that persists across refreshes. ### GET /current/oauth/sessions/ List all active (non-expired, non-revoked) OAuth sessions for the authenticated user. **Auth:** Required (Bearer JWT) ```bash curl -X GET "https://api.fast.io/current/oauth/sessions/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "sessions": [ { "session_id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "client_id": "my-app", "scopes": "[\"org:3814271023567182934:rw\"]", "admin": false, "legacy": false, "agent_name": "My MCP Agent", "device_name": "Chrome on macOS", "device_type": "desktop", "ip_address": "203.0.113.42", "last_used": "2026-01-22 14:00:00 UTC", "created": "2026-01-21 14:00:00 UTC", "expires": "2036-01-21 14:00:00 UTC", "country": "US", "last_ip": "203.0.113.42", "last_country": "US", "mcp": false } ], "count": 1 } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `result` | boolean | `true` on success | | `sessions` | array | List of active session objects | | `sessions[].session_id` | string | 32-character hex session identifier | | `sessions[].client_id` | string | OAuth client that created the session | | `sessions[].scopes` | string or null | **JSON-encoded** array of granted scope strings in `entity_type:entity_id:access_mode` format -- the same encoding as `scopes` in the token response, so parse it. A `user` scope grant is stored explicitly as `"[\"user:*:rw\"]"`; the value is `null` only for a session minted before scoped tokens existed. | | `sessions[].admin` | boolean | `true` when the session carries at least one `rwa` scope, and can therefore perform administrative operations | | `sessions[].legacy` | boolean | `true` when the session declares no scopes claim at all -- a grant minted before scoped tokens existed. `legacy` never implies `admin`, and `admin` never implies `legacy`. | | `sessions[].agent_name` | string or null | Agent display name if set during authorization, otherwise `null` | | `sessions[].device_name` | string or null | Human-readable device description (the `device_name` the client sent, or set via PATCH), otherwise `null` | | `sessions[].device_type` | string or null | Device category as sent by the client in `device_type` at the token endpoint (free-form, e.g. `desktop`), otherwise `null` | | `sessions[].ip_address` | string or null | IP address from the most recent refresh (overwritten on every refresh) | | `sessions[].last_used` | string or null | Last token refresh timestamp (`YYYY-MM-DD HH:MM:SS UTC`), `null` if never refreshed. Access tokens are not DB-checked per request, so this reflects the last refresh, not the last API call. | | `sessions[].created` | string | Session creation timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `sessions[].expires` | string | Session expiration timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `sessions[].country` | string or null | ISO-3166 alpha-2 country of the IP that **created** the session. Set once, never updated. `null` for a session minted before this field existed. | | `sessions[].last_ip` | string or null | Client IP of the most recent refresh. Written periodically, not on every refresh. `null` if never refreshed since tracking began. | | `sessions[].last_country` | string or null | ISO-3166 alpha-2 country of `last_ip`. Same write cadence. | | `sessions[].mcp` | boolean | `true` when this grant's OAuth `resource` (token audience) is the Fastio MCP server; `false` for a grant whose `resource` is an ordinary API audience, and `false` when no `resource` was recorded on the grant at all. Matches the `mcp` field on org credential-inventory rows (see *Compliance & Audit* in `llms/orgs.txt`). | | `count` | integer | Total number of active sessions | **Error Responses:** | Scenario | Error Code | HTTP Status | |----------|-----------|-------------| | Not authenticated | `1650 (Authentication Invalid)` | 401 | | Internal error | `1654 (Internal Error)` | 500 | --- ### GET /current/oauth/sessions/{session_id}/ Get details of a specific OAuth session. The session must belong to the authenticated user. **Auth:** Required (Bearer JWT) **Path Parameters:** | Parameter | Type | Required | Constraints | Description | |-----------|------|----------|-------------|-------------| | `session_id` | string | Yes | 32 hexadecimal characters | Session identifier | ```bash curl -X GET "https://api.fast.io/current/oauth/sessions/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "session": { "session_id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "client_id": "my-app", "scopes": "[\"org:3814271023567182934:rw\"]", "admin": false, "legacy": false, "agent_name": "My MCP Agent", "device_name": "Chrome on macOS", "device_type": "desktop", "ip_address": "203.0.113.42", "last_used": "2026-01-22 14:00:00 UTC", "created": "2026-01-21 14:00:00 UTC", "expires": "2036-01-21 14:00:00 UTC", "country": "US", "last_ip": "203.0.113.42", "last_country": "US", "mcp": false } } ``` Field meanings are the same as the list response above (`country` = creation IP, never updated; `last_ip`/`last_country` = last refresh, written periodically, not on every refresh; `mcp` = whether this grant's audience is the Fastio MCP server). **Error Responses:** | Scenario | Error Code | HTTP Status | |----------|-----------|-------------| | Not authenticated | `1650 (Authentication Invalid)` | 401 | | `session_id` missing | `1605 (Invalid Input)` | 406 | | `session_id` not 32 hex chars | `1605 (Invalid Input)` | 406 | | Session not found or wrong user | `1609 (Not Found)` | 404 | | Internal error | `1654 (Internal Error)` | 500 | --- ### PATCH /current/oauth/sessions/{session_id}/ Update the `device_name`, `agent_name` and/or granted `scopes` of a specific OAuth session. The session must belong to the authenticated user and must not be revoked. **Auth:** Required (Bearer JWT) **Path Parameters:** | Parameter | Type | Required | Constraints | Description | |-----------|------|----------|-------------|-------------| | `session_id` | string | Yes | 32 hexadecimal characters | Session identifier | **Body Parameters:** | Parameter | Type | Required | Constraints | Description | |-----------|------|----------|-------------|-------------| | `device_name` | string | No | Max 128 characters. Empty string clears to null. | New device name | | `agent_name` | string | No | Max 128 characters. Empty string clears to null. | New agent name | | `scopes` | string | No | Non-empty JSON array of scope strings, narrower than or equal to what the session already holds | Narrow the session's granted scopes | At least one of `device_name`, `agent_name` or `scopes` must be provided -- a `scopes`-only request is valid. **Narrowing only.** `scopes` is a JSON-encoded list of `entity_type:entity_id:access_mode` strings, the same shape an API key uses. It must be **narrower than or equal to both** what the session already holds **and** what the calling credential holds; a broader list is refused with `403` and error code `10768`. There is no widening path -- a session cannot grow, and neither can the credential asking on its behalf. Narrowing takes effect for the access token at the session's next refresh, so an access token already issued keeps its old scopes until it expires. `[]` -- or a value that does not parse as a list of scope strings -- is refused with `406` and error code `136396`: an empty scope set is not a way to disable a session. To end a session entirely, use `DELETE /current/oauth/sessions/{session_id}/`. ```bash curl -X PATCH "https://api.fast.io/current/oauth/sessions/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "device_name=My%20Work%20Laptop" ``` **Response (200 OK):** ```json { "result": true, "session": { "session_id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "client_id": "my-app", "scopes": "[\"org:3814271023567182934:rw\"]", "admin": false, "legacy": false, "agent_name": "My MCP Agent", "device_name": "My Work Laptop", "device_type": "desktop", "ip_address": "203.0.113.42", "last_used": "2026-01-22 14:00:00 UTC", "created": "2026-01-21 14:00:00 UTC", "expires": "2036-01-21 14:00:00 UTC", "country": "US", "last_ip": "203.0.113.42", "last_country": "US", "mcp": false } } ``` **Error Responses:** | Scenario | Error Code | HTTP Status | |----------|-----------|-------------| | Not authenticated | `1650 (Authentication Invalid)` | 401 | | `session_id` missing | `1605 (Invalid Input)` | 406 | | `session_id` not 32 hex chars | `1605 (Invalid Input)` | 406 | | None of `device_name`, `agent_name` or `scopes` provided | `1605 (Invalid Input)` | 406 | | `device_name` exceeds 128 chars | `1605 (Invalid Input)` | 406 | | `agent_name` exceeds 128 chars | `1605 (Invalid Input)` | 406 | | `agent_name` is a reserved name | `121806` | 406 | | `scopes` is `[]` or unparseable | `136396` | 406 | | `scopes` broader than the scopes the session holds | `10768` (`scope_exceeds_issuer`) | 403 | | `scopes` broader than the calling credential | `10768` (`scope_exceeds_issuer`) | 403 | | `scopes` no longer grantable to this user | `176886` | 406 | | The session's scopes changed while the request was in flight -- re-read the session and retry | `172160` | 409 | | Session is revoked | `1605 (Invalid Input)` | 406 | | Session not found or wrong user | `1609 (Not Found)` | 404 | | Internal error | `1654 (Internal Error)` | 500 | --- ### DELETE /current/oauth/sessions/{session_id}/ Revoke a specific OAuth session. The session must belong to the authenticated user. This operation is idempotent -- if the session is already revoked, success is still returned. **Auth:** Required (Bearer JWT) **Path Parameters:** | Parameter | Type | Required | Constraints | Description | |-----------|------|----------|-------------|-------------| | `session_id` | string | Yes | 32 hexadecimal characters | Session identifier | ```bash curl -X DELETE "https://api.fast.io/current/oauth/sessions/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "message": "Session has been revoked." } ``` **Error Responses:** | Scenario | Error Code | HTTP Status | |----------|-----------|-------------| | Not authenticated | `1650 (Authentication Invalid)` | 401 | | `session_id` missing | `1605 (Invalid Input)` | 406 | | `session_id` not 32 hex chars | `1605 (Invalid Input)` | 406 | | Session not found or wrong user | `1609 (Not Found)` | 404 | | Internal error | `1654 (Internal Error)` | 500 | --- ### DELETE /current/oauth/sessions/ Revoke all OAuth sessions (logout everywhere). Optionally exclude the current session for "log out everywhere else" functionality. **Auth:** Required (Bearer JWT) **Query Parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `exclude_current` | string | No | -- | Set to `"true"` or `"1"` to keep the current session active | | `current_session_id` | string | No | -- | Session ID to preserve when `exclude_current` is set. If omitted (or `exclude_current` is unset), all sessions are revoked. | ```bash # Revoke ALL sessions curl -X DELETE "https://api.fast.io/current/oauth/sessions/" \ -H "Authorization: Bearer {jwt_token}" # Revoke all EXCEPT current session curl -X DELETE "https://api.fast.io/current/oauth/sessions/?exclude_current=true¤t_session_id=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "message": "All sessions have been revoked." } ``` Or when `exclude_current` is used: ```json { "result": true, "message": "All other sessions have been revoked." } ``` **Error Responses:** | Scenario | Error Code | HTTP Status | |----------|-----------|-------------| | Not authenticated | `1650 (Authentication Invalid)` | 401 | | Internal error | `1654 (Internal Error)` | 500 | **Notes:** - When `exclude_current` is `"true"` but `current_session_id` is not provided, all sessions are revoked. --- ## Token Scope Introspection ### GET /current/auth/scopes/ Returns the current token's scope information, auth type, and agent status. Enables clients to discover their token's capabilities without decoding the JWT. **Auth:** Required (Bearer JWT or API Key) ```bash curl -X GET "https://api.fast.io/current/auth/scopes/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "auth_type": "jwt_v2", "scopes": ["org:3814271023567182934:rwa", "org:3829104758203948571:rw"], "scopes_detail": [ { "entity_type": "org", "entity_id": "3814271023567182934", "access_mode": "rwa", "admin": true, "name": "Acme Corp", "domain": "acme" }, { "entity_type": "org", "entity_id": "3829104758203948571", "access_mode": "rw", "admin": false, "name": "Beta Inc", "domain": "beta" } ], "is_agent": true, "agent_name": "My MCP Agent", "full_access": false, "admin": true, "legacy": false } ``` **Response (200 OK -- API-key auth with scopes and expiration):** ```json { "result": true, "auth_type": "api_key_scoped", "scopes": ["org:3814271023567182934:rw"], "scopes_detail": [ { "entity_type": "org", "entity_id": "3814271023567182934", "access_mode": "rw", "admin": false, "name": "Acme Corp", "domain": "acme" } ], "is_agent": true, "agent_name": "CI Pipeline", "full_access": false, "admin": false, "legacy": false, "expires": "2026-12-31 23:59:59 UTC" } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `result` | boolean | `true` on success | | `auth_type` | string | `"jwt_v2"` (scoped JWT, or a sign-in session that declared `agent_name` -- still unscoped, so `legacy` stays `true`), `"jwt_v1"` (browser login session without a declared agent), `"api_key"` (API key with no scopes claim), or `"api_key_scoped"` (API key whose stored token record has a non-empty scopes claim). An OAuth grant for `scope=user` now reports `jwt_v2`, not `jwt_v1` -- do not branch on `jwt_v1` to detect a full-access token. | | `scopes` | array | Scope strings in `entity_type:entity_id:access_mode` format. Empty only for a credential that declares no scopes claim at all (a browser login session -- including one that declared `agent_name`, which is unscoped -- or a pre-scopes API key). | | `scopes_detail` | array | Hydrated scope objects with entity names and metadata. Empty under the same conditions as `scopes`. | | `is_agent` | boolean | Whether the token represents an agent. `true` for a sign-in session that declared `agent_name` at `GET /current/user/auth/` (the name is reported in `agent_name`). | | `agent_name` | string or null | Agent display name if set, otherwise `null` | | `full_access` | boolean | The credential is account-wide **and may write**: `true` for `user:*:rw` and `user:*:rwa`, and for a credential that declares no scopes claim. **`user:*:r` is `false`** -- it is account-wide but read-only. | | `admin` | boolean | The credential can perform administrative operations. `true` for a browser login session, and for any credential holding an `rwa` scope. | | `legacy` | boolean | The credential declares no scopes claim at all -- a pre-scopes API key or OAuth session, and every browser login session. It is not a synonym for "old API key". A legacy API key or OAuth grant behaves as `user:*:rw`: whole-account read and write, no administration and no account settings. | | `expires` | string | API-key auth only: token expiration timestamp in canonical `YYYY-MM-DD HH:MM:SS UTC` format. Omitted entirely when the token has no expiration set or when auth is JWT-based. | **How the three booleans combine:** | Credential | `full_access` | `admin` | `legacy` | `auth_type` | |---|---|---|---|---| | `user:*:rwa` | true | true | false | `jwt_v2` / `api_key_scoped` | | `user:*:rw` | true | false | false | `jwt_v2` / `api_key_scoped` | | `user:*:r` | false | false | false | `jwt_v2` / `api_key_scoped` | | scoped, e.g. `org:123:rwa` | false | true | false | `jwt_v2` / `api_key_scoped` | | legacy key with no scopes | true | false | true | `api_key` | | browser login session | true | true | true | `jwt_v1` | | sign-in session that declared `agent_name` | true | true | true | `jwt_v2` | **Scope Detail Fields:** Each entry in `scopes_detail` contains entity-specific fields: | Field | Type | Present For | Description | |-------|------|-------------|-------------| | `entity_type` | string | All | `user`, `org`, `workspace`, `share`, `sign_envelope`, `fileshare`, `memory`, or `userdetails` | | `entity_id` | string | All | Numeric ID or `*` for wildcard | | `access_mode` | string | All | `r` (read), `rw` (read/write) or `rwa` (read/write/administer) | | `admin` | boolean | All | `true` when this entry's access mode is `rwa`, so a client need not re-parse the access mode | | `label` | string | Full access / wildcard | Human-readable label. `user:*:rw` is "Full Access", `user:*:rwa` is "Full Access (Admin)", `user:*:r` is "Entire account (Read Only)", `userdetails:*:rw` is "Account settings", and other wildcards read like "All Organizations (Read/Write/Admin)" | | `name` | string | Org, Workspace | Entity display name | | `domain` | string | Org | Organization subdomain | | `folder_name` | string | Workspace | Workspace URL slug | | `org_id` | string | Workspace, Share, Sign envelope, File share | Parent organization ID | | `org_name` | string | Workspace, Share, Sign envelope, File share | Parent organization name | | `org_domain` | string | Workspace, Share, Sign envelope, File share | Parent organization subdomain | | `title` | string | Share | Share display title | | `share_type` | string | Share | `send`, `receive`, or `exchange` | | `envelope_status` | string | Sign envelope | The envelope's current status | | `workspace_id` | string | Share, Sign envelope, File share | Parent workspace ID | | `workspace_name` | string | Share, Sign envelope, File share | Parent workspace name | | `load_error` | string | On failure | `"Entity not found"` if the referenced entity could not be loaded | **Error Responses:** | Scenario | Error Code | HTTP Status | |----------|-----------|-------------| | Not authenticated | `1650 (Authentication Invalid)` | 401 | | Wrong HTTP method | `1651 (Invalid Request Type)` | 405 | --- ## Complete PKCE Example Flow Here is a complete example of the PKCE authorization flow: ``` 0. CLIENT -> API: Discover server configuration (optional) GET /.well-known/oauth-authorization-server/ -> Returns endpoints, grant types, PKCE methods, resource_indicators_supported 0b. CLIENT -> API: Register client dynamically (if no client_id) POST /current/oauth/register/ Content-Type: application/json {"client_name": "My Agent", "redirect_uris": ["http://localhost:8080/callback"]} -> Returns client_id, registration_access_token OR use a CIMD URL as client_id (no registration needed) 1. CLIENT: Generate PKCE parameters code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" code_challenge = base64url(sha256(code_verifier)) = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" 2. CLIENT -> BROWSER: Open authorization URL in user's browser GET /current/oauth/authorize/ ?client_id=my-app &redirect_uri=http://localhost:8080/callback &response_type=code &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM &code_challenge_method=S256 &state=xyz123 &resource=https://mcp.fast.io/mcp 3. API -> BROWSER: 302 redirect to login/consent page (programmatic clients add &response_format=json and open the returned login_url verbatim) -> Browser lands on login page, user signs in, approves access 4. BROWSER -> CLIENT: Authorization code returned http://localhost:8080/callback?code=AUTH_CODE_HERE&state=xyz123 (or, with display_code=true, displayed on screen for the user to copy) 5. CLIENT -> API: Exchange code for tokens POST /current/oauth/token/ Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code=AUTH_CODE_HERE &code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk &client_id=my-app &redirect_uri=http://localhost:8080/callback &resource=https://mcp.fast.io/mcp 6. API -> CLIENT: Tokens returned (bare JSON) { "access_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "4f9a2c...", "scope": "user", "scopes": "[\"user:*:rw\"]" } 7. CLIENT -> API: Use access token for requests GET /current/user/details/ Authorization: Bearer eyJ... 8. CLIENT -> API: Refresh when access token expires POST /current/oauth/token/ Content-Type: application/x-www-form-urlencoded grant_type=refresh_token &refresh_token=4f9a2c... &client_id=my-app -> Returns a new access_token; the same refresh_token is returned unchanged 9. CLIENT -> API: Revoke on logout POST /current/oauth/revoke/ Content-Type: application/x-www-form-urlencoded token=4f9a2c... ``` --- ## Error Handling ### Token and Revoke Endpoints (RFC 6749 SS5.2) The token (`POST /current/oauth/token/`) and revoke (`POST /current/oauth/revoke/`) endpoints return RFC 6749 compliant error responses -- bare JSON, not the standard platform envelope: ```json { "error": "invalid_grant", "error_description": "The authorization code is invalid or has expired." } ``` ### Registration Endpoint (RFC 7591) The registration endpoint (`POST /current/oauth/register/` and `PUT /current/oauth/register/`) also returns bare JSON error responses with RFC 7591 error codes: ```json { "error": "invalid_client_metadata", "error_description": "The client_name must be between 1 and 128 characters." } ``` ### Other OAuth Endpoints All other OAuth endpoints (authorize, authorize/info, sessions) use the standard platform error envelope: ```json { "result": false, "error": { "code": 116775, "text": "The client_id is invalid or not found.", "documentation_url": "https://api.fast.io/llms.txt", "resource": "GET /current/oauth/authorize/" } } ``` | Scenario | Error Code | HTTP Status | |----------|-----------|-------------| | Rate limited | `1671 (Rate Limited)` | 429 | > Part of the Fastio API Reference. Overview: https://api.fast.io/current/llms/ # Enterprise Single Sign-On (Administration) Base URL: `https://api.fast.io/current/` All authenticated endpoints require: `Authorization: Bearer {jwt_token}` Enterprise Single Sign-On (SSO) lets an organization delegate authentication to its own identity provider. An org administrator connects **one** identity provider to the org, proves ownership of the email domains that provider speaks for, runs a configuration check, and then offers SSO to the org's users. **This document covers the administrative configuration surface only** — reading, writing, checking and removing an org's SSO configuration, and claiming, verifying and releasing its email domains. The sign-in flow the configuration switches on is summarised under *How a user signs in* below; its endpoint-by-endpoint reference — parameters, response fields and error tables — lives in the **Auth reference**, under *Enterprise SSO Sign-In*. Directory provisioning (SCIM) is documented separately. **Enterprise SSO is not the personal Google / Microsoft sign-in** available to any individual account. That is a per-user convenience that needs no org configuration. Enterprise SSO is org-wide policy, owned by an administrator, and scoped to email domains the org has proven it controls. > On the dedicated API hosts (`api.fast.io`) call `https://api.fast.io/current/org/{org_id}/sso/` with no `/api` prefix. From any other hostname (for example `go.fast.io`), include the `/api` prefix: `https://go.fast.io/api/current/org/{org_id}/sso/`. `{org_id}` accepts a 19-digit numeric org ID or the org's domain name, exactly as elsewhere in the Organizations reference. --- ## Who can call these endpoints Every configuration endpoint on this page — the `/current/org/{org_id}/sso/…` and `/current/org/{org_id}/scim/…` management endpoints — requires **org administrator** authority (admin or owner) on `{org_id}`. The sign-in endpoints this page marks as unauthenticated need no credential, and the SCIM protocol endpoints authenticate with the SCIM bearer token instead. A member or view-level caller is refused, and so is a credential that is not admin-capable — see *Scope errors* in the Auth reference for what "admin-capable" means for API keys and OAuth tokens. Reading the configuration is always allowed for an administrator. **Writing** is additionally plan-gated (below). --- ## Enterprise plan requirement Enterprise SSO is an **Enterprise plan** feature. Configuration writes on a lower plan are refused with `reason` = `plan_required`. **Actions that reduce exposure always work, on every plan:** | Action | Plan-gated? | |--------|-------------| | Read the configuration (`GET .../sso/`) | No | | Get provider presets (`GET .../sso/presets/`) | No | | Preview enforcement impact (`GET .../sso/enforcement/preview/`) | No | | Enable SSO, change protocol, credentials or mapping (`POST .../sso/`) | Yes | | Preview SAML metadata (`POST .../sso/metadata/preview/`) | Yes | | Run the configuration check (`POST .../sso/test/`) | Yes | | Claim a domain (`POST .../sso/domains/`) | Yes | | Verify a domain (`POST .../sso/domains/{domain}/verify/`) | Yes | | Step the mode down, including turning SSO off (`mode` alone, to a lower mode) | **No** | | Delete the configuration (`POST .../sso/delete/`) | **No** | | Release a domain (`POST .../sso/domains/{domain}/delete/`) | **No** | The asymmetry is deliberate: a plan change must never trap an org in a login configuration it can no longer manage. If an org moves off the Enterprise plan, an existing configuration keeps working and keeps being manageable *downward* — an administrator can always step the mode down, drop the configuration, or release a domain — while new enablement and new domain claims are refused until the plan is restored. **Detect availability before you offer the feature.** Read `capabilities.sso` on `GET /current/org/{org_id}/details/` rather than inferring availability from a refused write. --- ## The setup order The steps are ordered because each one gates the next: 1. **Claim an email domain** — `POST /current/org/{org_id}/sso/domains/` returns a TXT record to publish. 2. **Publish the TXT record in DNS**, then **verify** it — `POST /current/org/{org_id}/sso/domains/{domain}/verify/`. 3. **Configure the identity provider** — `POST /current/org/{org_id}/sso/` with the protocol, display name and the provider's own values. Paste the returned `sp` URLs into the identity provider at the same time. 4. **Run the configuration check** — `POST /current/org/{org_id}/sso/test/`. This sets `tested` to `true` for the configuration as it stands right now. 5. **Run a test sign-in** — the same endpoint with `mode: "signin"`, accepted whatever `mode` currently is. It completes the real protocol and creates nothing. See *The test sign-in*. 6. **Choose a mode** — `POST /current/org/{org_id}/sso/` with `mode=optional`, then `mode=required` when the organization is ready to enforce (see *Enforcement*). *Connecting an identity provider* below walks the same order in full, with per-vendor notes for Okta, Microsoft Entra ID, Google Workspace and JumpCloud. Steps 1-2 and step 3 can be done in either order; a configuration can be written before any domain is verified. What cannot be skipped is the check before enforcement: `mode=required` needs at least one verified domain and a successful configuration check (see *Enforcement*). Two read-only helpers support the wizard along the way. Before step 3, `GET /current/org/{org_id}/sso/presets/` returns per-vendor defaults (NameID format, attribute map, groups attribute) to pre-fill the provider form. Before step 6, `GET /current/org/{org_id}/sso/enforcement/preview/` reports how many of the organization's members would be signed out, exempt or excepted if `mode` were switched to `required`, so an administrator can warn them first. It counts members only: Fastio accounts on a verified domain that are not members of the organization are signed out too, and a sign-out ends that account's sessions everywhere, not only in this organization. --- ## Modes `mode` is the single switch that says what SSO does for this org. | Mode | Meaning | |------|---------| | `off` | Configured but not offered. Nothing about sign-in changes for anyone. This is the mode a new configuration starts in, and the mode a deleted configuration returns to. | | `optional` | Users in a verified domain may sign in with SSO. Existing sign-in methods keep working. | | `required` | Users in a verified domain **must** sign in with SSO. Password and social sign-in, signup, password reset, setting or rotating a password, changing an email address and creating a new API key are all refused for those addresses; the organization's owner, its admins, and any address on the exception list keep a break-glass password path. Needs at least one verified domain and a successful configuration check. See *Enforcement*. | `required` is the only mode that takes something away, so it is gated on the way in: read `enforcement_available` on the read response to know whether it may be offered at all, rather than probing with a write. *Enforcement* below has the prerequisites, what users on a verified domain experience, the break-glass path for the owner and the admins, and how to step back down. --- ## Enforcement (`required` mode) `mode=required` tells Fastio that every email address on this organization's **verified domains** must authenticate through the organization's identity provider. It is the only mode that takes something away from users, so the platform gates it on the way in and always leaves a way back out. **Prerequisites — checked on the write:** | Requirement | Refusal when it is missing | |-------------|----------------------------| | The Enterprise plan | `plan_required` | | At least one **verified** domain | `domains_unverified` | | A successful configuration check for the configuration **as it stands now** (`tested: true`) | `test_required` | | For `provisioning_mode: "scim_only"` — SCIM has authenticated at least one request | `scim_not_connected` | **Read `enforcement_available` — do not work the rule out for yourself.** The read response carries `enforcement_available`, a boolean saying whether `required` may be selected for this organization right now. It is computed on the server from the same rule the refusal uses, so the control you draw and the server that answers it cannot disagree. Read that field to decide whether to offer the `required` option; do not probe with a write. When it is `false`, `enforcement_blockers` names why — a list of `not_configured`, `domain_unverified`, `check_not_run`, `check_stale`, `check_failed`, `scim_not_connected` — so a UI can tell an admin exactly what is missing instead of just disabling the control. **What changes for users on a verified domain.** Password sign-in, signup, the personal Google / Microsoft sign-in, password reset, setting or rotating a password, changing an email address, and creating a **new** API key all refuse with `403` and `reason` = `sso_required`, carrying the organization's domain and the `start_path` to sign in through instead. The refusal is **identical for every address on the domain**: it does not say whether an account exists, nor whether that account is exempt, so it cannot be used to map who works there or who still holds a password. The endpoint-by-endpoint detail is in the **Auth reference**, under *Enterprise SSO Enforcement*. **Existing access is not withdrawn by the gate itself.** API keys already issued keep working — the gate refuses the creation of *new* ones. (This describes the login-time gate alone. A separate, request-time check — *Credential-request enforcement* below — does reach already-issued credentials.) **Switching to `required` revokes web sessions.** Every user on the organization's verified domains loses their existing web sessions — except the exempt ones — gets a `401` on their next call, and signs back in through the identity provider. The same happens when a new domain is verified while the mode is already `required`, when a user's address moves onto an enforced domain, when an administrator loses their admin role (a former owner stays an administrator after transferring ownership, so the transfer alone signs no one out), and when an address is taken off the exception list. Expect a burst of re-authentication immediately after the switch, and warn the organization's users before you throw it. **Break-glass: the owner, the admins, and any address on the exception list keep a password path**, so an administrator can never be locked out of the console they administer: - `GET /current/user/auth/?break_glass=true` — the ordinary password check runs **first**, and only a **correct** password is then tested for owner, admin, or an address on the organization's exception list. A non-exempt account gets the identical `403` `sso_required`. Unlike the default sign-in surface, failures here count toward the ordinary sign-in lockout. - `POST /current/user/email/reset/?break_glass=true` — always answers `202`, exactly like a reset request for an address the platform does not recognize. The email is sent only when the account is the organization's owner, an admin, or an address on the exception list, and the response never reveals which. The hosted sign-in start (`POST /current/user/auth/login/start/`) accepts the same `break_glass` flag; see the **Auth reference**. **Enforcement keys on the organization's verified domains, and on nothing else.** An address that is not on one of those domains is untouched by `required`, whatever its relationship to the organization: a member whose address sits on a domain the organization has not verified keeps every ordinary sign-in route. The rule follows the **address**, not the membership — which cuts both ways. Somebody who works at the company and holds only a guest share on a verified domain address **is** enforced; a contractor who belongs to the organization on their own domain is **not**. **Two things exempt an account: a role, and a list.** - **By role** — the organization's owner and its administrators, always. An administrator who loses the role loses the password path, and their existing sessions, at the same moment — unless their address is also on the exception list, which exempts them on its own and keeps both. - **By list** — `enforcement.exceptions`, up to 100 addresses an administrator names on the configuration. A listed address is exempt **exactly like an administrator**: on the same surfaces, and on no others. **An exemption reaches exactly three surfaces**, and every one of them authenticates the caller before anything can differ: the break-glass password sign-in; setting or rotating the account password, whether by spending a reset code that was mailed to the account or by changing an existing password from a signed-in session; and creating a new API key. On the other five enforced surfaces — the ordinary password sign-in, signup, the personal Google / Microsoft sign-in, requesting a password reset, and changing an email address — a listed address gets the byte-identical `sso_required` refusal every other address on the domain gets. **A listed user reaches a first password only through break-glass**: requesting an ordinary reset code stays refused, so the only code they can be mailed is the break-glass one. Once they hold a password they can rotate it, which is why the rotation surface is exempt — an account allowed to keep a credential must be able to change it. Beyond those three sign-in surfaces, the same exemption also applies to the request-time check under *Credential-request enforcement* below, so an exempt owner, admin or listed address keeps using its existing API keys and OAuth grants. **An entry whose address is not on a verified domain does nothing.** It is stored, it is returned, and it never applies — there was nothing for it to be exempt from. It is not an error and it is not a back door: the exception list cannot admit an outside collaborator, because enforcement never reached them in the first place. **Addresses are canonicalised on the way in** — lowercased, with plus-extras stripped, exactly as sign-in canonicalises the address it is given. `alice+it@acme.com` and `alice@acme.com` are therefore one entry. Read the list back after a write and render what comes back; the stored form is the canonical one. **Removing an address while the organization is enforcing revokes that user's sessions**, in the same request that saves the shortened list. They get a `401` on their next call and sign back in through the identity provider. Removing an address the organization does not actually enforce — the owner, an administrator, an address on somebody else's domain, an address with no Fastio account — is a no-op, not a failure. **Stepping back down is never plan-gated.** A request whose only field is `mode`, moving to `optional` or `off`, is accepted on any plan, so an organization that leaves the Enterprise plan can always stop enforcing. Releasing the **last** verified domain while the organization is enforcing is refused with `last_verified_domain` — step the mode down first. **If the platform cannot determine an organization's enforcement state**, the affected endpoints answer with a retryable, temporarily-unavailable error rather than letting a password through. Retrying is the correct response; treating it as "not enforced" is not. Folding an outage into "no enforcement" would, during an incident, invite exactly the password sign-ins that enforcement exists to prevent. ### Credential-request enforcement `required` mode also reaches API keys, OAuth grants, and other scoped credentials — not just interactive sign-in — on every authenticated request that resolves to a governing organization, not only at credential creation. This is a separate, request-time check layered on top of the login-time gate above: an existing key or OAuth grant, minted before enforcement was ever turned on, is checked on every call it makes. **What is checked.** The credential's owner — the account the key or grant belongs to — is looked up against this organization's SSO configuration. If the org's mode is not `required`, this check does not run at all: no domain lookup, no cost. If it is `required`, the owner's email domain is resolved and compared against this org's verified domains; a match that is not exempt (see *Two things exempt an account*, above — the same owner/admin/exception-list rule) is refused. **Refusal.** `403` with `params.reason` = `credential_policy_sso`, and `params.org.domain` naming the enforcing organization. This is a distinct reason from the login-time `sso_required` above — the refusal never carries `start_path`, since there is no sign-in redirect to offer mid-request, so it never collides with the login-redirect payload. See *Org Credential Policy* in `llms/auth.txt` for the full refusal-reason table (`credential_policy_mode`, `credential_policy_scope`, `credential_policy_sso`). **Provenance is not proven.** A scoped credential carries no evidence of how its owner signed in. A user who authenticated through the identity provider and then uses a first-party app, agent, or MCP token in this organization is still refused if their own account is enforced and not exempt — signing in through SSO earlier does not attach to the credential. There is no first-party bypass. **A lookup failure is `503`, never a silent pass and never a `401`.** If the platform cannot determine this organization's SSO mode or domain match, the request answers `503` (temporarily unavailable) — retryable, and preserved through deferred and two-stage authorisation flows rather than collapsing into a generic authentication failure. Folding an outage into "not enforced" would let through exactly the credentials this check exists to constrain, during an incident. **What is not covered.** A public File Share single-file link carries no owning API key and no account session and is not evaluated by this check — an enforcing organization's link recipients are unaffected. Three collection endpoints — listing an account's own orgs and shares in bulk — resolve no governing organization per row and are outside this check; a small number of late-loaded cloud write-back endpoints are checked, if at all, at their own endpoint rather than on this shared path. None of these are silent gaps — they are documented limits, not undiscovered ones. --- ## Protocols An org configures exactly one identity provider, under exactly one protocol. ### `oidc` — OpenID Connect | Field | Direction | Description | |-------|-----------|-------------| | `oidc.issuer_url` | read/write | The provider's issuer URL. The configuration check fetches its discovery document from here. | | `oidc.client_id` | read/write | The client ID the provider issued for Fastio. | | `oidc.client_secret` | **write-only** | The client secret. Never returned. | | `oidc.client_secret_set` | read-only | `true` once a secret has been stored. This is how a client shows "a secret is configured" without ever reading it. | | `oidc.groups_claim` | read/write | The claim in the provider's token that carries the user's group names. Used by role mapping. | | `oidc.extra_scopes` | read/write | Up to 5 extra scope tokens appended to the authorize request. `[]` when none are configured. | Fastio always requests `openid email profile`, plus `groups` when the provider advertises it. Add more through `oidc.extra_scopes`. ### `saml` — SAML 2.0 | Field | Direction | Description | |-------|-----------|-------------| | `saml.idp_entity_id` | read/write | The identity provider's entity ID. | | `saml.idp_sso_url` | read/write | The identity provider's sign-on URL. | | `saml.idp_certificate` | **write-only** | The signing certificate, PEM encoded. Never returned. | | `saml.idp_certificate_fingerprint` | read-only | Fingerprint of the stored certificate — enough to confirm *which* certificate is loaded. | | `saml.idp_certificate_not_after` | read-only | When that certificate expires, so a client can warn before it does. | | `saml.idp_certificate_rollover` | **write-only** | An optional second certificate, for a rollover window where the provider may sign with either. | | `saml.idp_certificate_rollover_fingerprint` | read-only | Fingerprint of the rollover certificate, or `null`. | | `saml.idp_certificate_rollover_not_after` | read-only | Expiry of the rollover certificate, or `null`. | | `saml.groups_attribute` | read/write | The assertion attribute carrying the user's group names. Used by role mapping. | | `saml.nameid_format` | read/write | Which NameID format to ask the provider for: `emailAddress` (the default), `persistent` or `unspecified`. See *Choosing a NameID format* below. | A certificate is public material, so its fingerprint and expiry are readable; the PEM you submit is not echoed back. Configure the rollover certificate **before** the provider starts signing with it, and drop it once the old certificate is retired. **Write-only means write-only.** Neither the OIDC client secret nor either SAML certificate PEM appears in any response, at any output level, to any caller. A client that needs to show whether a secret is present reads `oidc.client_secret_set`; a client that needs to show *which* certificate is loaded reads the fingerprint. ### Choosing a NameID format (SAML) `saml.nameid_format` is the format Fastio asks the identity provider to put in the assertion's subject, and it accepts three values: | Value | Use it when | |-------|-------------| | `emailAddress` | The default, and the right answer for almost every organization. The subject is the user's address. | | `persistent` | The provider issues an opaque, stable per-user identifier instead of an address. The assertion must then also carry an email **attribute** — Fastio has no address to fall back on. | | `unspecified` | The provider will not commit to a format. Treated like `emailAddress` when the value it sends looks like an address. | `transient` is deliberately **not** accepted. The federated subject *is* the NameID, so a format that issues a new value on every sign-in would bind a new identity each time and fail every sign-in after the first with `account_conflict`. **The format locks once the organization has SAML identities bound to it.** A change after the first federated sign-in is refused with `nameid_format_locked`, because it would orphan every identity row the organization already has — every one of those users would arrive as somebody new. Choose the format during setup, before the first sign-in; if it genuinely has to change afterwards, that is a support conversation, not a write. ### Claim and attribute names (`attribute_map`) By default Fastio reads the user's address and name from the names providers usually use. `attribute_map` overrides them when a provider does not: ```json { "email": "mail", "given_name": "firstName", "family_name": "lastName" } ``` - Each value is an **exact** claim name (OIDC) or attribute name (SAML), up to 64 characters. There are no dotted paths, no XPath and no JSONPath — a nested value cannot be reached, so release it as a top-level claim at the provider instead. - Every key is optional. An omitted key keeps the default behaviour for that field. - Send `{}` — an empty object — to clear the whole map and go back to the defaults. A JSON `null` is indistinguishable from an omitted key at this endpoint and therefore does nothing. - **A configured name that the assertion does not carry refuses the sign-in.** It does not quietly fall back to the default name. A mapping is a statement about the provider, and silently ignoring it would create accounts from whatever the default name happened to hold. - The reserved OpenID Connect names — `iss`, `aud`, `sub`, `exp`, `iat`, `nbf`, `nonce`, `azp` — are refused as the email mapping. **Mapping the email claim changes what "verified" means under OIDC.** The provider's `email_verified` signal describes the literal `email` claim and nothing else, so an OIDC address read out of a different claim is never treated as provider-verified: it will not auto-link to an existing Fastio account or claim a pending invitation, and those sign-ins refuse with `email_unverified`. Under **SAML** there is no such signal and none is needed — every attribute inside a validated, signed assertion carries the assertion's own trust, so a remapped SAML email attribute links exactly as the default one does. **Under OIDC the email is read from the ID token or, if it is absent there, from the provider's userinfo endpoint** — under the same claim name (default or mapped) and the same `email_verified` rule, and only when the userinfo response is for the same subject as the ID token. A provider that releases `email` only from userinfo needs no extra configuration. **Read `attribute_map_invalid` before you render the map.** If the stored map cannot be read back, `attribute_map` comes back as an empty object `{}` with `attribute_map_invalid: true`; an unset map is `null`. Read the flag rather than inferring anything from the shape of the map. Show it as an error state, "the claim mapping could not be read, save it again", never as "defaults in use": while it is true, every sign-in for that organization is refused. ### Importing the provider's metadata (SAML) Rather than copying the entity ID, sign-on URL and certificate one at a time, a SAML provider's metadata document can be handed over whole. Send **exactly one** of these on a POST: | Field | Direction | Description | |-------|-----------|-------------| | `saml.metadata_url` | write; read back for display | An `https://` address Fastio fetches the document from. Responses return the address the current SAML settings were last imported from, or `null` when they were entered by hand or pasted as a document. | | `saml.metadata_xml` | **write-only** | The document itself, pasted. Up to 256 KB. Never echoed back. | What the import takes is exactly three things — the entity ID, the HTTP-Redirect sign-on URL, and one or two **signing** certificates — and it writes them into the ordinary `saml.idp_*` fields. Nothing from the document is stored verbatim — only the address it was fetched from is kept, for display — there is no standing subscription to the URL, and nothing re-fetches it later: **a re-import is a new POST.** Editing any `saml.idp_*` field by hand, importing a pasted document, or switching to OIDC clears the remembered address; any other save leaves it as it was. An import **replaces** the certificate set, so a document carrying a single signing certificate clears any rollover certificate that was set. **The rules that make an import predictable:** - **Send one metadata field, and send it alone.** Both keys in one request is refused with `metadata_conflict`. Either key alongside a manual `saml.idp_*` field is refused with `metadata_conflicts_with_manual_fields` rather than resolved by precedence — the request does not say which the administrator meant. Either key while the effective protocol is OIDC is refused with `metadata_requires_saml`. - **These checks look at whether the key is *present*, not at whether it has a value.** An empty string is a real request. **Omit both keys entirely from every ordinary save** — sending `"metadata_url": ""` beside the manual fields is a refused request, not a no-op, and a lone empty key reaches the importer and fails there. The same goes for the `saml.metadata_url` a GET returns: it is there to show where the settings came from, and posting it back is a fresh import, not a no-op. - **Repeated URL imports are throttled** more tightly than an ordinary save, because a URL import opens a connection to an address the caller chose. Import once and save the rest normally. - A document must carry **exactly one** identity-provider descriptor; a document carrying none, or several, is refused with `metadata_unsupported` rather than resolved by order. Only HTTP-Redirect sign-on is accepted; a document whose only single sign-on endpoint is HTTP-POST is refused with the dedicated `metadata_redirect_binding_missing` reason rather than the generic one, since the fix is a single setting at the identity provider (see the JumpCloud notes below). When a document offers both Redirect and POST, Redirect is used and the import succeeds. Duplicate certificates are collapsed by fingerprint before the two slots are filled. **Import refusals:** | `reason` | What it means | |----------|---------------| | `metadata_unreachable` | The address could not be fetched, was not `https://`, redirected, or was too long. Publish the document at a final address that answers directly. | | `metadata_too_large` | The document exceeded the size Fastio will read. | | `metadata_invalid` | The document could not be parsed, a certificate in it was unreadable, or the entity ID was missing. | | `metadata_no_signing_cert` | It parsed, but carries no signing certificate. A document that offers only an encryption certificate lands here. | | `metadata_unsupported` | It parsed and is unusable: more than one identity-provider descriptor, no usable single sign-on endpoint of any other kind (an Artifact-only binding, for example), more than two distinct signing certificates, or an over-long entity ID. | | `metadata_redirect_binding_missing` | The metadata publishes only an HTTP-POST single sign-on endpoint, and Fastio signs in over HTTP-Redirect. Enable the identity provider's HTTP-Redirect endpoint and import again (JumpCloud: tick "Declare Redirect Endpoint" in the application's SSO settings and save). | Not every provider publishes a metadata URL, and some publish a document only as a download. Where the vendor offers a URL, prefer it; where it offers a file, paste the file's contents. The per-vendor notes below say which is which. **To check a document before saving it**, `POST /current/org/{org_id}/sso/metadata/preview/` runs this same import and returns the parsed entity ID, SSO URL, certificates and a detected-vendor hint, without saving anything — see *Preview SAML metadata* under *Endpoints*. --- ## Service-provider values to paste into the identity provider The `sp` object on the read response carries the values the identity provider needs. They are generated for the org — copy them, do not compose them yourself. | Field | Used for | |-------|----------| | `sp.oidc_redirect_uri` | The redirect / callback URI to register with an OIDC provider. | | `sp.saml_acs_url` | The Assertion Consumer Service URL for a SAML provider. | | `sp.saml_entity_id` | The service-provider entity ID for a SAML provider. Primarily a name, not a link — it identifies Fastio to the provider — but it also resolves if a provider fetches it: it is the same metadata route named with the organization's numeric id instead of its slug. | | `sp.saml_metadata_url` | The address the service-provider metadata document is actually served at, for providers that import it rather than take fields one at a time. **Not the same string as `sp.saml_entity_id`** — see below. Named by the organization's login slug when it has one, or by its numeric id otherwise — it is never empty. | These URLs are returned pinned to an explicit API version rather than to the `current` alias, because an identity provider's configuration has to stay valid across API versions. **Paste them exactly as returned**; do not rewrite them to `current`, and do not shorten or re-host them. --- ## Connecting an identity provider Every provider is connected the same way. The **setup core** below is the whole procedure; the per-vendor notes after it say only what that vendor calls each thing and where it differs. ### The setup core **1. Claim and verify an email domain.** `POST /current/org/{org_id}/sso/domains/` returns the TXT record; publish it, then `POST /current/org/{org_id}/sso/domains/{domain}/verify/`. Nothing about enforcement works without this, and it is the step with a wait in it, so start it first. **2. Read the service-provider values.** `GET /current/org/{org_id}/sso/` and keep the `sp` object open beside the provider's console. These are the four values the provider needs from Fastio, and they are generated for the organization — copy them, never compose them. **3. Create the application at the provider**, choosing one protocol, and paste the `sp` values in: | Protocol | Paste this | Into the provider's field for | |----------|-----------|-------------------------------| | OIDC | `sp.oidc_redirect_uri` | The redirect / callback URI | | SAML | `sp.saml_acs_url` | The Assertion Consumer Service URL | | SAML | `sp.saml_entity_id` | The service-provider entity ID / audience | | SAML | `sp.saml_metadata_url` | Service-provider metadata, where the provider imports it | **4. Copy the provider's values back** with `POST /current/org/{org_id}/sso/`: - **OIDC** — `oidc.issuer_url`, `oidc.client_id`, `oidc.client_secret`. Most providers show the client secret exactly once; copy it before leaving the page. - **SAML** — either `saml.metadata_url` or `saml.metadata_xml` if the provider publishes a metadata document, or `saml.idp_entity_id`, `saml.idp_sso_url` and `saml.idp_certificate` by hand. Do not send both a metadata field and a manual field in one request; see *Importing the provider's metadata*. **5. Release groups, if you want role mapping.** No provider in this list releases group names by default. Configure the claim or attribute at the provider, put its name in `oidc.groups_claim` or `saml.groups_attribute`, and set `role_mapping`. Skip this step entirely if every user should arrive at `jit.default_role`. **6. Run the structural check** — `POST /current/org/{org_id}/sso/test/`. It proves the values parse and the provider's published material is reachable. It does **not** prove a user can sign in. **7. Run a real test sign-in** — `POST /current/org/{org_id}/sso/test/` with `mode: "signin"`, accepted whatever `mode` currently is. This is the step that finds the problems: the browser goes to the provider, authenticates, comes back, and Fastio completes the whole protocol and then stops without creating anything. It reports the address the provider asserted, the groups it released and the role those groups map to. See *The test sign-in*. It runs and records the structural check as its first step, so this one call is enough to satisfy step 6 as well — once it passes, `enforcement_available` reflects it immediately. **8. Turn it on** — `mode: "optional"`, and `mode: "required"` when the organization is ready to enforce. Read `enforcement_available` first. **9. Add SCIM if you need offboarding.** Just-in-time creation covers arrivals; SCIM covers departures. See *SCIM 2.0 provisioning*. ### Per-vendor notes Each note says how its claims were checked: - **live-tested** — exercised end to end against a real tenant of that provider. - **documentation-checked** — traced to that vendor's current public documentation, not exercised. Vendor consoles are renamed often; if a label below does not match what you see, trust the console and the vendor's own documentation. Only the terminology and the gotchas differ. The order of operations in the setup core is the same for all four. #### JumpCloud — OIDC and SAML — *documentation-checked* No end-to-end run against a JumpCloud tenant has been recorded yet, so every JumpCloud step below — sign-in, metadata import and the test sign-in — is documentation-checked. The metadata importer itself was exercised live against a public SAML test identity provider; the JumpCloud-specific field names and menu paths come from JumpCloud's documentation. - **Create:** Access → SSO Applications → "+ Add New Application" → "Custom Application" → "Manage Single Sign-On (SSO)", then "Configure SSO with SAML" or "Configure SSO with OIDC". - **OIDC:** `sp.oidc_redirect_uri` goes in "Redirect URIs". The client ID and secret are shown **once**, after you activate the application. The issuer is JumpCloud's OAuth issuer host (it has regional variants — use the one for your tenant), and its discovery document sits at the usual well-known path under it. - **SAML:** `sp.saml_acs_url` goes in "ACS URLs", copied exactly. `sp.saml_entity_id` goes in "SP Entity ID" — **not** `sp.saml_metadata_url`. The two look alike and differ only in the `org` query parameter, and the metadata URL in that field fails every sign-in. JumpCloud can import a service-provider metadata **file**. - **Declare the redirect endpoint, then save:** tick "Declare Redirect Endpoint" in the application's SSO settings and **save the application before** you copy its metadata URL or export its metadata. Without it JumpCloud publishes only an HTTP-POST sign-on endpoint, and Fastio refuses the metadata with `metadata_redirect_binding_missing`. - **Copy back:** hand `saml.metadata_url` the "Copy Metadata URL" value, or `saml.metadata_xml` the document from "Export Metadata". Entering the fields by hand instead: copy "IdP Entity ID" into `saml.idp_entity_id` exactly as JumpCloud shows it, the IdP URL into `saml.idp_sso_url`, and the certificate from "Export Metadata" into `saml.idp_certificate`. - **Sign the assertion:** set the application's signing option to sign the **assertion** — "Assertion" or "Assertion and Response" (older consoles: tick "Sign Assertion"). By default JumpCloud signs only the response, and Fastio requires a signed assertion: a response-only signature is refused at sign-in, the test sign-in included. This is a setting on the application, not part of the metadata, so it applies whether you import metadata or enter the fields by hand. - **Groups:** set a "Groups Attribute Name" on the application and put the same name in `oidc.groups_claim` or `saml.groups_attribute`. There is no dedicated OIDC `groups` scope here; groups arrive as an attribute either way. - **NameID:** "SAMLSubject NameID" defaults to email, which matches the Fastio default. Change it only if you have a reason, and then set `saml.nameid_format` to match. - **SCIM:** the application's "Provisioning" tab → custom SCIM → base URL and token → Test Connection → Activate. Under `provisioning_mode: "scim_only"`, a person must be provisioned before their first sign-in, or the sign-in is refused with `not_provisioned`. - **The gotcha:** users are implicitly denied the application until you bind them to it. Bind the users or user groups who should sign in, or JumpCloud refuses them and the sign-in fails. When a test sign-in fails at the provider before it ever returns, check that the user is assigned to the application. - **Note:** the SAML IdP URL is fixed when the application is created and cannot be edited afterwards. If it is wrong, make a new application. #### Okta — OIDC and SAML — *documentation-checked* - **Create:** Applications → "Create App Integration" → "OIDC - OpenID Connect" (application type "Web Application") or "SAML 2.0". - **OIDC:** `sp.oidc_redirect_uri` goes in "Sign-in redirect URIs". Client ID and secret are under "Client Credentials" on the application's General tab. The issuer is your Okta org, or a custom authorization server if you use one; its discovery document is at the usual well-known path. - **SAML:** `sp.saml_acs_url` goes in "Single sign-on URL", `sp.saml_entity_id` in "Audience URI (SP Entity ID)". **Okta does not import a service-provider metadata document** into a custom application — paste both fields by hand. Copy back "Identity Provider Issuer", "Identity Provider Single Sign-On URL" and the X.509 certificate from the application's Sign On tab, which also publishes a metadata URL you can give to `saml.metadata_url`. - **Groups:** not released by default, on either protocol. **For OIDC, where the group claim lives depends on which issuer you gave Fastio, and the two places are not interchangeable.** If the issuer is your Okta org, edit the application's Sign On tab, add the group claim to the OpenID Connect ID token, name it `groups`, give it a filter, and refresh the application data afterwards. If the issuer is a **custom authorization server**, the Sign On tab does not reach it: add the claim on that server instead, under Security → API → the authorization server → Claims, with the value type "Groups", the ID token as the token type, and a filter. For SAML, add a "Group Attribute Statements" entry — it has no default name, so whatever you type there is what goes in `saml.groups_attribute`. - **NameID:** the "Name ID format" field pairs with "Application username". The default is unspecified, so set it to the email-address format to match Fastio's default. - **SCIM:** available on a SAML custom application, under App Settings → Provisioning. It is **not** available on an OIDC integration created through the classic wizard — if the organization needs SCIM, choose SAML. - **The gotcha:** the redirect URI must match exactly, and users must be assigned to the application before any of them can sign in. #### Microsoft Entra ID — OIDC and SAML — *documentation-checked* - **Create (SAML):** Enterprise applications → New application → "Create your own application" → integrate a non-gallery application → Single sign-on → SAML. - **Create (OIDC):** App registrations → New registration; the redirect URI is added afterwards, under Authentication, as a "Web" platform. - **SAML:** `sp.saml_acs_url` goes in "Reply URL (Assertion Consumer Service URL)", `sp.saml_entity_id` in "Identifier (Entity ID)". Entra accepts an uploaded service-provider metadata **file** and fills both from it. Copy back "Microsoft Entra Identifier", the "Login URL" and the Base64 signing certificate — or give `saml.metadata_url` the "App Federation Metadata Url". - **OIDC:** the client ID is "Application (client) ID" on the Overview blade. The secret's **Value** is visible only on the page where you create it. The issuer is the tenant's v2.0 authority, with its discovery document at the usual well-known path. - **Groups:** opt-in — "Add a group claim" under User Attributes & Claims for SAML, "Add groups claim" under Token configuration for OIDC. Two things will bite you. First, the claim carries **group object IDs, not names**, unless you change the source, so either switch it or write the object IDs into `role_mapping`. Second, Entra **omits the group claim entirely** once a user is in more than 150 groups for a SAML assertion or 200 for a token — choose "Groups assigned to the application" rather than "All groups", or role mapping will silently do nothing for exactly the people who are in the most groups. - **NameID:** "Unique User Identifier (Name ID)" → "Choose name identifier format" offers an email-address option; pick it to match Fastio's default. - **SCIM:** Provisioning → new configuration → tenant URL and secret token → Test Connection. Schema discovery is not supported for a custom SCIM application, which is expected rather than an error. #### Google Workspace — SAML only — *documentation-checked* - **SAML only.** The Admin console has **no generic OpenID Connect application type** for third-party service providers; its OpenID Connect surface points the other way, for signing Google users in against somebody else's provider. Configure Fastio with `protocol: "saml"`. - **Create:** Apps → Web and mobile apps → Add App → "Add custom SAML app". - **Copy back** from the "Google Identity Provider details" step: the SSO URL, the entity ID and the certificate — or download the identity-provider metadata and paste the document into `saml.metadata_xml`. Google publishes the document as a download rather than as an address to fetch, so prefer `metadata_xml` over `metadata_url` here. - **Paste in** on the "Service Provider Details" step: `sp.saml_acs_url` into "ACS URL" and `sp.saml_entity_id` into "Entity ID". Google does not import a service-provider metadata document. - **Groups:** under attribute mapping, "Group membership (optional)". You search for and list the specific groups to release and choose the attribute name yourself; put that name in `saml.groups_attribute`. **Only the groups you explicitly listed are ever released** — a group you forgot is not a mapping that failed, it is a group the assertion never mentioned. - **NameID:** the Name ID and its format are set on the Service Provider Details step. The default Name ID is the user's primary email, which matches Fastio's default. - **Provisioning:** Google's automated provisioning is documented for its catalogue applications, not for a custom SAML application. Plan on just-in-time creation for arrivals and a deliberate process for departures. - **The gotcha:** the application must be turned **on** for the user under "User access", and changes can take a while to propagate. The entity ID comparison is case-sensitive. --- ## How a user signs in This section is the sign-in flow the configuration above switches on: what an administrator is actually enabling, and what to expect when testing it. The endpoint-by-endpoint reference — parameters, response fields and error tables — is in the **Auth reference**, under *Enterprise SSO Sign-In*. **The sequence:** ``` 1. discover POST /current/user/sso/discover/ (optional) an email address -> which org, if any 2. start GET /current/user/sso/start/?org={org_domain} -> redirect_url at the org's identity provider 3. provider the user authenticates at the organization's own identity provider 4. callback the provider returns the browser to Fastio (the redirect URI / ACS URL from the sp values above), which redirects it to {origin}/signin/sso and sets a short-lived, single-use handoff cookie 5. exchange POST /current/user/sso/exchange/ empty body, cookies included -> JWT ``` **SP-initiated only.** Every sign-in must begin at step 2. There is no IdP-initiated path: a user cannot launch Fastio from a tile on the identity provider's app dashboard, and a SAML Response that names no live sign-in record is rejected rather than accepted as unsolicited. Configure the provider's tile, if it has one, to point at the org's own Fastio sign-in page rather than at the ACS URL. **One callback URL per protocol, for the whole platform.** `sp.oidc_redirect_uri` and `sp.saml_acs_url` are not org-specific. The organization a callback belongs to is resolved from the single-use record created at step 2, never from the assertion's issuer — which is why an administrator registers one stable URL and never has to change it. `sp.saml_entity_id` *is* org-specific, and is keyed to the organization's stable numeric ID rather than to its domain (slug), so that renaming the org never moves its entity ID. **`sp.saml_entity_id` and `sp.saml_metadata_url` are different values, pasted into different fields — but the metadata route now resolves either one.** An entity ID is a *name*: SAML only requires it to be a URI, and it is keyed to the stable numeric ID so that renaming the organization cannot move it or let somebody else re-register it. The metadata URL is an *address* a provider fetches, named by the organization's domain (slug) when it has one, because it is read by a person at setup time and a readable URL is nicer to paste. `GET /current/user/sso/saml/metadata/?org=` accepts **either** the slug or the numeric id, so `sp.saml_entity_id` — `…/saml/metadata/?org={org_id}` — is itself a working metadata URL an identity provider can import by URL, and `sp.saml_metadata_url` falls back to the numeric-id form when the organization has no slug rather than coming back empty. Paste each into the field it belongs in, and re-copy the metadata URL at the provider if the organization's domain is ever changed. **The handoff never travels in a URL.** On success the callback redirects the browser to `{origin}/signin/sso` carrying nothing, and sets an HttpOnly, Secure, `SameSite=Lax` cookie good for 60 seconds and exactly one use. On failure it redirects to `{origin}/signin/sso#error={reason}&org={org_domain}` and sets no cookie — a reason string carries nothing sensitive, which is why it may appear in a URL where the handoff may not. **The `org` parameter is present only once the organization is known** — a refusal that happens before that, such as a sign-in record that has expired or already been used, carries the reason alone. **The sign-in is bound to the browser that started it.** Step 2 requires the browser-key credential the platform already issues, and step 5 re-checks it, so a handoff lifted out of one browser fails in another rather than succeeding on possession. A user who starts a sign-in in one browser and finishes it in another gets `browser_mismatch` and starts again. **The session is an ordinary revocable account session.** The token the exchange issues is the same kind a password login issues with `revocable=true`: sign-out and invalidate-all both reach it, and everything downstream works unchanged. **Fastio's own 2FA step is not consulted** on a federated sign-in — the identity provider owns multi-factor for these users, and the exchange response carries no `2factor` field at all. **There is no single logout.** Signing out of Fastio ends the Fastio session only — it does not sign the user out at the identity provider, and there is no RP-initiated logout request sent to the provider's own end-session endpoint. A user who signs out and immediately starts a new sign-in is typically returned without being prompted again, because their session at the provider is still live. Fastio also does not accept an IdP-initiated logout: there is no endpoint for a provider to notify that it ended a session, so ending one there does not end the corresponding Fastio session either. Revoke access from the Fastio side with sign-out or invalidate-all; revoke it at the provider by ending the session or the account there. --- ## Discovery: finding the org from an email address A platform-wide sign-in page does not know which organization a user belongs to until they type an address. `POST /current/user/sso/discover/` (unauthenticated, IP-throttled) maps an email domain to the organization that federates it, and returns the path to start the sign-in. Three different situations return a **byte-identical** negative answer: a domain nobody has claimed, a domain claimed but not yet verified, and a verified domain whose organization has SSO switched off. They cannot be told apart, so the endpoint cannot be used to map which companies are on the platform, and it never reveals whether an account exists. The only thing it ever discloses is that a *verified* domain federates — already public knowledge to everyone who works there. **A failed lookup is a retryable `503`, never a negative answer.** Folding an outage into "no SSO" would, during an incident, tell every federated organization's users to sign in with a password. --- ## What an org's sign-in page may offer `GET /current/org/{org_id}/public/details/` — unauthenticated — carries a `login_options` block **on the `org` object** so an org-scoped sign-in page can draw the right buttons before anybody has authenticated: ```json "login_options": { "password": true, "social": ["google", "microsoft"], "sso": { "enabled": true, "mode": "optional", "protocol": "oidc", "display_name": "Acme SSO", "start_path": "/user/sso/start/?org=acme-corp" }, "signup": true } ``` Every value is **already resolved for the organization** — a client does not combine `mode` with the other flags itself. Under `required` the block reports `password: false`, `social: []` and `signup: false`, and the server refuses those routes too, so the page never draws a door that is locked. **What is deliberately absent:** the organization's verified domain list, and any indication of who is exempt from enforcement. Publishing the first would hand an attacker the exact addresses worth phishing; publishing the second would tell an unauthenticated caller that a particular organization has an administrator who can still use a password. **An absent `login_options` block means the answer could not be determined** — fall back to the default sign-in options, never to "this organization has no SSO". --- ## Sign-in refusal reasons Sign-in refusals use their own closed vocabulary, separate from the configuration refusals listed later on this page. The same list covers a `#error=` fragment on the landing route and a `reason` on a 4xx from `start` or `exchange`. **Branch on `reason`**, never on the numeric code or the HTTP status alone. | `reason` | What happened | Whose problem it is | |----------|---------------|---------------------| | `sso_not_configured` | No such organization, or it has no usable configuration. The two are the same answer on purpose. | Administrator. | | `sso_disabled` | A configuration exists but `mode` is `off`. | Administrator. | | `domain_not_permitted` | The email address is not on a verified domain of this organization. | Administrator (verify the domain) or user (wrong address). | | `idp_error` | The identity provider refused, or returned something unusable. The provider's own message is never passed through. | Administrator. | | `email_unverified` | The provider did not assert the address as verified, and the address matches an existing account or a pending invitation to this organization. Linking an unverified address to either is how account takeover is spelled. A brand-new account for an address nobody has invited is still created. | Administrator, at the provider. | | `account_conflict` | The address belongs to an account this organization may not link to the federated identity. | Support. | | `state_expired` | The sign-in took too long, its record is gone, or a **first** SAML sign-in could not be completed safely at that moment. No administrator action is implied. | User — restart the sign-in. | | `code_expired` | The handoff was missing, already used, or older than 60 seconds. | User — start again. | | `browser_mismatch` | The sign-in was finished in a different browser than it started in, or no browser key was presented. | User — finish in the browser that started, with cookies enabled. | | `deprovisioned` | The organization's directory removed this identity; a sign-in must not step over that. | Administrator. | | `not_provisioned` | The organization is `scim_only` and its directory has not provisioned this person. Includes a returning member who originally arrived through just-in-time creation. | Administrator — provision them through SCIM. | | `link_confirmation_required` | The sign-in matched an existing Fastio account — or an invited account that has not yet been claimed — that has not yet been linked to this organization's identity provider. A confirmation link was emailed to the account's address; no session was created. See *Account-link confirmation*. | User — check the account's mailbox and confirm from there. | | `link_confirmation_invalid` | The confirmation link is unknown, expired, already used, or the organization's configuration changed since it was sent. | User — start sign-in again. | **Testing a new configuration.** The configuration check (`POST .../sso/test/`) is structural — it says the values are well-formed and the provider's published material parses. It does not say a user can get in. Prove the protocol and the claims round-trip with a **test sign-in** (`POST .../sso/test/` with `mode: "signin"`, accepted whatever `mode` currently is), which runs the real protocol without creating anything, and then confirm with one real sign-in under `optional` — the test sign-in stops before eligibility, admission and membership; see *The test sign-in*. Read any `reason` verbatim: every one of the rows above names something specific. --- ## Account-link confirmation An identity provider can assert an email address that already belongs to a Fastio account nobody has linked to this **organization's identity-provider binding** yet, or that belongs to somebody who was invited to the organization and has not claimed their account yet. Fastio does not sign that assertion straight in. Instead it emails the account's stored address — for an invited account, the address the invitation went to — a single-use confirmation link and answers the exchange with `link_confirmation_required` — no token, no session cookie. This is what stops a misconfigured or malicious identity provider on a verified domain from silently taking over a pre-existing account: the account holder has to act from their own mailbox before anything is linked. New accounts (just-in-time provisioning) and accounts already linked to this identity provider are unaffected and sign in immediately, as before. **Confirmation is bound to that identity-provider link, not to the account forever** — if an administrator later releases the binding (recreating the SSO configuration, for example), the next sign-in for that account asks for confirmation again. **The exchange refusal:** ```json { "result": false, "error": { "code": 123456, "text": "Confirm this sign-in using the link we emailed you.", "params": { "reason": "link_confirmation_required", "email": "user@acme.com", "org": { "id": "1234567890123456789", "name": "Acme Corporation" }, "provider": "saml" } } } ``` **The email.** The link opens on your organization's app origin (for example `https://{org}.fast.io/signin/sso/confirm/?token={token}`), the same origin the SSO sign-in page uses — not this API host. It is single-use and good for 30 minutes from issue. Issuing it is rate-limited: while a live challenge already exists for the account, re-running the sign-in inside that window answers the same `link_confirmation_required` and leaves the earlier email's link valid rather than sending a second one; once the window has passed, a fresh sign-in attempt issues a new email that supersedes the earlier link. Switching `mode` or editing role mapping never invalidates an outstanding link — only a credential, certificate or provider change does (see the uniform refusal below). **`POST /current/user/sso/link/confirm/`** completes the sign-in from the emailed link. Unauthenticated, rate-limited per caller. Form-urlencoded body, same as `exchange`. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `token` | string | Yes | The token from the confirmation link. | | `preview` | boolean | No | Default `false`. `true` reads who is about to sign in — including whether the account is still eligible (not deprovisioned, suspended or otherwise blocked) — without consuming the token, for a confirmation page shown before the user clicks. | Preview (`preview=true`) — reads the challenge and the account's eligibility without consuming the token: ```bash curl -X POST "https://api.fast.io/current/user/sso/link/confirm/" \ --data-urlencode "token={token}" \ --data-urlencode "preview=true" ``` ```json { "result": true, "email": "user@acme.com", "org": { "id": "1234567890123456789", "name": "Acme Corporation" } } ``` Confirming (`preview` omitted or `false`) atomically consumes the token and then runs the **entire** ordinary sign-in sequence again — domain eligibility, provisioning mode, account usability and every other gate — against the organization's **current** configuration, never the one in force when the email was sent. On success it answers the **exact shape `exchange` answers** — `result`, `provider`, `email`, `token`, `account_created`, `org` — and sets the session cookie exactly as `exchange` does. `account_created` is `true` when the confirmation created the account for an invited address, and `false` when it linked one that already existed: ```bash curl -X POST "https://api.fast.io/current/user/sso/link/confirm/" \ --data-urlencode "token={token}" ``` **Every non-transient failure is the same uniform refusal**, regardless of cause — unknown token, expired, already used, superseded by a newer sign-in attempt, the identity provider's credentials or certificate changed since the email was sent, or the account is no longer eligible: ```json { "result": false, "error": { "code": 123456, "text": "This sign-in confirmation link is no longer valid.", "params": { "reason": "link_confirmation_invalid" } } } ``` There is no way to tell these causes apart from the response, by design — the remedy is the same regardless: start sign-in again. **A genuinely temporary failure is a separate, retryable outcome** (`503`, no reason) and never this refusal. Whether the same link still works after one depends on timing: before the token is consumed — reading the challenge, loading the organization or its configuration — nothing is spent, so the identical request can simply be retried; once the token has been consumed, a temporary failure still forces the user back to the start of SSO sign-in, because the single-use link is already gone. **Confirming an account whose email address was never verified revokes what it held.** When the confirmation links an existing account that never verified its address, everything issued to it before — every API key, OAuth grant and already-issued OAuth or agent access token, connected Google/Microsoft sign-in, session, two-factor enrolment, stored phone number and pending email change — is revoked, any password is replaced with an unknown one (the holder sets a new one through password reset), and the address is marked verified, all before the account is linked and the new session is issued. If that revocation cannot complete, the confirmation answers the temporary `503` above; the link is already spent, so the user starts sign-in again. **Administrators cannot be silently opted around this.** An organization's identity provider can never take over a pre-existing account on the org's verified domain without the account holder confirming from their own mailbox — even for an administrator who controls the identity-provider configuration. A member under `required` mode whose mailbox is unreachable has no self-service path around the confirmation; an org owner or admin's break-glass access (see *Enforcement*) is the only way to help them. --- ## Domain verification SSO only applies to email domains the org has proven it controls. A domain is claimed, then verified by DNS. **Claiming** (`POST /current/org/{org_id}/sso/domains/`) returns a TXT record: | Part | Value | |------|-------| | Record name | `_fastio-verification.{domain}` — e.g. `_fastio-verification.acme.com` | | Record value | `fastio-verification={verification_token}` | Publish that TXT record, then call the verify endpoint. Verification reads DNS at the moment you call it; there is no queue to wait on. **Rules that decide whether a name can be claimed at all:** - The domain is stored **lowercase**. `ACME.com` and `acme.com` are the same claim. - An internationalized domain must be supplied in its **ASCII (punycode) form** — `xn--…`. A Unicode form is refused; convert it before you send it. - **Public suffixes are refused** — `com`, `co.uk` and the like. So are bare IP addresses, and Fastio's own domains. These come back as `domain_not_allowed`. - A domain may be at most 255 characters. **Two orgs, one name.** Two organizations may each hold a **pending** claim on the same domain at the same time — a pending claim proves nothing, so it blocks nothing. Only **one** org may hold a **verified** claim. Whichever org verifies first takes it; a later attempt by the other org is refused with `domain_in_use`. An unverified claim **expires after 7 days**, releasing the name. **Staying verified.** Verified domains are re-checked daily. A domain is only un-verified after **three consecutive checks confirm the TXT record is gone** — a resolver timeout, a transient failure, or any other inability to *read* DNS never counts toward those three and never un-verifies a domain. Administrators are notified when a domain is un-verified. **Releasing.** `POST /current/org/{org_id}/sso/domains/{domain}/delete/` releases a claim, verified or not, and is never plan-gated. Releasing the **last verified domain** while the org is enforcing SSO is refused with `last_verified_domain` — step the mode down first. **Deleting the SSO configuration does not release the domains.** Verified domains survive `POST /current/org/{org_id}/sso/delete/`, so an administrator who switches identity providers does not have to prove domain ownership a second time. --- ## Roles: just-in-time default and group mapping `jit.default_role` is the org role a user receives when they first arrive through SSO. `role_mapping` maps identity-provider groups to org roles — a list of `{group, role}` pairs, read from `oidc.groups_claim` or `saml.groups_attribute` depending on protocol. **Role mapping is promote-only.** A mapped group **grants** a role. It never takes one away: - A user in a mapped group gets at least that role. - A user who *leaves* the group keeps the role they already hold — the identity provider never demotes anyone, and removing a mapping does not strip roles already granted. Remove a role through ordinary org member management. - **The organization owner is never affected** by role mapping, in either direction. This is intentional for a first release: a mis-typed group name can over-grant, which an administrator can see and correct in the member list, but it can never silently lock an organization out of its own account. **`role_mapping[].role` accepts `admin` only.** Mapping a group to `member` never did anything — every JIT arrival already gets the org's default role — so the value is no longer offered. A configuration saved before this restriction may still hold a stored `member` row; it is dropped, with a notice, the next time the configuration is saved (even a no-op resubmission of the same array), and is simply absent from the response after that. It is never refused outright, so an administrator making an unrelated change is not blocked by a row they did not know was there. --- ## Who may sign in: `provisioning_mode` `provisioning_mode` decides whether a successful authentication is allowed to *create* the account behind it. | Value | Meaning | |-------|---------| | `jit` | The default. Somebody on a verified domain who authenticates at the provider gets an account and a membership if they do not already have one. | | `scim_only` | The directory is the source of truth. Only people the organization has provisioned through SCIM may sign in. Everybody else is refused with `not_provisioned`, and nothing is created, linked, claimed or bound. | **`scim_only` locks out returning users as well as new ones, and that is what it means.** A member who originally arrived through just-in-time creation, and has been signing in for months, is refused the moment the organization flips to `scim_only` — exactly like somebody who has never been here. It is not a grandfathering bug. "The directory decides who may sign in" cannot be true and also make an exception for everyone who predates the decision. **Say so in the interface next to this control**, because the administrator who flips it is usually not the person who gets the support ticket. **The recovery path is to provision them.** Creating that person through SCIM **adopts** the identity they already have: the existing account, their federated subject and their current role are all kept, and they sign in again immediately. It does not make a second account, and it does not reset the role they hold. **"Provisioned by SCIM" means the resource id this service minted** when the person was created — not the `externalId` the provider sent. `externalId` is optional on the SCIM wire and plenty of providers omit it, so a person created without one is a perfectly ordinary SCIM user and signs in normally under `scim_only`. **Somebody the directory removed stays removed.** A deprovisioned identity is refused with `deprovisioned`, in either mode, rather than with `not_provisioned` — the two are different answers to different questions. **Group mapping never raises a SCIM-managed member's role, in either mode.** Where SCIM owns a person, SCIM's groups own their role, so a login-time group claim cannot promote them; a just-in-time user in a mapped group still is promoted as documented above. Change a SCIM-managed member's role in the directory, not at sign-in. `provisioning_mode` is a policy field: changing it does not bump `config_revision` and does not invalidate a passing configuration check. --- ## The configuration check `POST /current/org/{org_id}/sso/test/` runs a **structural configuration check**: - **OIDC** — fetches the discovery document at `oidc.issuer_url` and the key set it advertises, and checks their shape. - **SAML** — parses the configured certificate (and the rollover certificate, when one is set) and checks it has not expired. **It checks the configuration, not that a user can sign in.** A passing check says the values you entered are well-formed and the provider's published material is reachable and parsable. It does **not** say that a real user's credentials work, that the provider will release the claims you expect, that group names match your `role_mapping`, or that the provider has been told about the `sp` URLs. Those are only proven by an actual sign-in, which is documented separately. Do not describe a passing check to an administrator as "SSO works". **One warning a passing check can carry.** When `role_mapping` is set and the OIDC provider's discovery document advertises no `groups` scope, the check still passes and `last_test.message` ends with a warning. Fastio asks for `groups` only where the provider says it offers it, so such a provider releases no group names and every mapped role would silently do nothing. Fix it at the provider — publish the scope, or release the groups in the claim named by `oidc.groups_claim` — or clear the mapping so the expectation matches reality. When `oidc.extra_scopes` is also configured, that warning's wording changes: an unadvertised `groups` scope no longer proves role mapping is dead, because a custom scope the org added may be exactly what releases the claim, so the message says discovery cannot confirm it rather than asserting the mapping will not work. A third, independent warning can appear on its own when an extra scope simply is not in the advertised list. At most one warning sentence is ever appended. **The check's discovery fetch is always live; a real sign-in's is not.** `POST /sso/test/` re-fetches the discovery document on every call, so a change at the provider is reflected immediately. An actual sign-in reuses a cached copy for up to one hour, keyed to the current `config_revision` — so a provider-side change (a rotated signing key, an added or removed scope) that leaves the stored configuration untouched can take up to an hour to reach real sign-ins even though a check run right after it will already show it. Editing a credential, an issuer, a certificate, a claim map or a NameID format busts the cache immediately, because the revision it is keyed on changes. Editing anything else — a display name, `oidc.extra_scopes`, a role mapping — leaves that revision alone, so the cached copy stands until it ages out. **`tested` tracks the current configuration.** The read response carries three related fields, and they answer different questions: - `tested` — has a **successful** check been run against the configuration **as it stands now**? - `tested_at` — when the last recorded check **ran**, whichever configuration it targeted. `null` if no check has ever run. - `last_test` — that same last attempt in full: `at`, `ok` and a `message`. Changing a credential, an issuer or a certificate **invalidates the previous check**: `tested` goes back to `false` while `tested_at` and `last_test` still report the older, passing run. Read `tested` when you need to know whether the current configuration has been checked; read `tested_at` or `last_test` when you want to show what happened last. **`tested` and `tested_at` can legitimately disagree — that is a signal, not a bug.** They answer different questions, so a recent `tested_at` sitting beside `tested: false` is a meaningful state: a credential, issuer or certificate changed after a check passed, and that check no longer speaks for what is configured now. Show it as "checked at {time}, but the configuration has changed since" and re-run the check. --- ## The test sign-in The structural check proves the configuration parses. A **test sign-in** goes further and proves the protocol and the claims: a real administrator authenticates at the real provider, and Fastio validates the exchange or the signature, reads the claims or attributes, extracts the groups, and reports the role the mapping would produce. **It is a protocol and claims test, not an eligibility test.** It stops before anything that decides whether a given person may actually have an account: it does not check that the asserted address sits on a verified domain, does not apply `scim_only` admission, does not look for a deprovisioned tombstone or an account-link conflict, and creates no membership and no session. A test can therefore pass for somebody whose real sign-in is refused. Prove the rest by setting `mode` to `optional` and having a real, non-administrator user sign in before you enforce. `POST /current/org/{org_id}/sso/test/` with `mode: "signin"` first runs and records the structural check — returned as `check: {ok, message}` — and only proceeds if it passes. A failing check stops there: the response is a success envelope with `check.ok: false` and `redirect_url`/`expires_at` both `null`, and no browser trip happens. On a passing check it starts the sign-in transaction and returns `redirect_url`. Send the administrator's browser there. They authenticate at the provider exactly as a user would, the provider returns the browser to Fastio, and Fastio runs the whole protocol — token exchange or signature validation, claims or attributes, group extraction — and then **stops**. No account is created, no membership changes, no identity is bound, no session is issued, and no handoff cookie is set. The browser lands on: ``` {your_origin}/signin/sso?sso-test=1&ok=<0|1>&message= ``` **Treat that landing as a third outcome, not as a failed sign-in.** It is neither of the two the sign-in flow has: there is no handoff to exchange, and nothing went wrong with a login, because no login was attempted. Branch on `sso-test=1` before your ordinary sign-in error handling runs, or a passing test will be reported to the administrator as a broken one. The `message` values are a closed vocabulary — render an unrecognised one as a generic failure rather than showing it raw. **A test sign-in is accepted whatever `mode` currently is** — `off`, `optional` or `required`. It is not confined to initial setup: re-run it against a live organization after a certificate rotation or an identity-provider change, exactly as you would during setup. It still creates no account, membership or session, whatever the mode. **The summary is in the URL; the result is on the configuration read.** Refetch `GET /current/org/{org_id}/sso/` when the landing route mounts and read `last_signin_test`: | Field | Type | Description | |-------|------|-------------| | `at` | string | When the test completed (`Y-m-d H:i:s UTC`). | | `ok` | boolean | Whether the protocol completed successfully. | | `message` | string | The summary for the administrator. It is the redirect's value, plus — where the mapping resolved to `admin` — a fixed note that a SCIM-managed member is not promoted by group mapping. Do not apply the redirect's closed allowlist to it: match the leading value, or render it as given. Only the redirect's `message` is closed. | | `email` | string or null | The address the provider asserted. `null` when none could be read. | | `groups` | array | The group names the provider released. Bounded by count, by the length of each entry and by total size — a provider that releases hundreds of groups will be truncated. | | `mapped_role` | string or null | What `role_mapping` resolves those groups to. `admin` when an **`admin` mapping** matched; otherwise `member` whenever the groups are **known** — a matching entry whose `role` is `member` still yields `member` — including a known-empty list, which is what an absent SAML attribute and an absent or empty OIDC claim both produce. `null` only when the provider **withheld** the group information (the over-large-group-list indication some OIDC providers send in place of the list) or when the test failed. | | `config_revision` | integer | The configuration revision the test ran against. | | `stale` | boolean | Whether the configuration has changed since. | | `failure_reason` | string or null | `null` when `ok` is `true`, and on a result recorded before this field existed. On a failed test, the most likely cause, from a closed set: `assertion_unsigned` — the SAML assertion itself is not signed (only the response envelope, or nothing, is); `signature_invalid` — the signature does not verify against the configured certificate (SAML) or the provider's published signing keys (OIDC); `acs_mismatch` — the assertion's Recipient or the response's Destination is not the ACS URL; `audience_mismatch` — the audience is not this organization's SP entity id (SAML) or client id (OIDC); `issuer_mismatch` — the issuer is not the configured identity provider; `email_missing` — no usable email in the NameID or the mapped email attribute or claim; `clock_skew` — the assertion or token is not yet valid or has expired, usually a clock difference; `idp_error` — the identity provider itself reported a failure; `response_invalid` — anything else. Render an unrecognised value as `response_invalid`. | **`mapped_role` is what the mapping resolves to. It is not a prediction of the role a real sign-in would grant.** Those are different questions, and they have different answers for anybody the directory manages: group mapping never promotes a SCIM-managed member, so `mapped_role: "admin"` for such a person means "the mapping matched", not "they will become an administrator". When it resolves to `admin`, the stored `message` carries a fixed note saying so. That note is on `last_signin_test.message` only and never appears in the redirect. **Read `stale` and believe it.** `mapped_role` depends on `role_mapping`, and editing a role mapping deliberately does **not** bump `config_revision` — so a test that passed before the edit would otherwise look current while describing a mapping that no longer exists. Show a stale result as stale and offer to run another one; never present it as the state of things now. **A failed test is still a result.** A provider that refuses the administrator, or a protocol failure after the sign-in record was claimed, records `ok: false` with a message — that is the test working. A run that never got that far, because its record had expired or had already been used, records **nothing**: `last_signin_test` still shows the previous attempt. Compare `at` against when you started, rather than assuming the newest read reflects the run you just did. **An expired test lands on the ordinary sign-in error route, not on the test one.** Without its record nothing can tell that the returning browser was running a test, so it arrives at `/signin/sso` carrying `error=state_expired` in the URL **fragment** — no `sso-test=1`, no `ok`, no `message`. An administrator reporting that their test "said the sign-in expired" has hit exactly this; they should start a new test rather than read it as a broken configuration. **What a test sign-in writes, and what it leaves alone.** Its kickoff runs and records the structural check, so `tested`, `tested_at`, `last_test`, `enforcement_available` and `enforcement_blockers` move exactly as they would from calling `mode: "structural"` directly — a passing test sign-in is enough on its own to satisfy `required`'s check prerequisite. `config_revision` and the rest of the stored configuration are untouched, and the sign-in portion that follows the check — the protocol run itself — emits no configuration-updated event and changes none of those fields again. --- ## `issuer_shared` `issuer_shared` is `true` when another organization has configured the same issuer (or SAML entity ID). It is **advisory and informational only**. It never blocks a write, never fails a check, and does not mean anything is wrong — a company running several organizations on one identity provider will see it on every one of them, legitimately. Surface it as a note, not as an error; the useful reading is "confirm you meant to point two organizations at one provider." --- ## Endpoints ### Read the SSO configuration ``` GET /current/org/{org_id}/sso/ ``` Auth required. Org admin. Allowed on every plan. **curl example:** ```bash curl -X GET "https://api.fast.io/current/org/1234567890123456789/sso/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK) — an OIDC configuration:** ```json { "result": true, "sso": { "mode": "optional", "enforcement_available": true, "enforcement_blockers": [], "detected_provider": null, "protocol": "oidc", "display_name": "Acme Identity", "oidc": { "issuer_url": "https://login.example-idp.com/", "client_id": "example-client-id", "client_secret_set": true, "groups_claim": "groups", "extra_scopes": ["offline_access"], "extra_scopes_invalid": false }, "saml": { "idp_entity_id": null, "idp_sso_url": null, "idp_certificate_fingerprint": null, "idp_certificate_not_after": null, "idp_certificate_rollover_fingerprint": null, "idp_certificate_rollover_not_after": null, "groups_attribute": "groups", "nameid_format": "emailAddress", "metadata_url": null }, "sp": { "oidc_redirect_uri": "https://api.fast.io/v1.0/user/sso/oidc/callback/", "saml_acs_url": "https://api.fast.io/v1.0/user/sso/saml/acs/", "saml_entity_id": "https://api.fast.io/v1.0/user/sso/saml/metadata/?org=1234567890123456789", "saml_metadata_url": "https://api.fast.io/v1.0/user/sso/saml/metadata/?org=acme-corp" }, "jit": { "default_role": "member" }, "role_mapping": [ { "group": "acme-platform-admins", "role": "admin" } ], "provisioning_mode": "jit", "attribute_map": { "email": "mail" }, "attribute_map_invalid": false, "enforcement": { "exceptions": ["contractor@acme.com"] }, "tested": true, "tested_at": "2026-04-27 16:37:29 UTC", "last_test": { "at": "2026-04-27 16:37:29 UTC", "ok": true, "message": "The configuration is well formed and the issuer is reachable. This checks the configuration, not that a user can sign in." }, "last_signin_test": { "at": "2026-04-27 16:41:08 UTC", "ok": true, "message": "Sign-in test completed. The identity provider returned a valid sign-in response. (group mapping; SCIM-managed users are not promoted)", "email": "admin@acme.com", "groups": ["acme-platform-admins"], "mapped_role": "admin", "config_revision": 4, "stale": false, "failure_reason": null }, "issuer_shared": false, "certificate_warning": { "level": "none", "days_remaining": null, "rollover_available": false }, "domains": [ { "domain": "acme.com", "verified": true, "verified_at": "2026-04-26 09:14:02 UTC", "txt_record": { "name": "_fastio-verification.acme.com", "value": "fastio-verification={verification_token}" }, "auto_check_until": null }, { "domain": "acme-labs.com", "verified": false, "verified_at": null, "txt_record": { "name": "_fastio-verification.acme-labs.com", "value": "fastio-verification={verification_token}" }, "auto_check_until": "2026-04-28 09:14:02 UTC" } ], "config_revision": 4, "updated": "2026-04-27 16:37:29 UTC" } } ``` **Response (200 OK) — a SAML configuration (protocol-specific fields only):** ```json { "result": true, "sso": { "mode": "optional", "protocol": "saml", "display_name": "Acme Identity", "saml": { "idp_entity_id": "https://idp.example-idp.com/entity", "idp_sso_url": "https://idp.example-idp.com/sso", "idp_certificate_fingerprint": "11:22:33:44:55:66:77:88:99:00:AA:BB:CC:DD:EE:FF:11:22:33:44", "idp_certificate_not_after": "2027-04-27 16:37:29 UTC", "idp_certificate_rollover_fingerprint": "AA:BB:CC:DD:EE:FF:11:22:33:44:55:66:77:88:99:00:AA:BB:CC:DD", "idp_certificate_rollover_not_after": "2028-04-27 16:37:29 UTC", "groups_attribute": "groups" }, "tested": false, "tested_at": "2026-04-27 11:02:41 UTC", "last_test": { "at": "2026-04-27 11:02:41 UTC", "ok": true, "message": "The configuration is well formed and the signing certificate is valid. This checks the configuration, not that a user can sign in." }, "issuer_shared": false, "detected_provider": "okta", "certificate_warning": { "level": "expiring", "days_remaining": 14, "rollover_available": false }, "enforcement_available": false, "enforcement_blockers": ["domain_unverified", "check_stale"] } } ``` `tested: false` alongside a recent `tested_at` and `last_test.ok: true` is the normal state right after a credential or certificate change — the last check passed, but it was run against the previous configuration. **Response fields:** | Field | Type | Description | |-------|------|-------------| | `sso.mode` | string | `off`, `optional`, or `required`. See *Modes*. | | `sso.enforcement_available` | boolean | Whether the `required` mode may be selected for this organization right now — Enterprise plan, at least one verified domain, and a successful check for the current configuration. Read this rather than deriving the rule yourself. See *Enforcement*. | | `sso.enforcement_blockers` | array of strings | Why `enforcement_available` is `false`, in the order to fix them: `not_configured`, `domain_unverified`, `check_not_run`, `check_stale`, `check_failed`, `scim_not_connected`. Empty exactly when `enforcement_available` is `true`. Render from this list rather than re-deriving the rule. `scim_not_connected` is a `provisioning_mode: "scim_only"` organization whose SCIM token has never authenticated a request — nothing can provision anyone into it yet, so `required` is refused until SCIM connects. An organization already on `required` in that state is not blocked from saving its other fields. | | `sso.detected_provider` | string or null | One of `jumpcloud`, `okta`, `entra`, `google`, `onelogin`, inferred from the stored identity-provider URLs — a hint for showing provider-specific setup guidance on a returning visit. It is never a stored choice and never consulted at sign-in. `null` when nothing matches or no configuration exists yet. | | `sso.protocol` | string | `oidc` or `saml`. | | `sso.display_name` | string | The label a user sees on the sign-in button. | | `sso.oidc` | object | OIDC settings. See *Protocols*. | | `sso.oidc.client_secret_set` | boolean | Whether a client secret is stored. The secret itself is never returned. | | `sso.oidc.extra_scopes` | array | Extra scope tokens appended to the authorize request, as stored. `[]` when none are configured. | | `sso.oidc.extra_scopes_invalid` | boolean | `true` when the stored scope list could not be read back. `extra_scopes` then arrives as `[]` — the same shape as "none configured" — and login is refused for the organization until it is saved again. Mirrors `attribute_map_invalid` below; render it the same way. | | `sso.saml` | object | SAML settings. See *Protocols*. | | `sso.sp` | object | The service-provider values to paste into the identity provider. | | `sso.jit.default_role` | string | Org role assigned to a user arriving through SSO for the first time. | | `sso.role_mapping` | array | List of `{group, role}` pairs. Promote-only. | | `sso.saml.nameid_format` | string | `emailAddress`, `persistent` or `unspecified`. See *Choosing a NameID format*. | | `sso.saml.metadata_url` | string or null | The metadata URL the SAML settings were last imported from; `null` when they were entered by hand or pasted as a document. Display only. See *Importing the provider's metadata*. | | `sso.provisioning_mode` | string | `jit` or `scim_only`. See *Who may sign in*. | | `sso.attribute_map` | object or null | Claim and attribute name overrides, or `null` when none are set. See *Claim and attribute names*. | | `sso.attribute_map_invalid` | boolean | `true` when the stored map could not be read back. `attribute_map` then arrives as an empty object `{}` rather than as `null` — every sign-in for the organization is refused until the map is saved again. Render it as an error, never as "defaults in use". | | `sso.enforcement.exceptions` | array | Canonicalised addresses that are exempt exactly as an administrator is, on the three exemptible surfaces and no others. See *Enforcement*. | | `sso.last_signin_test` | object or null | The last test sign-in in full, or `null` if none has run. See *The test sign-in*. | | `sso.tested` | boolean | Whether a successful configuration check exists for the configuration **as it stands now**. | | `sso.tested_at` | string or null | When the last recorded check ran (`Y-m-d H:i:s UTC`), whichever configuration it targeted. `null` if none has run. May be recent while `tested` is `false`. | | `sso.last_test` | object | `at` (`Y-m-d H:i:s UTC`), `ok` (boolean), `message` (string) — the last attempt, whichever configuration it targeted. `at` and `ok` are `null`, and `message` is `""`, when no check has run. | | `sso.issuer_shared` | boolean | Advisory: another org has configured the same issuer. Never blocks anything. | | `sso.certificate_warning` | object | `{level, days_remaining, rollover_available}` — the SAML signing certificate's expiry state. Present on every response, including an unconfigured org and an OIDC one, so the front end needs one branch. See below. | | `sso.domains` | array | Claimed domains: `domain`, `verified`, `verified_at`, the `txt_record` (`name`, `value`) to publish, and `auto_check_until` — when the automatic hourly re-check of a PENDING domain's DNS stops on its own (`Y-m-d H:i:s UTC`, 48 hours after the claim, emailing the owner and admins on verification); `null` once verified or once that window has passed. A manual verify works at any time regardless. | | `sso.config_revision` | integer | Increments on every change that invalidates the configuration check — see *What a write can invalidate*. `0` for an org that has not saved a configuration yet; the first save is revision 1. | | `sso.updated` | string | When the configuration last changed (`Y-m-d H:i:s UTC`). | An organization that has not saved a configuration yet gets the same shape: `protocol`, `display_name` and `updated` are `null`, `config_revision` is `0`, and the `sp` values and any claimed `domains` are already present, so a domain can be proved before a provider is chosen. **`certificate_warning`.** `level` is `none`, `expiring` or `expired`; `days_remaining` counts down to the **active** signing certificate's `idp_certificate_not_after`, not a rollover's. `expiring` means the active certificate expires within 30 days. `rollover_available` is `true` only when a second certificate is also configured, has not itself expired, and expires later than the active one — it is what turns an `expiring` level from urgent into informational, because the identity provider still signs with the primary until it is actually rotated out, so the day count never jumps out just because a successor is loaded. The four combinations: - `level: "none"` → `days_remaining: null`, `rollover_available: false`. No certificate configured, or nowhere near expiry. - `level: "expiring"`, a rollover configured → `days_remaining` on the active cert, `rollover_available: true`. Worth noting, not urgent. - `level: "expiring"`, no rollover → `rollover_available: false`. Needs attention before it lapses. - `level: "expired"` → `days_remaining: 0`. Every federated sign-in for this org has stopped. An OIDC configuration and an org with no configuration at all both read `{level: "none", days_remaining: null, rollover_available: false}`, so the front end can render this key unconditionally rather than branching on protocol. Fastio does not currently send an email warning when a certificate nears expiry, so this field is the signal to surface to administrators. Render `certificate_warning` directly; do not derive a warning from `idp_certificate_not_after` yourself. --- ### Update the SSO configuration ``` POST /current/org/{org_id}/sso/ ``` Auth required. Org admin. **Enterprise plan required**, except for a request whose only field is `mode` and whose value is lower than the org's current mode (`required`→`optional`, `required`→`off`, `optional`→`off`). A **partial update** — send only the fields you are changing. Omitted fields are left alone. **Request parameters:** | Name | Type | Required | Description | |------|------|----------|-------------| | `mode` | string | No | `off`, `optional` or `required`. `required` additionally needs at least one verified domain and a successful configuration check — see *Enforcement*. | | `protocol` | string | No | `oidc` or `saml`. Required on the first save, when the organization has no configuration yet. | | `display_name` | string | No | Up to 128 characters. The label shown on the sign-in button. | | `oidc.issuer_url` | string (URL) | No | The provider's issuer URL. | | `oidc.client_id` | string | No | The client ID issued for Fastio. | | `oidc.client_secret` | string | No | **Write-only.** Never returned. | | `oidc.groups_claim` | string | No | Claim carrying group names. | | `oidc.extra_scopes` | array | No | Up to 5 extra scope tokens, each up to 64 characters, appended to the authorize request as-is. Refused if an entry repeats one of the three scopes Fastio sends on every request (`openid`, `email`, `profile`), repeats another entry, or uses a character outside the OAuth scope-token set. `groups` is accepted: Fastio requests it automatically only where the provider advertises it, so configuring it is how to ask for it from a provider that does not — and the authorize request carries it once either way. Omit the field to leave the stored list unchanged; send `[]` to clear it. Does **not** invalidate a passing check — see below. | | `saml.idp_entity_id` | string | No | The identity provider's entity ID. | | `saml.idp_sso_url` | string (URL) | No | The identity provider's sign-on URL. | | `saml.idp_certificate` | string (PEM) | No | **Write-only.** The signing certificate. | | `saml.idp_certificate_rollover` | string (PEM) | No | **Write-only.** Optional second certificate for a rollover window. | | `saml.groups_attribute` | string | No | Assertion attribute carrying group names. | | `saml.nameid_format` | string | No | `emailAddress` (default), `persistent` or `unspecified`. **Locked** once the organization has bound SAML identities. See *Choosing a NameID format*. | | `saml.metadata_url` | string (URL) | No | An `https://` address to fetch the provider's metadata document from. Send it **alone** — see *Importing the provider's metadata*. The address is remembered and read back as `saml.metadata_url` for display; sending it again is a new import. | | `saml.metadata_xml` | string | No | **Write-only.** The provider's metadata document itself, up to 256 KB. Send it **alone**. | | `jit.default_role` | string | No | Org role for a first-time SSO arrival: `member` (the default) or `admin`. | | `provisioning_mode` | string | No | `jit` (the default) or `scim_only`. See *Who may sign in*. | | `attribute_map` | object | No | Claim and attribute name overrides — `email`, `given_name`, `family_name`, each up to 64 characters. Send `{}` to clear; a `null` does nothing. See *Claim and attribute names*. | | `enforcement.exceptions` | array | No | Up to 100 email addresses, replacing the whole list. `[]` clears it. Canonicalised server-side. See *Enforcement*. | | `role_mapping` | array | No | Replaces the whole list, up to 100 entries. Each entry `{group, role}`, `group` up to 255 characters; `role` accepts `admin` only. An entry with `role: "member"` (typically one stored before this restriction) is silently **stripped**, not refused — a resubmission of the whole array (which the front end always sends) is accepted, and the legacy row is gone from the next read. | **curl example:** ```bash curl -X POST "https://api.fast.io/current/org/1234567890123456789/sso/" \ -H "Authorization: Bearer {jwt_token}" \ --data-urlencode "protocol=oidc" \ --data-urlencode "display_name=Acme Identity" \ --data-urlencode 'oidc={"issuer_url":"https://login.example-idp.com/","client_id":"example-client-id","client_secret":"{client_secret}","groups_claim":"groups"}' ``` Fields are form-encoded (`application/x-www-form-urlencoded`), not a JSON body. A nested object (`oidc`, `saml`) is one form field whose value is a JSON string, as shown above. Use `--data-urlencode` rather than `-d`: a real client secret containing `&`, `+` or `=` is otherwise mangled on the wire and the endpoint stores the wrong value. **Response (200 OK):** the same object as `GET /current/org/{org_id}/sso/`, reflecting the update. For a **SAML** configuration that has not already passed a check at its current revision, the save also runs the configuration check before responding, so `tested` / `last_test` / `enforcement_available` / `enforcement_blockers` in this response already reflect it — a SAML save can move an org straight to `enforcement_available: true` with no separate call. OIDC is not auto-checked on save, since its check is an outbound request; run *Run the configuration check* or a test sign-in for it. **What a write can invalidate.** Changing the protocol, a credential (client ID, client secret), an issuer or entity ID, the sign-on URL, a certificate, the groups claim or attribute, the claim mapping or the NameID format sets `tested` back to `false` and bumps `config_revision`. Re-run the configuration check afterwards. Changing a display name, a role mapping, `oidc.extra_scopes`, the provisioning mode or the exception list does not — those are policy, not credentials, and they leave a passing check standing. `oidc.extra_scopes` is deliberately policy rather than a credential: bumping the revision for it would clear `enforcement_available` on an edit that changes nothing about whether the configuration works, so it instead carries an advisory warning on the next check — see below. **Omit `saml.metadata_url` and `saml.metadata_xml` from an ordinary save.** They are checked on *presence*, not on value, so sending either one empty alongside the manual `saml.idp_*` fields is a refused request rather than a no-op. Send a metadata field only on the request that is actually an import, and send nothing else SAML-related with it. **Telling your save from somebody else's.** The same changes increment `config_revision`. It is `0` for an org that has not saved a configuration yet, and the first save is revision 1. Compare the revision you read back against the one you were holding when two administrators may be editing at once. **Refusals:** `plan_required`, `invalid_config`, `enforcement_unavailable`, and — on an attempt to enforce — `domains_unverified`, `test_required` and, for a `provisioning_mode: "scim_only"` org whose SCIM has never connected, `scim_not_connected`. A NameID format change after identities are bound is `nameid_format_locked`. A metadata import has its own five reasons plus `metadata_conflict`, `metadata_conflicts_with_manual_fields` and `metadata_requires_saml`. See *Refusal reasons* and *Importing the provider's metadata*. --- ### Run the configuration check ``` POST /current/org/{org_id}/sso/test/ ``` Auth required. Org admin. Enterprise plan required. Runs one of two checks, chosen by `mode`. **Request parameters:** | Name | Type | Required | Description | |------|------|----------|-------------| | `mode` | string | No | `structural` (the default) runs the configuration check described in *The configuration check*, and on success records the result against the configuration as it stands, which is what flips `tested` to `true`. `signin` starts a **test sign-in** instead — see *The test sign-in*. | `mode: "signin"` is accepted **whatever `sso.mode` currently is** — `off`, `optional` or `required` — so an administrator can re-test a live organization after a certificate rotation or an identity-provider change. It runs and records the structural check first — the same as calling this endpoint with `mode: "structural"` — so it does touch `tested`; the sign-in that follows emits no event, and its only record is the result shown as `last_signin_test` once you return. **curl example:** ```bash curl -X POST "https://api.fast.io/current/org/1234567890123456789/sso/test/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "sso_test": { "ok": true, "message": "The configuration is well formed and the issuer is reachable. This checks the configuration, not that a user can sign in.", "issuer": "https://login.example-idp.com/" } } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `sso_test.ok` | boolean | Whether the structural check passed. | | `sso_test.message` | string | Human-readable summary, suitable for display to an administrator. | | `sso_test.issuer` | string | The issuer the check ran against. | A failed check is reported as `ok: false` with a `message` — it is a result, not a transport error. A refusal to *run* the check at all (wrong plan, unusable configuration) is a 4xx carrying a `reason`. **`oidc.extra_scopes` gets an advisory line in `message`, never a failure.** The discovery document's `scopes_supported` is optional and often incomplete, so an unadvertised extra scope is not proof the provider will reject it — the check appends a note that discovery cannot confirm it, and `ok` stays `true`. If role mapping is configured and the provider advertises no `groups` scope, the check already carries a warning about that (see *The configuration check*); with extra scopes configured, that warning's wording changes from asserting no role will be applied to saying discovery cannot confirm the extra scopes either — because a scope the org added may be exactly what releases the group claim. At most one warning sentence is appended. **Response (200 OK) — `mode: "signin"`:** ```json { "result": true, "sso_test": { "mode": "signin", "check": { "ok": true, "message": "The configuration is well formed and the issuer is reachable. This checks the configuration, not that a user can sign in." }, "redirect_url": "https://login.example-idp.com/authorize?{provider_parameters}", "expires_at": "2026-04-27 16:47:29 UTC" } } ``` | Field | Type | Description | |-------|------|-------------| | `sso_test.mode` | string | Always `signin` on this response. | | `sso_test.check` | object | The structural check this call ran first, as `{ok, message}` — identical shape to the `mode: "structural"` response above. | | `sso_test.redirect_url` | string or null | Send the administrator's browser here. `null` when `check.ok` is `false` — the sign-in never started. | | `sso_test.expires_at` | string or null | When the started test expires (`Y-m-d H:i:s UTC`). `null` alongside a null `redirect_url`. | When `check.ok` is `false`, the response stops there: no sign-in transaction opens, and `redirect_url` / `expires_at` are both `null`. Otherwise the outcome of the sign-in itself does not come back on this response — the browser goes to the provider and returns to `{your_origin}/signin/sso?sso-test=1&...`, and the full result is read from `last_signin_test` on `GET /current/org/{org_id}/sso/`. See *The test sign-in*. --- ### Delete the SSO configuration ``` POST /current/org/{org_id}/sso/delete/ ``` Auth required. Org admin. **Never plan-gated** — this reduces exposure. Clears the configuration and returns the org to `mode: off`. **Verified domains are kept.** An administrator re-configuring a provider — switching vendors, rebuilding a broken configuration — does not have to prove domain ownership again. Release a domain explicitly if you want it gone. **Existing SAML sign-in bindings are released when the configuration is deleted, and re-established on the next sign-in.** Each person's SAML binding is let go along with the configuration, and rebuilt the next time they sign in — matched by their verified email address, and bound under whatever the new configuration asks for. A SAML configuration recreated with a different NameID format therefore cannot orphan them. **OpenID Connect bindings are kept**, because an OpenID Connect account can exist on an address the provider never verified and is recognised on return by its bound subject alone; releasing one would lock that person out rather than re-link them. Accounts, memberships, directory-provisioned records and deprovisioned identities are all left exactly as they were. **curl example:** ```bash curl -X POST "https://api.fast.io/current/org/1234567890123456789/sso/delete/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true } ``` The response carries no configuration. Read `GET /current/org/{org_id}/sso/` afterwards to show the cleared state and the domains that remain. --- ### Claim a domain ``` POST /current/org/{org_id}/sso/domains/ ``` Auth required. Org admin. Enterprise plan required. **Request parameters:** | Name | Type | Required | Description | |------|------|----------|-------------| | `domain` | string | Yes | The email domain to claim. Stored lowercase; supply an internationalized name in its ASCII (punycode) form. Max 255 characters. | **curl example:** ```bash curl -X POST "https://api.fast.io/current/org/1234567890123456789/sso/domains/" \ -H "Authorization: Bearer {jwt_token}" \ -d "domain=acme.com" ``` **Response (200 OK):** ```json { "result": true, "domain": { "domain": "acme.com", "verified": false, "verified_at": null, "txt_record": { "name": "_fastio-verification.acme.com", "value": "fastio-verification={verification_token}" }, "auto_check_until": "2026-10-08 14:02:11 UTC" } } ``` Publish `txt_record.name` as a TXT record with `txt_record.value`, then call the verify endpoint. **The claim expires 7 days after it is created** if it has not been verified by then. **Fastio also re-checks it on its own**, every hour until `auto_check_until` passes (48 hours after the claim), emailing the organization's owner and admins the moment it verifies; a manual verify call still works at any time and `auto_check_until` reads `null` once the domain is verified or that window has closed. A new claim fires the SSO audit event with `policy_changes.domains.added` carrying the name — see *Events and the audit log*. Re-claiming a domain this org already holds returns the same token unchanged and raises nothing new. **Refusals:** `plan_required`, `domain_not_allowed` (public suffix, IP address, a Fastio domain, or a malformed or non-ASCII name), `domain_in_use` (another org already holds a **verified** claim on this name). --- ### Verify a domain ``` POST /current/org/{org_id}/sso/domains/{domain}/verify/ ``` Auth required. Org admin. Enterprise plan required. Reads DNS **now** and reports what it found. Call it once the record has propagated; there is no queue and no callback. **curl example:** ```bash curl -X POST "https://api.fast.io/current/org/1234567890123456789/sso/domains/acme.com/verify/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK) — verified:** ```json { "result": true, "domain": { "verified": true, "message": "The verification record was found and the domain is now verified." } } ``` **Response (200 OK) — not yet:** ```json { "result": true, "domain": { "verified": false, "message": "The verification record was not found. DNS changes can take a while to publish — check the record and try again." } } ``` A record that has not propagated yet — including an authoritative absence, where DNS answered and the record is simply not there — is `domain.verified: false` with a `domain.message`, a 200, not an error. Retry the verify call — re-claiming is safe: while the claim is still live it returns the same verification token, so the record you already published stays valid. Once a claim has expired, re-claiming issues a NEW token and the old TXT record no longer proves anything — publish the value the new claim returns. A **transient** DNS lookup failure (the resolver did not return an authoritative answer at all) is a different case and is reported as its own retryable error — `503` with `reason` = `dns_unavailable` — never as `verified: false` — telling an admin their record is missing when DNS simply did not answer sends them off editing a configuration that was already correct. **Refusals:** `plan_required`, `not_configured` (this organization holds no claim on that name — claim it first), `domain_in_use` (another organization verified the name first), `domain_not_allowed`, and the retryable `dns_unavailable`. --- ### Release a domain ``` POST /current/org/{org_id}/sso/domains/{domain}/delete/ ``` Auth required. Org admin. **Never plan-gated** — this reduces exposure. Releases the claim, verified or pending. The name becomes claimable by any org again. **curl example:** ```bash curl -X POST "https://api.fast.io/current/org/1234567890123456789/sso/domains/acme.com/delete/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true } ``` **Refusals:** `not_configured` — this organization holds no claim on that name; `last_verified_domain` — the org is enforcing SSO and this is its last verified domain. Step the mode down first, then release. --- ### Preview SAML metadata ``` POST /current/org/{org_id}/sso/metadata/preview/ ``` Auth required. Org admin. Enterprise plan required. Validates a SAML identity provider's metadata document without saving it — check it before committing it to the configuration. **Request parameters:** | Name | Type | Required | Description | |------|------|----------|-------------| | `metadata_url` | string | One of these two | An `https://` address to fetch the metadata document from. | | `metadata_xml` | string | One of these two | The metadata document itself, pasted. | Send exactly one. This runs the identical import `POST .../sso/` runs on `saml.metadata_url` / `saml.metadata_xml` — see *Importing the provider's metadata* — so a refusal carries the same `reason` the save would give. A URL preview opens an outbound request on the organization's behalf and is throttled more tightly than an ordinary call; a pasted document opens no connection. **Persists nothing and emits no event.** **curl example:** ```bash curl -X POST "https://api.fast.io/current/org/1234567890123456789/sso/metadata/preview/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"metadata_url": "https://sso.jumpcloud.com/saml2/fastio/metadata.xml"}' ``` **Response (200 OK):** ```json { "result": true, "preview": { "idp_entity_id": "https://sso.jumpcloud.com/saml2/fastio", "idp_sso_url": "https://sso.jumpcloud.com/saml2/fastio", "certificate": { "fingerprint": "AB:CD:...:EF", "not_after": "2031-01-01 00:00:00 UTC" }, "rollover_certificate": null, "detected_provider": "jumpcloud" } } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `preview.idp_entity_id` | string | The identity provider's entity ID, parsed from the document. | | `preview.idp_sso_url` | string | The identity provider's HTTP-Redirect sign-on URL. | | `preview.certificate` | object | The primary signing certificate, as `{fingerprint, not_after}`. `fingerprint` is the same SHA-256 colon-separated format `GET .../sso/` reports. | | `preview.rollover_certificate` | object or null | The same shape, when the document publishes a second signing certificate; otherwise `null`. | | `preview.detected_provider` | string or null | One of `jumpcloud`, `okta`, `entra`, `google`, `onelogin`, inferred from the metadata URL and the entity ID / SSO URL hosts — a hint for pre-selecting a preset (`GET .../sso/presets/`), never used for sign-in. `null` when nothing matches. | **Refusals:** `plan_required`; `metadata_conflict` (both, or neither, of `metadata_url`/`metadata_xml` supplied); the importer's own reasons — `metadata_unreachable`, `metadata_too_large`, `metadata_invalid`, `metadata_no_signing_cert`, `metadata_unsupported`, `metadata_redirect_binding_missing` — identical to what `POST .../sso/` would give for the same document. --- ### Get provider presets ``` GET /current/org/{org_id}/sso/presets/ ``` Auth required. Org admin. Allowed on every plan. Returns a fixed set of per-vendor defaults the setup wizard can pre-fill. Nothing is read from or written to the organization, and no event is emitted. **curl example:** ```bash curl -X GET "https://api.fast.io/current/org/1234567890123456789/sso/presets/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "presets": { "jumpcloud": { "protocol": "saml", "nameid_format": "emailAddress", "attribute_map": { "email": "email", "given_name": "givenName", "family_name": "familyName" }, "groups_attribute": "memberOf" }, "okta": { "protocol": "saml", "nameid_format": "emailAddress", "attribute_map": { "email": "email", "given_name": "firstName", "family_name": "lastName" }, "groups_attribute": "groups" }, "entra": { "protocol": "saml", "nameid_format": "emailAddress", "attribute_map": null, "groups_attribute": "http://schemas.microsoft.com/ws/2008/06/identity/claims/groups" }, "google": { "protocol": "saml", "nameid_format": "emailAddress", "attribute_map": { "email": "email", "given_name": "firstName", "family_name": "lastName" }, "groups_attribute": null }, "onelogin": { "protocol": "saml", "nameid_format": "emailAddress", "attribute_map": { "email": "Email", "given_name": "FirstName", "family_name": "LastName" }, "groups_attribute": "MemberOf" }, "other_saml": { "protocol": "saml", "nameid_format": "emailAddress", "attribute_map": null, "groups_attribute": "groups" }, "other_oidc": { "protocol": "oidc", "nameid_format": null, "attribute_map": null, "groups_attribute": "groups" } } } ``` **Response fields:** one entry per vendor key above, each `{protocol, nameid_format, attribute_map, groups_attribute}` — values ready to send as the matching fields on `POST .../sso/`. `nameid_format` and `attribute_map` apply to `saml`; `groups_attribute` maps to `saml.groups_attribute` or `oidc.groups_claim` depending on the chosen protocol. `attribute_map: null` means use the default attribute names (see *Claim and attribute names*); `groups_attribute: null` means that vendor sends no group claim by default. **A configured attribute name the provider does not actually send fails the sign-in** — releasing the matching claim or attribute at the provider is still the administrator's job. No refusals beyond the standard authentication and permission checks. --- ### Preview enforcement impact ``` GET /current/org/{org_id}/sso/enforcement/preview/ ``` Auth required. Org admin. Allowed on every plan. Reports how many of the organization's members a switch to `mode: "required"` would affect, before you make it. Read-only: nothing is revoked and no event is emitted. **The counts cover members only (`scope: "members"`).** Switching to `required` signs out every Fastio account whose address is on a verified domain and that is not exempt — including accounts that are **not members** of this organization — and a sign-out ends that account's sessions everywhere, not only in this organization. The real impact can therefore be larger than this preview shows; warn everyone on the domain, not only the members counted here. **curl example:** ```bash curl -X GET "https://api.fast.io/current/org/1234567890123456789/sso/enforcement/preview/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "enforcement_preview": { "scope": "members", "members_signed_out": 42, "exempt": 3, "exceptions": 1, "unreadable": 0, "domains": ["acme.com"], "capped": false, "complete": true } } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `enforcement_preview.scope` | string | Always `members`: the counts below cover the organization's members only. Non-member accounts on a verified domain are signed out too and are not counted. | | `enforcement_preview.members_signed_out` | integer | Members on a verified domain who are not the owner, not an admin, and not on the exception list — the members who would need to sign back in through the identity provider. | | `enforcement_preview.exempt` | integer | The owner and admins on a verified domain. | | `enforcement_preview.exceptions` | integer | Other members on a verified domain whose address is on `enforcement.exceptions`. | | `enforcement_preview.unreadable` | integer | Members whose account could not be read, so they are in none of the counts above. | | `enforcement_preview.domains` | array of strings | The organization's verified domains, sorted. | | `enforcement_preview.capped` | boolean | `true` when the organization is too large for the preview to count every member — the counts above are then a lower bound, not exact. | | `enforcement_preview.complete` | boolean | `false` when `capped` is `true` or `unreadable` is above `0` — the counts are then a lower bound. Retry later for an exact count. | With no verified domain, every count is `0` and `complete` is `true`. Even a `complete` preview counts the organization's **members** only — enforcement itself reaches every account on a verified domain, member or not, and ends its sessions everywhere, so the real impact of switching to `required` can be larger than this preview shows. **Refusals:** `internal` (`500`) — a domain, exception-list, or member read failed. --- ## Refusal reasons A refused request carries a `reason` string in `error.params`. **Branch on `reason`.** Do not branch on the numeric `error.code` (assigned per endpoint, so the same condition reports different numbers from different routes) and do not branch on the HTTP status alone (one status covers several unrelated conditions). | `params.reason` | What happened | What the caller should do | |-----------------|---------------|---------------------------| | `plan_required` | A configuration write was attempted on a plan without Enterprise SSO. | Offer an upgrade. Read `capabilities.sso` on the org rather than probing with a write. | | `domains_unverified` | Enforcement was requested with no verified domain. | Claim and verify at least one domain first. | | `test_required` | Enforcement was requested without a successful configuration check for the **current** configuration. | Run `POST .../sso/test/`, then retry. | | `domain_in_use` | Another organization already holds a **verified** claim on this domain. | Only one org can own a domain. Two orgs may hold pending claims; only one may verify. | | `invalid_config` | The submitted configuration is not usable — a malformed URL or certificate, a field the chosen protocol does not accept, or a combination that cannot work. | Fix the values and resubmit. | | `domain_not_allowed` | The name cannot be claimed: a public suffix (`com`, `co.uk`), an IP address, a Fastio domain, or a malformed or non-ASCII name. | Claim a domain your organization actually controls, in its ASCII (punycode) form. | | `enforcement_unavailable` | `mode=required` was selected for an organization that may not enforce — `enforcement_available` is `false`. Where a single prerequisite is the blocker, the refusal names it instead (`plan_required`, `domains_unverified`, `test_required`). | Read `enforcement_available` and satisfy the prerequisites in *Enforcement*, rather than probing with a write. | | `last_verified_domain` | A release would leave an enforcing org with no verified domain. | Step the mode down, then release the domain. | | `nameid_format_locked` | A SAML NameID format change was attempted after the organization already has federated identities bound to it. | Leave the format as it is. Changing it would orphan every one of those identities, so it is refused rather than allowed to strand them. | | `scim_not_connected` | `mode=required` was selected for a `provisioning_mode: "scim_only"` organization whose SCIM token has never authenticated a request. | Mint the SCIM token, point the directory at it, and let it connect before enforcing. An organization already enforcing in this state may still save its other fields. | | `not_configured` | The organization has no SSO configuration to check or test, or the domain named in the path is not claimed by this organization. | Save a configuration, or claim the domain, first. | | `dns_unavailable` | `503`. DNS gave no authoritative answer while verifying a domain. | Retry shortly. This never means the record is missing. | | `internal` | Something failed on Fastio's side. | Retry later. | | `metadata_unreachable`, `metadata_too_large`, `metadata_invalid`, `metadata_no_signing_cert`, `metadata_unsupported`, `metadata_redirect_binding_missing`, `metadata_conflict`, `metadata_conflicts_with_manual_fields`, `metadata_requires_saml` | A SAML metadata import could not be completed. | Each names something specific — see *Importing the provider's metadata*. | **Error envelope.** These follow the standard envelope documented in `llms.txt` — `result: false` and an `error` object carrying `code`, `text` and `params`: ```json { "result": false, "error": { "code": 123456, "text": "Enterprise Single Sign-On is not available on your current plan.", "params": { "reason": "plan_required" } } } ``` --- ## SCIM 2.0 provisioning Just-in-time creation covers most organizations: a person signs in and the account appears. SCIM covers what JIT cannot — **removal**. An identity provider that speaks SCIM 2.0 can create people ahead of their first sign-in, keep their details current, put them in groups that map to an organization role, and **deprovision** them the moment they leave, without anybody logging in to make it happen. SCIM is optional. An organization that does not configure it loses nothing that SSO already gives it. Base URL: `https://api.fast.io/v1.0/scim/v2/` ### The token `POST /current/org/{org_id}/scim/token/` mints the bearer the identity provider will use. It is returned **once**, in that response, and is not recoverable afterwards — only a keyed hash of it is stored. Minting again **rotates** the token: by default the previous one stops authenticating the moment the new one is written, so re-point the provider at the new value before you press it a second time. An optional `overlap_seconds` (0..86400, default 0) keeps the outgoing token authenticating for that many seconds instead, so provisioning does not stop while the provider is re-pointed — see *Rotating with an overlap* below. | Endpoint | Who | Plan-gated | Returns | |---|---|---|---| | `GET /current/org/{org_id}/scim/` | org admin | no | `scim: {enabled, token_set, created, last_seen, last_error, last_error_at, previous_token_expires_at, base_url}` | | `POST /current/org/{org_id}/scim/token/` | org admin | **yes** | `{token, base_url}`, shown once. Body: `overlap_seconds` (integer, 0..86400, default 0). | | `POST /current/org/{org_id}/scim/token/revoke/` | org admin | no | `{result: true}` only | ### Rotating with an overlap By default, rotating the token is an immediate cutover: the previous token stops working the instant the new one is minted. Sending `overlap_seconds` above zero keeps the outgoing token authenticating for exactly that many seconds afterwards, so a directory sync mid-flight against the old value does not fail while the identity provider is being re-pointed. **There is one predecessor slot.** A second rotation while an overlap is still open drops the first predecessor — only the immediately preceding token can ever authenticate alongside the current one, never two generations back. `overlap_seconds: 0` also ends an overlap already in flight, so a rotation can be used to cut one short. **The overlap never resurrects a revoked credential.** A token that was revoked is not carried into an overlap on the next rotation, and revoking the current token clears any predecessor overlap that was still open. `POST .../revoke/` is always an immediate, total cutoff for whatever it revokes. **`previous_token_expires_at`** on `GET /current/org/{org_id}/scim/` is `null` when no overlap is in flight, a **future** time while the previous token still authenticates, and a **past** time once that window has closed — so "still usable" and "expired" are distinguishable from the same field rather than collapsing to one absent value. **A presentation after the window closes is an attributed refusal.** `last_error` gains a fourth value, `previous_token_expired`: the identity provider presented the token that used to be current, but its overlap window has passed. A presentation of that same token *inside* the window succeeds silently, exactly like the current one. The predecessor slot itself stays in place for attribution until the next rotation or revocation replaces it, so a later presentation of that same expired token is refused the same way again; `last_error` is cleared by the next successful call, exactly like every other reason. `enabled` reports whether the plan carries the feature; `token_set` reports whether a usable token exists right now. Revocation is deliberately never plan-gated and is safe to repeat: it is the lever an administrator reaches for when a token has leaked, and a plan check standing between them and it would be a control that fails exactly when it is needed. **`last_seen` and `last_error` answer two different questions, and only one of them is "is this working?"** - `last_seen` is when a request last authenticated successfully. **This is the health field.** - `last_error` is a short description of the last refusal that could be attributed to *this organization's token*, with `last_error_at` for when. There are exactly four: the token had been revoked, the presented bearer did not match the stored hash, the token names an organization that cannot be provisioned into, and — see *Rotating with an overlap* — a rotation predecessor was presented after its overlap window closed (`previous_token_expired`). **A `null` `last_error` does not mean the integration is healthy.** The two refusals an administrator most wants to see — a request that presented no bearer at all, and a bearer that matches no token anywhere — carry no organization, so there is nothing to record them against and they are not recorded. That is deliberate: a field that any anonymous caller could write into would be a field that anyone on the internet could fill with noise for an organization they have never heard of. **Read `last_seen` to answer "is the provider getting in", and `last_error` only to explain a specific failure.** Say as much in the interface; a bare "no errors" badge over this field is a lie an administrator will act on. `last_error` never contains the bearer, the `Authorization` header, or any part of a token — the stored text is a fixed phrase, not something derived from the request. Both fields are cleared by the next successful call, and reset when the token is re-minted, so a fresh token never displays the previous one's failure. Repeated identical refusals are written at most periodically rather than on every request; a change of reason is recorded immediately. ### Talking to the SCIM endpoints The token goes in the `Authorization` header, as `Bearer {scim_token}`, and **only** there. A token supplied in a request body or a query string is not accepted. Every response, including every error, is `application/scim+json` and uses SCIM's own `Error` document rather than the platform envelope described elsewhere in these docs. Status codes are SCIM's: `201` on create, `204` on delete, `409` on a uniqueness conflict, `400` with a `scimType` on a bad request. The three discovery documents — `ServiceProviderConfig`, `Schemas` and `ResourceTypes` — are **unauthenticated**, so a client can read what this service supports before it has been given a token. Read `ServiceProviderConfig` first: it declares that `patch` and `filter` are supported and that `bulk`, `sort` and `etag` are not. | Endpoint | Methods | Auth | Returns | |---|---|---|---| | `/v1.0/scim/v2/ServiceProviderConfig` | `GET` | none | The RFC 7644 §4 capabilities document — which of `patch`/`filter`/`bulk`/`sort`/`etag` are supported, and the bearer-token authentication scheme to use once a token has been minted | | `/v1.0/scim/v2/ResourceTypes` | `GET` | none | The two resource types this service exposes (`User`, `Group`) — each one's routed `endpoint` and its schema URN | | `/v1.0/scim/v2/Schemas` | `GET` | none | The full attribute schema for `User` and `Group`, per RFC 7643 | ### Resources | Endpoint | Methods | |---|---| | `/v1.0/scim/v2/Users` | `GET` (filter, `startIndex`, `count`), `POST` | | `/v1.0/scim/v2/Users/{id}` | `GET`, `PUT`, `PATCH`, `DELETE` | | `/v1.0/scim/v2/Groups` | `GET`, `POST` | | `/v1.0/scim/v2/Groups/{id}` | `GET`, `PUT`, `PATCH`, `DELETE` | `{id}` is the SCIM resource id this service returned when the resource was created. It is not the organization id, the user id, or any other identifier that appears elsewhere in these docs. An id belonging to another organization does not resolve: it answers `404`, exactly as an id that never existed does. **The user list holds only the people the directory owns.** `GET /Users` — with a filter or without one — returns the identities this service provisioned, and nothing else. Somebody who arrived through just-in-time sign-in and has not been adopted does not appear, because there is no resource id an endpoint could address them by. Read that absence as "not provisioned", never as "not here": `POST` the person by `userName` and the service adopts the identity that already exists rather than answering `409`. See *Creating and updating a person*. **Filtering** supports one form, `attribute eq "value"`, on `userName` and `externalId` for Users and on `displayName` and `externalId` for Groups. Anything else — a different operator, `and`/`or`/`not`, a value path — is refused with `400` and `scimType: invalidFilter`. It is refused rather than ignored on purpose: a client that received every user back for a one-user query would provision against the wrong answer. **Pagination** is SCIM's: `startIndex` is 1-based and `count` defaults to 100, capped at 200. Reading the same `startIndex` twice returns the same page. `meta.created` and `meta.lastModified` on a SCIM resource use RFC 3339 (`2026-04-27T16:37:29Z`), because RFC 7643 requires that format of every SCIM service. Every other timestamp on this page, including `created` and `last_seen` on the token endpoints above, uses the usual `2026-04-27 16:37:29 UTC` form. ### Creating and updating a person `userName` and the primary email must be **the same address**, and that address must sit inside a domain the organization has verified. Both rules follow from what an organization's claim actually is: proof of control over a domain, not over an individual mailbox. A document whose handle and address disagree is refused rather than resolved in favour of one of them. After a person is provisioned, `name` and `emails` are **immutable** unless the account belongs to this organization and no other — a `400` with `scimType: mutability` otherwise. Somebody who is also a member of another organization has an account that organization can see too, and one provider rewriting a shared account would reach across a boundary it never had a claim over. `externalId` is always writable, and so is `active` — but only when the document actually states it. A `PUT` that omits `active` leaves the person's state alone rather than treating the omission as `true`, so a routine profile refresh cannot reactivate somebody the organization removed. A `POST` carrying `active: false` for somebody who is already a member of the organization is an **offboarding**, and runs exactly the steps below. A first synchronisation often sends a provider's whole directory, leavers included, and treating those documents as quiet creates would leave the people in question with their access intact. **A `POST` for somebody who has already been signing in through SSO adopts that identity** instead of answering `409`. Their account, their federated subject and the role they currently hold are all kept, and the response is the ordinary `201` with the resource. This is the repair path for an organization that has moved to `provisioning_mode: "scim_only"` and needs to bring its existing just-in-time members under the directory — provision each of them, and they can sign in again straight away. The organization's default role is applied **only** where the person has no membership at all: an adoption fixes the identity record, it never re-levels somebody. **Adoption requires an active create.** Three cases still answer `409`: a person the directory already owns, one who has been deprovisioned, and a create carrying `"active": false` over a live just-in-time member — that last one is an offboarding written as a create, so it neither adopts nor offboards. To offboard such a person, **adopt them first** with an active create, then `DELETE` or `PATCH` with `active: false` using the resource id that create returned — until they have been adopted they are not in the user list and have no resource id at all, so `DELETE` and `PATCH` have nothing to reach. **An active create onto an existing account whose email address was never verified revokes what it held.** When a `POST` carrying `active: true` provisions somebody onto an account that already held the address and never verified it — and is not adopting their SSO identity — everything issued to that account before is revoked once: every API key, OAuth grant and already-issued OAuth or agent access token, connected Google/Microsoft sign-in, session, two-factor enrolment, stored phone number and pending email change. Any password is replaced with an unknown one (the holder sets a new one through password reset), and the address is marked verified. A create that is refused, carries `active: false`, or adopts an existing SSO identity revokes nothing. If the revocation cannot complete, the create answers `500` without provisioning the person, and the provider's retry revokes again in full. ### Removing a person `DELETE /Users/{id}` and `PATCH` with `active: false` are the **same operation**. Both: - mark the identity deprovisioned, so a later SSO sign-in is refused with `reason` = `deprovisioned`, an invitation to this organization cannot be accepted, and automatic domain join does not apply; - revoke the person's existing sessions; - remove their organization membership; - transfer any workspace they owned to the organization owner, remove them from the workspaces they belong to, and clear the shares they held inside those workspaces. The first three happen before the response. The workspace cleanup completes shortly afterwards, and is scheduled before the membership is removed so it cannot be lost. **The account itself is not deleted, and personal shares are untouched.** Deprovisioning removes the person from this organization — its membership, its workspaces, and the shares held inside those workspaces — but their Fastio account survives, and any share they own directly rather than through an organization workspace is not touched by this cascade at all. Somebody removed from one organization keeps their account and their own shares, and can still be found and re-provisioned into this organization, or any other, later. Repeating either is a success — identity providers retry, and a retry that failed would look like an outage. A repeat that finds the removal unfinished finishes it, so a removal interrupted partway through is completed by the provider's own retry rather than being reported as already done. `PATCH` with `active: true` puts the person back at the organization's default role, provided their address still sits in a verified domain. **The organization owner and its last remaining administrator cannot be deprovisioned.** Either attempt is refused with `400`. A mis-scoped group in an identity provider must not be able to lock an organization out of itself. ### Groups and roles A group whose `displayName` matches an entry in the organization's role mapping raises its members to that role. Membership is edited with `PATCH`, including the `members[value eq "{scim_user_id}"]` form used to remove one person without resending the group. Role mapping through SCIM follows the same rule as sign-in: it **promotes and never demotes**. Taking somebody out of a mapped group does not lower their role, and neither does deleting the group. Lower a role from the organization's own member list. --- ## Events and the audit log Every SSO write emits **two** things: - the ordinary **organization-updated** event, so a client already watching the org refetches without knowing anything about SSO; and - a **dedicated SSO audit event**, so the change is attributable in the organization's audit log. Configuration changes, deletions, and domain verification and un-verification all appear there. See the Events & Activity reference for reading the audit log; administrative event reads require an admin-capable credential. **The SSO audit event carries a value payload for `mode` and domains — nothing else.** `updates` names every field a request changed, but only as field names: a credential, a certificate or a claim map never appears there, on purpose. Two fields are different, because "the mode changed" says nothing about which direction and "domains changed" says nothing about which domain: the audit event's `policy_changes` map carries `mode: {before, after}` when the mode moved, and `domains: {added, removed}` when a domain was claimed. Both are derived from the state actually committed under the write lock, not from a separate read taken earlier, and a save that did not move either contributes nothing to `policy_changes`. Claiming a domain now raises this event on its own — it used to raise only the generic organization-updated event, leaving the claim itself off the audit trail. **`org_sso_certificate_expiring` is a defined event type for a signing-certificate expiry warning, but it is not currently raised.** When it is, it will carry no acting user: nothing in the organization changed, it is a notification about what is about to. Until then, read `certificate_warning` on the configuration. SCIM writes are audited too — provisioning, deprovisioning and group changes each raise their own event. Those carry **no acting user**, because there is not one: the actor is an identity provider holding a token rather than a person, and an audit log that named somebody would be naming the wrong somebody. A client rendering the log should show those entries as originating from the identity provider. **A SCIM write also moves the organization's ordinary membership events.** Adding somebody raises the usual member-added event, removing them the usual member-removed event, and a role change the usual membership-updated event — so a client that already watches org membership sees directory-driven changes without having to know SCIM exists. Those events carry **no acting user either**, for the same reason as the dedicated ones: the request came from a token, not from a signed-in person, and there is no session behind it to name. Render them as coming from the identity provider rather than drawing an empty name where an actor would be. They fire only when something actually changed — a role that was already high enough, or a membership that was already gone, raises nothing. **The configuration object does not say who changed it.** `updated` tells you when the configuration last changed; the acting user is recorded on the organization's audit events, which outlive the configuration itself. Read the audit log when you need attribution. --- ## Getting it wrong — the mistakes worth designing against 1. **Treating a passing check — structural or test sign-in — as proof that a user can get in.** The structural check reads the configuration. The test sign-in goes further and runs the protocol and the claims, but stops there: it checks neither domain eligibility, nor `scim_only` admission, nor tombstones, nor membership. Run both, then set `mode` to `optional` and have a real non-administrator user sign in before enforcing. 2. **Reading `last_test.ok` when you meant `tested`.** After a credential change, `last_test.ok` can still be `true` — and `tested_at` recent — while `tested` is `false`. `tested` is the one that describes the configuration you are looking at. 3. **Treating `issuer_shared: true` as an error.** It is advisory, and it is legitimately `true` for a company running several organizations on one identity provider. 4. **Assuming a DNS failure un-verified a domain.** It takes three consecutive checks that confirm the record is *absent*; a resolver failure is not one of them. 5. **Flipping to `scim_only` without provisioning the people who are already here.** Existing members who arrived through just-in-time creation are refused `not_provisioned` at their next sign-in, exactly like strangers. Provision them first, or be ready to provision them the moment the tickets arrive. 6. **Reading `last_error` on the SCIM status as a health check.** It records only refusals attributable to the organization's own token. `null` means "nothing was attributed", not "everything is fine" — `last_seen` is the field that says the provider is getting in. 7. **Routing the test sign-in's landing into the sign-in error handler.** `signin/sso?sso-test=1&...` is a third outcome, not a login that failed. A passing test reported to an administrator as a broken one costs an afternoon. > Part of the Fastio API Reference. Overview: https://api.fast.io/current/llms/ # Organization Management Base URL: `https://api.fast.io/current/` All authenticated endpoints require: `Authorization: Bearer {jwt_token}` 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 via `GET /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 via `GET /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. = { 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: ```json { "admin": "allowed", "member": "denied", "overrides": { "9876543210987654321": "allowed" } } ``` - `admin` and `member` are each `"allowed"` or `"denied"`, applying to owners/admins and to ordinary members respectively (an owner reads the `admin` baseline). - `overrides` maps 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 the `member` baseline 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 literal `null`. An omitted field leaves the stored value unchanged. Every write replaces the **whole** value — sending `overrides` as `{}` 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` / `_workspaces` on 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 `llms/shares.txt` and `llms/workspaces.txt`); 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 `llms/auth.txt` for the response shape. **Audit.** `org_updated` carries the three keys inside its existing `policy_changes` map: `policy_changes. = { 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: ```json { "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_mode` is 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_types` is the subset of entity types a credential in that family may hold a grant for against this org — `org`, `workspace`, `share`, `fileshare`. (The retired `workflow` type and the `sign_envelope` type are not selectable here. A `sign_envelope` grant is never refused by `scope_types`, but it is still bounded by `max_mode`.) Account-only authority (`user:*`, `memory:*`, `userdetails:*`) is never refused by `scope_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 by `max_mode`, evaluated **locally** against whichever org a given request actually touches. - `overrides` maps 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 whole `credential_policy` object as a JSON **string** in the `credential_policy` field (see *Update Organization* below). An omitted family is left unchanged; a family set to `null` is cleared back to unconfigured. Enterprise plan only to *tighten* — a write that lowers a `max_mode` or removes a `scope_types` entry, for either family, for anyone (`admin`, `member`, or an override), is refused with HTTP 403 and `params.reason` = `plan_required` on an org without the plan. - **Read:** `GET /current/org/{org_id}/details/` echoes `credential_policy` **raw, admin-only**, plus `capabilities.credential_policy_api_keys` and `capabilities.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 `llms/auth.txt`), and at OAuth consent (see `llms/oauth.txt`), 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_policy` is 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 `llms/sso.txt`. | - A signed-in **browser session** is never subject to `credential_policy` — it carries no `scopes` claim, exactly as it is exempt from the ordinary scope checks in `llms/auth.txt`. - 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 a `401`. - **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:rwa` and `org:B:r` is 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: ```json { "enabled": true, "mode": "read_write" } ``` - `enabled` (`bool`) — whether cloud sync runs **at all** for the caller this value resolves to. `false` stops sync in both directions; a source parks at `status: "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 from the provider **continues** — an admin asking for read-only wants a mirror, not for their files to stop arriving. A `mode`-only flip changes no source status. - **Unconfigured is permissive** — `{enabled: true, mode: "read_write"}` — exactly like every other policy envelope on this page. **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_mode` ceiling — see *Cloud Sync Policy* in `llms/workspaces.txt` for the full resolution order, the per-source `effective_access_mode` fields, and how a queued write-back behaves under a `read` policy (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 the `cloud_sync` field (see *Update Organization* below). Sending `""`/`"null"`/`null` clears the policy back to unconfigured. An omitted field leaves the stored value unchanged. - **Read:** `GET /current/org/{org_id}/details/` echoes `cloud_sync` **raw, admin-only** — the stored setting the shared policy editor round-trips. There is no org-level effective-answer field: the effective answer is the workspace's `effective_cloud_sync` (`llms/workspaces.txt`), 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. **Refusal reasons** — HTTP 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: ```json { "admin": "required", "member": "optional", "overrides": { "9876543210987654321": "required" } } ``` - `admin` and `member` are each `"required"` or `"optional"`, applying to owners/admins and to ordinary members respectively (an owner reads the `admin` baseline). - `overrides` maps 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: `optional` for 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 `llms/auth.txt` 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 the `auth_require_2fa` field (see *Update Organization* below). Sending `""` or `"null"` clears the policy back to unconfigured; the server also accepts a literal `null`. An omitted field leaves the stored value unchanged. Enterprise plan only to *tighten* — a write moving `optional`/unconfigured to `required`, for `admin`, `member`, or an override. - **Read:** `GET /current/org/{org_id}/details/` echoes `auth_require_2fa` **raw, admin-only** — the stored setting the shared policy editor round-trips. **There is no `capabilities` twin.** Every other envelope policy on this page also reports the calling user's own resolved answer under `capabilities`; 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_required` on 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** `required` by 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: ```json {"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 `admin` and `member` (and each entry in `overrides`, 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 (including `XK`), plus the special codes `XX` (unknown location) and `T1` (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:** 1. **Pass** if `ips` is set and the IP falls inside any listed range — this bypasses the country rule entirely. 2. Otherwise **pass** if `countries` is set and the country passes it: in `allow` mode the country (or `XX`/`T1`) must be listed; in `deny` mode any country not listed passes, and an unknown/Tor caller passes unless `XX`/`T1` is explicitly listed. 3. Otherwise **pass** if both `countries` and `ips` are `null` (unrestricted). 4. **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 `llms/events.txt`) 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, Deep Indexing & 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 `llms/shares.txt`), 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, Deep Indexing & 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 Deep Indexing (API field: `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 Deep Indexing/metadata background processing (and interactive Deep Indexing/metadata) applies to; `null` = every workspace, `[]` = none | - Each of `ai_agent` / `ai_intelligence` / `ai_metadata` / `ai_summaries` / `mcp_access` is the same envelope shape as the other policy families above: `{"admin":"allowed"|"denied","member":..., "overrides":{}}`. `ai_summaries` has no per-user override controls in the editor (background-only). - **Tightening** — `allowed` → `denied`, adding a `denied` override, or narrowing `ai_workspaces` — needs the Enterprise plan (403 `plan_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 `denied` for **both** the `admin` and `member` baselines, or when the workspace is off the `ai_workspaces` list. A single-baseline denial or a per-user `denied` override only refuses that caller's interactive use (so for `ai_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 Deep Indexing 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-all` sweep or a queued template extraction stops rather than running — its progress reports `stop_reason: "paused_policy"` (see *Jobs Status (Unified Async Processing)* in `llms/ai.txt`) — 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 with `stop_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_extraction` switch; 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 in `llms.txt` 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 ``` POST /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. | New organizations select a paid plan (Starter, Business, or Enterprise). Agent accounts are ordinary accounts tagged `account_type=agent`; they follow the same paid-plan flow as everyone else. A newly created org must select a paid plan 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) and cannot consume resources. **Monthly plans include a 30-day free trial (up to 30 days, or until the included trial credits are used); annual plans do not** — an annual subscription is billed for the full term at signup. Do not infer a trial from the interval alone: read each plan's own `free_days`, where `0` means payment is due at checkout. 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:** ```bash 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:** ```json { "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: ```json { "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: ```json { "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. Codes shown as five or six digits (and the `9661`-`9669` family) ARE `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." | Storage instance creation failed | | `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 ``` GET /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:** ```bash curl -X GET "https://api.fast.io/current/org/1234567890123456789/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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, Deep Indexing & MCP Access Policy*. | | `org.ai_workspaces` | array/string/null | Raw Deep Indexing/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 — role, override and policy combined. 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, Deep Indexing & MCP Access Policy*. | | `org.capabilities.ai_intelligence` | boolean | Same, for turning a workspace's/share's Deep Indexing 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 | This endpoint does not check the subscription or credit limit, so an org without an active subscription can still read its details. --- ### Get Onboarding Checklist ``` GET /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:** ```bash curl -X GET "https://api.fast.io/current/org/1234567890123456789/onboarding/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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 ``` GET /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:** ```bash curl -X GET "https://api.fast.io/current/org/1234567890123456789/public/details/" ``` **Response (200 OK):** ```json { "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 `llms/sso.txt`. Omitted when it cannot be determined. | --- ### Update Organization ``` POST /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, Deep Indexing & 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 Deep Indexing/metadata/summary processing and interactive Deep Indexing/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:** ```bash 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):** ```json { "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, Deep Indexing & MCP Access Policy* above. | | `1663 (Update Failed)` | 500 | "There was an internal error processing your update request." | Internal error | --- ### Close Organization ``` POST /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:** ```bash curl -X POST "https://api.fast.io/current/org/1234567890123456789/close/" \ -H "Authorization: Bearer {jwt_token}" \ -d "confirm=acme-corp" ``` **Response (202 Accepted):** ```json { "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 ``` GET /current/org/assets/ ``` Auth required. Returns available org asset metadata types (e.g., logo, background images). **curl example:** ```bash curl -X GET "https://api.fast.io/current/org/assets/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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 ``` GET /current/org/{org_id}/assets/ ``` Auth required. Any member with at least View permission. Returns assets currently set on the org. **curl example:** ```bash curl -X GET "https://api.fast.io/current/org/1234567890123456789/assets/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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 ``` POST /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:** ```bash 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 ``` DELETE /current/org/{org_id}/assets/{asset_name}/ ``` Auth required. Admin or above. **curl example:** ```bash curl -X DELETE "https://api.fast.io/current/org/1234567890123456789/assets/logo/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true } ``` --- ### Read Org Asset (Raw) ``` GET /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 ``` POST /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 (for adding an 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 (for 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):** ```bash 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):** ```bash 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:** ```json { "result": true } ``` **Response (200 OK) -- invitation created:** ```json { "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 ``` DELETE /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:** ```bash curl -X DELETE "https://api.fast.io/current/org/1234567890123456789/members/9876543210987654321/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true } ``` --- ### List Org Members ``` GET /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:** ```bash curl -X GET "https://api.fast.io/current/org/1234567890123456789/members/list/?limit=50&offset=0" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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) ``` DELETE /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:** ```bash curl -X DELETE "https://api.fast.io/current/org/1234567890123456789/member/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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 ``` GET /current/org/{org_id}/member/{user_id}/details/ ``` Auth required. Any org member. **curl example:** ```bash curl -X GET "https://api.fast.io/current/org/1234567890123456789/member/9876543210987654321/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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 ``` POST /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:** ```bash curl -X POST "https://api.fast.io/current/org/1234567890123456789/member/9876543210987654321/update/" \ -H "Authorization: Bearer {jwt_token}" \ -d "permissions=admin" ``` **Response (200 OK):** ```json { "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 ``` POST /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:** ```bash curl -X POST "https://api.fast.io/current/org/1234567890123456789/member/9876543210987654321/transfer_ownership/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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 | Message | Cause | |--------|------|---------|-------| | `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." | The transfer did not finish — repeat the call with the same target to complete it | | — | 503 | "Ownership transfer is temporarily unavailable. Please try again shortly." | Temporarily unavailable; 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. #### `POST /current/org/{org_id}/member/{user_id}/transfer_access/preview/` — dry run `{user_id}` is A. **Body:** `to_user_id` (required, B's user id). No writes. **curl example:** ```bash 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):** ```json { "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. #### `POST /current/org/{org_id}/member/{user_id}/transfer_access/` — execute **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. `plan_hash` does **not** cover `offboard` (the preview takes no `offboard` input), so the same `plan_hash` from one preview stays valid whichever way you set `offboard` on execute. **curl example:** ```bash 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** ```json {"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`). #### `GET /current/org/{org_id}/transfers/{transfer_id}/` — status + report **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:** ```bash curl -X GET "https://api.fast.io/current/org/1234567890123456789/transfers/mt7q…/?limit=100&offset=0" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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 ``` POST /current/org/{org_id}/members/join/ ``` Auth required. Join an org via invite or domain-based auto-join. **Join methods:** 1. **Via invitation:** Append the invitation key to the URL path: `.../join/{invitation_key}/` optionally followed by `accept` or `decline`. Default is `accept`. 2. **Via authorized domain:** The org must have `perm_authorized_domains` set, 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. Only `notify_options` is read from input; `permissions` is ignored. **curl example (invitation):** ```bash curl -X POST "https://api.fast.io/current/org/1234567890123456789/members/join/abc123def456/accept/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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 ``` GET /current/org/{org_id}/members/invitations/list/ ``` Auth required. Any org member. An optional state filter can be appended: `.../list/pending/`. **curl example:** ```bash curl -X GET "https://api.fast.io/current/org/1234567890123456789/members/invitations/list/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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 ``` POST /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:** ```bash 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):** ```json { "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 ``` DELETE /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:** ```bash 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):** ```json { "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 (see *Legal Holds* below). 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_only` credentials are always revocable by an eligible actor. - `user_wide` and `mixed` credentials are revocable **only** when the credential owner's email is on one of this org's verified SSO domains — otherwise 409 `credential_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. --- ### GET /current/org/{org_id}/credentials/ List every API key and OAuth grant that reaches this org, across all members. **Auth:** admin+ or auditor (see credential rule above). 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:** ```bash curl -X GET "https://api.fast.io/current/org/1234567890123456789/credentials/?type=oauth&reach=mixed" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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 (only that member's list is capped) | Token secrets and hashes are never returned. Expired API keys and revoked/expired OAuth sessions are not listed. **Errors:** 403 `compliance_access_required` / `scope_admin_required` / `plan_required`, 406 validation. --- ### DELETE /current/org/{org_id}/member/{user_id}/credentials/{type}/{credential_id}/ Revoke one credential belonging to `{user_id}`. **Auth:** an eligible actor (admin whose role is strictly above the target's). Admin-scope credential required. Enterprise. **Path Parameters:** `{user_id}` the credential's owner (a live org member); `{type}` `api_key` \| `oauth`; `{credential_id}`. No body. **curl example:** ```bash curl -X DELETE "https://api.fast.io/current/org/1234567890123456789/member/9876543210987654321/credentials/api_key/k8x2mq4rt7/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json {"result": true, "revoked": {"type": "api_key", "id": "k8x2mq4rt7", "user_id": "9876543210987654321"}} ``` **Errors:** | 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. --- ### POST /current/org/{org_id}/member/{user_id}/sign-out/ Force-sign-out a member: invalidate every session everywhere, and revoke whatever of their API keys and OAuth grants the revoke rule above allows. **Auth:** an eligible actor. Admin-scope credential required. Enterprise. No body — this is the one fixed behaviour, there is no partial mode. **curl example:** ```bash curl -X POST "https://api.fast.io/current/org/1234567890123456789/member/9876543210987654321/sign-out/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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"}] } ``` **Errors:** `member_not_found` (404), `cannot_act_on_member` (403), 429 when throttled (honour `Retry-After`), `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`. **FE/client 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 /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. Each section is independently best-effort — see `partial`/`truncated`. **Auth:** admin+ or auditor. Enterprise. Target must be a live org member (404 `member_not_found`). **curl example:** ```bash curl -X GET "https://api.fast.io/current/org/1234567890123456789/member/9876543210987654321/report/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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 — this 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 the rows, call `GET .../credentials/?user_id=` above | | `report.logins.recent` | array | Newest 20. For the full history, call `events/search` with `event=user_login&calling_user_id=` — see *Compliance & Audit* in `llms/events.txt` | | `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 | **Errors:** 404 `member_not_found`, 403 `compliance_access_required` / `scope_admin_required` / `plan_required`. --- ### GET /current/org/{org_id}/reports/sharing/ Sharing-exposure report: 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). ```json {"result": true, "workspaces": [{"workspace": {"id": "4123456789012345678", "name": "Legal"}}], "pagination": {"has_more": true, "next_cursor": "…", "page_size": 20}} ``` **List mode (no `workspace_id`) response (200 OK), default `counts=true`:** ```json { "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: ```json { "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 | **Errors:** 404 `workspace_not_found` (not in this org), 403 `compliance_access_required` / `scope_admin_required` / `plan_required`, 406 invalid cursor, 500 when a share, member, link or invitation read fails or a person's external status cannot be determined — retryable; no partial page is returned and the cursor does not advance. --- ### GET /current/org/{org_id}/audit/export/ Stream the org's audit log as a file — 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). **Auth:** admin+ or auditor, admin-scope credential. Enterprise. **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 it with the identical other parameters. Valid 7 days. | **curl example:** ```bash 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---."`. **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 with `cursor=` 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_cursor` you have, or from scratch. **Errors (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); the slot is 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 ``` POST /current/org/{org_id}/member/{user_id}/compliance-auditor/ ``` **Auth: the org owner only** — not admins. **Body:** `enabled` (bool, required). **curl example:** ```bash 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):** ```json {"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. **Errors, checked in this order:** `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:** ```json { "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. --- ### POST /current/org/{org_id}/legal-holds/ — place a hold **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:** ```bash 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). --- ### GET /current/org/{org_id}/legal-holds/ — list 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):** ```json { "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). --- ### GET /current/org/{org_id}/legal-holds/{hold_id}/ — detail + impact **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):** ```json { "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). --- ### POST /current/org/{org_id}/legal-holds/{hold_id}/release/ — release a hold **Auth:** the org owner, or an auditor. **Never plan-gated** — a lapsed org's owner can always lift a hold. No body. **curl example:** ```bash curl -X POST "https://api.fast.io/current/org/1234567890123456789/legal-holds/sabdqahjioknnh36puhwmjy5nluifp/release/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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 refuses `409 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 refuses `409 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 `llms/auth.txt`. - 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_id`** on 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 a `audit_stream_paused` security 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:** same credential rule as the rest of *Compliance & Audit* above — a browser session always passes; an API key needs an admin-scope (`rwa`) grant on the org. - `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`), and `DELETE`, which work on any plan. On a non-Enterprise org, `GET` returns the stored config with `state: "paused_plan"` instead of refusing. Creating, enabling, rotating the secret, and testing still need Enterprise. ### `GET /current/org/{org_id}/audit/stream/` **curl example:** ```bash curl -X GET "https://api.fast.io/current/org/1234567890123456789/audit/stream/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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`. ### `POST /current/org/{org_id}/audit/stream/` — create or update **Body:** `url` (https only, public host, no credentials in the URL, ≤2048 chars, required on create); `enabled` (bool). **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:** ```bash 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" ``` **Errors:** 406 `stream_url_rejected`; 409 `stream_exists` (a concurrent create raced you); 503 `stream_secret_unavailable` (nothing was stored — retry later); 403 `plan_required`; 406 field validation (no `url` on create, or nothing to change). **Event:** `org_audit_stream_updated`. ### `DELETE /current/org/{org_id}/audit/stream/` Removes the stream configuration. **Response:** `{"result": true}`. **Errors:** 404 `stream_not_configured`. **Event:** `org_audit_stream_deleted`. ### `POST /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:** `{"result": true, "secret": "whsec_…"}`. **Errors:** same as above. **Event:** `org_audit_stream_updated` with `change: "secret_rotated"`. ### `POST /current/org/{org_id}/audit/stream/test/` Sends a single test delivery to the configured URL right now. **Response (200):** `{"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 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=.")>`. **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. `mass_download` counts a **token-minted** download (zip, mass download, share-link) once, from its own existing download-token event, and counts an **authenticated direct read with no token** separately, aggregated per member per minute — the two are never double-counted for the same download. **Output:** - An `org_security_alert` audit-log event (severity high) — visible the same way as the rest of *Compliance & Audit*, through `events/search`. It carries **no event user** — the subject of the alert (if any) rides only in `subject_user_id`, so a member is never shown an alert about themselves in their own personal activity. It also carries **no `calling_user` and no `actor`**: 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_auditors` is 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 ``` GET /current/orgs/list/ ``` Auth required. Lists orgs where the user is a direct member (`member: true`). Non-admin/non-owner members only see orgs with active subscriptions; admins and owners always see their orgs. Paginated: optional `limit` (1-500, default 100) and `offset` query parameters; the response carries `pagination` (`total`, `limit`, `offset`, `has_more`). **curl example:** ```bash curl -X GET "https://api.fast.io/current/orgs/list/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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 ``` GET /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:** ```bash curl -X GET "https://api.fast.io/current/orgs/list/external/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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 ``` GET /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:** ```bash curl -X GET "https://api.fast.io/current/orgs/all/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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 ``` GET /current/orgs/available/ ``` Auth required. Lists orgs available to join (not yet joined). Excludes orgs the user is already a member of. **curl example:** ```bash curl -X GET "https://api.fast.io/current/orgs/available/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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 ``` GET /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:** ```bash curl -X GET "https://api.fast.io/current/orgs/check/domain/acme-corp" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (202 Accepted) -- domain available:** ```json { "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 ``` GET /current/orgs/industries/ ``` Auth required. Returns available industry types for org profiles. **curl example:** ```bash curl -X GET "https://api.fast.io/current/orgs/industries/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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 ``` GET /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:** ```bash 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):** ```json { "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 ``` POST /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. | | `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:** ```bash 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:** ```json { "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. What happens next depends on the trial: - **Trial applies** — the subscription starts in `trialing` and 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. Watch the subscription status, or `GET .../billing/details/`, to see it reach `active`. 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: ```bash 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" ``` ```json { "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 above. After the browser returns to `success_url`, poll `GET /current/org/{org_id}/billing/details/`: 1. `billing_status.current_plan.name` equals the plan you started → done. 2. `billing_status.payment_recovery` is non-empty → send the customer to `payment_recovery.invoice.hosted_invoice_url` to complete payment. 3. `billing_status.refusal_reason` is non-null → the card was refused and no subscription was created; let the customer start checkout again with a different card. 4. 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: ```json { "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" label — 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 ``` DELETE /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:** ```bash curl -X DELETE "https://api.fast.io/current/org/1234567890123456789/billing/" \ -H "Authorization: Bearer {jwt_token}" ``` Optionally include `reason` to tell us why: ```bash curl -X DELETE "https://api.fast.io/current/org/1234567890123456789/billing/?reason=price" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (202 Accepted):** ```json { "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: ```json { "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_end` and `cancel_at` are also reflected on `GET /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 ``` PUT /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:** ```bash curl -X PUT "https://api.fast.io/current/org/1234567890123456789/billing/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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 `PUT` on a subscription that is not currently scheduled to cancel is a successful no-op. - Once `cancel_at` has passed and the subscription has terminated, the org is no longer a subscriber and `PUT` returns 404. Use `POST /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 ``` GET /current/org/{org_id}/billing/details/ ``` Auth required. Admin or above. Returns subscription/billing details. **curl example:** ```bash curl -X GET "https://api.fast.io/current/org/1234567890123456789/billing/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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 ``` GET /current/org/{org_id}/billing/usage/limits/credits/ ``` Auth required. Admin or above. Returns credit consumption and limits. **curl example:** ```bash curl -X GET "https://api.fast.io/current/org/1234567890123456789/billing/usage/limits/credits/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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 ``` GET /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:** ```bash curl -X GET "https://api.fast.io/current/org/1234567890123456789/billing/usage/members/list/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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 ``` GET /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:** ```bash 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):** ```json { "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 ``` GET /current/org/billing/plan/list/ ``` Auth required. Returns the paid plans available to select when activating or upgrading an organization. The current plans are Starter, Business, and Enterprise, each 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`). Humans and agents see the same paid plans. Only these currently-offered paid plans are returned for selection. **curl example:** ```bash 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. ```json { "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 ``` GET /current/org/{org_id}/billing/invoices/ ``` Auth required. Admin or above. Returns a paginated list of invoices for the organization, including hosted URLs for linking users to their invoices. **Query parameters:** | Name | Type | Required | Default | Description | |------|------|----------|---------|-------------| | `limit` | integer | No | 10 | Number of invoices to return (1-100) | | `starting_after` | string | No | - | Invoice ID cursor for pagination | **curl example:** ```bash curl -X GET "https://api.fast.io/current/org/1234567890123456789/billing/invoices/?limit=10" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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 | Amounts are in the smallest currency unit (cents for USD). Use `hosted_invoice_url` to link users to their invoices. --- ## Create Workspace (from Org) ``` POST /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:** ```bash 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):** ```json { "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 | Deep Indexing 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 ``` GET /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:** ```bash curl -X GET "https://api.fast.io/current/org/1234567890123456789/list/workspaces/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "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 | > Part of the Fastio API Reference. Overview: https://api.fast.io/current/llms/ # Workspace Management Base URL: `https://api.fast.io/current/` All authenticated endpoints require: `Authorization: Bearer {jwt_token}` Profile IDs are 19-digit numeric strings. Most workspace endpoints also accept the workspace's `folder_name` (e.g., `my-project`) in place of the numeric ID. --- ## Endpoint Summary ### Workspace CRUD | Method | Path | Description | |--------|------|-------------| | POST | `/current/org/{org_id}/create/workspace/` | Create a workspace | | GET | `/current/workspace/{workspace_id}/details/` | Get workspace details | | POST | `/current/workspace/{workspace_id}/update/` | Update workspace settings | | DELETE | `/current/workspace/{workspace_id}/delete/` | Delete (close) a workspace | | POST | `/current/workspace/{workspace_id}/archive/` | Archive a workspace | | POST | `/current/workspace/{workspace_id}/unarchive/` | Unarchive a workspace | ### Assets | Method | Path | Description | |--------|------|-------------| | GET | `/current/workspace/assets/` | List available asset types | | GET | `/current/workspace/{workspace_id}/assets/` | List workspace assets | | POST | `/current/workspace/{workspace_id}/assets/{asset_name}/` | Upload/set asset | | DELETE | `/current/workspace/{workspace_id}/assets/{asset_name}/` | Delete asset | | GET | `/current/workspace/{workspace_id}/assets/{asset_name}/read/` | Download asset binary | | HEAD | `/current/workspace/{workspace_id}/assets/{asset_name}/read/` | Get asset metadata headers | ### Members | Method | Path | Description | |--------|------|-------------| | POST | `/current/workspace/{workspace_id}/members/{email_or_user_id}/` | Add member or send invitation | | DELETE | `/current/workspace/{workspace_id}/members/{user_id}/` | Remove a member | | GET | `/current/workspace/{workspace_id}/members/list/` | List all members | | POST | `/current/workspace/{workspace_id}/members/join/` | Self-join by org membership | | POST | `/current/workspace/{workspace_id}/members/join/{invitation_key}/{action}/` | Join via invitation | | DELETE | `/current/workspace/{workspace_id}/member/` | Leave workspace (self) | | GET | `/current/workspace/{workspace_id}/member/{member_id}/details/` | Get member details | | POST | `/current/workspace/{workspace_id}/member/{member_id}/update/` | Update member role | | POST | `/current/workspace/{workspace_id}/member/{member_id}/transfer_ownership/` | Transfer ownership | ### Invitations | Method | Path | Description | |--------|------|-------------| | GET | `/current/workspace/{workspace_id}/members/invitations/list/` | List all invitations | | GET | `/current/workspace/{workspace_id}/members/invitations/list/{state}/` | List invitations by state | | POST | `/current/workspace/{workspace_id}/members/invitation/{invitation_id}/` | Update an invitation | | DELETE | `/current/workspace/{workspace_id}/members/invitation/{invitation_id}/` | Delete an invitation | ### Shares (in workspace context) | Method | Path | Description | |--------|------|-------------| | POST | `/current/workspace/{workspace_id}/create/share/` | Create a share | | GET | `/current/workspace/{workspace_id}/list/shares/` | List shares | | POST | `/current/workspace/{workspace_id}/import/share/{share_id}/` | Import a user-owned share | ### File Shares (durable single-file links) | Method | Path | Description | |--------|------|-------------| | POST | `/current/workspace/{workspace_id}/create/fileshare/` | Create a File Share bound to a file node | | GET | `/current/workspace/{workspace_id}/list/fileshares/` | List the workspace's File Shares | | POST / PATCH | `/current/fileshare/{fileshare_id}/update/` | Update title / access tier / password | | DELETE | `/current/fileshare/{fileshare_id}/delete/` | Delete a File Share | | GET / POST / DELETE | `/current/fileshare/{fileshare_id}/grants/` | List, grant, or revoke per-user capabilities | ### Cloud Sync | Method | Path | Description | |--------|------|-------------| | POST | `/current/workspace/{workspace_id}/cloud-import/enable/` | Enable cloud sync | | POST | `/current/workspace/{workspace_id}/cloud-import/disable/` | Disable cloud sync | | GET | `/current/cloudsync/workspace/{workspace_id}/providers/` | List the providers this workspace's plan may connect | | GET | `/current/cloudsync/workspace/{workspace_id}/identities/` | List provider identities (your own in this organization, plus those this workspace's sources sync through) | | POST | `/current/cloudsync/workspace/{workspace_id}/identities/provision/` | Provision a provider identity | | POST | `/current/cloudsync/oauth/{provider}/complete/` | Finish a browser OAuth connect (all four providers) | | GET | `/current/cloudsync/workspace/{workspace_id}/identities/{identity_id}/` | Get identity details | | POST | `/current/cloudsync/workspace/{workspace_id}/identities/{identity_id}/revoke/` | Revoke a provider identity (owner only; reaches every workspace of the organization) | | GET | `/current/cloudsync/workspace/{workspace_id}/identities/{identity_id}/drives/` | List reachable drives (OneDrive for Business) | | POST | `/current/cloudsync/workspace/{workspace_id}/identities/{identity_id}/drives/refresh/` | Refresh the drive catalog | | GET | `/current/cloudsync/workspace/{workspace_id}/sources/` | List the workspace's sync sources (optional `?owner=me` or `?owner={user_id}`) | | POST | `/current/cloudsync/workspace/{workspace_id}/sources/create/` | Create a sync source from an identity you own | | POST | `/current/cloudsync/workspace/{workspace_id}/sources/discover/` | Browse an identity's cloud folders to pick what to sync | | POST | `/current/cloudsync/workspace/{workspace_id}/sources/estimate/` | Count the files and bytes in up to 10 folders before connecting them | | GET | `/current/cloudsync/details/{source_id}/` | Get source details | | POST | `/current/cloudsync/details/{source_id}/update/` | Update a source's settings | | POST | `/current/cloudsync/details/{source_id}/refresh/` | Trigger an immediate incremental sync | | POST | `/current/cloudsync/details/{source_id}/disconnect/` | Stop syncing a source, keeping or trashing the files it imported | | POST | `/current/cloudsync/details/{source_id}/delete/` | Delete a source and the files it imported | | GET | `/current/cloudsync/details/{source_id}/jobs/` | List a source's sync jobs | | GET | `/current/cloudsync/details/{source_id}/jobs/{job_id}/` | Get one sync job's details and progress | | POST | `/current/cloudsync/details/{source_id}/jobs/{job_id}/cancel/` | Cancel a **pending** sync job | | GET | `/current/cloudsync/details/{source_id}/writebacks/` | List write-back jobs | | POST | `/current/cloudsync/details/{source_id}/writebacks/push/{node_id}/` | Push one imported file back to the provider | | GET | `/current/cloudsync/details/{source_id}/writebacks/{writeback_id}/` | Get write-back job details | | POST | `/current/cloudsync/details/{source_id}/writebacks/{writeback_id}/retry/` | Retry a failed write-back | | POST | `/current/cloudsync/details/{source_id}/writebacks/{writeback_id}/resolve/` | Resolve a write-back conflict | | POST | `/current/cloudsync/details/{source_id}/writebacks/{writeback_id}/cancel/` | Cancel a pending or conflicting write-back | **Every `/current/cloudsync/details/{source_id}/...` endpoint above enforces a scoped credential's workspace scope, the same way the `/current/cloudsync/workspace/{workspace_id}/...` endpoints already do.** The source id resolves to a workspace, and a credential whose scope does not cover that workspace is refused with `10560 (Access Denied)` → **403**, `"Your token does not have sufficient scope for this Workspace."` — read endpoints (`details`, `jobs`, `jobs/{job_id}`, `writebacks`, `writebacks/{id}`) need read scope on the workspace, mutations (`update`, `refresh`, `delete`, `disconnect`, `jobs/{job_id}/cancel`, and every `writebacks/...` write action) need write scope. **Several of those mutations are also gated on who is acting, not only on scope.** `jobs/{job_id}/cancel` always requires an **admin-capable** credential. `refresh`, `update`, `delete`, `disconnect`, `writebacks/push/{node_id}` and `writebacks/{writeback_id}/cancel|resolve|retry` require one only when the caller is acting as a workspace admin rather than as the member who connected the source — that connector/identity owner still only needs the write scope above. **Admin-capable** means a sign-in session, or a token holding `rwa` on the workspace or its org (see `scope_admin_required` under *Transfer Workspace Ownership* above). A legacy **unscoped** API key passes the write-scope check above but is never admin-capable, so it cannot take these actions as an admin. ### Discovery | Method | Path | Description | |--------|------|-------------| | GET | `/current/workspaces/all/` | List all accessible workspaces (optional `limit`/`offset` pagination) | | GET | `/current/workspaces/available/` | List joinable workspaces | | GET | `/current/workspaces/check/name/{org_id}/{name}/` | Check folder name availability | | GET | `/current/org/{org_id}/list/workspaces/` | List workspaces in an org | --- ## Compact Responses (`output=`) Every endpoint that returns one or more workspace 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 workspace (cumulative) | |-------|------------------------------------------------| | `terse` | `id`, `name`, `folder_name`, `org_domain`, `user_status` (`user_status` is returned by `GET /current/workspaces/all/` only) | | `standard` | terse + `description`, `workspace_level`, `closed`, `archived`, `locked` (admin-only), `storage` (admin-only), `created`, `updated`, `logo`, `accent_color`, `intelligence`, `metadata_extraction`, `capabilities` (including the three `can_create_*` sharing answers) | | `full` | standard + `cloud_import`, `cloud_sync_mode`, `effective_cloud_sync`, `external_invites`, `comments`, `chat`, `search`, `assets`, `perm_join`, `perm_member_manage`, `sharing_shares`, `sharing_file_links`, `platform` (admin-only), `suspended` (admin-only), `owner_defined`, `parents` | Use `terse` for workspace switchers, autocomplete, and org-scoped navigation — it carries the identifier, display name, folder slug, parent org domain, and `user_status` so the two-column "Joined vs. Available" workspace list can render and the join button can enable/disable without a follow-up fetch. Use `standard` for workspace list views and the summary area of workspace detail pages — it adds lifecycle flags (including the `locked` admin-only chip), storage usage, timestamps, description, the workspace's visual identity (`logo`, `accent_color`), plus the `intelligence` and `metadata_extraction` feature toggles and the plan-gate `capabilities` bundle so list rows can differentiate "enabled but plan-locked" from "usable." Use `full` (or omit the parameter) for the workspace settings screen, branding editors, and any workflow that reads permission matrices or remaining feature blocks (`comments`, `chat`, `search`, `assets`). 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 in `llms.txt` for the full contract. --- ## Workspace Field Constraints | Field | Constraint | |-------|-----------| | `folder_name` | 4-80 characters matching regex `^[\p{L}\p{N}-]+$` (letters, numbers, hyphens). Must be globally unique. | | `name` | 2-100 characters, string | | `description` | 10-1000 characters, string (optional) | | `title` (shares) | 2-80 characters | | `custom_name` (shares) | 4-80 characters, URL-friendly | --- ## Permission Values **`perm_join`** -- who can self-join from the parent org: | Value | Description | |-------|-------------| | `'Member or above'` | Any org member can join (default) | | `'Admin or above'` | Only org admins and owners | | `'Only Org Owners'` | Only org owners | | `'No one can join automatically'` | Nobody self-joins; direct members only | **`perm_member_manage`** -- who can manage workspace members: | Value | Description | |-------|-------------| | `'Member or above'` | Any workspace member can manage (default) | | `'Admin or above'` | Only workspace admins and owners | **Permission Levels (numeric hierarchy):** | Level | Numeric | Description | |-------|---------|-------------| | Owner | 1000 | Full control; one per workspace | | Admin | 500 | Administrative access | | Member | 100 | Standard member | | Guest | 50 | Limited guest access | | View | 20 | Read-only access | --- ## Deep Indexing Setting Deep Indexing (API field: `intelligence`) is a boolean on a workspace that controls whether uploaded files are automatically indexed for RAG (retrieval-augmented generation). - **Enable** (`intelligence=true`) -- files are auto-indexed for semantic search, summarization, and citation. Required for `chat_with_files` AI chat type. **Requires both the `content_ai` and `ai_agent` plan features.** Plans that lack either feature cannot set `intelligence=true`; the update endpoint rejects the request with `1605 (Invalid Input)`. - **Disable** (`intelligence=false`) -- files are stored/shared without RAG indexing. You can still attach files directly to a `chat` type conversation for one-off analysis on plans that support chat. - **On by default at creation.** `POST /current/org/{org_id}/create/workspace/` enables Deep Indexing unless you send `intelligence=false`; omitting the field means on, and is not an error. On a plan that lacks either `content_ai` or `ai_agent` the new workspace is created with Deep Indexing off whatever you send, because the indexing pipeline has no consumer there — creation still succeeds rather than being rejected. - **Change it later:** `POST /current/workspace/{id}/update/` with `intelligence=true|false`. - **Can be enabled and disabled within time restrictions.** Disabling Deep Indexing destroys indexed embeddings (the vector index is flushed). Re-enabling Deep Indexing incurs re-indexing costs as AI credits are consumed to re-index all files. - **An Enterprise org can additionally gate this with an AI policy.** Turning `intelligence=true` ON can be refused (`ai_policy_denied` / `ai_policy_workspace_not_allowed`) independently of the plan check above, and even with the switch already on, background indexing and interactive use can be paused by the org. Read `capabilities.can_use_intelligence` and `capabilities.ai_policy_state` on workspace details for the effective, policy-aware answer rather than inferring it from the raw `intelligence` switch — see *AI, Deep Indexing & MCP Access Policy* in `llms/orgs.txt`. --- ## Automatic Metadata Extraction Setting The `metadata_extraction` boolean on a workspace controls whether **automatic** metadata extraction runs for newly uploaded files. It is an opt-out layered under the Deep Indexing setting and the plan: automatic extraction runs only when `intelligence` is on, the plan includes the `metadata` feature (`capabilities.can_use_metadata`), **and** `metadata_extraction` is not `false`. It can withhold extraction; it can never enable it where the Deep Indexing setting or the plan does not allow it. - **On by default.** Every workspace reports `metadata_extraction: true` unless it was explicitly switched off — including workspaces created before the setting existed. `POST /current/org/{org_id}/create/workspace/` accepts `metadata_extraction=false` to create it off; omitting the field means on. - **Change it later:** `POST /current/workspace/{id}/update/` with `metadata_extraction=true|false`. No plan requirement: setting it on a plan without the `metadata` feature is accepted and has no effect. Unlike `intelligence` it is not rate-limited. - **Turning it off** stops automatic extraction for files that reach processing from then on. The setting is read when a file is processed after upload, not at the moment the upload is accepted, so a file already being processed may still be extracted and an extraction already queued is not cancelled. Nothing is deleted: existing metadata values, fields, views and filters stay readable and editable, metadata search keeps working, and explicit per-file or per-folder extraction requests (`POST .../storage/{node_id}/metadata/extract/`, `.../extract-all/`) still run — the switch governs only what happens on its own at upload time. - **Turning it back on** does not extract files uploaded while it was off; only new uploads are picked up. Use the explicit extraction endpoints to catch existing files up. - Read it back as the boolean `metadata_extraction` on the workspace object at the `standard` output level and above (same level as `intelligence`). - **An Enterprise org can additionally gate this with an AI policy.** Turning `metadata_extraction=true` ON can be refused the same way as `intelligence=true` above, and the explicit per-file/per-folder extraction endpoints and automatic on-ingest extraction alike can be paused org-wide or narrowed to specific workspaces. Read `capabilities.can_use_metadata` and `capabilities.ai_policy_state.metadata` on workspace details for the effective answer — see *AI, Deep Indexing & MCP Access Policy* in `llms/orgs.txt`. --- ## Sharing Policy Two booleans on a workspace control whether new sharing may be created in it: | Setting | Governs | |---------|---------| | `sharing_shares` | Shares — Send, Receive and Exchange, including shared folders created from the workspace. | | `sharing_file_links` | Single-file share links. | The organization carries a twin of each. **The effective answer is the org setting AND the workspace setting**, so the org is a ceiling: a workspace may switch sharing off for itself, but cannot switch it back on once the org has switched it off. Switching either level off requires the Enterprise plan; switching one back on, resubmitting it unchanged, and reading them do not. - **On by default.** A workspace that has never configured these reports both as `true`, including every workspace created before the settings existed. - **Change them:** `POST /current/workspace/{workspace_id}/update/` with `sharing_shares` and/or `sharing_file_links`. Workspace Admin or above. Send them form-encoded or as query parameters, with the string `"true"` or `"false"` — a JSON request body is not read by this endpoint. - **Switching one off blocks NEW creation only.** Shares and links that already exist keep working and remain editable and deletable. Nothing is revoked, expired or deleted. - **Read the effective answer, not the settings.** `capabilities.can_create_share`, `capabilities.can_create_folder_share` and `capabilities.can_create_fileshare` on the workspace already combine the calling user's role with both policy levels. The raw `sharing_*` booleans report this workspace's own setting only, which is not the whole answer when the org has switched sharing off. - **The org half of the rule is readable on the workspace.** `capabilities.org_sharing_shares` and `capabilities.org_sharing_file_links` report the parent organization's ceiling for each switch, and `capabilities.org_controls` reports whether that organization's plan allows configuring these controls at all; all three are returned to workspace members at the `full` output level, so a settings screen can distinguish "switched off here" from "switched off for the whole organization" without a second request. - **Refusals** from the create endpoints are HTTP 403 with `params.reason` = `policy_sharing_disabled`. A refusal never leaves a partially created share behind — for a shared folder in particular, the folder is not created. - **Audit.** A write that changes either switch adds `policy_changes. = { before, after }` to the `workspace_updated` event (`before` is the value in force, so a switch never configured reports `true`). `policy_changes` is absent when neither switch changed. --- ## Collaboration Policy (External Invites) A separate org-level setting, `external_invites_workspaces`, governs whether a member may bring an outside person onto a workspace — distinct from the sharing policy above, which governs creating shares at all. See *Collaboration Policies* in `llms/orgs.txt` for the full envelope shape, the override rules, and the org-level request/response fields. - **This workspace's own switch:** `external_invites` (`allowed` / `denied`, or absent to inherit the org policy) — a raw setting, readable by any member at the `full` output level and writable only by a workspace admin, and gated by nothing itself. It can only ever **tighten** below the org's result, never loosen it, and there is currently no way to reset it back to "inherit" once set. - **Read the effective answer:** `capabilities.can_invite_external` on the workspace's details response combines the org policy, the calling user's role/override, and this workspace's own switch for the **calling user**. Do not recompute it client-side from `external_invites`. - **Refusals** are HTTP 403 with `params.reason` = `external_invites_denied` (the org policy) or `external_invites_object_denied` (this workspace's own switch), raised at member invitation (fresh, resend, and any broadening edit — a permission reduction is never gated) and at invitation acceptance, re-checked against the live policy at redemption time. A redemption refusal leaves the invitation **pending**, never failed; decline is never gated. - Inviting a member to the **org itself** is a different action and is never gated by this policy. --- ## Cloud Sync Policy Distinct from the `cloud_import` on/off switch (which the *Cloud Sync* endpoints below still use to gate the feature entirely), an Enterprise-plan org can also restrict the **direction** cloud sync is allowed to run in, and a workspace can tighten that further for itself. **Two controls, not one, because "on" and "read-only" answer different questions:** | Setting | Level | Shape | Governs | |---------|-------|-------|---------| | `cloud_sync` | Org | A **policy envelope** — `{"admin": V, "member": V, "overrides": {"": V}}` where each `V` is `{"enabled": bool, "mode": "read"\|"read_write"}` | Whether sync runs at all, and whether it may write back, per role and per named member | | `cloud_sync_mode` | Workspace | `"read"` \| `"read_write"` | The per-workspace **direction ceiling** under the org value — never a grant, and **not** an on/off switch (that is `cloud_import`) | - `enabled = false` stops sync in **both** directions: every import source in the workspace parks at `status: "suspended_policy"` and nothing syncs in or out. `mode = "read"` stops only the **outbound** half — inbound sync from the provider **continues** — an admin asking for read-only is asking for a mirror, not for their files to stop arriving. A source paused this way keeps its live status; only `enabled = false` changes it. - **Effective access is always the org-then-workspace meet.** The org value is resolved first (its own role baseline / member override), then met with the workspace's `cloud_sync_mode` — a workspace can only ever *tighten* what the org allows, and a member override that would widen access still loses to a workspace stored at `read`. There is no flat three-way minimum; the two levels are resolved in that order. - **`cloud_sync_mode` is absent on every workspace created before this setting existed, and absent reads as `read_write`** — the permissive default, not `read`. Never treat a missing field as read-only. - **The workspace value is a preference, not an access grant.** `POST workspace/{workspace_id}/update/` accepts either value from an authorised workspace admin, including widening `read` back to `read_write` — there is no write-time refusal keyed on the editing admin's own ceiling, because the runtime meet above already prevents a stored `read_write` from beating an org `read`. - **Read the effective answer, not the raw fields.** `GET workspace/{workspace_id}/details/` (`full` output tier only) returns both the raw `cloud_sync_mode` and the resolved `effective_cloud_sync: {enabled, mode, reason}` for the calling user — the second is composed server-side from the org value, this workspace's value, and the caller's own org membership/override, and is what a client should render. `reason` is `null` when nothing is restricted, otherwise `cloud_sync_disabled` or `cloud_sync_read_only` (see refusal reasons below). **`null` is never permissive** — it means the answer could not be determined and must be rendered as unknown. - **Per-source effect.** Every cloud-sync source object (list and details) additionally carries `effective_access_mode` and `effective_access_mode_reason` beside its own `access_mode` / `enforced_access_mode` — see *Cloud Sync* below for the full field. - **A stopped large deletion.** The **list** response (`GET .../sources/`) additionally carries `pending_removal`, a structured field describing a sync that stopped rather than remove an unusually large share of a connected folder — see *Confirming a Large Deletion* below. It is not present on `GET .../details/{source_id}/`. - **Write-back queued before a flip to `read` is deferred, not failed** — no `write_back_failed` event, no terminal status, and it resumes automatically if the policy widens again. The hold is bounded: a row still held at the ~5-day ceiling retires with a terminal `failed` status and `properties.terminal_reason: "policy_hold_expired"`; the local edit itself is never touched, only the queued push. Do not render a deferred write-back as an error. - **Manual write-back actions refuse immediately instead of deferring.** `push-writeback`, `retry-writeback` and a `keep_local` `resolve-conflict` — the three entry points where a human is waiting on the answer — refuse before any mutation with HTTP 403 and `params.reason = cloud_sync_read_only` (or `cloud_sync_disabled`) rather than accepting the request and quietly queuing a row that will only ever be held. Cancellation is never gated. - **Opening a NEW sync connection is refused outright when `enabled = false`** — identity provision (`identities/provision/`), source create and each provider's OAuth-complete endpoint answer 403 with `params.reason = cloud_sync_disabled`. On OAuth-complete the refusal is raised before the authorization code is redeemed. `mode` never gates a connection: a `read` org still connects and still syncs inbound. Inspecting or disconnecting an existing connection is never blocked by this policy, whatever `enabled` is set to. - **How a policy refusal reads.** A refusal is `1700 (Forbidden)` → **403** carrying `params.reason` (`cloud_sync_disabled` or `cloud_sync_read_only`) — settled until an admin changes the policy, so do not retry it. When the policy could not be **read** at all, the same endpoints answer `1693 (Temporarily Unavailable)` → **503** "Cloud sync policy is temporarily unavailable. Please try again shortly." — transient; send the same request again shortly and never render it as a denial. Branch on the HTTP status and `params.reason`, never on the numeric `error.code`. - **A policy-parked source can still be paused.** `POST cloudsync/details/{source_id}/update/` with `action: pause` is accepted on a `suspended_policy` source (as well as `synced` / `error`), so a user can keep it held when the policy re-enables sync; it then stays `paused` until they resume it. A source that is already `paused` is never moved to `suspended_policy`. - **A source paused this way is `status: "suspended_policy"`** — a new value in the existing source status enum, alongside `suspended_plan` (see *Cloud Sync* below). It resumes automatically once the org (or workspace) policy widens again; there is nothing to reconnect. > **Both halves are wired.** The org-level write (`cloud_sync` on `POST org/{org_id}/update/`) and its > raw, admin-only echo on `GET org/{org_id}/details/` use the same shared policy spine as > *Collaboration Policies* and *Credential Policy* — see *Cloud Sync Policy* in `llms/orgs.txt` for the > org-side shape. --- ## Workspace CRUD ### Create Workspace ``` POST /current/org/{org_id}/create/workspace/ ``` Creates a new workspace within an organization. The authenticated user becomes the workspace owner. **Auth:** JWT required. Org membership (Member or above) required. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{org_id}` | string | Yes | 19-digit numeric organization ID | **Request Parameters:** | Name | Type | Required | Constraints | Description | |------|------|----------|-------------|-------------| | `folder_name` | string | Yes | 4-80 chars, regex `^[\p{L}\p{N}-]+$`, globally unique | URL-safe identifier used in workspace URLs | | `name` | string | Yes | 2-100 chars, non-blank | Display name | | `perm_join` | string | Yes | See Permission Values | Who can self-join from the org | | `perm_member_manage` | string | Yes | See Permission Values | Who can manage workspace members | | `intelligence` | string | No | `"true"` or `"false"` | Enable AI indexing. **Defaults to `"true"` when omitted.** On a plan without both `content_ai` and `ai_agent` the workspace is created with it off regardless of what you send — the create still succeeds. | | `metadata_extraction` | string | No | `"true"` or `"false"` | Automatic metadata extraction for new uploads. **Defaults to `"true"` when omitted.** Effective only while `intelligence` is on and the plan includes `metadata`; see *Automatic Metadata Extraction Setting*. | | `description` | string | No | 10-1000 chars | Workspace description | | `accent_color` | string (JSON) | No | JSON color object `{"color":"#RRGGBB","opacity":0-100}` | Accent color styling | | `background_color1` | string (JSON) | No | JSON color object `{"color":"#RRGGBB","opacity":0-100}` | Background color 1 styling | | `background_color2` | string (JSON) | No | JSON color object `{"color":"#RRGGBB","opacity":0-100}` | Background color 2 styling | **curl Example:** ```bash curl -X POST "https://api.fast.io/current/org/1000000000000000001/create/workspace/" \ -H "Authorization: Bearer {jwt_token}" \ -d "folder_name=engineering" \ -d "name=Engineering Team" \ -d "description=Main engineering workspace for the team" \ -d "perm_join=Member or above" \ -d "perm_member_manage=Admin or above" ``` **Response (200 OK):** ```json { "result": true, "workspace": { "id": "1234567890123456789", "folder_name": "engineering", "intelligence": true } } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `workspace.id` | string | 19-digit numeric workspace profile ID | | `workspace.folder_name` | string | The URL-safe folder name that was set | | `workspace.intelligence` | boolean | Deep Indexing as actually applied: `false` when you sent `intelligence=false`, or when the plan lacks `content_ai` or `ai_agent` (forced off whatever you sent) | **Access Levels:** | Role | Access | |------|--------| | Org Owner | Can create workspaces | | Org Admin | Can create workspaces | | Org Member | Can create workspaces | **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. Codes shown as five or six digits (and the `9661`-`9669` family) ARE `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 | |------------|-------------|---------|-------| | `1685 (Feature Limit)` | 412 | "Workspace creation is not available on your current plan." | Feature disabled on billing plan | | `1685 (Feature Limit)` | 412 | "You have reached your workspace creation limit." | Workspace count limit exceeded | | `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`. | | `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` format | | `1605 (Invalid Input)` | 406 | "An invalid configuration was supplied..." | Metadata validation failure | | `1654 (Internal Error)` | 500 | "There was an internal error processing your create request." | Internal failure | --- ### Get Workspace Details ``` GET /current/workspace/{workspace_id}/details/ ``` Returns full workspace details including settings, permissions, owner, Deep Indexing state, and branding. **Auth:** JWT required. Workspace membership required (View or above). **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` | string | Yes | 19-digit numeric workspace ID or `folder_name` | **curl Example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "workspace": { "id": "1234567890123456789", "name": "Engineering Team", "folder_name": "engineering", "description": "Main engineering workspace", "accent_color": {"color": "#0066CC", "opacity": 100}, "logo": "https://assets.fast.io/1234567890123456789/logo.png", "closed": false, "archived": false, "perm_join": "Member or above", "perm_member_manage": "Admin or above", "created": "2023-01-15 10:30:00 UTC", "updated": "2024-01-20 14:45:00 UTC", "org_domain": "acme-corp" } } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `workspace.id` | string | 19-digit workspace profile ID | | `workspace.name` | string | Display name | | `workspace.folder_name` | string | URL-safe folder identifier | | `workspace.description` | string or null | Workspace description | | `workspace.accent_color` | object or null | Brand accent color (`{color, opacity}`) | | `workspace.logo` | string or null | Logo asset URL | | `workspace.closed` | boolean | Whether workspace is closed (soft-deleted) | | `workspace.archived` | boolean | Whether workspace is archived | | `workspace.perm_join` | string | Who can join | | `workspace.perm_member_manage` | string | Who can manage members | | `workspace.created` | string | Creation timestamp | | `workspace.updated` | string | Last update timestamp | | `workspace.org_domain` | string | Parent organization domain | | `workspace.sharing_shares` | boolean | Whether this workspace allows new shares. `true` on workspaces that have never configured it. The org carries a twin of this setting and is a ceiling — see *Sharing Policy* below. | | `workspace.sharing_file_links` | boolean | Whether this workspace allows new single-file share links. `true` on workspaces that have never configured it. | | `workspace.capabilities.can_create_share` | boolean | Whether the **calling user** may create a share here right now — role and both policy levels combined. | | `workspace.capabilities.can_create_folder_share` | boolean | Whether the calling user may share a folder from this workspace. Always the same answer as `can_create_share`; a shared folder is a share. | | `workspace.capabilities.can_create_fileshare` | boolean | Whether the calling user may create a single-file share link here right now. | | `workspace.external_invites` | string or null | This workspace's own external-invite switch: `allowed`, `denied`, or `null` to inherit the org's collaboration policy. Not the effective answer — see `capabilities.can_invite_external` below and *Collaboration Policy* above. | | `workspace.capabilities.can_invite_external` | boolean | Whether the calling user may currently bring an outside person onto this workspace — org policy, role/override and this workspace's own switch combined. | | `workspace.capabilities.can_use_intelligence` / `can_use_metadata` | boolean | Existing fields, now also AND-ed with the org's `ai_intelligence` / `ai_metadata` policy (including the `ai_workspaces` allowlist) — see *Deep Indexing Setting* below and *AI, Deep Indexing & MCP Access Policy* in `llms/orgs.txt`. | | `workspace.capabilities.can_use_ai_agent` | boolean | Whether the calling user may currently use Ripley Agent chat in this workspace — plan and the org's `ai_agent` policy combined. | | `workspace.capabilities.ai_policy_state` | object | Per-feature detail behind the three booleans above: `{"agent": "allowed"\|"paused_by_org", "intelligence": "allowed"\|"paused_by_org"\|"workspace_not_allowed", "metadata": "allowed"\|"paused_by_org"\|"workspace_not_allowed"}` (`agent` is never `workspace_not_allowed`). Show "Paused by your organization" for a `paused_by_org` feature rather than rendering its switch as simply off. Present on **details** responses only — org workspace lists and dashboard lists do not carry it; treat an absent field as `allowed` there and let the server's own refusal be the backstop. | | `workspace.capabilities.org_controls` | boolean | Whether the parent organization's plan allows configuring the organization and workspace security controls. Members only, `full` output level. Reading the controls never requires it. | | `workspace.capabilities.org_sharing_shares` | boolean | The parent organization's ceiling for new shares. `false` means no workspace under it may create shares, whatever `sharing_shares` says here. Members only, `full` output level. | | `workspace.capabilities.org_sharing_file_links` | boolean | The parent organization's ceiling for new single-file share links. Members only, `full` output level. | **Access Levels:** | Role | Fields Returned | |------|-----------------| | Owner | All fields, including the admin-only `locked`, `storage`, `platform` and `suspended` | | Admin | All fields, including the admin-only `locked`, `storage`, `platform` and `suspended` | | Member | All fields except the admin-only ones | | View | All fields except the admin-only ones | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1650 (Authentication Invalid)` | 401 | "Authentication required" | Missing or invalid JWT | | `1609 (Not Found)` | 404 | "Workspace not found" | Invalid ID or no access | --- ### Update Workspace ``` POST /current/workspace/{workspace_id}/update/ ``` Updates workspace configuration. All fields are optional; only provided fields are updated. **Auth:** JWT required. Admin or Owner required. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` | string | Yes | 19-digit numeric workspace ID | **Request Parameters (all optional):** | Name | Type | Constraints | Description | |------|------|-------------|-------------| | `folder_name` | string | 4-80 chars, regex `^[\p{L}\p{N}-]+$`, unique | URL-safe identifier | | `name` | string | 2-100 chars. Cannot be cleared. | Display name | | `description` | string | 10-1000 chars. Send `"null"` or `""` to clear. | Description | | `perm_join` | string | See Permission Values | Who can self-join | | `perm_member_manage` | string | See Permission Values | Who can manage members | | `intelligence` | string | `"true"` or `"false"`. Can be toggled. Setting to `"true"` requires both `content_ai` and `ai_agent` plan features. Disabling flushes embeddings; re-enabling re-indexes (costs AI credits). | AI indexing toggle | | `metadata_extraction` | string | `"true"` or `"false"`. Switches automatic metadata extraction for new uploads on or off. Deletes nothing, not rate-limited, no plan requirement (has no effect unless `intelligence` is on and the plan includes `metadata`). Explicit extraction requests are unaffected. | Automatic extraction toggle | | `sharing_shares` | string | 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. | Whether this workspace allows new shares. See *Sharing Policy*. | | `sharing_file_links` | string | 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. | Whether this workspace allows new single-file share links. | | `external_invites` | string | `"allowed"` or `"denied"`. Ungated (workspace admin only) — see *Collaboration Policy*. | This workspace's own external-invite switch. | | `cloud_sync_mode` | string | `"read"` or `"read_write"`; any other value is rejected. No plan requirement, and either value is accepted from a workspace admin (including widening `read` back to `read_write`). | This workspace's cloud-sync direction ceiling under the org `cloud_sync` policy. See *Cloud Sync Policy*. | | `accent_color` | string (JSON) | JSON color object `{"color":"#RRGGBB","opacity":0-100}`. Send `"null"` to clear. | Accent color | | `background_color1` | string (JSON) | JSON color object `{"color":"#RRGGBB","opacity":0-100}`. Send `"null"` to clear. | Background color 1 | | `background_color2` | string (JSON) | JSON color object `{"color":"#RRGGBB","opacity":0-100}`. Send `"null"` to clear. | Background color 2 | | `owner_defined` | string (JSON) | Valid JSON. Send `"null"` or `""` to clear. | Custom owner-defined properties | **curl Example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/update/" \ -H "Authorization: Bearer {jwt_token}" \ -d "name=Updated Workspace Name" \ -d "description=New description for the workspace" \ -d "perm_join=Admin or above" ``` **Response (200 OK):** ```json { "result": true } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1680 (Access Denied)` | 401 | "Appropriate access is not granted to this Workspace." | User is not admin or owner | | `1605 (Invalid Input)` | 406 | "An invalid workspace folder name was supplied." | Invalid `folder_name` | | `1658 (Not Acceptable)` | 406 | "The supplied workspace folder name is already in use." | Duplicate `folder_name` | | `1685 (Feature Limit)` | 412 | "The Deep Indexing setting can only be changed twice per minute and five times per hour. Please wait and try again." | Deep Indexing toggle rate-limited by the per-window throttle | | `278337` | 406 | "Deep Indexing requires a plan upgrade. This feature is not available on your current subscription plan." | Plan missing `content_ai` or `ai_agent` (cannot set `intelligence=true`). Update endpoint only — the create endpoint never rejects on this. | | `1700 (Forbidden)` | 403 | "This configuration requires an Enterprise plan." | `sharing_shares` or `sharing_file_links` was switched OFF by an org without the entitlement. Resubmitting one unchanged, or switching one back on, is not refused. `params.reason` = `plan_required`. | | `1605 (Invalid Input)` | 406 | "The external invite setting must be \"allowed\" or \"denied\"." | Invalid `external_invites` value | | `1605 (Invalid Input)` | 406 | "An invalid configuration was supplied..." | Metadata validation failure | | *(generated per call site)* | 403 | "Your organization's AI policy does not allow enabling this here." | Turning `intelligence=true` or `metadata_extraction=true` **ON** while the org's AI policy denies the caller that feature for this workspace. `params.reason` = `ai_policy_denied` (`params.feature:"intelligence"` or `"metadata"`) or `ai_policy_workspace_not_allowed` (allowlist arm, adds `params.workspace_id`). Turning either **off** is never refused by this policy. See *AI, Deep Indexing & MCP Access Policy* in `llms/orgs.txt`. | | `1663 (Update Failed)` | 500 | "There was an internal error processing your update request." | Internal failure | **Notes:** - If no fields have changed, returns `200 OK` without making changes. - JSON fields are decoded server-side; send valid JSON strings. --- ### Delete Workspace ``` DELETE /current/workspace/{workspace_id}/delete/?confirm={folder_name_or_id} ``` Permanently close (soft-delete) a workspace. Enters a retention period before final purge. **Auth:** JWT required. Owner only. 2FA required. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` | string | Yes | 19-digit numeric workspace ID | **Query Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `confirm` | string | Yes | Must match the workspace's `folder_name` (case-insensitive) or numeric `id`. Safety confirmation. | **curl Example:** ```bash curl -X DELETE "https://api.fast.io/current/workspace/1234567890123456789/delete/?confirm=engineering" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (202 Accepted):** ```json { "result": true } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1680 (Access Denied)` | 401 | "Appropriate access is not granted to this Workspace." | User is not the workspace owner | | `130670` | 406 | "The `confirm` field is required. Pass the workspace's folder name or numeric `id` as the `confirm` query parameter." | `confirm` query parameter was not provided | | `10563` | 406 | "The `confirm` field provided does not match the workspace's folder name or `id`." | Confirmation does not match folder name or ID | | `1663 (Update Failed)` | 500 | "There was an internal error processing your request." | Internal failure | --- ### Archive Workspace ``` POST /current/workspace/{workspace_id}/archive/ ``` Archives a workspace. Archived workspaces are hidden from default listings. **Auth:** JWT required. Admin or Owner required. 2FA required. **curl Example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/archive/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (202 Accepted):** ```json { "result": true } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1680 (Access Denied)` | 401 | "Appropriate access is not granted to this Workspace." | Not admin or owner | | `1663 (Update Failed)` | 500 | "The workspace is already archived." | Already archived | --- ### Unarchive Workspace ``` POST /current/workspace/{workspace_id}/unarchive/ ``` Restores an archived workspace to active status. **Auth:** JWT required. Admin or Owner required. 2FA required. **curl Example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/unarchive/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (202 Accepted):** ```json { "result": true } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1680 (Access Denied)` | 401 | "Appropriate access is not granted to this Workspace." | Not admin or owner | | `1663 (Update Failed)` | 500 | "The workspace is not archived. It cannot be unarchived." | Not currently archived | --- ## Workspace Assets ### List Available Asset Types ``` GET /current/workspace/assets/ ``` Returns available workspace asset metadata types (e.g., logo). **Auth:** JWT required. **curl Example:** ```bash curl -X GET "https://api.fast.io/current/workspace/assets/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "names": ["logo"], "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"} } ``` `names` lists the asset names a workspace accepts (only `logo`); `file_types` maps each to its file kind; `metadata_scheme` maps each to the metadata properties validated on upload. --- ### List Workspace Assets ``` GET /current/workspace/{workspace_id}/assets/ ``` Returns assets currently set on the workspace. **Auth:** JWT required. Owner only. 2FA required. **curl Example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/assets/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "assets": { "logo": { "metadata": {"width": 512, "height": 512, "megapixels": 0, "mime": "image/png"} } } } ``` Keyed by asset name; each entry carries the stored `metadata`. A workspace with no assets returns `"assets": []`. Fetch the bytes with *Read Asset Binary* below. --- ### Upload/Set Workspace Asset ``` POST /current/workspace/{workspace_id}/assets/{asset_name}/ ``` Upload or replace an asset. Sent as multipart/form-data. **Auth:** JWT required. Admin or Owner required. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` | string | Yes | 19-digit numeric workspace ID | | `{asset_name}` | string | Yes | Name of the asset (e.g., `logo`) | **Request Body (multipart/form-data):** | Field | Type | Required | Description | |-------|------|----------|-------------| | `file` | file | Yes | The asset file to upload | | `metadata` | string (JSON object) | No | Optional metadata for the asset, sent as a JSON object string (e.g. `{}`). Invalid JSON or a non-object value is refused with 406. | **curl Example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/assets/logo/" \ -H "Authorization: Bearer {jwt_token}" \ -F "file=@/path/to/logo.png" \ -F "metadata={}" ``` **Response (200 OK):** ```json { "result": true } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1691 (File Missing)` | 412 | "Asset upload missing" | No file in request | | `100289` | 406 | "metadata must be a JSON object encoded as a string." | `metadata` is not valid JSON, or is not a JSON object | --- ### Delete Workspace Asset ``` DELETE /current/workspace/{workspace_id}/assets/{asset_name}/ ``` Delete a specific asset from a workspace. **Auth:** JWT required. Admin or Owner required. **curl Example:** ```bash curl -X DELETE "https://api.fast.io/current/workspace/1234567890123456789/assets/logo/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true } ``` --- ### Read Asset Binary ``` GET /current/workspace/{workspace_id}/assets/{asset_name}/read/ HEAD /current/workspace/{workspace_id}/assets/{asset_name}/read/ ``` GET returns raw binary data. HEAD returns metadata headers only (`Content-Type`, `Content-Length`). **Auth:** JWT required. Any signed-in user; workspace membership is not required. 2FA required. **curl Example:** ```bash # Download asset curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/assets/logo/read/" \ -H "Authorization: Bearer {jwt_token}" \ --output logo.png # Get metadata headers only curl -I "https://api.fast.io/current/workspace/1234567890123456789/assets/logo/read/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** Raw binary content with appropriate `Content-Type` header (not JSON). --- ## Workspace Members ### Add or Invite a Member ``` POST /current/workspace/{workspace_id}/members/{email_or_user_id}/ ``` Add an existing user directly by user ID, or send an invitation by email address. **Auth:** JWT required. Permission depends on workspace `perm_member_manage` setting. You cannot add or invite someone at a role above your own — the ceiling applies to invitations as well as direct adds. Send the parameters as form fields (`application/x-www-form-urlencoded` or `multipart/form-data`); a JSON request body is refused with a 406 error. 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. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` | string | Yes | 19-digit numeric workspace ID | | `{email_or_user_id}` | string | Yes | 19-digit user ID (direct add) or email address (invitation) | **Request Parameters (adding by user ID):** | Name | Type | Required | Description | |------|------|----------|-------------| | `permissions` | string | No | `"admin"`, `"member"`, `"guest"`, or `"view"`; omitted means `"member"` for a new member, while a current (unexpired) member keeps their role (your own role must be at least that role) and an expired membership is restored as `"member"`. Cannot be `"owner"`. Any other value (including `"any"`) is refused with a 406 error rather than treated as `member`. | | `notify_options` | string | No | Notification preference | | `expires` | string | No | Membership expiration (`YYYY-MM-DD HH:MM:SS UTC`) | | `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 | No | `"admin"`, `"member"`, `"guest"`, or `"view"`; omitted means `"member"`. Cannot be `"owner"`. Any other value (including `"any"`) is refused with a 406 error rather than treated as `member`. | | `message` | string | No | Custom message in invitation email | | `invitation_expires` | string | No | Invitation expiration (`YYYY-MM-DD HH:MM:SS UTC`) | **curl Examples:** ```bash # Add existing user by ID curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/members/9876543210987654321/" \ -H "Authorization: Bearer {jwt_token}" \ -d "permissions=member" # Invite by email curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/members/newuser@example.com/" \ -H "Authorization: Bearer {jwt_token}" \ -d "permissions=member" \ -d "message=Welcome to the project!" ``` **Response -- Direct Add (200 OK):** ```json { "result": true } ``` **Response -- Invitation Sent (200 OK):** ```json { "result": true, "invitation": { "id": "aea3w-cuan6-edcu5-vkaex-g52gm-dacr", "inviter": "John Doe", "invitee_email": "newuser@example.com", "entity_type": "workspace", "state": "pending", "created": "2025-01-15 10:30:00 UTC", "expires": "2025-01-18 10:30:00 UTC" } } ``` Abbreviated: the `invitation` object carries every field of a *List Workspace Invitations* entry, plus a `workspace` object. **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1692 (Cannot Add As Owner)` | 406 | "Adding a member as an owner is not allowed" | Attempted owner-level permission | | `1656 (Limit Exceeded)` | 413 | Varies | Workspace or org member limit reached | | `1680 (Access Denied)` | 401 | "Appropriate access is not granted to this Workspace." | Below workspace `perm_member_manage` level | | `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. …" | The request was sent as a JSON body | | `127022` | 406 | "You cannot add, update, or delete a membership with a higher permission than your own." | The requested role (when omitted: `member` for a new member or invitation, a current (unexpired) member's role on a re-add) is above your own — for an invitation as well as a direct add | | 403 | — | Reason-carrying refusal (`params.reason`) | The invitee is external and the org's collaboration policy (or this workspace's own `external_invites` switch) denies the inviter. `reason` = `external_invites_denied` or `external_invites_object_denied`. Applies to a fresh invite, a resend, and a broadening edit to an existing membership; a pure permission reduction is never gated. See *Collaboration Policy* above. | **Notes:** - Direct-add and invitation both count as "adding a member" for the collaboration policy above; an org invite (adding someone to the org itself, not to this workspace) is a separate action and is never gated by it. --- ### Remove a Member ``` DELETE /current/workspace/{workspace_id}/members/{user_id}/ ``` Removes a member from the workspace. Cannot remove the workspace owner. **Auth:** JWT required. Permission depends on `perm_member_manage`. **curl Example:** ```bash curl -X DELETE "https://api.fast.io/current/workspace/1234567890123456789/members/9876543210987654321/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `146727` | 401 | "Unable to remove owner. Use transfer ownership API." | Attempted to remove the workspace owner | | `150044` | 401 | "User can not remove user with greater permissions." | The member's role is above your own | | `1680 (Access Denied)` | 401 | "Appropriate access is not granted to this Workspace." | Below workspace `perm_member_manage` level | | `1605 (Invalid Input)` | 406 | "The membership you specified does not exist." | The user is not a member | **Notes:** - Removing a member cascades removal into all shares within the workspace. - Removing a member disconnects the cloud-sync sources they connected in this workspace (files are kept). See *When a Member Leaves or Is Removed* under Cloud Sync; `GET /current/cloudsync/workspace/{workspace_id}/sources/?owner={user_id}` lists them beforehand. --- ### List Workspace Members ``` GET /current/workspace/{workspace_id}/members/list/ ``` Lists all members with their permissions, notification preferences, and membership metadata. **Auth:** JWT required. Any workspace member. **Query Parameters:** `limit` (default 100, max 500) and `offset` (default 0). The response carries a `pagination` object (`total`, `limit`, `offset`, `has_more`). **curl Example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/members/list/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "users": [ { "id": "1234567890123456789", "account_type": "human", "email_address": "owner@example.com", "first_name": "Alice", "last_name": "Johnson", "permissions": "owner", "status": "active", "notify": "Notify me in app" }, { "id": "9876543210987654321", "account_type": "agent", "email_address": "bot@example.com", "first_name": "Sync", "last_name": "Bot", "permissions": "member", "status": "active", "expires": "2025-12-31 23:59:59 UTC" }, { "id": "5566778899001122334", "account_type": "human", "email_address": "invited@example.com", "first_name": "invited@example.com", "last_name": "", "permissions": "member", "status": "pending", "invite": { "id": "aea3wcuan6edcu5vkaexg52gmdacr", "created": "2025-01-15 10:30:00", "expires": "2025-01-18 10:30:00" } } ], "pagination": { "total": 3, "limit": 100, "offset": 0, "has_more": false } } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `users` | array | Array of member objects | | `users[].id` | string | 19-digit user profile ID | | `users[].account_type` | string | `"human"` or `"agent"` | | `users[].email_address` | string | Member's email address | | `users[].first_name` | string | First name | | `users[].last_name` | string | Last name | | `users[].permissions` | string | Role: `"owner"`, `"admin"`, `"member"`, `"guest"` | | `users[].status` | string | `"active"` for registered users, `"pending"` for invited users who have not yet signed up | | `users[].invite` | object | Present on pending members (absent when unset, which is normal for active members): `id` (the invitation ID, unhyphenated), `created`, `expires` (the invitation's acceptance deadline, or null). A snapshot taken when the invitation was sent, with timestamps as `YYYY-MM-DD HH:MM:SS` (UTC, no suffix) | | `users[].notify` | string | Notification preference — present only on your own entry | | `users[].expires` | string | Membership expiration; absent for a permanent membership | | `pagination` | object | `total`, `limit`, `offset`, `has_more` | --- ### Leave Workspace (Self) ``` DELETE /current/workspace/{workspace_id}/member/ ``` Removes the authenticated user from the workspace. Owners cannot leave; they must transfer ownership first. **Auth:** JWT required. **curl Example:** ```bash curl -X DELETE "https://api.fast.io/current/workspace/1234567890123456789/member/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "You cannot leave a workspace you are the owner of, transfer ownership or close workspace." | User is the owner | | `1605 (Invalid Input)` | 406 | "You cannot leave an workspace you are not a member of." | Not a member | **Notes:** - Leaving disconnects the cloud-sync sources you connected in this workspace (files are kept). See *When a Member Leaves or Is Removed* under Cloud Sync; `GET /current/cloudsync/workspace/{workspace_id}/sources/?owner=me` lists them beforehand. --- ### Get Member Details ``` GET /current/workspace/{workspace_id}/member/{member_id}/details/ ``` Returns membership details for a specific user. **Auth:** JWT required. Any workspace member. **curl Example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/member/9876543210987654321/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "user": { "id": "9876543210987654321", "account_type": "human", "email_address": "user@example.com", "first_name": "Jane", "last_name": "Smith", "permissions": "member" } } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `user.id` | string | 19-digit user profile ID | | `user.account_type` | string | `"human"` or `"agent"` | | `user.email_address` | string | Email address | | `user.first_name` | string | First name | | `user.last_name` | string | Last name | | `user.permissions` | string | Permission level name | | `user.invite` | object | Pending-invitation snapshot, as in List Workspace Members; absent when unset | | `user.notify` | string | Notification preference — present only when you read your own membership | | `user.expires` | string | Membership expiration; absent for a permanent membership | | `user.member_added_at` | string | When the membership was created — present only to the member themselves or a workspace admin or above | **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 a Member ``` POST /current/workspace/{workspace_id}/member/{member_id}/update/ ``` Updates a member's role, notification preferences, or expiration. **Auth:** JWT required. Permission depends on `perm_member_manage`; you may always update your own membership. Either way you cannot set a role above your own. Send the parameters as form fields; a JSON request body is refused with a 406 error. **Request Parameters (all optional):** | Name | Type | Description | |------|------|-------------| | `permissions` | string | New role: `"admin"`, `"member"`, `"guest"`, `"view"`. Omitted leaves the role unchanged; `"owner"` is ignored (use *Transfer Workspace Ownership*). Any other value (including `"any"`) is refused with a 406 error. | | `notify_options` | string | Notification preference; omitted leaves it unchanged | | `expires` | string | Membership expiration (`YYYY-MM-DD HH:MM:SS UTC`) | **curl Example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/member/9876543210987654321/update/" \ -H "Authorization: Bearer {jwt_token}" \ -d "permissions=admin" ``` **Response (200 OK):** ```json { "result": true } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "The membership you specified does not exist." | Target is not a member | | `1680 (Access Denied)` | 401 | "Appropriate access is not granted to this Workspace." | Below workspace `perm_member_manage` level (updating someone else) | | `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. …" | The request was sent as 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 Workspace Ownership ``` POST /current/workspace/{workspace_id}/member/{member_id}/transfer_ownership/ ``` **POST only** — `GET`, `HEAD`, and every other method return 405. Transfers ownership to another member, who must be an enabled, non-phantom member of both the workspace and its parent org. The current owner is demoted to admin. The workspace's parent org is never changed. **Auth:** JWT required. Owner only. **Fixed behaviour:** - The successor is promoted before the current owner is demoted, under a per-workspace lock. - **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 workspace returns 409 `transfer_in_progress`. - No email is sent — this is recorded as an event only. **curl Example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/member/9876543210987654321/transfer_ownership/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "ownership": { "profile_id": "1234567890123456789", "profile_type": "workspace", "previous_owner": "1111111111111111111", "new_owner": "9876543210987654321", "transferred_at": "2026-09-23 16:37:29 UTC" } } ``` **Error Responses (`error.params.reason`):** | Reason | HTTP | Message | Cause | |--------|------|---------|-------| | `successor_is_self` | 406 | "You cannot transfer ownership to yourself." | Target is self | | `successor_not_member` | 406 | "The membership you specified does not exist." / "The new owner must be a member of the parent organization." | Target is not a workspace member (or their membership was removed or has expired), or not a member of the parent org | | `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 | | `transfer_in_progress` | 409 | "Another ownership transfer of this workspace is in progress. Please try again shortly." | Another transfer of this workspace is running; retry shortly | | — | 401 | "Appropriate access is not granted to this Workspace." / "You are no longer the owner of this workspace." | Not the workspace owner (the second text: ownership changed while the call was waiting) | | `scope_admin_required` | 403 | — | The credential is not admin-capable on this workspace (an API key/OAuth token without `rwa`) | | — | 500 | "The ownership transfer did not finish. Transfer to the same member again to complete it." | The transfer did not finish — repeat the call with the same target to complete it | | — | 503 | "Ownership transfer is temporarily unavailable. Please try again shortly." | Temporarily unavailable; retry | **Event:** `ownership_transferred` (audit log; `profile_type`, `from_user`, `to_user`). The two existing `membership_updated` events (promotion and demotion) still fire. --- ### Join Workspace ``` POST /current/workspace/{workspace_id}/members/join/ ``` Self-join a workspace based on org membership. Subject to the workspace's `perm_join` setting. **Auth:** JWT required. **Request Parameters:** | Name | Type | Required | Description | |------|------|----------|-------------| | `notify_options` | string | No | Notification preference | **curl Example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/members/join/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1680 (Access Denied)` | 401 | "You do not have the appropriate permissions to join this workspace." | User's org role does not meet `perm_join` | | `1656 (Limit Exceeded)` | 413 | Varies | Workspace member limit reached | | `1654 (Internal Error)` | 500 | "Unable to verify workspace member limits. Please try again later." | The member limit could not be checked; retry | **Notes:** - A new self-joined member gets the `member` role; a current (unexpired) member who self-joins keeps their role; an expired membership is restored as `member`. Only `notify_options` is read; `permissions` is ignored. - A self-join never sets a membership expiry. --- ### Join Workspace via Invitation ``` POST /current/workspace/{workspace_id}/members/join/{invitation_key}/{action}/ ``` Join or decline a workspace invitation. **Auth:** JWT required. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{invitation_key}` | string | Yes | Unique invitation key | | `{action}` | string | No | `"accept"` (default) or `"decline"` | **curl Example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/members/join/abc123def456/accept/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1654 (Internal Error)` | 500 | "Failed to get invitation." | Invalid or expired invitation key | | `1680 (Access Denied)` | 401 | "The inviter no longer has appropriate permissions..." | Inviter lost management permissions | | `1656 (Limit Exceeded)` | 413 | Varies | Member limit reached | | 403 | — | Reason-carrying refusal (`params.reason`) | Accept only: re-checked against the collaboration policy at redemption, since the org policy or the inviter's own standing can have changed since the invitation was sent. `reason` = `external_invites_denied` or `external_invites_object_denied`. The invitation stays **pending**, not failed. `decline` is never gated. See *Collaboration Policy* above. | **Notes:** - The system validates the inviter still has sufficient permissions at acceptance time. --- ### Pending Members When a user is invited to a workspace by email but does not yet have a Fastio account, they appear as a **pending member** in the member list. Pending members are placeholders that reserve a seat for the invitee before they sign up. **How pending members appear in responses:** - The `status` field is `"pending"` (vs `"active"` for registered users). - An `invite` object is included with basic invitation details (`id`, `created`, `expires`). - `email_address` shows the invited email address. - `first_name` is set to the invited email address; `last_name` is empty. **Example member list entry (pending):** ```json { "id": "5566778899001122334", "account_type": "human", "email_address": "newuser@example.com", "first_name": "newuser@example.com", "last_name": "", "permissions": "member", "status": "pending", "invite": { "id": "aea3wcuan6edcu5vkaexg52gmdacr", "created": "2025-01-15 10:30:00", "expires": "2025-01-18 10:30:00" } } ``` **Account claim:** When the invited user signs up or accepts the invitation with an existing account, their status transitions from `"pending"` to `"active"` automatically. **Removal:** To remove a pending member, delete their invitation using the invitation endpoints (see Workspace Invitations below). Deleting the invitation removes the pending member. **Notifications:** Pending members do not receive in-app or email notifications until they claim their account. --- ## Workspace Invitations ### List Workspace Invitations ``` GET /current/workspace/{workspace_id}/members/invitations/list/ ``` Returns all invitations for the workspace. **Auth:** JWT required. Any workspace member. **curl Example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/members/invitations/list/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "invitations": [ { "id": "aea3w-cuan6-edcu5-vkaex-g52gm-dacr", "inviter": "Alice Johnson", "inviter_actor": { "user_id": "1234567890123456789", "kind": "human", "agent_name": null, "name_source": null, "credential_type": "session", "verified": false }, "invitee_email": "newuser@example.com", "invitee_uid": "5566778899001122334", "accepted_uid": null, "entity_type": "workspace", "state": "pending", "consumed": false, "created": "2025-01-15 10:30:00 UTC", "updated": "2025-01-15 10:30:00 UTC", "expires": "2025-01-18 10:30:00 UTC" } ] } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `invitations` | array | Array of invitation objects | | `invitations[].id` | string | Invitation ID | | `invitations[].inviter` | string | Display name of inviting user | | `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 the invitation was sent to | | `invitations[].invitee_uid` | string or null | User ID (19-digit string) of the invitee's pending-member placeholder; null when there is none | | `invitations[].accepted_uid` | string or null | 19-digit user ID of the account that accepted, as a string; null until accepted | | `invitations[].entity_type` | string | Always `"workspace"` | | `invitations[].state` | string | `"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 or null | Deadline for accepting the invitation | --- ### List Invitations by State ``` GET /current/workspace/{workspace_id}/members/invitations/list/{state}/ ``` Filter invitations by state. **Auth:** JWT required. Any workspace member. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{state}` | string | Yes | `"pending"`, `"accepted"`, `"declined"` | **curl Example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/members/invitations/list/pending/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** Same format as List Workspace Invitations, filtered by state. **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "An invalid invitation state was supplied." | Unrecognized state | --- ### Update an Invitation ``` POST /current/workspace/{workspace_id}/members/invitation/{invitation_id}/ ``` Update an existing invitation. The `{invitation_id}` can be the invitation ID or the invitee's email address. The invitation must belong to this workspace; otherwise the request fails with 406 exactly as an unknown invitation does ("Invalid invitation id or Invitation not found." for an ID). **Auth:** JWT required. Permission depends on `perm_member_manage`. Send the parameters as form fields; a JSON request body is refused with a 406 error. **Request Parameters (all optional):** | Name | Type | Description | |------|------|-------------| | `state` | string | New state: `"pending"`, `"accepted"`, `"declined"` | | `permissions` | string | The role granted when the invitation is accepted: `"admin"`, `"member"`, `"guest"`, `"view"`. 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 | New deadline for accepting the invitation (`YYYY-MM-DD HH:MM:SS UTC`, 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:** ```bash # Decline by ID curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/members/invitation/aea3w-cuan6-edcu5-vkaex-g52gm-dacr/" \ -H "Authorization: Bearer {jwt_token}" \ -d "state=declined" # Update by email curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/members/invitation/user@example.com/" \ -H "Authorization: Bearer {jwt_token}" \ -d "permissions=admin" ``` **Response (200 OK):** ```json { "result": true } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "An invalid invitation ID or email was supplied." | Malformed identifier | | `1605 (Invalid Input)` | 406 | "Invalid invitation id or Invitation not found." | No invitation with that ID, or it belongs to a different workspace | | `1605 (Invalid Input)` | 406 | "An invalid state was supplied." | Unrecognized state | | `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. …" | The request was sent as 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 failure | | `1680 (Access Denied)` | 401 | "Insufficient permissions" | Below required permission level | --- ### Delete an Invitation ``` DELETE /current/workspace/{workspace_id}/members/invitation/{invitation_id}/ ``` Delete (revoke) an invitation. The `{invitation_id}` can be the invitation ID or the invitee's email. The invitation must belong to this workspace; otherwise the request fails with 406 exactly as an unknown invitation does ("Invalid invitation id or Invitation not found." for an ID). **Auth:** JWT required. Permission depends on `perm_member_manage`. **curl Example:** ```bash curl -X DELETE "https://api.fast.io/current/workspace/1234567890123456789/members/invitation/aea3w-cuan6-edcu5-vkaex-g52gm-dacr/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "An invalid invitation ID or email was supplied." | Malformed identifier | | `1605 (Invalid Input)` | 406 | "Invalid invitation id or Invitation not found." | No invitation with that ID, or it belongs to a different workspace | | `1666 (Delete Failed)` | 500 | "Failed to delete invitation." | Internal failure | | `1680 (Access Denied)` | 401 | "Insufficient permissions" | Below required permission level | --- ## Creating Shares from Workspaces ``` POST /current/workspace/{workspace_id}/create/share/ ``` Create a new share within a workspace. Shares can use independent storage (isolated portal) or a workspace folder as their storage root (live folder share). **Auth:** JWT required. Workspace Guest or above, subject to the *Sharing Policy* — read `capabilities.can_create_share` for the effective answer. For full share management documentation, see `shares.txt`. **Required Parameters:** | Name | Type | Constraints | Description | |------|------|-------------|-------------| | `intelligence` | string | `"true"` or `"false"` | Enable AI features for the share | **Optional Parameters:** | Name | Type | Constraints | Description | |------|------|-------------|-------------| | `share_type` | string | `"send"`, `"receive"`, `"exchange"` | Type of share. Optional — falls back to the profile default when omitted. **Portal (`independent`) shares are always `send`** — a `receive`/`exchange` value is silently overridden. `receive`/`exchange` require `storage_mode=workspace_folder`. | | `access_options` | string | See access options below | Access control setting. Optional — falls back to the profile default when omitted. | | `invite` | string | `"owners"` or `"guests"` | Who can manage invitations. Optional — falls back to the profile default when omitted. | | `storage_mode` | string | `"independent"` (default) or `"workspace_folder"` | Storage isolation mode | | `folder_node_id` | string | Valid OpaqueId | Existing workspace folder (for `workspace_folder` mode) | | `create_folder` | string | `"true"` or `"false"` | Create new folder (for `workspace_folder` mode) | | `folder_name` | string | 1-255 characters | Name for new folder (defaults to `"Shared Folder"`) | | `title` | string | 2-80 chars | Display title | | `description` | string | 10-500 chars | Share description | | `custom_name` | string | 4-80 chars, URL-friendly | Custom URL name. Auto-generated if omitted. | | `custom_url` | string | 10-100 chars | Custom URL for linking to the share. Default `null`; not auto-generated. | | `password` | string | 4-128 chars | Password protection (Send type only, requires `"Anyone with the link"` access) | | `expires` | string | datetime | Expiration date (portals only, not for workspace folder shares) | | `notify` | string | `"never"`, `"notify_on_file_received"`, `"notify_on_file_sent_or_received"` | Notification preference | | `comments_enabled` | string | `"true"` or `"false"` | Enable comments | | `download_security` | string | `high`, `medium`, `off` | Download security level. `high`: downloads disabled. `medium`: restricted. `off`: unrestricted. | | `guest_chat_enabled` | string | `"true"` or `"false"` | Enable guest AI chat | | `accent_color` | string (JSON) | JSON color object `{"color":"#RRGGBB","opacity":0-100}` | Accent color | | `background_color1` | string (JSON) | JSON color object `{"color":"#RRGGBB","opacity":0-100}` | Background color 1 | | `background_color2` | string (JSON) | JSON color object `{"color":"#RRGGBB","opacity":0-100}` | Background color 2 | | `owner_defined` | string (JSON) | Valid JSON | Custom properties | **Access Options (`access_options`):** | Value | Description | |-------|-------------| | `'Only members of the Share or Workspace'` | Most restrictive (default) | | `'Members of the Share, Workspace or Org'` | Includes org members | | `'Anyone with a registered account'` | Any authenticated user | | `'Anyone with the link'` | Least restrictive; allows password. Not available for Receive/Exchange types. | **curl Example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/create/share/" \ -H "Authorization: Bearer {jwt_token}" \ -d "title=Client Deliverables" \ -d "share_type=send" \ -d "intelligence=true" \ -d "access_options=Anyone with a registered account" \ -d "invite=owners" ``` **Response (200 OK):** ```json { "result": true, "share": { "id": "9876543210987654321", "custom_name": "abc123opaque", "storage_mode": "independent" } } ``` **Response -- Workspace Folder Share:** ```json { "result": true, "share": { "id": "9876543210987654321", "custom_name": "def456opaque", "storage_mode": "workspace_folder", "folder_node_id": "abc123def456" } } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `share.id` | string | 19-digit share profile ID | | `share.custom_name` | string | URL name (custom or auto-generated) | | `share.storage_mode` | string | `"independent"` or `"workspace_folder"` | | `share.folder_node_id` | string | (Workspace folder shares only) Folder node ID | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `131979` | 403 | "Sharing is turned off for this workspace." | The org or workspace sharing policy denies new shares. `params.reason` = `policy_sharing_disabled`. See *Sharing Policy* above. | | `1658 (Not Acceptable)` | 406 | "The supplied share custom name is already in use." | Duplicate `custom_name` | | `1605 (Invalid Input)` | 406 | "An invalid share custom name was supplied." | Invalid `custom_name` format | | `1605 (Invalid Input)` | 406 | "Workspace folder shares cannot have an expiration date." | `expires` set on workspace folder share | | `1605 (Invalid Input)` | 406 | "Receive and Exchange shares cannot have \"Anyone\" access option." | Invalid access/type combination | | `1605 (Invalid Input)` | 406 | "Password can only be set for shares with \"Anyone\" access option." | Password on non-public share | | `1658 (Not Acceptable)` | 406 | "This folder has already been shared." | Folder already has a share | | `1660 (Conflict)` | 409 | "Unable to process share creation request due to concurrent operation." | Concurrent folder share creation | | `1700 (Forbidden)` | 403 | "Creating a share with this access setting is not permitted here by policy." | The final `access_options` admits people outside the org (`'Anyone with a registered account'` or `'Anyone with the link'`) and the org's collaboration policy denies the acting user. `params.reason` = `external_invites_denied`. See *Collaboration Policy* above. | | `1654 (Internal Error)` | 500 | "The policy that governs sharing in this workspace could not be determined. Please try again." | The collaboration policy could not be evaluated (transient — retry). | --- ### List Shares in Workspace ``` GET /current/workspace/{workspace_id}/list/shares/ ``` Lists all shares belonging to a workspace. **Auth:** JWT required. View or above. **Query Parameters:** | Name | Type | Default | Description | |------|------|---------|-------------| | `archived` | string | `"false"` | `"true"` for archived shares, `"false"` for active | | `limit` | integer | `100` | Page size (max 500) | | `offset` | integer | `0` | Number of shares to skip | **curl Example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/list/shares/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "shares": [ { "id": "9876543210987654321", "title": "Client Deliverables", "share_type": "send", "custom_name": "client-deliverables", "archived": false, "closed": false } ], "pagination": { "total": 1, "limit": 100, "offset": 0, "has_more": false } } ``` --- ### Import Share into Workspace ``` POST /current/workspace/{workspace_id}/import/share/{share_id}/ ``` Transfers a user-owned share into workspace ownership. **Auth:** JWT required. Workspace Member or above AND owner of the share. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` | string | Yes | 19-digit workspace ID | | `{share_id}` | string | Yes | 19-digit share ID to import | **curl Example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/import/share/9876543210987654321/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "share": { "id": "9876543210987654321", "parent_type": "workspace", "parent_workspace": "1234567890123456789" } } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "This share is not owned by you and cannot be imported." | Share parent is not the current user | | `1650 (Authentication Invalid)` | 401 | "You must be the owner of the share to import it to a workspace." | Not the share owner | | `1605 (Invalid Input)` | 406 | "The share has multiple owners..." | Remove other owners first | **Notes:** - Share must be user-owned (not already in another workspace). - User must be the sole owner. Multiple owners must be removed first. - Archived shares are auto-unarchived during import. --- ## File Shares (Durable Single-File Links) A **File Share** is a durable, link-shareable view of one workspace file — the successor to the deprecated QuickShare. It is durable by default (an optional expiry can be set on create/update) and has no per-link transfer cap (bandwidth is metered to the owning organization). A File Share is bound to a single file node at creation and the binding is immutable. The public read endpoints (details, download, preview, versions) are documented in the Shares and Storage references; the management endpoints below require the caller to be an authenticated **member of the workspace**. **Comment visibility is one-directional (workspace ⊇ File Share).** Because a File Share is just a view of a workspace file, comments left by File Share recipients also surface to workspace members — read-only on the workspace file's comment thread and in workspace comment search. The reverse is never true: a File Share recipient sees only the comments made under that File Share and never the workspace's internal comments. ### Create a File Share ``` POST /current/workspace/{workspace_id}/create/fileshare/ ``` Create a File Share bound to a workspace file node. **Auth:** JWT required. Permission: Member of the workspace named in the path. **Request body (form-encoded):** | Name | Type | Required | Description | |------|------|----------|-------------| | `node` | string | Yes | OpaqueId of the **file or note** node to share (a folder is refused) | | `title` | string | No | Display title (max 255 chars) | | `access_option` | string | No | `anyone_with_link`, `any_registered`, or `named_people` (default: `named_people`) | | `password` | string | No | Optional link password (1-255 chars). **Body-only** — never accepted from the query string. | | `expires` | integer | No | Optional expiry, RELATIVE: seconds from now (> 0). Mutually exclusive with `expires_at`. Omitted = durable (never expires). | | `expires_at` | string | No | Optional expiry, ABSOLUTE: a future datetime; a value without a timezone is interpreted as UTC. Mutually exclusive with `expires`. | | `comments_enabled` | string | No | `"true"` or `"false"` — allow comments on the File Share (default off) | An expired File Share stops serving **immediately** at the expiry moment (recipients get `404`) and is then reaped automatically by the hourly cleanup — a `file_share_deleted` event fires and the deletion pipeline cleans up its grant records afterwards (access is already dead at the expiry moment). The bound file is never touched. The share object's `expires` field reflects the resolved absolute time. **curl Example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/create/fileshare/" \ -H "Authorization: Bearer {jwt_token}" \ -d "node=2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4" \ -d "title=Quarterly Presentation" \ -d "access_option=anyone_with_link" ``` **Response (200 OK):** ```json { "result": true, "fileshare": { "fileshare": "1234567890123456789", "id_alt": "adheih5r326qjiqvk4wfamvt4rqeh", "title": "Quarterly Presentation", "access_option": "anyone_with_link", "has_password": false, "bound_node_id": "2ltsuq4mjacuv7pgc5ydlxnsjwee4", "creator_uid": "9876543210987654321", "created": "2026-04-27 16:37:29 UTC", "updated": "2026-04-27 16:37:29 UTC", "expires": null, "comments_enabled": false } } ``` **Error Responses:** | Error Code | HTTP Status | Cause | |------------|-------------|-------| | `1605 (Invalid Input)` | 406 | `node` missing/invalid, the node is not a file, or the password is unusable | | `1680 (Access Denied)` | 401 | The bound file is not publicly serveable (locked/DMCA/infected) | | `1700 (Forbidden)` | 403 | Org or workspace sharing policy refuses new single-file links. `params.reason` = `policy_sharing_disabled`. Read `capabilities.can_create_fileshare` on the workspace to avoid it. | | `1700 (Forbidden)` | 403 | `access_option` is `any_registered` or `anyone_with_link` and the org's collaboration policy denies the acting user ("Creating a file link with this access setting is not permitted here by policy."). `params.reason` = `external_invites_denied`. See *Collaboration Policy* above. | | `1654 (Internal Error)` | 500 | The collaboration policy could not be evaluated ("The policy that governs sharing this file could not be determined. Please try again."; transient — retry). | ### List File Shares in Workspace ``` GET /current/workspace/{workspace_id}/list/fileshares/ ``` Lists the workspace's File Shares (offset pagination). Scoped strictly to File Shares — never mixes with the Share list. **Auth:** JWT required. Permission: Member. Each item additionally carries `grant_count` (the number of **live** named-people grants on the File Share — revoked / expired grants excluded) and `grants_preview` (the first few grants, in the same shape as the grants list endpoint, for rendering an avatar stack without a second call). Both are best-effort: if the grant read faults for an item, the two fields are omitted for that item (the rest of the list still returns). The full access list is the `GET .../grants/` endpoint below. **Response (200 OK):** ```json { "result": true, "fileshares": [ { "fileshare": "1234567890123456789", "id_alt": "adheih5r326qjiqvk4wfamvt4rqeh", "title": "Quarterly Presentation", "access_option": "anyone_with_link", "has_password": false, "bound_node_id": "2ltsuq4mjacuv7pgc5ydlxnsjwee4", "creator_uid": "9876543210987654321", "created": "2026-04-27 16:37:29 UTC", "updated": "2026-04-27 16:37:29 UTC", "expires": null, "comments_enabled": false, "grant_count": 2, "grants_preview": [ { "user": "9876543210987654321", "name": "Ada Lovelace", "email": "ada@example.com", "capability": "edit", "state": "active", "created": "2026-04-27 16:37:29 UTC", "expires": null }, { "user": null, "name": null, "email": "pending.invitee@example.com", "capability": "view", "state": "pending", "created": "2026-04-27 16:40:00 UTC", "expires": "2026-05-27 16:40:00 UTC" } ] } ], "pagination": { "total": 1, "limit": 100, "offset": 0, "has_more": false } } ``` ### Update a File Share ``` POST /current/fileshare/{fileshare_id}/update/ PATCH /current/fileshare/{fileshare_id}/update/ ``` Update a File Share's mutable settings. The bound file node is **immutable** here (a rebind is a new File Share); only `title`, `access_option`, `password`, `comments_enabled` and the expiry change. The caller is authorized against the File Share's **own** parent workspace. **Auth:** JWT required. Permission: Member of the File Share's parent workspace. **Request body (form-encoded):** | Name | Type | Description | |------|------|-------------| | `title` | string | New title (max 255). Send empty / `null` to clear. | | `access_option` | string | `anyone_with_link`, `any_registered`, or `named_people` | | `password` | string | New link password (max 255). **Body-only.** Send empty to clear. | | `expires` | integer | New expiry, RELATIVE: seconds from now (> 0). Mutually exclusive with `expires_at`. | | `expires_at` | string | New expiry, ABSOLUTE: a future datetime (no timezone = UTC). Send `null` to **clear** the expiry (durable again). Mutually exclusive with `expires`. | | `comments_enabled` | string | `"true"` or `"false"` — turn commenting on the File Share on or off | Widening `access_option` (`named_people` → `any_registered` or `anyone_with_link`, or `any_registered` → `anyone_with_link`) is governed by the org's collaboration policy exactly like the grants endpoint — narrowing and every other field are unaffected. **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `177116` | 403 | "Widening this FileShare's access is not permitted here by policy." | `params.reason` = `external_invites_denied`. See *Collaboration Policy* above. | | `198821` | 500 | "The policy that governs sharing this file could not be determined. Please try again." | The policy could not be evaluated (transient — retry). | Returns the updated File Share in the same shape as create. ### Delete a File Share ``` DELETE /current/fileshare/{fileshare_id}/delete/ ``` Delete a File Share. Revokes the link and cascades its grants. **Auth:** JWT required. Permission: Member of the File Share's parent workspace. **Response:** `{"result": true}` ### List / Manage Per-User Grants ``` GET /current/fileshare/{fileshare_id}/grants/ -- list the named-people access list POST /current/fileshare/{fileshare_id}/grants/ -- grant or raise a user's capability DELETE /current/fileshare/{fileshare_id}/grants/ -- revoke a user's grant (idempotent) ``` Manage the named-people access list. A grant raises an individual user's capability regardless of tier; the `named_people` tier consults this list directly. **Auth:** JWT required. Permission: Member of the File Share's parent workspace (all three methods). **List (GET):** Returns the File Share's **live** named-people grants — revoked and expired grants are excluded. There are no pagination parameters (the access list is small; the first 1000 grants are returned). **Response (200 OK):** ```json { "result": true, "grants": [ { "user": "9876543210987654321", "name": "Ada Lovelace", "email": "ada@example.com", "capability": "edit", "state": "active", "created": "2026-04-27 16:37:29 UTC", "expires": null }, { "user": null, "name": null, "email": "pending.invitee@example.com", "capability": "view", "state": "pending", "created": "2026-04-27 16:40:00 UTC", "expires": "2026-05-27 16:40:00 UTC" } ] } ``` Grant fields: | Field | Type | Description | |-------|------|-------------| | `user` | string \| null | The grantee's 19-digit user profile ID. `null` for a `pending` invitee (no account yet). | | `name` | string \| null | Display name. `null` when unknown (a pending invitee, or no name set). | | `email` | string \| null | The grantee's email address (the real invitation email for a pending invitee). `null` in the rare case a claimed account has no email on record. | | `capability` | string | `view`, `download`, or `edit`. | | `state` | string | `active` (a real, claimed user) or `pending` (an invited address that has not yet been claimed). | | `created` | string | When the grant was created (`Y-m-d H:i:s UTC`). | | `expires` | string \| null | For an `active` grant, the grant's own expiry; for a `pending` invitee, the invitation's expiry. `null` when it does not expire. | **Grant / revoke (POST / DELETE) parameters:** **Where to send them differs by method.** On `POST`, send them as a **form-encoded request body** (they are also accepted on the query string). On `DELETE`, send them as **query-string parameters** — a request body is not read on this `DELETE`, so a form-encoded `DELETE` body is silently ignored and the request fails as though you had supplied neither `user` nor `email`. Supply **exactly one** of `user` or `email` (supplying both, or neither, is rejected as invalid input — `1605`, HTTP 406). | Name | Type | Required | Description | |------|------|----------|-------------| | `user` | string | One of `user`/`email` | 19-digit user profile ID of the grantee (an existing account) | | `email` | string | One of `user`/`email` | Email address of the grantee — lets you grant a person who may not have an account yet | | `capability` | string | Yes for POST | `view`, `download`, or `edit` | A `view` grant can open the link and preview; `download` can also download the bytes (and historical versions); `edit` can additionally **replace the file's content** via an upload session (see the Upload reference). Re-granting the same capability, or revoking a non-existent grant, is a silent no-op. **Collaboration policy.** A grant to an **external** person (not a current member of the File Share's owning org, and not on one of that org's verified SSO domains) is governed by the org's `external_invites_shares` policy — see *Collaboration Policy* above. A File Share has no external-invite switch of its own; it follows the org policy only. A new grant or any **broadening** edit — raising the capability, extending an expiry, or widening `access_option` — is gated; a pure reduction is always available. This applies to `POST` (both the email and numeric-user arms) and to a resend that raises the capability, but never to `DELETE`. **Granting by email:** - If the email belongs to an **existing account that has verified that address**, it is granted directly — the response carries the resolved `user`, and the grant appears in the list as `state: active`. A new grant sends that person an email with a direct link to the File Share (granting by `user` does the same); changing the capability of an existing grant sends nothing. - If the email has **no account yet**, or belongs to an account that has not verified it, a pending **invitation** to that address is created and an invite email is sent. The grant appears in the list as `state: pending` (with `user: null`) and **activates automatically** when that person signs up with the invited address — they then hold exactly the capability you granted. The response carries the new pending `grant` in the **same shape as a grants-list row** (so it folds straight into the rendered access list). - Re-granting a pending email at a **different** capability updates the pending invitation to the new capability. - Granting a now-registered, verified email directly **supersedes** any pending invitation for that address (the pending entry is replaced by the active grant). **Revoking by email (DELETE):** - Revokes an **active** grant for that address, and/or **cancels** a still-`pending` invitation for it. Cancelling a pending invitation confers nothing, so it does not emit an access-revoked event. A DELETE that matches neither is an idempotent success. **Response (POST by `user`, or DELETE):** `{"result": true}` **Response (POST by registered `email`):** ```json { "result": true, "user": { "id": "9876543210987654321" } } ``` **Response (POST by unregistered `email` — invitation created):** The pending grant is returned in the **same shape as a grants-list row** (`user` is `null` for an unclaimed invitee; the internal invitation/account id is never exposed): ```json { "result": true, "grant": { "user": null, "name": null, "email": "newperson@example.com", "capability": "view", "state": "pending", "created": "2026-04-27 16:37:29 UTC", "expires": "2026-05-27 16:37:29 UTC" } } ``` **Error Responses:** | Error Code | HTTP Status | Cause | |------------|-------------|-------| | `1605 (Invalid Input)` | 406 | `user` is not a valid user id / the user does not exist; `email` is malformed or cannot receive access; both `user` and `email` (or neither) were supplied; or `capability` is missing on a grant | | `143671` | 403 | "External invitations are not permitted here by policy." A new grant, or a broadening of an existing one, to an external person. `params.reason` = `external_invites_denied` — see *Collaboration Policy* above. | | `184840` | 500 | The policy could not be evaluated (transient — retry). | --- ## Workspace Discovery ### List All Workspaces ``` GET /current/workspaces/all/ ``` Lists all workspaces the user has joined or can access across all organizations. Supports optional offset-based pagination. **Auth:** JWT required. **Query Parameters:** | Query Parameter | Type | Required | Description | |------------------|---------|----------|------------------------------------------------------------------| | `limit` | integer | No | Number of results to return per page, max `500`. The default of `100` applies only once `limit` or `offset` is present; with neither, every result is returned. An invalid value is rejected with HTTP 406 (`1605`). | | `offset` | integer | No | Number of results to skip. Default `0`, must be `0` or greater. | Pagination is **opt-in**: it activates only when the request includes `limit` and/or `offset` (an explicit `offset=0` counts as a request). When neither parameter is present, the response is unchanged from before — every accessible workspace, and no `pagination` key. **curl Example:** ```bash curl -X GET "https://api.fast.io/current/workspaces/all/" \ -H "Authorization: Bearer {jwt_token}" ``` **curl Example (paginated):** ```bash curl -X GET "https://api.fast.io/current/workspaces/all/?limit=50&offset=0" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "workspaces": [ { "id": "1234567890123456789", "name": "Engineering Team", "folder_name": "engineering", "description": "Main engineering workspace", "accent_color": {"color": "#0066CC", "opacity": 100}, "logo": "https://assets.fast.io/1234567890123456789/logo.png", "closed": false, "archived": false, "perm_join": "Member or above", "perm_member_manage": "Admin or above", "created": "2023-01-15 10:30:00 UTC", "updated": "2024-01-20 14:45:00 UTC", "user_status": "joined", "org_domain": "acme-corp" } ] } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `workspaces` | array | Array of workspace objects | | `[].id` | string | 19-digit workspace profile ID | | `[].name` | string | Display name | | `[].folder_name` | string | URL-safe folder identifier | | `[].description` | string or null | Description | | `[].accent_color` | object or null | Brand accent color (`{color, opacity}`) | | `[].logo` | string or null | Logo asset URL | | `[].closed` | boolean | Whether closed | | `[].archived` | boolean | Whether archived | | `[].perm_join` | string | Who can join | | `[].perm_member_manage` | string | Who can manage members | | `[].created` | string | Creation timestamp | | `[].updated` | string | Last update timestamp | | `[].user_status` | string | `"joined"` or `"available"` | | `[].org_domain` | string | Parent organization domain | | `pagination` | object | Present only when the request included `limit` and/or `offset` | | `pagination.total` | integer | Total number of accessible workspaces after filtering, across all pages | | `pagination.limit` | integer | The `limit` applied to this page | | `pagination.offset` | integer | The `offset` applied to this page | | `pagination.has_more` | boolean | Whether more workspaces exist beyond this page | **Notes:** - Spans all organizations the user belongs to. - Workspaces from orgs without active subscriptions are filtered out. - Pagination uses the same `limit`/`offset` parameters and `pagination` object as the shares listing but, unlike it, applies only when requested. - An out-of-range or non-numeric `limit` (valid `1`-`500`), or a negative or non-numeric `offset`, is rejected with an invalid-input error (`1605`). --- ### List Available Workspaces ``` GET /current/workspaces/available/ ``` Lists workspaces the user can join but has not yet joined. Useful for discovery UI. **Auth:** JWT required. **curl Example:** ```bash curl -X GET "https://api.fast.io/current/workspaces/available/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** Same structure as List All Workspaces (without `user_status` or `pagination`), but only includes un-joined workspaces. --- ### Check Workspace Name ``` GET /current/workspaces/check/name/{org_id}/{name}/ ``` Checks if a workspace folder name is already in use. Useful for real-time form validation. **Auth:** JWT required. Membership (Member or above) of the org in the path required. 2FA required. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{org_id}` | string | Yes | 19-digit ID of an org you are a member of | | `{name}` | string | Yes | The folder name to check | **curl Example:** ```bash curl -X GET "https://api.fast.io/current/workspaces/check/name/1234567890123456789/engineering/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response -- Name Available (202 Accepted):** ```json { "result": true } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "An invalid workspace folder name was supplied." | Invalid name format | | `10073` | 406 | "The supplied workspace folder name is already in use." | Name taken | | `1680 (Access Denied)` | 401 | "Appropriate access is not granted to this Org." | Not an org member | **Notes:** - Checks globally across all workspaces, not just the current org. - Returns `202 Accepted` (not `200 OK`) when the name is available. --- ### List Workspaces in Org ``` GET /current/org/{org_id}/list/workspaces/ ``` Lists workspaces within a specific organization. Paginated. A workspace is listed when **either** the caller is a direct member of it, or the caller is an org member whose org permission level satisfies that workspace's join permission (`perm_join`). These are alternatives, not requirements, so the list contains both the workspaces the caller has joined and the ones they are entitled to join. Users who are not org members see only workspaces where they are a direct member. Each row reports the caller's own standing in `workspace_level` (`owner`, `admin`, `member`, `guest`, `viewer`, `none`) at the `standard` and `full` output levels; `?output=terse` omits it. A row the caller can see but has not joined reports `none`. Read this field to determine membership — do not infer it by subtracting `/current/workspaces/available/` from this list. When the caller's level cannot be determined, `workspace_level` is **omitted** rather than reported as `none`, so `none` always means an authoritative "not a member". **Auth:** JWT required. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{org_id}` | string | Yes | 19-digit numeric organization ID | **Query Parameters:** | Name | Type | Default | Description | |------|------|---------|-------------| | `limit` | integer | 100 | 1-500, items per page | | `offset` | integer | 0 | Items to skip | | `archived` | string | `"false"` | `"true"` for archived, `"false"` for active | **curl Example:** ```bash curl -X GET "https://api.fast.io/current/org/1000000000000000001/list/workspaces/?limit=50&offset=0" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "workspaces": [ { "id": "1234567890123456789", "folder_name": "engineering", "name": "Engineering Team", "description": "Main project workspace" } ], "pagination": { "total": 5, "limit": 50, "offset": 0, "has_more": false } } ``` **Access Levels:** | Role | Visibility | |------|-----------| | Org Owner | Direct memberships, plus workspaces whose `perm_join` admits an owner | | Org Admin | Direct memberships, plus workspaces whose `perm_join` admits an admin | | Org Member | Direct memberships, plus workspaces whose `perm_join` admits a member | | External User | Only workspaces where they are a direct member | No role sees a workspace set to `No one can join automatically` unless they are a direct member of it, and an admin does not see a `Only Org Owners` workspace unless they are a direct member. A removed or expired org membership confers no visibility. --- ## Cloud Sync ### Enable Cloud Sync ``` POST /current/workspace/{workspace_id}/cloud-import/enable/ ``` Enables cloud sync features for a workspace. If already enabled, returns success with a message indicating the current state. **Auth:** JWT required. Admin or Owner required. Requires the cloud sync billing feature. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` | string | Yes | 19-digit numeric workspace ID or `folder_name` | **curl Example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/cloud-import/enable/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "message": "Cloud import features enabled", "cloud_import": true } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1680 (Access Denied)` | 401 | "Appropriate access is not granted to this Workspace." | Not admin or owner | | `1695 (Upgrade Required)` | 402 | "Access to cloud_import requires an upgraded plan." | Cloud import not included in billing plan | | `1610 (Internal Error)` | 500 | "Failed to enable cloud import features" | Internal failure | --- ### Disable Cloud Sync ``` POST /current/workspace/{workspace_id}/cloud-import/disable/ ``` Disables cloud sync features for a workspace. If already disabled, returns success with a message indicating the current state. **Auth:** JWT required. Admin or Owner required. Requires the cloud sync billing feature. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` | string | Yes | 19-digit numeric workspace ID or `folder_name` | **curl Example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/cloud-import/disable/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "message": "Cloud import features disabled", "cloud_import": false } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1680 (Access Denied)` | 401 | "Appropriate access is not granted to this Workspace." | Not admin or owner | | `1695 (Upgrade Required)` | 402 | "Access to cloud_import requires an upgraded plan." | Cloud import not included in billing plan | | `1610 (Internal Error)` | 500 | "Failed to disable cloud import features" | Internal failure | --- ### When the Plan Stops Including Cloud Sync A workspace whose plan no longer includes cloud sync keeps everything it has already imported. Its sources move to **`suspended_plan`** at their next scheduled check: the imported files, the import folder and the link to the remote folder are all kept, and only scheduling stops — nothing syncs in and nothing writes back while a source sits there. Nothing has to be repaired. A source in `suspended_plan` resumes on its own at the next check once the workspace is back on a plan that includes cloud sync; there is no call to make and no reconnect to redo. Read it as its own state rather than as an idle healthy source: it is reported in the source's `status` like any other value, and a source that has stopped syncing because of the plan looks identical to a quiet one in every other field. --- ### When the Workspace Is Deleted Deleting a workspace — or closing the org that owns it — does not disconnect its cloud-sync sources, because a deleted workspace can be restored for a while. Instead each source moves to **`suspended_workspace`** at its next scheduled check: the imported files, the import folder and the link to the remote folder are all kept, and only scheduling stops — nothing syncs in and nothing writes back while a source sits there. The check that found the workspace deleted is recorded once as a failed job; no further jobs run for the source while it is suspended. If the workspace is restored, the source resumes on its own within about a day: it goes back to `synced` and syncs at the next opportunity, with the same files and the same connection — nothing is re-imported. There is no call to make and no reconnect to redo. If the workspace is never restored, its sources are removed together with the rest of the workspace when the deletion becomes permanent. **Deleting a workspace never revokes a connected account that serves the rest of the organization.** A connected account belongs to its owner within the organization (see *One Connected Account per Organization* below), so it stays connected even when the workspace it was first connected from is deleted, and its sources in the organization's other workspaces keep syncing. When the deletion becomes permanent, only accounts that served this workspace alone are revoked — those connected in a workspace with no organization. **Closing the organization** revokes every account connected in it once the organization's own deletion becomes permanent, after its workspaces. A source in `suspended_workspace` cannot be resumed or refreshed by hand (`update/` with `action: resume` and `refresh/` both answer **409**); it can still be paused, or disconnected. --- ### When the Org or Workspace Policy Restricts Cloud Sync See *Cloud Sync Policy* above for the org `cloud_sync` envelope and the workspace `cloud_sync_mode` ceiling. Two outcomes, driven by the two halves of that policy: - **`enabled = false`** parks every source in the workspace at **`status: "suspended_policy"`** — a new value alongside `suspended_plan`, with the same "nothing to repair" behavior: files, the graft and the remote link are all kept, and the source resumes on its own once the policy re-enables sync. The policy is resolved for the member who owns each source's connected account, so a per-member override parks only that member's sources. - **`mode = "read"`** changes **no** source status. Inbound sync continues; only outbound write-back is affected — see *Write-Back* below for how a queued push behaves under it. Every cloud-sync source object (list, details, and the `sources/create/` and `details/{source_id}/update/` responses) carries `effective_access_mode` and `effective_access_mode_reason` beside `access_mode` / `enforced_access_mode`: the actual behavior for the calling principal once the policy is applied, in the source's own `read_only` / `read_write` vocabulary (not the policy's `read` / `read_write` vocabulary — the two are never conflated). It is the caller's own resolved policy met with the policy of the member who owns the source's connected account (the write-back runs under that account), then capped by the source's own `access_mode` — so a `read_only` source is never reported `read_write`, and a permissive caller still sees `read_only` when the owner's policy is `read`. `effective_access_mode_reason` is `null`, `cloud_sync_disabled` or `cloud_sync_read_only`. Both fields are `null` when the answer could not be determined (the policy could not be read, or the caller or the account's owner could not be resolved); `null` is never permissive. **Timestamp format change.** The cloud-sync source object's `last_sync_at`, `next_sync_at`, `created` and `updated` are now emitted as `YYYY-MM-DD HH:MM:SS UTC` — the canonical API datetime format, and the format the write-back job's own `created`/`updated` already used. They previously omitted the ` UTC` suffix. A client that parses these fields with a fixed pattern must accept the suffix. --- ### A Plan With No Cloud Connections Refuses at the Door Some plans include cloud sync as a feature but allow **no cloud connections at all**. On those plans the whole connect flow is refused up front — the providers listing, `identities/provision/`, and the OAuth completion endpoint all answer: | `error.code` | HTTP Status | Message | Cause | |--------------|-------------|---------|-------| | `147688` | 412 | "Your plan does not include cloud connections." | The plan permits zero sync sources | **This is a settled answer, and the fix is an upgrade rather than a retry.** It is deliberately raised before an identity is created and before a consent screen is shown, so a user is never asked to hand a cloud credential to an account that could not use it. On the OAuth completion it is raised before the authorization code is redeemed, so a plan that lapsed while the consent screen was open leaves nothing stranded at the provider — start again from `provision` after upgrading. Do not confuse it with `134248` → **503** on the same endpoints, which means the plan could not be *determined* right now and is worth retrying shortly. Gate on the HTTP status first: 412 is the plan answer, 503 is "ask again". --- ### One Connected Account per Organization A provider identity (a connected cloud account) belongs to a **user within an organization**, not to the workspace it was first connected from. A user has at most one identity per provider in an organization, and it can be used in **every workspace of that organization where the user is a Member** — connect Dropbox once, then browse, estimate and create sources from it in any of those workspaces, each through that workspace's own `/current/cloudsync/workspace/{workspace_id}/...` routes. A different organization has its own set: an identity is never usable from another organization's workspace, and naming one there is refused with the same error that route gives an identity that does not belong to the workspace (for example `178568` → **401** on `identities/{identity_id}/`). A workspace with no organization is its own scope, so its identities serve that workspace only. - **Provisioning from a second workspace returns the existing identity.** `identities/provision/` called in workspace B for a provider the caller already connected from workspace A answers with that same identity (same `id`, `already_exists: true`) instead of creating a second one. An `error` or `revoked` one is reconnected in place, keeping its `id`. `identity.profile_id` names the workspace the identity was first connected from, so it can differ from the `{workspace_id}` in the URL — that is expected, and it does not limit where the identity can be used. - **The identity cap is per organization:** 4 active identities per user per organization. - **Using an identity needs Member in the workspace you act in, as well as ownership.** `drives/`, `drives/refresh/`, `sources/discover/` and `sources/estimate/` refuse a caller below Member in that workspace with `197845`, `122987` or `108624` → **401** ("This action requires workspace member permissions"), even when they own the identity; `sources/create/` already required Member. A discovery or estimate job runs against the workspace it was started from, and ends `failed` if the owner is no longer a Member there when it runs. - **What `identities/` lists.** The caller's own identities in the organization (when the caller is a Member of this workspace), plus every identity a source **in this workspace** syncs through. Another member's identity that feeds nothing in this workspace is not listed. `revoked` identities are still left out. Order (newest first) and response shape are unchanged. - **`identities/{identity_id}/`** answers for the identity's owner, and for anyone else only when a source in this workspace syncs through that identity; otherwise `178568` → **401** ("Identity does not belong to this workspace"). A `revoked` identity stays readable, so a client can poll it after a revoke. - **Only the owner sees the account address.** On the list and on `identities/{identity_id}/`, `identity_email`, `owner_user_id` and the provider-internal properties are shown in full to the identity's owner only. Everyone else — **workspace admins included** — gets a masked address (the first three characters of the local part, then `***@` and the domain, e.g. `cas***@contoso.com`) and no owner-only fields. The `provider_identity_created` and `provider_identity_revoked` events carry the masked address too, for every reader. - **Revoking is the owner's alone, and it reaches every workspace of the organization.** See *Revoke Provider Identity* below. - **A folder is reserved per workspace.** The same remote folder can be connected once in each workspace of the organization; see *Recognising a Folder That Was Renamed or Moved* below. **Fail-closed errors.** When the workspace's organization, or the identities its sources use, cannot be read, these calls refuse rather than guess — retry shortly: | Error Code | HTTP Status | Message | Endpoint | |------------|-------------|---------|----------| | `174500` | 500 | "Import operations temporarily unavailable" | `identities/provision/` | | `199242` | 500 | "Import operations temporarily unavailable" | `identities/` | | `138123` / `136456` | 500 | "Failed to retrieve provider identities" | `identities/` — the identities this workspace's sources use could not be read | | `123152` | 500 | "Import operations temporarily unavailable" | `identities/{identity_id}/` — the workspace's sources could not be read | --- ### When a Member Leaves or Is Removed Leaving a workspace and being removed from it by an admin have the same effect on cloud sync, and so do leaving and being removed from the organization. - **Leaving or being removed from a workspace disconnects only that member's sources in that workspace** — every source there that syncs through an account the member connected. Each moves to `disconnect_pending` with `access_mode` `read_only` (nothing syncs in or writes back, and queued write-backs are cancelled), then to `disconnected` once the background disconnect runs in `keep` mode. The imported files stay in the workspace as ordinary content. Sources other members connected are not touched. - **The connected account stays connected for the member's other workspaces.** It is not revoked, and their sources in the organization's other workspaces keep syncing. - **Being removed from the organization** disconnects the member's sources in every workspace of the organization and revokes the accounts they connected in it (`revoking`, then `revoked`). - **In a workspace with no organization** the workspace is the account's whole scope, so leaving or being removed from it also revokes the accounts the member connected there. - **The cleanup runs in the background** after the membership change. If a pass is interrupted, disconnecting the member's sources is completed automatically later. Sync and write-back already refuse to run for a departed member in the meantime. - **Warn before it happens.** `GET .../sources/?owner=me` (a member about to leave) or `?owner={user_id}` (an admin about to remove someone) lists exactly the sources that leaving or removal from this workspace will disconnect — see *Listing Sources by Connector Owner* below. For an organization removal, list each of the organization's workspaces. - **To keep a folder syncing**, another member connects their own account and creates a new source for it; a departed member's source cannot be revived. --- ### Listing Sources by Connector Owner `GET /current/cloudsync/workspace/{workspace_id}/sources/` accepts an optional `owner` query parameter that narrows the list to the sources whose **connected account** belongs to one member — exactly the sources that member's removal from this workspace would disconnect. Use it to warn a member before they leave, or an admin before they remove someone. | Value | Who may send it | |-------|-----------------| | `owner=me` | Anyone who can list the workspace's sources | | `owner={user_id}` | The caller's own id behaves like `me`; **another member's id needs workspace admin** | - **Omitting `owner` returns the full list exactly as before.** - **The filtered list omits `disconnected` sources**, because it lists what removal would disconnect. Every other status is included, including sources already being disconnected. The unfiltered list still shows `disconnected` sources. - **Same response shape and pagination.** The filtered list pages with the same `limit` / `offset`, and `pagination.total` is the number of matching sources. Ownership is the owner of the connected account the source syncs through, not the member who created the source. **Ownership fields on every source.** Each source in the list response — filtered or not — carries: | Field | Type | Description | |-------|------|-------------| | `owner` | object or null | The member who owns the connected account the source syncs through: `{ "user_id": string, "name": string }`. `name` is the member's given and family name and can be an empty string. `null` when that user no longer exists or the connected account cannot be read | | `account_email_masked` | string or null | The connected account's address, masked for **every** caller including its owner (the first three characters of the local part, then `***@` and the domain, e.g. `cas***@contoso.com`). The owner sees the full address on `identities/`. `null` when the connected account has no address or cannot be read | ```json { "id": "{source_id}", "status": "synced", "is_owner": false, "owner": { "user_id": "{user_id}", "name": "Casey Jordan" }, "account_email_masked": "cas***@contoso.com" } ``` **Errors:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `166464` | 406 | "The owner filter must be \"me\" or a user id" | `owner` is empty, repeated as an array, or not `me` or a user id | | `129298` | 403 | "Only a workspace admin can list another member's sources" | `owner` names another member and the caller is not a workspace admin | | `115708` | 500 | "Failed to retrieve import sources" | The filtered list could not be read — retry shortly | --- ### Provision Provider Identity ``` POST /current/cloudsync/workspace/{workspace_id}/identities/provision/ ``` Starts a browser OAuth connection for the specified cloud provider and returns immediately. **All four providers behave identically**: the connection is completed by the person signing in to their OWN cloud account. There is no longer a background-provisioning family — Google Drive and Box used to create a robot account inside Fastio's own Google and Box tenants and ask you to share a folder with it, and that model has been removed entirely. **Every provider — connected by the user, in a browser.** The response carries `status: "provisioning"` plus an `authorize_url`, and the identity becomes `"active"` only once the user completes the consent screen and your app posts the result back — see *Complete a Browser OAuth Connect* below. Nothing is provisioned in the background and there is no address to share a folder with. **Provisioning requires workspace Member** on every provider. A Viewer is refused: the resulting grant is only ever usable by someone who can create a source, so issuing one to a Viewer would mint a live credential its owner could never use. **One identity per user, provider and organization.** If the caller already has an identity for this provider anywhere in the workspace's organization, that identity is returned (`already_exists: true`) or reconnected in place rather than a new one created — see *One Connected Account per Organization* above. An identity in `error` or `revoked` state can be re-provisioned. A `provisioning` OAuth identity is returned as-is, **without** a fresh `authorize_url`, while its consent could still be live — so a user who abandoned the browser tab cannot immediately retry. That row is reclaimable: provision it again after about 30 minutes and a new `authorize_url` is issued, or revoke it and provision to recover sooner. Such an identity carries `properties.oauth_pending: true`, and that marker is part of this contract — it is set by **every** provider, it is visible to non-owner callers, and it is cleared when the connect completes. It exists so a client can tell *"waiting for a person to finish a consent screen"* apart from *"the server is still working"*, which otherwise look identical: both report `status: "provisioning"`. Read it together with `authorize_url` — `authorize_url` is issued **only on the provision response** and never appears when polling the identity afterwards. So `provisioning` **+** `oauth_pending: true` **+** no `authorize_url` is terminal by construction: no amount of polling will produce a link, and the connection must be restarted rather than waited on. **Auth:** JWT required. The identity is owned by the caller, and the floor is **Member on every one of the four providers** — there is no per-provider carve-out, and a Viewer who could connect before is refused now. See *Provisioning requires workspace Member* above. **Request body (JSON):** > Send `Content-Type: application/json`. This endpoint parses a **JSON** body and > rejects a form-encoded one with `127872 Invalid JSON in request body`. | Field | Type | Required | Description | |-------|------|----------|-------------| | `account_type` | string | No | **OneDrive only.** `work` (default) or `personal`. Selects which kind of Microsoft account the consent screen accepts and sizes the permissions requested to it: a personal Microsoft account has no SharePoint and cannot consent to any `Sites.*` permission, so requesting one would fail the whole consent. Ignored by the other providers. Omit it and you get `work`, which is the previous behaviour. | | `provider` | string | Yes | `google_drive`, `box`, `onedrive_business`, or `dropbox`. **Which of these a workspace may actually connect varies, and the plan is only part of what decides it — a provider can be unavailable to a workspace whose plan grants it** — read the workspace's providers endpoint rather than assuming all four | **Response (200 OK):** ```json { "result": true, "identity": { "id": "abc123...", "profile_id": "1234567890123456789", "provider": "google_drive", "identity_email": "provisioning-pending", "status": "provisioning", "created": "2026-07-23 16:37:29 UTC", "updated": "2026-07-23 16:37:29 UTC" }, "authorize_url": "https://accounts.google.com/o/oauth2/v2/auth?...", "instructions": "Open the authorization link to sign in and connect your own account." } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Unsupported provider" | Invalid provider name | | `197845`, `122987` or `108624` | 401 | "This action requires workspace member permissions" | Caller is below Member — on any of the four providers. One message, three call sites: `197845` when the permission level is below Member, `122987` when the caller has no user id, `108624` when the permission lookup itself failed | | `147688` | 412 | "Your plan does not include cloud connections." | The plan permits zero sync sources — settled; upgrade rather than retry | | `1654 (Internal Error)` | 500 | "Maximum of 4 provider identities per user per organization" | Limit reached — the cap counts the caller's identities across every workspace of the organization | | `179470` | 503 | "Identity provisioning in progress, please retry" | Another provision for this owner is still in progress — retry shortly | | `174500` | 500 | "Import operations temporarily unavailable" | The workspace's organization could not be determined — retry shortly | | `1700 (Forbidden)` | 403 | "Cloud sync is turned off for this organization." | The org or workspace cloud-sync policy resolves to `enabled: false` for the caller. `params.reason` = `cloud_sync_disabled`. See *Cloud Sync Policy* | | `1693 (Temporarily Unavailable)` | 503 | "Cloud sync policy is temporarily unavailable. Please try again shortly." | The cloud-sync policy could not be read — retry shortly | --- ### Complete a Browser OAuth Connect ``` POST /current/cloudsync/oauth/{provider}/complete/ ``` Finishes a connection started by `provision`. `{provider}` is `dropbox`, `onedrive`, `box` or `google_drive` — **all four use this endpoint**. After the user consents, the provider redirects the browser to a route on the **app** origin — `https://go.{host}/imports/oauth/{provider}/callback` — which is a front-end route, not an API one. That route reads `code` and `state` out of its own URL and posts them here, on the signed-in user's session. All four providers work exactly this way; there is deliberately **no public callback endpoint** for any of them, and the previous Dropbox one (`GET /current/cloudsync/oauth/dropbox/callback/`) has been removed. The session is what binds the flow to a person. A `state` token identifies the connection, not the human holding it, so an `authorize_url` that gets forwarded to a colleague would otherwise attach *their* cloud account to *your* identity — whose owner could then read, and on a `read_write` source write, their files. Requiring the completion to arrive on the originating user's session closes that: **anyone else gets `1680 (Access Denied)`.** **Post the decline, too.** When the user refuses consent the provider sends back an `error` instead of a `code`; post `{ "state": …, "error": … }` so the identity is marked. Without it the identity sits `provisioning` with nothing coming to finish it. Authentication is checked *before* the `state` is consumed, so a session that lapsed during a slow consent returns `401` with both the state and the code still redeemable — refresh the session and post the same pair once more. State tokens are single-use and short-lived (about ten minutes, matched to the provider's own code lifetime); a replayed, expired, tampered or unknown one is rejected. **Auth:** JWT required. The caller must BE the user who started the connection. **Request body (JSON):** | Field | Type | Required | Description | |-------|------|----------|-------------| | `state` | string | Yes | The `state` value the provider handed back | | `code` | string | Conditional | The authorization code. Required unless `error` is sent | | `error` | string | Conditional | The provider's error value when the user declined. Required unless `code` is sent | **Response (200 OK):** ```json { "result": true, "identity": { "id": "abc123...", "profile_id": "1234567890123456789", "provider": "onedrive_business", "identity_email": "casey@contoso.com", "status": "active", "created": "2026-08-09 16:37:29 UTC", "updated": "2026-08-09 16:38:04 UTC" }, "connected": true, "connect_error_code": null } ``` `connected` and `connect_error_code` report the outcome directly, so the result does not have to be discovered by polling the identity afterwards. | `connect_error_code` | Meaning | |----------------------|---------| | `null` | Connected; `identity` is `active` | | `declined` | The user refused consent | | `exchange_failed` | The provider would not exchange the code | | `account_read_failed` | The code WAS exchanged, but the follow-up call asking the provider which account just connected failed. Distinct from `exchange_failed` because the sign-in itself worked; retrying is still the right suggestion | | `credential_store_failed` | The credential could not be stored | | `activation_failed` | The identity could not be activated | | `insufficient_scope` | **OneDrive only.** The tenant withheld a permission the importer cannot work without, so the signed-in user cannot fix it by retrying. The response also carries an `admin_consent_url` a tenant administrator can open | | `scope_not_granted` | Consent completed but omitted a permission the connection needs, and the user can fix it — for example, on Google Drive the user approved sign-in but left Drive access unticked. Reconnecting and approving that permission is the fix; no consent URL is returned | | `account_mismatch` | The consent was completed with a **different** cloud account than the one this identity was set up with. The reconnect is refused and the connection is not switched to the other account. To use another account, disconnect this identity and create a new connection | | `legacy_app_only` | **The identity predates per-user sign-in and can no longer be used.** It was created under the old model, where Fastio held a robot account in its own tenant. There is no path that can give such a row a per-user credential, so it fails closed rather than erroring deep inside a sync. **Clients should surface a reconnect prompt:** disconnect the identity and provision again to authorize your own account. Sources bound to it stop syncing until then | Request faults — a bad `state`, the wrong caller, an unknown identity — are ordinary `4xx` errors, not a `connect_error_code`. **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Invalid JSON in request body" | Body is not valid JSON | | `1605 (Invalid Input)` | 406 | "Missing or invalid connection state" | No `state` field | | `1605 (Invalid Input)` | 406 | "This connection link has expired or was already used. Start the connection again." | Unknown, replayed, expired or tampered `state` | | `1605 (Invalid Input)` | 406 | "Missing authorization code" | Neither `code` nor `error` was sent | | `1680 (Access Denied)` | 401 | "This connection was started by a different user." | The caller is not the user the state was issued to | | `1680 (Access Denied)` | 401 | "This connection state does not match the connection it names." | The state and the identity it names disagree | | `1609 (Not Found)` | 404 | "The connection being completed no longer exists." | The identity was removed while consent was open | | `1664 (Datastore Error)` | 500 | "Failed to load the connection being completed." | The identity could not be read, BEFORE the authorization was redeemed | | `1664 (Datastore Error)` | 500 | "The connection could not be verified after authorization; start a new connection." | The identity could not be read AFTER the authorization was redeemed | | `147688` | 412 | "Your plan does not include cloud connections." | The plan permits zero sync sources. Raised **before** the authorization code is redeemed, so the code is unspent and nothing is stranded at the provider — settled; upgrade and start again from `provision` | | `1610 (Internal Error)` | 500 | "This connection is no longer waiting to be completed." | The identity is no longer `provisioning` | | `1700 (Forbidden)` | 403 | "Cloud sync is turned off for this organization." | The cloud-sync policy was switched off while consent was open. `params.reason` = `cloud_sync_disabled`. Raised **before** the authorization code is redeemed. See *Cloud Sync Policy* | | `1693 (Temporarily Unavailable)` | 503 | "Cloud sync policy is temporarily unavailable. Please try again shortly." | The cloud-sync policy could not be read — retry shortly | **Three outcomes, and the MESSAGE tells them apart — the numeric code alone does not.** The 404 is settled: the identity is genuinely gone, and reposting the same `code` and `state` cannot bring it back — start again from `provision`. A `1664` reading *"Failed to load the connection being completed."* happens **before** the authorization is redeemed: the grant is still good and the `code` is unspent, so **post the same `code` and `state` again**. A `1664` reading *"The connection could not be verified after authorization; start a new connection."* happens **after** the authorization has already been redeemed — **the `code` is spent and reposting it will fail**, so start again from `provision`. Reposting a spent authorization does not just fail; it marks the pending connection as failed, which is worse than doing nothing. A completion that arrives for an identity already `active` is treated as success and returns the identity rather than an error — a double-posted callback is safe. --- ### Revoke Provider Identity ``` POST /current/cloudsync/workspace/{workspace_id}/identities/{identity_id}/revoke/ ``` Begins revocation of a provider identity. The provider-side deletion happens in the background; the endpoint returns immediately with `status: "revoking"`. Poll `GET .../identities/{identity_id}/` until `status` is `"revoked"`. If a revocation is already in progress for this identity, the endpoint returns an error. **It reaches every workspace of the organization.** One identity serves all of the organization's workspaces, so revoking it stops every source that syncs through it, wherever it lives: each is parked in `error` with the message "Provider identity revoked" (a `paused` source stays paused). `{workspace_id}` may be any workspace of the organization the owner can open — not only the one the identity was first connected from. To stop one folder in one workspace, disconnect that source instead. **Auth:** JWT required. **The identity's owner only** — a workspace admin is refused (`196524`); admins can still disconnect or delete the sources in their workspace. The credential must also cover the whole organization: a sign-in session, or an API key or token scoped to the organization with write access. A credential scoped to a single workspace is refused even for the owner (`175899`), because the revoke reaches the organization's other workspaces. In a workspace with no organization, a credential scoped to that workspace qualifies. **Response (200 OK):** ```json { "result": true, "identity": { "id": "abc123...", "profile_id": "1234567890123456789", "provider": "google_drive", "identity_email": "member@example.com", "status": "revoking", "created": "2026-07-23 12:00:00 UTC", "updated": "2026-07-23 16:37:29 UTC" } } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `176936` / `145733` | 503 | "Identity revocation already in progress" | Revoke already running — retry shortly | | `168196` | 503 | "This connection is busy. Try again in a moment." | Another operation on this identity (e.g. a concurrent OAuth completion) is still in progress — retry shortly | | `117111` / `126939` | 409 | "Identity is already revoked" | Settled — this identity is already revoked, and retrying will never change that | | `1654 (Internal Error)` | 500 | "Identity not found" | Unknown identity ID | | `144363` | 401 | "Identity does not belong to this workspace" | The identity belongs to another organization | | `196524` | 403 | "Only the owner of the connected cloud account can disconnect it" | The caller is not the identity's owner (workspace admins included). Settled — route the request to the owner | | `175899` | 403 | "This connected account is shared across the organization. Use a session or an organization-scoped API key to disconnect it." | The credential does not cover the organization — a workspace-scoped API key or token, or an organization-scoped one without write access. Settled for this credential — retry with a session or an organization-scoped key | --- ### Identity Statuses | Status | Description | |--------|-------------| | `provisioning` | The user has not finished the browser consent yet, on any provider. Polling will not advance it on its own — a person has to complete a consent screen | | `active` | Identity is ready. On all four providers `identity_email` is the connected account's own address. **OneDrive has one fallback**: a Microsoft account that reports no address at all gets a synthetic label of the form `onedrive-user:{16 hex characters}` instead, so an active identity is never left showing a pending placeholder. Treat the value as a display label, not an address to route mail to | | `error` | Provisioning or the browser connect failed; the identity can be re-provisioned | | `revoking` | Revocation is in progress; poll until `revoked` | | `revoked` | Identity has been revoked; the identity can be re-provisioned | --- ### List Identity Drives ``` GET /current/cloudsync/workspace/{workspace_id}/identities/{identity_id}/drives/ ``` Lists the document libraries this identity can reach — the connected user's own OneDrive, plus the SharePoint libraries their account has access to. **OneDrive for Business only** — every other provider returns an error, because no other provider makes the caller choose a drive. A OneDrive identity is one connected Microsoft account — **work or school, or personal**. A work or school account can usually reach several libraries with no meaningful default among them; a personal account has exactly one drive and no SharePoint. Which library an import uses is therefore a **per-source** choice, made from this catalog and fixed when the source is created. This endpoint answers from stored rows and never contacts the provider. To rebuild the catalog, call the refresh endpoint below. **Auth:** JWT required. **The identity owner only — on every provider.** Every catalog enumerates the connecting person's own cloud account, so a workspace admin browsing one would be reading that member's personal account; there is no admin override on any of the four. Admins keep the power to disconnect or delete a source in their workspace (revoking the identity itself is the owner's alone); what they cannot do is browse it. The owner must also be a Member of the workspace in the URL. The same gate covers the discovery RESULT — polling a discovery job returns the folder listing, so it is owner-only too. **Query Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `site_path` | string | No | Return only the libraries discovered through this SharePoint site path | | `limit` | int | No | Page size (default 50, max 100) | | `offset` | int | No | Page offset (default 0) | **Response (200 OK):** ```json { "result": true, "identity_id": "abc123...", "provider": "onedrive_business", "drives": [ { "drive_id": "b!X9tKqL3mEkO7nQfR2sVwYzA1bCdEfGhIjKlMnOpQrStUvWxYz0123456789AbCdEf", "drive_type": "documentLibrary", "name": "Documents", "site_name": "Marketing", "web_url": "https://contoso.sharepoint.com/sites/Marketing/Shared%20Documents", "is_default": false, "site_path": null } ], "drives_state": "ready", "drives_refreshed_at": "2026-08-03 16:42:11 UTC", "drives_error": null, "requires_site_path": false, "site_path": null, "pagination": { "limit": 50, "offset": 0, "total": 1 } } ``` `site_name` is worth rendering: nearly every SharePoint site has a library called "Documents", so the library name alone is often ambiguous. **Branch on `drives_state`, not on the length of `drives`.** An empty catalog is the NORMAL state on a first connection — nothing is enumerated until a refresh is asked for — so `drives: []` on its own cannot say whether nobody has looked yet, an enumeration is running, access was refused, or the account genuinely reaches no libraries. | `drives_state` | Meaning | What the caller does | |----------------|---------|----------------------| | `never_refreshed` | Nobody has enumerated this identity yet | Call the refresh endpoint | | `refreshing` | An enumeration is queued or running | Poll until it changes | | `ready` | The catalog holds at least one library | Let the user pick one | | `empty` | The enumeration completed and found no libraries | The connected account reaches none; check the account, or name a site | | `requires_site_path` | The enumeration could not prove it saw everything reachable, and found nothing to offer | Refresh again with `site_path` | | `permission_denied` | Something the enumeration NAMED was refused | A site path will not fix this; the account needs access, or reconnect with fuller consent | | `failed` | The enumeration itself failed (provider outage, token error) | Retry; `drives_error` carries the detail | **`requires_site_path` is not a permission error.** There is no Microsoft API that lists every site a signed-in user can reach — only a search that is permission-trimmed and not guaranteed exhaustive. The state means *"we cannot prove this list is complete, and it is empty; name a site and we will look there"*. It is raised only when nothing was refused, so it is genuinely distinct from `permission_denied`, which is always a refusal of something specific and is not fixed by supplying a path. A scoped refresh never produces it. `requires_site_path` is a convenience alias of `drives_state === "requires_site_path"`. **A partial consent is not an error.** If the user grants access to their files but withholds the SharePoint permission, the connection succeeds and the identity becomes `active` — the catalog simply covers their own OneDrive and no site libraries. Treat that as a working connection with a smaller catalog, not a failed one. `drives_error` is normally null on success, with ONE exception: a `ready` catalog that was cut off at an internal ceiling carries a truncation note. Those libraries are real and selectable — the list is merely incomplete — so show it as a warning beside a usable list rather than as a failure. Note that `ready` and `empty` are derived from the rows returned in the same response, so the state and the list can never disagree. The other five describe how the last enumeration ENDED and are reported as recorded. **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | " does not use drive selection" | Provider is not OneDrive for Business | | `1609 (Not Found)` | 404 | "Provider identity not found for this workspace" | Unknown identity, or one that belongs to another organization | | `1680 (Access Denied)` | 401 | "Only the owner of the connected cloud account can import its folders" | Not the identity owner (a workspace admin is also refused) | --- ### Refresh Identity Drives ``` POST /current/cloudsync/workspace/{workspace_id}/identities/{identity_id}/drives/refresh/ ``` Re-enumerates the drive catalog from the provider. **OneDrive for Business only.** **Asynchronous, and it returns no job id.** The response comes back immediately with `drives_state: "refreshing"`; poll `GET .../drives/` until the state leaves `refreshing`. The state is recorded before the work is queued, so a client that reloads mid-refresh still sees `refreshing` on a cold read. One refresh at a time per identity: a second call while one is genuinely in flight is rejected. That block expires on its own, so an interrupted refresh cannot lock the catalog permanently. A refresh scoped with `site_path` replaces only the libraries belonging to that site and leaves the rest of the catalog alone, so an account whose sites have to be named one at a time can build its catalog across several calls. An unscoped refresh looks at the connected account's own OneDrive first, then the SharePoint sites it can find; it replaces only what it actually covered, so a run that found no sites does not delete the site libraries an earlier scoped refresh recorded. An enumeration that fails part-way — a provider outage rather than a denial — leaves the previous catalog in place rather than replacing it with a partial one. **Auth:** JWT required. **The identity owner only** — same rule as the list endpoint above, and for the same reason. **Request Body (optional):** ```json { "site_path": "contoso.sharepoint.com:/sites/Marketing" } ``` | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `site_path` | string | No | Enumerate one named SharePoint site instead of everything the account can find | **Response (200 OK):** ```json { "result": true, "identity_id": "abc123...", "provider": "onedrive_business", "drives_state": "refreshing", "drives_refreshed_at": "2026-08-03 16:40:02 UTC", "requires_site_path": false, "site_path": null, "message": "Drive refresh started. Poll GET .../identities/abc123.../drives/ until drives_state leaves \"refreshing\"." } ``` `drives_refreshed_at` is the PREVIOUS refresh's timestamp — this one has not finished — or null if none has ever completed. Use `drives_state` to detect a never-refreshed identity, not a null timestamp. **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | " does not use drive selection" | Provider is not OneDrive for Business | | `1609 (Not Found)` | 404 | "Provider identity not found for this workspace" | Unknown identity, or one that belongs to another organization | | `1680 (Access Denied)` | 401 | "Only the owner of the connected cloud account can import its folders" | Not the identity owner (a workspace admin is also refused) | | `142434` | 503 | "A drive refresh is already in progress for this identity" | A refresh is in flight — retry shortly | | `1610 (Internal Error)` | 500 | "Provider identity is not active" | Identity is provisioning, revoked, or errored | --- ### Selecting a Drive on a Source `drive_id` is **required** when creating or discovering against a OneDrive for Business identity, and is not accepted for any other provider. - **Discover** (`POST .../sources/discover/`) takes `drive_id` so the folders it returns come from the library the user actually chose. - **Create** (`POST .../sources/create/`) takes the same `drive_id`. It is validated against that identity's own catalog, so a caller cannot reach a library it was never granted by supplying an arbitrary id. The library's type, name and site name are recorded from the stored row, never from the request. - **Update** (`POST /current/cloudsync/details/{source_id}/update/`) **rejects** `drive_id`. The library is part of what the source IS: repointing an existing source would make the recorded origin of every file it has already imported wrong. Create a second source instead. Pass `drive_id` exactly as the drives endpoint returned it — it is an opaque provider string, not a Fastio id, and must not be reformatted or validated client-side. **Watching progress.** A single import job is at `GET /current/cloudsync/details/{source_id}/jobs/{job_id}/`, and one source's history at `GET /current/cloudsync/details/{source_id}/jobs/`. For a workspace-wide view — every sync running right now, alongside the other async work — poll `GET /current/workspace/{workspace_id}/jobs/status/`, whose `import_sync` list reports the same progress fields. Discovery and estimate jobs appear only under `/cloudsync/`, never in the workspace-wide view: browsing your own cloud account is not workspace activity. **Search indexing in that same view.** The `upsert_file` entry is an object (or `null`) carrying `active`, `status` (`queued` · `processing` · `completed` · `failed`), `node_id`, `node_name`, `file_count`, `processed_count`, `failed_count`, `current_file`, `current_file_units_indexed`, `current_file_units_total`, `progress_percent`, `started_at`, `updated_at` and `completed_at`. The two `current_file_units_*` fields are WITHIN-FILE progress in the units the indexer measured that file in (pages, for a document): a large document is ONE file indexed over several passes, so those two move while `processed_count` and `progress_percent` stand still. Both are always present and are `null` when nothing was measured and once the file is finished or failed. See the AI reference for the full field table and an example. **Who may do this.** Creating a source is restricted to the **identity owner** for every provider, with no workspace-admin override: only the person whose cloud account is connected may graft its folders into a workspace, and they must hold Member on that workspace. For Dropbox and OneDrive for Business, discovery is owner-only as well — it browses that person's own cloud account. Admins are refused here on purpose, and keep the power to remove a source: disconnecting or deleting a source remains owner-or-admin. Revoking the identity itself is owner-only (see *Revoke Provider Identity*). The identity may have been connected from any workspace of the organization; what matters is that the caller owns it and is a Member of this workspace. | Error Code | HTTP Status | Reason | Cause | |------------|-------------|--------|-------| | `1605 (Invalid Input)` | 406 | `drive_required` | OneDrive source without a `drive_id` | | `1605 (Invalid Input)` | 406 | `drive_unknown` | `drive_id` is not in this identity's catalog | | `1605 (Invalid Input)` | 406 | `drive_not_supported` | `drive_id` sent for a provider that does not use one | | `1605 (Invalid Input)` | 406 | `drive_immutable` | `drive_id` sent to the update endpoint | | `1610 (Internal Error)` | 500 | `drive_lookup_failed` | The catalog could not be read; retry | --- ### Browsing Below the Provider Root `POST .../sources/discover/` accepts an optional `remote_path`: the remote folder to enumerate. Omit it — or send an empty string, or `null` — to enumerate the provider root, which is the historical behaviour and stays the default. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `remote_path` | string | No | Remote folder to enumerate, e.g. `/Clients/Northwind`. Omit for the provider root. Maximum 2048 characters, 4096 bytes overall, and 255 bytes for a single path component | Discovery returns **folders only, one level down**. It does not walk the tree: a client expands a level at a time, passing the folder it wants to open back as `remote_path` on the next call. The response shape, the job id, and the polling endpoint are unchanged. Two paths are two different questions, and they do not collide. A discovery already in flight blocks only another discovery of the same workspace, provider, drive **and** path — so `/Clients` and `/Clients/Northwind` can be browsed at the same time, while asking the same question twice is still deduplicated into the first. If the same scope was tried recently and failed, and is still within its short retry window, the call answers `503` ("A recent discovery for this folder has not finished retrying. Try again shortly.") rather than `200`. This is distinct from the in-flight case above — no job is currently running to hand back — and it is a normal, expected retry signal: wait briefly and call discover again. **The shape of the path is checked before the call is accepted; whether the folder exists is not.** Characters, directory traversal and the length limits below are all applied synchronously, so a malformed `remote_path` comes back as `1605` straight away. Only the provider-side question — does this folder exist, and can it be listed — is deferred: that is reported by the discovery **job**, not by this endpoint, so the call returns `200` with a job id and that job finishes `failed` with the reason in its `error_message`. Check the job, not just the HTTP status. **Some folders exist in a cloud account but cannot be listed at all.** When discovery can confirm one of these on a root listing, it leaves it out of the results rather than offering a dead end — OneDrive's "Personal Vault" is the known case. That check is best-effort, so an unconfirmed unlistable folder can still be offered like any other. Either way — offered and then chosen, or named directly with `remote_path` — the discovery job for that folder fails immediately, without retrying, and its `error_message` says the provider refused to list it. Such a folder cannot be imported. On Google Drive, a shortcut whose target has since been deleted is omitted from listings the same way. `remote_path` is also an input on `POST .../sources/create/`, where it names the remote folder the source imports. On discover it names the folder to browse; the two are separate calls and neither implies the other. **What `remote_path` accepts, on BOTH calls.** The two endpoints apply the same rule, so the picker can never offer a folder that create then refuses. A `remote_path` may be any name the provider can hold — `Sales & Marketing`, `John's Files`, `Q3 Reports (Final)`, `#general`, `100% Done`, emoji, and any script, in either Unicode normal form — **except** the characters providers themselves reject: `*`, `?`, `|`, `<`, `>`, the null byte, and control characters. Directory traversal (`..` as a whole component, in either slash direction) is refused. Length limits are 2048 characters overall, 4096 bytes overall, and 255 bytes for a single path component. **It is stored exactly as sent, including leading and trailing spaces.** `remote_path` is an address, not a label: providers permit edge whitespace in a folder name, so trimming it would name a different folder than the one the listing offered. Post back the `remote_path` a discovery entry gave you, byte for byte. A value that is only whitespace counts as not given — on discover that enumerates the provider root, and on create it is refused as a missing field. `remote_name`, which is a display label rather than an address, is still trimmed. | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Validation error" | `remote_path` is longer than 2048 characters, longer than 4096 bytes overall, or has a single path component longer than 255 bytes. A missing, empty or whitespace-only `remote_path` is an error on `sources/create/` only — on discover it is not an error at all, it enumerates the provider root | | `1605 (Invalid Input)` | 406 | "Invalid remote path" | `remote_path` contains a directory-traversal sequence, or one of `*` `?` `<` `>`, a vertical bar, a null byte or a control character | | `151157` | 503 | "A recent discovery for this folder has not finished retrying. Try again shortly." | A discovery attempt for the same scope is already within its retry window | --- ### Estimating Folders Before Connecting Them `POST /current/cloudsync/workspace/{workspace_id}/sources/estimate/` counts how many files and how many bytes up to 10 remote folders hold, so a picker can show whether each one fits the workspace plan **before** a source is created. It runs as a job, like discovery. It is not metered: no credits are charged for an estimate. **Auth:** JWT required. The same rules as `sources/discover/`: the caller must be a member of the workspace and the **owner of the chosen identity** — there is no workspace-admin override. The identity must be active, cloud sync must be enabled for the workspace, and the endpoint is rate limited. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `provider_identity_id` | string | Yes | The identity whose cloud account holds the folders. Hyphenated or not | | `remote_paths` | array of string | Yes | 1 to 10 folder paths, e.g. `["/Clients", "/Archive/2025"]`. A string holding a JSON array is also accepted. Each path is checked exactly like `remote_path` on discover and create (same characters, traversal and length rules, not trimmed). Exact duplicates are collapsed, first one kept. The whole list, JSON-encoded, may be at most 16 KB | | `drive_id` | string | OneDrive for Business only | Required for OneDrive for Business, as on discover; not accepted for other providers | **Response (200):** ```json { "result": true, "job_id": "{job_id}", "status": "estimating", "message": "Estimate job created. Poll for results: GET /cloudsync/details/estimate/jobs/{job_id}/", "drive_id": null, "drive_type": null, "drive_name": null, "site_name": null } ``` The drive fields are filled in for OneDrive for Business. Sending the **same** request again (same identity, same drive, same folders in the same order) while an estimate is still running returns that same job, in the same shape, with the message "An estimate for these folders is already running. Poll for results: GET /cloudsync/details/estimate/jobs/{job_id}/". A different order is a different request. **Polling.** `GET /current/cloudsync/details/estimate/jobs/{job_id}/` — the job endpoint with the fixed segment `estimate` where a source id would go, exactly as discovery uses `discovery`. Connector owner only, and the caller must still be a Member of the workspace the job ran for, like discovery polling. A discovery job id asked for under `estimate`, or an estimate job under `discovery`, answers not found. The response is `{ "result": true, "job": { ... } }` with the standard job object: `job_type` is `discovery` with `properties.discovery_kind` set to `estimate` (that is how an estimate is told apart from a folder discovery), `status` is `pending` · `running` · `completed` · `failed` · `canceled`, and `import_source_id` is `null`. `job.properties` also carries `provider`, `identity_id`, `drive_id` (OneDrive for Business), `estimate_paths` (the folders asked about) and, once the job has completed, `estimate_results` and `estimate_completed_at` (`YYYY-MM-DD HH:MM:SS UTC`). If the job as a whole cannot run — the identity was revoked, the workspace was closed, the organization's policy turned cloud sync off, or the plan could not be determined — it ends `failed` with the reason in `error_message`. Estimate jobs cannot be canceled, and they never appear in the workspace-wide jobs view. **`estimate_results`** holds one row per folder, in the order requested: ```json { "remote_path": "/Clients", "file_count": 1250, "total_size": 734003200, "complete": true, "stopped": null, "counted_at": "2026-10-04 12:00:00 UTC", "error": null } ``` | Field | Type | Meaning | |-------|------|---------| | `remote_path` | string | The folder, as requested | | `file_count` | integer | Files found anywhere under the folder. Folders themselves are not counted | | `total_size` | integer | Bytes, the sum of each file's size. A file whose provider reports no size (Google Docs, Sheets and Slides files) counts as 10 MiB | | `complete` | boolean | `true` when the whole folder was counted and the numbers are exact | | `stopped` | string \| null | Why counting stopped early: `limit` or `timeout`. `null` when complete or on error | | `counted_at` | string | When the folder was counted, `YYYY-MM-DD HH:MM:SS UTC` | | `error` | string \| null | Why this folder could not be counted. `null` otherwise | Files and bytes are counted the same way the first sync counts a newly connected folder against the plan. The folder can change at the provider between the estimate and that sync. - **`complete: true`** — exact numbers; `stopped` and `error` are `null`. - **`stopped: "limit"`** — the count passed the plan's per-folder file cap or size cap, and counting stopped there. `file_count` and `total_size` are the values reached, already over the cap. **This folder will not fit the plan;** connecting it would be refused at its first sync. - **`stopped: "timeout"`** — the folder took longer than about two minutes to count. The numbers are partial and are a lower bound. - **`error` set** — the folder could not be counted; `file_count` and `total_size` are `0`, `complete` is `false` and `stopped` is `null`. The other folders in the same request are still counted. The message is one of: "folder not found at the cloud provider", "provider rate limited, try again shortly", "the cloud provider refused to list this folder", "the cloud provider refused access for the connected account", "this folder path cannot be read", "the folder listing could not be read completely, try again shortly", "the folder could not be counted, try again shortly". | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `178916` | 406 | "Invalid JSON in request body" | The body is not a JSON object | | `162806` | 406 | "provider_identity_id is required" | Missing or empty `provider_identity_id` | | `135552` | 406 | "remote_paths must be a non-empty array of folder paths" | `remote_paths` missing, empty, or not a list | | `142495` | 406 | "remote_paths accepts at most 10 folders" | More than 10 entries | | `101510` | 406 | "Each remote_paths entry must be a non-empty folder path" | An entry is not a string, or is empty or whitespace only | | `191592` | 406 | The path validation message | An entry fails the `remote_path` rules above | | `165485` | 406 | "remote_paths is too long; estimate fewer folders per request" | The encoded list is over 16 KB | | `179221` | 404 | "Provider identity not found for this workspace" | No such identity, or one that belongs to another organization | | `113609` | 500 | "Provider identity is not active" | The identity is revoked or not yet connected | | `129651` | 500 | "Import operations temporarily unavailable" | The identity could not be read — retry | | `114242` | 503 | "A recent estimate for these folders has not finished retrying. Try again shortly." | The same estimate failed recently and is still inside its retry window | | `176964` | 503 | "Other estimates for this connection are still running. Try again shortly." | Two estimates on this identity are already running (for other folders); an identical request is answered with its running job instead | | `178716` | 503 | "Another estimate for this connection is being started. Try again shortly." | Another estimate request for the same identity was being submitted at the same moment; retry shortly | | `148256` | 500 | "Failed to create estimate job" | The job could not be queued — retry | | `139908` | 500 | "Failed to resolve workspace profile" | Internal failure | `drive_id` errors are the same as on discover (see *Selecting a Drive on a Source*), and so are the owner-only refusal and the cloud-sync-disabled refusals. --- ### Choosing Where an Import Lands **Two concurrent `POST .../sources/create/` calls for the same workspace can briefly collide.** If another source-creation request for this workspace is still in progress, the call answers `503` rather than creating a source or naming a field problem — wait briefly and send the same request again. | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `131035` | 503 | "Import source creation is temporarily unavailable. Please try again." | Another source-creation request for this workspace is still in progress — retry shortly | `POST .../sources/create/` accepts an optional `destination_node_id`: the workspace storage folder the source's imported folder is created under. Omit it — or send an empty string, or `null` — and the import lands in the `Imports` system folder at the storage root, which is the historical placement and unchanged for every existing caller. Send `root` to place the import directly at the top level of the workspace instead, with no `Imports` folder. A client that always sends the key can therefore leave it null here — but **not** on the update endpoint below, which rejects the key whatever its value. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `destination_node_id` | string | No | Storage node id of an existing **folder** in this workspace, or `root` for the workspace's top level. Omit for the `Imports` system folder | Pass the node id exactly as a storage endpoint returned it; the formatted (dashed) form is accepted. - The node must exist **in this workspace** and must be a **folder**. It is resolved against this workspace's own storage, so an id from another workspace is simply not found here. - **Create-time only.** `POST /current/cloudsync/details/{source_id}/update/` **rejects** `destination_node_id`, on the key's presence rather than its value, and rejects it *before* applying any other field — a request carrying both a destination change and a legitimate one applies neither. Repointing a live source would leave every file it has already imported recorded against a location it is no longer in. The imported folder itself stays freely movable: move it in the workspace instead, and the sync follows it. - The source object carries the choice **and** its outcome — see *Reading the destination back* below. `root_node_id` names the folder the import actually created, and is populated once the first sync has run. - If the chosen folder has been trashed or deleted by the time the first sync runs, the import lands in the default `Imports` folder rather than failing, and the source records `destination_fallback_at`. A degraded placement is visible and fixable; a failed first sync looks like a broken import. - **An import's folder may not be created inside another import.** A destination that is itself part of an import — an import's own folder, or anything beneath it — is **rejected at create**, with reason `destination_nested`, before any source exists. Choose a folder outside every existing import. (Relatedly, `POST /current/workspace/{workspace_id}/storage/{node_id}/move/` refuses to move a cloud-sync folder inside another one.) - **A destination the server could not verify is a retry, not a rejection.** The nesting check walks the folder's ancestry; if that walk cannot complete, the call answers `1610 (Internal Error)` with "Could not verify the chosen destination; please try again". That means **undetermined**, not nested — retry the same request rather than sending the user back to pick a different folder. **Reading the destination back.** Every source object — from create, list, and details alike — carries two top-level fields, alongside `drive_id`. Both are also present inside `properties`. | Field | Type | Meaning | |-------|------|---------| | `destination_node_id` | string \| null | The folder the caller chose at create; `root` means the workspace's top level. Null means the default `Imports` folder | | `destination_fallback_at` | string \| null | UTC `Y-m-d H:i:s`. Set when the chosen folder could **not** be used at graft time and the import landed in `Imports` instead. Null means no fallback happened | **Read them as a pair.** `destination_node_id` on its own is the folder the user *asked for*, and it reads as a successful placement — which is exactly the wrong conclusion when the import actually fell back. A non-null `destination_fallback_at` beside it means "you asked for that folder, you got the default, at this time". A client that renders only the first will tell the user their choice was honoured when it was not. `destination_fallback_at` describes placement **at graft time, not current location**. The imported folder is freely movable afterwards, so treat it as a record of what happened when the import landed, not as a live assertion about where it is now. It is written once and never cleared. **A destination that does not resolve is deliberately indistinguishable from one that is not yours.** A malformed id, a node that does not exist, a trashed node, and a node in another workspace all answer with the same message, the same code and the same reason — "The chosen destination folder was not found in this workspace" — so the endpoint cannot be used to probe which node ids exist elsewhere. Only "the node is a file, not a folder" answers differently, and that branch is reachable only for a node the caller can already list. | Error Code | HTTP Status | Reason | Cause | |------------|-------------|--------|-------| | `1605 (Invalid Input)` | 406 | `destination_unknown` | The id is malformed, or names a node that is missing, trashed, or in another workspace | | `1605 (Invalid Input)` | 406 | `destination_unknown` | The node is a file, not a folder ("The chosen destination must be a folder, not a file") | | `1605 (Invalid Input)` | 406 | `destination_unknown` | The value is not a string (an array or object was sent) | | `1605 (Invalid Input)` | 406 | `destination_nested` | The folder is itself part of an import ("That folder is already part of an import. Choose a folder outside it.") | | `1605 (Invalid Input)` | 406 | `destination_immutable` | `destination_node_id` sent to the update endpoint | | `1610 (Internal Error)` | 500 | *(none)* | The nesting check could not complete ("Could not verify the chosen destination; please try again"). Undetermined, **not** nested — retry | Every `1605` rejection above is **field-scoped**: the response carries a detail naming `destination_node_id`, with `kind: "invalid"`, the human `message`, a support `code`, and the stable `reason`. Branch on `reason`, never on the message text. The `1610` case is the one exception — it is a plain error with no field detail and no `reason`, because it reports that the destination could not be *evaluated*, not that it was bad. For that branch alone the numeric code **`138825` is the discriminator, and it is contract-stable** — it will not be merged into another call site. Treat it as *retry the same request*, never as *pick a different folder*. --- ### Linking to the Folder at the Provider Every source object — from create, list, details and update alike — carries `remote_folder_web_url`: a browser link to the folder the source mirrors, at the provider. | Field | Type | Meaning | |-------|------|---------| | `remote_folder_web_url` | string \| null | Browser URL for the grafted folder at the provider. Null when no link can be built | **Null means exactly one thing: no valid stored ID-to-URL mapping can be built.** Treat it as *do not render the affordance* — never as a broken or unhealthy source. Several unrelated situations produce it, and the field deliberately does not distinguish them, because none of them is something a user can act on: - no folder id is stored yet. A source acquires one on a sync, so this covers a source created before the field existed **until a later sync records one** — it is not limited to sources that have never synced, and an existing source can gain a link without anything being done to it; - the provider was found to publish no id for the folder at all, and the source is recorded as such; - the provider publishes an id but has no public URL form that addresses a folder by it. **Dropbox and OneDrive for Business are both in this category today** and always answer null. Google Drive and Box return a link; - the stored id does not have the shape that provider's ids have, so no URL is built from it rather than one that would not resolve; - the connected account behind the source could not be read, so the provider is unknown and the id cannot be attributed to any URL form. **The link is built from the folder's id, never from `remote_path`.** That is what makes it survive the folder being renamed or moved at the provider — a path-derived link would quietly open the wrong folder afterwards, which is worse than opening none. For the same reason, do not build your own provider URL out of `remote_path`: if this field is null, there is no link to show. Do not parse the URL, and do not assume its shape is stable per provider. It is for opening, not for extracting ids from. --- ### Recognising a Folder That Was Renamed or Moved `POST .../sources/discover/` returns one entry per folder, and each entry carries an `already_imported` flag. Compute nothing yourself: the flag needs every source in this workspace on the same cloud account, which the discovery response does not carry and the caller may not be entitled to enumerate. An absent flag does not read as unknown, it reads as `false`, and the picker then offers a folder that is already connected. | Field | Type | Meaning | |-------|------|---------| | `remote_id` | string | The provider's own id for the folder, or `""` when the provider publishes none | | `name` | string | The folder's name — post this back as `remote_name` | | `remote_path` | string | Absolute remote path — post this back as `remote_path` | | `type` | string | `folder` or `file` | | `size` | integer \| null | Null when the provider reports no size, which is normal for folders | | `already_imported` | boolean | True when this workspace already has a source for this folder | | `overlaps_graft` | boolean | True when this folder is not itself connected but contains, or sits inside, a folder that is. `sources/create/` would refuse it (`165964`). Always `false` when `already_imported` is `true` | **`already_imported` follows the folder, not the path.** **Two keys are checked and a match on EITHER sets the flag** — it is not an id check with a path check behind it. An entry is already imported when its `remote_id` matches the folder id of a source **on the same drive**, *or* when its `remote_path` matches a source's remote path. Both are proof, and neither absence disproves the other: a source that has an id can still be matched by path, and an entry whose `remote_id` is `""` must still be matched by path. An empty value never matches an empty value on either key. Renaming or moving a connected folder at the provider therefore keeps it marked as already imported, at its new path — which it previously did not, and a second source for the same folder could be created as a result. **The id key is scoped to the drive being listed; the path key is not.** Provider item ids are unique within a drive rather than across drives, so an id is only compared against sources on the same drive — otherwise a folder in one library could be reported as already imported because an unrelated folder in another library happens to share its id, and you would be blocked from importing a folder you never imported. The path key is unscoped, which is long-standing behaviour: the same path on two libraries can mark each other as already imported even though `sources/create` would accept the second one. Sources with no recorded id — everything created before the id existed, and everything on a provider that publishes none — are still matched by path exactly as before. Nothing that used to be recognised stopped being recognised. **Pass `remote_id` back when you create a source.** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `remote_id` | string | No | The `remote_id` of the discovery entry the user chose | It is optional and advisory: it can only make `POST .../sources/create/` refuse a duplicate it would otherwise have admitted, never the other way round. Sending it lets the duplicate check recognise a folder that has been renamed or moved since it was connected — the case a path comparison alone cannot see. Omitting it leaves the check exactly as strict as it was, and **sending one that matches nothing does not weaken the path check**: the two keys are OR-ed there too, so a wrong value cannot be used to slip a duplicate past the refusal. The id is only compared against sources on the same drive, as on discovery — but the two are **not** otherwise identical: this endpoint scopes its path key to the drive as well, while discovery's path key is unscoped. So the same path in two libraries can read as already imported in a discovery response and still be accepted here. **This endpoint is the authority on what is actually refused.** The value is not stored on the source and is not what the source is identified by. The server records the folder's id itself, from the provider, on the first sync. **A folder this workspace has already connected is refused.** Two sources for one remote folder would each mirror it and each write back into it. Disconnected sources do not reserve their folder, so reconnecting a folder you previously removed keeps working. The same folder connected on a **different drive** is a different folder and is not refused. **The refusal spans the whole workspace, scoped to the connected cloud account.** Each member who connects a provider gets their own connection, so two members can be looking at the same cloud account through two of them — even when they connected it from different workspaces of the organization. A folder one member has already connected in this workspace is refused for the other, because it is one folder and two sources would each mirror and write back into it. **Two different cloud accounts are not compared:** if two members connect two separate Dropbox accounts, each may connect its own `/Photos` — those are different folders that happen to share a name. **A folder is reserved per workspace, not per organization.** Only sources in the workspace you are creating in count. The same folder can be connected once in each workspace of the organization — connecting `/Clients` in workspace B succeeds while workspace A already syncs it, and a second `/Clients` in B is then refused. `already_imported` and `overlaps_graft` in discovery answer the same way, for the workspace the discovery was started from. **A folder that contains, or sits inside, a connected folder is refused too.** Connecting `/Clients` while `/Clients/Northwind` is already connected — or the other way round — would sync the same files into two places, and on a two-way source each would write back into the other's folder. The candidates are the same as for the duplicate check above: sources in this workspace on the same connected cloud account (and, for OneDrive for Business, the same library) that still hold their folder; a disconnected source does not count. Paths are compared ignoring letter case and leading or trailing slashes, one whole folder name at a time — `/Foo` does not overlap `/Foobar`. A source connected at the account root overlaps every folder. An exact match keeps the duplicate refusal (`268832`); only a strict ancestor or descendant gets `165964`. Discovery flags these folders in advance with `overlaps_graft`. | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `268832` | 409 | "This folder is already connected to this workspace" | A live source **anywhere in this workspace, on the same connected cloud account**, already has this folder — matched on the folder's id or on `remote_path`. **Settled** — disconnect the existing source or pick another folder | | `165964` | 409 | "This folder overlaps a folder already connected to this workspace." | The folder contains, or sits inside, a folder a live source on the same cloud account (and library) already has. **Settled** — disconnect that source or pick a folder outside it | | `160255` | 503 | "Import source creation is temporarily unavailable. Please try again." | The already-connected folders could not be read, so neither check could be made. Retry | --- ### Disconnecting a Source `POST /current/cloudsync/details/{source_id}/disconnect/` ends a source's sync relationship permanently. Owner-or-admin: the member who owns the connected cloud account, or a workspace admin (a workspace admin needs a sign-in session or an `rwa` credential on the workspace or its org — an unscoped API key does not qualify). The permission check runs before the "already disconnected" check below, so a caller without access learns nothing about the source's status from the answer. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `action` | string | Yes | `keep` leaves every imported file in the workspace as ordinary content; `delete` moves the files this source imported to the trash | Neither action changes any FILE in the connected cloud account. Disconnecting ends the connection and `delete` trashes the workspace copies only. Nothing in the connected account's contents is added, changed or removed either way. **Change notifications are registered per connected cloud ACCOUNT, not per folder** — one registration serves every folder connected from that account, so disconnecting one folder does not tear it down, and the account's other connected folders keep receiving changes. The disconnected source simply stops: it reads `disconnected`, and nothing syncs or writes back for it again. **Response (200 OK)** — a source of 1000 files or fewer is disconnected before the call returns: ```json { "result": true, "status": "disconnected", "message": "Source has been disconnected successfully.", "action": "delete", "data_deleted": true } ``` `data_deleted` is `null` on `keep` and `true` on `delete`. **It is never `false`.** A `delete` that did not happen is an error response — the endpoint does not answer `200` and then report in a field that it changed nothing. A source with **more than 1000 files** disconnects in the background instead, answering `status: "disconnecting"` with a message and no `action` or `data_deleted`. Poll the source until it leaves `disconnecting`. **`delete` is REFUSED rather than guessing.** The imported folder is checked in full before anything moves, and the delete is refused unless every item in it is proven to be this source's own import. **How that refusal reaches you depends on which path ran.** On a source of 1000 files or fewer the request itself is refused with **403** — nothing changes and the source is left connected exactly as the call found it, so the cause can be fixed and the same request sent again. On a source of more than 1000 files the call has already answered `200` with `disconnecting`, so the same refusal surfaces on the source instead: it parks at `disconnect_pending` carrying the reason, as described below. That check is a snapshot taken before any trashing begins, so it cannot speak for content added or moved into the folder while the operation is already running; on a folder being changed concurrently, disconnect once the changes have settled. What refuses, and what clears each: | Why | What to do | |-----|------------| | The folder holds an item somebody added or moved into it, which did not come from this connection | Move that item out of the imported folder, then retry | | The folder holds an item belonging to a **different** connected folder | Deal with that connection separately, or move the item out | | The folder holds an imported item whose connection cannot be identified | Move it out, or disconnect with `keep` | | The imported folder itself is not this connection's, or no longer reads as imported | Nothing the API can fix — this connection cannot claim that folder. Read the `keep` caveat below before falling back to it | | The folder is nested deeper than the check walks, or its structure loops back on itself | Flatten the folder, or disconnect with `keep` | A 403 here is settled: the same request refuses identically until the folder changes, so never put it in a retry loop. The message names the item that blocked it where it can, but the wording is advisory text for a person — branch on the status. **`keep` is not a universally safe fallback from a refusal.** It releases *every* imported item in the folder, **including items belonging to another connected folder** — those stop being imported too, and the connection that owns them stops tracking them. It is clean for content the user added themselves, which carries no import state to release. Offer it knowing that, rather than as an automatic retry. **A disconnect that could not be carried out no longer reports success.** Any unproven cleanup — a refusal, or storage that did not answer — ends the request with an error and leaves the source connected and retryable, instead of recording it as disconnected with its files still in place. Callers that treated a `200` as final can keep doing so; what changed is that the failure cases are no longer `200`. On the background path (>1000 files) the usual equivalent is `disconnect_pending`: the source leaves `disconnecting`, does **not** reach `disconnected`, carries the reason in its `error_message`, and neither syncs nor writes back while it sits there. Send the same disconnect again once the cause is fixed — `disconnect_pending` accepts a retry, and `disconnected` does not. That is the normal settling point rather than a guarantee: a background disconnect that cannot record its own outcome can be left in `disconnecting` instead, so read the source's current `status` rather than assuming which one it reached. **A source left in `disconnecting` is recoverable — send the same disconnect again.** The status on its own is no longer a refusal. `disconnect` is accepted whenever nothing is actually working on the source, which is what an abandoned background disconnect looks like, and it starts a fresh one. It answers `503` only while a disconnect is genuinely in progress, and that answer means what it says: retry shortly. So a `disconnecting` source that stays that way is not stuck — retry the disconnect rather than waiting. | Error Code | HTTP Status | Cause | |------------|-------------|-------| | `173328` | 403 | `delete` refused: this source could not confirm every item in the folder is its own to remove. **Nothing was trashed and nothing was disconnected.** Settled — do not retry unchanged | | `159808` | 500 | The cleanup could not be carried out, or could not be confirmed — including a folder whose contents could not be listed in full. Nothing was disconnected. Retry the same request | | `153282` | 500 | The source could not be read while the disconnect held it. Nothing was changed. Retry the same request | | `184326` | 503 | This source is already being disconnected — another disconnect is in flight. Retry shortly | | `158130` | 406 | "Source is busy with another operation; please retry" — another operation holds the source. Retry shortly | | `102628` | 406 | "A sync or disconnect job is already in progress for this source" — wait for the job to finish, then retry | | `191762` | 409 | This source is already disconnected. Settled — retrying can never change that | --- ### Confirming a Large Deletion ``` POST /current/cloudsync/details/{source_id}/refresh/ ``` A sync mirrors deletions: files removed from the connected cloud folder are removed from the workspace too. A single run pauses for confirmation — instead of being applied — when it would remove an unusually large share of the connected folder: **at least 10 files, and more than a quarter of the files the folder holds; or 1,000 files or more, regardless of the folder's size.** A provider listing that comes back completely empty for a folder that previously held files **always** pauses, whatever the count. When a run pauses: nothing is deleted, the source is left in `error`, and its `error_message` names how many files the run would have removed and how many the folder holds, ending with "The person who connected this account can confirm the removal to let it proceed." Every later sync stops in the same place, because a provider that under-reports its own listing looks exactly like a folder somebody emptied, and guessing wrong destroys content. **Removed files land in the workspace trash, not gone for good.** A removal that proceeds — whether it was below the pause threshold or went through after confirmation — moves the files to the workspace's trash like any other delete, so they can be restored from there. Restoring a file that was removed from a `read_write` connected folder uploads it back to the cloud provider, the same as creating a new file in that folder would. **Read `pending_removal` on the list response, not `error_message`.** `GET .../workspace/{workspace_id}/sources/` carries a structured `pending_removal` field on each source — `error_message` is advisory text for a human and is not part of the contract. `pending_removal` is `null` when nothing is waiting for confirmation (the normal case), while a sync is queued or running, and for a stop that cannot be confirmed at all (see the fourth point below); otherwise: ```json "pending_removal": { "remove_count": 6, "folder_file_count": 16, "files": ["Reports/q3.xlsx", "Old Drafts/"], "files_truncated": false, "detected_at": "2026-10-02 16:58:14 UTC", "connector_owner": { "user_id": "2477739036763523387", "name": "Jane Doe" }, "can_confirm": true } ``` | Field | Type | Meaning | |-------|------|---------| | `remove_count` | integer | Files the stopped sync would remove — the number the confirmation is bound to | | `folder_file_count` | integer \| null | Files the connected folder held when this was measured. Null for a stop recorded before this field existed | | `files` | list\ | Up to 20 paths, relative to the connected folder, that would be removed. A folder that disappeared entirely is one entry ending in `/`, so `files` can be shorter than `remove_count`. May be empty for an older stop | | `files_truncated` | boolean | True when removed paths were left out of `files` (more than 20, or the list hit its size limit). A folder entry covering many files does not by itself make this true | | `detected_at` | string \| null | When the stop was recorded, as `YYYY-MM-DD HH:MM:SS UTC` | | `connector_owner` | object | `{ "user_id": string \| null, "name": string }` — the person who connected the cloud account. `user_id` is null and `name` is `""` when the owner cannot be resolved | | `can_confirm` | boolean | True only when the caller IS that connector owner, using a credential that can write | This field is **not present** on `GET .../details/{source_id}/` — read it from the list. To let the removal through, repeat the refresh with the confirmation: ```json { "acknowledge_large_delete": true } ``` The field is **optional** and the endpoint still accepts a request with no body at all. **A plain refresh (no `acknowledge_large_delete`) stays owner-or-admin, like the rest of `refresh/`. Confirming the removal itself is the connector owner's alone — there is no workspace-admin override.** A caller who passes the usual refresh access check (owner or workspace admin) but is not the person who connected the account gets the first error below; a caller who fails that access check gets the endpoint's ordinary access errors instead: | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `128584` | 403 | "Only the person who connected this cloud account can confirm removing these files" | The caller is not the connector owner | | `177276` | 500 | "Import operations temporarily unavailable" | The connector-owner lookup failed transiently. Retryable | After a confirmed refresh, `pending_removal` becomes `null` immediately. `status` stays `error` for a few seconds until the sync starts, then moves `syncing` → `synced` — or back to `error` with a *new* `pending_removal` if more files vanished in the meantime. The response shape of `refresh/` itself does not change. Five things about `acknowledge_large_delete` are worth knowing before you build on it: - **It confirms the number the caller was shown, not "delete whatever you find".** The confirmation is bound to the count recorded when the sync was stopped. If the folder has lost substantially more by the time the confirmed run measures it, that run stops again and reports the NEW count — send the refresh again to confirm that one. Small movement in between is tolerated. - **It is spent by one sync.** Nothing is stored on the connection, so the next scheduled sync is bounded exactly as before. Re-send it if a later run stops again. - **It only applies to a folder that is still stopped.** The confirmation is matched against the folder's most recent finished sync. If that sync completed normally, there is nothing left to confirm and the request is treated as an ordinary refresh — a folder that recovered on its own is not carrying an old confirmation forward, and a confirmation given for one stopped run can never be spent on a different one later. - **It cannot clear every stopped sync.** When the run could not measure the folder completely, no exact count exists and the confirmation is ignored: the message says the deletion could not be judged rather than naming a number. Those clear when the underlying read succeeds, not by confirming. - **Confirm with a person, never automatically.** This authorises deleting content from the workspace. Show the count and the folder to the connector owner (`pending_removal.connector_owner`) before sending it, and never wire it into a retry loop. --- ### Reading a Source Against Its Plan Limits Each connected folder is limited by the workspace plan in two ways: how many files it may hold and how many bytes. Every source object from the list, details and `sources/create/` responses carries both caps and where the source stands against them: | Field | Type | Meaning | |-------|------|---------| | `object_limit` | integer \| null | The plan's file cap for one connected folder | | `object_limit_state` | string \| null | `ok`, `approaching` (at or above 90% of the cap) or `exceeded` (above the cap), from `file_count` | | `size_limit` | integer \| null | The plan's size cap for one connected folder, in bytes | | `size_limit_state` | string \| null | `ok`, `approaching` or `exceeded`, the same way, from `total_size` | | `limit_exceeded` | string \| null | Which cap the most recent sync **refused** this source on: `objects` or `size`. `null` otherwise | The two caps and the two states are `null` on a response that carries no plan context (the `update/` response); `limit_exceeded` is present everywhere. **`limit_exceeded` is the refusal itself, not arithmetic.** When a sync is refused because the folder is over a cap, the source goes to `status: "error"` with an `error_message`, and `limit_exceeded` names the cap. The matching `object_limit_state` or `size_limit_state` then reads `exceeded` even if `file_count` or `total_size` is still `0` — which is the normal case for a newly connected folder refused before anything was imported. `limit_exceeded` is set only while the source is still in that error: it is `null` again as soon as the source leaves `status: "error"` or its `error_message` changes to a different reason — when the next sync starts, succeeds, fails for another reason, or the source is suspended, paused or disconnected. **Upgrading the plan does not clear it by itself:** `limit_exceeded` and the matching `exceeded` state remain until the source leaves the refusal state — normally when the next sync of that source starts — even though the new plan's caps are already reported in `object_limit` / `size_limit`. **`size_limit_state` can read lower than what the next sync compares.** `total_size` includes a file with no provider-declared size (a Google Docs, Sheets or Slides file) only once it has actually been imported, while the sync's own check counts each such file it has never imported as 10 MiB. Use the estimate endpoint (see *Estimating Folders Before Connecting Them*) to check a folder before connecting it. --- ### Source and Job Lifecycle Refusals The source and job lifecycle endpoints — `update/`, `refresh/`, `delete/` and `jobs/{job_id}/cancel/` — each have a **state precondition**, and a request that violates one is answered **409 Conflict**, never a 5xx. The exception is the two `update/` busy refusals, which answer **406** and are retryable (see below). This is the same rule the write-back endpoints already follow, applied to the other half of the surface; these used to answer **500**, which told clients to retry a request whose outcome had nothing to do with a server fault. Gate on the HTTP status. The `error.code` values below are the ones actually serialized, and they identify the call site rather than the reason — several share a message, and the message is what tells the cases apart. | Endpoint | `error.code` | HTTP Status | Message | |----------|--------------|-------------|---------| | `update/` (`action: pause`) | `187554` | 409 | "Source can only be paused when synced, in error state, or suspended by policy or workspace deletion" | | `update/` (`action: pause`) | `156588` | 409 | "Source cannot be paused from its current state" | | `update/` (`action: resume`) | `112266` | 409 | "Source can only be resumed when paused" | | `update/` (`action: resume`) | `122558` | 409 | "Source cannot be resumed from its current state" | | `update/` (any field) | `144246` | 406 | "Source is busy with another operation; please retry" | | `update/` (any field) | `199777` | 406 | "A sync or disconnect job is already in progress for this source" | | `refresh/` | `192986` | 409 | "Source must be in synced or error state to refresh" | | `delete/` | `172501` | 409 | "Cannot delete source in active state. Disconnect or wait for completion first." | | `sources/create/` | `181494` | 409 | "Provider identity is not active" | | `sources/create/` | `268832` | 409 | "This folder is already connected to this workspace" | | `sources/create/` | `165964` | 409 | "This folder overlaps a folder already connected to this workspace." | | `sources/create/` | `134664` | 412 | "Import source limit reached for your plan" | | `sources/create/` | `134248` | 503 | "Your plan could not be determined right now. Please try again shortly." | | `sources/create/` | `105962` | 403 | "Cloud sync is turned off for this organization." (`params.reason` = `cloud_sync_disabled` — see *Cloud Sync Policy*) | | `sources/create/` | `178341` / `178934` | 503 | "Cloud sync policy is temporarily unavailable. Please try again shortly." | | `jobs/{job_id}/cancel/` | `130927` | 409 | "Only pending jobs can be canceled; a running job cannot be stopped once it has started" | | `jobs/{job_id}/cancel/` | `125572` | 409 | Same message — the job started between reading it and cancelling it | | `jobs/{job_id}/cancel/` | `104099` | 409 | "A disconnect in progress cannot be canceled; wait for it to finish." | **One row here is transient, not a refusal.** `sources/create/` answers `134248` → **503** when the workspace's plan could not be read at all. Nothing about the request is wrong and nothing is settled: send the same request again shortly. It is the same answer the connect endpoints give for the same condition, and it is deliberately distinct from the `412` plan limit above — gate on the HTTP status first, exactly as you would there. **`update/` now refuses while a sync or disconnect owns the source**, and this is a change to a shipped endpoint: pausing, resuming, renaming and changing the interval used to be accepted at any moment. They were not safe — a settings change made during a source's first sync overwrote the sync's own progress, leaving the connection wedged in a state nothing could move it out of. The two refusals answer **406**, matching `disconnect/`, which has always refused the same way for the same reason. Both are transient: re-read the source with `source-details`, wait for the job to finish, and send the same request again. **None of these is answered by repeating the request immediately — but they are not all permanent.** Two kinds are mixed together here, and the difference decides what a client should do: - **Settled for good.** A `disconnect` job is never cancellable, at any status; a running job is never cancellable; a folder already connected stays connected until someone disconnects it; and a plan limit is lifted by an upgrade. Resending the same request unchanged cannot ever succeed — change the request, or change the account. - **Settled for now.** `delete/`, `refresh/` and `update/` refuse because of the state the source is in *at this moment* — a source that is syncing or discovering will finish, and the same call is then valid. These clear on their own. In both cases: **do not tight-loop.** Re-read the source with `source-details` (or the job) and act on the state it is actually in; where the state is one that transitions, wait for the transition rather than polling the refusing endpoint. Some rows are not a 409 at all: the plan limit answers **412**, and its fix is an upgrade rather than a different request; the unreadable plan answers **503**, and its fix is to send the same request again shortly. The cloud-sync policy rows split the same way: `105962` → **403** is settled until an admin changes the policy, while `178341` / `178934` → **503** means the policy could not be read and is worth sending again shortly. **A running sync job cannot be cancelled.** Cancel applies only while the job is still `pending`, and it is applied as a compare-and-swap on that status — so a job that starts between reading it and cancelling it answers 409 rather than having its record rewritten. Once a sync is running it runs to completion; there is no way to stop a transfer in flight, and the previous behaviour only made the audit record say otherwise. **A `disconnect` job is never cancellable, at any status.** Cancelling one does not stop the teardown — it removes the record that something owns it, and leaves the source stuck mid-disconnect. Wait for it to finish. **A 500 still appears on these endpoints** and now means what it says: a datastore read or write failed, a profile could not be resolved, or a job could not be queued. Those are worth retrying. A state refusal is not. --- ### Write-Back Queue Write-back is the return direction of cloud sync. A change made to an imported file **inside Fastio** — an edit, a new version, a delete — is pushed back out to the folder at the provider, so the connected cloud account ends up holding the same content. **FOLDER changes propagate too, and the QUEUEING is all-or-nothing.** Trashing a folder inside a `read_write` connected folder removes every file beneath it that this connection imported from the connected cloud account as well, not only from the workspace; restoring the folder re-uploads them, undoing the removals. Moving a folder OUT of the connected folder is treated the same way as trashing it, because those files have left the connection — the workspace keeps them as ordinary content, and the provider copies are removed. Each of these queues one write-back per imported file beneath the folder, created together: **if the folder's contents cannot be listed completely, nothing at all is queued and the change stays local**, which is the safe direction. That queueing pass performs no removals itself; rows it already queued carry their own jobs and complete on their own; a pass that could not queue every descendant is retried before it executes anything. **What follows is not a transaction at the connected account.** Once queued, each removal completes on its own and is retried until it lands, so a large folder can be briefly half-removed at the provider while the rest catch up. The folder CONVERGES rather than flipping in one step — read the write-back list if you need to know when it has settled. Files under a *nested* connected folder, files that were never imported, and files belonging to a *different* connection are left alone in every case. Restoring an earlier version of a file, and copying a file into a connected folder, also reach the provider. Tell a user this before they trash or move a folder inside a `read_write` connection: it reaches their own cloud account, not just this workspace. **Notes DO take part in cloud sync, in both directions.** A note is Markdown — a `.md` node holding ordinary stored content — so it mirrors to the provider as its own `.md` object. A note you create or edit inside a `read_write` synced folder **is** pushed, queues a write-back like any file, and appears in the connected cloud account as a `.md` file; trashing one propagates the delete, and an edit made to that `.md` **at the provider** is pulled back into the note. **The TYPE mapping, however, is one-way, and that is the part not to assume:** a `.md` arriving from the provider with **no matching note already in the workspace** is imported as an ordinary **file**, never converted into a note — so the Markdown in a synced code or docs folder stays files rather than becoming collaborative notes. **A provider edit that is not valid note content is refused, and the sync stays healthy.** Notes cap at 100 KB and must be markdown, so if the `.md` at the provider grows past that, or is replaced with something binary, the note **keeps its previous content** and the provider copy is left untouched. This is not a sync failure — the source does not go into `error` over it, and the rest of the folder keeps syncing — so a note that has quietly stopped tracking its provider file is something to look for rather than something you will be alerted to. Fix it at the provider and the next sync picks it up. It is off by default and enabled per source: a source only writes back while its `access_mode` is `read_write`. A `read_only` source imports and never writes — but a write-back it already queued is held, not discarded; see below. **Most write-backs are created for you.** Changing an imported file queues one automatically; there is no endpoint to call for the ordinary case. The endpoints in this section exist for the cases automation cannot settle by itself — forcing a push, retrying one that failed, deciding a conflict, and cancelling one that has not run yet. **Reading the queue is member-level; every write action is not.** A push, a retry, a `keep_local` resolve — each of these runs the transfer under the **cloud credential of the member who connected the source's identity**, writing into that person's own cloud account. So the write actions require **that identity's owner, or a workspace admin**; anyone below that is refused with `1680 (Access Denied)`. The identity owner needs only the write scope documented above; a workspace admin additionally needs an **admin-capable** credential — a sign-in session, or a token holding `rwa` on the workspace or its org — and is otherwise refused `scope_admin_required` (see *Transfer Workspace Ownership* above). Listing and reading a job need only ordinary view access to the workspace. **Timestamp format change.** `created`, `updated` and `remote_mtime_before` are emitted as `YYYY-MM-DD HH:MM:SS UTC` — the canonical API datetime format. They previously omitted the ` UTC` suffix. A client that parses these fields with a fixed pattern must accept the suffix. #### Status lifecycle The lifecycle is `pending → uploading → completed | failed | conflict | canceled`. | `status` | Meaning | What the caller does | |----------|---------|----------------------| | `pending` | Queued; the transfer has not started | Wait, or cancel it | | `uploading` | The transfer is running | Wait. It cannot be retried, resolved or cancelled from here | | `completed` | The provider holds the change | Nothing — final | | `failed` | The attempt ended in an error | Read `error_message`, fix the cause, then retry | | `conflict` | The copy at the provider changed as well, so nothing was written | Resolve it `keep_local` or `keep_remote`, or cancel it | | `canceled` | Ended by a caller, or by the source losing write access | Nothing — final | **Two of those are not the end of the story.** `failed` returns to `pending` when you retry it, and `conflict` returns to `pending` when you resolve it. `completed` and `canceled` are final: no endpoint accepts a job in either state. **`canceled` does not only arrive from `uploading`.** Cancelling acts directly on a `pending` or `conflict` job, and a job is also cancelled for you when the write-back route itself goes away for good — the source is disconnected or deleted, the connected identity is revoked, the member who owns it loses access to the workspace, or the workspace is closed. A `canceled` job you did not cancel means the route went away, not that the transfer failed. **Flipping a source to `read_only` HOLDS its queued write-backs instead of cancelling them.** A `pending` job stays `pending`; the route resumes automatically, with no re-queue needed, as soon as the source is switched back to `read_write`. The hold is bounded: a write-back still held after about 5 days ends `failed` instead of waiting indefinitely, with `properties.terminal_reason: "policy_hold_expired"` — `retry-writeback` accepts it from there, so the edit is never silently lost. This is a reversible policy choice, not a loss of the route: disconnecting the source, deleting it, a member removal, or a revoked connection still cancel outright, exactly as above. **A workspace on a temporary hold does NOT cancel its queued write-backs — they wait.** A workspace or org that is locked, suspended or under review is on a hold that ends, so its queued jobs stay `pending` and the work is held rather than discarded. They are re-checked periodically and go out on their own once the hold is lifted; there is nothing to retry and nothing to re-queue. This is the difference between a hold and a closure: a closed workspace cancels, a suspended one waits. A job held this way can still reach its retry ceiling if the hold lasts long enough, and it then ends `failed` — which `retry-writeback` accepts, so the edit is never silently lost. **An edit made while a job is already `uploading` gets its own job.** A queued `pending` job absorbs later edits to the same file, because it reads the file's current content when it runs. An `uploading` one cannot: the transfer has already read the content it is going to send, so a newer edit gets a fresh `pending` job queued behind it. Expect to see two jobs for one file in that window — one `uploading` and one `pending` — and expect the second to absorb any further edits rather than adding a third. **Do not treat `conflict` as an error state.** It is a decision waiting on a person: both copies changed, and only the caller knows which one is right. Nothing has been overwritten at either end while a job sits there. #### The write-back object Every endpoint in this section returns this shape. | Field | Type | Meaning | |-------|------|---------| | `id` | string | The write-back job's id | | `import_source_id` | string | The source this job belongs to | | `node_id` | string | The imported file being written | | `profile_id` | string | The workspace that owns the source | | `remote_path` | string | Absolute path of the object at the provider | | `operation` | string | `upload` (a local create, edit or new version) or `delete` (a local delete) | | `status` | string | One of the six above | | `file_size` | integer | Size of the local file in bytes | | `bytes_uploaded` | integer | Bytes transferred so far; equal to `file_size` once `completed` | | `remote_mtime_before` | string \| null | The provider's modification time for the object as it stood when the job was created — the baseline the conflict check compares against. Null until it has been captured | | `error_message` | string \| null | Why the job failed or conflicted | | `retry_count` | integer | Attempts spent on the current queue entry; a retry resets it to 0 | | `properties` | object | See below | | `created` | string | `YYYY-MM-DD HH:MM:SS UTC` | | `updated` | string | `YYYY-MM-DD HH:MM:SS UTC` | **`error_message` is advisory text for a human.** It is reduced to a safe summary before it leaves the API, its wording is not part of the contract, and it is not what a client should branch on — branch on `status`, and on the error responses documented below. **`operation` is part of a job's identity, not a detail of it.** An `upload` and a `delete` for the same file are two independent jobs and neither blocks the other, so one file can legitimately have two live write-backs at once. `properties` publishes only what a caller can act on. Internal transfer state is not included, so `{}` is the ordinary value for an automatically created job. | Key | Value | When present | |-----|-------|--------------| | `triggered_by` | `manual_push` | Only on a job created by the push endpoint — its absence is how you tell an automatic job from a forced one | | `resolution` | `keep_local` \| `keep_remote` | Only after a conflict on this job has been resolved | --- ### List Write-Back Jobs ``` GET /current/cloudsync/details/{source_id}/writebacks/ ``` Lists the write-back jobs recorded for one source, newest first. There is no status filter and no sort control — read the page and filter client-side on `status`. **Auth:** JWT required. Any workspace member with view access; the source's owner is not required. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{source_id}` | string | Yes | The import source's id | **Query Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `limit` | int | No | Page size (default 50, max 200) | | `offset` | int | No | Page offset (default 0) | Both are **clamped, never rejected**: a `limit` above the maximum returns the maximum, and a `limit` of 0, a negative number, or a value that is not a number returns 1. A negative `offset` reads as 0. So a malformed page request returns a small page rather than an error, and a client that wants to validate its own paging must check what came back. **Response (200 OK):** ```json { "result": true, "writebacks": [ { "id": "al3eo-t5elp-ltput-g5a62-m3pmo-wian", "import_source_id": "axk7q-5ljws-dv2eb-z4aux-dsrpd-ki5u", "node_id": "2vqwd-zclih-azwk2-i6ay3-3bdrw-qa3p", "profile_id": "1234567890123456789", "remote_path": "/Marketing/Q3 Report.docx", "operation": "upload", "status": "conflict", "file_size": 184320, "bytes_uploaded": 0, "remote_mtime_before": "2026-04-27 16:37:29 UTC", "error_message": "The copy at the provider changed since this write-back was queued", "retry_count": 0, "properties": {}, "created": "2026-04-27 16:38:02 UTC", "updated": "2026-04-27 16:41:15 UTC" } ], "pagination": { "limit": 50, "offset": 0, "total": 1 } } ``` **`pagination.total` is the number of items in `writebacks`, not the number of jobs the source has.** It cannot be used to size a progress bar or to decide how many pages exist. Page until a page comes back shorter than `limit`. **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Source ID is required" | No source id in the path | | `1609 (Not Found)` | 404 | "Import source not found" | Unknown source, or one already deleted | | `1680 (Access Denied)` | 401 | "Insufficient permissions to access this resource" | Not a member of the source's workspace | | `1680 (Access Denied)` | 401 | "Cloud import features are not enabled for this workspace" | Cloud sync is switched off for the workspace | | `1680 (Access Denied)` | 401 | "Cloud import features are not available on your current plan" | Cloud sync not included in the plan | | `10560 (Access Denied)` | 403 | "Your token does not have sufficient scope for this Workspace." | A scoped credential whose scope does not cover the source's workspace | | `1610 (Internal Error)` | 500 | "Failed to retrieve write-back jobs" | The queue could not be read | --- ### Write-Back Job Details ``` GET /current/cloudsync/details/{source_id}/writebacks/{writeback_id}/ ``` Returns one write-back job. This is the endpoint to poll while a job is live, and the endpoint to re-read after any refusal — the job's current `status` is what tells you whether a refusal was settled or transient. **Auth:** JWT required. Any workspace member with view access. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{source_id}` | string | Yes | The import source's id | | `{writeback_id}` | string | Yes | The write-back job's id | **Response (200 OK):** ```json { "result": true, "writeback": { "id": "al3eo-t5elp-ltput-g5a62-m3pmo-wian", "import_source_id": "axk7q-5ljws-dv2eb-z4aux-dsrpd-ki5u", "node_id": "2vqwd-zclih-azwk2-i6ay3-3bdrw-qa3p", "profile_id": "1234567890123456789", "remote_path": "/Marketing/Q3 Report.docx", "operation": "upload", "status": "completed", "file_size": 184320, "bytes_uploaded": 184320, "remote_mtime_before": "2026-04-27 16:37:29 UTC", "error_message": null, "retry_count": 0, "properties": { "triggered_by": "manual_push" }, "created": "2026-04-27 16:38:02 UTC", "updated": "2026-04-27 16:39:44 UTC" } } ``` **A job belongs to exactly one source.** Asking for a valid job id under the wrong source is refused rather than answered, so the pair must match. **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Source ID is required" | No source id in the path | | `1605 (Invalid Input)` | 406 | "Write-back ID is required" | No write-back id in the path | | `1609 (Not Found)` | 404 | "Import source not found" | Unknown source, or one already deleted | | `1609 (Not Found)` | 404 | "Write-back job not found" | Unknown write-back id | | `1609 (Not Found)` | 404 | "Write-back job does not belong to this source" | The job exists, but under a different source | | `1680 (Access Denied)` | 401 | "Insufficient permissions to access this resource" | Not a member of the source's workspace | | `10560 (Access Denied)` | 403 | "Your token does not have sufficient scope for this Workspace." | A scoped credential whose scope does not cover the source's workspace | --- ### Push a File Back to the Provider ``` POST /current/cloudsync/details/{source_id}/writebacks/push/{node_id}/ ``` Queues a write-back for one imported file immediately, without waiting for a change to trigger one. Use it to re-send content the provider is known to be missing or stale on, or to force the current content out after a failure has been dealt with by other means. The job is created `pending`, with `operation: upload` — a manual push is always an upload, never a delete — and `properties.triggered_by` set to `manual_push`. It returns as soon as the job is queued; it does not wait for the transfer. **Auth:** JWT required. **The owner of the source's connected identity, or a workspace admin** (a workspace admin needs a sign-in session or an `rwa` credential on the workspace or its org; the identity owner needs only write scope). The transfer runs under the owner's cloud credential. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{source_id}` | string | Yes | The import source's id | | `{node_id}` | string | Yes | The imported **file** to push | No request body. **`{node_id}` must name an imported FILE or NOTE, never a folder.** This is worth stating plainly because a folder inside a synced folder is an imported node just as much as a file is, so a folder's id is a perfectly plausible thing to send. It is refused at request time — **409 Conflict**, `error.code` `166884`, "Node is not an imported file or note" — rather than accepted with a `200` and a queued job that could only fail later. There is no "push this folder" operation; push each file. **A note's id is accepted.** A note is Markdown — a `.md` node holding ordinary stored content — so it mirrors to the provider as its own `.md` object, and pushing one queues a write-back exactly as pushing a file does. The node must also be **under this source** and still carry its import metadata. A node that belongs to another source, or that sits outside the folder this source imported, is refused `404` — the id is not addressable through this source. **Response (200 OK):** ```json { "result": true, "writeback": { "id": "al3eo-t5elp-ltput-g5a62-m3pmo-wian", "import_source_id": "axk7q-5ljws-dv2eb-z4aux-dsrpd-ki5u", "node_id": "2vqwd-zclih-azwk2-i6ay3-3bdrw-qa3p", "profile_id": "1234567890123456789", "remote_path": "/Marketing/Q3 Report.docx", "operation": "upload", "status": "pending", "file_size": 184320, "bytes_uploaded": 0, "remote_mtime_before": null, "error_message": null, "retry_count": 0, "properties": { "triggered_by": "manual_push" }, "created": "2026-04-27 16:38:02 UTC", "updated": "2026-04-27 16:38:02 UTC" } } ``` The response is `200`, not `201`. **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Source ID is required" | No source id in the path | | `1605 (Invalid Input)` | 406 | "Node ID is required" | No node id in the path | | `1605 (Invalid Input)` | 406 | "Invalid node ID format" | The node id is not a well-formed id | | `1660 (Conflict)` | 409 | "Write-back is only available for read-write import sources" | The source's `access_mode` is `read_only` | | `1660 (Conflict)` | 409 | "Node is not an imported file or note" | A folder, a node that is not imported, or one with no import metadata | | `1609 (Not Found)` | 404 | "Import source not found" | Unknown source, or one already deleted | | `1609 (Not Found)` | 404 | "Node not found" | Unknown node id | | `1609 (Not Found)` | 404 | "Node does not belong to this import source" | The node is imported, but by a different source | | `1609 (Not Found)` | 404 | "Node is not under this import source" | The node is outside the folder this source imported | | `1660 (Conflict)` | 409 | "This node already has a write-back in progress" | **Transient** — another live job already covers this file. See below | | `1693 (Temporarily Unavailable)` | 503 | "Another write-back action for this node is in progress" | **Retryable** — a concurrent action holds this file's turn | | `1693 (Temporarily Unavailable)` | 503 | "Could not check whether this node already has a write-back in progress" | **Retryable** — the already-covered check could not be carried out, so the request was never evaluated | | `1693 (Temporarily Unavailable)` | 503 | "The write-back lock for this node expired before the change was applied" | **Retryable** — this file's turn was held and lapsed before the job was created. **Nothing was written**, so re-sending cannot queue it twice | | `1680 (Access Denied)` | 401 | "This action requires the source owner or a workspace admin" | Neither the identity owner nor a workspace admin | | `10767 (Forbidden)` | 403 | "Your credential is not authorized for administrative operations on this Workspace." | The caller is a workspace admin acting on another member's source, but the credential is not admin-capable — no sign-in session, no `rwa` on the workspace or its org (`scope_admin_required`) | | `10560 (Access Denied)` | 403 | "Your token does not have sufficient scope for this Workspace." | A scoped credential whose scope does not cover the source's workspace | | `1700 (Forbidden)` | 403 | "Cloud sync is read-only here, so changes are not pushed back to the provider." / "Cloud sync is turned off for this organization." | The cloud-sync policy does not allow write-back for the caller. `params.reason` = `cloud_sync_read_only` or `cloud_sync_disabled`. **Settled** until an admin changes the policy. See *Cloud Sync Policy* | | `1693 (Temporarily Unavailable)` | 503 | "Cloud sync policy is temporarily unavailable. Please try again shortly." | **Retryable** — the cloud-sync policy could not be read | | `1610 (Internal Error)` | 500 | "Failed to create write-back job" | The job could not be queued | **The permission check runs before the access-mode check.** A member who is neither owner nor admin, pushing to a `read_only` source, is told about their permissions rather than about the access mode — so a caller without access, or one whose credential's scope doesn't cover the workspace, learns nothing about the source's mode from the answer. **Three of the refusals above share one `error.code`** — "Node is not an imported file or note", "Node does not belong to this import source" and "Node is not under this import source". They are distinguished by HTTP status (`409` versus `404`), never by the numeric code. This is the general rule for this API: numeric codes are assigned per call site and are for diagnostics, not for branching. --- ### Retry a Failed Write-Back ``` POST /current/cloudsync/details/{source_id}/writebacks/{writeback_id}/retry/ ``` Puts a `failed` job back in the queue. The job returns to `pending`, its `error_message` is cleared, and `retry_count` resets to 0 because the new attempt starts a fresh budget. **Retrying does not change anything about the transfer.** If the cause was a permission the connected account does not have, or a provider that rejects the file, the retry will fail the same way. Fix the cause first — a write-back that failed because the connected account lacks permission to write that folder is a **permanent** failure, and only granting the permission makes a retry worthwhile. **Auth:** JWT required. The owner of the source's connected identity, or a workspace admin (a workspace admin needs a sign-in session or an `rwa` credential on the workspace or its org; the identity owner needs only write scope). **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{source_id}` | string | Yes | The import source's id | | `{writeback_id}` | string | Yes | The write-back job's id | No request body. **Precondition:** `status` must be `failed`. Any other status is refused. **Response (200 OK):** the job, as re-queued — `status: "pending"`, `error_message: null`, `retry_count: 0`, under the `writeback` key. **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Source ID is required" | No source id in the path | | `1605 (Invalid Input)` | 406 | "Write-back ID is required" | No write-back id in the path | | `1609 (Not Found)` | 404 | "Import source not found" | Unknown source, or one already deleted | | `1609 (Not Found)` | 404 | "Write-back job not found" | Unknown write-back id | | `1609 (Not Found)` | 404 | "Write-back job does not belong to this source" | The job exists, but under a different source | | `1660 (Conflict)` | 409 | "Only failed write-back jobs can be retried" | **Settled** — the job is not in `failed`, or it left `failed` while the request was being handled | | `1660 (Conflict)` | 409 | "This node already has a write-back in progress" | **Transient** — another live job already covers this file. See below | | `1693 (Temporarily Unavailable)` | 503 | "Another write-back action for this node is in progress" | **Retryable** — a concurrent action holds this file's turn | | `1693 (Temporarily Unavailable)` | 503 | "Could not check whether this node already has a write-back in progress" | **Retryable** — the already-covered check could not be carried out | | `1693 (Temporarily Unavailable)` | 503 | "The write-back lock for this node expired before the change was applied" | **Retryable** — this file's turn was held and lapsed before the re-queue. **Nothing was written**, so the job is still `failed` and re-sending cannot re-queue it twice | | `1664 (Datastore Error)` | 500 | "Failed to load import source" / "Failed to load write-back job" | **Retryable** — the record could not be read | | `1680 (Access Denied)` | 401 | "This action requires the source owner or a workspace admin" | Neither the identity owner nor a workspace admin | | `10767 (Forbidden)` | 403 | "Your credential is not authorized for administrative operations on this Workspace." | The caller is a workspace admin acting on another member's source, but the credential is not admin-capable — no sign-in session, no `rwa` on the workspace or its org (`scope_admin_required`) | | `10560 (Access Denied)` | 403 | "Your token does not have sufficient scope for this Workspace." | A scoped credential whose scope does not cover the source's workspace | | `1700 (Forbidden)` | 403 | "Cloud sync is read-only here, so changes are not pushed back to the provider." / "Cloud sync is turned off for this organization." | The cloud-sync policy does not allow write-back for the caller. `params.reason` = `cloud_sync_read_only` or `cloud_sync_disabled`. **Settled** until an admin changes the policy. See *Cloud Sync Policy* | | `1693 (Temporarily Unavailable)` | 503 | "Cloud sync policy is temporarily unavailable. Please try again shortly." | **Retryable** — the cloud-sync policy could not be read | | `1610 (Internal Error)` | 500 | "Failed to retry write-back job" | The job could not be re-queued | --- ### Resolve a Write-Back Conflict ``` POST /current/cloudsync/details/{source_id}/writebacks/{writeback_id}/resolve/ ``` Decides a `conflict`: the file changed in Fastio and the copy at the provider changed too, so the write was held rather than applied. Resolving re-queues the job to `pending` with the decision recorded, and clears `error_message` and `retry_count`. **Auth:** JWT required. The owner of the source's connected identity, or a workspace admin (a workspace admin needs a sign-in session or an `rwa` credential on the workspace or its org; the identity owner needs only write scope). **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{source_id}` | string | Yes | The import source's id | | `{writeback_id}` | string | Yes | The write-back job's id | **Request body (JSON):** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `resolution` | string | Yes | `keep_local` or `keep_remote` | | `resolution` | What happens | |--------------|--------------| | `keep_local` | The Fastio copy wins. The job is re-queued and pushes it out, **overwriting the changed copy at the provider** — the conflict check is skipped for this one attempt, which is the whole point of the choice | | `keep_remote` | The provider's copy wins. The remote version is pulled down over the Fastio copy, and nothing is written outward | The value is case-sensitive, surrounding whitespace is ignored, and any other value is refused. **`keep_local` discards the other side's change at the provider, and `keep_remote` discards the local one — put the choice in front of a person rather than defaulting it.** **Precondition:** `status` must be `conflict`. **Response (200 OK):** the job, as re-queued — `status: "pending"`, `properties.resolution` set to the value you sent, under the `writeback` key. **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Source ID is required" | No source id in the path | | `1605 (Invalid Input)` | 406 | "Write-back ID is required" | No write-back id in the path | | `1605 (Invalid Input)` | 406 | "Invalid JSON in request body" | The body was not a JSON object | | `1605 (Invalid Input)` | 406 | `resolution must be "keep_local" or "keep_remote"` | Missing, misspelled, or a different value | | `1609 (Not Found)` | 404 | "Import source not found" | Unknown source, or one already deleted | | `1609 (Not Found)` | 404 | "Write-back job not found" | Unknown write-back id | | `1609 (Not Found)` | 404 | "Write-back job does not belong to this source" | The job exists, but under a different source | | `1660 (Conflict)` | 409 | "Only conflicting write-back jobs can be resolved" | **Settled** — the job is not in `conflict`, or it left `conflict` while the request was being handled | | `1660 (Conflict)` | 409 | "This node already has a write-back in progress" | **Transient** — a *different* live job already covers this file. See below | | `1693 (Temporarily Unavailable)` | 503 | "Another write-back action for this node is in progress" | **Retryable** — a concurrent action holds this file's turn | | `1693 (Temporarily Unavailable)` | 503 | "Could not check whether this node already has a write-back in progress" | **Retryable** — the already-covered check could not be carried out, so the request was never evaluated | | `1693 (Temporarily Unavailable)` | 503 | "The write-back lock for this node expired before the change was applied" | **Retryable** — this file's turn was held and lapsed before the re-queue. **Nothing was written**, so the job is still `conflict` and re-sending cannot re-queue it twice | | `1664 (Datastore Error)` | 500 | "Failed to load import source" / "Failed to load write-back job" | **Retryable** — the record could not be read | | `1680 (Access Denied)` | 401 | "This action requires the source owner or a workspace admin" | Neither the identity owner nor a workspace admin | | `10767 (Forbidden)` | 403 | "Your credential is not authorized for administrative operations on this Workspace." | The caller is a workspace admin acting on another member's source, but the credential is not admin-capable — no sign-in session, no `rwa` on the workspace or its org (`scope_admin_required`) | | `10560 (Access Denied)` | 403 | "Your token does not have sufficient scope for this Workspace." | A scoped credential whose scope does not cover the source's workspace | | `1700 (Forbidden)` | 403 | "Cloud sync is read-only here, so changes are not pushed back to the provider." / "Cloud sync is turned off for this organization." | **`keep_local` only** — the cloud-sync policy does not allow write-back for the caller. `params.reason` = `cloud_sync_read_only` or `cloud_sync_disabled`. `keep_remote` writes nothing to the provider and is never refused by policy. See *Cloud Sync Policy* | | `1693 (Temporarily Unavailable)` | 503 | "Cloud sync policy is temporarily unavailable. Please try again shortly." | **`keep_local` only.** **Retryable** — the cloud-sync policy could not be read | | `1610 (Internal Error)` | 500 | "Failed to resolve write-back conflict" | The job could not be re-queued | **The already-covered check on `resolve` excludes the job being resolved.** A `conflict` job is itself live, so a check that counted it would refuse every resolve. Resolving is therefore never blocked by its own row — only by a *different* live write-back on the same file, for the same `operation`, which is what a later edit or a retry can put there while the conflict waits for a decision. --- ### Cancel a Write-Back ``` POST /current/cloudsync/details/{source_id}/writebacks/{writeback_id}/cancel/ ``` Ends a job that has not run yet, or one waiting on a conflict decision. The job moves to `canceled` and nothing is written to the provider. **Auth:** JWT required. The owner of the source's connected identity, or a workspace admin (a workspace admin needs a sign-in session or an `rwa` credential on the workspace or its org; the identity owner needs only write scope). Cancelling runs no transfer and needs nobody's cloud credential — the same gate is applied to every write action on this queue so that one rule covers the whole surface. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{source_id}` | string | Yes | The import source's id | | `{writeback_id}` | string | Yes | The write-back job's id | No request body. **Precondition:** `status` must be `pending` or `conflict`. **A job already `uploading` cannot be cancelled** — the transfer is in flight, and there is no point at which it could be stopped cleanly. Wait for it to reach a terminal status. **Response (200 OK):** the job, with `status: "canceled"`, under the `writeback` key. **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Source ID is required" | No source id in the path | | `1605 (Invalid Input)` | 406 | "Write-back ID is required" | No write-back id in the path | | `1609 (Not Found)` | 404 | "Import source not found" | Unknown source, or one already deleted | | `1609 (Not Found)` | 404 | "Write-back job not found" | Unknown write-back id | | `1609 (Not Found)` | 404 | "Write-back job does not belong to this source" | The job exists, but under a different source | | `1660 (Conflict)` | 409 | "Only pending or conflicting write-back jobs can be canceled" | **Settled** — the job is `uploading`, or already terminal | | `1693 (Temporarily Unavailable)` | 503 | "Another write-back action for this node is in progress" | **Retryable** — a concurrent action holds this file's turn | | `1693 (Temporarily Unavailable)` | 503 | "The write-back lock for this node expired before the change was applied" | **Retryable** — this file's turn was held and lapsed before the cancel was applied. **Nothing was written** — the job is untouched and still cancellable | | `1664 (Datastore Error)` | 500 | "Failed to load import source" / "Failed to load write-back job" | **Retryable** — the record could not be read | | `1680 (Access Denied)` | 401 | "This action requires the source owner or a workspace admin" | Neither the identity owner nor a workspace admin | | `10767 (Forbidden)` | 403 | "Your credential is not authorized for administrative operations on this Workspace." | The caller is a workspace admin acting on another member's source, but the credential is not admin-capable — no sign-in session, no `rwa` on the workspace or its org (`scope_admin_required`) | | `10560 (Access Denied)` | 403 | "Your token does not have sufficient scope for this Workspace." | A scoped credential whose scope does not cover the source's workspace | | `1610 (Internal Error)` | 500 | "Failed to cancel write-back job" | The job could not be cancelled | **Cancel has no already-covered check.** It removes a live job rather than creating one, so whether the file is already covered is irrelevant to it — the two refusals about coverage that push, retry and resolve can return do not appear on this endpoint. --- ### Reading a Write-Back Refusal This is the part clients get wrong, so it is worth stating on its own. **Refusals on the four write endpoints fall into three groups, and only two of the three are worth sending again.** | Answer | Meaning | What to do | |--------|---------|------------| | `1693 (Temporarily Unavailable)` → **503** | This file's exclusive turn could not be taken, could not be used to check coverage, or lapsed before the change was applied. **Nothing was written**, whichever of the three it was | **Retry.** Back off briefly and send the same request again, unchanged — it cannot apply the change twice | | `1660 (Conflict)` → **409**, "This node already has a write-back in progress" | Another live job already covers this file | **Re-read and wait.** It clears on its own. Do **not** repost blindly | | `1660 (Conflict)` → **409**, any state precondition | The job is not in a status this action accepts | **Settled.** Resending the same request unchanged can never succeed | **Not every 409 on this surface is settled**, which is the opposite of the general rule for conflicts elsewhere in this API. The already-covered refusal is the exception, and treating it as permanent means abandoning work that would have succeeded a moment later. **A cloud-sync policy refusal is a separate answer again.** Push, retry and a `keep_local` resolve refuse with `1700 (Forbidden)` → **403** and `params.reason` = `cloud_sync_read_only` or `cloud_sync_disabled` when the org or workspace policy does not allow write-back — **settled** until an admin widens the policy, so do not retry it. A "Cloud sync policy is temporarily unavailable" `1693` → **503** is retryable like the other 503s. Cancel and a `keep_remote` resolve are never refused by policy. See *Cloud Sync Policy*. **Why the 503s happen at all.** Push, retry, resolve and cancel each take a short exclusive turn on the file they act on, so two write-back actions for one file can never interleave. **Three distinct things can go wrong with that turn. All three answer `1693` → 503, and on all three nothing was written:** | Message | What actually happened | Which endpoints | |---------|------------------------|-----------------| | "Another write-back action for this node is in progress" | The turn could **not be taken** — another action is holding it right now | push, retry, resolve, cancel | | "Could not check whether this node already has a write-back in progress" | The turn was taken, but the already-covered check **could not be carried out**, so the request was never evaluated on its merits | push, retry, resolve | | "The write-back lock for this node expired before the change was applied" | The turn was taken and **held, then lapsed** before the change was applied. The turn is short-lived and is re-proved immediately before the write; it had expired by then, so the request stopped there | push, retry, resolve, cancel | These are three different facts, not three phrasings of one: *could not start*, *started but could not look*, *started and lost the turn before writing*. Only the last one implies the request got as far as being ready to write — and it still wrote nothing, so re-sending it cannot queue, re-queue or cancel anything twice. All three are ordinary transient conditions that a plain retry fixes. **Cancel has no already-covered check** — it removes a live job rather than creating one, so coverage is irrelevant to it and the middle row above cannot occur there. **Live means `pending`, `uploading` or `conflict`** — those are the statuses that make a file "already covered". A `completed`, `failed` or `canceled` job does not block anything. And because `operation` is part of the match, a pending `upload` does not block a `delete` for the same file. **Telling the two 409s apart.** - **On retry**, re-read the job. If it is still `failed` — the status retry requires — the refusal was the transient one, and waiting will clear it. Any other status means the refusal was settled and the job has moved on without you. - **On resolve**, re-read the job the same way. Still `conflict` means the refusal was the transient one; any other status means it was settled. The check **excludes the job being resolved**, so a resolve is never blocked by its own row — only by a *different* live write-back that appeared on the same file while the conflict was waiting for a decision. - **On push** there is no job id to re-read, so the refusal itself is the discriminator. An access-mode refusal, or one saying the node is not an imported file or note, is settled. Only "This node already has a write-back in progress" clears as the queue drains — list the queue for that source and wait for the covering job to reach a terminal status. - **On cancel** there is only one 409 to read — the state precondition — and it is settled. **In short: `1693` → 503, retry it; a `1660` saying the node is already covered, re-read and wait; a `1660` on a state precondition, settled — never retry.** #### Two outcomes worth planning for **A permission refusal from the provider is permanent.** If the connected cloud account cannot write to the folder, every attempt fails identically. Retrying is wasted; the fix is to grant the account write access at the provider, and only then retry. **Renaming or moving an imported file inside Fastio leaves a duplicate at the provider.** The write-back creates the file at its new remote path and deliberately does not delete the object at the old one — an unattended delete of a customer's cloud object is the more dangerous of the two failure modes. Expect the old copy to remain, and remove it at the provider if you do not want it. > Part of the Fastio API Reference. Overview: https://api.fast.io/current/llms/ # Storage Operations Base URL: `https://api.fast.io/current/` Storage endpoints are available on both workspaces and shares. The API patterns are identical -- replace `workspace/{workspace_id}` with `share/{share_id}` in any path below unless noted as workspace-only or share-only. All endpoints require JWT authentication unless otherwise noted. Include the header `Authorization: Bearer {jwt_token}` with every request. --- ## Conventions - **Root folder:** Use the literal string `"root"` as the path parameter (e.g., `/storage/root/list/`) - **Trash folder:** Use `"trash"` to list trashed items (e.g., `/storage/trash/list/`) - **Node IDs:** OpaqueIds -- 29-character alphanumeric strings displayed with hyphens (e.g., `2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4`). Use as-is in API calls. - **Name length:** File, folder, and note names are **1-255 characters**. Every endpoint that creates or renames a node by name enforces this range and rejects a longer name with `1605 (Invalid Input)`. The count is characters, not bytes, so an accented, CJK, or emoji character each counts as one. - **Node types in responses:** `"file"`, `"folder"`, `"note"`, `"link"` (lowercase strings) - **Parent field:** Nodes at the storage root have `"parent": "root"`; nested nodes show the parent's OpaqueId - **Delete vs purge:** `DELETE .../storage/{node_id}/delete/` moves to trash. `DELETE .../storage/trash/delete/` empties the entire trash. `DELETE .../storage/{node_id}/purge/` permanently deletes a single trashed item. - **Workspace folder shares:** Shares that reference a workspace folder have their `root` mapped to the designated folder. All operations are scoped to that subtree. - **Compact responses:** Every storage endpoint that returns nodes (list, details, search, metadata, trash, quickshares) accepts an optional `?output=` query parameter with three detail levels: `terse`, `standard`, or `full`. See the "Compact Responses" section below for the full contract, field lists, and the HTTP 406 rule for multi-level combinations. (QuickShare creation is **deprecated** — use the durable **File Share** instead.) - **Reading the error tables:** four-digit `16xx`/`17xx` values 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. Codes shown as five or six digits (and the `9661`-`9669` family) ARE `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. --- ## Compact Responses (`output=`) Every storage endpoint that returns node objects — folder listings, node details, search hits, metadata endpoints, trash listings, and quickshares — accepts an optional `output` query parameter that selects the shape of each node in the response. 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 node (cumulative) | |-------|-------------------------------------------| | `terse` | `id`, `type`, `name`, `parent`, `version`, file/note-only `mimetype` and `size`, `modified`, recursive `nodes` for folders when the endpoint returns children, file/note-only `summary` reduced to `{title}` only, file/note-only `previews` reduced to a per-type `{ready: bool}` map (keys preserved: `thumbnail`, `image`, `pdf`, `mp4`, `hlsstream`, `audio`, `spreadsheet`), folder-only `is_share_root`/`share_id`, file/note-only `metadata_facts` reduced to a comma-separated `fields` name list | | `standard` | terse + `created`, `restricted`, `dmca`, `locked`, file/note-only `mimecategory`, file/note-only `summary` widened to `{title, short}`, `origin` reduced to `{creator, type, actor}`, file/note-only `ai` reduced to `{state}`, file/note-only `metadata` reduced to `{title, short}` (user-authored overrides), `deleted` and `deleted_from` (present ONLY while the node is in the trash — on a live node they are absent at every tier, including `full`, so their absence never means "not deleted" for a node you have not checked), `is_imported`, `import_state`, link-only `target_type`/`target_id`, `metadata_facts` reduced to `field`, abbreviated `value`, and `value_truncated` | | `full` | standard + `summary.long`, note-only `summary.category`, `virus`, full `ai` object, full `file_attributes` (embedded EXIF / media metadata, returned only to callers permitted to download the file -- see *Node Object Schema*), all remaining `origin.*` fields, `hash`, `hash_algo`, `crc32c`, `lock_info`, `import_metadata`, full `previews` state map, full `metadata_facts` items (complete fact records) | **A tier is a CEILING, not a guarantee — read every row above as "at most these fields".** A key is present only when the node actually has it, so the same tier returns a different key set for a file, a folder, and a link. File/note-only: `mimetype`, `size`, `summary`, `previews`, `metadata_facts`, `mimecategory`, `ai`, `metadata`, `hash`, `hash_algo`, `crc32c`, `file_attributes`. Folder-only: `is_share_root`, `share_id`, recursive `nodes`. Link-only: `target_type`, `target_id`. Trashed-only: `deleted`, `deleted_from`. The gap is large — at `full`, a file returns 28 keys and a folder 17 — so **treat a missing key as "not applicable to this node type", never as a null value or an error**, and never infer a node's state from a key's absence. Use `terse` for list rows, tree rendering, pickers, breadcrumb navigation, and drag-and-drop targets — it carries `modified` (so list rows can render the date column and "sort by modified" without a follow-up fetch), a per-type `previews` readiness map (so the thumbnail selector can pick the best available source), and the summary title. Use `standard` for most detail views, file-browser main lists, and any UI that shows AI-processing state, lock/restricted chips, DMCA chips, import-provider chips, or trash state — it adds the `ai.state` that drives the "summarizing…" spinner, timestamps, `origin` creator, type and actor, the `dmca` flag for DMCA chip rendering, `is_imported` for import-provider chips, link-node `target_type`/`target_id` discriminators, and the `metadata.title`/`metadata.short` user overrides that list rows render when a custom title is set. Use `full` (or omit the parameter) for the node detail pane, virus/AI inspection, version history, and any workflow that reads long-form summaries, EXIF, import provider metadata, or content hashes. 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 in `llms.txt` for the full contract. --- ## Node Object Schema All endpoints that return node data use this format. Fields vary by node type. | Field | Type | Present On | Description | |-------|------|------------|-------------| | `id` | string | all | OpaqueId of the node | | `name` | string | all | File, folder, or note name | | `type` | string | all | `"file"`, `"folder"`, `"note"`, or `"link"` | | `parent` | string | all | Parent folder OpaqueId or `"root"` | | `size` | integer | file, note | File size in bytes | | `hash` | string | file, note | Content hash of the file | | `hash_algo` | string | file, note | Hash algorithm (e.g., `"md5"`) | | `crc32c` | string/null | file, note | Whole-file CRC-32C (Castagnoli) of the content: 8 lowercase hex digits (e.g. `"e3069283"`), `"00000000"` for an empty file. `null` for content stored before the platform began recording it. An upload that was already in progress when the server was updated may complete without the whole-file check, and its stored `crc32c` may be `null`. An integrity checksum only -- compare it with a CRC-32C you computed locally to confirm the bytes; `hash` is unchanged and remains the content hash | | `mimetype` | string | file, note | MIME type (e.g., `"application/pdf"`) | | `mimecategory` | string | file, note | MIME category (e.g., `"document"`, `"image"`, `"pdf"`) | | `version` | string | all | Current version identifier — an OpaqueId in the hyphenated form (e.g., `"3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5"`). Send it back as `if_version_id` to make a write conditional | | `created` | string | all | Creation timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `modified` | string | all | Last-modified timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `restricted` | boolean | all | Whether the file has been restricted | | `dmca` | boolean | all | Whether the file has a DMCA flag | | `locked` | boolean | all | Whether the node has an active lock | | `lock_info` | object/null | all | Lock details when locked; `null` otherwise. `{"locker_uid": "...", "locked_at": "...", "expires_at": "...", "locker": {"display_name": "...", "agent_name": "...", "agent_name_source": "...", "actor": {...}}}`. `locker.display_name` is the holder's name, or `null` when it cannot be resolved. `locker.actor` describes who took the lock in the common actor shape -- see *Actor Attribution* below. `locker.agent_name` names the agent that took the lock on that account's behalf, when one did, and `locker.agent_name_source` says where that name came from; both are `null` when a person took the lock directly. **`agent_name` is self-declared, not verified** -- display it beside the holder, never rely on it to identify or authorize anyone. **It belongs to the CREDENTIAL, not the lock**: it is read from the JWT claim or API-key label set when you signed in or minted the key, there is no per-lock parameter for it, and every lock that credential takes carries the same label -- see *Lock Status* below. **Identity requires MEMBER level or above**: any caller below member -- including share guests and public-link recipients, not only outsiders -- receives `lock_info: null` while `locked` stays truthful, so they still learn the file is held without learning by whom | | `virus` | object | file, folder | Virus scan status. A scanned file is `{"status": "scanned", "infected": false}` (an infected one has `"infected": true` and a `reason`); otherwise `status` is `"unscanned"` or `"unknown"` with a `reason`. Folders always report `"unsupported"`. Notes and links carry no `virus` key | | `file_attributes` | object | file, note | Metadata read out of the file itself -- `media_metadata` and/or `exif_metadata` when present. Returned only to callers permitted to download the file; see *Embedded File Metadata* below | | `summary` | object/null | file, note | AI-generated summary: `{"title": "...", "short": "...", "long": "..."}` (notes add `category`); `null` when there is none | | `metadata` | object/null | file, note | User-defined custom title and description overrides | | `metadata_facts` | object | file, note | Extracted metadata facts recorded for the node -- `count`, `total`, `is_truncated`, and a payload shaped by the `output` level (`items` on `full` and `standard`, a comma-separated `fields` name list on `terse`). Returned only to WORKSPACE members; never present in a share context; see *Extracted Metadata Facts* below | | `previews` | object | file, note | Preview generation state per type (e.g., `{"thumbnail": {"state": "ready"}}`) | | `ai` | object | file, note | AI processing state: `{"state": "...", "attach": true/false, "summary": true/false}`. `summary` says whether an AI summary exists | | `origin` | object | all | Origin info: `{"type": "upload", "creator": "{user_id}", "actor": {...}}`, plus `upload_session_id` when the node came from an upload. `origin.actor` says who created the node and whether an agent acted for them -- see *Actor Attribution* below | | `is_imported` | boolean | all | Whether the node was imported from a connected cloud source | | `import_metadata` | object/null | all | `{"access_mode": "...", "provider": "...", "synced_at": "..."}` for an imported node; `null` otherwise | | `import_state` | object/null | all | Sync state of an imported node: `is_root`, `provider`, `access_mode`, `status`, `synced_at`, `source_id`, and `graft_root_id` (plus `grafted_by` on the import root, in workspace context only); `null` when the node is not imported | | `deleted`, `deleted_from` | string, string/null | trashed nodes only | When the node was trashed, and the OpaqueId of the folder it was trashed from | | `is_share_root` | boolean | folder | Whether the folder is the root of a share | | `share_id` | string | folder | Id of the share rooted at this folder; present only when `is_share_root` is `true` | | `target_type`, `target_id` | string | link | What the link points at | ### Actor Attribution (`actor`) Every attributable action records one `actor` object describing **who acted**: the person the action is credited to, and whether an agent acted on their behalf. The shape is the same on every surface that carries it: ```json "actor": { "user_id": "9876543210987654321", "kind": "agent", "agent_name": "Dobby", "name_source": "api_key_label", "credential_type": "api_key", "verified": false } ``` | Field | Type | Description | |-------|------|-------------| | `user_id` | string/null | 19-digit id of the user the action is credited to, as a string; `null` when none was recorded | | `kind` | string | `human`, `agent`, `api_key`, `app`, `system`, or `unknown` -- see below | | `agent_name` | string/null | Name of the agent that acted for the user, or `null` | | `name_source` | string/null | Where `agent_name` came from: `api_key_label`, `oauth_client`, `platform`, `session_declared`, or `null` | | `credential_type` | string/null | The kind of credential that acted: `session`, `api_key`, `oauth`, `platform`, or `null` | | `verified` | boolean | `true` only for Fastio's own built-in agent; `false` for everything else | **Kinds:** - `human` -- the user acted through an interactive sign-in session. - `agent` -- an agent acted for the user: a named API key (`name_source: "api_key_label"`), an OAuth/PKCE client that declared an agent name (`oauth_client`), a password sign-in that declared `agent_name` (`session_declared` -- see `GET /current/user/auth/` in the Auth reference), or Fastio's own built-in AI agent (`platform`, the only verified kind). - `api_key` -- an API key with no name. - `app` -- an OAuth app that declared no agent name. - `system` -- platform housekeeping such as cloud sync, credited to the relevant user. - `unknown` -- recorded before attribution existed, or no trusted record of who acted. Show the user's name only. **Self-declared vs verified.** `verified: true` appears only on Fastio's own built-in agent, a verified platform agent displayed as "Ripley". Every other `agent_name` is **self-declared** by the credential's owner or client: display it beside the user (for example "Dobby (via API key) for Derek"), but never present it as verified and never use it to identify or authorize anyone. Names reserved for the platform are rejected when declared. Attribution grants and changes no permissions. **Where it appears:** node `origin.actor`, version `author.actor` (see *List Versions*), lock and intent `locker.actor`, event `actor`, comment `actor`, invitation `inviter_actor`, and each metadata fact's `actor`. **Older items.** A node created before attribution existed reads its actor from the original creation record where one is available; otherwise `kind` is `"unknown"`. Nothing is backfilled. **Anonymous contributors.** When the acting user is an anonymous public-link contributor, `actor` is withheld the same way `origin.creator` is: `{"user_id": "anonymous", "kind": "unknown", "agent_name": null, "name_source": null, "credential_type": null, "verified": false}`. ### Embedded File Metadata (`file_attributes`) `file_attributes` carries metadata read out of the file's own bytes: `exif_metadata` (camera and lens details, device make and model, authoring software, author and copyright text) and `media_metadata` (container, codec, and per-stream details). Because that content comes from inside the file, it is returned **only to callers who are permitted to download the file**. - When the caller may view a file but not download it, the `exif_metadata` and `media_metadata` keys are **omitted**, so `file_attributes` comes back as an empty object `{}`. It is never `null`, no error is raised, and no other field changes. - Everything else reported about a file -- `size`, `hash`, `hash_algo`, `crc32c`, `mimetype`, `mimecategory`, `created`, `modified`, `origin`, `previews`, `virus`, `ai`, and the AI `summary` -- is derived by the platform rather than read from the file, and is returned regardless of download rights. - **Workspace responses are unaffected.** Workspace members always have download rights, so workspace endpoints return `file_attributes` exactly as before. - On a **share**, the gate follows the share's file download permission: a share whose downloads are turned off (and receive-share guests, who are upload-only) get `{}`. Members and administrators always download, so they always receive it. - On a **File Share** link, the gate follows the link's `effective_capability`: `view` alone gets `{}`; `download` and `edit` receive the metadata. ### Extracted Metadata Facts (`metadata_facts`) `metadata_facts` carries the extracted metadata facts recorded for a file: the `field`-and-`value` pairs the extraction pipeline produced against the workspace's field vocabulary. It is returned on `file` and `note` nodes at all three `?output=` levels, in a different shape at each level, and every shape carries `count` -- how many facts this payload carries, **not** how many the node holds -- `total` -- how many the node holds, counted before any cap -- and `is_truncated`. Folder and link nodes never carry it, and the lightweight recent-files listing does not include it. **If you send no `output` parameter you get `full`** -- on node listings and on node details alike. There is one global default and no endpoint overrides it, so the two cannot diverge: a node arriving from a folder listing carries the same complete fact records as the same node fetched directly. The lean and projected shapes below are reachable only by asking for them explicitly. - **`full`** -- up to 100 facts as complete records: `field`, `value`, `declared_type`, `stored_type`, `source` (one of `ai`, `user`, `exif`, `mediainfo`, `validated_server`), `confidence` (`low`, `medium`, `high`, `certain`, or `null`), `rationale` (a string or `null`), `updated` (`YYYY-MM-DD HH:MM:SS UTC`), and `actor` (who wrote the value, in the shape described under *Actor Attribution* above; an AI-extracted value is credited to the verified platform agent acting for the user who triggered extraction). A fact is identified by its `field` name -- no id of any kind is returned. ```json "metadata_facts": { "count": 2, "total": 2, "is_truncated": false, "items": [ { "field": "invoice_number", "value": "INV-1042", "declared_type": "string", "stored_type": "string", "source": "ai", "confidence": "high", "rationale": "Read from the header block on page 1", "updated": "2026-08-20 14:02:11 UTC", "actor": {"user_id": "9876543210987654321", "kind": "agent", "agent_name": "Ripley", "name_source": "platform", "credential_type": "platform", "verified": true} }, { "field": "amount_due", "value": 4820.5, "declared_type": "float", "stored_type": "float", "source": "ai", "confidence": "certain", "rationale": null, "updated": "2026-08-20 14:02:11 UTC", "actor": {"user_id": "9876543210987654321", "kind": "agent", "agent_name": "Ripley", "name_source": "platform", "credential_type": "platform", "verified": true} } ] } ``` - **`standard`** -- up to 8 facts carrying `field`, an abbreviated `value`, and a `value_truncated` boolean, with no provenance. The 8 are chosen in a fixed priority order -- typed values first (numbers, dates, booleans), then identifier fields (names ending `_number`, `_id`, `_code`, `_reference`), then everything else, alphabetical by field name within each group -- so a monetary or date fact is never cut in favour of an address or contact string. `value_truncated` is always present at this level. A string value longer than 64 characters is cut to 64 characters with `…` appended and `value_truncated` is `true`; a complete string, number, boolean or `null` value is returned unchanged with `value_truncated: false`. A `json`-typed **list** value is returned as a real array (no longer rendered as a JSON string): the array is whole, with `value_truncated: false`, when its JSON encoding is 64 characters or shorter -- counted on the value's own characters, with unicode and `/` counted unescaped rather than on an escaped-for-transmission byte form -- and otherwise holds the longest leading run of elements whose encoding fits that same budget (at least one element) with `value_truncated: true` -- elements present are exact, except that when the first element alone does not fit, it is cut so the one-element array's own encoding fits the 64-character budget -- the kept text comes out shorter than 64 characters (for example 59 plus `…` for a plain string) -- cutting the string itself for a string element or the element's JSON encoding for any other type, whatever the element's own type (a number, boolean, `null`, nested object or array all reduce to that same cut string). A `json`-typed **object** value is returned as the structure itself when its encoding is 64 characters or shorter by the same measure, and otherwise as a cut JSON preview **string** with `value_truncated: true`. Use `full` when you need the exact value or its type. **Never paste a `value_truncated: true` value into a `metadata_filters` equality predicate -- it cannot match; call `full` (or the node facts endpoint) for the complete value.** **The same key name reappears elsewhere under a different rule: `results[].matched_fields[].value_truncated` on *Metadata Search* below cuts to a window taken around the matched text, with no ellipsis appended -- do not apply one "strip the ellipsis" handler to both fields.** **Because each tier caps independently, `count` is not comparable across tiers for the same node:** a node holding 9 facts can report `"count": 9, "is_truncated": false` at `terse` (under its 20-name cap) and `"count": 8, "is_truncated": true` at `standard` (capped at 8) for the identical set of facts -- both are correct, and `is_truncated` signals that more facts exist beyond what was returned. **`total` IS comparable across tiers**, because it counts what the node holds rather than what the tier emitted: the same node reports `"total": 9` at both levels. Compare totals across tiers, never counts. ```json "metadata_facts": { "count": 8, "total": 14, "is_truncated": true, "items": [ {"field": "abstract", "value": "This master services agreement between Northwind and Con…", "value_truncated": true}, {"field": "amount_due", "value": 4820.5, "value_truncated": false} ] } ``` - **`terse`** -- up to 20 field **names** and no values at all, as one comma-separated string under a `fields` key (`fields`, not `items`), in the same priority order as `standard` (typed values, then identifiers, then the rest; alphabetical within each group). **Splitting that string on `, ` is safe:** a field name may contain only letters, digits, spaces, underscores and hyphens (Unicode-aware, so accented and non-Latin names are fine), so a comma can never appear inside a name. That charset is enforced wherever a name enters the vocabulary, including names an AI proposes during extraction. **Which characters are allowed is a different question from which names are the SAME name:** field names are compared case-, accent- and width-insensitively, so `Category`, `category` and `catégory` are ONE field, not three — the first spelling written owns the name and a later write in another spelling resolves to it. ```json "metadata_facts": { "count": 5, "total": 5, "is_truncated": false, "fields": "abstract, amount_due, contract_type, currency, invoice_number" } ``` - **`is_truncated` is present at every level** and is `true` when the node holds more facts than were returned. To read the complete set, call the node facts endpoint -- `GET /current/workspace/{workspace_id}/storage/{node_id}/metadata/facts/` -- which is not paginated and returns every fact on the node in one call. - **`total` is present at every level** and is how many facts the node holds, counted before any cap. `count` is what this payload carries; `total` is what there was to carry; `total` is never less than `count`. Read the pair as "8 shown of 14" -- that is what tells you whether re-reading the node at `?output=full` is worth a second call, and when `total` equals `count` there is nothing more to fetch. The node facts endpoint's own wrapper does not carry it, and does not need to: that read is uncapped, so its `count` already is the total. - **An absent `metadata_facts` does not mean the file has no metadata.** The key is **omitted entirely -- never `null`** -- when the caller is not entitled to extracted metadata, or when the facts could not be read for that request. A file that genuinely holds none returns `"count": 0, "total": 0` with an empty `items` (or an empty `fields` string) instead. - **Extracted metadata is a WORKSPACE-ONLY surface.** It is returned only to members of the owning workspace, because both the values and the field names are that team's own data -- the field vocabulary is what the team decided is worth recording about its documents, and it is not present in the file's bytes. It is **never** returned in a share context, to any share role: not to share administrators or members, not to share guests or public-link guests, and not to File Share link recipients. A workspace-folder share mirrors workspace nodes, and those nodes are covered by the same rule. ### AI States | Value | Description | |-------|-------------| | `disabled` | AI processing is disabled for this file | | `pending` | Queued for AI processing | | `in_progress` | AI processing is running | | `ready` | AI processing complete | | `failed` | AI processing failed | | `indexed` | File has been indexed for search and RAG | --- ## Keyset Pagination (Storage List) Storage listing endpoints (`list` and `recent`) use cursor-based pagination, not offset-based. **Request parameters:** | Parameter | Type | Default | Description | |-----------|--------|---------|-----------------------------------------------------| | `sort_by` | string | `name` | One of: `name`, `updated`, `created`, `type` | | `sort_dir` | string | `asc` | One of: `asc`, `desc` | | `page_size` | int | `100` | One of `100`, `250`, `500`. `list` rejects any other value with `406`; `recent` snaps it to the nearest allowed size | | `cursor` | string | -- | Opaque cursor string from previous response | **Response pagination fields:** | Field | Type | Description | |--------------------------|--------------|--------------------------------------| | `pagination.has_more` | boolean | Whether more pages exist | | `pagination.next_cursor` | string/null | Cursor for the next page; `null` if last page | | `pagination.page_size` | integer | Effective page size used | **Notes:** - Cursors are HMAC-signed; tampered cursors are rejected with an error. - When using a cursor, the page size from the cursor takes precedence over the request parameter. - Results for the first page may be slightly delayed. - The `recent` endpoint ignores `sort_by` and `sort_dir` (always sorted by `updated` descending). --- ## List Folder Contents ``` GET /current/workspace/{workspace_id}/storage/{parent_id}/list/ GET /current/share/{share_id}/storage/{parent_id}/list/ ``` List the contents of a folder. Uses keyset pagination (see above). **Auth required.** Permission: View (workspace), Guest+ (share). Share `list` on public shares may not require JWT. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{parent_id}` | string | Yes | Folder OpaqueId, `"root"`, or `"trash"` | **Query parameters:** See Keyset Pagination section above. **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/root/list/?sort_by=name&sort_dir=asc&page_size=100" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "nodes": { "count": 2, "items": [ { "id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "type": "folder", "name": "Documents", "parent": "root", "created": "2025-01-01 00:00:00 UTC", "modified": "2025-01-20 14:45:00 UTC" }, { "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "type": "file", "name": "photo.jpg", "parent": "root", "size": 2048000, "mimetype": "image/jpeg", "created": "2025-01-10 09:00:00 UTC", "modified": "2025-01-10 09:00:00 UTC" } ] }, "pagination": { "has_more": true, "next_cursor": "eyJwIjoiMmFiYzEyMy4uLiIsInMi...", "page_size": 100 } } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `nodes.count` | integer | Number of nodes in this page | | `nodes.items` | array | Array of node objects for current page | | `pagination.has_more` | boolean | `true` if more pages exist | | `pagination.next_cursor` | string/null | Cursor for next page | | `pagination.page_size` | integer | Actual page size used | **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Folder not found | | `1605 (Invalid Input)` | 406 | Node is not a folder | | `1605 (Invalid Input)` | 406 | Invalid pagination cursor (tampered or mismatched) | | `1680 (Access Denied)` | 401 | No permission to view files in this share (share only) | --- ## Workspace Inventory ``` GET /current/workspace/{workspace_id}/storage/inventory/ GET /current/share/{share_id}/storage/inventory/ ``` Enumerate **every live node** in a workspace or share -- files, folders, notes and links -- as one flat, paged list covering the whole tree. There is no folder scope and no recursion to drive: one walk returns everything. Trashed nodes are excluded. **This is the intended way to enumerate every live file.** Listing the tree with `/storage/{parent_id}/list/` costs one call per folder, and the number of folders cannot be known in advance. An inventory row is deliberately lightweight, so enumerating a whole workspace is much cheaper per file than listing folders -- reach for this endpoint instead of walking the tree whenever you want "everything in this workspace". Rows are ordered by **node id ascending** and walked with a keyset cursor. Ordering by id is what makes the walk stable: renaming or editing a file between pages does not move it, so it is neither skipped nor served twice. **The default `page_size` is `100` because that is what an AI agent can render whole.** An agent renders a page into a context window under a ceiling of roughly 35,000 characters, and a lean inventory row costs about 324 rendered characters -- about 386 with `include=path` -- so a page of 100 arrives complete while a larger one has to be truncated, and a truncated enumeration is indistinguishable from a complete one. Use `250` or `500` when the caller is not an agent -- a sync process or a UI has no rendering ceiling and wants the round trips instead. The cursor is **the id of the last row you kept** — not an opaque token. The comparison is **strictly greater than**, so that row is not repeated, and it is **independent of `type` and `page_size`**, both of which may change between pages. This is deliberate: a client that renders a page and truncates it stopped somewhere the server does not know, and an opaque cursor would make it skip every row it dropped. Rows are **terse by design**. There is no `output=` parameter on this endpoint and it is not accepted. A caller that wants a file's full record already has its id -- read it with `/storage/{node_id}/details/`. **Auth required.** Permission: View (workspace), Guest+ (share). Share `inventory` on public shares may not require JWT. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | **Query parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `page_size` | integer | No | `100` | Must be `100`, `250`, or `500`. See the note below on why the default is `100`. | | `cursor` | string | No | - | The `id` of the last row you kept, hyphenated or raw. Absent means the first page. Strictly greater-than, and independent of `type` and `page_size`. A value that is not a node id is refused with `406`. | | `type` | string | No | - | Restrict the walk to one node type: `file`, `folder`, `link`, or `note` | | `include` | string | No | - | Comma-separated extra field groups. Only `path` is recognised. An unrecognised token is **refused with `406`**, never silently ignored. | **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/inventory/?type=file" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "nodes": { "count": 2, "items": [ { "id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "type": "folder", "name": "Documents", "parent_id": "root", "size": null, "mimetype": null, "updated": "2025-01-20 14:45:00 UTC", "version": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5", "summary": null, "facts_total": null }, { "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "type": "file", "name": "Q4 Report.pdf", "parent_id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "size": 2048000, "mimetype": "application/pdf", "updated": "2025-01-10 09:00:00 UTC", "version": "3gzfc-3x7gt-4xsnm-qw52d-sjzcx-cuxa", "summary": { "title": "Q4 revenue and headcount summary" }, "facts_total": 12 } ] }, "pagination": { "has_more": true, "next_cursor": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "page_size": 100 } } ``` The envelope uses the **same key names as `/storage/{parent_id}/list/`** (`nodes.count`, `nodes.items`, `pagination.*`), so a client that already pages folder listings reuses its parser unchanged. **Response fields:** | Field | Type | Description | |-------|------|-------------| | `nodes.count` | integer | Number of rows in this page | | `nodes.items` | array | The inventory rows for this page | | `nodes.items[].id` | string | Node OpaqueId | | `nodes.items[].type` | string | `"file"`, `"folder"`, `"link"`, or `"note"` | | `nodes.items[].name` | string | Node name | | `nodes.items[].parent_id` | string | Parent folder OpaqueId, or `"root"` for a node directly in the storage root | | `nodes.items[].size` | integer/null | Size in bytes for files and notes; `null` for folders and links | | `nodes.items[].mimetype` | string/null | MIME type for files and notes; `null` otherwise | | `nodes.items[].updated` | string | Last-modified time, `YYYY-MM-DD HH:MM:SS UTC` | | `nodes.items[].version` | string | Current version OpaqueId | | `nodes.items[].summary` | object/null | `{"title": "..."}` when the file has an AI summary, otherwise `null`. **Title only** -- the short and long forms stay on `details/`. | | `nodes.items[].facts_total` | integer/null, or absent | How many extracted metadata fields the file has. **Emitted only for callers who may read metadata facts (workspace members)** -- absent for everyone else, and never emitted at all by the share twin. When present: an integer count, or `null` when the file has never had metadata extracted. Folders and links are always `null` when the key is present; notes follow the same rule as files. See the note below -- an absent key means *unknown*, never zero. | | `pagination.has_more` | boolean | `true` if more pages exist | | `pagination.next_cursor` | string/null | Cursor for the next page; `null` when there are no more pages | | `pagination.page_size` | integer | Effective page size used | **`facts_total` -- absent, `null` and a number mean three different things.** The field is gated on permission to read metadata facts, and facts never cross into a share, so it is emitted only for callers who may read them (workspace members) and the **share twin never emits it at all**. Read the three states separately: - **Absent** -- unknown, or not permitted for this caller. The key is also absent when the count is not available for this response. Treat it as *unknown*: never as `0`, and never as "this file has no metadata". - **`null`** -- the key is present and the file has never had metadata extracted. Folders and links are always `null` when the key is present; notes follow the same rule as files. - **A number** -- that many extracted metadata fields. **`include=path` -- where each node lives.** Add `include=path` and every row gains three more fields: `path` (string/null), `ancestors` (array of `{id, name}`), and `path_complete` (boolean). They have **exactly the same meaning and shape** as the fields of the same names on a storage-search hit -- read *Folder paths* under the *Search* section below for the full contract, including why `path_complete` is the field the other two are read through. `include` is the only way to get them here; an `include` token other than `path` is refused with `1605 (Invalid Input)` / `406` rather than ignored, so a typo can never look like "the field is unavailable". **Share twin.** Inventory is available on **independent-storage shares only**. A share backed by a workspace folder refuses it with `1609 (Not Found)` / `404` and the message *"Inventory is not available for Shared Folders"* -- the same restriction the share `storage/search/` twin has. Enumerate such a share's contents through the backing workspace instead. The share twin drops rows the caller may not view after the page is read, so a share page can hold fewer than `page_size` rows (even none) while `has_more` is `true` -- page on `has_more` and `next_cursor`, never on a short page. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | `cursor` is not a node id | | `1605 (Invalid Input)` | 406 | Unsupported `include` value | | `1605 (Invalid Input)` | 406 | `output` supplied, an unrecognised `type`, or any query parameter not listed above | | `1609 (Not Found)` | 404 | Inventory is not available for Shared Folders (share only, workspace-folder shares) | | `1680 (Access Denied)` | 401 | No permission to view files in this share (share only) | --- ## Node Details ``` GET /current/workspace/{workspace_id}/storage/{node_id}/details/ GET /current/share/{share_id}/storage/{node_id}/details/ ``` Get full details for a single node (file, folder, note, or link). **Auth required.** Permission: View (workspace), View (share). **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | Node OpaqueId | **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "format": "single", "node": { "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "type": "file", "name": "document.pdf", "parent": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "size": 5242880, "hash": "d41d8cd98f00b204e9800998ecf8427e", "hash_algo": "md5", "crc32c": "6f9c3a21", "mimetype": "application/pdf", "mimecategory": "pdf", "version": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5", "created": "2025-01-15 10:30:00 UTC", "modified": "2025-01-20 14:45:00 UTC", "restricted": false, "dmca": false, "locked": false, "lock_info": null, "is_imported": false, "import_metadata": null, "import_state": null, "virus": { "status": "scanned", "infected": false }, "file_attributes": {}, "summary": { "title": "Quarterly Report", "short": "Q4 financial summary", "long": "Comprehensive financial report covering revenue and expenses." }, "metadata": null, "previews": { "thumbnail": { "state": "ready" }, "pdf": { "state": "ready" } }, "ai": { "state": "indexed", "attach": true, "summary": true }, "origin": { "type": "upload", "creator": "9876543210987654321", "actor": { "user_id": "9876543210987654321", "kind": "human", "agent_name": null, "name_source": null, "credential_type": "session", "verified": false } }, "metadata_facts": { "count": 0, "total": 0, "is_truncated": false, "items": [] } } } ``` **Response fields:** See Node Object Schema above for complete field reference. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | `{node_id}` is not a node OpaqueId | | `1609 (Not Found)` | 404 | Node not found | | `1680 (Access Denied)` | 401 | No permission to view details (share only) | ### Bulk Form The `details` endpoint accepts a comma-separated list of node ids in place of a single `{node_id}`: ``` GET /current/workspace/{workspace_id}/storage/{id1},{id2},{id3}/details/ ``` Up to **25** ids per call. Duplicate ids are silently deduplicated. Empty segments (e.g. trailing comma) return `406`. The bulk form is currently only available for the workspace path. The bulk response shape differs from the single-id form. Successfully resolved nodes appear as an array under `nodes`; per-id failures appear in a parallel `errors` array. HTTP status is `200` when at least one node resolves and `404` when every requested id errored. ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4,2qk7d-kri4y-yievb-q5hri-eq4io-hij5,2ik5q-a43cm-uixi2-van5r-3eolo-7mue/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (HTTP 200), node objects abbreviated:** ```json { "result": true, "format": "multi", "nodes": [ { "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "type": "file", "name": "report.pdf" }, { "id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "type": "folder", "name": "Documents" } ], "errors": [ { "node_id": "2ik5q-a43cm-uixi2-van5r-3eolo-7mue", "code": 133123, "message": "No such file or folder exists" } ] } ``` Each `nodes` entry is a full node object (see *Node Object Schema*). Each `errors` entry echoes the id as sent in `node_id`; its `code` is `191878` when the id is not a storage node id, `133123` when no such node exists, `146950` when the file's content is no longer available, and another value for an internal failure. The workspace details endpoint carries a top-level `format` field on every response -- `"single"` (single-id form, paired with a `node` object) or `"multi"` (bulk form, paired with `nodes` + `errors` arrays) -- so a client can branch on `format` instead of inferring shape from key presence. The share details endpoint has only the single-id form and always returns `"format": "single"`. | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | Empty segment between commas | | `1605 (Invalid Input)` | 406 | More than 25 unique ids in one request | | `1609 (Not Found)` | 404 | Every requested id errored. The body keeps the bulk shape -- `"result": false`, `"format": "multi"`, an empty `nodes` and the populated `errors` -- with no top-level `error` object | --- ## Node Content (extracted text) ``` GET /current/workspace/{workspace_id}/storage/{node_id}/content/ GET /current/share/{share_id}/storage/{node_id}/content/ ``` Return a file's or note's **extracted text** as ordered chunks -- the same text the platform already extracted and indexed for search and AI. `read/` hands back raw bytes and search returns only a snippet, so this is the route that lets a caller actually read a PDF's words. **The unit is a chunk, not a page.** Text is chunked for retrieval, and a chunk can span two pages or split one, so `page=` returns the whole chunks that OVERLAP that page -- never a page-shaped blob of text. Pages are known only for formats that carry them (documents that were paginated when they were converted); spreadsheets, plain text, code, and notes have no pages and are addressed by `chunk_from`/`chunk_to` instead. Read `page_addressable` before sending a `page`. Audio and video files are not served by this route today and answer `indexed: false`. **A chunk's address is its `position`.** `position` is the chunk's 0-based place in the file's read order, and it is what `chunk_from`/`chunk_to` select on. `chunk_index` is the older name for that idea and is now nullable -- newer text may not carry one -- so address chunks by `position` and treat `chunk_index` as legacy. `sequence` is a different coordinate, assigned when the file's text was extracted: it rises through the file without being contiguous, is `null` on text extracted before it was assigned, and is published for correlation rather than addressing. `chunk_from`/`chunk_to` reach the first 10000 positions of a file; a position at or beyond `10000` cannot be addressed with them and is refused as an invalid window -- keep walking a large file with `cursor`, which has no such limit. Only the file's **current version** has text. `indexed_version_id` names the version the returned chunks were indexed from. A cursor walk is not a snapshot: a cursor is stamped with the version it was issued against, so if the file is replaced mid-walk, the next call with the old cursor returns an EMPTY `chunks` list with `next_cursor: null`. `indexed_version_id` shows the new version once its text is indexed, and is `null` while it is still being processed -- restart from the beginning either way, rather than splicing old text onto new. **Auth required.** Permission: View (workspace). The share path requires **download** permission, not view -- extracted text is the interior of the file, so a guest who may not fetch the bytes may not read the text either. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | Node OpaqueId. Both spellings are accepted -- with or without hyphens. Ids in the response are always hyphenated. | **Query parameters:** All optional. At most **one** window selector (`page`, `chunk_from`/`chunk_to`, `q`); with none, the response starts at the beginning of the file. | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `q` | string | No | -- | **Relevance mode.** Rank this file's own chunks by keyword match and return the best ones, each with full text and a numeric `score`. 1-512 characters. Cannot be combined with `page`, `chunk_from`/`chunk_to`, or `cursor`. | | `page` | integer | No | -- | Return the chunks whose `[start_page, end_page]` range overlaps this page. 1-based. | | `chunk_from` | integer | No | -- | Start of an inclusive `position` range. Minimum `0`. Positions at or beyond `10000` cannot be addressed this way -- continue into a large file with `cursor` instead. | | `chunk_to` | integer | No | -- | End of that inclusive `position` range. Requires `chunk_from` and must be greater than or equal to it, and is subject to the same `10000` ceiling. | | `cursor` | string | No | -- | Continue after this chunk within the selected window. Opaque; pass back the `next_cursor` from the MOST RECENT response verbatim, never a value you built yourself and never one you stored from an earlier walk. Not valid with `q`. When used together with `chunk_from`/`chunk_to`, the cursor must have come from a walk of that same range: a token whose position falls outside the range is refused as an invalid window. | | `limit` | integer | No | `5` (`3` with `q`) | Chunks per response. `1`-`20`. | | `max_bytes` | integer | No | `32768` | UTF-8 **byte** budget over the text in one response, applied in the ordered modes only. `1024`-`262144`. | | `output` | string | No | `full` | `terse` returns the chunk map with no `text`; `standard` and `full` include it. Composable with the `markdown` modifier like every other storage endpoint. | **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/content/?limit=2" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "node_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "name": "Master Services Agreement.pdf", "mimetype": "application/pdf", "indexed": true, "complete": true, "indexed_version_id": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5", "page_addressable": true, "num_pages": 3, "total_chunks": 3, "chunks": [ {"position": 0, "sequence": 0, "chunk_index": 0, "start_page": 1, "end_page": 1, "chars": 4000, "chunk_hash": "9f2c41ab07", "score": null, "text": "MASTER SERVICES AGREEMENT ..."}, {"position": 1, "sequence": 12, "chunk_index": 1, "start_page": 1, "end_page": 2, "chars": 4309, "chunk_hash": "0e7bd934aa", "score": null, "text": "... 5. TERM AND TERMINATION ..."} ], "next_cursor": "", "truncated": false } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `node_id` | string | The node, hyphenated. | | `name`, `mimetype` | string | Taken from the node itself, never from the text index, so a stale index entry can never change what the file is reported to be. | | `indexed` | boolean | Whether this version has any extracted text at all. Describes the WHOLE file, not the window you asked for. | | `complete` | boolean | Whether the file's text extraction finished. When `false`, only part of the text has been written so far -- the chunks you get are real, and more may appear later. | | `indexed_version_id` | string/null | The file version the chunks were indexed from. `null` when `indexed` is `false`. | | `page_addressable` | boolean | `true` only when EVERY chunk of the file carries a page range. When `false`, a `page` selector returns an empty `chunks` list -- switch to `chunk_from`/`chunk_to`. | | `num_pages` | integer/null | Page count of the converted document; `null` when the format has no pages. | | `total_chunks` | integer | Chunks in the whole file, not in this response. A window that matches nothing on an indexed file still returns `indexed: true` with the real `total_chunks` and an empty `chunks` list. | | `chunks` | array | The matching chunks, ordered by `position` (by `score` descending in relevance mode). | | `chunks[].position` | integer | **The chunk's address.** Its 0-based ordinal place in the file's read order. Always present and always exact. This is what `chunk_from` and `chunk_to` select on, and the value to record if you want to come back to a chunk later; `cursor` continues a walk from an opaque token instead. | | `chunks[].sequence` | integer/null | The ordering coordinate assigned when the file's text was extracted. It increases through the file but is not necessarily contiguous, and is `null` for text extracted before it was assigned. Published for correlation and debugging -- address a chunk by `position`, not by this. | | `chunks[].chunk_index` | integer/null | Legacy field, kept for compatibility and now nullable: newer text may not carry one. Read `position` as the address instead. | | `chunks[].start_page`, `chunks[].end_page` | integer/null | Inclusive 1-based page range the chunk covers; both `null` on a file with no pages. | | `chunks[].chars` | integer | Character length of the full chunk text -- present under `output=terse` too, so a caller can budget before asking for the text. | | `chunks[].chunk_hash` | string | Stable identity label for this chunk: SHA-256 of the whitespace-normalised chunk text, first 10 hex characters. Only whitespace is normalised -- case and punctuation are significant. The same passage gets the same label everywhere, so it is how a caller recognises a passage it already holds, or spots the same passage repeated across files, without comparing bodies. Present under `output=terse` too. Not a checksum to verify and not a security boundary -- treat it as an opaque identity string, never branch on its value. | | `chunks[].score` | number/null | The keyword relevance score in `q` mode; `null` in every ordered mode. | | `chunks[].text` | string | The chunk's text. Absent under `output=terse`. | | `next_cursor` | string/null | Opaque continuation token for the last chunk actually emitted, to pass back as `cursor`. Treat it as an opaque string -- never construct, parse or store one, and always send back the token from the most recent response; a token held over from an earlier release is refused as an invalid window. It carries the file version it was issued against, so a walk can never straddle two versions. `null` when the window is exhausted -- that, not a short page, is how you know you are done. | | `truncated` | boolean | `true` when whole chunks were withheld by `max_bytes`. | **Reading a whole file:** call with no selector, then keep re-calling with `cursor` set to the previous `next_cursor` until `next_cursor` is `null`, concatenating `text`. Chunks are contiguous and in order; joining them with a newline between chunks reproduces the extracted text apart from the whitespace the splitter dropped at each boundary. **Ordered modes and the byte budget.** In the ordered modes (no selector, `page`, `chunk_from`/`chunk_to`) the response stops BEFORE the chunk that would push it past `max_bytes`; text is never cut inside a chunk, and at least one chunk is always returned even when that one chunk is larger than the budget. `truncated: true` means matched chunks were withheld -- not that a chunk's text was shortened -- and `next_cursor` is the last chunk actually sent, so continuing from it loses nothing. **Relevance mode (`q`).** `q` ranks this one file's chunks by keyword match (the terms are matched independently, so a natural-language question still returns the passage carrying the most of its words first). It returns the top `limit` chunks -- default 3 -- sorted by `score` descending, each with its FULL text: `max_bytes` is deliberately not applied, so a hit is never silently dropped. Use it to locate and quote a clause without walking the file; use the ordered modes to read the file through. **`output=terse`** returns the same envelope and the same chunk list with `text` omitted -- a chunk map of positions, page ranges and `chars`. The byte budget is not spent on a response that carries no text, so a terse page is never shortened. **A file with no extracted text** is a normal `200`, not an error: ```json { "result": true, "node_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "name": "scan.jpg", "mimetype": "image/jpeg", "indexed": false, "complete": false, "indexed_version_id": null, "page_addressable": false, "num_pages": null, "total_chunks": 0, "chunks": [], "next_cursor": null, "truncated": false } ``` `indexed: false` means this version has no text in the index -- it may never have been processed, processing may still be queued, or the format may carry no extractable text. It never means the read failed: a failure to read the index is a `500`, so `indexed: false` is always a statement about the file and never about the platform. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Node not found, or the node is in the trash | | `1605 (Invalid Input)` | 406 | Node is a folder or a link -- only files and notes carry extracted text | | `1605 (Invalid Input)` | 406 | Window parameters conflict or are out of range: more than one selector, `q` with `cursor`, `chunk_to` without `chunk_from`, `chunk_from` greater than `chunk_to`, a `chunk_from`/`chunk_to` position at or beyond `10000`, a `cursor` that was not taken verbatim from the most recent response, a `cursor` used with a `chunk_from`/`chunk_to` range it does not fall inside, or a value outside the documented bounds | | `1680 (Access Denied)` | 401 | Share caller has no download permission, the share's download security is `medium` and the caller is a guest, or the file is virus-flagged (share path) | | `1652 (Resource Not Found)` | 404 | The file's content is no longer available | | `1654 (Internal Error)` | 500 | Content temporarily unavailable -- the text could not be read. Retry; never treat this as "the file has no text" | --- ## Multi-File Content (relevance search, or an ordered read, across several files) ``` GET /current/workspace/{workspace_id}/storage/content/ ``` Read **up to ten named files** in a single call, in either of two modes selected by whether `q` is present: - **With `q`** -- score every file against one query and get back the passages that answered it. This is the relevance mode of the single-file route above, asked of several files at once, so a caller assembling context for a prompt makes one request instead of ten. The chunk objects are identical to that route's, field for field, so the two can be mixed freely. - **Without `q`** -- an ORDERED window of each file, in `position` order, with the same `chunk_from`/`chunk_to`/`limit`/`max_bytes` semantics as the single-file route's ordered mode. This is the shape for a head read: `?nodes=a,b,c,...&chunk_from=0&max_bytes=2048` returns the first couple of KB of up to ten files in one call, so a caller can glance at many files before deciding which ones are worth reading in full -- a contract sweep, triaging meeting notes, or a literature-review pass all pay one call instead of one per file. The two modes cannot be combined: sending `q` together with `chunk_from`/`chunk_to` is refused as an invalid window, the same rule the single-file route enforces. Note the shape of the path: the file ids travel in the `nodes` query parameter, not in the path, so this route sits beside `search/` rather than under a `{node_id}`. **Each file is read independently.** In `q` mode, every file is ranked only among its own chunks, and the scores are not comparable BETWEEN files -- keyword relevance scores are only meaningful within one result set; take the top chunks per file, do not merge the lists and re-sort them by `score`. In the ordered mode there is no score to merge: each file's window is read server-side as its own call, so this route is a convenience over ten single-file calls rather than a cheaper query -- keep `max_bytes` small for a head read. **Auth required.** Permission: View (workspace) -- the same gate as the single-file route. Workspace only; there is no share form of this route. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` | string | Yes | 19-digit profile ID | **Query parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `nodes` | string | Yes | -- | Comma-separated node ids, **1-10** of them. Both spellings are accepted -- with or without hyphens. Blank segments are ignored (`a,,b` names two files) and a repeated id is de-duplicated silently. | | `q` | string | No | -- | The query to score the files against, 1-512 characters. **Omit it** to get the ordered window mode instead. Refused together with `chunk_from`/`chunk_to`. | | `chunk_from` | integer | No | -- | Ordered mode only. Inclusive start of the `position` window, applied per file. `>= 0`. Omitted means "from the start of each file." Positions at or beyond `10000` cannot be addressed this way -- continue into a large file with `cursor` on the single-file route instead. | | `chunk_to` | integer | No | -- | Ordered mode only. Inclusive end of the `position` window, applied per file. Requires `chunk_from` and must be `>= chunk_from`, and is subject to the same `10000` ceiling. | | `limit` | integer | No | `3` with `q`, `5` without | Chunks returned **per file**. `1`-`20`. | | `max_bytes` | integer | No | `32768` | UTF-8 **byte** budget over the emitted text, spent **per file** -- same name, default and bounds as the single-file route's `max_bytes`, but ten files at the cap means ten separate budgets, not one shared pot: the response ceiling is `nodes × max_bytes`, roughly 320 KiB of text at ten files and the default. At the maximum `max_bytes`, the ceiling is `10 × 262144` ≈ 2.5 MiB of text -- a deliberate opt-in, equal to the single-file route's per-request bound times the node cap. `1024`-`262144`. Text is never cut inside a chunk -- a file's emission stops before the chunk that would overflow its budget, and at least one chunk is always returned. | | `output` | string | No | `full` | `terse` returns the chunk map with `chunk_hash` and no `text`; `standard` and `full` include it. Works in both modes. | There is no `page` or `cursor` parameter on this route: those address a walk through ONE file. In ordered mode, each file's entry carries its own `next_cursor` (see below) -- pass that value as `cursor` to the single-file route to continue reading THAT file from where this route stopped. When this read was bounded by `chunk_from`/`chunk_to`, send those same values back alongside `cursor`, or the single-file walk runs on to the end of the file instead of stopping at the range you asked for. Follow a passage found in `q` mode the same way, using the chunk's `position` as `chunk_from` on the single-file route. **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/content/?nodes=2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4,2azz3-bexth-xvixb-cpq37-azkzx-xinq&q=retention%20policy&limit=3" \ -H "Authorization: Bearer {jwt_token}" ``` **Head-read triage example (ordered mode, no `q`):** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/content/?nodes=2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4,2azz3-bexth-xvixb-cpq37-azkzx-xinq&chunk_from=0&max_bytes=2048" \ -H "Authorization: Bearer {jwt_token}" ``` Up to ten files, the first ~2 KB of text for each, in one call -- a triage pass before deciding which files are worth reading in full. **Response:** ```json { "result": true, "q": "retention policy", "limit": 3, "nodes": { "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4": { "name": "Master Services Agreement.pdf", "mimetype": "application/pdf", "indexed": true, "indexed_version_id": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5", "page_addressable": true, "num_pages": 6, "total_chunks": 12, "chunks": [ {"position": 4, "sequence": 1005, "chunk_index": 4, "start_page": 3, "end_page": 4, "chars": 4102, "chunk_hash": "5a13cf20de", "score": 7.25, "text": "... 9. RECORD RETENTION ..."} ], "truncated": false } }, "missing": [ {"id": "2azz3-bexth-xvixb-cpq37-azkzx-xinq", "reason": "trashed"} ] } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `q` | string/null | Echoed back as applied in `q` mode; `null` in ordered mode. | | `limit` | integer | Echoed back as applied, so a caller can see the default that was used -- `3` with `q`, `5` without. | | `nodes` | object | Keyed by hyphenated node id, in the order the ids were named. An **object even when empty** -- a caller keying into it never has to handle a list. | | `nodes.{id}.name`, `nodes.{id}.mimetype` | string | Taken from the file itself, never from the text index. | | `nodes.{id}.indexed` | boolean | Whether this version has any extracted text at all -- describes the whole file, not the query window. | | `nodes.{id}.indexed_version_id` | string/null | The file version the returned chunks were indexed from. `null` when `indexed` is `false`. | | `nodes.{id}.page_addressable` | boolean | `true` only when EVERY chunk of the file carries a page range -- the same meaning as on the single-file route. | | `nodes.{id}.chunks` | array | That file's chunks, at most `limit` of them -- `score` descending in `q` mode, `position` ascending in ordered mode. Identical in fields and formatting to the single-file route's chunks: `position`, `sequence`, `chunk_index`, `start_page`, `end_page`, `chars`, `chunk_hash`, `score`, `text`. `score` is `null` in ordered mode. `position` means the same thing and can be sent straight back to the single-file route as `chunk_from`; `chunk_hash` is the same identity label the single-file route returns for the same chunk. | | `nodes.{id}.total_chunks` | integer | Chunks in the WHOLE file, not in this response. A file that is indexed but matched nothing comes back with an empty `chunks` list and a non-zero `total_chunks`; a file with no extracted text at all comes back with `total_chunks: 0`. | | `nodes.{id}.num_pages` | integer/null | Page count of the converted document; `null` when the format has no pages. | | `nodes.{id}.truncated` | boolean | `true` when that file's chunks were cut short by `max_bytes` -- not by `limit`. A file whose text matched in more places than `limit` still returns `truncated: false`; a `chunks` length equal to `limit` means more matches may exist -- raise `limit` to see them. | | `nodes.{id}.complete` | boolean | **Ordered mode only** -- not present in `q` mode. Same meaning as the single-file route's `complete`: `true` when that file's text extraction is recorded as finished. It says nothing about this response's window -- a finished file can return `complete: true` with a non-null `next_cursor`; use `next_cursor`, never `complete`, to decide whether to keep reading. | | `nodes.{id}.next_cursor` | string/null | **Ordered mode only** -- not present in `q` mode. Pass this value as `cursor` to the single-file route (`GET .../storage/{node_id}/content/`) to continue reading THAT file from where this response stopped -- and when this read was bounded by `chunk_from`/`chunk_to`, send those same values back alongside `cursor`, or the walk runs on to the end of the file. `null` when that file's window has no more chunks. It tracks the window, not `complete`: an unfinished extraction can hand back `null` once its indexed chunks run out, and a finished one still hands back a token while chunks remain in range. | | `missing` | array | One entry per named id the read could not answer for: `{id, reason}`. One unreadable id never costs you the others. | | `missing[].reason` | string | `not_found` (no such file in this workspace, or its content is gone), `trashed` (the file is in the bin), or `not_text` (a folder or a link, which carries no extracted text). | **A failure to read the text index is a `500`, never an empty `chunks` list.** An empty list always means those files hold nothing matching your query, and never that the platform could not look. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | `nodes` names no ids, names more than 10, or contains a malformed id; `q` is present and longer than 512 characters; `limit` is outside `1`-`20`; `max_bytes` is outside `1024`-`262144`; `chunk_to` given without `chunk_from`, `chunk_from` greater than `chunk_to`, or a `chunk_from`/`chunk_to` position at or beyond `10000`; `q` combined with `chunk_from`/`chunk_to` | | `1654 (Internal Error)` | 500 | Content temporarily unavailable -- the text could not be read. Retry; never treat this as "these files have no matching text" | | `1654 (Internal Error)` | 500 | A named node could not be retrieved (a backend read failure at the node or physical-record lookup) -- never reported as `missing`/`not_found`; the whole request fails instead. | --- ## Add File from Upload ``` POST /current/workspace/{workspace_id}/storage/{parent_id}/addfile/ POST /current/share/{share_id}/storage/{parent_id}/addfile/ ``` Add a previously uploaded file to storage. **Auth required.** Permission: Guest (workspace), file creation permission (share). **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{parent_id}` | string | Yes | Parent folder OpaqueId or `"root"` | **Request body (form-encoded):** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `name` | string | Yes | Filename for the new file. 1-255 characters (counted as characters, not bytes). | | `from` | string | Yes | JSON-encoded source object (see below) | **`from` format:** Both workspace and share variants accept the same JSON shape. Two source types are supported: `upload` (a completed upload session) and `hash` (deduplicate against an existing object by content hash). ```json {"type": "upload", "upload": {"id": "{upload_id}"}} ``` ```json {"type": "hash", "hash": {"hash": "{file_hash}", "hash_type": "sha256"}} ``` A `hash` source adds the file instantly only when its content already exists in that storage; if the content is not present, the call returns `404 Not Found` ("The specified file content was not found in this storage. Upload the file instead.") and the client should upload the file normally instead. On a share that exposes a workspace folder, a `hash` source always returns this 404. **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/root/addfile/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'name=document.pdf' \ -d 'from={"type":"upload","upload":{"id":"abc123opaqueid"}}' ``` **Response:** ```json { "result": true, "node": { "id": "2emaf-exxpw-thkzj-5rlym-ocyoh-iufa", "type": "file", "name": "document.pdf", "parent": "root", "size": 5242880, "hash": "d41d8cd98f00b204e9800998ecf8427e", "hash_algo": "md5", "crc32c": "6f9c3a21", "mimetype": "application/pdf", "mimecategory": "document", "version": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5", "created": "2025-01-15 10:30:00 UTC", "modified": "2025-01-15 10:30:00 UTC", "restricted": false, "dmca": false, "locked": false } } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | Upload session not found or not associated with your account | | `1605 (Invalid Input)` | 406 | Upload is not complete | | `1609 (Not Found)` | 404 | Parent folder not found | | `1605 (Invalid Input)` | 406 | Parent node is not a folder | | `1609 (Not Found)` | 404 | Parent folder is in trash | | `1605 (Invalid Input)` | 406 | Name conflict (only when using FAIL strategy) | | `1693 (Temporarily Unavailable)` | 503 | Chunk manifest not yet durable — retry after a brief delay | | `1609 (Not Found)` | 404 | The uploaded file has expired or is missing — upload it again | **Notes:** - The upload session must be in COMPLETE status before adding the file. - Virus scanning occurs during upload assembly, not at this stage. - **Conflict resolution:** If a file with the same name exists, the default behavior is to **replace** (overwrite) the existing file, creating a version for rollback. Folder or type-mismatch conflicts fall back to renaming. --- ## Add Link (Workspace Only) ``` POST /current/workspace/{workspace_id}/storage/{parent_id}/addlink/ ``` Add a share link node to workspace storage. Link nodes represent references to shares within the workspace tree. **Auth required.** Permission: Guest. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` | string | Yes | 19-digit workspace profile ID | | `{parent_id}` | string | Yes | Parent folder OpaqueId or `"root"` | **Request body (form-encoded):** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `link_target_type` | string | Yes | Must be `"share"` | | `share` | string | Yes | Share identifier to link | **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/root/addlink/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'link_target_type=share' \ -d 'share=my-share-name' ``` **Response:** ```json { "result": true } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1683 (Resource Missing)` | 404 | Share not found or not accessible | | `1605 (Invalid Input)` | 406 | Share does not belong to this workspace | | `1605 (Invalid Input)` | 406 | A link to this share already exists (only one per share) | | `1605 (Invalid Input)` | 406 | Share has no title or custom URL to name the link node with | | `1610 (General Error)` | 500 | The link node was created but the share's back-link could not be saved; the node is removed again and the request fails — retry | | `1610 (General Error)` | 500 | The link node could not be created (a parent folder that does not exist also lands here), or the share's existing link could not be verified | The link node is named after the share's title, falling back to its custom URL when no title is set. --- ## Create Folder ``` POST /current/workspace/{workspace_id}/storage/{parent_id}/createfolder/ POST /current/share/{share_id}/storage/{parent_id}/createfolder/ ``` Create a new folder. This endpoint is idempotent by default: if a folder with the same `name` already exists in the parent, the existing folder is returned (HTTP 200) instead of creating a duplicate. Pass `force=true` to bypass this and always create a new folder (auto-renamed on a name collision, e.g. `Documents (2)`). **Auth required.** Permission: Guest (workspace), folder creation permission (share). **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{parent_id}` | string | Yes | Parent folder OpaqueId or `"root"` | **Request body (form-encoded):** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `name` | string | Yes | Folder name. 1-255 characters (counted as characters, not bytes). | | `force` | boolean | No | When `true`, always create a new folder even if one with this name already exists (auto-renamed on collision). Defaults to `false` (idempotent — returns the existing folder). | **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/root/createfolder/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'name=Documents' ``` **Response:** ```json { "result": true, "node": { "id": "2uvd6-rylta-qajvp-y3yr6-fzadn-e4rc", "type": "folder", "name": "Documents", "parent": "root", "created": "2025-01-15 10:30:00 UTC", "modified": "2025-01-15 10:30:00 UTC", "restricted": false, "dmca": false, "locked": false } } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | A non-folder item already uses this name in the parent folder | | `1609 (Not Found)` | 404 | Parent folder not found | | `1605 (Invalid Input)` | 406 | Parent node is not a folder | | `1609 (Not Found)` | 404 | Parent folder is in trash | | `1680 (Access Denied)` | 401 | No folder creation permission (share only) | --- ## Create Note (Workspace Only) ``` POST /current/workspace/{workspace_id}/storage/{parent_id}/createnote/ ``` Create a markdown note. Notes are auto-indexed for AI when workspace Deep Indexing is enabled. **Auth required.** Permission: Guest. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` | string | Yes | 19-digit workspace profile ID | | `{parent_id}` | string | Yes | Parent folder OpaqueId or `"root"` | **Request body (form-encoded):** | Parameter | Type | Required | Constraints | Description | |-----------|------|----------|-------------|-------------| | `name` | string | Yes | 1-255 characters (counted as characters, not bytes); must end in `.md` | Note name | | `content` | string | Yes | Max 100 KB | Markdown content | **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/root/createnote/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'name=meeting-notes.md' \ -d 'content=# Meeting Notes\n\nDiscussed project timeline.' ``` **Response:** ```json { "result": true, "note": { "id": "2ik5q-a43cm-uixi2-van5r-3eolo-7mue", "type": "note", "name": "meeting-notes.md", "parent": "root", "mimetype": "text/markdown", "created": "2025-01-15 10:30:00 UTC", "modified": "2025-01-15 10:30:00 UTC" } } ``` If a file, folder or note with the same name already exists in the parent, the note is created under the next free name (for example `meeting-notes (2).md`); the existing item is left untouched. Read `note.name` in the response for the name actually used. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | Filename must end with `.md` | | `1609 (Not Found)` | 404 | Parent folder not found | | `1605 (Invalid Input)` | 406 | Parent node is not a folder | | `1609 (Not Found)` | 404 | Parent folder is in trash | --- ## Update Note (Workspace Only) ``` POST /current/workspace/{workspace_id}/storage/{node_id}/updatenote/ ``` Update an existing note's name and/or content. Updating content creates a new version. **Auth required.** Permission: Guest. This workspace endpoint also accepts a `realtime-note` bearer token (minted by `GET /current/realtime/note-auth/{profile_id}/{note_id}`) in place of a user JWT, for the collaborative-editing backend to save on the user's behalf. The token is bound to a specific note and workspace and is rejected (`403 Forbidden`) if it does not match the requested note or workspace, or if it carries only `view` permission (a view token cannot update). The share note endpoints do not accept this token. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` | string | Yes | 19-digit workspace profile ID | | `{node_id}` | string | Yes | Note OpaqueId | **Request body (form-encoded):** | Parameter | Type | Required | Constraints | Description | |-----------|------|----------|-------------|-------------| | `name` | string | No | 1-255 characters (counted as characters, not bytes); must end in `.md` | New note name | | `content` | string | No | Max 100 KB, non-blank | New markdown content (an empty or whitespace-only value is rejected) | | `if_version_id` | string | No | Version OpaqueId | Compare-and-swap precondition. When supplied, the update only proceeds if the note's current `version` matches this value; otherwise the request is rejected with `409 Conflict` and no change is made. Use the `version` returned by a prior read or update as the value to guard against overwriting concurrent edits. `version` is returned at every detail level, including `?output=terse`, so a compact read is a valid base for a later conditional write. | At least one of `name` or `content` is required. **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ik5q-a43cm-uixi2-van5r-3eolo-7mue/updatenote/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'content=# Updated Notes\n\nRevised content here.' ``` **Response:** ```json { "result": true, "note": { "id": "2ik5q-a43cm-uixi2-van5r-3eolo-7mue", "type": "note", "name": "meeting-notes.md", "parent": "root", "mimetype": "text/markdown", "created": "2025-01-15 10:30:00 UTC", "modified": "2025-01-20 14:45:00 UTC" } } ``` **Conflict response (`if_version_id` mismatch):** When `if_version_id` is supplied and does not match the note's current version, the request is rejected with HTTP `409 Conflict` and no version is created. `error.params` is a **list of parameter entries**, matching the structured detail used elsewhere in this API: ```json { "error": { "code": 113958, "text": "The note was modified since the supplied version", "params": [ { "name": "if_version_id", "kind": "conflict", "message": "The note was modified since the supplied version. current_version_id=3jnix-kx7lb-pf2lf-qw6jd-6jng2-hqjn", "code": 174450, "reason": "conflict_version_mismatch", "current_version_id": "3jnix-kx7lb-pf2lf-qw6jd-6jng2-hqjn" } ] } } ``` **How to recognise this conflict.** Branch on `params[].reason == "conflict_version_mismatch"` — that is the only field that names the *cause*. If your transport keeps only the four standard entry fields, `params[].name == "if_version_id"` with `params[].kind == "conflict"` tells you a precondition on that parameter failed, which is enough to stop and re-read — but it is **not** equivalent to the reason: another conflict cause on the same parameter would look identical, so do not treat it as proof of a version mismatch. Do **not** branch on the HTTP status — `409` also reports several unrelated conditions — and do **not** branch on the numeric code, which is assigned per call site and therefore differs between endpoints reporting the same cause. `kind` here is `conflict`, which extends the values used for validation failures (`missing`, `invalid`, `type_mismatch`). It is distinct from `invalid` on purpose: `invalid` means the supplied `if_version_id` was not a well-formed id, whereas `conflict` means it was well-formed and the version had moved. Treating them alike would leave you unable to tell a malformed precondition from a stale one. **Rebasing.** Take the current version from `current_version_id`, re-read the note, and re-apply your edit against it. The id is also appended to `message` in the fixed form ` current_version_id=` for clients that surface only the message text. Do **not** resend the same request unchanged — the version has moved and will stay moved, so an unmodified retry cannot succeed. If the current state cannot be resolved at the time of the conflict, `current_version_id` is omitted and the message carries no id suffix; the entry still carries `name`, `kind`, `message`, `code` and `reason`, so the conflict remains identifiable. The entry may also carry the contention keys `contested`, `rebase_count` and `contested_since`, with the same meaning and the same omitted-when-unknown rule as on *Update Node* below. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Note not found | | `1605 (Invalid Input)` | 406 | Node is not a note | | `1609 (Not Found)` | 404 | Note is in trash | | `1605 (Invalid Input)` | 406 | No content or name provided | | `1605 (Invalid Input)` | 406 | Duplicate name in parent folder | | `113958` | 409 | `if_version_id` did not match the note's current version. `error.params[]` carries the conflict entry — see *Conflict response* above. (`1660` is not the value of `error.code`.) | | `1700 (Forbidden)` | 403 | `realtime-note` token does not match the requested note or workspace | | `1700 (Forbidden)` | 403 | `realtime-note` token lacks edit permission (a view token cannot update) | --- ## Read Note ``` GET /current/workspace/{workspace_id}/storage/{node_id}/readnote/ GET /current/share/{share_id}/storage/{node_id}/readnote/ ``` Read a note's content as JSON. Unlike the binary `/read/` endpoint, this returns the sanitized markdown content as a string within the JSON response along with the full note resource. **Auth required.** Permission: View (workspace), download permission or download token (share). The workspace endpoint also accepts a `realtime-note` bearer token (minted by `GET /current/realtime/note-auth/{profile_id}/{note_id}`) in place of a user JWT, for the collaborative-editing backend to read on the user's behalf. The token is bound to a specific note and workspace and is rejected (`403 Forbidden`) if it does not match the requested note or workspace. The share note endpoint does not accept this token. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | Note OpaqueId | **Query parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `version_id` | string | No | Specific version OpaqueId to read | | `token` | string | No | Download token (share only -- bypasses JWT auth) | **curl example:** ```bash # Workspace curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ik5q-a43cm-uixi2-van5r-3eolo-7mue/readnote/" \ -H "Authorization: Bearer {jwt_token}" # Share curl -X GET "https://api.fast.io/current/share/1234567890123456789/storage/2ik5q-a43cm-uixi2-van5r-3eolo-7mue/readnote/" \ -H "Authorization: Bearer {jwt_token}" # Share with download token (no JWT needed) curl -X GET "https://api.fast.io/current/share/1234567890123456789/storage/2ik5q-a43cm-uixi2-van5r-3eolo-7mue/readnote/?token={download_token}" ``` **Response:** ```json { "result": true, "content": "# Meeting Notes\n\nDiscussed project timeline.", "note": { "id": "2ik5q-a43cm-uixi2-van5r-3eolo-7mue", "type": "note", "name": "meeting-notes.md", "parent": "root", "mimetype": "text/markdown", "version": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5", "created": "2025-01-15 10:30:00 UTC", "modified": "2025-01-15 10:30:00 UTC" } } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `content` | string | Sanitized markdown content | | `note` | object | Full note node object (same shape as other node responses). `note.version` is the version whose content was returned -- the requested `version_id` when one is supplied, not necessarily the note's current version. | **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | Invalid node ID | | `1609 (Not Found)` | 404 | Note not found | | `1605 (Invalid Input)` | 406 | Node is not a note | | `1609 (Not Found)` | 404 | Note is in trash | | `1609 (Not Found)` | 404 | Version not found | | `1605 (Invalid Input)` | 406 | Version does not belong to this note | | `1609 (Not Found)` | 404 | Version data no longer available | | `1680 (Access Denied)` | 401 | No permission to read notes (share only) | | `1680 (Access Denied)` | 401 | No permission to read notes you did not create (share, creator-only restriction) | | `1680 (Access Denied)` | 401 | The share's download security is `medium` and the caller is a guest (share, without a download token) | | `1700 (Forbidden)` | 403 | `realtime-note` token does not match the requested note or workspace | | `1700 (Forbidden)` | 403 | `realtime-note` token lacks read permission | **Notes:** - On shares, a valid download token can be passed via the `token` query parameter to bypass JWT authentication. - Share permissions may restrict note reading to notes the user created (creator-only restrictions). --- ## Update Node ``` POST /current/workspace/{workspace_id}/storage/{node_id}/update/ POST /current/share/{share_id}/storage/{node_id}/update/ ``` Update a node: rename, replace content with a new upload, and/or update custom metadata. Note nodes can only be **renamed** with this endpoint (a name-only update). To change a note's content, use `updatenote` instead (a note's custom title and short description cannot be set by any endpoint) — supplying `from`, `metadata_title`, or `metadata_short` on a note (or omitting `name`) is rejected. An optional `if_version_id` parameter adds a compare-and-swap precondition on a content replace or a rename — see *Conflict response* below. **Auth required.** Permission: Guest (workspace), file modification permission (share). **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | Node OpaqueId | **Request body (form-encoded):** | Parameter | Type | Required | Constraints | Description | |-----------|------|----------|-------------|-------------| | `name` | string | No | 1-255 characters (counted as characters, not bytes) | New node name | | `from` | string | No | JSON-encoded | New file content source (same format as addfile) | | `metadata_title` | string | No | Max 50 chars | Custom title override | | `metadata_short` | string | No | Max 2048 chars | Custom short description override | | `if_version_id` | string | No | Version OpaqueId | Compare-and-swap precondition — the update proceeds only if the node's current `version` still matches this value; otherwise the request is rejected with `409 Conflict` and no change is made. Applies to a content replace via either `from.type=upload` or `from.type=hash`, and to a rename via `name`. It is refused with `406` on a metadata-only update, which creates no new version (see below). Omit it and behavior is unchanged — last write wins. A present-but-empty value (`if_version_id=`) is rejected as invalid input, not treated as omitted. | At least one field should be provided. `if_version_id` does not itself count toward that requirement — supplying only `if_version_id` (with no `name`, `from`, `metadata_title`, or `metadata_short`) still returns "No update parameters were specified". **curl example (rename):** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/update/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'name=new-document-name.pdf' ``` **curl example (replace content and update metadata):** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/update/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'from={"type":"upload","upload":{"id":"upload_opaque_id"}}' \ -d 'metadata_title=Updated Report' \ -d 'metadata_short=Q1 2025 revision' ``` **Response:** ```json { "result": true, "node": { "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "type": "file", "name": "new-document-name.pdf", "parent": "root", "size": 5242880, "version": "3ak5n-dr47a-qnylo-kzv6c-e6bnm-3u3c", "modified": "2025-01-22 11:00:00 UTC" } } ``` **A precondition must have something that can invalidate it.** `if_version_id` is refused with `406` when the request supplies neither `from` nor `name` — a metadata-only update. Custom metadata does not create a new version, so the precondition would still be satisfied for the next writer: two callers holding the same version could both succeed and the second would silently overwrite the first, while both were told a compare-and-swap protected them. Send `if_version_id` together with `name` or `from`, or omit it. Metadata alongside a rename or a content replace is fine — those do create a version, so the precondition is real. **Conflict response (`if_version_id` mismatch):** When `if_version_id` is supplied and does not match the node's current version, the request is rejected with HTTP `409 Conflict` and no new version is created. Two limits are worth knowing. **A `409` guarantees the node was not changed — no new version, no rename, no content swap — but it is not a promise that the request left nothing at all behind:** replacing content by upload may already have stored the uploaded bytes before the precondition was evaluated, and those unreferenced bytes are reclaimed automatically. Replacing by `hash` never has this residue, because it only references content that already exists. **And on a share whose storage is its parent workspace's (a workspace-folder share), replacing by `hash` is not available at all** and answers `404` regardless of `if_version_id`; use an upload there. `error.params` is a **list of parameter entries**, the same shape documented for `updatenote` above: ```json { "error": { "code": 180212, "text": "The file was modified since the supplied version", "params": [ { "name": "if_version_id", "kind": "conflict", "message": "The file was modified since the supplied version. current_version_id=3jnix-kx7lb-pf2lf-qw6jd-6jng2-hqjn", "code": 106523, "reason": "conflict_version_mismatch", "current_version_id": "3jnix-kx7lb-pf2lf-qw6jd-6jng2-hqjn", "contested": true, "rebase_count": 3, "contested_since": "2026-08-26 04:00:00 UTC" } ] } } ``` Shown for the workspace endpoint; the share endpoint returns the identical shape with outer `error.code` `158175` and nested `params[].code` `101301`. Branch on `params[].reason == "conflict_version_mismatch"` — the only field naming the cause — and not on the `409` status (which also reports unrelated conditions) or on the numeric `code` (assigned per call site, so it differs between the workspace and share variants of this same endpoint, and between this endpoint and `updatenote`). See *Conflict response* and *How to recognise this conflict* under Update Note above for the full field-by-field rationale; it applies unchanged here. **Contention (`contested`, `rebase_count`, `contested_since`).** A sliding ~10-minute window of how many writes this node has refused, so a losing writer can tell bad luck from a genuine fight. `rebase_count` counts conflicts inside the window including this one; `contested` is `true` from the second onward; `contested_since` is when the window opened. **`contested: true` means ESCALATE TO A HUMAN — which is not the same as "stop writing", and what it implies depends on what you are.** An **autonomous agent** deciding whether to loop should stop and surface: retrying against a live editor is how one conflict becomes a storm. A **stateful relay** holding a person's live editing session must NOT stop persisting — its "retry" is the mechanism by which that person's unsaved keystrokes reach the platform, so stopping silently drops their work. Escalate by telling the human a conflict is live, and keep holding their content. **These three keys are OMITTED, never zeroed, when the signal is unavailable.** Absent means *unknown*; it does not mean uncontested. Treat a missing block as "no information" and fall back to your normal retry policy, not as permission to retry. **Rebasing.** Take the current version from `current_version_id`, re-read the node (or re-fetch its `version` via `list`/`details`), and re-apply your update against it. Do **not** resend the same request unchanged — the version has moved and will stay moved, so an unmodified retry cannot succeed. **Link rename refusal.** Supplying `if_version_id` together with a new `name` is refused with HTTP `406` when the target node is a **link**, rather than attempted: renaming a link propagates the new name to the link's target *before* the version check runs, so a rejected write could still leave the rename applied. Rename the link without `if_version_id`, or supply `if_version_id` without a new `name`. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Node not found | | `1605 (Invalid Input)` | 406 | Notes can only be renamed here — supply only `name`; use `updatenote` for content changes (a note's title and description are not settable) | | `1605 (Invalid Input)` | 406 | No update parameters were specified | | `1605 (Invalid Input)` | 406 | Cannot update a folder or link with file data | | `1605 (Invalid Input)` | 406 | `if_version_id` supplied on a metadata-only update (neither `name` nor `from`) | | `1609 (Not Found)` | 404 | `from.type=hash` content not found in this storage (always, on a workspace-folder share), or the uploaded file has expired or is missing | | `1605 (Invalid Input)` | 406 | Name conflict — a file or folder with that name already exists in this location | | `1693 (Temporarily Unavailable)` | 503 | Chunk manifest not yet durable when replacing content — retry after a brief delay | | `1609 (Not Found)` | 404 | Node is in trash | | `1680 (Access Denied)` | 401 | No modify permission (share only) | | `1680 (Access Denied)` | 401 | No permission to modify files you did not create (share, creator-only restriction) | | `158175` / `180212` (share / workspace) | 409 | `if_version_id` did not match the node's current version (no change made). `error.params[]` carries the conflict entry — see *Conflict response* above. | | `182375` / `132163` (share / workspace) | 406 | `if_version_id` supplied together with a new `name` on a link node — see *Link rename refusal* above. | **Notes:** - At least one of `name`, `from`, `metadata_title`, or `metadata_short` must be provided. - Replacing content creates a new version. - Renaming a link node propagates the rename to the linked share. - Custom metadata overrides AI-generated summary values for display. - Share permissions may restrict modification to files the user created (creator-only restrictions). - `if_version_id` guards a content replace or a rename with compare-and-swap; see *Conflict response* above. It cannot be combined with a new `name` on a link node — see *Link rename refusal* above. --- ## Move Node ``` POST /current/workspace/{workspace_id}/storage/{node_id}/move/ POST /current/share/{share_id}/storage/{node_id}/move/ ``` Move a node to a different folder within the same storage instance. **Auth required.** Permission: Guest (workspace), file modification permission (share). **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | Node OpaqueId to move | **Request body (form-encoded):** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `parent` | string | Yes | Destination folder OpaqueId or `"root"` | **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/move/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'parent=2qk7d-kri4y-yievb-q5hri-eq4io-hij5' ``` **Response:** ```json { "result": true } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Source or destination node not found | | `1609 (Not Found)` | 404 | Source or destination is in trash | | `1605 (Invalid Input)` | 406 | Cannot move a folder into itself or its subfolders | | `1680 (Access Denied)` | 401 | No move permission (share only) | | `1680 (Access Denied)` | 401 | No permission to move a folder containing files you cannot modify (recursive move requires modify-all, share only) | **Notes:** - Guests who can view or modify only their own files may copy or move individual files they created; copying or moving a whole folder requires permission to view (copy) or modify (move) all files in the share. - **Conflict resolution:** If a file with the same name exists in the destination, the existing file is replaced (moved to trash for rollback). Folder or type-mismatch conflicts fall back to renaming. --- ## Copy Node ``` POST /current/workspace/{workspace_id}/storage/{node_id}/copy/ POST /current/share/{share_id}/storage/{node_id}/copy/ ``` Copy a node to another folder within the same storage instance. Folder copies are recursive. **Auth required.** Permission: Guest (workspace), file/folder creation permission (share). **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | Node OpaqueId to copy | **Request body (form-encoded):** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `parent` | string | Yes | Destination folder OpaqueId or `"root"` | **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/copy/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'parent=2qk7d-kri4y-yievb-q5hri-eq4io-hij5' ``` **Response:** ```json { "result": true, "node": { "id": "2fbt2-66lwc-hle6y-kokf2-wahnc-z4py", "type": "file", "name": "document.pdf", "parent": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5" }, "job": null } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Source or destination not found | | `1605 (Invalid Input)` | 406 | Destination is not a folder | | `1609 (Not Found)` | 404 | Source or destination is in trash | | `1605 (Invalid Input)` | 406 | Cannot copy a folder into itself or a descendant -- the destination parent is the source folder, or a folder beneath it | | `1680 (Access Denied)` | 401 | No file creation permission, or no folder creation permission when copying a folder (share only) | | `1680 (Access Denied)` | 401 | No permission to view or copy files you did not create (share, creator-only restriction) | | `1680 (Access Denied)` | 401 | No permission to copy a folder containing files you cannot view (recursive copy requires view-all, share only) | **Notes:** - Creates a deep copy for folders (all children are copied recursively). - The copied node gets a new OpaqueId. - Guests who can view only their own files may copy or move individual files they created; copying or moving a whole folder requires permission to view (copy) or modify (move) all files in the share. - **Conflict resolution:** If a file with the same name exists in the destination, the existing file is replaced (moved to trash for rollback). Folder or type-mismatch conflicts fall back to renaming. - **The copy normally inherits the original's metadata.** Copying a file or a note carries its extracted and hand-entered metadata values onto the copy, with their sources and confidence intact — the copy holds the same content, so the same values are true of it, and it is not re-analyzed at your expense. Inheritance happens after the copy itself is saved, and it is **best-effort**: if it cannot complete, or if the original's stored content changed while the copy was being made — so the values would no longer describe the copy's own content — the copy is still created successfully, just with no metadata, and is treated as new content from then on. **Nothing is retried in the background**, so a copy that arrives without metadata keeps none until it is analyzed or filled in again. See *Transfer Node* below for metadata behavior when copying to a different storage instance. --- ## Transfer Node ``` POST /current/workspace/{workspace_id}/storage/{node_id}/transfer/ POST /current/share/{share_id}/storage/{node_id}/transfer/ ``` Copy or move a node to a different storage instance (e.g., from workspace to share, share to workspace, or share to share). By default the original node remains in place (`mode=copy`, the default). Use `mode=move` to copy the node and then trash the source. There is no separate "move" endpoint -- use this transfer endpoint with the `mode` parameter to control the behavior. **Auth required.** Permission: Guest on source + write access on destination. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | Source profile ID | | `{node_id}` | string | Yes | Node OpaqueId to transfer, or `"root"` for all | **Request body (form-encoded):** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `instance` | string | Yes | 19-digit destination workspace or share profile ID | | `parent` | string | Yes | Destination parent folder OpaqueId or `"root"` | | `mode` | string | No | `copy` (default) or `move`. When `move`, the source node is trashed after copying. Cannot use `mode=move` when `{node_id}` is `"root"`. | **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/transfer/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'instance=9876543210987654321' \ -d 'parent=root' ``` **Move example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/transfer/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'instance=9876543210987654321' \ -d 'parent=root' \ -d 'mode=move' ``` **Response (copy):** ```json { "result": true, "node": { "id": "2rugc-wuylb-far5j-yxics-lla5z-rmbu", "type": "file", "name": "document.pdf", "parent": "root" }, "job": null } ``` **Response (move):** ```json { "result": true, "node": { "id": "2rugc-wuylb-far5j-yxics-lla5z-rmbu", "type": "file", "name": "document.pdf", "parent": "root" }, "source_trashed": true, "job": null } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Source node not found or outside scope | | `1609 (Not Found)` | 404 | Source or destination instance (workspace/share) not found | | `1680 (Access Denied)` | 401 | No modify permission on the source node (share only) | | `1680 (Access Denied)` | 401 | No permission to modify source files you did not create (share, creator-only restriction) | | `1680 (Access Denied)` | 401 | No permission to transfer a whole folder or root (recursive copy requires view-all, move requires modify-all, share only) | | `1680 (Access Denied)` | 401 | No write access to destination -- the caller lacks file-creation permission on the destination share (e.g. a send-type share where guests cannot create) | | `1680 (Access Denied)` | 401 | Destination is a password-protected public share and a valid share password was not supplied | | `1680 (Access Denied)` | 401 | Writing into a personal (user-owned) share requires an identified caller with creation permission (an unauthenticated caller is denied) | | `1680 (Access Denied)` | 401 | The request token's scope does not cover the destination share (a scoped token must include the destination share, or its parent workspace/org) | | `1605 (Invalid Input)` | 406 | Name conflict at destination, or unsupported/not-allowed transfer | | `1605 (Invalid Input)` | 406 | `mode=move` cannot be used with `"root"` as the source node | | `1605 (Invalid Input)` | 406 | Cannot move/copy a folder into itself or a descendant -- the destination `parent` is the source folder, or a folder beneath it, in the same storage instance | **Notes:** - Folder transfers are recursive. - The user must have write access to both source and destination. Writing into a destination share requires file-creation permission on that share, honoring its type and access settings; a password-protected public destination share also requires a valid share password. - When `mode=move`, the source node is trashed in the source storage instance after the copy completes. The response includes `source_trashed` only when `mode=move`: `true` when the source was trashed, `false` when the copy succeeded but the source could not be trashed -- the request still succeeds, the copy stands, and the source remains in place. - Guests who can view or modify only their own files may copy or move individual files they created; copying or moving a whole folder requires permission to view (copy) or modify (move) all files in the share. - **Metadata does not carry over to the destination.** Copying or moving a node to a different storage instance carries no metadata: field definitions belong to a workspace, so a value moved across would be attached to the wrong field. The transfer itself succeeds either way. --- ## Delete Node (Move to Trash) ``` DELETE /current/workspace/{workspace_id}/storage/{node_id}/delete/ DELETE /current/share/{share_id}/storage/{node_id}/delete/ ``` Move a node to trash. Pass `"trash"` as the `{node_id}` to empty the entire trash bin. **Auth required.** Permission: Guest (workspace), file modification permission (share). Emptying trash on shares requires admin permission. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | Node OpaqueId, or `"trash"` to empty the entire trash | **curl example:** ```bash # Delete a specific node curl -X DELETE "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/delete/" \ -H "Authorization: Bearer {jwt_token}" # Empty the trash curl -X DELETE "https://api.fast.io/current/workspace/1234567890123456789/storage/trash/delete/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "job": null } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Node not found | | `1609 (Not Found)` | 404 | Node already in trash | | `1680 (Access Denied)` | 401 | No delete permission (share only) | | `1680 (Access Denied)` | 401 | No permission to empty trash (share, non-admin) | | `1693 (Temporarily Unavailable)` | 503 | Emptying the trash only: another trash operation on this workspace or share is in progress; nothing was changed — retry the same request after a brief delay | | `1693 (Temporarily Unavailable)` | 503 | Deleting a node: concurrent writes kept conflicting, or a sync is in progress for this cloud-imported folder — retry shortly | **Notes:** - Deleting a folder moves it and all children to trash recursively. - Share delete permissions may be restricted to files the user created. - **Trashed files still count toward storage usage.** Moving a node to trash does not reduce the storage your org is billed for — the bytes are still stored, and they are still yours to restore. Only a permanent delete releases them. - **Emptying the trash returns as soon as the trash is empty**, but what it held is not removed straight away: those files are retained for 30 days as a recovery window, and they keep counting toward your storage usage for the whole of that window. Emptying the trash therefore does NOT reduce storage usage immediately — the bytes are released only once the window has passed. The call is still not undoable from the API; the window is a safeguard against an accidental empty, not a second trash. - **A storage limit measured over a billing period does not clear when you free space.** The storage meter records the highest usage seen during the period, so an org that went over its limit keeps returning `402` on writes until the period resets or more credit is added. Emptying the trash is not a remedy for it: the emptied files keep counting for their 30-day retention window, and even once they are released the period's high-water mark stands. --- ## Purge Node (Permanent Delete) ``` DELETE /current/workspace/{workspace_id}/storage/{node_id}/purge/ DELETE /current/share/{share_id}/storage/{node_id}/purge/ ``` Permanently delete a node that is already in trash. **Irreversible.** **Auth required.** Permission: Member (workspace), admin (share). **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | OpaqueId of the trashed node | **curl example:** ```bash curl -X DELETE "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/purge/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "job": null } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Node not found | | `1605 (Invalid Input)` | 406 | Node is not in trash | | `1680 (Access Denied)` | 401 | Insufficient permission (share, non-admin) | | `1693 (Temporarily Unavailable)` | 503 | Another trash operation on this workspace or share is in progress; nothing was changed — retry the same request after a brief delay | **Legal hold:** purging an item covered by an org legal hold is never refused — the call still succeeds and the item leaves the trash, but its content is retained rather than destroyed until the hold is released. See *Legal Holds* in `llms/orgs.txt`. --- ## Restore from Trash ``` POST /current/workspace/{workspace_id}/storage/{node_id}/restore/ POST /current/share/{share_id}/storage/{node_id}/restore/ ``` Restore a trashed node to its original location. **Auth required.** Permission: Guest (workspace), file modification + admin (share). **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | OpaqueId of the trashed node | **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/restore/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "job": null } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Node not found | | `1605 (Invalid Input)` | 406 | Node is not in trash | | `1605 (Invalid Input)` | 406 | Node is inside a trashed folder (restore the parent folder instead) | | `1680 (Access Denied)` | 401 | No restore permission (share only) | | `1693 (Temporarily Unavailable)` | 503 | Another trash operation on this workspace or share is in progress; nothing was changed — retry the same request after a brief delay | --- ## List Versions ``` GET /current/workspace/{workspace_id}/storage/{node_id}/versions/ GET /current/share/{share_id}/storage/{node_id}/versions/ ``` List all versions of a file, note, or folder node. **Workspace route:** auth required, View permission on the workspace. **Share route:** auth is optional -- an anonymous caller may read a public-link share (the share password, when set, must still be satisfied). Access is decided entirely by the share's own `permissions.filesystem.file_view` policy; there is no workspace-level requirement. - `file_view: "all"` -- every version of every node in the share is listable. - `file_view: "owned"` -- only versions of nodes the caller created are listable; every other node is rejected. An anonymous caller has no creator identity, so all nodes are rejected. - `file_view: "none"` -- the endpoint is rejected outright. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | Node OpaqueId | **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/versions/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** `versions` is an object carrying a `count` and an `items` array -- not a bare array. ```json { "result": true, "versions": { "count": 2, "items": [ { "id": "3maag-qdkzs-whhrp-5jifb-zgbni-2udf", "type": "file", "current_version": true, "nodeId": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "name": "document.pdf", "parent": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "created": "2025-01-20 14:45:00 UTC", "size": 5242880, "hash": "abc123def456789...", "hash_algo": "sha256", "crc32c": "9a3b51c7", "mimetype": "application/pdf", "mimecategory": "document", "previews": { "thumbnail": { "state": "ready" }, "pdf": { "state": "ready" } }, "virus": { "status": "scanned", "infected": false }, "ai": { "state": "indexed", "attach": true, "summary": true }, "file_attributes": {}, "summary": { "title": "Quarterly Report", "short": "Q4 financial summary", "long": "..." }, "origin": { "type": "User", "creator": "9876543210987654321", "operations": ["modify"], "created": "2025-01-20 14:45:00 UTC" }, "replaces": { "status": "known", "version_id": "34wse-ehjvl-zefmd-2dfeh-hhw66-t4xe" }, "author": { "status": "known", "user_id": "9876543210987654321", "actor_type": "User", "agent_name": "Claude-2", "agent_name_source": "api_key_label", "actor": { "user_id": "9876543210987654321", "kind": "agent", "agent_name": "Claude-2", "name_source": "api_key_label", "credential_type": "api_key", "verified": false } } }, { "id": "34wse-ehjvl-zefmd-2dfeh-hhw66-t4xe", "type": "file", "current_version": false, "nodeId": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "name": "document.pdf", "parent": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "created": "2025-01-15 10:30:00 UTC", "size": 4194304, "hash": "def456abc789012...", "hash_algo": "sha256", "crc32c": "2d04e8f6", "mimetype": "application/pdf", "mimecategory": "document", "previews": { "thumbnail": { "state": "ready" }, "pdf": { "state": "ready" } }, "virus": { "status": "scanned", "infected": false }, "ai": { "state": "indexed", "attach": true, "summary": false }, "file_attributes": {}, "summary": null, "origin": { "type": "User", "creator": "9876543210987654321", "operations": ["create"], "created": "2025-01-15 10:30:00 UTC" }, "replaces": { "status": "none", "version_id": null }, "author": { "status": "known", "user_id": "9876543210987654321", "actor_type": "User", "agent_name": null, "agent_name_source": null, "actor": { "user_id": "9876543210987654321", "kind": "human", "agent_name": null, "name_source": null, "credential_type": "session", "verified": false } } } ] } } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `versions.count` | integer | Number of entries in `versions.items` | | `versions.items` | array | The version entries, described below | **Per version entry:** | Field | Type | Description | |-------|------|-------------| | `id` | string | Version OpaqueId | | `type` | string | Node type: `"file"`, `"folder"`, or `"note"` | | `current_version` | boolean | `true` if this is the node's current (live) version | | `nodeId` | string | OpaqueId of the node this version belongs to | | `name` | string | Node name at this version | | `parent` | string | Parent folder OpaqueId, or `"root"` / `"trash"` | | `created` | string | Version creation timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `deleted` | string | Present only on a version created by trashing (`YYYY-MM-DD HH:MM:SS UTC`) | | `deleted_from` | string/null | OpaqueId of the folder the node was trashed from; present alongside `deleted` | | `size` | integer | File size in bytes (file/note versions) | | `hash` | string | Content hash for this version (file/note versions) | | `hash_algo` | string | Hash algorithm, e.g. `"sha256"` (file/note versions) | | `crc32c` | string/null | Whole-file CRC-32C of this version's content, 8 lowercase hex digits; `null` for content stored before the platform began recording it (file/note versions) | | `mimetype` | string | MIME type (file/note versions) | | `mimecategory` | string | MIME category (file/note versions) | | `previews` | object | Preview state keyed by preview type, each `{ "state": "..." }` (file/note versions) | | `virus` | object | Virus scan result: `status` (`"scanned"`, `"unscanned"`, `"unknown"`), plus `infected` and/or `reason` (file/note versions) | | `ai` | object | AI processing state: `state` (`"disabled"`, `"pending"`, `"in_progress"`, `"ready"`, `"indexed"`, `"failed"`), `attach` (boolean), `summary` (boolean) (file/note versions) | | `file_attributes` | object | Metadata read out of the file itself: `media_metadata` and/or `exif_metadata` when available, otherwise empty (file/note versions). Returned only to callers permitted to download the file -- a view-only caller gets `{}` (see *Embedded File Metadata* under Node Object Schema) | | `summary` | object/null | AI summary `{ title, short, long }`, or `null` when none (file/note versions) | | `origin` | object | Provenance for this version -- see below | | `replaces` | object | Which version this version replaced -- see below | | `author` | object | Who created this version -- see below | **`origin` object:** | Field | Type | Description | |-------|------|-------------| | `origin.type` | string | Identifier type of the actor that produced the version (e.g. `"User"`), or `"unknown"` for versions that predate origin tracking | | `origin.creator` | string | Creator profile ID, `"anonymous"` for an anonymous public-link contributor, or `"unknown"` | | `origin.operations` | array | Operations that produced this version: any of `"create"`, `"rename"`, `"move"`, `"modify"`, `"restore"` | | `origin.created` | string/null | When the origin record was written (`YYYY-MM-DD HH:MM:SS UTC`), or `null` for versions that predate origin tracking | **`replaces` object:** | Field | Type | Description | |-------|------|-------------| | `replaces.status` | string | `"known"`, `"none"`, or `"unknown"` | | `replaces.version_id` | string/null | The id of the version this one replaced; non-null only when `status` is `"known"` | - `known` -- `version_id` is the version this version replaced. - `none` -- this version replaced nothing; it is the version the file was created with. - `unknown` -- it cannot be determined; `version_id` is `null`. **Clients must branch on `status` and must never treat a null `version_id` as "this was the first version"** -- `none` and `unknown` are different answers. - Replacement lineage is derived from the order of the versions that still exist, not stored. Older version history is thinned over time, so for versions older than roughly half a day the preceding surviving version is not necessarily the one that was replaced -- those report `unknown` rather than naming a version that might be wrong. The **current** version's `replaces` is always resolvable, at any age. - `unknown` is also returned when the returned list is not the file's complete history -- a full page of results (older versions exist beyond it), or a listing that omits versions the caller may not access (the File Share version listing omits versions whose content is unavailable). **`author` object:** | Field | Type | Description | |-------|------|-------------| | `author.status` | string | `"known"` or `"unknown"` | | `author.user_id` | string/null | Profile id credited with creating this version; `"anonymous"` for an anonymous public-link contributor; `null` when unknown | | `author.actor_type` | string/null | What kind of profile `user_id` is -- usually `"User"`, but not always (see below); `null` when unknown | | `author.agent_name` | string/null | Name of the agent that acted on that account's behalf, when one did | | `author.agent_name_source` | string/null | Where that name came from; `null` when no agent acted | | `author.actor` | object | Who created this version, in the common actor shape (`user_id`, `kind`, `agent_name`, `name_source`, `credential_type`, `verified`) -- see *Actor Attribution* under Node Object Schema. `kind` is `"unknown"` when authorship was never recorded | - **`user_id` is a PROFILE id and it is not always a person.** Check `actor_type` before resolving it against a user lookup: some versions are attributed to a workspace rather than to a user, and looking one of those up as a user will find nothing. `author.user_id` / `author.actor_type` carry the same pair as `origin.creator` / `origin.type`. - The account is who the version is attributed to; the agent name only qualifies it -- there is always an account behind an agent. - **`agent_name` is self-declared, not verified.** Display it; never rely on it to identify or authorize anyone. - `status` is `unknown` in two different situations that `user_id` alone cannot tell apart. Most history does carry an author, so `unknown` is not the common case -- but it is a value you will genuinely receive, and it never fills in later: - **Authorship was never recorded** -- versions created before authorship tracking existed. These also return an empty `origin.operations` and a `null` `origin.created`, which is how you distinguish this case. - **Nothing was acting as an account** -- versions created by background processing rather than by a request: a synced file that changed at the connected cloud provider, a folder copy large enough to run in the background, or an upload assembled without an associated account. Render both as "unknown", never as an empty or missing author. Folder versions carry only the basic fields (`id`, `type`, `current_version`, `nodeId`, `name`, `parent`, `created`, `replaces`, `author`) plus `origin`; the file-specific fields above are omitted. A version that was created by trashing carries additional `deleted` / `deleted_from` fields. **Error responses:** the Error Code column shows the application error class followed by the per-call-site value returned in `error.code`. | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` -- `193615` / `124906` | 404 | Node not found (share / workspace) | | `1609 (Not Found)` -- `173577` | 404 | Node exists but falls outside the share's folder scope (share only) | | `1609 (Not Found)` -- `148373` | 404 | The workspace folder backing the share was deleted (share only) | | `1605 (Invalid Input)` -- `194361` / `149251` | 406 | Unsupported node type, for example a link (share / workspace) | | `1665 (Object Init Failed)` -- `167497` / `112869` | 500 | Node data is corrupted (share / workspace) | | `1664 (Datastore Error)` -- `186805` / `137723` | 500 | Version lookup failed (share / workspace) | | `1680 (Access Denied)` -- `144499` | 401 | Share `file_view` is `none` (share only) | | `1680 (Access Denied)` -- `134467` | 401 | No file-view access to this node (share only) | | `1680 (Access Denied)` -- `120944` | 401 | Share `file_view` is `owned` and the caller did not create this node (share only) | **Notes:** - Share permissions may restrict version listing to files the user created (`file_view: "owned"`). - The share route needs no workspace permission and no authenticated user; a public-link share is readable anonymously. --- ## Restore Version ``` POST /current/workspace/{workspace_id}/storage/{node_id}/restore-version/ POST /current/share/{share_id}/storage/{node_id}/restore-version/ ``` Restore a file to a previous version. Creates a new version pointing to the historical version's content. Both filename and content are restored. **Auth required.** Permission: Guest (workspace), file modification permission (share). **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | File OpaqueId | **Request body (form-encoded):** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `version_id` | string | Yes | OpaqueId of the version to restore | **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/restore-version/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'version_id=34wse-ehjvl-zefmd-2dfeh-hhw66-t4xe' ``` **Response:** ```json { "result": true, "new_version": { "id": "3qced-56d4r-4o7q3-w3xru-zw3m5-nutj", "type": "file", "current_version": true, "nodeId": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "name": "original-name.pdf", "parent": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "created": "2025-01-25 09:00:00 UTC", "size": 4194304, "hash": "def456abc789012...", "hash_algo": "sha256", "crc32c": "2d04e8f6", "mimetype": "application/pdf", "mimecategory": "document", "previews": { "thumbnail": { "state": "ready" }, "pdf": { "state": "ready" } }, "virus": { "status": "scanned", "infected": false }, "ai": { "state": "indexed", "attach": true, "summary": false }, "file_attributes": {}, "summary": null, "origin": { "type": "User", "creator": "9876543210987654321", "operations": ["restore"], "created": "2025-01-25 09:00:00 UTC" }, "replaces": { "status": "unknown", "version_id": null }, "author": { "status": "known", "user_id": "9876543210987654321", "actor_type": "User", "agent_name": null, "agent_name_source": null, "actor": { "user_id": "9876543210987654321", "kind": "human", "agent_name": null, "name_source": null, "credential_type": "session", "verified": false } } }, "node": { "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "type": "file", "name": "original-name.pdf", "version": "3qced-56d4r-4o7q3-w3xru-zw3m5-nutj" } } ``` `new_version` carries the same per-version fields as a [List Versions](#list-versions) entry (it is the freshly created current version); `node` is the standard node resource (see *Node Object Schema*). **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Node not found | | `1605 (Invalid Input)` | 406 | Can only restore file versions (not folders) | | `1605 (Invalid Input)` | 406 | Cannot restore version of trashed file | | `1609 (Not Found)` | 404 | Version not found | | `1605 (Invalid Input)` | 406 | Version does not belong to this file | | `1609 (Not Found)` | 404 | Version data no longer available | | `1680 (Access Denied)` | 401 | No permission to restore versions (share only) | **Notes:** - Original versions are preserved; restoring creates a new current version whose content and name match the selected historical version. - Both filename and content are restored to the historical version's state. - `new_version.replaces` is always `"unknown"` on this endpoint -- this response returns a single version with no surrounding history to derive lineage from. Call [List Versions](#list-versions) for lineage. --- ## Download File (Read) ``` GET /current/workspace/{workspace_id}/storage/{node_id}/read/ GET /current/share/{share_id}/storage/{node_id}/read/ ``` Download file content as binary. For notes, returns raw markdown. Supports byte-range requests for partial downloads and video streaming. **Auth: JWT or download token.** Permission: View (workspace), download permission (share). With a valid `token` query parameter, no JWT is required. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | File or note OpaqueId | **Query parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `token` | string | No | Download token from `requestread` (bypasses JWT auth) | | `version_id` | string | No | Specific version OpaqueId to download | **curl examples:** ```bash # Download with JWT auth curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/read/" \ -H "Authorization: Bearer {jwt_token}" \ -o output.pdf # Download with token (no JWT needed) curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/read/?token={download_token}" \ -o output.pdf # Download specific version curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/read/?version_id=34wse-ehjvl-zefmd-2dfeh-hhw66-t4xe" \ -H "Authorization: Bearer {jwt_token}" \ -o output_v1.pdf # Byte-range request (streaming) curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/read/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Range: bytes=0-1023" ``` **Response:** Binary file content streamed directly. - Status `200 OK` for full file, `206 Partial Content` for range requests. - Headers: `Content-Type`, `Content-Length`, `Content-Disposition`, `Accept-Ranges: bytes`. - On a `206`, `Content-Length` is the length of the **returned slice**; the total size is the value after the slash in `Content-Range: bytes {first}-{last}/{total}`. - A syntactically valid range that cannot be satisfied — a first byte at or past the end of the file — returns `416 Range Not Satisfiable` with `Content-Range: */{total}`. An unparseable `Range` header is ignored and the full file is returned with `200`. - Only `GET` is accepted; `HEAD` returns `405`. To learn a file's size without downloading it, send `Range: bytes=0-0` and read the total from `Content-Range`. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | File not found | | `1605 (Invalid Input)` | 406 | Can only read file or note (not folder) | | `1609 (Not Found)` | 404 | File is in trash | | `1609 (Not Found)` | 404 | Version not found | | `1605 (Invalid Input)` | 406 | Version does not belong to this file | | `1609 (Not Found)` | 404 | Version data no longer available | | `1680 (Access Denied)` | 401 | File flagged as virus-infected (share only) | | `1680 (Access Denied)` | 401 | No download permission, a creator-only restriction on a file you did not create, or the share's download security is `medium` and the caller is a guest (share only) | --- ## Request Download Token ``` GET /current/workspace/{workspace_id}/storage/{node_id}/requestread/ GET /current/share/{share_id}/storage/{node_id}/requestread/ ``` Generate a temporary auth-free download token. **Auth required.** Permission: View (workspace), download permission (share). **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{node_id}` | string | Yes | File or note OpaqueId | **Query parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `version_id` | string | No | Pin the token to one specific version. Omit it and the token is bound to the node only — the read call can still name its own `version_id`, and the bytes returned are whatever that read resolves (current, or the version it names). | **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/requestread/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` ### Pinning a token to a version Pass `version_id` and the issued token is bound to that exact version. The response then echoes the version it was pinned to: ```json { "result": true, "token": "{download_token}", "version_id": "3o2ex-os4uz-32bkm-icr5l-uv3jd-546r" } ``` **Why pin.** Without it, the token authorises the node and the `version_id` on the *read* selects the bytes — the two are never compared, so a token issued while looking at one version can fetch a different one that was committed in between. **A pinned token cannot**: a read naming any other version is rejected. Use this whenever the bytes you read will become the basis of a later write, so the version you record is provably the version you received. Pass the SAME `version_id` on the subsequent `read` call. A pinned token is rejected on a read that names a different version, and on a read that names none. **Compatibility.** `version_id` is optional and additive — omit it and behaviour is exactly as before. Tokens already issued without it keep working, including on reads that name a version. **Applies to the note read too.** `GET /current/share/{share_id}/storage/{node_id}/readnote/` accepts the same token and the same `version_id`, and enforces the same pin. **Errors at mint time**, rather than later at fetch time: an unknown version returns `404`; a version belonging to a different file returns `406`; a version whose content is no longer stored returns `404`. A backend failure returns a `5xx` and is safe to retry — it is deliberately NOT reported as a missing version, which would tell you the resource is gone when it is not. **Usage:** Append `?token={token}` to the `read` endpoint to download without an Authorization header. Useful for opening files in browser tabs. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | File not found | | `1605 (Invalid Input)` | 406 | Can only read file or note | | `1609 (Not Found)` | 404 | File is in trash | | `182855` (workspace) / `125883` (share) | 404 | Version not found | | `141154` (workspace) / `105838` (share) | 406 | Version does not belong to this file | | `164665` (workspace) / `191654` (share) | 404 | Version data no longer available | | `129880` (workspace) / `190664` (share) | 5xx | Unable to verify version (backend failure, safe to retry) | | `1680 (Access Denied)` | 401 | No download permission (share only) | --- ## Download Folder as ZIP ``` GET /current/workspace/{workspace_id}/storage/{folder_id}/zip/ GET /current/share/{share_id}/storage/{folder_id}/zip/ ``` Download an entire folder as a streaming ZIP archive. **Auth: JWT or ZIP token.** Permission: View (workspace), download permission (share). With a valid `token` query parameter from `requestzip`, no JWT is required. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{folder_id}` | string | Yes | Folder OpaqueId, or `"root"` for entire storage | **Query parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `token` | string | No | ZIP token from `requestzip` for this same profile and folder (replaces the Authorization header) | **curl examples:** ```bash # Download with JWT auth curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/root/zip/" \ -H "Authorization: Bearer {jwt_token}" \ -o workspace.zip # Download with a ZIP token (no JWT needed) curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/root/zip/?token={zip_token}" \ -o workspace.zip ``` **Response:** Binary ZIP archive streamed directly with `Content-Type` and `Content-Disposition` headers. **Notes:** - Uses ZIP64 format — supports archives up to 50GB (plan-dependent limits may be lower). - Compatible with all modern extraction tools (WinRAR, 7-Zip, macOS Archive Utility, Windows Explorer). - The archive is streamed; it is not buffered in memory. - Files are stored without compression for immediate streaming. - Maximum 10,000 files per archive. - Rate limited more aggressively than other endpoints due to resource cost. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | Invalid folder id, or the folder does not exist, is not a folder, or is in trash | | `1605 (Invalid Input)` | 406 | The `token` is malformed, expired, not a ZIP token, or was issued for a different profile or folder | | `1680 (Access Denied)` | 401 | You are already downloading this folder, or already have the maximum number of ZIP downloads running -- wait for one to finish | | `1680 (Access Denied)` | 401 | The account the `token` was issued to no longer has access (token downloads re-check that account's current access on every use) | --- ## Request ZIP Download Token ``` GET /current/workspace/{workspace_id}/storage/{folder_id}/requestzip/ GET /current/share/{share_id}/storage/{folder_id}/requestzip/ ``` Generate a temporary token that downloads a folder as a ZIP archive without an Authorization header. Use it to hand out a ZIP link that does not contain your credentials. **Auth required.** The same access the `zip` endpoint requires: View (workspace); download permission (share), where a guest is refused when the share's download security is `medium`. The caller must be signed in to an account -- an anonymous share visitor cannot request a ZIP token. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` or `{share_id}` | string | Yes | 19-digit profile ID | | `{folder_id}` | string | Yes | Folder OpaqueId, or `"root"` for entire storage (on a share, `root` is the share's own top folder) | **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/root/requestzip/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "token": "{zip_token}" } ``` **Usage:** Append `?token={token}` to the `zip` endpoint for the SAME profile and folder: ``` GET /current/workspace/{workspace_id}/storage/{folder_id}/zip/?token={token} ``` **How the token behaves:** - It expires after 2 hours. - It is valid only on the `zip` endpoint of the profile and folder it was issued for. It is refused for any other folder, by the single-file `read` endpoint, and by previews; a `requestread` token is likewise refused by `zip`. - The download runs as the account that requested the token, and that account's access is checked again on every use. If the account loses access (removed from the workspace or share, download permission revoked, account disabled), the token stops working. - The ZIP endpoint's per-account download limits apply to the token's account. - Treat the token as a credential: anyone holding it can download the folder until it expires. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | Invalid folder id, or the folder does not exist, is not a folder, or is in trash | | `1680 (Access Denied)` | 401 | No access to the folder, no download permission, a guest on a `medium` download-security share, or the caller is not signed in to an account | | `1609 (Not Found)` | 404 | The shared folder no longer exists (share only) | --- ## Recent Files ``` GET /current/workspace/{workspace_id}/storage/recent/ GET /current/share/{share_id}/storage/recent/ ``` List recently modified nodes across all folders, sorted by `updated` descending. Unlike `list` which is scoped to a single folder, this endpoint returns nodes from the entire storage tree. **Auth required.** Permission: View (workspace), Guest+ (share). Public shares may allow password-only access. **Query parameters:** | Parameter | Type | Default | Description | |-----------|--------|---------|------------------------------------------------------| | `page_size` | int | `100` | One of: `100`, `250`, `500` (snapped to nearest) | | `cursor` | string | -- | Opaque cursor string from previous response | | `type` | string | -- | Filter by node type: `file`, `folder`, `link`, `note` | **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/recent/?type=file&page_size=250" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "nodes": { "count": 1, "items": [ { "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "name": "report.pdf", "type": "file", "parent": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "size": 2048576, "modified": "2025-02-18 14:30:00 UTC", "created": "2025-02-17 10:00:00 UTC" } ] }, "pagination": { "has_more": true, "next_cursor": "eyJsYXN0X3VwZGF0ZWQiOiIyMDI1LTAyLTE4...", "page_size": 250 } } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `nodes.count` | integer | Number of nodes in this page | | `nodes.items` | array | Array of node resources | | `pagination.has_more` | boolean | Whether more pages exist | | `pagination.next_cursor` | string/null | Cursor for the next page | | `pagination.page_size` | integer | Effective page size used | **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | Invalid pagination cursor | | `1680 (Access Denied)` | 401 | Insufficient permissions to view files (share only) | | `1609 (Not Found)` | 404 | Orphaned workspace folder share | **Notes:** - Sort order is always `updated DESC` and is not configurable. - Uses cursor-based (keyset) pagination, same as the `list` endpoint. - For workspace folder shares, results are post-filtered to the share's subtree. --- ## Search ``` GET /current/workspace/{workspace_id}/storage/search/ GET /current/share/{share_id}/storage/search/ ``` Search files by filename, by content, or by both. By default a query matches the filename **and** the content together and blends the two into one ranked list — which is what you want when you are looking for a topic. When you are looking for a *file*, ask for the filename side explicitly with `search_in=filename` and pick a matching style with `name_match` (`exact`, `prefix`, `contains`, or shell-style `glob`). See *Search modes* below. **Auth required.** Permission: View (workspace), search + file view permissions (share). **Query parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|----------------------------| | `search` | string | Yes | - | Search query string. Under a precise `name_match` this string **is** the pattern. | | `queries` | array (JSON) | No | - | A JSON **array** of up to 3 extra query strings, e.g. `queries=["invoice 2025","billing statement"]` (URL-encode the value); a JSON object or any non-array value is rejected. The 3-entry cap applies to what you SEND, before de-duplication — a 4-entry array is rejected even if two entries are identical. Each surviving entry runs its own semantic leg over the same scope and is rank-fused into the one ranked list — use it to cover a few paraphrases of the same intent in a single request instead of issuing several searches. Bracket syntax (`queries[]=`) is not supported. A duplicate of `search` or of another entry in `queries` (compared trimmed, case-insensitive) is dropped silently before fusing. See `pagination.queries_fused` below. | | `search_in` | string | No | `both` | Which side of the file to match: `filename`, `content`, or `both`. See *Search modes*. | | `name_match` | string | No | `auto` | How the filename is matched: `auto`, `exact`, `prefix`, `contains`, or `glob`. Ignored when `search_in=content`. | | `case_sensitive` | string | No | `false` | `true` / `false` / `1` / `0`. Applies to the precise `name_match` values; ignored under `auto`. | | `files_scope` | string | No | - | Comma-separated `nodeId:versionId` pairs, **query string only**. **Narrows the meaning-based (semantic) leg ONLY** — filename and summary matches are not restricted by it. Takes nodes of `type: "file"` **or** `type: "note"`; a folder or link is refused. See *Scoping to files or folders*. | | `folders_scope` | string | No | - | Comma-separated `nodeId:depth` pairs, **query string only**. **Narrows the meaning-based (semantic) leg ONLY**, same as `files_scope`. Takes nodes of `type: "folder"` only; a file, note or link is refused. **The `:depth` is REQUIRED** and must be an integer from `1` to `10`; a bare node id is refused with `406` / `113920` *"Invalid folder format. Expected nodeId:depth"*. See *Scoping to files or folders*. Folder **node ids only** — the `root` and `trash` folder aliases are refused; omit the scope entirely to search everything. A scope carries at most 100 references in total, counting expanded subfolders; a tree that runs past that is truncated and the response says so via `search_metadata.scope_incomplete`. Both `files_scope` and `folders_scope` are validated on every request: a malformed or invalid scope is refused with `406` even when the meaning-based leg does not run (AI features off or `search_in=filename`). | | `filters` | string (JSON) | No | - | **Workspace routes only.** A JSON array of metadata predicate objects. Narrows the search to files whose extracted metadata satisfies **every** predicate, *before* the query runs. Cannot currently be combined with `folders_scope`. See *Filtering by metadata*. | | `details` | string | No | - | `"true"` to include the full node resource for each result, as `files.{id}.node` (default limit drops to 10) | | `limit` / `offset` | integer | No | `100` / `0` | Offset pagination. `limit` is `1`–`500` (default `10` when `details=true`). In hybrid mode the two retrieval legs are fetched to a fixed depth that does not change with the page, so every page is a slice of one ranking and paging forward never re-orders what you already saw. **`queries` breaks that guarantee** — every page re-runs all legs fresh, so one extra leg can fail on page 1 and a different extra leg can fail on page 2, changing which legs are fused into each page independently. Matching `pagination.queries_fused` / `pagination.total_relation` values across two pages do **not** prove the same legs contributed or that the ordering is stable — those fields can read identically while the underlying leg mix differs. **Fetch the whole set in one call** (`limit` up to 500) instead of paging when `queries` is in use. | | `output` | string | No | `full` | Verbosity: `terse`, `standard`, or `full` (default). Trims `content_snippet` and `best_chunk.text` to a byte budget, and drops `summary_short`, when Deep Indexing is enabled. See *Verbosity* below. | `search_in`, `name_match`, and `case_sensitive` are all optional and all **additive**: omit them and you get exactly the behavior this endpoint has always had — same query, same ranking, same response keys. Values are matched case-insensitively (`GLOB` and `glob` are the same value). **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/search/?search=quarterly+report" \ -H "Authorization: Bearer {jwt_token}" ``` ### Search modes **`search_in` — which side of the file to match** | Value | Matches | |-------|---------| | `filename` | The file's **name** only. Predictable and pattern-driven — this is the `find`-style surface, and the one to reach for when you know (or can describe) what the file is called. It never runs a content lookup, so it behaves identically whether or not AI features are enabled. | | `content` | The AI's **understanding** of the file — its AI-generated summary plus meaning-based (semantic) matches on what the file is about. | | `both` | *(default)* Filename and content together, blended and ranked — the behavior that shipped before these parameters existed. | ⚠️ **`content` is not a text scan of the file.** Fastio does not index the literal bytes of your documents, so `search_in=content` is **not** `grep` and not full-text search — it matches the AI-generated summary of a file and the file's semantic (meaning-based) index. Consequences worth planning around: - **`content` matches two channels, and only one of them needs Deep Indexing (API field: `intelligence`) on.** The two are the file's AI-generated **summary** (matched by keyword) and the file's **semantic** (meaning-based) index. Instance Deep Indexing gates the *semantic* half only: turning AI features off stops new meaning-based matching, but it does **not** un-index summaries that were already written. A `content` search on a workspace with AI features off therefore still returns hits for files whose summaries were indexed earlier, and returns nothing when no such summaries exist. - Either way, a file has to have been indexed at some point (`ai.state: indexed`). A file uploaded a moment ago is findable by **name** immediately and by **content** only once indexing completes. - A phrase that appears verbatim inside a document will not necessarily match, and a phrase that never appears in it may match. Ask `content` questions in terms of what a document is *about*, not in terms of an exact string you expect to find. - If you need an exact string, that string is almost always in the **filename** — use `search_in=filename`. **`name_match` — how the filename is matched** Only meaningful when the filename participates in the query (`search_in=filename` or `both`); ignored under `search_in=content`. | Value | Matches | Example | |-------|---------|---------| | `auto` | *(default)* Layered relevance — phrase, prefix, fuzzy, stemmed, and substring tiers, with near-exact filename matches promoted to the top. Unchanged behavior. | `quarterly report` finds `Q4 Quarterly Report (final).pdf` | | `exact` | The whole filename equals the query. Also matches the **extensionless base**, so you can type the name without knowing the extension. | `Q4 Report` matches `Q4 Report.pdf` and `Q4 Report` | | `prefix` | The filename **starts with** the query, taken literally. | `Invoice-` matches `Invoice-2026-0042.pdf` | | `contains` | The filename **contains** the query as a literal substring, anywhere. | `2026` matches `Invoice-2026-0042.pdf` | | `glob` | Shell-style pattern over the **whole** filename: `*` matches any run of characters (including none), `?` matches exactly one. | `Quarterly*.pdf` matches `Quarterly Report.pdf` | **`glob` is the `find`-style surface, and it spans spaces and hyphens.** Because the pattern runs against the complete filename rather than word-by-word, `Quarterly*.pdf` finds `Quarterly Report.pdf`, `report-*.xlsx` finds `report-2026-q1.xlsx`, and `Q?-2026.csv` finds `Q1-2026.csv`. `*.pdf` gives you every PDF. This is the reason `glob` exists — it is the only mode that can match across a space or a hyphen in a filename. **Do not escape your query — escaping is handled for you.** Under `exact`, `prefix`, and `contains`, `*` and `?` are matched **literally**: `name_match=contains` with `search=report*` looks for a filename that really contains the two characters `report*` — it will not match `report-2026.pdf`. That is what makes a filename containing an asterisk searchable at all (`name_match=prefix` with `search=weird*` finds `weird*name.txt`). Only `glob` treats `*` and `?` as wildcards. A client that pre-escapes its query (`report\*`) will search for the backslash. **Case sensitivity.** `case_sensitive` defaults to `false`, which is what an agent almost always wants (it matches `find -iname`). Case-insensitive matching folds **non-ASCII letters too** — `ÄNDERUNG` matches `änderung.docx` and `RÉSUMÉ` matches `résumé.pdf`. It folds case only and does **not** strip accents, so `resume` will not match `résumé.pdf`. Send the query exactly as the user typed it: do **not** lowercase or otherwise normalize it client-side. **Pattern rules (precise modes only).** Under any `name_match` other than `auto`: the query must be non-empty after trimming, and at most **256 characters**; and a `glob` pattern made up of nothing but `*` and `?` is rejected because it matches every file. Violations return `1605 (Invalid Input)`. `auto` keeps the length rules it has always had — `search` is not capped on this endpoint. **Examples:** ```bash # Every PDF in the workspace, by name only — no content lookup at all curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/search/?search=*.pdf&search_in=filename&name_match=glob" \ -H "Authorization: Bearer {jwt_token}" # A filename that spans a space curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/search/?search=Quarterly*.pdf&search_in=filename&name_match=glob" \ -H "Authorization: Bearer {jwt_token}" # Everything whose name starts with a known prefix, case-sensitively curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/search/?search=Invoice-&search_in=filename&name_match=prefix&case_sensitive=true" \ -H "Authorization: Bearer {jwt_token}" # Ask what a document is about, ignoring filenames entirely curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/search/?search=revenue+guidance+for+next+year&search_in=content" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (keyword-only, Deep Indexing disabled):** ```json { "result": true, "files": { "2ltsuq4mjacuv7pgc5ydlxnsjwee4": { "name": "Q4 Report.pdf", "parent_id": "2qk7dkri4yyievbq5hrieq4iohij5", "path": "Finance/Reports", "ancestors": [ { "id": "2e2f3-inlkh-p63sd-3ungd-vfyvc-gilh", "name": "Finance" }, { "id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "name": "Reports" } ], "path_complete": true, "type": "file", "content_snippet": null, "match_source": "keyword", "summary_short": null, "text_indexed": false, "best_chunk": null, "metadata_match": false, "metadata_match_field": null } }, "pagination": { "total": 1, "limit": 100, "offset": 0, "has_more": false, "total_relation": "eq", "queries_fused": 0 } } ``` **Response (hybrid, Deep Indexing enabled):** ```json { "result": true, "files": { "2emafexxpwthkzj5rlymocyohiufa": { "name": "MSA - Northwind Traders.pdf", "parent_id": "27ifbytevmpzoriybzuldro2udqcm", "path": "Legal/Contracts", "ancestors": [ { "id": "2tyji-byjgb-3f72a-cpvhd-5jw2i-6um7", "name": "Legal" }, { "id": "27ifb-ytevm-pzori-ybzul-dro2u-dqcm", "name": "Contracts" } ], "path_complete": true, "type": "file", "relevance_score": 0.504, "raw_score": 4.31, "score_source": "metadata", "content_snippet": "This Master Services Agreement is entered into by Northwind Traders …", "match_source": "keyword", "media_segment": null, "mimetype": "application/pdf", "page": { "start_page": 1, "end_page": 1 }, "text_indexed": true, "summary_short": "Master services agreement with Northwind Traders, effective 2026-01-01.", "best_chunk": { "text": "This Master Services Agreement is entered into by Northwind Traders …", "same_as_snippet": false, "page": { "start_page": 1, "end_page": 1 }, "media_segment": null, "score": 6.42, "result_type": "doc", "position": 0, "sequence": 2001, "indexed_version_id": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5", "chunk_hash": "4b8e1d07c2" }, "metadata_match": true, "metadata_match_field": null }, "2ltsuq4mjacuv7pgc5ydlxnsjwee4": { "name": "Q4 Report.pdf", "parent_id": "2qk7dkri4yyievbq5hrieq4iohij5", "path": "Finance/Reports", "ancestors": [ { "id": "2e2f3-inlkh-p63sd-3ungd-vfyvc-gilh", "name": "Finance" }, { "id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "name": "Reports" } ], "path_complete": true, "type": "file", "relevance_score": 1.0, "raw_score": 0.87, "score_source": "semantic", "content_snippet": "Quarterly revenue grew 18% year-over-year …", "match_source": "both", "media_segment": null, "mimetype": "application/pdf", "page": { "start_page": 3, "end_page": 3 }, "text_indexed": true, "summary_short": "Q4 revenue and margin review for the North America segment.", "best_chunk": { "text": "Quarterly revenue grew 18% year-over-year …", "same_as_snippet": false, "page": { "start_page": 3, "end_page": 3 }, "media_segment": null, "score": 0.87, "result_type": "doc", "position": 12, "sequence": 3001, "indexed_version_id": "3mcys-2dr56-rmgdt-nh36b-prmp7-b47q", "chunk_hash": "c71f05a9e3" }, "metadata_match": false, "metadata_match_field": null } }, "pagination": { "total": 2, "limit": 100, "offset": 0, "has_more": false, "total_relation": "eq", "queries_fused": 0 }, "search_metadata": { "intelligence_enabled": true, "semantic_available": true, "scoped": false } } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `files` | object | Map of node OpaqueIds to file info. A matching node appears **exactly once**, whichever part of the search found it. Keys are the **unformatted** OpaqueId — the 29-character form with no hyphens — so compare them hyphen-insensitively if you hold ids in the grouped display form. | | `files.{id}.name` | string | File name | | `files.{id}.parent_id` | string\|null | Parent folder node ID in the **unformatted** form (no hyphens, like the `files` keys — `ancestors[].id` is the formatted form), **as it is right now**. The server reads the file's own record on every request and reports the folder it finds there, so this is never the stale value a lagging search index may hold, and `details=true` is not needed for it. A hit that matched only by meaning (`match_source: "semantic"`) arrives from the content engine without one and is filled the same way. It is empty (`""` or `null`) only when that lookup failed, and `path_complete` is `false` on the same row whenever it did. | | `files.{id}.path` | string\|null | **Where the file lives** — the names of the folders from the workspace or share root down to the folder holding this hit, joined with `/`, with no leading or trailing slash (`"Finance/Invoices/AR"`). The file's own name is **not** in it; that is `name`. `""` means the hit sits directly in the root. **`null` whenever `path_complete` is `false`** — read that flag, not this field, to tell "at the root" from "not known". Present on every result row, in both hybrid and keyword-only mode and at every `output` tier. ⚠️ **Display value, not an address.** Folder names are returned exactly as stored and the `/` separator is **not escaped**, so a folder whose own name contains `/` is indistinguishable from a level boundary. Use `ancestors` or `parent_id` to address a folder. | | `files.{id}.ancestors` | array | The same folders as `path`, in the same order, as `{id, name}` objects — `id` being the folder's OpaqueId in the **formatted** (hyphenated) form the rest of the API takes, so each entry is directly usable with `/storage/{parent_id}/list/`. Whenever `path_complete` is `true`, `path` is exactly these names joined with `/`, so the two are one answer in two spellings; `[]` then means the hit is at the root. When `path_complete` is `false` this is a **best-effort suffix**: the folders NEAREST the file, with an unknown number of levels missing from the **top** — never a prefix, and never padded or guessed. It is `[]` when nothing could be resolved at all. | | `files.{id}.path_complete` | boolean | Whether the walk from the file up to the root actually reached it. `true` means `path` and `ancestors` are the complete answer. `false` means the walk stopped early — a folder could not be read, the chain was longer than the server walks, or the response needed more folder lookups than one request will make — in which case `path` is `null` and `ancestors` holds only the partial suffix above. **Every hit is resolved from the file's CURRENT record, never from the `parent_id` the row carried** — a search index lags the tree, so a file moved a moment ago still names its old folder there, and a path walked from it would be wrong while claiming to be complete. That recovery is ATTEMPTED on every row, so a `match_source: "semantic"` hit resolves the same as any other with or without `details=true`; when the record itself cannot be read, the row reports `path_complete: false` like any other unresolved one. `parent_id` is corrected to the live folder when the two disagree AND the walk reached the root, so a corrected value always matches the `path` beside it. On a row whose walk stopped short, `parent_id` is left exactly as the index had it rather than replaced with a folder the platform could not place. **A `false` here is never a statement about where the file is**; a file at the root reports `path_complete: true` with `path: ""`. | | `files.{id}.type` | string | Node type | | `files.{id}.relevance_score` | float | A **rank-fusion** score combining the two retrieval legs — the name/text search and the content search. Each leg contributes by the file's **position** in that leg, a file found by both legs collects both contributions, and the result is normalised **within this result set** so the **maximum `relevance_score` in the set is exactly `1.0`** and every hit is above `0.0`. That is the maximum, **not necessarily the first result** — ordering is tier-first (see *Result ordering*), so a promoted hit with a lower score can come back above it. Its scale is therefore re-derived for every query, and `1.0` means "the best of these results", not "a good result". **Use it to order results — do not threshold it, and do not compare it across queries.** Hybrid mode only. | | `files.{id}.raw_score` | float\|null | The **un-rescaled** retrieval score, on the scale named by `score_source` (on a `metadata` row that is the **keyword** scale — see below). The merge does not divide, clamp or round it against the other hits that came back with it. **That is a statement about rescaling, not a promise the number is constant:** re-running retrieval can still return a different value, because a `keyword` score is BM25 and moves with the index statistics. Not bounded to 0.0–1.0. `null` when `score_source` is `filename`. Hybrid mode only. | | `files.{id}.score_source` | string | An **attribution** — and only three of its four values name a scale. `keyword` (the keyword engine's BM25 score — **unbounded above**, and dependent on the index contents and the query terms), `semantic` (the content engine's similarity score for the passage that ranked the file — the same passage that produced `content_snippet`, `page`, and `media_segment`) and `filename` (the row was placed by a **name match**, not by a measured score, so `raw_score` is `null`) each name **the scale `raw_score` is on**. On a file both legs found, the leg reported is the one that ranked it **higher**; on an equal position the keyword leg is reported, because its score is always a real measurement while a content score is optional and can come back as `0.0`. `metadata` is **not a fourth scale**: it says the row was **promoted** because the query matched its extracted metadata, and its `raw_score` is still populated and **remains on the `keyword` scale** — compare it with other `keyword` rows, not with `semantic` ones. A promoted hit ranked higher by the content engine reports `semantic` rather than `metadata`; read `metadata_match` for the promotion itself. There is **no `both`** — which legs matched is a separate question, answered by `match_source`. Decided **per hit**, so one response can carry all four: **group by `score_source` before comparing any two `raw_score` values.** Hybrid mode only. | | `files.{id}.content_snippet` | string\|null | Matching text from the highest-scoring passage. On a `semantic` or `both` hit that is the passage the content search ranked the file by. **A `keyword`-only hit now carries one too**: the search goes back over the file's indexed text and quotes the best-matching passage, for the **top few results of the page only** and only where `text_indexed` is `true` — so a filename or metadata match is no longer returned without the text behind it. Still `null` on a `semantic` or `both` result when that passage carries no text; on a `keyword` result past that cut-off, one whose text nothing matched, or one whose file has no indexed text; and at `?output=terse` (that tier skips the per-hit file read the quote is scoped by). `content_snippet`, `page` and `media_segment` are taken as a set from one passage, so they always describe the same passage rather than being filled in individually from different ones. A `null` snippet means there is no matching excerpt in this response — read the file itself for content. Trimmed per `output`. | | `files.{id}.match_source` | string | Which legs of the hybrid search matched this file: `keyword` (filename/text index only), `semantic` (content embedding only), or `both` (matched by BOTH legs — not merely by several semantic passages). Always `keyword` when Deep Indexing is not enabled. | | `files.{id}.media_segment` | object\|null | `{start_seconds, end_seconds}` for audio/video matches. Hybrid mode only. | | `files.{id}.mimetype` | string\|null | File MIME type. Hybrid mode only. Populated on a `semantic`-or-`both` hit as before; a `keyword`-only hit now carries it too, filled from the file itself — except at `?output=terse` (that tier skips the per-hit file read that supplies it). | | `files.{id}.page` | object\|null | `{start_page, end_page}` for paginated documents. Hybrid mode only. A `keyword`-only hit carries one whenever the search quoted a passage for it — see `content_snippet`. | | `files.{id}.text_indexed` | boolean\|null | Whether the file has **any indexed text** at all — the question a `null` `content_snippet` cannot answer on its own. `false` means there is nothing indexed to quote, so reading the file is the only way to get at its content. `true` means the file's current version has indexed text (or, where the current version cannot be determined for the row, any still-active indexed version) — but **not** that this query matched it: a `true` beside a `null` snippet says the search did not return a passage for this query, not that the file has none. `null` means this response could not determine coverage — never that the file is unindexed. Present on **every** result row, in both hybrid and keyword-only mode. | | `files.{id}.summary_short` | string\|null | The stored short AI summary of the **whole file**. `null` when the file has no summary, and on a share search `null` whenever the caller's share role may not view summaries. **Always `null` at `?output=terse`** — the verbosity tier drops it, the same way it trims a node's summary — and always `null` on the keyword-only response. | | `files.{id}.best_chunk` | object\|null | The highest-scoring real **passage** of the file, as distinct from a whole-file summary: `{text, page, media_segment, score, result_type, position, sequence, indexed_version_id, chunk_hash, same_as_snippet}`. `text` is the passage text, trimmed to the same byte budget as `content_snippet` under `output` (~200 bytes at `terse`, ~600 at `standard`, untrimmed at `full`); at `terse` and `standard`, `text` is `null` when `same_as_snippet` is `true` (read `content_snippet` for the text instead). `same_as_snippet` (boolean) is `true` when, after both are trimmed to the tier's byte budget, `text` was byte-identical to `content_snippet` — the caller then reads `content_snippet` for the text while still using `best_chunk`'s locator fields — and `false` otherwise; always `false` at the default `full`. `page` and `media_segment` are **both always present**, and **at most one of them is ever set**: `page` is `{start_page, end_page}` (1-based, inclusive) on a document passage (`result_type: "doc"`), `media_segment` is `{start_seconds, end_seconds}` on a transcript passage (`result_type: "transcript"`), and the other is `null` — both are `null` for a passage from a source with no locator. `score` is the passage's own **un-rescaled** retrieval score: on a `semantic` or `both` row it is on the content engine's scale — the same scale a `semantic` `raw_score` is on; on a keyword-backfilled row (see below) it is an in-file **text-match** score on a different scale. Never compare `score` across rows with different `score_source`. `result_type` is `doc` or `transcript`. `position` (integer\|null) is the passage's **0-based address in the file's chunk order** — the same address `GET /current/{workspace\|share}/{id}/storage/{node_id}/content/` takes as `chunk_from` / `chunk_to`, so it is how you read the passage and its surroundings without downloading the file. `sequence` (integer\|null) is the underlying ordering coordinate, absent on content indexed before it existed. `indexed_version_id` (string\|null) is the file version the address was worked out against, in the same form the content endpoint returns it: **compare it with the content response's own `indexed_version_id`, and if they differ the file was re-indexed in between, so the position is stale and the search should be re-run.** `chunk_hash` (string\|null) is the same stable identity label the content routes publish as `chunks[].chunk_hash`, computed from this passage's own full indexed text rather than the trimmed `text` above -- it is the label of the chunk `position` points at, so a caller can recognise a passage it already holds, or spot the same passage repeated across files, without comparing bodies; it is `null` exactly when `position` is `null`, and `same_as_snippet` being `true` does not change its meaning. Both `position` and `indexed_version_id` are `null` when this response could not resolve the address: they are published for **document text only** — an audio or video passage (`result_type: "transcript"`) never carries one, and neither does a passage that no longer opens a chunk after a re-index or one on a page too large to resolve addresses for (only the first 100 hits of a page get one). That is **not** a statement that the passage cannot be read. `null` when no qualifying non-summary passage was returned for that file on this query — the retrieval window may simply not have returned one, so this is not a statement that the file has no passages. **A `keyword`-only hit can carry one too**: for the top few results of the page, and only where `text_indexed` is `true`, the search quotes the best-matching passage of the file's indexed text so a filename or metadata match is not returned bare. On such a hit `score` is that passage's **text-match** score within its own file — it is on neither the `semantic` nor the row's own `raw_score` scale, and like every score here it is not a threshold; `result_type` is always `doc`, `media_segment` always `null`, and `position` / `sequence` / `indexed_version_id` are resolved exactly as for a passage the content search returned. It stays `null` on a `keyword` hit past that cut-off, on one whose text nothing matched, and at `?output=terse`. See *Passages vs summaries* below. | | `files.{id}.metadata_match` | boolean | `true` when the query **also** matched the file's **extracted metadata** — entity-style values such as a counterparty, a customer, or a document title. `false` otherwise. It commonly co-occurs with a filename or summary match; it does **not** mean the match happened instead of those. Read `match_source` for which retrieval legs matched, and *Result ordering* below for why a row sits where it does — a `true` here makes the row eligible for tier 3 unless a stronger filename tier (exact or prefix name match) already applies. | | `files.{id}.metadata_match_field` | string\|null | **Reserved — currently always `null`.** It will name the metadata field that carried the match once per-field matching exists. Do not branch on it today. | | `files.{id}.node` | object\|null | Only with `details=true`: the full node resource for the hit, or `null` when it could not be read. | | `pagination.total` | int | How many distinct files the search found before the page was cut, **read together with `total_relation` below** — it is a floor whenever that says `gte`. | | `pagination.total_relation` | string | Whether `pagination.total` is an exact count (`eq`) or a **lower bound** (`gte`). Always present. See *Knowing whether `total` is exact* below. | | `pagination.queries_fused` | int | How many extra `queries` entries were actually fused into the results — the keyword leg and the primary `search` semantic leg are not counted here. Always present. `0` when no `queries` was sent, when the request ran keyword-only (e.g. `search_in=filename`), when every extra leg failed, or when every entry was a dropped duplicate. **A lower value than the number of `queries` you sent does not by itself mean a leg failed** — a silently-dropped duplicate lowers it too, with nothing failed, and neither this field nor `pagination.total_relation` names which cause applies on a given response — `total_relation: "gte"` says only that the total is a lower bound, for any of several reasons (see *Knowing whether `total` is exact* below), not specifically that a `queries` leg failed. | | `search_metadata` | object | `{intelligence_enabled, semantic_available, scoped}`, plus `scope_incomplete` when the applied scope was cut short, plus `scope_requested` / `scope_resolved` whenever `scoped` is `true`, plus the capability keys below when `search_in` was supplied. ⚠️ **Despite the name, this is not the files' metadata** — it describes THIS SEARCH (which channels were available, whether a scope applied). For extracted metadata, read the fields on each file, or `metadata_filter` for the outcome of a filter you sent. See *Knowing whether content search is possible*. | | `metadata_filter` | object | `{applied, matched, truncated, scope_incomplete, coverage}`. Present **only** when a `filters` value reached the server and ran. Its absence on a request you believe was filtered means the filter was dropped in transit — see *Filtering by metadata*. | **Passages vs summaries (`content_snippet` vs `best_chunk`).** `content_snippet` reports whichever piece of evidence ranked the file highest — and that can be the file's whole-document **summary**. A summary is not located anywhere in the file, so `page` is `null` on such a hit and there is nothing to jump to. `best_chunk` always reports a real **passage**, with the locator that belongs to it — `page` for a document passage, `media_segment` for a transcript passage, and the other one `null` — which is what you want when you are opening the file at the right place, quoting it, or citing a page. When the top-ranked evidence is already a passage, `best_chunk.text` carries the same text as `content_snippet` — as in the example above, which is at the default `full` tier where both stay populated and `same_as_snippet` is `false`. At `?output=terse` and `?output=standard`, once both are trimmed to the tier's byte budget, the server drops the duplicate instead: `best_chunk.text` is `null` and `best_chunk.same_as_snippet` is `true`, and the caller reads `content_snippet` for the text while still using `best_chunk`'s locator fields. **Passages on a filename or metadata match.** A `keyword` hit is found by the file's name and its extracted metadata, neither of which is document text, so such a row used to come back with nothing to quote even where the file's text was fully indexed. It is now quoted the same way any other row is: the search takes the best-matching passage of that file's indexed text and fills `content_snippet`, `page` and `best_chunk` from it. Three limits are worth knowing. It applies to the **top few results of the page**, not all of them, so a `null` on a lower-ranked `keyword` row is a budget, not a verdict. It applies only where `text_indexed` is `true`. And `best_chunk.score` on such a row is a **text-match** score inside that one file — do not compare it with a `semantic` passage's score, or with the row's own `raw_score`. **Reading around the passage.** `best_chunk.position` is the address the storage `content/` endpoint takes: send it back as `chunk_from` (and `chunk_to` a little higher) on `GET /current/{workspace|share}/{id}/storage/{node_id}/content/` to read the matching passage in full together with the text on either side of it, without downloading the file. Clamp both ends -- `chunk_from` at `0`, `chunk_to` at `9999`. Then compare `best_chunk.indexed_version_id` with the `indexed_version_id` that response returns: if they differ the file was re-indexed between the two calls, the position is stale, and the search should be re-run rather than the text quoted. `raw_score` and `score_source` are on `/storage/search/` only — the unified `/search/` route does not return them. **Result shape under `search_in=filename`.** A filename search has no content leg at all, so it returns the **keyword-only** item shape shown above — `name`, `parent_id`, `type`, `content_snippet: null`, `match_source: "keyword"`, plus `summary_short: null`, `best_chunk: null`, `metadata_match` and `metadata_match_field: null` — even on a workspace or share with AI features enabled. The hybrid-only fields (`relevance_score`, `raw_score`, `score_source`, `mimetype`, `media_segment`, `page`) are **absent**, because there is no content match to score or locate. If your client requires `relevance_score`, use `both` (the default) rather than `filename`. This shape is not new: it is exactly what every response looks like when AI features are off. The unified endpoints are unaffected — their `files` bucket keeps its usual item shape in every mode. ### Folder paths Every search hit says **where it lives**, so you do not have to list folders one at a time to find out. Three fields, one answer: - **`path`** — the ancestor folder names from the workspace or share root down to the folder holding the file, joined with `/`, no leading or trailing slash: `"Finance/Invoices/AR"`. The file's own name is not in it. `""` means the root. - **`ancestors`** — the same folders as `{id, name}` objects in the same order, with `id` in the formatted form `/storage/{parent_id}/list/` takes. Use this when you want to navigate rather than display. - **`path_complete`** — whether the walk reached the root. **Read this first.** When `path_complete` is `true`, `path` is exactly the `ancestors` names joined with `/`, and `[]` / `""` together mean the file is at the root. When it is `false`, `path` is `null` and `ancestors` carries only a best-effort **suffix** — the folders nearest the file, with an unknown number of levels missing from the top, never padded and never guessed. It goes `false` when a folder could not be read and past an internal depth limit. It is never a claim about where the file is. A hit that came back with an empty `parent_id` — which is every hit the meaning side found on its own — still gets a path: the server reads the file's own record to recover its folder. You do **not** need `details=true` for that, and the two retrieval legs answer alike. Paths are built when the response is assembled, so a folder rename shows up on the next call with no re-indexing delay. Through a share the root is the shared folder, so a path never names anything outside it. ⚠️ **`path` is a display value, not an address.** Folder names are returned exactly as stored and the `/` separator is **not escaped**, so a folder whose own name contains `/` is indistinguishable from a level boundary. Address folders with `ancestors[].id` or `parent_id`. ### Result ordering Results are ordered **tier first, then by `relevance_score` descending within a tier**. Two rows tie on the score readily — a file at position `r` of the name/text leg and another at position `r` of the content leg fuse to the same value — so the comparator has two further steps: a row whose `raw_score` is on the **keyword** scale (`score_source` `keyword` or `metadata`) sorts above one on the `semantic` scale, matching the rule that decides attribution on an equal position, and `node_id` ascending ends the comparison so the same result set always paginates the same way. The tiers, highest first: | Tier | What lands in it | |------|------------------| | 1 | Exact filename match | | 2 | Filename prefix match | | 3 | Metadata-entity match (`metadata_match: true`) | | 4 | Everything else | Rows in the fourth group are ordered by `relevance_score` descending. ⚠️ **A promoted hit can appear above a hit with a higher `relevance_score`.** This is the one thing most likely to surprise an integrator, and it is the ordering working as designed rather than a scoring bug: the tier is applied first, and `relevance_score` only breaks ties *inside* a tier. **Do not re-sort results by `relevance_score` client-side** — that throws the promotion away. To see why a row sits where it does, read `score_source` and `metadata_match`. **A promotion changes the ORDER, never the score.** All three promoting tiers work the same way: they place the row above the untiered results and leave `relevance_score` exactly as retrieval produced it. A promoted row is therefore identifiable by its position and by `score_source` / `metadata_match` — never by a special score value. The one field a promotion does change is `raw_score` on a **name** match (tiers 1 and 2): a name equality is not a measurement, so those rows report `score_source: "filename"` with `raw_score: null`. A metadata-promoted row (tier 3) keeps its `raw_score`, and that number is on the `keyword` scale **only where `score_source` is `metadata`** — that value is reported when the keyword leg ranked the file higher. A metadata-promoted row the content engine ranked higher reports `score_source: "semantic"` and carries the provider score. ### Knowing whether content search is possible A `content` search on an instance that cannot match content returns HTTP `200` with an **empty** result set, not an error. An empty list therefore has two very different meanings, and `search_metadata` is how you tell them apart: ```json "search_metadata": { "intelligence_enabled": false, "semantic_available": false, "scoped": false, "content_search_available": false, "reason": "intelligence_disabled" } ``` | Field | Type | Description | |-------|------|-------------| | `intelligence_enabled` | bool | Whether AI features are enabled on this workspace or share. | | `semantic_available` | bool | Whether the meaning-based channel actually served **this** request. This is the per-request outcome — read it, not `content_search_available`, to know what you just got. Under `search_in=filename`, which never asks that channel, it reports `true` whenever AI features are on. | | `scoped` | bool | Whether the meaning-based leg was actually narrowed by `files_scope` / `folders_scope`. It reports the narrowing that was **applied**, not the parameter you sent — sending a scope on a request where that leg does not run (AI features off, `search_in=filename`, or the leg failing) gives `scoped: false`, because nothing in those results was narrowed by it. | | `scope_incomplete` | `true` | Present, and always `true`, when the scope that was applied is **narrower than the one you asked for**: a `folders_scope` tree ran past the reference limit, so part of it was left out. Absent otherwise, and never present alongside `scoped: false`. A short answer would otherwise be indistinguishable from a complete one — you cannot count a folder's subtree before naming it. See *Scoping to files or folders*. | | `scope_requested` | int | How many scope entries you sent, counting `files_scope` and `folders_scope` together. Present only alongside `scoped: true` — see the note below. | | `scope_resolved` | int | How many of those entries the scope **resolved to**. Present only alongside `scoped: true`. A gap against `scope_requested` means part of what you named could not be resolved. ⚠️ It measures scope resolution, **not** final coverage: sending `filters` as well narrows the searched set further, and that further narrowing is not subtracted here. | | `content_search_available` | bool | Whether **either** content channel — the semantic index **or** summary access — is open here at all. A statement of *capability*, not a prediction of results. Present only when `search_in` was supplied. | | `reason` | string | Why content search is unavailable. Present only when `content_search_available` is `false`. Treat unrecognized values as opaque. | | `reason` | Meaning | What to do | |----------|---------|------------| | `intelligence_disabled` | AI features are off for this share, so meaning-based matching cannot run for anyone. | Retry with `search_in=filename`, or tell the user AI features must be enabled on the share. | | `summary_permission_denied` | AI features are on, but neither channel is open: the meaning-based channel did not serve this request, and this share's permissions do not let you search file summaries. | Retry with `search_in=filename`. This one is specific to you — another member of the same share may be permitted. | | `content_not_indexed` | Generic fallback for any other cause. Not currently emitted. | Retry with `search_in=filename`. | **Required client behavior:** when `content_search_available` is `false`, do **not** report "no files found." Either retry the same query with `search_in=filename` or tell the user that content search is unavailable here. This is the difference between recovering and confidently reporting a wrong answer. **What the flag does and does not tell you.** `content_search_available` answers *"can content search work here at all?"* — **not** *"will this query return results?"* It is `true` whenever either channel is open to you, so a `true` flag over an empty list is an ordinary "nothing matched," not a malfunction. For the per-request outcome, read `semantic_available`: it reports whether the meaning-based channel actually served **this** request. It is reachable as `false` only on **share** routes, and only on a share where *neither* channel is open. A workspace member may always search file summaries, so on `/workspace/{id}/storage/search/` the flag is always `true` and `reason` never appears — including when AI features are off for that workspace, because the summary channel is still open there. There is no workspace recovery path to code for. **When the block is emitted.** `search_metadata` is returned whenever the response is a hybrid one (AI features enabled), exactly as before. It is additionally returned on the keyword-only path when you explicitly supply `search_in`. The two capability keys (`content_search_available`, `reason`) appear **only** when you explicitly supply `search_in`. Supplying only `name_match` and/or `case_sensitive` does not change the response shape at all. **`semantic_available` is ALWAYS present; `content_search_available` is not — and they answer different questions.** `intelligence_enabled`, `semantic_available` and `scoped` are written unconditionally, so `semantic_available` is readable on every `/storage/search/` response that carries the block. On the unified search route the block is attached only when `search_in` is supplied. `content_search_available` is **derived and wider**: it is true when *either* the semantic channel **or** the AI-summary channel is open. ⚠️ **Summary access is always granted on a workspace** (it is permission-gated only on shares), so on a workspace `content_search_available` can never be `false` — do not use it to detect degradation there. The condition it cannot express is **`semantic_available: false` with `content_search_available: true`**: the meaning-based half degraded, keyword and summary still answered, and the response is a `200` carrying a partial result. **Read `semantic_available` for that.** And because `content_search_available` is omitted entirely when you did not send `search_in`, treat its ABSENCE as "not asked", never as `false`. ⚠️ **`search_metadata` sits in a DIFFERENT PLACE on the two search routes, and reading only one position is indistinguishable from "no metadata".** On this route (`/storage/search/`) it is **top level**. On the unified route (`/workspace/{workspace_id}/search/`) it rides **inside the files bucket**, at `buckets.files.search_metadata` — see *Unified search* below. Nothing in either response points at the other location, so a client that hard-codes one position reads `null` on the other route and will report a search as unscored, unscoped, or semantically unavailable when it was none of those. ⇒ **Read the top-level key first and fall back to `buckets.files.search_metadata`** (or branch on which route you called). Treat a missing block as "not reported", never as "the capability is off" — and note the two are not interchangeable in content either: the unified route carries no scope parameters, so its `scoped` is always `false` and it never emits `scope_incomplete` / `scope_requested` / `scope_resolved`. ### Filtering by metadata `filters` narrows a search to files whose **extracted metadata** satisfies a set of predicates, so one call answers *"find `payment terms` in open invoices over $1,000"* instead of two. It is available on the **workspace** route only. ⚠️ **On the share route `filters` is IGNORED, not refused.** The share search does not declare the parameter, so sending it there does not fail — the request returns `200` with the **unfiltered** result set and no `metadata_filter` block. Nothing in the response says the filter was discarded except that absent block, so a client that sends `filters` to a share and does not check for `metadata_filter` will present unfiltered results as filtered. `metadata_filter` is workspace-only for the same reason. If you need metadata narrowing on a share, do it client-side. The filter runs **first**, as a stage of its own, and produces the candidate set the search is then confined to. Both legs of the search obey it: filename/text matches outside the candidate set are dropped, and the meaning-based leg searches only the survivors. This is deliberately not a post-filter — you are searching *within* the filtered files, not filtering what a workspace-wide search happened to return. **A filter that matches nothing yields no results.** There is no fallback to an unfiltered search. If you asked for files satisfying a predicate and none do, the answer is an empty `files` map with `metadata_filter.matched: 0`. **A folder scope cannot be combined with `filters`.** Sending both `folders_scope` and `filters` is refused with an input error rather than quietly answered. The filter stage does not narrow by folder, so *"inside this folder, matching this filter"* is not something this endpoint can answer accurately today — and answering it by ignoring the folder would search the whole workspace and hand back far more than you asked for, which is worse than saying no. Filter the whole workspace instead, or name specific files with `files_scope`. Combining `filters` with `files_scope` **is** supported: the meaning-based leg searches the intersection of the two (the filename/text leg is narrowed by the filter only — `files_scope` never restricts it). **The predicate array.** `filters` is a JSON array of clause objects, each `{"field", "operator", "value"}` — the same shape the saved metadata filter `predicate` uses. Clauses are **AND**-combined. A request may carry at most **5** clauses. `field` names a field in the workspace's metadata vocabulary (canonical name or alias); the field's type is resolved server-side, so you never declare it. | Operator | Value | Meaning | |----------|-------|---------| | `=` `!=` `<` `<=` `>` `>=` | required | Compare the field against `value`. `!=` is the complement of `=`. Ordered comparison is not legal on boolean or JSON fields. | | `in` | required (non-empty list) | The field's value is one of the list. Counts as one clause. Not legal on JSON fields. | | `exists` / `not_exists` | omitted | The field is present / absent on the file. | | `confidence_gte` | required (int `0`–`3`) | The extracted value's confidence band is at least this level. The levels are `0` = `low`, `1` = `medium`, `2` = `high`, `3` = `certain` — so `2` means "high or better". Legal on every field type, because it tests how the value was obtained rather than the value itself. Two traps below. | **`confidence_gte` — read these before using it.** The operator takes the integer, while a fact's `confidence` comes back as the band *name*, so the mapping is `0` = `low`, `1` = `medium`, `2` = `high`, `3` = `certain`. Send the integer (a decimal string such as `"2"` is also accepted); a band name such as `"high"` is rejected, as is any non-integer, including `2.0`. - **`confidence_gte: 3` excludes every AI-extracted value.** `certain` is reserved for deterministic sources (`exif`, `mediainfo`, `validated_server`); AI-extracted facts are capped at `high` on write. So `3` means "deterministic sources only", **not** "the most confident AI results". - **`confidence_gte: 0` is not "no minimum".** A value entered by a person has no extraction confidence (`confidence` is `null`), and a `null` confidence satisfies no level — including `0`. So `0` matches extracted values only and **drops every hand-entered value**. To match a field regardless of how it was obtained, use `exists` instead. A `value` can be a bare JSON integer, and the server compares it exactly as sent. **Do not round-trip such a value through a JavaScript `Number`** — anything above `Number.MAX_SAFE_INTEGER` (`9007199254740991`) silently loses precision if you `JSON.parse` it into a `Number` and re-serialize; pass it through unmodified. **curl example:** ```bash curl -G "https://api.fast.io/current/workspace/1234567890123456789/storage/search/" \ -H "Authorization: Bearer {jwt_token}" \ --data-urlencode "search=payment terms" \ --data-urlencode 'filters=[{"field":"status","operator":"=","value":"open"},{"field":"invoice_total","operator":">=","value":1000}]' ``` **Response:** ```json { "result": true, "files": { "2ltsuq4mjacuv7pgc5ydlxnsjwee4": { "name": "Invoice-2026-0042.pdf", "parent_id": "2qk7dkri4yyievbq5hrieq4iohij5", "path": "Finance/Reports", "ancestors": [ { "id": "2e2f3-inlkh-p63sd-3ungd-vfyvc-gilh", "name": "Finance" }, { "id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "name": "Reports" } ], "path_complete": true, "type": "file", "relevance_score": 1.0, "raw_score": 0.62, "score_source": "semantic", "content_snippet": "Payment terms are net 30 from the invoice date …", "match_source": "semantic", "media_segment": null, "mimetype": "application/pdf", "page": { "start_page": 1, "end_page": 1 }, "text_indexed": true, "summary_short": "Invoice 2026-0042 for professional services, net 30.", "best_chunk": { "text": "Payment terms are net 30 from the invoice date …", "same_as_snippet": false, "page": { "start_page": 1, "end_page": 1 }, "media_segment": null, "score": 0.62, "result_type": "doc", "position": 4, "sequence": 1000, "indexed_version_id": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5", "chunk_hash": "9d40a6e1f8" }, "metadata_match": false, "metadata_match_field": null } }, "pagination": { "total": 1, "limit": 100, "offset": 0, "has_more": false, "total_relation": "eq", "queries_fused": 0 }, "search_metadata": { "intelligence_enabled": true, "semantic_available": true, "scoped": false }, "metadata_filter": { "applied": true, "matched": 34, "truncated": false, "scope_incomplete": false, "coverage": { "scope": "workspace", "available": true, "files_in_scope": 40, "files_without_metadata": 6 } } } ``` **Knowing whether `total` is exact.** Each leg of the search reads a **retrieval window** — the most relevant matches it will consider for one query, rather than every match in the workspace or share. When a query has more matches than a window holds, the window comes back full and the search never sees the rest, so `pagination.total` counts what it found and not what exists. `pagination.total_relation` says which of the two you are holding: | Value | Meaning | |-------|---------| | `eq` | `total` is exact. Every file matching this request is counted in it. | | `gte` | `total` is a **conservative lower bound**. At least one retrieval window filled up, so additional matching files may exist beyond what is counted — whether they do, and how many, is not knowable from this response. | The key is on **every** search response, in both hybrid and keyword-only mode, and whether or not you sent `search_in` or `filters`. A `gte` is a normal answer to a broad query, not an error. What to do with a `gte`: - **Do not present `total` as a match count** — show it as "1,000+" or "at least 1,000", never as "1,000 results". - **Do not size a progress bar, a page count, or a "page N of M" control from it.** - **Do not page to the end and conclude you have seen everything.** `has_more` is derived from `total`, so it inherits the same floor: a `false` beside a `gte` means "no more within what the search saw", not "no more exist". - **Narrow the request instead.** A more specific `search` term, a `files_scope` or `folders_scope`, or a metadata `filters` clause all shrink the candidate set until it fits, at which point the same request answers `eq`. `gte` also appears for reasons that have nothing to do with a full window. Not all of them can arise on every request — a filter-related one needs a `filters` clause, and shares have no metadata filter — but any one of them is enough, and the last changes what you should do next: - **`metadata_filter` reports `truncated: true`** — the filter matched more files than the search could carry into its legs, so those files were never searched at all. Narrowing the filter is the fix. - **`search_metadata` reports `scope_incomplete: true`** — the `files_scope` or `folders_scope` you named was larger than the search could expand, so part of what you named was never searched. Name fewer folders, or narrower ones. - **Part of the search was temporarily unavailable.** One of the two halves did not answer, or a transient fault shortened what it was given to search. Either way the response carries what the rest found, so the count is short for a reason that will not repeat. **Retrying is the fix here, and narrowing is not** — a narrower request does not bring the missing part back. - **An extra `queries` entry failed** — one cause of `gte` among the others on this list, and not one `total_relation` identifies on its own. When `queries` was sent, a failed extra leg means that leg's candidates are missing from the fused list, which can produce `gte` even if the primary `search` leg was not full — but `total_relation` says only that the total is a lower bound; it does not say *which* of these causes applies, since a saturated window or a truncated scope reads identically. **`pagination.queries_fused` being lower than the number of entries you sent does NOT by itself mean one failed either** — a duplicate of `search` (or of another `queries` entry, compared trimmed and case-insensitively) is dropped silently and never counted, and the count is `0` under keyword-only execution (`search_in=filename`) where no extra leg runs at all. Any of these sets `gte` even when neither retrieval window filled. **The `metadata_filter` block:** | Field | Type | Description | |-------|------|-------------| | `applied` | bool | Always `true` when the block is present. The block's **presence** is the signal — see below. | | `matched` | int | How many files satisfied the filter. This is the candidate count *before* the search narrowed it further and before any cap was applied to the meaning-based leg, so it is normally larger than the number of files returned. When `truncated` is `true`, treat it as a floor rather than an exact count. | | `truncated` | bool | `true` when the candidate set was clipped because it exceeded a cap. **Deterministic** — see below. | | `scope_incomplete` | bool | `true` when a transient fault dropped candidates that genuinely match. **Retryable** — see below. | | `coverage` | object | How much of the searched scope the filter could even see: `{scope, available, files_in_scope, files_without_metadata}`. Always present when `metadata_filter` is, and populated for a whole-workspace filter as well as an explicit `files_scope`. See *Coverage* below. | ⚠️ **`metadata_filter` is an acknowledgement that the filter RAN, and its absence is meaningful.** The block is emitted **only when a `filters` value actually reached the server and ran**. A request that sent no filter gets exactly the response it always got, with no such key. So, for a request you believe carried `filters`: - **`metadata_filter` present** → **the filter ran on the server**, and `matched` is trustworthy. - **`metadata_filter` absent** → **the filter did not reach the server.** The results in your hands are **unfiltered** and must not be treated as filtered. **What the block does not tell you.** `applied` certifies that the predicate executed — nothing more. It is *not* a statement that the result set is exactly what you asked for. `truncated` and `scope_incomplete` are the fields that qualify the result set, and you must read them separately. **`matched` describes the PREDICATE, not the PAYLOAD.** It counts the files the filter selected; it does not count the files this response contains. The two answer different questions and are routinely different numbers — `matched` is measured before the search narrows further and before any cap, so it is normally the larger of the two. Do not derive a result count from it, and do not treat `matched` being non-zero as a promise that `files` is non-empty. **Two things cause the key to be absent** on a request you believe carried `filters`: an intermediate client, proxy, or SDK that strips query parameters it does not recognise (at least one client library does), or a deployment that does not yet accept `filters`. You do not need to tell them apart — the correct action is the same either way: do not treat the results as filtered. Check for the key before trusting a filtered result. It is the only way to tell a filter that ran from one that never arrived: both come back `200` with a plausible-looking list of files. **`truncated` and `scope_incomplete` are different conditions — do not collapse them into "results may be incomplete."** One is worth retrying and the other never is: - **`truncated: true` — deterministic. Retrying changes nothing.** The candidate set was larger than a cap (the filter's own match ceiling, or the ceiling on how many files the meaning-based leg will accept) and was clipped. Re-running the identical request returns the identical answer. The fix is to **narrow**: add a clause, or make an existing one more selective. - **`scope_incomplete: true` — transient. Retrying is worth it.** A temporary fault dropped candidates that genuinely match your filter, so the answer is short for a reason that has nothing to do with your query. **Re-run the same request** — it may return more. Do not report the short list as complete. They are independent booleans and both can be `true` at once. `truncated` says *you asked for too much*; `scope_incomplete` says *we lost some of it*. **A filtered search only reaches files that have extracted metadata.** Predicates are evaluated against the metadata Fastio has already extracted for a file, so a file with no extracted metadata is not a candidate for **any** clause — including `not_exists`, which means "has metadata, but none for this field," not "has no metadata at all." Extraction is asynchronous, so a file added moments ago may not be filterable yet. To see what has been extracted for a file, read its `metadata_facts` on the node object. Read an empty filtered result as *nothing that has been extracted matches this filter*, not *no such files exist*. **Coverage — how many files the filter could not even see (`metadata_filter.coverage`).** Because a file with no extracted metadata is never a candidate, it is *absent* from a filtered result rather than *unmatched*, and `matched` cannot express that: a file the filter never got to evaluate is not a file it rejected. `coverage` puts a number on it. | Field | Type | Description | |-------|------|-------------| | `scope` | string | `files` when you narrowed the search with an explicit `files_scope`; `workspace` when the filter ran across the whole workspace. | | `available` | bool | Whether the two counts are real numbers. **This is the only field to branch on.** | | `files_in_scope` | int/null | How many files the search covered. `null` when `available` is `false`. | | `files_without_metadata` | int/null | How many of those hold no extracted metadata, and so could not match any clause. `null` when `available` is `false`. | - **`scope: "files"`, `available: true`** — you named the files, so both counts are exact. - **`scope: "workspace"`, `available: true`** — a whole-workspace filtered search reports coverage too. `files_in_scope` counts the **live files** in the workspace — files and notes, which are exactly the things metadata is extracted from. Folders, links and anything in the trash are not counted, because no clause could match them. `files_without_metadata` is that number minus the files that currently carry any metadata. The covered share — `files_in_scope` minus `files_without_metadata` — counts only files that are **both live and currently carrying metadata**. A file that has been deleted is never counted as covered, even when metadata was extracted from it before it was deleted. - **`scope: "workspace"`, `available: false`, both counts `null`** — the numbers could not be established for **this request**. Either the workspace is larger than the ceiling the two counts are read under (**5,000**), so the count stopped short of a total and a partial one would understate the uncovered share; or a count could not be read at all; or the two counts came back as a pair that cannot be true (see below); or a zero failed the final check described below. The search itself still answers normally — only this advisory block goes unfilled. The ceiling is there because establishing the covered count means checking every file that carries metadata against live storage, so the work grows with how many of them there are; the limit is set from what that costs. It bounds the counting only — it never limits the search itself. ⚠️ **That ceiling counts metadata RECORDS, not live files.** A file's metadata record outlives the file — deleting a file leaves it behind — so a workspace with a long history of uploads and deletions can be permanently past the ceiling while holding very few files today, and will report `available: false` on every filtered search. (Explicitly clearing a file's metadata does remove it from the count; deleting the file does not.) Nothing about the workspace's current size tells you whether coverage will be available for it. ⚠️ **`available: false` means the numbers are UNKNOWN, never that they are zero.** Rendering an absent count as `0` states that every file in scope has metadata, which nothing measured. Say nothing about coverage when `available` is `false`. ⚠️ **A pair of counts that cannot be true is reported as unknown, never as a zero.** The two whole-workspace counts are read separately, so a workspace changing underneath the request can produce a pair that is impossible — more covered files than there are files in scope. An impossible pair means one of the counts could not be trusted, so the block answers `available: false` with both counts `null` rather than publishing a coverage of `0`. A zero is the one value a caller would act on by trusting the result set, which is exactly what an untrustworthy count must not invite. ⚠️ **A `0` is VERIFIED against the actual files, not computed from two totals.** A `0` is the one answer a caller acts on — it says the predicate saw every file, so an empty result set is a real absence rather than a coverage gap — and a difference of two totals cannot support that claim: a covered file deleted and a different file appearing in the same moment leave both totals untouched while a real file goes uncovered. So before a `0` is published, the live files in scope are listed and every one of them is checked for a metadata record. The `0` means: **at the final read of this request, every live file in scope had metadata.** If any live file is missing one, or the list does not match the count being published, the block answers `available: false` instead. ⚠️ **`files_without_metadata` is not a fault count.** It counts files a predicate cannot evaluate at all, and that includes files with **nothing to extract** — an image carrying no text — as well as files nobody has run extraction over. A non-zero value is therefore not by itself a sign that anything is wrong; only a value of `0` proves the predicate saw every file in the scope. `coverage` is workspace-only for the same reason `metadata_filter` is: the share route accepts no `filters`, so no predicate runs there and neither block is emitted. **Combining `filters` with a scope.** `filters` and `files_scope` work together: the meaning-based leg runs over the **intersection** — the files you named that also satisfy the filter. The filename/text leg is not restricted by `files_scope` (see *Scoping to files or folders*), so it returns filter-matching files from outside the named set with `match_source: "keyword"`. `filters` and `folders_scope` **cannot currently be used together**, and sending both is refused with `1605 (Invalid Input)`. Either drop the folder scope and let the filter select across the whole workspace, or name the specific files you want with `files_scope`. This is a current limitation rather than a permanent rule, so handle the refusal as a condition rather than building on it as an invariant. Note that `search_metadata.scoped` reports only `files_scope` / `folders_scope`. A metadata filter is reported by `metadata_filter`, not by `scoped`, so `scoped: false` alongside a `metadata_filter` block is normal. **Scoping to files or folders (`files_scope` / `folders_scope`):** **Both narrow the meaning-based (semantic) leg only.** The filename/summary leg is deliberately not restricted by them, so a hybrid response can contain files from outside the scope carrying `match_source: "keyword"`. **For a hard boundary, filter on `match_source` yourself: keep the results marked `"semantic"` or `"both"`, and drop the ones marked `"keyword"`.** Both of the kept values mean the file came back from the meaning-based leg — the leg the scope narrowed — so they are the results that are genuinely inside the scope. ⚠️ **`search_in=content` is not a substitute for that filter.** `content` matches two channels, and only one of them is the meaning-based leg; the other is a keyword match against the file's AI-generated **summary**, which the scope does not restrict either. Wherever summary search is open to you, a scoped `search_in=content` request can therefore still return files from outside the scope, carrying `match_source: "keyword"`. Summary search is always open on the **workspace** routes, so `search_in=content` never bounds the result set there; on a **share** it depends on that share's permissions for you specifically. Filtering on `match_source` is the only approach that holds on every route and for every caller. Because the scope applies to that leg alone, it changes nothing at all when that leg does not run — AI features off on the workspace or share, `search_in=filename`, or the leg failing on this request. `search_metadata.scoped` reports that honestly: it is `true` only when the narrowing was really applied, so `scoped: false` on a request that carried a scope means the scope had no effect on what came back. **A scope that resolves to no files returns no meaning-based results** — never the whole workspace or share. A reference you name can drop out during resolution (the file was trashed, or a transient fault made it unreadable), and when every one of them does, the answer is an empty meaning-based result set rather than an unscoped search. An empty scope is still a scope: the empty one. **`scope_requested` / `scope_resolved` tell you how much of the scope you named could be resolved.** They count **entries** — the comma-separated items you wrote across `files_scope` and `folders_scope` — not the folders a tree expands into. A gap between them means part of what you named could not be resolved, which an empty result set alone can never show you: *"the folders resolved and matched nothing"* and *"the folders no longer resolve"* come back byte-identical without this pair. 🔴 **They measure SCOPE RESOLUTION, not the final searched set.** The pair is counted when the scope is parsed and resolved. If you also send `filters`, the metadata predicate intersects with your named scope **afterwards** and can shrink the searched set further — that later narrowing is **not** subtracted from `scope_resolved`. So on a combined `files_scope` + `filters` request, `scope_requested: 2, scope_resolved: 2` is entirely consistent with only one file actually being searched. Read the pair as *"how much of what I named survived resolution"*, never as *"how many files were searched"*; for the filter's own outcome read the `metadata_filter` block instead. ⚠️ **The pair reports counts, never a cause.** A gap can hold entries that failed to resolve, entries left out once the scope hit its reference limit, and entries after that limit that were never looked up at all. `scope_incomplete` tells you the limit was reached; it does not divide the gap between those three. ⚠️ **A missing entry does not mean a deleted file.** An entry that does not resolve covers both a node that is genuinely gone and one that merely could not be read on this request — a transient fault, for instance. Do not render this pair to a user as *"those folders no longer exist"*; *"part of what you selected could not be searched"* is what it actually supports, and a retry is worth trying before you tell anyone their files are missing. Both keys are present **only alongside `scoped: true`**. Where no narrowing was applied there is nothing for them to describe — and because the scope narrows the meaning-based leg alone, a `search_in=filename` request that still carried a scope would otherwise report everything you named as unresolved when none of it was ever looked up. Whenever `scoped` is `true` **both keys are always present**, including as `0` and `0`, so you never have to tell "this response does not report counts" apart from "nothing resolved". **Entries are validated, not silently dropped.** A value that is not a `nodeId:versionId` / `nodeId:depth` pair at all — including a bare `0` — a pair whose `versionId` is not a version of the file it is paired with, an id that is not a valid node or version id, or a node of a type the parameter does not take — a folder in `files_scope`, a file or note in `folders_scope`, or a link in either — is refused with `1605 (Invalid Input)` / `406`, and the message names the entry that was wrong. So is a `:depth` that is not an integer from `1` to `10`. Validation happens on every request: a bad entry is refused even where the meaning-based leg does not run (AI features off, or `search_in=filename`) and the scope would have narrowed nothing. This matters because ids render both hyphenated and unhyphenated for the same value, so a pair assembled from two different responses is easy to mismatch by accident while each half stays individually well-formed. To send no scope, **omit the parameter** rather than sending a placeholder value. **`files_scope` takes files AND notes; `folders_scope` takes folders; links cannot be scoped.** Search results carry a `type` of `file`, `folder`, `link` or `note`. Notes are indexed the same way files are and are returned by meaning-based search, so `files_scope` accepts a note's `nodeId:versionId` pair exactly as it accepts a file's — if a note came back as a hit, you can scope your next query to it. A **link** has no stored content to index and is accepted by neither parameter. A node of the wrong type for the parameter it was named in is refused with `1605 (Invalid Input)` / `406`, and the message names the type the node actually is and, where the other parameter would take it, which one to use instead. **Both parameters are read from the query string only.** These are `GET` endpoints and the scope is a query parameter; a scope sent in a request body is not read, and the search runs unscoped. Put `files_scope` / `folders_scope` in the URL. **Folder aliases are not accepted.** `folders_scope` takes a folder's own node id. The `root` and `trash` aliases are refused with `1605 (Invalid Input)` / `406`, and the message names the alias you sent. To search everything, omit `files_scope` and `folders_scope` rather than scoping to the root. **A scope carries at most 100 references in total**, counting every file you name plus every folder you name plus every subfolder reached by expanding a `folders_scope` entry to its `:depth`. Naming more than 100 **files** is refused. A **folder** tree that runs past the limit is not refused — you cannot count a subtree before naming it — so it is **truncated instead, and the truncation is reported**: the response carries `search_metadata.scope_incomplete: true`, meaning the search covered less than you asked for. Narrow the `:depth`, or name fewer folders, and retry. **Verbosity (`output`):** `content_snippet`, `best_chunk.text`, `best_chunk.same_as_snippet` and `summary_short` are the fields affected by `output=`. All other fields are returned unchanged at every level. | `output` | `content_snippet` and `best_chunk.text` shape | `summary_short` | |----------|--------------------------|-----------------| | `terse` | First ~200 bytes of the matching chunk, UTF-8 safe. Truncated values end with `…`. | Always `null` — the tier drops it, the same way it trims a node's summary. | | `standard` | First ~600 bytes (roughly one paragraph). Same `…` suffix when truncated. | Returned in full. | | `full` (default) | Full matching chunk, untrimmed. | Returned in full. | Snippets shorter than the budget are returned unchanged (no padding, no `…`). Null/empty snippets are returned unchanged at every level. The byte budget is inclusive of the trailing `…` so the wire payload never exceeds the cap. `best_chunk.text` follows the same budget and the same `…` rule as `content_snippet`; at `terse` and `standard`, if the trimmed `best_chunk.text` then comes out byte-identical to the trimmed `content_snippet`, `text` is replaced with `null` and `best_chunk.same_as_snippet` is `true` (otherwise `text` is present and `same_as_snippet` is `false`) — at `full`, `text` is never suppressed and `same_as_snippet` is always `false`. The rest of `best_chunk` (`page`, `media_segment`, `score`, `result_type`, `position`, `sequence`, `indexed_version_id`, `chunk_hash`) is returned unchanged at every level. Default `output=full` preserves the prior `best_chunk.text` value and never deduplicates it; the `same_as_snippet` key is the one additive change at that level. **A snippet is an excerpt, not the passage.** When you need the surrounding text in full -- to quote a clause, or to read what the match sits in -- do not widen `output` and hope: call the file's *Node Content* endpoint with `?q=` (the same query terms) and it returns that file's best-matching chunks with their complete text, page ranges, and no snippet budget at all. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | `search_in` or `name_match` is not one of the listed values; `case_sensitive` is not `true`/`false`/`1`/`0`; or the pattern is empty / longer than 256 characters / a `glob` of nothing but `*` and `?` | | `1605 (Invalid Input)` | 406 | `queries` is not a JSON array (an object included), has more than 3 entries, or has an entry that is not a non-empty string | | `1605 (Invalid Input)` | 406 | `filters` is not a JSON array of clause objects, or a clause is missing a `field` / `operator` (workspace only) | | `1605 (Invalid Input)` | 406 | The filter could not be applied: more than 5 clauses, a field the workspace vocabulary does not have, or an operator/value that does not suit the field's type (workspace only) | | `1693 (Temporarily Unavailable)` | 503 | The metadata filter could not be evaluated right now — retry shortly (workspace only) | | `1605 (Invalid Input)` | 406 | `filters` was combined with `folders_scope` — not currently supported together (workspace only) | | `1605 (Invalid Input)` | 406 | A `files_scope` / `folders_scope` entry is wrong: not a `nodeId:versionId` / `nodeId:depth` pair at all (a bare `0` included), a `:depth` that is not an integer from `1` to `10`, an id that is not a valid node or version id, a `versionId` that is not a version of that file, or a node of the wrong type — a folder in `files_scope`, a file or note in `folders_scope`, or a link in either; or more than 100 files named. The message names the offending entry and the type the node actually is. Checked on every request, even when the meaning-based leg does not run | | `1605 (Invalid Input)` | 406 | `folders_scope` named the `root` or `trash` folder alias. It takes folder node ids only — send the folder's own node id, or omit the scope entirely to search everything | | `1609 (Not Found)` | 404 | Search not available for workspace folder shares, or the shared folder no longer exists (share only) | | `1680 (Access Denied)` | 401 | No search permission, or no permission to view files in the share (share only) | **Notes:** - Share search is not available for workspace-backed shares (shared folders). - Search results are filtered by the user's file view permissions. - Omitting `search_in`, `name_match`, and `case_sensitive` reproduces the exact query, ranking, and response keys this endpoint returned before they existed. - `search_in=filename` skips the content lookup entirely rather than running it and discarding the result, so it is also the fastest mode. - The pattern rules are checked whenever a precise `name_match` is *asked for*, even in combination with `search_in=content` where the filename is not matched at all — a request that would be silently ignored is rejected instead. - A single search examines at most **1,000** matching files. A deliberately broad pattern (`*`, `*a*`) can reach that ceiling and return a truncated view, so prefer the narrowest pattern that answers the question. - The precise `name_match` values (`exact`, `prefix`, `contains`, `glob`) depend on filename indexing that is rolled out per environment; `auto` works everywhere. If a precise match returns nothing where you expect a hit, retry with `name_match=auto` before concluding the file is absent. - Omitting `filters` reproduces the exact response this endpoint returned before it existed — no `metadata_filter` key is added. - Sending `filters` as an empty array (`[]`) is treated as no filter at all: the search runs unfiltered and no `metadata_filter` block is returned. - `filters` is accepted on the **workspace** route only, and the share route **ignores it rather than refusing it** — the request succeeds and returns the **unfiltered** results, with no `metadata_filter` block. A `406` would tell you the parameter did not apply; a `200` does not, so the absent block is the only signal and you have to look for it. Same for `metadata_filter`: workspace-only. - Always read `metadata_filter` before presenting a filtered result. A missing block means the results are unfiltered, and a `truncated` or `scope_incomplete` flag means they are partial for two very different reasons. --- ## Metadata Search (Workspace Only) ``` GET /current/workspace/{workspace_id}/metadata/search/ ``` Keyword search across the metadata stored on workspace files. Returns matching nodes ordered by relevance. Trashed nodes are filtered out automatically, as are files whose metadata has all been deleted. The endpoint is workspace-scoped — results never cross workspace boundaries regardless of caller input. **This now searches the current metadata corpus.** It previously searched only the older key-value store, which stopped receiving new values when automatic extraction moved to the current model — so recently extracted metadata was not findable here even though it was visible on the file itself. If you have searched for a value you could see on a file and got nothing back, that is the gap this closes; no request change is needed. **Every result now tells you which metadata field matched.** A metadata hit used to carry only the node, its name and a score — exactly what a filename hit carries — so there was no way to show a user *why* a file came back, and a correct match was indistinguishable from a match on the filename. Each result now includes `matched_fields`: the field name(s) whose value matched, each with the matching value. This is additive; every field that was there before is unchanged. **Auth required.** Permission: View (workspace member). **Query parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `q` | string | Yes | - | Keyword query (max 1024 characters). Whitespace-trimmed; an empty value is rejected. | | `template_id` | string | No | - | **Retired — supplying it is now an error (406).** It used to restrict matches to nodes carrying a value contributed by that template; the searched corpus no longer records a template association, so the filter cannot be honoured. It is REFUSED rather than ignored, because silently accepting a narrowing filter and then returning every match is a widening a caller cannot detect. Remove the parameter. | | `limit` | int | No | 100 | Maximum number of results (1-100). | | `offset` | int | No | 0 | Number of results to skip. Requests beyond the supported result window return an input error. | **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/metadata/search/?q=invoice&limit=25" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "results": [ { "node_id": "2ltsuq4mjacuv7pgc5ydlxnsjwee4", "score": 4.215, "template_ids": [], "matched_fields": [ { "field": "document_type", "value": "Invoice", "value_truncated": false }, { "field": "vendor", "value": "Invoice Systems Ltd", "value_truncated": false } ], "matched_fields_truncated": false, "node": { "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "type": "file", "name": "Invoice-2026-0042.pdf", "parent": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "size": 524288, "mimetype": "application/pdf", "mimecategory": "document", "version": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5", "created": "2026-04-20 14:12:08 UTC", "modified": "2026-04-22 09:30:55 UTC", "restricted": false, "dmca": false, "locked": false } } ], "pagination": { "total": 1, "limit": 25, "offset": 0, "has_more": false } } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `results` | array | Matching nodes, ordered by relevance score (highest first) | | `results[].node_id` | string | OpaqueId of the matching node, in unhyphenated form (`node.id` carries the same id hyphenated) | | `results[].score` | number | Relevance score for the match | | `results[].template_ids` | array of string | **Always empty.** Templates are retired and the searched corpus records no template association. The key is kept so existing clients keep parsing, but it will not be populated again. | | `results[].matched_fields` | array of object | The metadata field(s) whose value matched this query, in the order they are stored on the file. May be empty — see *Reading `matched_fields`* below. Omitted entirely — not empty — for callers below Member permission; see *Access* below. | | `results[].matched_fields[].field` | string | The field name, exactly as it is spelled on the file. Case and accents are preserved verbatim; do not fold or normalise it before matching it against your own field list. | | `results[].matched_fields[].value` | string | The matching value, as text. Long values are shortened — see `value_truncated`. | | `results[].matched_fields[].value_truncated` | bool | `true` when the stored value is longer than the returned text. The returned text is a window taken around the part that matched, so it always contains the match — it is not simply the beginning of the value. | | `results[].matched_fields_truncated` | bool | `true` when the `matched_fields` list is known to be **incomplete** — more fields matched than are listed, or part of this file's metadata was too large to be searched. Present it as "matched X and more", not as the full list. | | `results[].node` | object | Standard node resource (same shape as storage list/details) | | `pagination.total` | int | Total number of index matches. It can include files that are then left out of `results` (trashed, or no longer visible), so a page can hold fewer than `limit` entries | | `pagination.has_more` | bool | `true` when more results exist past the current window | **Access.** `matched_fields` and `matched_fields_truncated` are returned only to callers holding **Member** permission on the workspace — the same level every dedicated metadata read requires. The route itself is unchanged and still opens at View: a caller below Member still searches metadata and still receives every matching file, its score, its ranking and its full node payload. The two keys are simply **not present** on each result. Read them with a **presence check on the key**, not with a length check. An **absent** `matched_fields` means the caller is not cleared to see metadata values. An **empty** `matched_fields` means something different and unrelated: the match could not be attributed to a specific field on that file. Do not treat the two as the same state, and never suppress a hit for either one. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | `q` missing/blank, `template_id` supplied at all (the filter is retired — remove it), or a request beyond the supported result window | | `1680 (Access Denied)` | 401 | Caller is not a member of the workspace | | `1654 (Internal Error)` | 500 | Search backend transient failure | **Notes:** - Returns at most one entry per node, even when multiple metadata fields on that node matched — the fields that matched are listed in `matched_fields` on that single entry. - Matching is **substring and case-insensitive** — `inv` already matches `invoice`, so no wildcard is needed. (Substring matching applies to queries up to 64 characters; a longer query matches whole words only.) `*` and `?` in your query are treated **literally**, not as wildcards: `inv*` searches for a literal asterisk and will NOT match `invoice`. A multi-word query requires ALL of its words to be present, not any. - Indexing is event-driven — newly written or updated metadata typically becomes searchable within a few seconds. - Every value type is matched as text: strings, numbers, booleans and dates, plus the leaf values of JSON values (not their keys). - Each indexed document is bounded; metadata values exceeding the per-node ceiling are truncated for indexing only — source data is unaffected. - This endpoint is independent of `/storage/search/` — that endpoint searches filenames and file content, this one searches metadata field values. To search content *within* files selected by their metadata, use `/storage/search/` with its `filters` parameter instead (see *Filtering by metadata* under *Search*). - **Reading `matched_fields`.** Values are truncated to at most 256 characters, and at most 10 fields are listed per result; both limits are reported rather than applied silently (`value_truncated` and `matched_fields_truncated`). An **empty** `matched_fields` is a valid result, not an error — it means attribution was not available for this file, so the match could not be tied back to a specific field. The result itself is still correct: the file genuinely matched. Render it normally when the list is empty; never suppress the hit. An **absent** `matched_fields` is a different state entirely — the caller is below Member permission; see *Access* above. - Field names come from the metadata on the file and are **caller data**. Compare them byte-for-byte against your own vocabulary; a lowercased or accent-stripped comparison will miss fields that genuinely matched. --- ## Compound Search (Metadata Filter + Content Query) ``` POST /current/workspace/{workspace_id}/metadata/compound-search/ ``` **Two inputs, never one blended string.** A structured metadata filter selects the candidate files, then a semantic content query ranks the ones whose *content* answers the question. Use it for "the contracts signed last quarter that mention early termination": the quarter is a metadata predicate, the clause is a content question, and neither half alone answers it. Workspace only — there is no share form. **Auth required.** Permission: **Member** on the workspace. The organization's plan must include **both** the `metadata` and `content_ai` features, and the workspace must have **Deep Indexing enabled**: the second stage searches the content index the Deep Indexing pipeline builds, so a workspace with Deep Indexing off is refused up front rather than handed a misleadingly empty result. There is no keyword leg to fall back on here — where `/storage/search/` degrades, this endpoint refuses. **An Enterprise org's AI policy can also refuse this call outright**, independently of the plan check above: `403 ai_policy_denied` / `ai_policy_workspace_not_allowed` when the policy denies the caller `metadata` for this workspace (`params.feature:"metadata"`), and the same pair of reasons again with `params.feature:"intelligence"` when the policy denies the semantic-retrieval stage instead — either one refuses the whole call. See *AI, Deep Indexing & MCP Access Policy* in `llms/orgs.txt`. **Parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `filters` | string (JSON) | Yes | - | JSON-encoded predicate array — `[{"field": "...", "operator": "...", "value": "..."}]`. Must be a non-empty list of objects, each carrying a string `field` and `operator`. Same predicate vocabulary as saved metadata filters; see *Filtering by metadata* under *Search*. | | `content_query` | string | Yes | - | The content question. Max 1024 characters; blank is rejected. | | `limit` | int | No | 10 | Result-count cap, minimum 1. A value above the maximum (100) is **clamped, not rejected**, and the clamp is reported as `scope.limit_clamped_from`. | 🔴 **`filters` is a FORM FIELD whose value is a JSON string — not a JSON request body.** This is the most common way to call this endpoint wrong. Send the request form-encoded (`application/x-www-form-urlencoded` or `multipart/form-data`). A request sent as `Content-Type: application/json` **does not populate `filters` at all**, and is refused with a `406` exactly as though you had sent no filters, which reads as "my filter is invalid" when the real problem is how the body was encoded. **The three parameters are not symmetric about this.** `content_query` and `limit` are read from either the POST body or the query string, so they survive being sent either way. `filters` is read from the POST body **only** — there is no query-string form of it, and it is the one that goes missing. **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/metadata/compound-search/" \ -H "Authorization: Bearer {jwt_token}" \ --data-urlencode 'filters=[{"field":"document_type","operator":"=","value":"contract"}]' \ --data-urlencode "content_query=early termination clause" \ --data-urlencode "limit=25" ``` **Response (200 OK):** ```json { "result": true, "items": [], "scope": { "match_count": 0, "match_relation": "eq", "scope_used": 100, "scope_truncated": false, "files_not_indexed": 3, "limit_clamped_from": null, "causes": [] } } ``` `items` holds standard node resources — the same shape as storage list/details, ranked by content relevance, honouring the request's `output=` tier. **Read the `scope` object; it is the point of this endpoint.** A short `items` list can mean "twelve files matched" or "far more matched and the answer was cut short", and `scope` is what tells the two apart. | Field | Type | Description | |-------|------|-------------| | `scope.match_count` | int | Matching files — those satisfying the filter **and** the content query. Always equals the number of entries in `items`. | | `scope.match_relation` | string | `eq` when `match_count` is exact, `gte` when it is a **floor** because a cap bounded the answer. | | `scope.scope_used` | int | How many candidate files the content stage actually searched. | | `scope.scope_truncated` | bool | `true` when more indexed candidates existed than the content stage could search. | | `scope.files_not_indexed` | int/null | Candidates the filter matched that had no content index entry and were therefore invisible to the content stage. `null` means the coverage read itself was unavailable — unknown, not zero. | | `scope.limit_clamped_from` | int/null | Your original `limit` when it exceeded the server maximum, else `null`. | | `scope.causes` | array of string | **Every** bound that fired, with no precedence between them. `[]` when nothing bounded the answer. | **`causes` values and what to do about each:** | Cause | Meaning | Remedy | |-------|---------|--------| | `filter_cap` | The filter matched more files than the metadata stage will carry forward | **Narrow the filter** | | `scope_cap` | More indexed candidates existed than the content stage's budget allows | **Narrow the filter** — the same remedy, not an upgrade | | `result_budget` | The content stage filled the requested `limit`, so more may match beyond it | Raise `limit`, or make the query more specific | | `coverage_unavailable` | The index-coverage read degraded, so `files_not_indexed` is unknown | Retry if completeness matters | Treat `causes` as an open set: handle the values you know and fall back for the rest. Only the count-bounding causes (`filter_cap`, `scope_cap`, `result_budget`) make `match_relation` a floor, so `causes` can be non-empty while `match_relation` is still `eq` — a degraded coverage read does not clip the candidate set. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `115280` | 406 | Deep Indexing is not enabled on this workspace | | varies per call site — read `error.code` from the response | 406 | `filters` or `content_query` missing or empty, `filters` is `[]` or not valid JSON, `content_query` over 1024 characters, or `limit` not an integer of at least 1 | | `119701` | 406 | `filters` is valid JSON but not a list (an object or a scalar) | | `116139` | 406 | An element of `filters` is not an object | | `155870` | 406 | An element of `filters` lacks a non-empty `field` or `operator` | | `100859` | 406 | `content_query` is only whitespace | | `179646` | 406 | The predicates are not valid for the fields they name — a wrong operator for the field's type, an unusable value, or too many clauses | | varies per call site — read `error.code` from the response | 401 | Caller is below Member on the workspace | | `274701` | 402 | The organization's plan does not include both `metadata` and `content_ai` | | *(generated per call site)* | 403 | `params.reason` = `ai_policy_denied` or `ai_policy_workspace_not_allowed`, `params.feature` = `metadata` or `intelligence` — the org's AI policy refuses the caller. See *AI, Deep Indexing & MCP Access Policy* in `llms/orgs.txt`. | | *(generated per call site)* | 503 | `access_policy_unavailable` — the AI policy verdict could not be read; retry. | | `109283` | 503 | A transient storage fault left the candidate set incomplete, so the answer cannot be trusted — retry | | `135817` | 503 | A transient fault in either stage — retry | | `193826` | 500 | The workspace's storage instance could not be resolved | | `134988` | 500 | A permanent, non-validation backend fault | **Notes:** - A `503` here is specifically **not** an empty result. The endpoint refuses rather than returning a partial answer that looks complete. - This is distinct from `/metadata/search/`, which is a keyword search over metadata values only and never reads file content. - To filter by metadata inside a plain content search instead, use `/storage/search/` with its `filters` parameter — see *Filtering by metadata* under *Search*. --- ## Unified Search (Grouped by Type) ``` GET /current/workspace/{workspace_id}/search/ GET /current/share/{share_id}/search/ ``` One search call across everything in a workspace or share, with results **grouped by type** into buckets. Instead of calling the per-type search endpoints separately, you issue a single query and get back a set of buckets — each with its own results and its own pagination. A workspace search returns up to three buckets: `files`, `metadata`, and `comments`. A share search returns the subset that applies to shares (typically `files`, plus `comments` when commenting is enabled on the share; `metadata` is workspace-only). Each bucket is independently paginated and independently health-reported, so a transient problem affecting one bucket never blocks the others — that bucket comes back `degraded` with an empty result set while the rest return normally. **Auth required.** Permission: View (workspace), search + file view permissions (share). All buckets are **permission-filtered** — see *Permission model* below. **Query parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `search` | string | Yes | - | Search query string (max 1024 characters; an empty value is rejected). | | `files_offset` | int | No | 0 | Result offset for the `files` bucket. | | `files_limit` | int | No | 25 | Page size for the `files` bucket. | | `metadata_offset` | int | No | 0 | Result offset for the `metadata` bucket (workspace only). | | `metadata_limit` | int | No | 25 | Page size for the `metadata` bucket (workspace only). | | `comments_offset` | int | No | 0 | Result offset for the `comments` bucket. | | `comments_limit` | int | No | 25 | Page size for the `comments` bucket. | | `search_in` | string | No | `both` | **`files` bucket only.** Which side of the file to match: `filename`, `content`, or `both`. | | `name_match` | string | No | `auto` | **`files` bucket only.** How the filename is matched: `auto`, `exact`, `prefix`, `contains`, or `glob`. Ignored when `search_in=content`. | | `case_sensitive` | string | No | `false` | **`files` bucket only.** `true` / `false` / `1` / `0`. Applies to the precise `name_match` values; ignored under `auto`. | | `details` | string | No | - | `"true"` enables `metadata_facts` on `file`/`note` items in the `files` bucket, shaped by `output` exactly as documented under *Extracted Metadata Facts*; any other value is treated as absent (no error). Folders and links never carry it. | | `output` | string | No | `full` | Verbosity: `terse`, `standard`, or `full` (default). Trims `content_snippet` on `files`-bucket items to a byte budget, and shapes `metadata_facts` when `details=true`. See *Verbosity* below. | **Search modes apply to the `files` bucket only.** `search_in`, `name_match`, and `case_sensitive` behave exactly as documented under *Search* above — same values, same defaults, same escaping and case rules, same pattern limits, same `1605 (Invalid Input)` on a bad value. They shape only the `files` bucket; the `metadata` and `comments` buckets are unaffected and keep matching as they always have. Omit all three and the response is byte-for-byte what it was before they existed. `search_in=filename` is the most useful of the three here: it turns the `files` bucket into a pure filename lookup while still returning `metadata` and `comments` matches from the same call. **`details=true` adds extracted metadata facts to the `files` bucket.** Each `file` or `note` item whose facts were read then carries `metadata_facts` -- the same block `/storage/search/` and the node object emit, in the same three `output` shapes (`terse`, `standard`, `full`) with the same per-tier caps and the same `count`/`total`/`is_truncated` semantics -- see *Extracted Metadata Facts* under *Node Object Schema* above. Folder and link items never carry the key, matching every other surface. Visibility follows the same rule as everywhere else `metadata_facts` appears: the key is **omitted entirely** (never emitted empty) when the caller is not entitled to extracted metadata. Because extracted metadata is a workspace-only surface, that omission is unconditional on the share twin -- `/share/{share_id}/search/` never returns `metadata_facts` regardless of `details`, for any share role. On the workspace route it is present for callers who are members of the owning workspace. With `details` absent or any string other than `"true"`, the response is unchanged from before this parameter existed -- no new key; a non-string value is rejected as invalid input, like any other declared parameter. Cost is **at most one batched facts read per `files`-bucket page**, not one read per item, so `details=true` is cheap here even at `files_limit=25`. **Verbosity (`output`):** `content_snippet` on `files`-bucket items is trimmed to a byte budget by the `output` level -- the same budget `/storage/search/` applies, so every search surface sizes the field the same way. | `output` | `content_snippet` shape | |----------|-------------------------| | `terse` | First ~200 bytes of the matching chunk, UTF-8 safe. Truncated values end with `…`. | | `standard` | First ~600 bytes (roughly one paragraph). Same `…` suffix when truncated. | | `full` (default) | Full matching chunk, untrimmed. | The budget is inclusive of the trailing `…`, so the wire payload never exceeds the cap, and the `…` is appended only when the value was actually cut. Snippets shorter than the budget are returned unchanged (no padding, no `…`), and a `null` snippet -- what a keyword-only hit carries -- is returned unchanged at every level. Only the `files` bucket is affected: the `metadata` and `comments` buckets are identical at every level, and `content_snippet` is the only field trimmed here, because this route returns no `best_chunk` and no `summary_short`. **`output` sizes the snippet whether or not you send `details=true`** -- the two parameters are independent, and `details` governs only whether `metadata_facts` is added. As on `/storage/search/`, when you need the surrounding text in full, do not widen `output`: call the file's *Node Content* endpoint with `?q=` and read the whole chunk with no snippet budget at all. Pagination is **per bucket**: `files_offset`/`files_limit` page the files bucket, `comments_offset`/`comments_limit` page the comments bucket, and so on. Each pair is optional; an omitted offset defaults to `0` and an omitted limit to `25`. A limit is clamped to the range 1-100 rather than rejected. A request beyond the supported result window for any bucket returns a `406` input error. Every applicable bucket is always searched (the share endpoint omits `metadata`). **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/search/?search=quarterly+report&files_limit=10&comments_limit=5" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "buckets": { "files": { "items": [ { "node_id": "2ltsuq4mjacuv7pgc5ydlxnsjwee4", "name": "Q4 Report.pdf", "parent_id": "2qk7dkri4yyievbq5hrieq4iohij5", "type": "file", "relevance_score": 1.0, "content_snippet": "Quarterly revenue grew 18% year-over-year …", "match_source": "both", "media_segment": null, "page": { "start_page": 3, "end_page": 3 }, "mimetype": "application/pdf", "updated": "2026-04-22 09:30:55 UTC" } ], "offset": 0, "limit": 10, "total": 1, "total_relation": "eq", "has_more": false, "status": "ok" }, "metadata": { "items": [ { "node_id": "23kghfgzmg72knwu676uvibbhjizb", "name": "Invoice-2026-0042.pdf", "parent_id": "2qk7dkri4yyievbq5hrieq4iohij5", "type": "file", "relevance_score": 4.215, "template_ids": [], "matched_fields": [ { "field": "document_type", "value": "Invoice", "value_truncated": false } ], "matched_fields_truncated": false, "updated": "2026-04-22 09:30:55 UTC" } ], "offset": 0, "limit": 25, "total": 1, "total_relation": "eq", "has_more": false, "status": "ok" }, "comments": { "items": [ { "comment_id": "caaq5twdpgaxqntbbtt4v2xitunmv2", "entity": "2ltsuq4mjacuv7pgc5ydlxnsjwee4", "author_profile_id": "1234567890123456789", "scope_id": null, "snippet": "Can we double-check the Q4 totals …", "reference_type": null, "relevance_score": 2.118, "created": "2026-04-21 08:02:11 UTC", "updated": "2026-04-21 08:02:11 UTC" } ], "offset": 0, "limit": 5, "total": 1, "total_relation": "eq", "has_more": false, "status": "ok" } } } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `buckets` | object | Map of bucket type → bucket object. Only applicable buckets are present (e.g. no `metadata` on a share). | | `buckets.{type}.items` | array | Result items for this bucket. The `metadata` and `comments` buckets are ordered by `relevance_score` descending. The **`files`** bucket is ordered **promotion tier first** — an exact filename match, then a filename prefix match, then everything else — and only then by `relevance_score` descending, then a `keyword`-scale row above a `semantic`-scale one, then `node_id` ending the comparison; it shares its ranking with `/storage/search/`, so **a name match can sit above an item with a higher `relevance_score` here too**. (The metadata-entity tier is `/storage/search/`-only and never applies on this route.) Item shape is type-specific — see *Bucket item shapes* below. | | `buckets.{type}.offset` | int | The offset applied to this bucket. | | `buckets.{type}.limit` | int | The page size applied to this bucket. | | `buckets.{type}.total` | int | Number of matching, permission-visible items. This count is computed **after** permission filtering — it reflects what you can actually see, not raw index hits. | | `buckets.{type}.total_relation` | string | `eq` when `total` is exact within the searched window, or `gte` when it is a lower bound (more visible matches may exist beyond the searched window). | | `buckets.{type}.has_more` | bool | `true` when more results exist past the current page. | | `buckets.{type}.status` | string | `ok` for a healthy bucket, or `degraded` when the backend behind that bucket was temporarily unavailable (the bucket returns an empty `items` array but is still present so you can tell a backend hiccup from a bucket that does not apply). | | `buckets.files.search_metadata` | object | Capability report for the `files` bucket. Present **only** when `search_in` was explicitly supplied. See below. | **`buckets.files.search_metadata`:** The unified response has no top-level metadata slot, so the capability report rides on the bucket it describes. ⚠️ **This is the reciprocal of the `/storage/search/` placement** — there the same block is **top level**. A client that reads only one position gets `null` on the other route, which is indistinguishable from "no metadata": **read the top-level key first, then fall back to `buckets.files.search_metadata`**, or branch on which route you called. ```json "buckets": { "files": { "items": [], "offset": 0, "limit": 25, "total": 0, "total_relation": "eq", "has_more": false, "status": "ok", "search_metadata": { "intelligence_enabled": false, "semantic_available": false, "scoped": false, "content_search_available": false, "reason": "intelligence_disabled" } } } ``` The fields and the `reason` values are identical to the `/storage/search/` block documented under *Knowing whether content search is possible* above, and the same client rule applies: on `content_search_available: false`, do not report "no files found" — retry with `search_in=filename` or say that content search is unavailable. `scoped` is always `false` here because this endpoint has no `files_scope` / `folders_scope` parameters. As on `/storage/search/`, `content_search_available` reports *capability*, not result likelihood — it is reachable as `false` only on share routes, and `semantic_available` is the field that tells you what this particular request got. The block is absent unless you supply `search_in` — supplying only `name_match` and/or `case_sensitive` leaves the response shape unchanged. **Bucket item shapes:** Every item carries a `relevance_score` (higher is more relevant) and an `updated` timestamp. Beyond that, fields are type-specific: - **`files`** — `node_id`, `name`, `parent_id`, `type`, plus the hybrid-match fields `content_snippet` (the matching text, `null` for keyword-only matches, trimmed per `output`), `match_source` (`keyword` / `semantic` / `both`), `media_segment` (`{start_seconds, end_seconds}` for audio/video, else `null`), `page` (`{start_page, end_page}` for paginated documents, else `null`), and `mimetype`. A `file` or `note` item additionally carries `metadata_facts` when the request set `details=true` -- see *`details=true` adds extracted metadata facts to the `files` bucket* above. - **`metadata`** (workspace only) — `node_id`, `name`, `parent_id`, `type`, `matched_fields` + `matched_fields_truncated`, and `template_ids`. `matched_fields` lists the metadata field(s) whose value matched, each as `{field, value, value_truncated}` — this is what distinguishes a metadata hit from a filename hit in the `files` bucket, and it is identical in shape and meaning to the `matched_fields` returned by `/metadata/search/` (see that endpoint for the full field table and the truncation rules). It may be empty, which is a valid result rather than an error. `matched_fields` and `matched_fields_truncated` are returned only to callers holding **Member** permission on the workspace, exactly as on `/metadata/search/`; below that level the two keys are **absent** from each item rather than empty, and the item's `node_id`, `name`, `parent_id`, `type` and ranking are unaffected. Test for the key's presence, not the list's length. `template_ids` is now ALWAYS EMPTY — templates are retired and the searched corpus records no template association. The key is kept so existing clients keep parsing; it will not be populated again. - **`comments`** — `comment_id`, `entity` (the commented-on node/container), `author_profile_id`, `scope_id` (the File Share's profile id when the comment was left through one of the workspace's File Shares, else `null`), `snippet` (the comment body, cut to 280 characters with a trailing `…` when longer), `reference_type` (the anchor type when the comment is anchored to a position in a file, else `null`), `created`. - Node and comment ids in bucket items (`node_id`, `parent_id`, `comment_id`, `entity`) are in unhyphenated form. **Permission model:** Every bucket is permission-filtered against the same rules as that type's dedicated endpoint. Results are produced by reading each match from the source of truth and re-checking the caller's permission **before** any content or count is returned, so search can never reveal an item, a snippet, or even a count for something the caller is not allowed to see. In particular: - **files / metadata** — filtered by the caller's file view permissions (and, on a share, the share's per-item file-view rules). - **comments** — only comments the caller can see in that workspace or share. On a workspace, the comments bucket is **owner-inclusive of File Shares**: it also returns comments left through the workspace's own **live** (active, non-expired, non-revoked) File Shares (a File Share is a view of a workspace file, so the comments belong to the same node). This is one-directional — workspace members see File Share comments, but File Share recipients never see the workspace's internal comments. Share search is unaffected. In rare cases (a workspace with a very large number of File Shares, or a transient error while enumerating them) workspace comment search may temporarily cover only the workspace's own comments; the File Share comments reappear on a later retry. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | `search` missing/blank/too long, a bucket request beyond the supported result window, or an invalid `search_in` / `name_match` / `case_sensitive` value or pattern | | `1609 (Not Found)` | 404 | Search not available for workspace-backed shares (shared folders), or the shared folder no longer exists; share only | | `1680 (Access Denied)` | 401 | No search permission | | `1654 (Internal Error)` | 500 | The search backend could not be reached or failed | **Notes:** - Search is not available for workspace-backed shares (shared folders) — the share endpoint returns `404` in that case. - Comments become searchable shortly after they are created or updated (indexing is asynchronous — typically within a few seconds). - A `degraded` bucket is safe to retry; the rest of the response is still valid. - This unified endpoint composes the same per-type searches as `/storage/search/`, `/metadata/search/`, and the comments surface — use it when you want a single grouped result set, or the per-type endpoints when you only need one kind of result. --- ## QuickShare (Workspace Only) > **Deprecated — use File Share.** The `POST` (create / extend-expiry) path now returns **403** (`10756 (Quickshare Deprecated)`) with a directed message pointing to `POST /current/workspace/{workspace_id}/create/fileshare/`. The durable **File Share** (see the next section) replaces it. `GET` (details), `DELETE` (revoke), and the public read endpoints below remain live during the drain so existing links keep serving and can be torn down. ``` POST /current/workspace/{workspace_id}/storage/{node_id}/quickshare/ (deprecated → 403) GET /current/workspace/{workspace_id}/storage/{node_id}/quickshare/ DELETE /current/workspace/{workspace_id}/storage/{node_id}/quickshare/ ``` Retrieve or delete an existing temporary public link for a single file. Creation is deprecated. **Auth required.** Permission: Member. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` | string | Yes | 19-digit workspace profile ID | | `{node_id}` | string | Yes | File OpaqueId | ### POST -- Create or Update QuickShare (deprecated → 403) This path is **deprecated** and returns **403** (`10756 (Quickshare Deprecated)`) for both create and extend-expiry. Use `POST /current/workspace/{workspace_id}/create/fileshare/` (see the File Share section below). The legacy `expires` / `expires_at` request parameters no longer apply because creation is closed. ### GET -- Get QuickShare Details Returns `{"result": true, "quickshare": {...}}`, where the `quickshare` object has the same shape as an entry of the list below. Returns `1609 (Not Found)` if no quickshare exists. ### DELETE -- Delete QuickShare Returns `{"result": true}`. Returns `1609 (Not Found)` if no quickshare exists. ### Public Access Endpoints (No Auth Required) Once a quickshare is created, these endpoints are accessible without authentication: ``` GET /current/quickshare/{quickshare_id}/details/ -- metadata and file info GET /current/quickshare/{quickshare_id}/storage/read/ -- download the file GET /current/quickshare/{quickshare_id}/storage/readnote/ -- read note content as JSON GET /current/quickshare/{quickshare_id}/storage/preview/{preview_type}/read/ -- preview GET /current/quickshare/{quickshare_id}/storage/preview/{preview_type}/read/file/{filename} -- preview sub-file ``` ### List QuickShares in Workspace ``` GET /current/workspace/{workspace_id}/storage/quickshares/list/ ``` **Auth required.** Permission: Member. Returns an array of all active quickshares in the workspace. **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/quickshares/list/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "quickshares": [ { "id": "{quickshare_id}", "node": { "id": "...", "type": "file", "name": "presentation.pdf" }, "creator_uid": { "id": "...", "email_address": "john@example.com" }, "limit_exceeded": false, "expires": "2025-01-22 10:30:00 UTC", "created": "2025-01-15 10:30:00 UTC" } ] } ``` --- ## File Share Read Endpoints (Public) A **File Share** is the durable successor to QuickShare — a long-lived, link-shareable view of one workspace file. These public read endpoints serve the link viewer. They are anonymous-allowed where the access tier (`anyone_with_link`) permits; for `any_registered` / `named_people` the caller must present a bearer token and have sufficient access. A link password, if set, is presented via the **`x-ve-password` request header** (never in the URL). The bound file is read from the File Share record, so a caller can never substitute a different node id. Bandwidth is metered to the owning organization (no per-link transfer cap). Management endpoints (create / list / update / delete / grants) are in the Workspaces reference. ``` GET /current/fileshare/{fileshare_id}/details/ -- viewer metadata + bound file info GET /current/fileshare/{fileshare_id}/storage/metadata/details/ -- the bound file's metadata pointer: node, template, extraction eligibility; no field values (view capability) GET /current/fileshare/{fileshare_id}/storage/read/ -- download the bound file (download capability) GET /current/fileshare/{fileshare_id}/storage/preview/{preview_type}/read/ -- preview (view capability) GET /current/fileshare/{fileshare_id}/storage/preview/{preview_type}/read/{download_token}/file/{filename} -- preview sub-file GET /current/fileshare/{fileshare_id}/storage/versions/ -- list the bound file's version history (view capability) GET /current/fileshare/{fileshare_id}/storage/versions/{version_id}/read/ -- download a specific version (download capability) ``` ### Details Response ```json { "result": true, "fileshare": { "fileshare": "1234567890123456789", "title": "Quarterly Presentation", "access_option": "anyone_with_link", "has_password": false, "comments_enabled": false, "effective_capability": "download", "file": { "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "type": "file", "name": "presentation.pdf", "parent": "2ekc7-5efba-yapdo-psqmq-3ntiv-ri56", "version": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5", "created": "2026-01-28 10:00:00 UTC", "modified": "2026-01-28 12:30:00 UTC", "size": 5242880, "hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "hash_algo": "sha256", "crc32c": "c41a7b0e", "mimetype": "application/pdf", "mimecategory": "document", "previews": { "pdf": { "state": "ready" }, "thumbnail": { "state": "ready" } }, "virus": { "status": "scanned", "infected": false }, "summary": { "title": "Quarterly Presentation", "short": "Q4 results deck", "long": "..." }, "metadata": { "title": null, "short": null } } } } ``` The bound `file` object carries the **same fields as a workspace file-node detail** (see *Node Details* in this reference): a viewer of the share sees that one file's full info — versioning, previews, summary, metadata, and provenance — exactly as it appears in the workspace. The share-level `effective_capability` (`view` / `download` / `edit`) reflects the highest capability the access tier / grant / password admit for the calling viewer. The envelope also carries `id_alt` -- the File Share's opaque public id, the handle to build links from -- and `comments_enabled`, which is always `false` on an `anyone_with_link` share. An authenticated caller additionally receives `can_manage`; only when it is `true` are `workspace_id` and `creator_uid` included. None of those three appear for an anonymous caller. One field follows `effective_capability`: **embedded file metadata**. `file_attributes.exif_metadata` and `file_attributes.media_metadata` are read out of the file's own bytes, so they are served only to a viewer who may download it. A `view`-only viewer receives `file_attributes` as an empty object `{}` (keys omitted, not `null`, not an error) — on `details`, on `versions`, and on the nested node object the metadata endpoint returns; `download` and `edit` viewers receive the metadata. See *Embedded File Metadata* under *Node Object Schema*. ### Metadata Response `GET /current/fileshare/{fileshare_id}/storage/metadata/details/` identifies the bound file's metadata — which node it is, which template governs it, and whether the file is eligible for automatic extraction. **It returns no metadata field VALUES.** View capability; the node is the bound file (never a caller-supplied node id). ```json { "result": true, "object_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "template_id": "cl4ev-5j54o-cladr-lnyuj-2lcub-gqjm", "node_id": { "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "type": "file", "name": "presentation.pdf", "parent": "2ekc7-5efba-yapdo-psqmq-3ntiv-ri56", "mimetype": "application/pdf" }, "autoextractable": true } ``` Those five keys are the entire response — there is no sixth. `template_id` is `null` when the bound file is mapped to no template. Do not code against an `instance_id` here either: the workspace metadata endpoint returns one, this endpoint deliberately does not, because it names the workspace the link was cut from. **No metadata VALUE crosses a File Share link, and that is deliberate.** A link admits anonymous recipients, while the values are the file's own contents and the field names are the owning team's private vocabulary — so this surface serves neither corpus. `metadata_facts` is absent (see *Extracted Metadata Facts* under *Node Object Schema*): absent here, absent on the nested node object this endpoint returns, and absent on every other File Share surface. The legacy `template_metadata` / `custom_metadata` key/value sets are absent too — those blocks have been withdrawn from every Fastio response, on this surface and in the workspace alike. **This is the settled end state, not a temporary restriction**; a recipient sees no metadata values, and nothing is queued to bring them back, so do not build a viewer that waits for them. A workspace member reading the same file through the workspace endpoint gets its facts — see *Get file metadata* in the [AI & Metadata reference](https://api.fast.io/current/llms/ai/). Preview types match the storage preview surface (`thumbnail`, `image`, `pdf`, `mp4`, `hlsstream`, etc.); multi-file previews (e.g. HLS) return a `307 Temporary Redirect` to a sub-file endpoint. Version listing is read-only — there is no restore/promote on the public surface; each entry carries the same per-version fields as a workspace version listing. **Error responses:** | HTTP Status | Code | Cause | |-------------|------|-------| | 406 | `1605 (Invalid Input)` | Invalid File Share id | | 401 | `1650 (Authentication Invalid)` | A link password is required and was missing or wrong (present it via the `x-ve-password` header) | | 403 | `1700 (Forbidden)` | The access tier or named grant does not permit the caller | | 404 | `1609 (Not Found)` | No such File Share | | 404 | `1652 (Resource Not Found)` | The bound file's content is no longer available | --- ## File Share Note Endpoints (Collaborative Editing) When a File Share's bound node is a **note** (a markdown `.md` node), recipients can read — and, with an edit-capable grant, collaboratively edit — the note through the File Share link. These endpoints back the real-time collaborative editor. The flow is two-step: 1. **Mint a realtime-note token** (`realtime/note-auth`) with a normal signed-in File Share credential. The token is bound to this File Share and this note, and it carries either `view` or `edit` standing. 2. **Read / update the note content** with that token in the `Authorization: Bearer` header. `readnote` and `updatenote` are **token-only** — they accept the realtime-note token, not a workspace user JWT (an external File Share recipient is not a workspace member). The bound note id is fixed on the File Share record; a caller can never substitute a different node id. Bandwidth is metered to the owning organization. ### Mint Realtime-Note Token ``` GET /current/fileshare/{fileshare_id}/realtime/note-auth/{note_id}/ ``` Mint a short-lived realtime-note token for the File Share's bound note. **Auth required.** The caller must be **signed in** (an anonymous `anyone_with_link` visitor cannot mint a token) and must pass the File Share's own access gate (access tier + named grant + link password). A link password, if set, is presented via the **`x-ve-password` request header**. The minted token's standing is capped at the caller's effective capability: an `edit` grant mints an `edit` token (permits `updatenote`); a `view` / `download` grant mints a `view` token (read-only). A read-only scoped access token also caps the result to `view`. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{fileshare_id}` | string | Yes | File Share id (numeric id or opaque id_alt) | | `{note_id}` | string | Yes | OpaqueId of the bound note | **curl example:** ```bash curl -X GET "https://api.fast.io/current/fileshare/1234567890123456789/realtime/note-auth/2ik5q-a43cm-uixi2-van5r-3eolo-7mue/" \ -H "Authorization: Bearer {jwt_token}" \ -H "x-ve-password: {link_password_if_set}" ``` **Response:** ```json { "result": true, "expires_in": 900, "auth_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `auth_token` | string | The realtime-note bearer token to present to `readnote` / `updatenote` | | `expires_in` | integer | Token lifetime in seconds | **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | Invalid note id, or the bound node is not a note | | `1609 (Not Found)` | 404 | No such File Share (or its owning workspace/organization is unavailable, or the owning organization no longer has an active plan), or the requested id is not the File Share's bound note | | `1680 (Access Denied)` | 401 | The signed-in account is anonymous (or not validated) — a real signed-in user is required to mint a realtime token | | `1700 (Forbidden)` | 403 | The access tier / named grant / password does not permit the caller, or the presented access token is not scoped to this File Share | | `1650 (Authentication Invalid)` | 401 | Credentials missing or invalid (sign-in required), or the token could not be minted | ### Read Note (File Share) ``` GET /current/fileshare/{fileshare_id}/storage/readnote/{note_id}/ ``` Read the bound note's content as JSON. Returns the sanitized markdown plus the full note resource — the same `{content, note}` shape as the workspace `readnote`. **Token-only.** Present the realtime-note token (from `note-auth`) as the `Authorization: Bearer` credential. There is no session fallback on this surface. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{fileshare_id}` | string | Yes | File Share id (numeric id or opaque id_alt) | | `{note_id}` | string | Yes | OpaqueId of the bound note | **Query parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `version_id` | string | No | Specific version OpaqueId to read | **curl example:** ```bash curl -X GET "https://api.fast.io/current/fileshare/1234567890123456789/storage/readnote/2ik5q-a43cm-uixi2-van5r-3eolo-7mue/" \ -H "Authorization: Bearer {realtime_note_token}" ``` **Response:** ```json { "result": true, "content": "# Meeting Notes\n\nDiscussed project timeline.", "note": { "id": "2ik5q-a43cm-uixi2-van5r-3eolo-7mue", "type": "note", "name": "meeting-notes.md", "parent": "root", "mimetype": "text/markdown", "version": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5", "created": "2026-07-07 10:30:00 UTC", "modified": "2026-07-07 10:30:00 UTC" } } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1650 (Authentication Invalid)` | 401 | Realtime-note token missing, invalid, or expired | | `1651 (Invalid Method)` | 405 | Only `GET` is accepted | | `1605 (Invalid Input)` | 406 | Invalid File Share id, note id, or version id | | `1700 (Forbidden)` | 403 | Token is not bound to this File Share/note, the File Share is unavailable, or the token lacks read capability | | `1609 (Not Found)` | 404 | Bound note no longer exists or is in the trash, or the requested `version_id` was not found | | `1680 (Access Denied)` | 401 | The note (or requested version) is blocked from serving — virus-infected, DMCA-flagged, or restricted (an edit lock does not block reads) | | `1693 (Temporarily Unavailable)` | 503 | The note's content is still being processed; retry shortly | | `1654 (Internal Error)` | 500 | Failed to retrieve the note | ### Update Note (File Share) ``` POST /current/fileshare/{fileshare_id}/storage/updatenote/{note_id}/ ``` Replace the bound note's markdown content and/or rename it. Updating content creates a new version. Returns the full `{note}` resource — the same shape as the workspace `updatenote`. **Conflict response (`if_version_id` mismatch)** — identical to the workspace `updatenote` conflict documented above: HTTP `409`, `error.params` is a **list of entries**, and the conflict entry carries `name: "if_version_id"`, `kind: "conflict"`, `reason: "conflict_version_mismatch"`, and `current_version_id` (also appended to `message` as ` current_version_id=`). Branch on `reason` — the only field naming the cause. `name` + `kind` is a fallback for clients that receive only the four standard fields: it proves *a* precondition on that parameter failed, not which one, so treat it as a generic conflict; never on the `409` status alone or on the numeric code. ⚠️ `error.params` is **not** an object — a client reading `error.params.current` receives nothing. **Token-only.** Present a realtime-note token that carries **edit** standing (a `view` token is rejected `403`). `name` and `content` must be supplied in the POST body (they are body-only — a write field in the query string is rejected). **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{fileshare_id}` | string | Yes | File Share id (numeric id or opaque id_alt) | | `{note_id}` | string | Yes | OpaqueId of the bound note | **Request body (form-encoded):** | Parameter | Type | Required | Constraints | Description | |-----------|------|----------|-------------|-------------| | `name` | string | No | 1-255 characters (counted as characters, not bytes); must end in `.md` | New note name | | `content` | string | No | Max 100 KB, non-blank | New markdown content (empty/whitespace-only is rejected) | | `if_version_id` | string | No | Version OpaqueId | Compare-and-swap precondition — the update proceeds only if the note's current `version` matches; otherwise `409 Conflict` with no change | At least one of `name` or `content` is required. **curl example:** ```bash curl -X POST "https://api.fast.io/current/fileshare/1234567890123456789/storage/updatenote/2ik5q-a43cm-uixi2-van5r-3eolo-7mue/" \ -H "Authorization: Bearer {realtime_note_edit_token}" \ -d 'content=# Updated Notes\n\nRevised content here.' ``` **Response:** ```json { "result": true, "note": { "id": "2ik5q-a43cm-uixi2-van5r-3eolo-7mue", "type": "note", "name": "meeting-notes.md", "parent": "root", "mimetype": "text/markdown", "version": "3ak5n-dr47a-qnylo-kzv6c-e6bnm-3u3c", "created": "2026-07-07 10:30:00 UTC", "modified": "2026-07-07 14:45:00 UTC" } } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1650 (Authentication Invalid)` | 401 | Realtime-note token missing, invalid, or expired | | `1651 (Invalid Method)` | 405 | Only `POST` is accepted | | `1605 (Invalid Input)` | 406 | Invalid File Share id / note id, neither `name` nor `content` supplied, `name` not ending in `.md`, a note with that name already exists in the folder, blank content, or malformed markdown / version id | | `1700 (Forbidden)` | 403 | Token is not bound to this File Share/note, the File Share is unavailable, or the token lacks edit capability (a view token cannot update) | | `1609 (Not Found)` | 404 | Bound note no longer exists or is in the trash | | `113958` | 409 | `if_version_id` did not match the note's current version (no change made). `error.params[]` carries the conflict entry — see *Conflict response* above. (`1660` is not the value of `error.code`.) | | `1680 (Access Denied)` | 401 | The note is blocked from writing — virus-infected, DMCA-flagged, or restricted | | `1654 (Internal Error)` | 500 | Failed to retrieve or update the note | --- ## File Locking Lock a file to prevent concurrent edits. Locks expire automatically if not renewed via heartbeat. Available on both workspace and share storage. ### Acquire Lock ``` POST /current/workspace/{workspace_id}/storage/{node_id}/lock/ POST /current/share/{share_id}/storage/{node_id}/lock/ ``` **Auth required.** Permission: Guest (workspace), file modification permission (share). **Request body (form-encoded):** | Parameter | Type | Required | Constraints | Description | |-----------|------|----------|-------------|-------------| | `duration` | integer | No | 60-3600 seconds | Lock duration (default 300) | | `client_info` | string | No | JSON object | Client metadata: `device_name` (max 255), `client_version` (max 50); the length limits are enforced on shares only | **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/lock/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'duration=300' \ -d 'client_info={"device_name":"My Laptop","client_version":"2.1.0"}' ``` **Response:** ```json { "result": true, "lock_token": "unique_lock_token_string", "locked_at": "2025-01-28 10:00:00 UTC", "expires_at": "2025-01-28 10:05:00 UTC", "node_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4" } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `lock_token` | string | Token required for heartbeat and release operations | | `locked_at` | string | Lock acquisition time (`YYYY-MM-DD HH:MM:SS UTC`) | | `expires_at` | string | Lock expiration time (`YYYY-MM-DD HH:MM:SS UTC`) | | `node_id` | string | OpaqueId of the locked node | **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Node not found or outside your workspace/share scope | | `1609 (Not Found)` | 404 | Cannot lock a deleted node (workspace only) | | `1660 (Conflict)` | 409 | Node already locked by another user | | `1680 (Access Denied)` | 401 | Insufficient permission to acquire a lock on this node (share only) | | `1693 (Temporarily Unavailable)` | 503 | Lock service momentarily unavailable; retry after a brief delay | ### Heartbeat (Extend Lock) ``` POST /current/workspace/{workspace_id}/storage/{node_id}/lock/heartbeat/ POST /current/share/{share_id}/storage/{node_id}/lock/heartbeat/ ``` **Auth required.** Permission: Guest (workspace), file modification permission (share). **Request body (form-encoded):** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `lock_token` | string | Yes | Token from acquire response | **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/lock/heartbeat/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'lock_token=unique_lock_token_string' ``` **Response:** ```json { "result": true, "expires_at": "2025-01-28 10:10:00 UTC", "time_remaining": 300 } ``` The share route returns the same fields plus `node_id` (the locked node's id, hyphenated). **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Node not found or outside your workspace/share scope | | `1660 (Conflict)` | 409 | Recreation race: another caller acquired the lock first; re-acquire and retry | | `1680 (Access Denied)` | 401 | Lock token does not match | | `1680 (Access Denied)` | 401 | **The lock was taken over by another user.** Stop editing and re-read the file — this is not an expiry and re-acquiring would discard the fact that somebody took the file from you | | `1680 (Access Denied)` | 401 | Insufficient permission to heartbeat this lock (share only) | | `1693 (Temporarily Unavailable)` | 503 | Lock service momentarily unavailable; retry after a brief delay | **Notes:** - **A heartbeat renews the lock for the same `duration` it was acquired with.** A lock taken for an hour is renewed for another hour, not shortened to some fixed amount. The renewal REPLACES the time still remaining rather than adding to it, so `expires_at` always comes back as roughly now plus that duration. - Send heartbeats well before the lock expires (e.g., at 50% of lock duration). - **Heartbeats recreate a lock that has already expired.** While the lock is live the `lock_token` is the only check — any authorized caller presenting it renews the lock — so treat the token as a secret. Once the lock has lapsed there is no stored token left to compare: the heartbeat recreates the lock with the presented token, held by the caller, unless that token was displaced by an override (`1680`). A recreated lock starts a fresh lease at the service default duration (300 seconds), NOT at the duration the original lock was acquired with — the expired lock is gone and its duration cannot be recovered, and this endpoint accepts no `duration`. Read `expires_at` from the response rather than assuming, and re-acquire with an explicit `duration` if you need a longer lease again. - **Branch on the refusal, don't just retry.** A lapsed lock is not refused — the heartbeat recreates it (see above). `1660 (Conflict)` means your lock lapsed and somebody else acquired it before you renewed; `1680 (Access Denied)` means you no longer hold it — either the token is wrong or somebody overrode you. In both cases re-read the file before doing anything else; treating them as a retryable expiry is how two people end up editing the same file. `1609 (Not Found)` means the node itself is gone. ### Release Lock ``` DELETE /current/workspace/{workspace_id}/storage/{node_id}/lock/ DELETE /current/share/{share_id}/storage/{node_id}/lock/ ``` **Auth required.** Permission: Guest (workspace), file modification permission (share). **Request body/query:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `lock_token` | string | Yes | Token from acquire response | **Response:** ```json { "result": true, "released": true } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | No lock exists on this node | | `1609 (Not Found)` | 404 | Node not found or outside your workspace/share scope | | `1680 (Access Denied)` | 401 | Lock token does not match | | `1680 (Access Denied)` | 401 | Insufficient permission to release this lock (share only) | | `1693 (Temporarily Unavailable)` | 503 | Lock service momentarily unavailable; retry after a brief delay | ### Lock Status ``` GET /current/workspace/{workspace_id}/storage/{node_id}/lock/ GET /current/share/{share_id}/storage/{node_id}/lock/ ``` **Auth required.** Permission: Guest (workspace), file view permission (share). **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Node not found or outside your workspace/share scope | | `1680 (Access Denied)` | 401 | Insufficient permission to view lock status (share only) | **The holder's identity requires MEMBER level or above.** `locked` and `node_id` are returned to every caller who may reach the endpoint — a client that cannot see them will offer a busy node as editable. The remaining fields (`locker_uid`, `locked_at`, `expires_at`, `locker`, and `time_remaining` on workspaces) identify *who* holds the lock and *when they started and stopped working*, and are returned only to callers at member level or above. Below that — workspace guests, share guests, public-link recipients — those keys are **absent**, exactly as `lock_info` is `null` on the node object for the same callers. Read `locked` for occupancy and treat the identity keys as optional; do not infer "unlocked" from a missing `locker_uid`. `locker.agent_name` names the agent that took the lock on the holder's behalf, when one did, and `locker.agent_name_source` says where that name came from; both are `null` when a person took the lock directly. **`agent_name` is self-declared, not verified** -- display it beside the holder, never rely on it to identify or authorize anyone. `locker.actor` carries the same attribution in the common actor shape (see *Actor Attribution* under Node Object Schema); only an actor with `verified: true` is Fastio's own agent. 🔴 **The name belongs to the CREDENTIAL, not to the lock.** It is read from the credential that took the lock -- a JWT claim (`agent_name_source: "jwt_claim"`) or the API key's own label (`"api_key_label"`) -- and it is set once, when you sign in or mint the key. **There is no per-lock parameter for it**, and nothing you send at acquire time can set, override, or suppress it. Two consequences worth designing around: every lock taken by one credential carries the same label, and a credential with no agent name produces `null` on every lock it takes, forever, until the credential itself is changed. If you want a lock to show an agent name, that decision happens at sign-in or key creation -- not at the lock call. This block carries no `display_name`; read the node object's `lock_info.locker` when you need the holder's name. **Response (locked, member or above):** ```json { "result": true, "locked": true, "node_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "locked_at": "2025-01-28 10:00:00 UTC", "expires_at": "2025-01-28 10:05:00 UTC", "time_remaining": 245, "locker_uid": "1234567890123456789", "locker": { "agent_name": "Claude-2", "agent_name_source": "api_key_label", "actor": { "user_id": "1234567890123456789", "kind": "agent", "agent_name": "Claude-2", "name_source": "api_key_label", "credential_type": "api_key", "verified": false } } } ``` **Response (locked, below member):** ```json { "result": true, "locked": true, "node_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4" } ``` **Response (unlocked):** ```json { "result": true, "locked": false } ``` ### Override Lock ``` POST /current/workspace/{workspace_id}/storage/{node_id}/lock/override/ POST /current/share/{share_id}/storage/{node_id}/lock/override/ ``` **Auth required.** Permission: Guest (workspace), file modification permission (share) — the same bar as writing the file. Takes the lock over from whoever holds it, without their `lock_token`, and gives it to you. Use it when a collaborator left a file locked and is no longer editing. Anyone who can write the file can override its lock: the lock is advisory and expires on its own, so it never granted exclusive write rights, and requiring more than write access to take it over would protect nothing. **This is a TAKEOVER, not a release — you end up holding the lock.** The response carries a new `lock_token` that is yours: heartbeat it and release it exactly as if you had acquired it. The displaced holder's token stops working immediately; their next heartbeat is refused with `1680 (Access Denied)`, which is how their client learns a person took the file rather than that their own lock lapsed. That refusal holds even after you release the lock again — for the remainder of the lifetime their own lock had left — so a slow or paused client cannot quietly pick the file back up. **Overriding does NOT let you overwrite someone else's changes, and must not be presented to a user as "force save".** A lock and a version precondition are different protections. The override takes the lock; it does not waive `if_version_id`. An update sent afterwards with a stale `if_version_id` still fails with the same `409` conflict it would have returned before the override — see *Conflict response*. To save after an override, re-read the file, rebase onto the current `version_id`, and send that. Overriding a file that is not locked also succeeds — you simply acquire it, and `overridden` is `false`. Overriding a lock **you already hold** is also safe: you keep your existing `lock_token`, nothing is displaced, and your in-flight heartbeat keeps working. A retry is therefore harmless. Every override is recorded as an event on the owning workspace or share, naming who overrode and which file, and is readable through the events API. The displaced holder is deliberately NOT named in that event: their identity requires member level, while the event itself is readable one tier below that, so it is recorded internally rather than published. **No request body.** **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/lock/override/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "lock_token": "unique_lock_token_string", "node_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "locked_at": "2025-01-28 10:03:12 UTC", "expires_at": "2025-01-28 10:08:12 UTC", "overridden": true, "previous_locker_uid": "1234567890123456789" } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `lock_token` | string | YOUR new token for the lock you now hold — required for heartbeat and release | | `node_id` | string | OpaqueId of the node | | `locked_at` | string | When you took the lock (`YYYY-MM-DD HH:MM:SS UTC`) | | `expires_at` | string | When your lock expires unless renewed (`YYYY-MM-DD HH:MM:SS UTC`) | | `overridden` | boolean | `true` when a live lock was actually replaced; `false` when the file was not locked, or when you already held it. It reports whether somebody was interrupted, which is not the same question as whether we can name them — it stays `true` even when `previous_locker_uid` is `null` | | `previous_locker_uid` | string/null | Who held the lock, or `null` when nobody did (or when the holder could not be identified). Always a **string**, never a JSON number — ids exceed JavaScript's safe integer range. **Requires MEMBER level or above**, exactly as `locker_uid` on lock status does; below that the key is **absent**. Read `overridden` to learn whether anyone was displaced — that stays truthful for every caller | **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Node not found or outside your workspace/share scope | | `1680 (Access Denied)` | 401 | Insufficient permission to modify locks on this node (share only) | | `1693 (Temporarily Unavailable)` | 503 | Lock service momentarily unavailable; retry after a brief delay | **Notes:** - After overriding, heartbeat the returned `lock_token` on the usual schedule — it is an ordinary lock with an ordinary expiry. - A client that has its heartbeat refused with `1680 (Access Denied)` should stop editing and re-read the file: someone took the lock. That is different from a lapsed lock, which a heartbeat simply recreates. --- ## Previews File previews provide rendered views of documents, images, video, and other content without downloading the original file. ### Preview Types | Value | Description | |-------|--------------------------------------| | `thumbnail` | Small thumbnail image | | `image` | Full-size image preview | | `mp4` | MP4 video preview (transcoded) | | `hlsstream` | HLS video/audio stream | | `audio` | Audio preview (transcoded) | | `pdf` | PDF document preview | | `spreadsheet` | Spreadsheet preview | | `bin` | Binary preview (raw bytes for clients that render their own preview) | ### Preview States Returned in node details responses under `previews.{type}.state`: | State | Description | |-----------------|--------------------------------------| | `unknown` | Preview status not yet determined | | `not possible` | File type cannot be previewed | | `not generated` | Preview not yet generated | | `error` | Preview generation failed | | `in progress` | Preview is being generated | | `ready` | Preview is available | ### Preauthorize Preview ``` GET /current/workspace/{workspace_id}/storage/{node_id}/preview/{preview_type}/preauthorize/ GET /current/share/{share_id}/storage/{node_id}/preview/{preview_type}/preauthorize/ ``` Get a preview download URL with an embedded token. **Auth required.** Permission: View (workspace), file download permission (share) — a share caller who may view but not download the file is refused `401`. **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{preview_type}` | string | Yes | One of: `thumbnail`, `image`, `mp4`, `hlsstream`, `audio`, `pdf`, `spreadsheet`, `bin` | **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/preview/thumbnail/preauthorize/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "downloadToken": "eyJhbGciOiJIUzI1NiJ9...", "path": "/api/v1.0/workspace/1234567890123456789/storage/2ltsuq4mjacuv7pgc5ydlxnsjwee4/preview/thumbnail/read/eyJhbGci.../file/preview.png", "primaryFilename": "preview.png" } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `downloadToken` | string | JWT token for preview access | | `path` | string | Host-relative API path (`/api/v1.0/...`, node id without hyphens) to read the preview file | | `primaryFilename` | string | Name of the primary preview file | **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | File not found | | `1605 (Invalid Input)` | 406 | Can only preview file or note | | `1609 (Not Found)` | 404 | File is in trash | | `1680 (Access Denied)` | 401 | No download permission on this file (share only) | | `1652 (Resource Not Found)` | 404 | File content is no longer available | | `1652 (Resource Not Found)` | 404 | Preview not available | ### Read Preview ``` GET /current/workspace/{workspace_id}/storage/{node_id}/preview/{preview_type}/read/ GET /current/share/{share_id}/storage/{node_id}/preview/{preview_type}/read/ ``` Read or redirect to a preview. For single-file previews, streams content directly. For multi-file previews, returns a `307 Temporary Redirect` to the file-specific endpoint with a generated token. If the source file is corrupt, truncated, or otherwise unreadable by the render pipeline, returns HTTP `422 Unprocessable Entity`. Clients should not retry — the source file itself is the problem. **Auth required.** ### Token-Based Preview Read ``` GET /current/workspace/{workspace_id}/storage/{node_id}/preview/{preview_type}/read/{token}/file/{filename} GET /current/share/{share_id}/storage/{node_id}/preview/{preview_type}/read/{download_token}/file/{filename} ``` Read a specific preview file using a token (from preauthorize). **Token-based auth -- no Authorization header needed.** **Path parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{token}` | string | Yes | Download token from preauthorize | | `{filename}` | string | Yes | Preview filename | **Range requests are supported**, with the same semantics as the file read endpoint above: `206 Partial Content` with `Content-Range` for a satisfiable range, `416` for a range that starts at or past the end, `200` with the whole body for an unparseable `Range` header, and `GET` only (`HEAD` returns `405`). The download token is **not single-use** — it stays valid for its lifetime, so a client may issue many ranged requests against the same URL. Note that the size being ranged over is the **preview artifact's**, which is not the source file's size unless the preview is served as the original (as it is for a PDF preview of a PDF). ### Request Preview Nonce (Share Only) ``` GET /current/share/{share_id}/storage/{node_id}/requestpreview/ ``` Mint a one-time nonce that lets a caller read a file it is not allowed to download. **Shares with `download_security=medium` only** — any other security level is rejected. This is how a share that withholds downloads still lets a viewer see the file: the nonce authorizes a single read and nothing else. **Auth required.** File-view permission on the share. **Response (200 OK):** ```json { "result": true, "preview_nonce": "abc123..." } ``` Pass the value back as the `preview_nonce` parameter on `GET /current/share/{share_id}/storage/{node_id}/read/`. It is **single-use and short-lived** — 60 seconds, consumed on first use — so mint it at the moment of the read, not ahead of time, and mint a fresh one per read. It is bound to the node it was issued for and is not valid for any other file. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `125621` | 406 | The share's download security is not `medium` | | `164543` | 406 | The node is a folder — only a file or note can be previewed | | `180409` | 404 | Node not found | | `160750` | 404 | The node exists but is outside this share | | `173838` | 404 | The node is in the trash | --- ## Transforms Image transforms allow on-the-fly resizing, cropping, rotating, and format conversion. ### Get Transform Status ``` GET /current/workspace/{workspace_id}/storage/{node_id}/transform/{transform_name}/ GET /current/share/{share_id}/storage/{node_id}/transform/{transform_name}/ ``` Check if a transformation is available without triggering it. **Auth required.** Currently the supported `{transform_name}` is `image`. **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/transform/image/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "state": "rendered" } ``` ### Transformation States | State | Description | |--------------------|------------------------------| | `rendered` | Transform is ready | | `rendering` | Transform in progress | | `unrendered` | Transform not yet requested | | `unable to render` | Transform failed or unsupported | ### Request Transform ``` POST /current/workspace/{workspace_id}/storage/{node_id}/transform/{transform_name}/request/ POST /current/share/{share_id}/storage/{node_id}/transform/{transform_name}/request/ ``` Request a transformation. If not yet rendered, triggers the transformation. If already rendered, returns immediately. **Auth required.** **Response:** ```json { "result": true, "state": "rendered" } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | Unknown transformation name (only `image` is accepted) | | `1609 (Not Found)` | 404 | Unable to transform (failed or unsupported) | ### Read Transformed File ``` GET /current/workspace/{workspace_id}/storage/{node_id}/transform/{transform_name}/read/ GET /current/share/{share_id}/storage/{node_id}/transform/{transform_name}/read/ ``` Download the transformed file. Supports byte-range requests and token auth. **Auth:** On the workspace route you must be signed in as a workspace member with View permission; a `token` from `requestread` is checked in addition, never instead, so a token alone is refused. On the share route a valid `token` query parameter stands in for the share download-permission check. ### Image Transform Parameters Pass as query parameters on transform read endpoints: | Parameter | Type | Values | |-----------------|--------|-----------------------------------------------| | `output-format` | string | `png`, `jpg`, `jpeg` -- **required** | | `width` | int | Target width in pixels | | `height` | int | Target height in pixels | | `cropwidth` | int | Crop region width | | `cropheight` | int | Crop region height | | `cropx` | int | Crop region X offset | | `cropy` | int | Crop region Y offset | | `rotate` | int | `0`, `90`, `180`, `270` | | `size` | string | Predefined: `IconTiny`, `IconSmall`, `IconMedium`, `Preview` | `output-format` is required on every transform read; the rest are optional. Send `OPTIONS` to a transform read URL for the accepted parameter list as the API itself reports it. **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/transform/image/read/?width=200&height=200&output-format=jpg" \ -H "Authorization: Bearer {jwt_token}" \ -o thumbnail.jpg ``` ### Request Transform Download Token ``` GET /current/workspace/{workspace_id}/storage/{node_id}/transform/{transform_name}/requestread/ GET /current/share/{share_id}/storage/{node_id}/transform/{transform_name}/requestread/ ``` Get a temporary download token for the transformed file. Available on both workspaces and shares. **Auth required.** On the workspace route the node must be a file or note in this workspace that is not in the trash: a node that does not exist in this workspace, or is in the trash, returns `404`; a folder or other non-file node returns `406`. **Response:** ```json { "result": true, "token": "eyJhbGciOiJIUzI1NiJ9..." } ``` --- ## Download Tokens Pattern The `requestread` endpoint generates temporary, auth-free download tokens for files and transforms. Previews get theirs from `preauthorize` instead. **Flow:** 1. `GET .../storage/{node_id}/requestread/` -- returns `{"token": "..."}` 2. `GET .../storage/{node_id}/read/?token={token}` -- download without Authorization header Useful for opening files in browser tabs or embedding in pages without exposing auth headers. Whole folders use the same pattern with their own token, `requestzip`: 1. `GET .../storage/{folder_id}/requestzip/` -- returns `{"token": "..."}` 2. `GET .../storage/{folder_id}/zip/?token={token}` -- download the folder as a ZIP without Authorization header A file token and a ZIP token are not interchangeable. In medium security mode, file previews are available for guests but direct downloads are restricted. Share members and above can still download normally. --- ## Saved metadata filters The per-user saved-view endpoints (`metadata/view/`, `metadata/views/`) have been REMOVED. They are replaced by workspace-shared **saved filters** — a named predicate over extracted metadata plus an optional display projection. ``` POST /current/workspace/{workspace_id}/metadata/filters/ GET /current/workspace/{workspace_id}/metadata/filters/ GET /current/workspace/{workspace_id}/metadata/filters/{filter_id}/ PUT /current/workspace/{workspace_id}/metadata/filters/{filter_id}/ DELETE /current/workspace/{workspace_id}/metadata/filters/{filter_id}/ GET /current/workspace/{workspace_id}/metadata/filters/{filter_id}/nodes/ ``` **Auth:** Bearer token required. Workspace member. Metadata billing feature required. Create and update take a **JSON object request body**; list and execute take query-string parameters. Create and update both require `name` and `predicate` (send `[]` for match-all), and **`PUT` replaces the whole filter** — an omitted `description` or `projection` is cleared, so read before you write. Create, get and update answer `{"result": true, "filter": …}`; list answers `{"result": true, "count", "items", "cursor", "has_more"}` — note `items`, not `filters`; delete answers `{"result": true}` and is idempotent, so a `200` does not prove anything was deleted. Full reference — the request/response contract per endpoint, predicate operators, the clause cap, projection semantics and the `scope` object — is in the AI & Metadata docs under *Saved metadata filters*. --- ## Workspace-Only Features These endpoints are only available on workspaces, not shares: - `addlink` -- add a share link to storage - `createnote` / `updatenote` -- markdown note creation and editing - `readnote` is available on both workspaces and shares (shares support token-based access) - `quickshare` -- temporary public file links (creation **deprecated** → use File Share) - `quickshares/list` -- list all quickshares - `create/fileshare` / `list/fileshares` -- durable single-file File Share management (replaces QuickShare) ## Share-Specific Notes - Share storage follows identical patterns to workspace storage for all common operations - Shares support file locking (acquire, heartbeat, release, status, override) - Shares support previews and transforms (status, request, read, requestread) - Shares additionally support `requestpreview` (one-time preview nonce) for `download_security=medium` shares - Share permissions are granular: separate permissions for file view, download, creation, modification, and administration - Embedded file metadata (`file_attributes.exif_metadata` / `file_attributes.media_metadata`) is returned only to callers permitted to download the file -- when downloads are not permitted the keys are omitted and `file_attributes` is `{}` (see *Embedded File Metadata* under *Node Object Schema*) - Shares may restrict operations to files the user created (creator-only restrictions) - Workspace folder shares map `root` to the designated folder and scope all operations to that subtree - Search is not available for workspace folder shares (files are indexed by workspace, not share) - Inventory (`/storage/inventory/`) is not available for workspace folder shares -- enumerate the backing workspace instead - Public shares may allow listing and downloading without JWT authentication > Part of the Fastio API Reference. Overview: https://api.fast.io/current/llms/ # Share Management Base URL: `https://api.fast.io/current/` Shares are purpose-built portals for exchanging files with internal teams and external guests. They support branded file preview, download controls, guest access, password protection, expiration, AI-powered features, and real-time collaboration. --- ## Share Types | Type | Direction | Description | Guest Uploads | "Anyone with the link" Access | |------------|---------------|----------------------------------------------------|---------------|-------------------------------| | `send` | Owner -> Guest | Owner distributes files; guests can download only | No | Allowed | | `receive` | Guest -> Owner | Owner collects files; guests can upload only | Yes | Allowed (sign-in to upload unless anonymous uploads are on) | | `exchange` | Bidirectional | Both parties can upload and download | Yes | Allowed (sign-in to upload unless anonymous uploads are on) | **Share type depends on storage mode.** The share type is constrained by the `storage_mode` you create the share with: - **Portal shares (`storage_mode=independent`, the default) are always `send`.** When a share is created in the default portal storage mode, the share type is forced to `send` regardless of the `share_type` you pass — a `receive` or `exchange` value is silently overridden to `send`. A default create (no `storage_mode`, no `share_type`) therefore yields a **Send** share. - **`receive` and `exchange` require `storage_mode=workspace_folder`.** Only workspace-folder-backed shares honor a `receive` or `exchange` `share_type`; portal shares cannot be either type. To create a Receive or Exchange share, create it as a `workspace_folder` share (see Storage Modes below). --- ## Storage Modes (Immutable at Creation) ### Portal (`storage_mode=independent`, default) The share has its own isolated storage. Files added to the portal are independent of any workspace. Changes to workspace files do not affect the portal, and vice versa. **Portal shares are always `send` type** — the share type is forced to `send` at creation and a `receive`/`exchange` request is silently overridden. Use `storage_mode=workspace_folder` to create a Receive or Exchange share. **Features:** Expiration dates, archiving, password protection, custom branding, guest access, inline file preview, download controls, post-download messaging, AI auto-titling. ### Shared Folder (`storage_mode=workspace_folder`) The share is backed by a specific workspace folder. The share displays the live contents of that folder -- files added, updated, or removed in the workspace folder are immediately reflected in the share. No file duplication, so no extra storage cost. **Creation:** Pass `folder_node_id={folder_opaque_id}` to link an existing folder, or `create_folder=true` with `folder_name` to create a new one. **Restrictions:** Expiration dates and archiving are not allowed since the content is live. Each workspace folder can only be shared once. If the backing folder is deleted, the share becomes orphaned (`is_orphaned: true` in details response). Both modes look the same to share recipients -- a branded portal with file preview, download controls, and all share features. ### Response Field: `share_category` API responses include a `share_category` field alongside `storage_mode`. The `storage_mode` field is the input parameter used when creating shares; `share_category` is a response-only field that provides an alternate label. | `storage_mode` | `share_category` | |--------------------|-------------------| | `independent` | `portal` | | `workspace_folder` | `shared_folder` | --- ## Access Options The `access_options` parameter controls who can access the share: | Value | Description | |-------------------------------------------------|---------------------------------------------------------------------| | `'Only members of the Share or Workspace'` | Most restrictive (default). Only explicit members or workspace members. | | `'Members of the Share, Workspace or Org'` | Org members can also access | | `'Anyone with a registered account'` | Any authenticated Fastio user | | `'Anyone with the link'` | Public access. Enables password protection. | **Restrictions:** - Receive and Exchange shares may use `'Anyone with the link'`, but guests must sign in to upload unless `anonymous_uploads_enabled` is true (requires premium plan). - Enabling comments or certain notification settings on a share with `'Anyone with the link'` access automatically upgrades access to `'Anyone with a registered account'` because those features require user identity. - Changing share type to `receive` or `exchange` while access is `'Anyone with the link'` automatically upgrades access to `'Anyone with a registered account'` (unless `anonymous_uploads_enabled` is true). - Changing access away from `'Anyone with the link'` or changing type away from Receive/Exchange automatically disables `anonymous_uploads_enabled`. --- ## Compact Responses (`output=`) Every endpoint that returns one or more share 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 share (cumulative) | |-------|--------------------------------------------| | `terse` | `id`, `title`, `share_type`, `share_level`, `share_root_id` (the share-root node id as a top-level scalar — member-only; guests do not receive this field), `creator` (scalar user id) | | `standard` | terse + `share_category`, `storage_mode`, `folder_node_id`, full `share_link` object, parent linkage, lifecycle flags (including `locked`, admin-only), `custom_name`, `custom_url`, `description`, `download_security`, `expires`, `created`, `updated`, `guest_chat_enabled`/`anonymous_uploads_enabled` flags, `user_status`, `intelligence` | | `full` | standard + `capabilities`, permission blocks, activity tracking, `comments`, `event_flow`, `filesystem`, `invite`, `member_visibility`, `multiplayer`, `chat`, `search`, `assets`, `access_options`, `display_type`, branding (accent color, logo, background), `password`, `platform`, `notify`, `link_1`/`link_2`/`link_3`, `owner_defined`, `deleted`, `storage`, `suspended`, `parents` | Use `terse` for share pickers, share-link previews, and navigation sidebars — it carries the ID, title, type (Send/Receive/Exchange), access level, the share's root node id as a top-level `share_root_id` scalar (what layouts and transfer pages need to scope navigation), and a `creator` user id so "Shared by {creator}" rows can resolve via the user cache without pulling the full share-link object. Use `standard` for share list views and the summary card on share detail pages — it adds storage mode, folder binding, the full `share_link` object, timestamps, expiration, the caller's membership status, the `locked` lifecycle chip (admin-only), and the `download_security` badge (`high`/`medium`/`off`) that share-list rows render. Use `full` (or omit the parameter) for the share settings screen, branding editors, password configuration, invite link management, and any workflow that reads capability or permission matrices. 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 in `llms.txt` for the full contract. --- ## Create Share ``` POST /current/workspace/{workspace_id}/create/share/ ``` Create a new share in a workspace. **Auth required.** Subject to the organization's and the workspace's sharing policy — both must allow shares, with the org acting as a ceiling. Read `capabilities.can_create_share` on the workspace's details response to know in advance whether the calling user may create one; see *Sharing Policy* in the Workspaces reference. When policy refuses a shared-folder create, the folder is not created either. ### Path Parameters | Parameter | Type | Required | Description | |------------------|--------|----------|---------------------------------------| | `{workspace_id}` | string | Yes | 19-digit numeric workspace profile ID | ### Request Body **Required parameters:** | Parameter | Type | Required | Description | |----------------|--------|----------|-------------------------------------------------------------------| | `intelligence` | string | Yes | Boolean string (`"true"` / `"false"`). Enable Deep Indexing. Setting `"true"` requires both the `content_ai` and `ai_agent` plan features; plans without both will be rejected with `1605 (Invalid Input)`. Portal (`independent`) shares only — workspace folder shares cannot enable Deep Indexing. | **Optional parameters:** | Parameter | Type | Required | Default | Constraints | Description | |----------------------|---------|----------|-----------------|----------------------------------|-----------------------------------------------------------------------------| | `share_type` | string | No | `send` (portal) / `exchange` (workspace folder) | `send`, `receive`, `exchange` | Share direction type. **Portal (`independent`) shares are always `send`** — a `receive`/`exchange` value is silently overridden. `receive`/`exchange` require `storage_mode=workspace_folder`. | | `custom_url` | string | No | `null` | 10-100 chars | Custom URL name for linking to the share. Not auto-generated (the auto-generated URL name is `custom_name`). Send `"null"` to leave unset. Distinct from `title`. | | `access_options` | string | No | `Only members of the Share or Workspace` | See Access Options | Who can access the share | | `invite` | string | No | `owners` | `owners`, `guests` | Who can manage share invitations | | `storage_mode` | string | No | `independent` | `independent`, `workspace_folder`| Storage mode (immutable after creation) | | `folder_node_id` | string | No | - | Valid opaque ID | Workspace folder opaque ID (required for `workspace_folder` if not creating)| | `create_folder` | string | No | - | Boolean string | Create a new backing folder (with `folder_name`) | | `folder_name` | string | No | `Shared Folder` | 1-255 characters | Name for the new backing folder (with `create_folder=true`) | | `title` | string | No | - | 2-80 chars | Share display title | | `description` | string | No | - | 10-500 chars | Share description | | `custom_name` | string | No | Auto-generated | 4-80 chars, URL-friendly | URL-friendly custom name. Auto-generated opaque ID if omitted. | | `password` | string | No | - | 4-128 chars | Password. Only with `'Anyone with the link'` access. | | `expires` | string | No | - | datetime (`YYYY-MM-DD HH:MM:SS`) | Expiration date. Portal mode only, not allowed on shared_folder. | | `notify` | string | No | `never` | `never`, `notify_on_file_received`, `notify_on_file_sent_or_received` | Notification preference | | `comments_enabled` | string | No | - | Boolean string | Enable comments | | `download_security` | string | No | - | `high`, `medium`, `off` | Download security level. `high`: downloads disabled. `medium`: downloads require a short-lived nonce (see storage docs). `off`: downloads unrestricted. | | `guest_chat_enabled` | string | No | - | Boolean string | Enable guest AI chat | | `display_type` | string | No | - | Not blank | Visual display mode (`grid`, `list`) | | `workspace_style` | string | No | - | Not blank | Workspace visual style | | `accent_color` | string | No | - | Valid JSON | JSON color object for accent | | `background_color1` | string | No | - | Valid JSON | JSON color object for primary background | | `background_color2` | string | No | - | Valid JSON | JSON color object for secondary background | | `background_image` | integer | No | - | Numeric, validated range | Background image selection | | `link_1` | string | No | - | Valid JSON | JSON link object (custom link #1) | | `link_2` | string | No | - | Valid JSON | JSON link object (custom link #2) | | `link_3` | string | No | - | Valid JSON | JSON link object (custom link #3) | | `owner_defined` | string | No | - | Valid JSON or `"null"` | Custom owner-defined properties | | `anonymous_uploads_enabled` | string | No | `false` | Boolean string | Enable anonymous file uploads. Receive/Exchange + `'Anyone with the link'` + premium plan only. | ### Request Example ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/create/share/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "share_type=send&intelligence=true&access_options=Anyone+with+the+link&title=Client+Deliverables" ``` ### Response **Status:** `200 OK` ```json { "result": true, "share": { "id": "9876543210987654321", "custom_name": "6lrs43jnglqu2ysgpyovmbgdtxa7c", "storage_mode": "independent" } } ``` For workspace folder shares, the response also includes `folder_node_id`. The create response does not include `share_category` — that response-only label appears on the details/list endpoints. ### Response Fields | Field | Type | Description | |---------------------------|--------|-------------------------------------------------------| | `share.id` | string | 19-digit share profile ID | | `share.custom_name` | string | URL name (custom or auto-generated) | | `share.storage_mode` | string | `independent` or `workspace_folder` | | `share.folder_node_id` | string | Backing folder node ID (workspace_folder only) | ### 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. Codes shown as five or six digits (and the `9661`-`9669` family) ARE `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. | HTTP Status | Code | Message | Cause | |-------------|--------------------------|-----------------------------------------------------------------------------|-------------------------------------------------------------| | 406 | `1605 (Invalid Input)`| "An invalid share custom name was supplied." | Custom name fails validation | | 406 | `1605 (Invalid Input)`| "An invalid share expiration date was supplied." | Invalid datetime format | | 406 | `1605 (Invalid Input)`| "Password can only be set for shares with 'Anyone' access option." | Password on non-public share | | 406 | `1605 (Invalid Input)`| "Workspace folder shares cannot have an expiration date..." | Expiration on workspace_folder share | | 406 | `1605 (Invalid Input)`| "Must provide folder_node_id or set create_folder=true..." | Missing folder config for workspace_folder mode | | 412 | `1685 (Feature Limit)` | "The organization has reached its share creation limit..." | Share quota exceeded for billing plan | | 403 | `1700 (Forbidden)` | "Sharing is turned off for this workspace." | Org or workspace sharing policy refuses new shares. `params.reason` = `policy_sharing_disabled`. Distinct from the plan-limit refusal above. | | 406 | `1658 (Not Acceptable)` | "The supplied share custom name is already in use." | Duplicate custom name | | 406 | `1658 (Not Acceptable)` | "This folder has already been shared." | Folder already linked to another share | | 409 | `1660 (Conflict)` | "Unable to process share creation request due to concurrent operation." | Concurrent operation conflict | | 500 | `1663 (Update Failed)` | "There was an internal error processing your create request." | Internal error | --- ## Share Details ``` GET /current/share/{share_id}/details/ ``` Get full share details. **Auth required (optional for public shares).** `{share_id}` accepts a 19-digit numeric ID or a custom name. ### Path Parameters | Parameter | Type | Required | Description | |--------------|--------|----------|--------------------------------------| | `{share_id}` | string | Yes | 19-digit share profile ID or custom name | ### Request Example ```bash curl -X GET "https://api.fast.io/current/share/1234567890123456789/details/" \ -H "Authorization: Bearer {jwt_token}" ``` ### Response **Status:** `200 OK` ```json { "result": true, "share": { "id": "1234567890123456789", "title": "Q4 Financial Reports", "description": "Quarterly financial documents", "share_type": "send", "custom_name": "q4-reports", "storage_mode": "independent", "share_category": "portal", "closed": false, "archived": false, "share_level": "admin", "share_root_id": "2ow3buqu5mdgt4tmjw7ucxcrwhins", "download_security": "off", "activity_tracking": { "enabled": true, "owner_activity": true, "guest_activity": true, "own_activity": true, "all_upload_activity": false }, "comments": { "enabled": true, "owner_comments_visible": true, "guest_comments_visible": true, "personal_replies_visible": true, "owner_replies_visible": true }, "filesystem": { "file_creation": true, "file_modification": "owned", "file_download": "all", "file_view": "all", "folder_creation": true, "folder_modification": "owned" }, "event_flow": { "enabled": true, "can_see_own_events": true, "can_see_owner_events": true, "can_see_guest_events": false }, "multiplayer": { "enabled_for_user": true, "enabled_for_owners": true, "enabled_for_guests": true, "owners_can_see_guests": true, "guests_can_see_owners": false }, "member_visibility": { "user_can_see_members": true, "owners_can_see_members": true, "guests_can_see_members": false }, "invite": { "setting": "owners", "can_invite": true }, "capabilities": { "can_archive": true, "can_set_expiration": true }, "parent_type": "workspace", "parent_workspace": "9876543210987654321", "parent_org": "1122334455667788990", "created": "2024-01-01 10:00:00 UTC", "expires": "2024-12-31 23:59:59 UTC" } } ``` ### Response Fields | Field | Type | Description | |------------------------------------|--------------|--------------------------------------------------------------| | `share.id` | string | Share profile ID | | `share.title` | string\|null | Display title | | `share.description` | string\|null | Share description | | `share.share_type` | string | `send`, `receive`, or `exchange` | | `share.custom_name` | string\|null | URL-friendly custom name | | `share.storage_mode` | string | `independent` or `workspace_folder` | | `share.share_category` | string | `portal` (independent) or `shared_folder` (workspace_folder). Response-only. | | `share.closed` | boolean | Whether the share has been soft-deleted | | `share.archived` | boolean | Whether the share is archived | | `share.share_level` | string | Current user's effective access level: `admin` (share owner/admin, or member-or-above of the parent workspace), `member`, `guest`, `public` (non-member viewing a public share), `excluded` | | `share.share_root_id` | string\|null | Node OpaqueId of the share's root folder (graft point). Top-level scalar form of `share_link.graft_point`. **Member-only.** Returned only when the caller has at least share-member access. | | `share.download_security`| string | Download security level: `high`, `medium`, or `off` | | `share.activity_tracking`| object | Activity visibility settings | | `share.comments` | object | Comment visibility and permission settings | | `share.filesystem` | object | File and folder operation permissions | | `share.event_flow` | object | Real-time event visibility settings | | `share.multiplayer` | object | Real-time collaboration/presence settings. Automatically determined based on share configuration -- not directly configurable via API. | | `share.member_visibility`| object | Who can see other share members | | `share.invite` | object | Invitation policy settings | | `share.capabilities` | object | Operations allowed based on storage mode. Member+ only and full output only. Also carries `can_invite_external` (boolean) — whether the **calling user** may currently bring an outside person onto this share, combining the org's collaboration policy, the caller's role/override, and this share's own `external_invites` flag. Server truth; do not recompute it from `external_invites`. | | `share.external_invites` | string\|null | This share's own external-invite setting: `allowed`, `denied`, or `null` to inherit the org's collaboration policy. Not the effective answer — see `capabilities.can_invite_external` below and *Collaboration Policies* in `llms/orgs.txt`. | | `share.parent_type` | string\|null | `workspace` or `user` | | `share.parent_workspace` | string\|null | Parent workspace ID (workspace-owned shares only) | | `share.parent_org` | string\|null | Parent organization ID (workspace-owned shares only) | | `share.created` | string | Creation timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `share.expires` | string\|null | Expiration timestamp, or null | | `share.folder_node_id` | string\|null | Backing folder node ID (workspace_folder only) | | `share.is_orphaned` | boolean | Whether backing folder was deleted (workspace_folder only) | ### Error Responses | HTTP Status | Code | Message | Cause | |-------------|-------------------|-------------------------------------------------|------------------------------------| | 401 | `1650 (Authentication Invalid)`| "Authentication required" | Non-public share without auth | | 401 | 144499 | "You do not have permissions to view this share."| Lacks view permission | --- ## Public Share Details ``` GET /current/share/{share_id}/public/details/ POST /current/share/{share_id}/public/details/ ``` Returns a comprehensive view of a share from a single API call, designed for public share landing pages. Includes share details, owner information, file listing, member list, and comments. **No auth required (optional; enhances permissions if provided).** For password-protected shares, pass the password JWT in the `x-ve-password` header (obtained from the password auth endpoint). ### Request Example ```bash # Public access (no auth) curl -X GET "https://api.fast.io/current/share/1234567890123456789/public/details/" # Password-protected share curl -X GET "https://api.fast.io/current/share/1234567890123456789/public/details/" \ -H "x-ve-password: {password_jwt_token}" # Authenticated user curl -X GET "https://api.fast.io/current/share/1234567890123456789/public/details/" \ -H "Authorization: Bearer {jwt_token}" ``` ### Response **Status:** `200 OK` ```json { "result": true, "share": { "id": "1234567890123456789", "title": "Q4 Financial Reports", "share_type": "send", "storage_mode": "independent", "share_category": "portal" }, "owner": { "id": "9988776655443322110", "account_type": "human", "status": "active", "first_name": "Jane", "last_name": "Smith", "profile_pic": null }, "nodes": [ { "id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "type": "file", "name": "report.pdf", "parent": "root", "size": 1048576 } ], "users": [ { "id": "1122334455667788990", "account_type": "human", "status": "active", "first_name": "John", "last_name": "Doe", "permissions": "guest" } ], "comments": [], "org": { "id": "5566778899001122334", "name": "Acme Corp" } } ``` ### Response Fields | Field | Type | Description | |---------------------|-------------|----------------------------------------------------------------------------| | `share` | object | Full share details (same as details endpoint) | | `owner` | object | Share owner's user resource. `email_address` is included only when the caller is a member of the share; anonymous and non-member visitors receive the owner without it | | `nodes` | array | Top-level files and folders in the share root | | `users` | array | Share members (if user has member visibility permission) | | `comments` | array | Currently always an empty array | | `org` | object\|null| Organization resource (only for workspace-owned shares) | ### Error Responses | HTTP Status | Code | Message | Cause | |-------------|-------------------|-------------------------------------------------|------------------------------------| | 401 | 183836 | "You do not have permissions to view this share."| Lacks public view permission | | 500 | 121168 | "Failed to access share storage" | Internal storage error | --- ## Update Share ``` POST /current/share/{share_id}/update/ ``` Update share settings. **Auth required. Owner/admin only.** Supports partial updates -- only provided fields are modified. Works on archived shares. ### Request Body All fields are optional: | Parameter | Type | Constraints | Description | |------------------------|---------|-----------------------------------|---------------------------------------------------------------------------| | `custom_url` | string | 10-100 chars, `"null"` to clear | Custom URL name for linking to the share | | `title` | string | Not blank, or `"null"` to clear | Display title (2-80 chars) | | `description` | string | `"null"` or `""` to clear | Share description (10-500 chars) | | `custom_name` | string | 4-80 chars, or `"null"` to clear | URL-friendly custom name. Must be unique. | | `share_type` | string | `send`, `receive`, `exchange` | Share direction type. Portal (`independent`) shares stay `send` — a `receive`/`exchange` value is silently overridden. | | `access_options` | string | See Access Options | Who can access the share | | `external_invites` | string | `allowed`, `denied` | This share's own external-invite switch. Ungated, object-admin only — the org-level policy tightening is what needs the Enterprise plan, not this per-object flag. There is currently no way to reset it back to "inherit" once set; see *Collaboration Policies* in `llms/orgs.txt`. | | `invite` | string | `owners`, `guests` | Who can manage invitations | | `password` | string | 4-128 chars, `"null"`/`""` to clear | Password. Only with `'Anyone with the link'` access. | | `expires` | string | datetime, `"null"` to clear | Expiration. Portal mode only. | | `notify` | string | See notification values | Notification preference | | `comments_enabled` | string | Boolean string | Enable/disable comments | | `download_security` | string | `high`, `medium`, `off` | Download security level. `high`: downloads disabled. `medium`: downloads require a short-lived nonce. `off`: downloads unrestricted. | | `display_type` | string | `grid`, `list` | Visual display mode | | `workspace_style` | string | Not blank | Workspace visual style | | `guest_chat_enabled` | string | Boolean string | Enable/disable guest AI chat | | `intelligence` | string | Boolean string | Can be toggled. Setting `"true"` requires both `content_ai` and `ai_agent` plan features. Disabling flushes embeddings; re-enabling re-indexes (costs AI credits). | | `accent_color` | string | JSON or `"null"` | JSON color object for accent | | `background_color1` | string | JSON or `"null"` | JSON color object for primary background | | `background_color2` | string | JSON or `"null"` | JSON color object for secondary background | | `background_image` | integer | Numeric, validated range | Background image selection | | `link_1` | string | JSON or `"null"` | JSON link object (custom link #1) | | `link_2` | string | JSON or `"null"` | JSON link object (custom link #2) | | `link_3` | string | JSON or `"null"` | JSON link object (custom link #3) | | `owner_defined` | string | JSON or `"null"` | Custom owner-defined properties | | `share_link_node_id` | string | `"null"` only | Can only be set to `"null"` to remove workspace share link node | | `anonymous_uploads_enabled` | string | Boolean string | Enable/disable anonymous file uploads. Receive/Exchange + `'Anyone with the link'` + premium plan only. Automatically disabled when access changes from "Anyone" or type changes from Receive/Exchange. | ### Request Example ```bash curl -X POST "https://api.fast.io/current/share/1234567890123456789/update/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "title=Updated+Title&description=New+description" ``` ### Response **Status:** `200 OK` ```json { "result": true } ``` ### Important Behaviors - **Password auto-clear**: Changing access away from `'Anyone with the link'` automatically clears the password. - **Access auto-upgrade**: Enabling comments or notification settings that require user identity automatically upgrades access from `'Anyone with the link'` to `'Anyone with a registered account'`. - **Share type auto-upgrade**: Changing to `receive` or `exchange` while access is `'Anyone with the link'` upgrades access to `'Anyone with a registered account'` (unless `anonymous_uploads_enabled` is true). - **Deep Indexing**: Can be enabled and disabled. Enabling requires both the `content_ai` and `ai_agent` plan features — plans that lack either are rejected with `1605 (Invalid Input)`. Disabling destroys indexed embeddings (vector index is flushed). Re-enabling incurs re-indexing costs (AI credits consumed to re-index all files). - **Expiration**: Workspace folder shares cannot have expiration dates. Expiration is validated against the billing plan. - **Share link sync**: A workspace share link node carries the share's effective link name — its title, or its custom URL when it has no title. Updating the title (or the custom URL of a share with no title) renames the link node to match; an update that would leave a linked share with neither is refused. ### Error Responses | HTTP Status | Code | Message | Cause | |-------------|--------------------------|-----------------------------------------------------------------------------|------------------------------------------------| | 406 | `1605 (Invalid Input)`| "The external invite setting must be \"allowed\" or \"denied\"." | Invalid `external_invites` value | | 403 | `153207` | "Widening this share's access is not permitted here by policy." | Widening `access_options` (including `'Anyone with a registered account'` → `'Anyone with the link'`) while the org's collaboration policy denies the acting user. `params.reason` = `external_invites_denied` or `external_invites_object_denied` — see *Collaboration Policies* in `llms/orgs.txt`. | | 500 | `191268` | "The policy that governs sharing this share could not be determined. Please try again." | The policy could not be evaluated (transient — retry). | | 406 | `1605 (Invalid Input)`| "An invalid share custom name was supplied." | Invalid or duplicate custom name | | 406 | `1605 (Invalid Input)`| "An invalid share expiration date was supplied." | Invalid datetime format | | 406 | `1605 (Invalid Input)`| "A share linked into workspace storage must keep a title or custom URL; set one before clearing the other." | The update would leave a linked share with no name for its link node | | 406 | `1605 (Invalid Input)`| "Password can only be set for shares with 'Anyone' access option." | Password on non-public share | | 406 | `1605 (Invalid Input)`| "Deep Indexing cannot be enabled on workspace folder shares. Only portal shares with independent storage support Deep Indexing." | Deep Indexing enable on a `workspace_folder` share | | 406 | `1605 (Invalid Input)`| "Deep Indexing requires a plan upgrade. This feature is not available on your current subscription plan." | Plan missing `content_ai` or `ai_agent` (cannot set `intelligence=true`) | | 403 | *(generated)* | "Your organization's AI policy does not allow enabling this here." | Turning `intelligence=true` **ON** while the org's AI policy denies the caller Deep Indexing for this share's parent workspace. `params.reason` = `ai_policy_denied` (`params.feature:"intelligence"`) or `ai_policy_workspace_not_allowed`. Turning it **off** is never refused by this policy. See *AI, Deep Indexing & MCP Access Policy* in `llms/orgs.txt`. | | 412 | `1685 (Feature Limit)` | "The Deep Indexing setting can only be changed twice per minute and five times per hour. Please wait and try again." | Deep Indexing toggle throttled (per-share limit) | | 406 | `1605 (Invalid Input)`| "Workspace folder shares cannot have an expiration date..." | Expiration on workspace_folder share | | 401 | 144499 | "You do not have permissions to access this share." | User lacks admin permission | | 406 | `1658 (Not Acceptable)` | "The supplied share custom name is already in use." | Duplicate custom name | | 503 | `174399` | "The plan that governs this share could not be determined. Please try again." | Setting `expires` requires reading the plan that owns this share, and that read failed. **Transient — retry.** It says nothing about the date you sent, and nothing about your plan | | 503 | `140240` | "The plan that governs this share could not be determined. Please try again." | The same guard on the `anonymous_uploads_enabled` path. **Transient — retry.** Do not read it as "your plan is too low" — that refusal is the separate feature-limit error | | 500 | `1663 (Update Failed)` | "There was an internal error processing your update request." | Internal error | --- ## Delete Share ``` DELETE /current/share/{share_id}/delete/ ``` Soft-delete (close) a share. **Auth required. Owner/admin only.** Requires confirmation. ### Query Parameters | Parameter | Type | Required | Description | |-----------|--------|----------|----------------------------------------------------------------------------------| | `confirm` | string | Yes | Must match either the share's `custom_name` (case-insensitive) or its numeric `id`. Send as a query-string parameter (`?confirm=...`) — a request body is not read on this `DELETE`. Safety check. | ### Request Example ```bash curl -X DELETE "https://api.fast.io/current/share/1234567890123456789/delete/?confirm=my-share-name" \ -H "Authorization: Bearer {jwt_token}" ``` ### Response **Status:** `202 Accepted` ```json { "result": true } ``` ### Error Responses | HTTP Status | Code | Message | Cause | |-------------|--------------------------|----------------------------------------------------------|------------------------------------| | 406 | 130987 | "The `confirm` field is required..." | `confirm` not supplied as a query parameter | | 406 | 10571 | "The `confirm` field provided does not match the share's `custom_name` or `id`." | Confirmation mismatch | | 401 | 144499 | "You do not have permissions to access this share." | Lacks admin permission | | 500 | `1663 (Update Failed)` | "There was an internal error processing your request." | Internal error | ### Notes - Soft delete with retention period before permanent removal. - For workspace folder shares, the share reference in the backing folder is cleaned up. - Share link nodes in the parent workspace are also removed. --- ## Archive / Unarchive ``` POST /current/share/{share_id}/archive/ POST /current/share/{share_id}/unarchive/ ``` Archive or unarchive a share. **Auth required. Owner/admin only.** Portals only -- workspace folder shares cannot be archived. No request body required. ### Request Example ```bash curl -X POST "https://api.fast.io/current/share/1234567890123456789/archive/" \ -H "Authorization: Bearer {jwt_token}" ``` ### Response **Status:** `202 Accepted` ```json { "result": true } ``` ### Error Responses | HTTP Status | Code | Message | Cause | |-------------|--------------------------|---------------------------------------------------------------|------------------------------------------| | 406 | `1605 (Invalid Input)`| "Workspace folder shares cannot be archived..." | workspace_folder share | | 500 | `1663 (Update Failed)` | "The share is already archived." / "The share is not archived."| Wrong current state | | 401 | 144499 | "You do not have permissions to access this share." | Lacks admin permission | ### Notes - Archived shares block guest access. - Unarchiving requires the share unarchive feature to be enabled on the billing plan. --- ## Password Protection Available only on shares with `'Anyone with the link'` access. ### Set/Update Password Set via `password` parameter on create or update (4-128 chars). Send `"null"` or `""` to clear. ### Authenticate with Password ``` POST /current/share/{share_id}/auth/password/ ``` Authenticate with a share password to get a scoped JWT token. **No user auth required.** #### Request Body | Parameter | Type | Required | Description | |------------|--------|----------|-------------------| | `password` | string | Yes | Share password | #### Request Example ```bash curl -X POST "https://api.fast.io/current/share/1234567890123456789/auth/password/" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "password=mysecretpassword" ``` #### Response **Status:** `200 OK` ```json { "result": true, "expires_in": 86400, "auth_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` #### Response Fields | Field | Type | Description | |------------------------|---------|-------------------------------------------------| | `expires_in` | integer | Token lifetime in seconds (86400 = 24 hours) | | `auth_token` | string | JWT token to use in `x-ve-password` header | #### Error Responses | HTTP Status | Code | Message | Cause | |-------------|---------------------|------------------------------------------------------------|----------------------------------------| | 406 | `1605 (Invalid Input)` | "Password is required for authentication." | Missing password field | | 401 | `1650 (Authentication Invalid)` | "This share does not require password authentication." | Not password-protected or not public | | 406 | `1658 (Not Acceptable)`| "Invalid password provided for this share." | Wrong password | | 429 | `10776` | "Too many failed password attempts for this share. Try again in N minutes." | Too many recent wrong passwords for this share — a temporary, self-clearing pause | | 500 | `1654 (Internal Error)`| "Failed to create authentication token." | JWT creation failure | #### Notes - Token expires after 24 hours. - If the share's password is changed, all previously issued tokens become invalid. - Use the returned token in the `x-ve-password` header for subsequent share requests. - **Repeated wrong passwords temporarily block password entry for the share.** While the block lasts, every attempt on that share is refused — even with the correct password — with `429`, `error.code` `10776`, and `error.params.retry_after_seconds`, the number of seconds until attempts are accepted again. Wait that long before retrying; the block clears by itself. A correct password clears the failure count. --- ## Anonymous File Drop (Guest Auth) Enables anonymous file uploads on public Receive and Exchange shares without requiring account registration. Visitors obtain a scoped JWT via the guest auth endpoint, then use it as a Bearer token for subsequent upload requests. **Requirements:** - Share must have `'Anyone with the link'` access - Share must be Receive or Exchange type - `anonymous_uploads_enabled` must be true on the share - Premium plan required ### Authenticate as Guest ``` POST /current/share/{share_id}/auth/guest/ ``` Obtain a scoped JWT for anonymous file uploads. Creates an ephemeral user account. **No user auth required.** #### Request No body required. POST request only. #### Request Example ```bash curl -X POST "https://api.fast.io/current/share/1234567890123456789/auth/guest/" ``` #### Response **Status:** `200 OK` ```json { "result": true, "auth_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "expires_in": 86400 } ``` #### Response Fields | Field | Type | Description | |------------------------|---------|------------------------------------------------- | | `auth_token` | string | Scoped JWT for anonymous uploads (Bearer token) | | `expires_in` | integer | Seconds until `auth_token` expires. A newly issued token reports 86400 (24 hours); when the request's existing guest token for this share is returned instead, this is that token's remaining lifetime | #### Error Responses | HTTP Status | Code | Message | Cause | |-------------|--------------------------|-------------------------------------------------------------------|---------------------------------------------------| | 401 | `1650 (Authentication Invalid)` | "Anonymous uploads are not enabled for this share." | Feature not enabled or share not eligible | | 412 | `1685 (Feature Limit)` | "Anonymous uploads require a premium plan." | Org not on premium plan | | 429 | `1671 (Rate Limited)` | Rate limit exceeded | Too many requests from this IP | | 503 | `199934` | "The plan that governs this share could not be determined. Please try again." | The plan behind the share could not be read. **Transient — retry.** It is deliberately not the "requires a premium plan" refusal above: that one is a settled answer, this one means we do not yet know | | 500 | `1654 (Internal Error)` | "Failed to create authentication token." | JWT creation failure | #### Notes - The returned `auth_token` should be used as a Bearer token in the `Authorization` header for subsequent upload requests. - Each call creates a new ephemeral user account scoped to the share, unless the request carries (as its `Authorization: Bearer` header) a guest token already issued for this share with more than an hour left — that same token is returned instead. - Tokens are short-lived; obtain a new token if the previous one expires. ### Anonymous Upload Workflow 1. **Check share eligibility:** `GET /current/share/{share_id}/public/details/` — verify `anonymous_uploads_enabled: true` in the response. 2. **Obtain guest token:** `POST /current/share/{share_id}/auth/guest/` — returns a scoped `auth_token`. 3. **Upload files:** Use the standard upload flow (`POST /current/upload/` to create a session, `POST /current/upload/{upload_id}/chunk/` per chunk, `POST /current/upload/{upload_id}/complete/`) with `Authorization: Bearer {auth_token}`. Create the session with `action=create`, `instance_id={share_id}` and `folder_id` to place the file in the share directly. 4. **Add to share (only if the session had no target):** `POST /current/share/{share_id}/storage/{folder_id}/addfile/` with `name` and `from={"type":"upload","upload":{"id":"{upload_id}"}}`, using the same Bearer token. --- ## Expiration Available only on **portal** shares (not workspace folder). Set the `expires` parameter (datetime format: `YYYY-MM-DD HH:MM:SS`) on create or update. Send `"null"` to clear. When the share expires, access is revoked. Expiration is validated against the billing plan. --- ## Branding & Styling Shares support custom branding through the update endpoint: | Parameter | Type | Description | |---------------------|---------|--------------------------------------| | `accent_color` | string | JSON color object for accent | | `background_color1` | string | JSON color object for primary bg | | `background_color2` | string | JSON color object for secondary bg | | `background_image` | integer | Background image selection (numeric) | | `link_1` | string | JSON link object (custom link #1) | | `link_2` | string | JSON link object (custom link #2) | | `link_3` | string | JSON link object (custom link #3) | Send `"null"` for any JSON field to clear it. --- ## Share Assets ### List Available Asset Types ``` GET /current/share/assets/ ``` Returns the schema of available asset types (e.g., logo, background). **No auth required.** ### List Share Assets ``` GET /current/share/{share_id}/assets/ ``` List assets set on a share. **Auth required. Share members (member role and above) only.** ### Upload/Set Asset ``` POST /current/share/{share_id}/assets/{asset_id}/ ``` Upload an asset (multipart/form-data). **Auth required. Share members (member role and above) only.** | Field | Type | Required | Description | |------------|------|----------|----------------------------------| | `file` | file | Yes | The asset file to upload | | `metadata` | string (JSON object) | No | Optional metadata for the asset, sent as a JSON object string (e.g. `{}`). Invalid JSON or a non-object value is refused with 406. | #### Request Example ```bash curl -X POST "https://api.fast.io/current/share/1234567890123456789/assets/logo/" \ -H "Authorization: Bearer {jwt_token}" \ -F "file=@logo.png" \ -F 'metadata={"crop_x": 0, "crop_y": 0}' ``` ### Delete Asset ``` DELETE /current/share/{share_id}/assets/{asset_id}/ ``` Remove an asset. **Auth required. Share members (member role and above) only.** ### Read Asset Binary ``` GET /current/share/{share_id}/assets/{asset_id}/read/ HEAD /current/share/{share_id}/assets/{asset_id}/read/ ``` Returns raw binary data of a share asset. **Auth optional for public shares.** Works on archived shares. | Query Parameter | Type | Required | Description | |-----------------|---------|----------|-------------------------------------------------------| | `width` | integer | No | Requested width (max 4096). Background images only. | | `height` | integer | No | Requested height (max 4096). Background images only. | --- ## Share Members ### Permission Levels | Level | Value | Description | |--------|-------|---------------------------------------------| | Owner | 1000 | Full control and ownership | | Admin | 500 | Administrative access, manage members | | Member | 100 | Standard member access (default for new) | | Guest | 50 | Limited guest access | | View | 20 | Read-only access | ### Notification Options | Value | Description | |------------------------------------------------|---------------------------------------------| | `"Do not notify me"` | No notifications | | `"Notify me in app"` | In-app only (default) | | `"Notify me in app and via email"` | In-app and email | | `"Notify me via app, email and text message"` | All notification channels | ### Add Member or Send Invitation ``` POST /current/share/{share_id}/members/{email_or_user_id}/ ``` Use a 19-digit user ID to add an existing user directly, or an email address to send an invitation. An existing user who is newly added is sent a notification email with a direct link to the share. **Auth required.** Share members (member role and above) may manage members; guests may too when the share's `invite` setting is `guests`. You cannot add or invite anyone at a role above your own. The parameters are **form fields** (`application/x-www-form-urlencoded` or `multipart/form-data`); a JSON request body is refused with a 406 input error. An omitted `permissions` means `member` for a new membership (re-adding a current (unexpired) member without it keeps their role; an expired membership is restored as `member`), so a caller below member (a guest) must send `permissions=guest` (or `view`); when it is omitted for someone already a member, your role must be at least their current role. 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 Body (Adding Existing User) | Field | Type | Required | Default | Description | |---------------------|--------|----------|----------------------|--------------------------------------------------------------------------------| | `permissions` | string | No | `member` | `admin`, `member`, `guest`, `view`. Cannot be `owner` (refused with a 406; use Transfer Ownership). Omit for the `member` default on a new member (omitting it for a current (unexpired) member keeps their role; an expired membership is restored as `member`); any other value (including `any`) is rejected with a 406 input error. | | `notify_options` | string | No | `"Notify me in app"` | Notification preference | | `expires` | string | No | - | Membership expiration (`YYYY-MM-DD HH:MM:SS UTC`). `null` or `""` to clear. | | `notification` | string | No | - | Send `force` to resend the notification email to an existing member (skipped within 60 seconds of the initial add). | #### Request Body (Inviting by Email) | Field | Type | Required | Default | Description | |----------------------|--------|----------|----------|--------------------------------------------| | `permissions` | string | No | `member` | Permission level for the invitation (same values as above; an invalid value is rejected with a 406 input error) | | `message` | string | No | - | Custom message for the invitation email | | `invitation_expires` | string | No | - | Invitation expiration datetime | #### Request Example ```bash # Add existing user by ID curl -X POST "https://api.fast.io/current/share/1234567890123456789/members/9876543210987654321/" \ -H "Authorization: Bearer {jwt_token}" \ -d "permissions=member" # Invite by email curl -X POST "https://api.fast.io/current/share/1234567890123456789/members/newuser@example.com/" \ -H "Authorization: Bearer {jwt_token}" \ -d "permissions=guest" \ --data-urlencode 'message=Join our shared files!' ``` #### Response (Invitation Created) ```json { "result": true, "invitation": { "id": "aea3w-cuan6-edcu5-vkaex-g52gm-dacr", "inviter": "Jane Smith", "inviter_actor": { "user_id": "9876543210987654321", "kind": "human", "agent_name": null, "name_source": null, "credential_type": "session", "verified": false }, "invitee_email": "newuser@example.com", "invitee_uid": null, "accepted_uid": null, "entity_type": "share", "share": { "id": "1234567890123456789", "name": "Project Files" }, "state": "pending", "consumed": false, "created": "2025-01-15 10:30:00 UTC", "updated": "2025-01-15 10:30:00 UTC", "expires": "2025-02-15 10:30:00 UTC" } } ``` The `share` object is abbreviated here; it is the full share resource. `inviter_actor` says who sent the invitation and whether an agent acted for them (`user_id`, `kind`, `agent_name`, `name_source`, `credential_type`, `verified`); the invitation email names the agent the same way (e.g. "Dobby (via API key) for Jane"). Any `agent_name` other than Fastio's own verified agent is self-declared. Full reference: *Actor Attribution* in the Storage reference. #### Error Responses | HTTP Status | Code | Message | Cause | |-------------|--------------------------|--------------------------------------------------------------------------------|--------------------------------| | 406 | `1692 (Cannot Add As Owner)`| "Adding a member as an owner is not allowed" | Tried to add as owner | | 406 | `1605 (Invalid Input)`| "Invalid permissions value. Valid values are: admin, member, guest, view." | `permissions` is not one of those role names | | 406 | `1605 (Invalid Input)`| "This endpoint does not accept a JSON request body..." | JSON request body sent | | 406 | `1605 (Invalid Input)`| "You cannot create a membership for the share owner." | Target is share owner | | 406 | `1605 (Invalid Input)`| "You cannot create a membership for yourself." | Target is the caller | | 406 | `1605 (Invalid Input)`| "You cannot add, update, or delete a membership with a higher permission..." | Permission exceeds caller | | 401 | `1680 (Access Denied)` | "You do not have permission to manage the members of this Share." | Lacks member edit permission | | 412 | `1685 (Feature Limit)` | "Share invitation limit reached: X of Y invitations used" | Invitation quota exceeded | | 403 | — | Reason-carrying refusal (`params.reason`) | The invitee is external and the org's collaboration policy (or this share's own `external_invites` flag) denies the inviter. `reason` = `external_invites_denied` or `external_invites_object_denied`. Applies to a fresh invite, a resend, a direct add of an existing account, and a permission change or expiry extension that broadens an existing membership; a pure permission reduction is never gated. See *Collaboration Policies* in `llms/orgs.txt`. | ### Remove Member ``` DELETE /current/share/{share_id}/members/{user_id}/ ``` Remove a member. **Auth required.** Same caller rule as Add Member; you cannot remove someone whose role is above your own, and the owner cannot be removed (use Transfer Ownership). ### Leave Share (Self-Removal) ``` DELETE /current/share/{share_id}/member/ ``` Leave a share. **Auth required.** Owners cannot leave; must transfer ownership first. Works on archived/expired shares. ### Member Details ``` GET /current/share/{share_id}/member/{user_id}/details/ ``` Get detailed membership info for a specific user. **Auth required.** #### Response ```json { "result": true, "user": { "id": "9876543210987654321", "account_type": "human", "email_address": "member@example.com", "first_name": "John", "last_name": "Doe", "permissions": "admin", "status": "active", "member_added_at": "2026-08-29 15:48:29 UTC" } } ``` `member_added_at` is when this membership was created (`YYYY-MM-DD HH:MM:SS UTC`). 🔴 **The key is absent, not null, when you are not allowed to see it** — it is emitted only to the member themselves or to an admin-or-above of this share. A peer member gets no `member_added_at` key at all, so read it with a presence check on the key rather than a null check. The same applies here and in List Members to `notify` (present only on your own membership), `expires` and `invite` (omitted when unset). ### Update Member ``` POST /current/share/{share_id}/member/{user_id}/update/ ``` Update permissions, notification settings, or expiration. **Auth required.** Same caller rule as Add Member; you cannot set a role above your own. Form fields only — a JSON request body is refused with a 406 input error. | Field | Type | Required | Description | |-----------------|--------|----------|---------------------------------------------------------------| | `permissions` | string | No | `admin`, `member`, `guest`, `view`. An unrecognised value is rejected with a 406 input error. `owner` is ignored (use Transfer Ownership). | | `notify_options`| string | No | Notification preference; omitted leaves it unchanged | | `expires` | string | No | Membership expiration datetime. `null`/`""` to clear. | ### Transfer Ownership ``` POST /current/share/{share_id}/member/{user_id}/transfer_ownership/ ``` **POST only** — `GET`, `HEAD`, and every other method return 405. Transfer share ownership to another member, who must be an enabled member of the share. **Auth required. Owner only.** Current owner is demoted to admin. The share's parent workspace is never changed. **Fixed behaviour:** the successor is promoted before the current owner is demoted, under a per-share lock. **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 share returns 409 `transfer_in_progress`. No email is sent — this is recorded as an event only. **Response (200 OK):** ```json { "result": true, "ownership": { "profile_id": "5123456789012345678", "profile_type": "share", "previous_owner": "1111111111111111111", "new_owner": "9876543210987654321", "transferred_at": "2026-09-23 16:37:29 UTC" } } ``` **Errors (`error.params.reason`):** 406 `successor_is_self`; 406 `successor_not_member` (not a member, or their membership was removed or has expired); 406 `successor_unavailable` (closed, suspended, or locked — an invited member who has not yet claimed an account is **not** refused here); 409 `transfer_in_progress`; 401 "You must be the owner to transfer ownership of this share." (not the owner) or "You are no longer the owner of this share." (ownership changed while the call was waiting); 403 `scope_admin_required` (the credential is not admin-capable on this share); 500 the transfer did not finish — repeat with the same target; 503 retry. **Event:** `ownership_transferred` (audit log; `profile_type`, `from_user`, `to_user`). The two existing `membership_updated` events (promotion and demotion) still fire. ### List Members ``` GET /current/share/{share_id}/members/list/ ``` List all members. **Auth required.** #### Response ```json { "result": true, "users": [ { "id": "1111111111111111111", "account_type": "human", "email_address": "owner@example.com", "first_name": "Jane", "last_name": "Smith", "permissions": "owner", "status": "active" }, { "id": "5566778899001122334", "account_type": "human", "email_address": "invited@example.com", "first_name": "invited@example.com", "last_name": "", "permissions": "guest", "status": "pending", "invite": { "id": "aea3wcuan6edcu5vkaexg52gmdacr", "created": "2025-01-15 10:30:00", "expires": "2025-01-18 10:30:00" } } ] } ``` ### Pending Members When a user is invited to a share by email but does not yet have a Fastio account, they appear as a **pending member** in the member list. Pending members are placeholders that reserve a seat for the invitee before they sign up. **How pending members appear in responses:** - The `status` field is `"pending"` (vs `"active"` for registered users). - An `invite` object is included with basic invitation details: `id` (the invitation ID, unhyphenated), `created`, and `expires` (the acceptance deadline, or null). It is a snapshot taken when the invitation was sent, with timestamps as `YYYY-MM-DD HH:MM:SS` (UTC, no suffix). - `email_address` shows the invited email address. - `first_name` is set to the invited email address; `last_name` is empty. **Account claim:** When the invited user signs up or accepts the invitation with an existing account, their status transitions from `"pending"` to `"active"`. Their share access is preserved across the transition. **Removal:** To remove a pending member, delete their invitation using the invitation endpoints (see List Invitations, Delete Invitation below). Deleting the invitation removes the pending member. **Notifications:** Pending members do not receive in-app or email notifications until they claim their account. --- ### Join Share ``` POST /current/share/{share_id}/members/join/ POST /current/share/{share_id}/members/join/{invitation_key}/ POST /current/share/{share_id}/members/join/{invitation_key}/accept/ POST /current/share/{share_id}/members/join/{invitation_key}/decline/ ``` Join a share via self-join or invitation. **Auth required.** **Self-join rules** (without invitation key): | Access Option | Who Can Self-Join | |-------------------------------------------------|---------------------------------------------------------------| | `'Only members of the Share or Workspace'` | Members of the share or its parent workspace | | `'Members of the Share, Workspace or Org'` | Members of the share, parent workspace, or parent org | | Other values | Self-join blocked; invitation required | A new self-joined member receives `member` permission with no expiration; a current (unexpired) member who self-joins keeps their role; an expired membership is restored as `member`. Only `notify_options` is read from input; `permissions` is ignored. **Accepting an invitation is re-checked against the collaboration policy at redemption**, not only at issuance — the org policy or the inviter's own standing can have changed since the invite went out. A refusal is HTTP 403 with `params.reason` = `external_invites_denied` or `external_invites_object_denied` and leaves the invitation **pending** (never failed) so the invitee can retry later; **decline is never gated**. See *Collaboration Policies* in `llms/orgs.txt`. ### List Invitations ``` GET /current/share/{share_id}/members/invitations/list/ GET /current/share/{share_id}/members/invitations/list/{state}/ ``` List invitations, optionally filtered by state (`pending`, `accepted`, `declined`). **Auth required. Share admin or above.** Returns `{"result": true, "invitations": [...]}`; each entry has the fields of the invitation object above (`id`, `inviter`, `inviter_actor`, `invitee_email`, `invitee_uid`, `accepted_uid`, `entity_type`, `state`, `consumed`, `created`, `updated`, `expires`) without the `share` object. `invitee_uid` and `accepted_uid` are 19-digit user IDs as strings, or null. ### Update Invitation ``` POST /current/share/{share_id}/members/invitation/{invitation_id}/ ``` Update an invitation. `{invitation_id}` can be an invitation ID or an email address; the invitation must belong to this share, otherwise the request fails with 406 exactly as an unknown invitation does ("Invalid invitation id or Invitation not found." for an ID). **Auth required. Share admin or above.** Form fields only — a JSON request body is refused with a 406 input error. | Field | Type | Required | Description | |-----------------|--------|----------|------------------------------------------------------------------| | `state` | string | No | `pending`, `accepted`, `declined` | | `permissions` | string | No | The role granted when the invitation is accepted: `admin`, `member`, `guest`, `view`. A new value cannot be above your own role and cannot be `owner`; such a request returns an error (406 `127022` / 406 `1692`) and leaves the invitation unchanged | | `notify_options`| string | No | Notification preference applied on acceptance | | `expires` | string | No | 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 | ### Delete Invitation ``` DELETE /current/share/{share_id}/members/invitation/{invitation_id}/ ``` Revoke an invitation. `{invitation_id}` can be an invitation ID or an email address; the invitation must belong to this share, otherwise the request fails with 406 exactly as an unknown invitation does ("Invalid invitation id or Invitation not found." for an ID). **Auth required. Share admin or above.** --- ## Share Discovery ### List All Shares ``` GET /current/shares/all/ ``` List all accessible shares (joined, invited, owned). **Auth required.** Orphaned workspace folder shares are automatically filtered out. Returns full permission and settings objects for each share. Paginated with `limit` (default 100, max 500) and `offset`; the response carries a `pagination` object (`total`, `limit`, `offset`, `has_more`). #### Response Fields Each share in the `shares` array includes the same fields as the details endpoint, plus: | Field | Type | Description | |-----------------------------------|-------------|------------------------------------------------------| | `shares[].user_status` | string | `joined` (you hold a direct membership on the share) or `available` (you can reach it without one, e.g. through its workspace) | | `shares[].folder_node_id` | string\|null| Backing folder node ID (workspace_folder only) | | `shares[].is_orphaned` | boolean | Whether backing folder was deleted | ### List Available Shares ``` GET /current/shares/available/ ``` List shares the user has joined or owns (excludes pending invitations). **Auth required.** Does not include parent workspace/org information. ### Check Share Name Availability ``` GET /current/shares/check/name/{name}/ ``` Check if a share custom name is available. **Auth required.** **Status `202 Accepted`** = name available. | HTTP Status | Code | Message | Cause | |-------------|--------------------------|--------------------------------------------------|--------------------| | 406 | `1605 (Invalid Input)`| "An invalid share name was supplied." | Fails validation | | 406 | `1658 (Not Acceptable)` | "The supplied share name is already in use." | Name already taken | ### List Shares in Workspace ``` GET /current/workspace/{workspace_id}/list/shares/ ``` List shares belonging to a workspace. **Auth required.** Paginated with offset-based pagination (`limit`/`offset`). | Query Parameter | Type | Required | Description | |-----------------|--------|----------|------------------------------------------| | `limit` | integer| No | Number of results to return | | `offset` | integer| No | Number of results to skip | | `archived` | string | No | Boolean string. Filter by archived state.| ### List User's Shares ``` GET /current/user/me/list/shares/ ``` List all shares accessible to the current user (owned, joined, invited). **Auth required.** Paginated (`limit`/`offset`). Includes parent workspace and org information. | Query Parameter | Type | Required | Description | |-----------------|--------|----------|------------------------------------------| | `limit` | integer| No | Number of results to return | | `offset` | integer| No | Number of results to skip | | `archived` | string | No | Boolean string. Filter by archived state.| --- ## Import Share into Workspace ``` POST /current/workspace/{workspace_id}/import/share/{share_id}/ ``` Import a user-owned share into a workspace. **Auth required.** Caller must be the share owner with no other co-owners. The share must currently be user-owned (not already in a workspace). Archived shares are automatically unarchived during import. ### Error Responses | HTTP Status | Code | Message | Cause | |-------------|--------------------------|---------------------------------------------------------------------|----------------------------------| | 406 | `1605 (Invalid Input)`| "This share is not owned by you and cannot be imported." | Share not user-owned | | 406 | `1605 (Invalid Input)`| "The share has multiple owners..." | Multiple co-owners exist | | 412 | `1685 (Feature Limit)` | "The workspace has reached its share limit." | Share quota exceeded | | 500 | `1654 (Internal Error)` | "Failed to import the share to the workspace." | Internal error | --- ## Share AI Shares support AI features when `intelligence` is enabled. Enabling `intelligence` and creating or sending chat messages require plan features (`content_ai` plus `ai_agent` for write/agentic flows). See the [AI reference](https://api.fast.io/current/llms/ai/#plan-requirements) for the full plan matrix. ### Auto-Generate OG Image ``` GET /current/share/{share_id}/ai/autoog/ ``` Returns the share's Open Graph image (binary image response, not JSON). **No auth needed** for shares with `'Anyone with the link'` or `'Anyone with a registered account'` access; every other share returns a generic placeholder image. ### Auto-Generate Title & Description ``` POST /current/share/{share_id}/ai/autotitle/ ``` Generate a title and description for the share based on its contents. **Auth required.** ### AI Agent (Ripley) Share AI follows the same agent thread/turn model as workspace AI. Replace `/workspace/{id}` with `/share/{id}` in all agent endpoints; the request and response shapes are identical, with one exception: message details (`.../message/{message_id}/details/`) returns the turn under a `turn` key on shares, where the workspace endpoint uses `message`. For the full request/response contract (thread objects, turn objects, streaming events, plan requirements), see the [AI reference](https://api.fast.io/current/llms/ai/) — the shapes are defined there and are not duplicated here. **Endpoints:** ``` POST /current/share/{share_id}/ai/agent/ -- create thread GET /current/share/{share_id}/ai/agent/list/ -- list threads GET /current/share/{share_id}/ai/agent/{thread_id}/details/ -- thread details POST /current/share/{share_id}/ai/agent/{thread_id}/update/ -- update thread DELETE /current/share/{share_id}/ai/agent/{thread_id}/ -- delete thread POST /current/share/{share_id}/ai/agent/{thread_id}/message/ -- send message (start a turn) POST /current/share/{share_id}/ai/agent/{thread_id}/cancel/ -- cancel the in-flight turn GET /current/share/{share_id}/ai/agent/{thread_id}/messages/list/ -- list messages GET /current/share/{share_id}/ai/agent/{thread_id}/message/{msg_id}/details/ -- message details GET /current/share/{share_id}/ai/agent/{thread_id}/message/{msg_id}/read/ -- stream response (SSE) POST /current/share/{share_id}/ai/agent/{thread_id}/publish/ -- publish thread (currently disabled platform-wide -- returns 403) POST /current/share/{share_id}/ai/share/ -- AI share markdown ``` --- ## QuickShare (Workspace Feature) > **Deprecated — use File Share.** Creating or extending a QuickShare now returns **403** with a directed message pointing to `POST /current/workspace/{workspace_id}/create/fileshare/`. The durable **File Share** (see the next section) replaces it. Existing QuickShare links can still be viewed, downloaded, and revoked during the drain; the public read endpoints below remain live until the existing population reaches its natural expiry. QuickShare creates a temporary public link for a single file. This is a **workspace** storage feature, not a share management feature, but is related to sharing. ### Create QuickShare (deprecated → 403) ``` POST /current/workspace/{workspace_id}/storage/{node_id}/quickshare/ ``` **Deprecated.** This endpoint returns **403** (`10756 (Quickshare Deprecated)`) for the create and extend-expiry paths. Use `POST /current/workspace/{workspace_id}/create/fileshare/` instead. The historical constraints (single file only, max 1 GB, default expiration 3 hours, maximum 7 days, `expires` / `expires_at` parameters) no longer apply because creation is closed. ### Public Access Endpoints (No Auth Required) #### QuickShare Details ``` GET /current/quickshare/{opaque_id}/details/ ``` Returns metadata including file information, creator, expiration, and download limit status. **Response:** ```json { "result": true, "quickshare": { "id": "a2qtn-6pa4l-5b4vn-gwruj-ibehh-xq4y", "node": { "id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "type": "file", "name": "presentation.pdf", "size": 5242880, "mimetype": "application/pdf", "mimecategory": "document", "summary": {"title": "Quarterly Presentation", "short": "Q4 slides overview"}, "metadata": {"title": "Custom Title", "short": "Custom description"} }, "creator_uid": { "id": "9876543210987654321", "account_type": "human", "first_name": "Jane", "last_name": "Doe" }, "limit_exceeded": false, "expires": "2024-01-15 13:30:00 UTC", "created": "2024-01-15 10:30:00 UTC" } } ``` #### Download File ``` GET /current/quickshare/{opaque_id}/storage/read/ ``` Returns raw binary file content with `Content-Type` and `Content-Disposition` headers. **Transfer Limits:** - Max total bytes: 10 GB - Max multiplier: 100x file size - Once either limit is reached, `limit_exceeded` is set permanently. #### Read Note Content ``` GET /current/quickshare/{opaque_id}/storage/readnote/ ``` Returns the content of a note/markdown file as JSON. Unlike the binary `/read/` endpoint, this returns the sanitized markdown content as a string within the JSON response along with the note resource. **Response:** ```json { "result": true, "content": "# Note content here\n\nMarkdown text...", "note": { "id": "{opaque_note_id}", "type": "note", "name": "my-note.md" } } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `content` | string | Sanitized markdown content | | `note` | object | Note resource (unpermissioned format) | **Error responses:** | HTTP Status | Code | Cause | |-------------|------|-------| | 406 | `1605 (Invalid Input)` | Node is not a note | | 404 | `1609 (Not Found)` | Quickshare not found, or note is in trash | | 450 | `1694 (Bandwidth Limit)` | Transfer limit reached | | 401 | `1680 (Access Denied)` | File access denied (virus/DMCA/locked) | #### Preview File ``` GET /current/quickshare/{opaque_id}/storage/preview/{preview_type}/read/ GET /current/quickshare/{opaque_id}/storage/preview/{preview_type}/read/file/{filename} ``` Preview types: `bin`, `thumbnail`, `image`, `hlsstream`, `pdf`, `spreadsheet`, `audio`, `mp4`. **Preview Limits:** Max 20x file size for total preview bytes. Multi-file previews (e.g., HLS) return a `307 Temporary Redirect` to a sub-file endpoint. #### QuickShare Error Responses | HTTP Status | Code | Message | Cause | |-------------|----------------------|------------------------------------------------------------------------|------------------------| | 406 | `1605 (Invalid Input)` | Validation error | Invalid opaque ID | | 404 | `1609 (Not Found)`| "Quickshare not found." | No such QuickShare | | 450 | `1694 (Bandwidth Limit)`| "You have exceeded the bandwidth policy for this Quickshare." | Transfer limit reached | | 401 | `1680 (Access Denied)` | "You have reached the maximum number of previews for this Quickshare." | Preview limit reached | | 422 | `1698 (Unprocessable Entity)` | "The source file is corrupt or unreadable and cannot be previewed." | Source is corrupt/truncated; do not retry | --- ## File Share (Durable Single-File Share) A **File Share** is the durable successor to QuickShare: a long-lived, link-shareable view of **one file** from a workspace. Unlike QuickShare it is durable by default (expiry is optional, never forced) and has no per-link transfer cap — bandwidth is metered to the owning organization like normal storage. A File Share is bound to a single workspace file node at creation; the binding is immutable (rebinding means creating a new File Share). **Addressing — opaque id.** A File Share is addressed by an **opaque public id** (the `id_alt` field): an unguessable, non-enumerable handle (the same id shape used for nodes and uploads), which appears in its share links. Every File Share route — public read and management alike — accepts this opaque id; the legacy numeric profile id is also accepted during the transition, so existing links keep working. Build links from `id_alt`. ### Access Tiers Set with `access_option` at create/update time: | Tier | Value | Who can open the link | |------|-------|-----------------------| | Anyone with link | `anyone_with_link` | Anyone, no account required (anonymous) | | Any registered | `any_registered` | Any authenticated Fastio user | | Named people | `named_people` | Only users explicitly granted access (the **default**) | On top of the tier, **per-user grants** raise an individual user's capability — `view`, `download`, or `edit`. An optional **link password** can gate any tier; it is supplied on the public read endpoints via the **`x-ve-password` request header**, never in the URL. **Workspace members (implicit access).** The shared file lives in the File Share's parent workspace, so an authenticated **member of that workspace** reaches it through the File Share exactly as they would through the workspace itself — they are admitted **regardless of tier or named grant**, at the capability their **workspace role** confers on the file (a workspace editor gets `edit`, a view-only member gets `view`). This is why the owner/creator and other workspace members can view — and collaboratively **edit notes on** — their own `named_people` share without granting themselves. A set **link password still applies** to members. Anonymous callers and non-members are governed solely by the tier + grant rules above. ### Public Read Endpoints These serve the link viewer. They are anonymous-allowed where the access tier permits (e.g. `anyone_with_link`); for `any_registered` / `named_people` the caller must present a bearer token and have sufficient access. A link password, if set, is presented via the `x-ve-password` header. #### File Share Details ``` GET /current/fileshare/{fileshare_id}/details/ ``` Returns viewer metadata and the bound file's full node detail under `file` — the same node shape the workspace file view returns. Metadata extracted from inside the file bytes (EXIF / media container) is included only when the caller holds `download` or higher. `comments_enabled` is the effective value for this view: always `false` on an `anyone_with_link` File Share. The `effective_capability` field is the single highest capability **the calling identity** actually holds on this File Share — `view`, `download`, or `edit` — resolved after the access tier, any per-user grant, and the link password are all applied. Use it to render the right controls up front (show the Download button only at `download` or higher; show "Upload new version" only at `edit`) instead of attempting an action and handling a later denial. Because the details response is only returned once the caller has passed the read gate, the value is present and at least `view` on the normal path; it is omitted only in the rare case the capability probe itself faults — treat an absent value as "unknown" and fall back to attempting the action. **Manage context (authenticated callers only).** The details response additionally carries an optional manage block that lets an owner's UI decide whether to surface in-place management controls without a separate lookup: - `can_manage` (boolean) — `true` when the **authenticated** caller may manage this File Share (a member of its parent workspace, the same requirement the management endpoints enforce), `false` when authenticated but not a manager. It is **omitted entirely for anonymous callers**, so the public link stays anonymous. - `workspace_id` (string) — the owning workspace id, returned **only when `can_manage` is `true`**. Pass it to the workspace-scoped management endpoints (list/update/grants/delete). A non-manager never receives it. - `creator_uid` (string) — the File Share creator's user id, returned **only when `can_manage` is `true`**. These fields are additive — anonymous viewers and clients that ignore them are unaffected. **Response (authenticated manager):** ```json { "result": true, "fileshare": { "fileshare": "1234567890123456789", "id_alt": "adheih5r326qjiqvk4wfamvt4rqeh", "title": "Quarterly Presentation", "access_option": "anyone_with_link", "has_password": false, "comments_enabled": false, "effective_capability": "download", "can_manage": true, "workspace_id": "9876543210987654321", "creator_uid": "1122334455667788990", "file": { "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "type": "file", "name": "presentation.pdf", "size": 5242880, "mimetype": "application/pdf" } } } ``` For an anonymous viewer the three manage fields are absent; for an authenticated non-manager only `can_manage: false` is present (no `workspace_id` / `creator_uid`). #### Download the Bound File ``` GET /current/fileshare/{fileshare_id}/storage/read/ ``` Returns raw binary content with `Content-Type` and `Content-Disposition` headers. Requires the `download` capability (the `view`-only level does not download). #### Preview the Bound File ``` GET /current/fileshare/{fileshare_id}/storage/preview/{preview_type}/read/ GET /current/fileshare/{fileshare_id}/storage/preview/{preview_type}/read/{download_token}/file/{filename} ``` Preview types match the storage preview surface (e.g. `thumbnail`, `image`, `pdf`, `mp4`, `hlsstream`). Requires the `view` capability. Multi-file previews (e.g. HLS) return a `307 Temporary Redirect` to a sub-file endpoint. #### Version History ``` GET /current/fileshare/{fileshare_id}/storage/versions/ GET /current/fileshare/{fileshare_id}/storage/versions/{version_id}/read/ ``` List the bound file's versions (`view` capability) and download a specific historical version (`download` capability). Version listing is read-only — there is no restore/promote on the public surface. ### Management Endpoints (Authenticated Workspace Member) Creating, listing, updating, deleting, and managing grants are documented in the **Workspaces** reference (`POST /current/workspace/{workspace_id}/create/fileshare/`, `GET /current/workspace/{workspace_id}/list/fileshares/`, and the `POST|PATCH /current/fileshare/{id}/update/`, `DELETE /current/fileshare/{id}/delete/`, `GET|POST|DELETE /current/fileshare/{id}/grants/` endpoints). All require the caller to be an authenticated **member of the File Share's parent workspace**. ### External Edit (Write-Back) A holder of an **`edit`** grant can replace the shared file's content without being a workspace member, by targeting the File Share id as the update target of a normal upload session. This is covered in the **Upload** reference (`action=update`, `instance_id={fileshare_id}`, `file_id={bound_node_id}`), including the optional `if_version_id` compare-and-swap precondition and how a version conflict is surfaced. ### Collaborative Notes (Realtime) When the bound file is a **note** (a `.md` text document), an authorized recipient can read and collaboratively edit it in real time rather than downloading and re-uploading binary content. Real-time edits from a File Share recipient and from members of the owning workspace converge on the **same live document** — there is no forking or separate copy. A note bound to a File Share cannot be replaced by a binary upload; its content changes only through the endpoints below or the real-time collaboration session. The flow is: mint a short-lived collaboration token with the **note-auth** endpoint, then send that token (not your session bearer token) as `Authorization: Bearer {auth_token}` to read the note's text and, if it carries edit standing, to push updates. The same token is what a client presents to join the real-time collaboration session. Full contract (responses, errors, the version-conflict shape): *File Share Note Endpoints* in the Storage reference. ``` GET /current/fileshare/{fileshare_id}/realtime/note-auth/{note_id}/ GET /current/fileshare/{fileshare_id}/storage/readnote/{note_id}/ POST /current/fileshare/{fileshare_id}/storage/updatenote/{note_id}/ ``` - **note-auth** — mints a 15-minute collaboration token for the bound note. The caller must be signed in and must pass the File Share's access gate, including the `x-ve-password` header when the link is password-protected. The token carries **edit** standing when the caller holds an `edit` grant on the File Share **or** is a workspace member whose role confers edit on the file (a credential scoped below edit on this File Share downgrades it); otherwise it is read-only. Anonymous anyone-with-link visitors cannot mint a token (they use the read-only download/preview surface above). ```json { "result": true, "expires_in": 900, "auth_token": "..." } ``` - **readnote** — returns the note's current text (`content`) and its node (`note`). Token-only: a session bearer token alone is refused with 401. Pass an optional `version_id` query parameter to read a historical version instead of the live content. - **updatenote** — replaces the note's text and/or renames it. Requires an **edit** token (a read-only token is refused with 403). Send `name` (must end in `.md`) and/or `content` as form fields in the request body; an empty or whitespace-only `content` is rejected as invalid input. Supply the optional `if_version_id` precondition for a compare-and-swap; on a version conflict the endpoint returns `409` naming the current version so the client can rebase and retry. ### Owner-Side Visibility (Comments) A File Share is a view of a file that **also lives in the owning workspace**, so comments left by File Share recipients are visible to workspace members on that same file — it's the same node, so the comments appear in both places. Specifically, for a workspace member: - **Activity feed** — File Share comment activity surfaces on the owning workspace's realtime / activity feed, so workspace members see when a recipient comments (the nudge carries no comment text). - **File comments** — the workspace node comment list for the bound file includes the File Share's comments alongside the workspace's own. They appear **read-only** in the workspace view (`can_reply` / `can_edit` / `can_delete` are `false`) — replies and edits to a File Share comment go through the File Share's own surface. - **Workspace search** — the workspace `comments` search bucket includes comments left through the workspace's **live** (active, non-expired, non-revoked) File Shares. This visibility is **one-directional: workspace ⊇ File Share.** Workspace members see File Share comments, but File Share recipients **never** see the workspace's internal comments — a recipient's view stays isolated to their own File Share's comment thread. ### File Share Error Responses | HTTP Status | Code | Cause | |-------------|------|-------| | 406 | `1605 (Invalid Input)` | Invalid id or input value | | 401 | `1650 (Authentication Invalid)` | A link password is required and was missing or wrong (present it via the `x-ve-password` header) | | 403 | `1700 (Forbidden)` | The access tier or named grant does not permit the caller | | 404 | `1609 (Not Found)` | No such File Share, the bound file content is no longer available, or the owning organization no longer has an active plan (the link stops working when the plan ends, and the response is the same as for a link that never existed) | --- ## Share Storage Share storage follows the same API patterns as workspace storage. See `storage.txt` for full endpoint documentation. Replace `/workspace/{workspace_id}` with `/share/{share_id}` in all storage paths. Available operations: - `addfile` -- add file from upload - `createfolder` -- create folder - `list` -- list folder contents (keyset pagination) - `details` -- node details - `update` -- rename, replace content - `move` -- move within share - `copy` -- copy within share - `transfer` -- copy to another storage instance - `delete` -- move to trash - `purge` -- permanently delete from trash - `restore` -- restore from trash - `restore-version` -- restore previous version - `versions` -- list version history - `read` -- download file - `requestread` -- get download token - `zip` -- download folder as ZIP (token-based access supported) - `requestzip` -- get ZIP download token - `search` -- keyword search - `preview` -- file previews (preauthorize, read, token-based read) - `transform` -- image transforms (status, request, read, requestread) - `requestpreview` -- one-time preview nonce (shares with `download_security=medium` only) - `readnote` -- read note content as JSON (token-based access supported) - `lock` -- file locking (acquire, heartbeat, release, status, override) - `content` -- read a file's or note's extracted text as ordered chunks - `inventory` -- enumerate every live file (`/storage/inventory/`) - `recent` -- recently changed files (`/storage/recent/`) **Not available in shares:** `addlink`, `createnote`, `updatenote`, `quickshare` > Part of the Fastio API Reference. Overview: https://api.fast.io/current/llms/ # File Upload Base URL: `https://api.fast.io/current/` All upload endpoints require authentication unless noted: `Authorization: Bearer {jwt_token}` Request format: `multipart/form-data` for file data, `application/x-www-form-urlencoded` for non-file requests. --- ## Overview Fastio supports four upload flows: 1. **Small files (< 4 MB)** -- Single-request upload. Send the file as a multipart chunk in the session creation request. Optionally auto-add to storage in the same call. 2. **Large files (>= 4 MB)** -- Chunked upload. Create a session, upload chunks (up to 3 in parallel), trigger assembly, poll until complete. 3. **Stream upload** -- Upload a file of unknown size in a single request (stream mode). Create a session with `stream=true`, then POST the raw file body to the stream endpoint. 4. **Web upload (URL import)** -- Import files from a public HTTPS URL. The server downloads and uploads the file asynchronously. All direct upload flows produce an upload session with a unique `id` (OpaqueId). Once the upload reaches `complete` status, the file is ready to use. --- ## Upload Constraints | Constraint | Value | |---|---| | Single-call upload max size | 4 MB (4,194,304 bytes) | | Chunk size | Plan-dependent (query `/upload/limits/` for exact values) | | Last chunk | May be smaller than the plan chunk size | | Max parallel chunk uploads per session | 3 | | Max undersized chunks per session | 1 (a chunk below the plan's minimum chunk size; make it the final chunk) | | Chunk ordering | 1-based (first chunk is `order=1`) | | Supported hash algorithms | `md5`, `sha1`, `sha256`, `sha384`, `crc32c` (recommended -- see *Integrity Checksums (CRC-32C)*) | | `relative_path` max length | 8192 characters | | `relative_path` max length per segment | 255 characters, counted as characters not bytes -- each path segment is itself capped, and a segment over the limit is rejected (never shortened); the error names the offending segment, its length, and the limit | | `relative_path` | Omit entirely if empty -- do NOT send as empty string | | `creator` format | 1-150 chars, alphanumeric and hyphens only (`/^[a-zA-Z0-9\-]+$/`) | | Max file size | Plan-dependent (up to 100 GB) | | Max concurrent sessions | Plan-dependent (Starter and Business 5,000; Enterprise 10,000) | | Long-poll max wait | 590 seconds | | Stream upload | Exactly one stream upload allowed per stream-mode session | **Org access policy.** When an Enterprise org has restricted access by location or network (see *Access Policy (Geo / IP Restrictions)* in `llms/orgs.txt`), every upload endpoint for a target owned by that org refuses a blocked caller with **403 `geo_restricted`** -- session create, chunk, stream, complete, and both the per-session `upload/{id}/details` and `web_upload/{id}/details` reads. An MCP-classified caller blocked by the org's `mcp_access` policy gets **403 `mcp_access_denied`** instead. The session itself is **not** deleted or cancelled by the refusal. A session that is dropped from an upload **list** for this reason still returns 403 `geo_restricted` from its own `details` call, never 404 -- so a 404 there still means the session itself is gone, not that it was hidden by policy. If a session's own target cannot itself be read to resolve the policy, the call fails **503 `access_policy_unavailable`** (retryable) rather than passing through unchecked — this applies to the two `details` reads above and to the upload/web_upload session **lists**: a list request whose row-level policy check hits an unreadable target fails 503 for the whole request rather than silently including or excluding that row. --- ## Upload Status Values | Status | Meaning | Action | |---|---|---| | `ready` | Session created, awaiting chunks | Upload chunks | | `uploading` | Chunks being received | Continue uploading | | `assemble` | Assembly queued | Keep polling | | `assembling` | Assembly in progress | Keep polling | | `complete` | Terminal success state for every path. With a target, the file is in storage and `new_file_id` is set; with no target, the file is held for a later `addfile` call. | Done (use `addfile` if there was no target) | | `store` | Reserved -- not emitted by current uploads | Treat like `storing` if seen | | `storing` | Being imported to storage | Keep polling | | `stored` | Reserved -- not emitted by current uploads | Treat like `complete` if seen | | `assembly_failed` | Assembly failed -- including a whole-file CRC-32C mismatch (see `session.integrity_failure`) | Handle error | | `store_failed` | Storage import failed | Handle error | **Terminal states:** `complete`, `assembly_failed`, `store_failed`. Stop polling when you reach one of these. Every successful path ends at `complete`. ### State machine branches A session takes one of two branches based on how it was created: - **Target path** -- request supplies a storage target (`action` + `instance_id`). Path: `uploading -> assemble -> assembling -> storing -> complete`. Terminal: `complete`, with `new_file_id` set; the file is accessible for download and preview. May instead end at `assembly_failed` or `store_failed`. - **No-target path** -- no `instance_id` provided. Path: `uploading -> complete` (it may pass through `assemble` / `assembling`). Terminal: `complete`. File is held for a later `addfile` call. ### Transition-Tracking Fields Each session object carries three fields for distinguishing "still moving" from "stuck" when polling: | Field | Type | Meaning | |---|---|---| | `updated_ms` | integer | Millisecond-precision unix epoch of the most recent session update. Orders snapshots that share the second-precision `updated` value. | | `state_epoch` | integer | Monotonic counter that increments on every status change. Compare across polls to detect transitions that happen inside a single wall-clock second. | | `assembling_deadline_ms` | integer | Present only while `status=assembling`. Unix-ms deadline; treat `now > assembling_deadline_ms && state_epoch unchanged` as stuck. Before either condition holds, the session is still legitimately in flight. | A short-lived `assemble → assembling → storing → complete` sequence can finish in well under a second for small chunk-based uploads, so a single poll can catch a session mid-transition. Do not treat a non-terminal status on the first poll as a failure. --- ## Integrity Checksums (CRC-32C) CRC-32C (Castagnoli) is the recommended integrity checksum for uploads. It is fast to compute, and per-chunk values can be combined into the whole-file value without reading the file a second time. `md5`, `sha1`, `sha256` and `sha384` keep working exactly as before. - **Format.** `hash_algo=crc32c` with `hash` set to exactly 8 lowercase hex digits, in the standard big-endian rendering: the CRC-32C of the ASCII bytes `123456789` is `e3069283`, and of empty input `00000000`. Uppercase digits, a `0x` prefix, or a decimal value are rejected as an invalid hash. - **Per chunk.** Send `hash_algo=crc32c&hash={crc32c_hex}` with each `POST /current/upload/{upload_id}/chunk/`. A chunk whose bytes do not match is rejected the same way as with any other algorithm ("The chunk failed to hash properly..."); re-send that chunk. - **Whole body.** On a single-call upload, a stream body, or a batch entry, `hash_algo=crc32c` + `hash` is the CRC-32C of the whole body and is checked against the bytes received. - **Whole file (chunked).** Declare the CRC-32C of the entire file either at session creation (`hash_algo=crc32c` + `hash`) or with the optional `file_crc32c` parameter on `POST /current/upload/{upload_id}/chunk/` (only there -- single-call, stream and batch uploads already carry a whole-body hash). Compute it by combining your per-chunk CRCs in chunk order with the zlib `crc32_combine` method, using the CRC-32C polynomial (reflected `0x82F63B78`); no second pass over the file is needed. - **Which chunk carries `file_crc32c`.** Send it on the **first** send of the chunk whose CRC you finish computing last -- for a client that uploads chunks one after another, the last chunk -- and on retries of that same chunk only. Never move it to a different chunk. The server must hold the value before the session has received every byte, so a `file_crc32c` that arrives after that is refused. Resending the same value is harmless; a different value is refused with `10779` (HTTP 409). If you know the whole-file CRC-32C before you start, you can instead declare it at session creation. - **Validation.** When the upload is finalized, the server combines the CRC-32Cs of the chunks it stored and compares the result with your value, without re-reading the file. On a match the file is stored as normal. On a mismatch **nothing is stored** and the session ends `assembly_failed`. The mismatch is reported in one of three shapes: (a) the completing chunk, the stream body, the single-call upload, or `/complete` fails with an HTTP 406 error, code `10778` -- as does any later chunk, stream or `/complete` call on that session; (b) a batch entry fails inline: the batch still returns HTTP 200, and that entry carries `status: "error"` with `error_code` `10778`; (c) session details still succeed, showing `status: "assembly_failed"` and `integrity_failure.error_code` `10778`. The session can still be deleted. A mismatch is terminal -- do not retry the session; create a new one and upload the file again. - **A legacy whole-file hash still works alongside it.** A session created with a `sha256` (or other legacy) whole-file `hash` can also receive a `file_crc32c`; each is checked on its own, and a legacy mismatch behaves as it always has. - **Stored on the file.** Every newly stored file records its whole-file CRC-32C, returned as `crc32c` on node and version details next to the unchanged `hash` (see the Storage reference). Compare it with your local value to confirm what was stored. Files stored before the platform began recording it report `null`. An upload that was already in progress when the server was updated may complete without the whole-file check, and its stored `crc32c` may be `null`. --- ## Workflow: Small File Upload (< 4 MB) A single request creates the session and uploads the file. Optionally auto-adds to storage. ### Step 1: Upload in one request ``` POST /current/upload/ Content-Type: multipart/form-data Authorization: Bearer {jwt_token} ``` **Parameters:** | Parameter | Type | Required | Description | |---|---|---|---| | `name` | string | Yes | File name including extension (1-255 chars). | | `size` | integer | Yes | File size in bytes (must match actual file) | | `chunk` | file | Yes | The file binary data (multipart field) | | `action` | string | No | `"create"` for new file, `"update"` for file replacement | | `instance_id` | string | Required if action=create or update | Workspace or share profile ID (19-digit numeric) | | `file_id` | string | Required if action=update | OpaqueId of the existing file to replace | | `folder_id` | string | No | Target folder OpaqueId or `"root"` for storage root | | `hash` | string | No | Hash of the full file. Must be provided with `hash_algo`. | | `hash_algo` | string | No | Hash algorithm: `"md5"`, `"sha1"`, `"sha256"`, `"sha384"`, or `"crc32c"` (recommended; 8 lowercase hex digits -- see *Integrity Checksums (CRC-32C)*) | | `relative_path` | string | No | For folder uploads, relative path for auto folder creation (max 8192 chars). Each path segment is itself capped at 255 characters, counted as characters not bytes; a segment over the limit is rejected (never shortened), with an error naming the offending segment, its length, and the limit. Omit entirely if not applicable. | | `org` | string | No | Organization ID for billing limit resolution (only used when no `action` is specified) | | `creator` | string | No | Client identifier string (1-150 chars, alphanumeric and hyphens only) | **curl example (small file with auto-add to workspace):** ```bash curl -X POST "https://api.fast.io/current/upload/" \ -H "Authorization: Bearer {jwt_token}" \ -F "name=notes.txt" \ -F "size=1024" \ -F "action=create" \ -F "instance_id=1234567890123456789" \ -F "folder_id=root" \ -F "creator=my-web-client" \ -F "chunk=@notes.txt" ``` **Response (201 Created):** ```json { "result": true, "id": "5uhlp-zzcds-g2ba5-rqnbo-s5ytn-geda", "creator": "my-web-client", "new_file_id": null } ``` **Response fields:** | Field | Type | Description | |---|---|---| | `result` | boolean | `true` on success | | `id` | string | Upload session OpaqueId | | `creator` | string | Echoed back only if `creator` was provided in the request | | `new_file_id` | string or null | Only present for single-call uploads with an upload target (`instance_id`). Usually `null`: the file is added to storage asynchronously after this response, so the node id is not yet known. When it is an OpaqueId, storage already completed within the request. | When `instance_id` and `folder_id` are provided, the file is automatically added to storage -- no `addfile` step is needed. Because storage completes asynchronously, get the new node id by long-polling `GET /current/upload/{id}/details/?wait=60` until the session reaches a terminal status, then read `session.new_file_id`. Skip the poll only when the create response already carried a non-null `new_file_id`. **Response (without `instance_id`, 201 Created):** ```json { "result": true, "id": "5uhlp-zzcds-g2ba5-rqnbo-s5ytn-geda" } ``` Without a target, only the upload session `id` is returned. Use `addfile` to place the file in storage after the upload completes. **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. Codes shown as five or six digits (and the `9661`-`9669` family) ARE `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 | |---|---|---| | `1605 (Invalid Input)` | 406 | "The size was not supplied." | | `1605 (Invalid Input)` | 406 | "The file name is not valid." | | `1605 (Invalid Input)` | 406 | "The size is not valid." | | `1605 (Invalid Input)` | 406 | "Invalid share, workspace, or sign envelope instance ID." (`action=update` also accepts a File Share id: "Invalid share, workspace, sign envelope, or file share instance ID.") | | `1609 (Not Found)` | 404 | "No such folder exists in this share." -- `folder_id` is missing, is not a folder, or lies outside the share (`action=create` on a share). | | `1609 (Not Found)` | 404 | "No such file exists in this share." -- `file_id` is missing or lies outside the share (`action=update` on a share). | | `1605 (Invalid Input)` | 406 | "A note cannot be replaced with a binary upload." (`action=update` on a note) | | `1658 (Not Acceptable)` | 406 | "This file type is not allowed for upload on your plan." -- only while extension enforcement is on (see `enforcement_enabled` on `GET /current/upload/limits/extensions/`; currently off). | | `1685 (Feature Limit)` | 412 | "The file size exceeds (4194304) single call upload, use chunks." | | `1685 (Feature Limit)` | 412 | "You have created too many upload sessions..." | | `1685 (Feature Limit)` | 412 | "The size is too large for the account plan." | | `1685 (Feature Limit)` | 412 | "The total size of all active upload sessions exceeds the limit." | | `1605 (Invalid Input)` | 406 | "The hash algorithm provided is not valid." | | `1605 (Invalid Input)` | 406 | "The hash provided is not valid." | | `1605 (Invalid Input)` | 406 | "The hash algorithm was provided but not the hash." | | `1605 (Invalid Input)` | 406 | "The chunk failed to hash properly, check the chunk hash and retry." -- the uploaded bytes do not match `hash`. | | `10778` | 406 | "The uploaded file did not match its whole-file CRC-32C, so it was not saved. Upload the file again in a new session." -- terminal; see *Integrity Checksums (CRC-32C)*. | | `1658 (Not Acceptable)` | 406 | "We were unable to create the upload session..." | | `1683 (Resource Missing)` | 404 | "The upload session was deleted before the file could be stored." -- the new session was deleted while the file was being processed (e.g. a concurrent `DELETE /current/upload/{upload_id}/`). Not retryable as-is; upload the file again. | --- ## Workflow: Large File Upload (Chunked) ### Step 1: Create upload session ``` POST /current/upload/ Content-Type: application/x-www-form-urlencoded Authorization: Bearer {jwt_token} ``` **Parameters:** | Parameter | Type | Required | Description | |---|---|---|---| | `name` | string | Yes | File name including extension (1-255 chars). | | `size` | integer | Yes | Total file size in bytes | | `action` | string | No | `"create"` for new file, `"update"` for file replacement. Omit it to upload with no storage target (add the file later with `addfile`). | | `instance_id` | string | Required if action=create or update | Workspace or share profile ID (19-digit numeric) for auto-add to storage after assembly | | `file_id` | string | Required if action=update | OpaqueId of existing file to replace | | `folder_id` | string | No | Target folder OpaqueId or `"root"` for storage root | | `hash` | string | No | Hex hash of the full file, computed with `hash_algo`, for integrity verification. Must be provided with `hash_algo`. | | `hash_algo` | string | No | Hash algorithm: `"md5"`, `"sha1"`, `"sha256"`, `"sha384"`, or `"crc32c"`. A `crc32c` value is the whole-file CRC-32C, checked when the upload is finalized against the CRCs of the stored chunks; session details report it as `file_crc32c` (not `hash`/`hash_algo`). You can instead send it later as `file_crc32c` on a chunk -- see *Integrity Checksums (CRC-32C)*. | | `relative_path` | string | No | For folder uploads, relative path for auto folder creation (max 8192 chars). Each path segment is itself capped at 255 characters, counted as characters not bytes; a segment over the limit is rejected (never shortened), with an error naming the offending segment, its length, and the limit. Omit entirely if not applicable. | | `org` | string | No | Organization ID for billing limit resolution (only used when no `action` is specified) | | `creator` | string | No | Client identifier string (1-150 chars, alphanumeric and hyphens only) | **curl example:** ```bash curl -X POST "https://api.fast.io/current/upload/" \ -H "Authorization: Bearer {jwt_token}" \ -d "name=annual-report.pdf" \ -d "size=52428800" \ -d "action=create" \ -d "instance_id=1234567890123456789" \ -d "hash_algo=sha256" \ -d "hash=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" ``` **Response (201 Created):** ```json { "result": true, "id": "5uhlp-zzcds-g2ba5-rqnbo-s5ytn-geda" } ``` When `instance_id` names a workspace or share owned by an Enterprise org restricting access by location or network, session creation refuses a blocked caller with 403 `geo_restricted` -- see *Org access policy* above. ### Step 2: Upload chunks ``` POST /current/upload/{upload_id}/chunk/?order={n}&size={bytes} Content-Type: multipart/form-data Authorization: Bearer {jwt_token} ``` **Path parameters:** | Parameter | Type | Required | Description | |---|---|---|---| | `{upload_id}` | string | Yes | The upload session ID from Step 1 | **Query parameters:** | Parameter | Type | Required | Description | |---|---|---|---| | `order` | integer | Yes | 1-based chunk number (first chunk = 1). Must not exceed plan's chunk limit. | | `size` | integer | Yes | Size of this chunk in bytes. Must match the actual uploaded file size. | | `hash` | string | No | Hash of this chunk. Must be provided with `hash_algo`. | | `hash_algo` | string | No | Hash algorithm: `"md5"`, `"sha1"`, `"sha256"`, `"sha384"`, or `"crc32c"` (recommended) | | `file_crc32c` | string | No | CRC-32C of the **entire file** (8 lowercase hex digits), checked when the upload is finalized. Send it on the first send of the chunk whose CRC you finish computing last (sequential clients: the last chunk) and on retries of that same chunk only -- see *Integrity Checksums (CRC-32C)*. Independent of this chunk's own `hash`/`hash_algo`. | **Request body (multipart/form-data):** | Field | Type | Required | Description | |---|---|---|---| | `chunk` | file | Yes | Binary chunk data | Upload up to 3 chunks in parallel. The last chunk may be smaller. Only 1 undersized chunk is allowed per session. When all chunks have been uploaded (total bytes equal the declared file size), auto-finalization triggers automatically. You can still call the complete endpoint explicitly. **curl example:** ```bash curl -X POST "https://api.fast.io/current/upload/5uhlp-zzcds-g2ba5-rqnbo-s5ytn-geda/chunk/?order=1&size=5242880&hash_algo=sha256&hash=abc123def456..." \ -H "Authorization: Bearer {jwt_token}" \ -F "chunk=@chunk_001.bin" ``` **curl example (last chunk with CRC-32C, carrying the whole-file `file_crc32c`):** ```bash curl -X POST "https://api.fast.io/current/upload/5uhlp-zzcds-g2ba5-rqnbo-s5ytn-geda/chunk/?order=5&size=5242880&hash_algo=crc32c&hash={chunk_crc32c_hex}&file_crc32c={file_crc32c_hex}" \ -H "Authorization: Bearer {jwt_token}" \ -F "chunk=@chunk_005.bin" ``` **Response (202 Accepted):** ```json { "result": true } ``` **Error responses:** | Error Code | HTTP Status | Message | |---|---|---| | `1605 (Invalid Input)` | 406 | "The session `id` provided is not valid." | | `1658 (Not Acceptable)` | 406 | "The session `id` provided is not in a valid state to accept a chunk." | | `1658 (Not Acceptable)` | 406 | "This session uses stream mode. Use the /param/stream/ endpoint instead of uploading chunks." -- send stream-mode sessions to `POST /current/upload/{upload_id}/stream/`. | | `1605 (Invalid Input)` | 406 | "No `order` supplied" | | `1605 (Invalid Input)` | 406 | "Invalid `order` supplied" | | `1605 (Invalid Input)` | 406 | "The order provided for this chunk is not valid..." | | `1605 (Invalid Input)` | 406 | "The size was not supplied." | | `1685 (Feature Limit)` | 412 | "The size is too large for the account plan." | | `1685 (Feature Limit)` | 412 | "The `order` specified exceeds the maximum chunk limit for the account plan." | | `1685 (Feature Limit)` | 412 | "The size is too small for the account plan." | | `1685 (Feature Limit)` | 412 | "You have exceeded the maximum number of chunks..." | | `1685 (Feature Limit)` | 412 | "The combined chunk size exceeds the size for this session." | | `1605 (Invalid Input)` | 406 | "The upload chunk failed or was the wrong size..." | | `1605 (Invalid Input)` | 406 | "The chunk failed to hash properly..." | | `1605 (Invalid Input)` | 406 | "The file_crc32c provided is not valid; it must be 8 lowercase hex digits." | | `1658 (Not Acceptable)` | 406 | "The session `id` provided is not in a valid state to accept a whole-file CRC-32C." -- `file_crc32c` arrived after the session had received every byte or left `ready`/`uploading`. Send it on the first send of the carrier chunk. | | `10778` | 406 | "The uploaded file did not match its whole-file CRC-32C, so it was not saved. Upload the file again in a new session." -- the whole-file CRC-32C did not match the stored chunks; the session is `assembly_failed`. Returned by the chunk that completed the file and by every later chunk on the session. Not retryable; start a new session. | | `10779` | 409 | "A different whole-file CRC-32C is already set for this upload session. Resend the value sent first." -- a retry must resend the original `file_crc32c`. | | `1683 (Resource Missing)` | 404 | "The `id` provided is not found..." -- the upload session was deleted while this chunk was being processed (e.g. a concurrent `DELETE /current/upload/{upload_id}/`), including when a chunk already stored is re-sent. Not retryable; create a new session. | | `1654 (Internal Error)` | 500 | "The chunk failed to be stored..." | | *(generated per call site)* | 403 | "Access to this organization is not permitted from your current location or network." -- `params.reason: "geo_restricted"`. See *Access Policy* in `llms/orgs.txt`. | ### Step 3: Trigger assembly ``` POST /current/upload/{upload_id}/complete/ Authorization: Bearer {jwt_token} ``` **Path parameters:** | Parameter | Type | Required | Description | |---|---|---|---| | `{upload_id}` | string | Yes | The upload session ID | **Query parameters (optional):** | Parameter | Type | Required | Description | |---|---|---|---| | `hash` | string | No | Final file hash. Checked for format only and **not stored** -- to record a whole-file hash on the session, supply `hash` and `hash_algo` at session creation (or, for CRC-32C, `file_crc32c` on a chunk). Must be provided with `hash_algo`. | | `hash_algo` | string | No | Hash algorithm: `"md5"`, `"sha1"`, `"sha256"`, `"sha384"`, or `"crc32c"` | No body parameters required. Triggers asynchronous assembly of all uploaded chunks. If the session is already in a completed or processing state (`complete`, `assemble`, `assembling`, `store`, `storing`, `stored`), the endpoint returns `200 OK` immediately without error. **curl example:** ```bash curl -X POST "https://api.fast.io/current/upload/5uhlp-zzcds-g2ba5-rqnbo-s5ytn-geda/complete/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (202 Accepted):** ```json { "result": true } ``` **Error responses:** | Error Code | HTTP Status | Message | |---|---|---| | `1683 (Resource Missing)` | 404 | "The `id` provided is not found..." | | `1658 (Not Acceptable)` | 406 | "The session `id` provided is not in a valid state to assemble." | | `1658 (Not Acceptable)` | 406 | "No chunks have been uploaded to this session." | | `1658 (Not Acceptable)` | 406 | "The chunks provided do not match the size of the file." | | `1685 (Feature Limit)` | 412 | "You have created too many upload sessions..." | | `10778` | 406 | "The uploaded file did not match its whole-file CRC-32C, so it was not saved. Upload the file again in a new session." -- returned when this call's finalization finds the mismatch, and on every later `/complete` call for a session that already failed it (session details still succeed). Not retryable; start a new session. | | `1678 (Enqueue Failed)` | 500 | "Your request was valid but could not be processed." | | *(generated per call site)* | 403 | "Access to this organization is not permitted from your current location or network." -- `params.reason: "geo_restricted"`. See *Access Policy* in `llms/orgs.txt`. | ### Step 4: Poll for completion ``` GET /current/upload/{upload_id}/details/?wait=60 Authorization: Bearer {jwt_token} ``` **Path parameters:** | Parameter | Type | Required | Description | |---|---|---|---| | `{upload_id}` | string | Yes | The upload session ID | **Query parameters:** | Parameter | Type | Required | Default | Description | |---|---|---|---|---| | `wait` | integer | No | - | Long-poll wait time in seconds (1 to 590; larger values are capped at 590). Server holds the connection while the upload is still processing (`assemble`, `assembling`, or `storing`) -- a session still in `ready` or `uploading` returns immediately -- and returns as soon as it reaches a terminal status: `complete` on success (for uploads with a target, `session.new_file_id` then carries the new file's node ID) or `assembly_failed`/`store_failed` on failure. | The server detects status changes efficiently during long-poll. Maximum wait is 590 seconds. A single `wait` call replaces polling: when it returns with status `complete`, the node ID is already in `session.new_file_id`. **curl example:** ```bash curl -X GET "https://api.fast.io/current/upload/5uhlp-zzcds-g2ba5-rqnbo-s5ytn-geda/details/?wait=60" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "session": { "status": "complete", "id": "5uhlp-zzcds-g2ba5-rqnbo-s5ytn-geda", "updated": "2025-01-20 10:35:00 UTC", "created": "2025-01-20 10:30:00 UTC", "updated_ms": 1737369300412, "state_epoch": 4, "size": 52428800, "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "hash_algo": "sha256", "filename": "annual-report.pdf", "org": "1234567890123456789", "target": { "action": "create", "instance_id": "1234567890123456789", "folder_id": "root", "relative_path": null, "relative_id": null }, "new_file_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "chunks": { "1": 10485760, "2": 10485760, "3": 10485760, "4": 10485760, "5": 10485760 } } } ``` **Response fields:** | Field | Type | Description | |---|---|---| | `session.id` | string | Upload session OpaqueId | | `session.filename` | string | Filename | | `session.size` | integer | Declared file size in bytes | | `session.status` | string | Current status (see status table above) | | `session.hash` | string | File hash (if provided; a `crc32c` whole-file hash is reported as `file_crc32c` instead) | | `session.hash_algo` | string | Hash algorithm (if provided) | | `session.file_crc32c` | string | The whole-file CRC-32C the server will check, from session creation (`hash_algo=crc32c`) or `file_crc32c` on a chunk. Absent until one is set. | | `session.integrity_failure` | object | Present only on an `assembly_failed` session that failed its whole-file CRC-32C check: `reason` (`"file_crc32c_mismatch"`), `expected_crc32c` (your value), `computed_crc32c` (the CRC-32C of the bytes the server received), and `error_code` (`10778`, the same code the chunk and `/complete` calls return). | | `session.created` | string | Session creation timestamp | | `session.updated` | string | Last update timestamp | | `session.chunks` | object | Map of chunk order (string key) to chunk size (integer value) | | `session.new_file_id` | string | OpaqueId of created storage node (only when `complete` with a target) | | `session.status_message` | string/null | Present on some terminal failures. For a File Share external-edit conflict it carries `CONFLICT_VERSION_MISMATCH:{current_version_id}` (see File Share External Edit below). | | `session.target` | object/null | Upload-target descriptor; `null` when the session was created without a target. For a create it carries `{action:"create", instance_id, folder_id, relative_path, relative_id}` (`folder_id` is `"root"` or a raw folder id); for an update `{action:"update", instance_id, file_id}`. | | `session.org` | string/null | Organization ID used for billing-limit resolution (from the target, or the `org` parameter); `null` when none applies. | | `session.updated_ms`, `session.state_epoch`, `session.assembling_deadline_ms` | integer | Transition-tracking fields -- see *Transition-Tracking Fields* above. | | `session.creator` | string | Echoed client identifier; present only when `creator` was supplied at creation. | | `session.stream_mode` | boolean | `true` for stream-mode sessions. Always present (and `true`) on a stream upload; omitted for non-stream sessions. | | `session.max_size` | integer | Maximum size ceiling in bytes for a stream-mode session. Always present on a stream upload; omitted when no ceiling applies. | **Exit condition:** Stop polling when `status` is a terminal state — `complete`, `assembly_failed`, or `store_failed`. See "State machine branches" above. **Whole-file CRC-32C mismatch.** A session whose stored bytes did not match its `file_crc32c` ends like this; nothing was stored, so upload the file again in a new session: ```json { "result": true, "session": { "status": "assembly_failed", "id": "5uhlp-zzcds-g2ba5-rqnbo-s5ytn-geda", "size": 26214400, "file_crc32c": "7a3c19e4", "filename": "presentation.pptx", "status_message": "The uploaded file did not match its whole-file CRC-32C. Please upload it again.", "integrity_failure": { "reason": "file_crc32c_mismatch", "expected_crc32c": "7a3c19e4", "computed_crc32c": "1c2f9a07", "error_code": 10778 } } } ``` If the target org restricts access by location or network and the caller is currently blocked, this read refuses with 403 `geo_restricted` instead of returning `session` — the session itself is not deleted or cancelled by the refusal. A genuine `404 (Not Found)` on this route still means the session is gone, never that it was hidden by policy. If the session's own target cannot itself be read to resolve the policy, this read fails 503 `access_policy_unavailable` (retryable) instead of passing through unchecked. ### Step 5 (if no `instance_id`): Add file to storage manually ``` POST /current/workspace/{workspace_id}/storage/{folder_id}/addfile/ ``` or ``` POST /current/share/{share_id}/storage/{folder_id}/addfile/ ``` **Path parameters:** - `{workspace_id}` or `{share_id}` -- Profile ID (19-digit numeric string) - `{folder_id}` -- OpaqueId of the target folder, or `"root"` for the storage root **Body parameters:** | Parameter | Type | Required | Description | |---|---|---|---| | `name` | string | Yes | Filename for the new file. 1-255 characters (counted as characters, not bytes). | | `from` | string (JSON) | Yes | Source specification as JSON-encoded string | **`from` format:** ``` from={"type":"upload","upload":{"id":"{upload_id}"}} ``` The value must be a JSON string sent as a form field: ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/root/addfile/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'name=annual-report.pdf' \ -d 'from={"type":"upload","upload":{"id":"5uhlp-zzcds-g2ba5-rqnbo-s5ytn-geda"}}' ``` ### Step 6 (optional): Clean up session ``` DELETE /current/upload/{upload_id} Authorization: Bearer {jwt_token} ``` --- ## Complete Chunked Upload Example **1. Create session (25 MB file, 5 chunks):** ```bash curl -X POST "https://api.fast.io/current/upload/" \ -H "Authorization: Bearer {jwt_token}" \ -d "name=presentation.pptx" \ -d "size=26214400" \ -d "action=create" \ -d "instance_id=1234567890123456789" \ -d "folder_id=root" ``` Response: ```json {"result": true, "id": "5uhlp-zzcds-g2ba5-rqnbo-s5ytn-geda"} ``` **2. Upload 5 chunks (3 in parallel, then 2 more):** ```bash # Chunks 1-3 in parallel curl -X POST "https://api.fast.io/current/upload/5uhlp-zzcds-g2ba5-rqnbo-s5ytn-geda/chunk/?order=1&size=5242880" \ -H "Authorization: Bearer {jwt_token}" -F "chunk=@chunk1.bin" & curl -X POST "https://api.fast.io/current/upload/5uhlp-zzcds-g2ba5-rqnbo-s5ytn-geda/chunk/?order=2&size=5242880" \ -H "Authorization: Bearer {jwt_token}" -F "chunk=@chunk2.bin" & curl -X POST "https://api.fast.io/current/upload/5uhlp-zzcds-g2ba5-rqnbo-s5ytn-geda/chunk/?order=3&size=5242880" \ -H "Authorization: Bearer {jwt_token}" -F "chunk=@chunk3.bin" & wait # Chunks 4-5 curl -X POST ".../chunk/?order=4&size=5242880" -H "Authorization: Bearer {jwt_token}" -F "chunk=@chunk4.bin" & curl -X POST ".../chunk/?order=5&size=5242880" -H "Authorization: Bearer {jwt_token}" -F "chunk=@chunk5.bin" & wait ``` **3. Trigger assembly:** ```bash curl -X POST "https://api.fast.io/current/upload/5uhlp-zzcds-g2ba5-rqnbo-s5ytn-geda/complete/" \ -H "Authorization: Bearer {jwt_token}" ``` **4. Poll until complete:** ```bash curl -X GET "https://api.fast.io/current/upload/5uhlp-zzcds-g2ba5-rqnbo-s5ytn-geda/details/?wait=60" \ -H "Authorization: Bearer {jwt_token}" ``` Response: `{"result": true, "session": {"status": "assembling", ...}}` -- keep polling ```bash curl -X GET "https://api.fast.io/current/upload/5uhlp-zzcds-g2ba5-rqnbo-s5ytn-geda/details/?wait=60" \ -H "Authorization: Bearer {jwt_token}" ``` Response: `{"result": true, "session": {"status": "complete", "new_file_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", ...}}` -- done **5. Clean up session:** ```bash curl -X DELETE "https://api.fast.io/current/upload/5uhlp-zzcds-g2ba5-rqnbo-s5ytn-geda" \ -H "Authorization: Bearer {jwt_token}" ``` --- ## Workflow: Stream Upload (Unknown File Size) For clients that don't know the exact file size upfront (piped output, generated content, compressed streams). The client declares a maximum size ceiling, streams the file in a single request, and the system records actual bytes. ### Step 1: Create Stream Session ```bash POST /current/upload/ ``` | Parameter | Type | Required | Description | |---|---|---|---| | `name` | string | Yes | Filename (1-255 chars). | | `stream` | string | Yes | Must be `"true"` | | `max_size` | integer | No | Maximum file size in bytes (defaults to plan limit) | | `action` | string | No | `"create"` or `"update"` (same as standard upload) | | `instance_id` | string | Conditional | Target workspace/share ID (required if action=create or update) | | `file_id` | string | Conditional | File to update (required if action=update) | | `folder_id`, `relative_path`, `if_version_id`, `org` | string | No | Same as the standard upload session parameters | | `hash` | string | No | Expected whole-file hash | | `hash_algo` | string | No | Hash algorithm (`crc32c` recommended -- a `crc32c` value is checked when the stream finalizes; see *Integrity Checksums (CRC-32C)*) | | `creator` | string | No | Client identifier | **Example:** ```bash curl -X POST "https://api.fast.io/current/upload/" \ -H "Authorization: Bearer $TOKEN" \ -d "name=output.tar.gz" \ -d "stream=true" \ -d "max_size=52428800" \ -d "action=create" \ -d "instance_id=1234567890123456789" ``` **Response:** `201 Created` -- returns session `id` for use in step 2. ### Step 2: Stream File Body ```bash POST /current/upload/{upload_id}/stream/ Content-Type: application/octet-stream ``` Send the raw file bytes as the request body. No `size` or `order` parameters needed. Pass the optional hash parameters in the query string. | Parameter | Type | Required | Description | |---|---|---|---| | `hash` | string | No | Whole-file hash for validation | | `hash_algo` | string | No | Hash algorithm (`md5`, `sha1`, `sha256`, `sha384`, or `crc32c`) | **Example:** ```bash curl -X POST "https://api.fast.io/current/upload/$SESSION_ID/stream/" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/octet-stream" \ --data-binary @myfile.tar.gz ``` **Response:** `201 Created` (`{"result": true}`) -- the session auto-finalizes. The session's `size` is updated to actual bytes received. **Error responses:** | Error Code | HTTP Status | Message | |---|---|---| | `1605 (Invalid Input)` | 406 | "The session `id` provided is not valid." | | `1658 (Not Acceptable)` | 406 | "This session was not created with stream mode enabled. Use the chunk endpoint instead." | | `1658 (Not Acceptable)` | 406 | "The session `id` provided is not in a valid state to accept a stream upload." | | `1658 (Not Acceptable)` | 406 | "A stream upload is already in progress for this session." | | `1658 (Not Acceptable)` | 406 | "A stream has already been uploaded for this session." | | `1605 (Invalid Input)` | 406 | "The stream upload was interrupted or contained no data." | | `1605 (Invalid Input)` | 406 | "The uploaded file is smaller than the minimum allowed size." | | `1685 (Feature Limit)` | 412 | "The uploaded file exceeds the maximum size for this session." | | `1605 (Invalid Input)` | 406 | "The file failed to hash properly, check the file hash and retry." | | `10778` | 406 | "The uploaded file did not match its whole-file CRC-32C, so it was not saved. Upload the file again in a new session." -- the body did not match the `crc32c` hash declared at session creation; the session is `assembly_failed`. Not retryable; start a new session. | | `1683 (Resource Missing)` | 404 | "The `id` provided is not found..." -- the upload session was deleted while the stream was being processed (e.g. a concurrent `DELETE /current/upload/{upload_id}/`). Not retryable; create a new session. | | `1654 (Internal Error)` | 500 | "The stream upload failed to be stored, please retry your upload or contact us if this persists." | ### Notes - The `max_size` parameter is used for quota validation at session creation. If omitted, defaults to the plan's maximum file size. - The actual uploaded bytes must not exceed `max_size`. - Stream mode sessions produce exactly one chunk and finalize automatically -- no `/complete/` call is needed. With a target, the session still moves through `assemble` / `assembling` / `storing` to `complete`; long-poll `GET /current/upload/{upload_id}/details/?wait=60` for `new_file_id`. - If you know the exact file size, you can still provide `size` instead of `max_size` (or both). - Stream upload is a single-shot operation: you cannot stream to the same session twice. - Stream mode sessions cannot use the chunk endpoint -- attempting to upload chunks to a stream session will return an error. - Only one concurrent stream upload is allowed per session; concurrent requests to the same session are rejected. --- ## Resume a Disconnected Upload If an upload is interrupted (network failure, client crash), resume it without re-uploading completed chunks. ### Steps: 1. **Get session status:** ```bash curl -X GET "https://api.fast.io/current/upload/{upload_id}/details/" \ -H "Authorization: Bearer {jwt_token}" ``` 2. **Read the `chunks` map** in the response. Keys are chunk numbers already uploaded, values are byte sizes. 3. **Upload only missing chunks.** Compare the `chunks` map against the expected chunk list. Upload any chunks not present. If `session.file_crc32c` is already set, do not send a different value; if it is not set and you use one, send `file_crc32c` with the missing chunk whose CRC you finish last. 4. **Trigger assembly:** ```bash curl -X POST "https://api.fast.io/current/upload/{upload_id}/complete/" \ -H "Authorization: Bearer {jwt_token}" ``` 5. **Poll for completion** as normal. --- ## File Update (New Version) To upload a new version of an existing file: 1. **Create session** with `action=update`, `instance_id`, and `file_id` (OpaqueId of the file to replace). 2. Upload chunks and complete as normal. The existing file receives a new version. ```bash curl -X POST "https://api.fast.io/current/upload/" \ -H "Authorization: Bearer {jwt_token}" \ -F "name=report-v2.pdf" \ -F "size=1024" \ -F "action=update" \ -F "instance_id=1234567890123456789" \ -F "file_id=2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4" \ -F "chunk=@report-v2.pdf" ``` The `instance_id` for an update target may be a **workspace**, a **share**, or a **File Share** (see below). ### Optional Compare-and-Swap (`if_version_id`) To avoid clobbering a concurrent edit, supply an optional `if_version_id` precondition on the update session. The replace is applied **only if** the target file's current version equals that id. This is accepted and enforced on **every** update target — workspace, share, and File Share alike. | Name | Type | Required | Description | |------|------|----------|-------------| | `if_version_id` | string | No | OpaqueId of the version you expect to be current. The replace lands only if it still matches. | Pass `if_version_id` at **session creation** (`action=update`) — the `complete/` finalize call does not read it. ### Version Conflict Surfacing This is an asynchronous upload session, not the synchronous storage-update endpoints (`POST .../storage/{node_id}/update/`, which answer a `409` with `error.params[]` — see the Storage reference). Here, when `if_version_id` does not match the current version, the session ends terminally instead: `status` becomes `assembly_failed` and `session.status_message` carries `CONFLICT_VERSION_MISMATCH:{current_version_id}`. There is no `409` on this path. A polling client can parse the current version id off that prefix, re-read the file, and retry with the fresh `if_version_id`. This terminal state is **not** retried by the server — the client decides whether to re-attempt. **Read the file before concluding nothing was written.** This status means the write was refused **on the attempt that reported it**. It is not a guarantee that the session as a whole wrote nothing: an internal retry can re-run the write against the base you supplied after an earlier attempt in the same session already applied it, and the conflict is then reported against your own committed version. Rebasing onto the returned id and re-uploading in that situation stores your content **a second time**. So on a conflict, fetch the file and compare it against what you intended to write; re-upload only if the content is not already there. ```json { "result": true, "session": { "id": "57m2t-rba7h-bjv5m-yxi6q-lhb24-jexy", "status": "assembly_failed", "status_message": "CONFLICT_VERSION_MISMATCH:3dcdvao5jdflim47gxa5lygwotenm" } } ``` ⚠️ **STATUS (2026-08-26): the persistence defect is FIXED in code and covered by tests that re-read the stored row, but the fix is NOT yet confirmed on a live conflict.** Until it is, treat `status_message` as the reliable conflict signal and the fields below as the intended contract rather than a measured one. This note is removed the moment a real conflict on a deployed build is observed to carry them. **Read `session.conflict`, not the status string.** The same terminal conflict is also persisted as a structured object, and that is what a new client should consume: ```json "conflict": { "type": "CONFLICT_VERSION_MISMATCH", "base_version_id": "33qhg-tbqyj-wrmr2-ssdmv-ht4lr-hal5", "current_version_id": "3dcdv-ao5jd-flim4-7gxa5-lygwo-tenm", "candidate_id": "4itaflutlgy3jnwfck7v5lf62cujg", "expires_at": "2026-08-27 04:00:00 UTC", "permitted_actions": ["rebase", "discard"] } ``` `base_version_id` is what you asserted; `current_version_id` is what the node actually moved to. You need **both** to decide whether to rebase or surface the conflict, and the colon-delimited `status_message` carries only the second. **`status_message` is kept for clients already parsing it and is not going away**, but it is a string with a separator, not a protocol — prefer `conflict`. ⚠️ **`current_version_id` is OPTIONAL.** When the store could not supply the current version — the target row was gone by the time the write was refused — the key is **absent entirely** rather than empty, because an empty string is not "unknown", it is an id you might send back. Test for the key before reading it; when it is missing, re-read the file to find out what state it is actually in. The same applies to `candidate_id` and `expires_at`, which are present only when content was retained. `conflict.candidate_download_url` is **deliberately absent**. The only way to address content with no node attached is internal-network-only and unsupported for chunked uploads — which is exactly the large file you would most want back — so a key that could not be fetched is not emitted. `candidate_id` plus `expires_at` is what is real today. **Your upload MAY be recoverable — branch on the fields, never assume them.** A conflicted session CAN carry `candidate_phy_id` and `candidate_retained_until`, meaning the bytes you sent are retained rather than discarded. **They are frequently absent, and absent is the ordinary case.** When the server can tell your `if_version_id` is stale before it has written anything, it refuses early and there is nothing to retain — no content was ever stored, so there is nothing lost either. The fields carry a value only when the write had already progressed far enough to store your content before losing the race. ⚠️ **`permitted_actions` is the signal to branch on, not the presence of the keys.** It contains `"discard"` only when there is genuinely something to discard; a conflict with `["rebase"]` alone means re-read and resend. `candidate_retained_until` is a real deadline when present — after it the content is reclaimed — but do not build a recovery flow that assumes it will be there. The candidate is exposed on the SESSION, which belongs to whoever created the upload. That ownership is the authorization: nobody else can read these fields, including users with write access to the target file. It is your unaccepted draft, not the file's. ⚠️ **Ids appear in TWO renderings in this payload, and the hyphenated set is an allowlist.** `session.id`, `session.new_file_id`, and the two version ids inside `session.conflict` (`base_version_id`, `current_version_id`) are emitted in the canonical **hyphenated** form (29 characters grouped in fives, 34 characters total). **Every other OpaqueId in the session payload is emitted RAW** — 29 lowercase alphanumeric characters, no hyphens — including `session.target.file_id`, `session.candidate_phy_id`, `conflict.candidate_id`, and any id carried inside `status_message`. The two version ids are hyphenated because they are compare-and-swap tokens: `current_version_id` is what you hand back as the next `if_version_id`, and it reads exactly like the `version` field you took it from. `conflict.candidate_id` stays raw because it is the same value as `session.candidate_phy_id`, and one id must not read two ways within one response. So the same node can appear both ways in one response (`new_file_id` hyphenated, `target.file_id` raw), and the same version appears hyphenated in `conflict.current_version_id` but raw in the `status_message` prefix. ⇒ **Do not write a parser that requires either shape.** Strip non-alphanumerics and lowercase before comparing or storing an id; the API accepts either form on input and canonicalises it, so an id read in one rendering can be sent back in the other. A new field added to the session payload defaults to **RAW** unless it is listed above. ⚠️ **Migrating from `status_message` to `conflict` changes the rendering you display.** The same version is raw in the `status_message` prefix and hyphenated in `conflict.current_version_id`, so a client that switches sources starts showing a different-looking id for the same version — and a user comparing today's output to yesterday's will reasonably conclude something moved. Two consequences worth planning for: **normalise before you compare across the two sources** (a direct string compare of the parsed `status_message` id against `conflict.current_version_id` returns *false on a match*, which is exactly the signal a compare-and-swap client acts on), and **decide deliberately which rendering you show a human**, rather than inheriting whichever source you happen to read. Both are correct ids for the same version. --- ## File Share External Edit (Write-Back) A holder of an **`edit`** grant on a durable **File Share** can replace the shared file's content — even without being a member of the owning workspace — by using the **File Share id** as the `instance_id` of an `action=update` upload session. The session targets the File Share's single bound file node; the bytes land in (and are metered to) the owning workspace's storage. 1. **Create session** with `action=update`, `instance_id={fileshare_id}` (the File Share's numeric id or its opaque `id_alt`), and `file_id={bound_node_id}` (the File Share's bound file, available as `bound_node_id` from the management list, or `file.id` from the public details endpoint). 2. Upload chunks and complete as normal. The bound file receives a new version, and a `file_share_content_updated` event is emitted. ```bash curl -X POST "https://api.fast.io/current/upload/" \ -H "Authorization: Bearer {jwt_token}" \ -F "name=report-v2.pdf" \ -F "size=1024" \ -F "action=update" \ -F "instance_id=1234567890123456789" \ -F "file_id=2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4" \ -F "chunk=@report-v2.pdf" ``` **Authorization.** The write requires a named `edit` grant on the File Share (or an equivalent scope token) — workspace/org membership is not required, and no access tier alone confers write. In addition to the `edit` grant, the same access gate that governs reads still applies to the write: if the File Share is password-protected, the editor must present the link password via the **`x-ve-password` request header**, and the File Share's access tier (`anyone_with_link` / `any_registered` / `named_people`) is enforced. The `file_id` you supply must canonicalize to the File Share's bound node; any other node id resolves to **404** (siblings cannot be probed). The optional `if_version_id` compare-and-swap precondition documented above applies here the same as on any other update target. --- ## Sign Envelope Target (Draft Documents) A **Sign Envelope** in draft status is also an accepted upload target: pass the Sign Envelope id as `instance_id` on `POST /current/upload/` — with `action=create` to add a draft document, or `action=update` to replace one. Uploads are permitted **only while the envelope is a draft** and require membership in the envelope's owning workspace with view access to the envelope; once the envelope leaves draft it no longer accepts document uploads. See the Signing reference for the envelope lifecycle and its document-management endpoints — the details here are limited to noting that the upload API accepts this target. --- ## Batch Upload (Many Small Files) Submit 1-200 small files in a single request. Returns a per-file result array, so partial success is legible and does not abort the batch. Use this when you have many small files destined for the same workspace or share — one rate-limit cost instead of one per file. For files over 4 MB, keep using the standard chunked `POST /current/upload/` flow. ### Batch Limits | Limit | Value | |---|---| | Files per batch | 1-200 | | Max per-file size | 4 MB (4,194,304 bytes) | | Max request body | 100 MB (applied post-base64-decode on the JSON path) | | Supported hash algorithms | `md5`, `sha1`, `sha256`, `sha384`, `crc32c` | | Status record TTL | 1 hour from POST | ### Create batch (multipart) ``` POST /current/upload/batch/ Content-Type: multipart/form-data ``` Authentication is **required** on `POST /current/upload/batch/`. Anonymous callers are not supported on batch — use single-file `POST /current/upload/` for anonymous public-receive / public-exchange share uploads. **Batch-level fields:** | Field | Type | Required | Description | |---|---|---|---| | `instance_id` | string | Yes | Target workspace or share profile ID (19-digit numeric). Every file in the batch lands in this target. | | `folder_id` | string | No | Destination folder for the whole batch: OpaqueId of a folder under `instance_id`, or the literal `"root"`. Omit (or pass `"root"`) to land files at the target's storage root. For workspace-folder shares, an omitted `folder_id` resolves to the share's configured storage-root folder; a supplied `folder_id` always overrides that default. | | `creator` | string | No | Optional echo-back correlation tag (1-150 chars, alphanumeric and hyphens only). | | `manifest` | string (JSON) | Yes | JSON-encoded array of per-file manifest entries (see below). | | `file_{index}` | file | Yes (one per manifest entry) | Binary file body. The suffix matches the `index` in the manifest entry. | **Manifest entry schema:** | Field | Type | Required | Description | |---|---|---|---| | `index` | integer | Yes | 0-based position. Indices must be contiguous from 0 to N-1. | | `filename` | string | Yes | File name (1-255 chars). | | `relative_path` | string | No | Optional per-entry sub-path applied under `folder_id`. 1-8192 characters, counted as characters not bytes; a path over the limit is rejected (never shortened). Segments are separated by `/` or `\`; leading, trailing, and repeated separators are ignored, so a trailing slash is optional and a leading slash does not make the path absolute -- the path always resolves under `folder_id`. Every segment becomes a literal folder name, so a `..` segment creates a folder named `..` rather than moving up a level. Each path segment is itself capped at 255 characters, counted as characters not bytes; a segment over the limit is rejected as a per-entry error (only that file fails; the rest of the batch still uploads), with an error naming the offending segment, its length, and the limit. Matches the validation rules on `POST /current/upload/` `relative_path`. | | `hash_algo` | string | No | `"md5"`, `"sha1"`, `"sha256"`, `"sha384"`, or `"crc32c"` (recommended) for optional integrity validation. | | `hash` | string | No | Hex digest of the uploaded bytes (for `crc32c`, exactly 8 lowercase hex digits). | Hash validation is opt-in per entry: supply both `hash_algo` and `hash`, or neither. A mismatch errors only that entry; the batch still returns 200. **curl example:** ```bash curl -X POST "https://api.fast.io/current/upload/batch/" \ -H "Authorization: Bearer {jwt_token}" \ -F "instance_id=1234567890123456789" \ -F "creator=my-importer" \ -F 'manifest=[{"index":0,"filename":"doc-001.txt"},{"index":1,"filename":"doc-002.txt"}]' \ -F "file_0=@doc-001.txt" \ -F "file_1=@doc-002.txt" ``` ### Create batch (JSON) A fallback for clients that cannot compose multipart. Base64 inflation adds ~33% to the wire size and forces the server to hold decoded bytes in memory while parsing; prefer multipart (which streams directly to disk) for non-trivial payloads. ``` POST /current/upload/batch/ Content-Type: application/json ``` **Body schema:** ```json { "instance_id": "1234567890123456789", "folder_id": "{folder_opaque_id}", "creator": "my-importer", "files": [ {"filename": "doc-001.txt", "content_b64": "SGVsbG8sIHdvcmxkIQ=="}, {"filename": "doc-002.txt", "relative_path": "2026/q1/", "content_b64": "U2Vjb25kIGZpbGU="} ] } ``` Each `files` entry accepts the same optional `relative_path`, `hash_algo`, and `hash` fields as the multipart manifest. The array position is the entry's logical index. `folder_id` (batch-level) and `relative_path` (per-entry) follow the same semantics as on `POST /current/upload/`. **Response (200 OK):** Always `200 OK` on a well-formed batch. Inspect `count_errored` to detect per-file failures. ```json { "result": true, "batch_id": "{batch_id}", "count_total": 2, "count_succeeded": 1, "count_errored": 1, "creator": "my-importer", "results": [ { "index": 0, "filename": "doc-001.txt", "status": "ok", "upload_id": "{upload_id}", "node_id": "{node_id}" }, { "index": 1, "filename": "doc-002.txt", "status": "error", "error_code": 196420, "error_message": "hash_algo and hash must both be provided for a manifest entry." } ] } ``` **Response fields:** | Field | Type | Description | |---|---|---| | `batch_id` | string | Opaque batch identifier for the GET status lookup (valid for 1 hour). | | `count_total` | integer | Files submitted. | | `count_succeeded` | integer | Files with `status: "ok"`. | | `count_errored` | integer | Files with `status: "error"`. | | `creator` | string | Echoed back only if `creator` was supplied. | | `results[].index` | integer | Matches the submitted manifest `index` (or array position for the JSON path). | | `results[].filename` | string | Submitted filename. | | `results[].status` | string | `"ok"` or `"error"`. | | `results[].upload_id` | string | Present on `ok`. Upload session OpaqueId. | | `results[].node_id` | string or null | Present on `ok`. OpaqueId when finalize completed inline; `null` when storage is async (node id assigned later by the assemble worker — matches single-file `/upload/`'s `new_file_id: null`). | | `results[].error_code` | integer | Present on `error`. A unique 6-digit diagnostic code identifying the specific per-file failure (e.g. `196420` for a manifest entry missing one of `hash`/`hash_algo`). These are batch-specific inline codes, distinct from the platform error codes returned by `POST /current/upload/` -- except `10778`, a whole-file CRC-32C mismatch, which is the same code those endpoints return. | | `results[].error_message` | string | Present on `error`. Human-readable description. | **Per-file errors** are reported inline on each entry — the batch itself returns HTTP 200 even if every file errored; check `count_errored`. The per-file shape is `{error_code, error_message}` and is intentionally flat (one error per file). It does **not** carry the envelope-level `params` array used by whole-batch validation rejections; the per-file `error_code` is a unique 6-digit diagnostic code specific to the batch endpoint, not one of the platform error-reference codes -- except `10778` (whole-file CRC-32C mismatch), which is the same code the other upload endpoints return. **Whole-batch error responses** (no `results[]` — standard error envelope; validation rejections include the structured `error.params` array described in the platform error reference): | Error Code | HTTP Status | Cause | |---|---|---| | `1605 (Invalid Input)` | 406 | Unsupported Content-Type (must be `multipart/form-data` or `application/json`). | | `1605 (Invalid Input)` | 406 | `instance_id` missing, not numeric, or not a valid workspace/share ID. | | `1605 (Invalid Input)` | 406 | `manifest` not valid JSON, empty, or entries malformed. | | `1605 (Invalid Input)` | 406 | Manifest `index` values not contiguous from 0, or contain duplicates. | | `1605 (Invalid Input)` | 406 | Manifest filename fails validation. | | `1605 (Invalid Input)` | 406 | JSON body missing `files` array or `files` empty. | | `1605 (Invalid Input)` | 406 | `creator` fails length or character-set validation. | | `1685 (Feature Limit)` | 412 | Batch contains more than 200 files. | | `1685 (Feature Limit)` | 412 | Request body exceeds 100 MB (pre-parse `Content-Length`, or post-decode total for JSON). | | `1685 (Feature Limit)` | 412 | Account plan does not allow files at the 4 MB per-file bound. | | `1685 (Feature Limit)` | 412 | The batch would exceed the plan's active upload-session count or aggregate in-flight session size. | | `1680 (Access Denied)` | 401 | Caller not authorized to upload to the target (anonymous callers are rejected here — use `POST /current/upload/` for anonymous public-receive / public-exchange share uploads). | | `1605 (Invalid Input)` | 406 | `folder_id` malformed (not `"root"` and not a valid OpaqueId), or a per-entry `relative_path` malformed at validation time. Rejects the whole batch. | | `1605 (Invalid Input)` | 406 | `folder_id` does not resolve to a folder under `instance_id`, or caller lacks write permission on it. | **Per-file error causes** (recorded in `results[]` with `status: "error"`; batch returns 200): - Missing / empty `file_{index}` part (multipart) or missing / empty `content_b64` (JSON). - Base64 decode failure (JSON path). - File over the 4 MB per-file bound -- use `POST /current/upload/` instead. - File type (extension or MIME) restricted for the account plan (only while extension enforcement is on). - `hash_algo` not supported or `hash` length wrong for the declared algorithm. - `hash_algo` supplied without `hash` (or vice versa). - Uploaded bytes do not match the declared hash. - The stored file did not match the entry's `crc32c` hash when it was finalized. This entry carries `error_code` `10778` (the same code as the single-file and chunked endpoints) and nothing was stored for it; resubmit the file. - Internal storage / finalization failure for the single entry. - The request reached the server's per-request processing budget before this entry was started (large batches only). These entries carry `error_code` `190079`, and the `error_message` asks you to resubmit the file; it was not stored. ### Fetch batch status Re-fetches the stored result record for a prior batch. Useful if the POST response was lost in transit, or for polling the batch outcome from a background worker. ``` GET /current/upload/batch/{batch_id}/ ``` Authentication is **not required** for this endpoint -- the opaque `batch_id` is the only credential. Treat `batch_id` as bearer-equivalent; transport over HTTPS and do not log alongside identifiers. **curl example:** ```bash curl -X GET "https://api.fast.io/current/upload/batch/{batch_id}/" ``` **Response (200 OK):** ```json { "result": true, "batch_id": "{batch_id}", "creator": "my-importer", "count_total": 2, "count_succeeded": 1, "count_errored": 1, "results": [ { "index": 0, "filename": "doc-001.txt", "status": "ok", "upload_id": "{upload_id}", "node_id": "{node_id}" }, { "index": 1, "filename": "doc-002.txt", "status": "error", "error_code": 196420, "error_message": "hash_algo and hash must both be provided for a manifest entry." } ], "created_ts": 1745000000 } ``` **Response fields:** | Field | Type | Description | |---|---|---| | `batch_id` | string | Same identifier from the path. | | `creator` | string or null | Echo-back tag, or null if none was supplied. | | `count_total`, `count_succeeded`, `count_errored` | integer | Aggregate counts. | | `results` | array | Per-file outcomes, same shape as the POST response. | | `created_ts` | integer | Unix timestamp when the batch was recorded. | **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1605 (Invalid Input)` | 406 | `batch_id` missing or not a valid opaque identifier. | | `1609 (Not Found)` | 404 | No record found, or the 1-hour TTL elapsed. 404 is returned for both cases; callers cannot distinguish "never existed" from "expired". | ### Notes - The batch endpoint is rate-limited in an independent bucket from `POST /current/upload/`. A client doing bulk uploads and then a chunked upload is not double-charged. - Each successful file produces an `upload_session_created` event -- downstream consumers (search indexing, AI pipelines) see the same event stream as N independent `POST /current/upload/` calls. - Partial success is the documented contract. A failure on file 37 does not roll back files 0-36; they are already persisted. - Files over 4 MB in the batch return per-file errors pointing back to `POST /current/upload/` -- the rest of the batch still processes. - A large batch can stop early when the request reaches the server's per-request processing budget. Entries already processed keep their results; every entry that was not started comes back with `status: "error"` and a retryable message asking you to resubmit that file. Match on `error_code` `190079` (not the message text) to find them, and resubmit **only** those entries in a new batch -- do not resend the whole batch, since entries reported `ok` are already stored. Re-number the resubmitted entries' `index` from 0 in the new manifest, with matching `file_{index}` parts, because indices must be contiguous from 0. --- ## Web Upload (URL Import) Import files from a public HTTPS URL. Supports OAuth-protected URLs (Google Drive, OneDrive, Dropbox, Box, iCloud). The server downloads the file in the background and streams it through the standard upload pipeline. ### Create web upload job ``` POST /current/web_upload/ Content-Type: application/x-www-form-urlencoded Authorization: Bearer {jwt_token} ``` **Parameters:** | Parameter | Type | Required | Description | |---|---|---|---| | `source_url` | string | Yes | URL to download the file from. Max 2048 characters. Must be `https://` on the default port (443) with a fully qualified domain name; plain HTTP, IP-address hosts, credentials embedded in the URL, and hosts that do not resolve to public addresses are refused. | | `file_name` | string | Yes | Filename to save as (1-255 chars). | | `profile_id` | string | Yes | Target workspace or share profile ID (19-digit numeric) | | `profile_type` | string | Yes | `"workspace"` or `"share"` | | `folder_id` | string | No | Target folder OpaqueId or `"root"` for storage root | | `relative_path` | string | No | Relative path for automatic folder creation (1-8192 chars). Each path segment is itself capped at 255 characters, counted as characters not bytes; a segment over the limit is rejected (never shortened), with an error naming the offending segment, its length, and the limit. | | `options` | integer | No | Non-negative integer bitfield (default: 0). Stored and echoed back on the job, but currently has no effect on how the import is processed. | | `creator` | string | No | Client identifier string (1-150 chars, alphanumeric and hyphens only) | **curl example:** ```bash curl -X POST "https://api.fast.io/current/web_upload/" \ -H "Authorization: Bearer {jwt_token}" \ -d "source_url=https://example.com/files/document.pdf" \ -d "file_name=document.pdf" \ -d "profile_id=1234567890123456789" \ -d "profile_type=workspace" \ -d "folder_id=root" ``` **Response (201 Created):** ```json { "result": true, "web_upload": { "id": "5dpct-ajic7-kyc2q-fpism-fg4xc-2ehf", "user_id": "1234567890123456789", "profile_id": "1234567890123456789", "profile_type": "workspace", "source_url": "[redacted]", "file_name": "document.pdf", "folder_id": null, "relative_path": null, "status": "queued", "bytes_downloaded": 0, "expected_size": null, "upload_session_id": null, "async_job_id": null, "status_description": "Queued for processing", "creator": null, "error_message": null, "options": 0, "created": "2025-01-26 15:00:00 UTC", "updated": "2025-01-26 15:00:00 UTC" } } ``` **Response fields:** | Field | Type | Description | |---|---|---| | `web_upload.id` | string | Web upload job OpaqueId | | `web_upload.user_id` | string | ID of the user who created the job | | `web_upload.profile_id` | string | Target workspace or share ID | | `web_upload.profile_type` | string | `"workspace"` or `"share"` | | `web_upload.source_url` | string | Always the literal `[redacted]`. The submitted URL is never returned on any response, because it routinely carries a credential (a provider OAuth token from the cloud picker, or a presigned signature). The field is retained so the response shape is stable. | | `web_upload.file_name` | string | Destination filename | | `web_upload.folder_id` | string/null | Target folder OpaqueId | | `web_upload.relative_path` | string/null | Relative path for folder creation | | `web_upload.status` | string | Status string (e.g., `"queued"`, `"downloading"`, `"complete"`) | | `web_upload.bytes_downloaded` | integer | Bytes downloaded so far (0 initially) | | `web_upload.expected_size` | integer/null | Expected file size (from HEAD request, if known) | | `web_upload.upload_session_id` | string/null | Linked upload session ID (populated during uploading phase) | | `web_upload.async_job_id` | string/null | The async job ID processing this upload | | `web_upload.status_description` | string | Human-readable status description | | `web_upload.creator` | string/null | Echoed-back client identifier supplied at creation, or `null` if none was supplied. | | `web_upload.error_message` | string/null | Error details if failed | | `web_upload.options` | integer | Options bitfield | | `web_upload.created` | string | Creation timestamp | | `web_upload.updated` | string | Last update timestamp | **Error responses:** | Error Code | HTTP Status | Message | |---|---|---| | `1605 (Invalid Input)` | 406 | "Only HTTPS URLs are supported." | | `1605 (Invalid Input)` | 406 | "IP addresses are not allowed; a fully qualified domain name is required." | | `1605 (Invalid Input)` | 406 | "A fully qualified domain name is required." | | `1605 (Invalid Input)` | 406 | "URL is not supported." -- non-443 port, credentials in the URL, a blocked host, or a host that does not resolve to public addresses. | | `1605 (Invalid Input)` | 406 | "Invalid profile_type. Must be \"workspace\" or \"share\"." | | `1680 (Access Denied)` | 401 | "You do not have permission to upload to this workspace." | | `1680 (Access Denied)` | 401 | "You do not have permission to upload to this share." | | `1658 (Not Acceptable)` | 406 | "You have too many active web uploads..." | | `1654 (Internal Error)` | 500 | "Failed to create web upload job." | **OAuth for protected URLs:** For Google Drive, OneDrive, and other OAuth-protected files, include the access token as a query parameter in the source URL: ``` https://www.googleapis.com/drive/v3/files/{fileId}?alt=media&access_token={oauth_token} ``` The server extracts the token from the URL, removes it from the query string, and sends it as an `Authorization: Bearer` header on all HTTP requests. Tokens are never logged or returned in API responses. ### List web upload jobs ``` GET /current/web_upload/ Authorization: Bearer {jwt_token} ``` **Query parameters:** | Parameter | Type | Required | Default | Description | |---|---|---|---|---| | `limit` | integer | No | 50 | Maximum number of results (1-100) | | `offset` | integer | No | 0 | Pagination offset | | `status` | string | No | - | Filter by status: `"pending"`, `"queued"`, `"downloading"`, `"uploading"`, `"complete"`, `"failed"`, `"canceled"` | **curl example:** ```bash curl -X GET "https://api.fast.io/current/web_upload/?status=downloading&limit=20" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "web_uploads": [ { "id": "5dpct-ajic7-kyc2q-fpism-fg4xc-2ehf", "user_id": "1234567890123456789", "profile_id": "1234567890123456789", "profile_type": "workspace", "source_url": "[redacted]", "file_name": "document.pdf", "folder_id": null, "relative_path": null, "status": "downloading", "status_description": "Downloading file from URL", "bytes_downloaded": 5242880, "expected_size": 52428800, "progress_percent": 10, "upload_session_id": null, "error_message": null, "created": "2025-01-26 15:00:00 UTC", "updated": "2025-01-26 15:01:00 UTC" } ], "total": 1, "limit": 20, "offset": 0 } ``` **Response fields:** | Field | Type | Description | |---|---|---| | `web_uploads` | array | Array of web upload job objects | | `web_uploads[].id` | string | Web upload job OpaqueId | | `web_uploads[].profile_type` | string | `"workspace"` or `"share"` | | `web_uploads[].status` | string | Status string (e.g., `"downloading"`, `"complete"`) | | `web_uploads[].status_description` | string | Human-readable status description | | `web_uploads[].bytes_downloaded` | integer | Bytes downloaded so far | | `web_uploads[].expected_size` | integer/null | Expected file size (null if unknown) | | `web_uploads[].progress_percent` | integer | Download progress percentage (0-100) | | `web_uploads[].upload_session_id` | string/null | Linked upload session ID | | `web_uploads[].error_message` | string/null | Error details if failed | | `total` | integer | Total count of matching records | | `limit` | integer | Applied limit | | `offset` | integer | Applied offset | ### Get web upload job details ``` GET /current/web_upload/{upload_id}/details/ Authorization: Bearer {jwt_token} ``` **Path parameters:** | Parameter | Type | Required | Description | |---|---|---|---| | `{upload_id}` | string | Yes | The web upload job OpaqueId | **curl example:** ```bash curl -X GET "https://api.fast.io/current/web_upload/5dpct-ajic7-kyc2q-fpism-fg4xc-2ehf/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "web_upload": { "id": "5dpct-ajic7-kyc2q-fpism-fg4xc-2ehf", "user_id": "1234567890123456789", "profile_id": "1234567890123456789", "profile_type": "workspace", "source_url": "[redacted]", "file_name": "document.pdf", "folder_id": null, "relative_path": null, "status": "complete", "bytes_downloaded": 52428800, "expected_size": 52428800, "upload_session_id": "5uhlp-zzcds-g2ba5-rqnbo-s5ytn-geda", "async_job_id": "apkti-6i76h-6o4xr-5aqkj-egxgu-zmrc", "status_description": "Upload complete", "creator": null, "error_message": null, "options": 0, "created": "2025-01-26 15:00:00 UTC", "updated": "2025-01-26 15:02:00 UTC" } } ``` Note: Both the details and list endpoints return status as string values (e.g., `"pending"`, `"queued"`, `"downloading"`, `"uploading"`, `"complete"`, `"failed"`, `"canceled"`). **Error responses:** | Error Code | HTTP Status | Message | |---|---|---| | `1609 (Not Found)` | 404 | "Web upload job not found." | | `1680 (Access Denied)` | 401 | "You do not have permission to view this web upload job." | | *(generated per call site)* | 403 | "Access to this organization is not permitted from your current location or network." -- `params.reason: "geo_restricted"`. The job is not cancelled by the refusal; a genuine 404 still means the job is gone. See *Access Policy* in `llms/orgs.txt`. | ### Cancel web upload job ``` DELETE /current/web_upload/?id={web_upload_id} Authorization: Bearer {jwt_token} ``` **Query parameters:** | Parameter | Type | Required | Description | |---|---|---|---| | `id` | string | Yes | The web upload job OpaqueId to cancel | **curl example:** ```bash curl -X DELETE "https://api.fast.io/current/web_upload/?id=5dpct-ajic7-kyc2q-fpism-fg4xc-2ehf" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "canceled": true, "id": "5dpct-ajic7-kyc2q-fpism-fg4xc-2ehf" } ``` **Error responses:** | Error Code | HTTP Status | Message | |---|---|---| | `1609 (Not Found)` | 404 | "Web upload job not found." | | `1680 (Access Denied)` | 401 | "You do not have permission to cancel this web upload job." | | `1658 (Not Acceptable)` | 406 | "This web upload job cannot be canceled because it is already in a terminal state." | | `1654 (Internal Error)` | 500 | "Failed to cancel web upload job." | ### Web Upload Status Values | Status | Value | Description | Terminal | |---|---|---|---| | `pending` | 1 | Job created, waiting for async job pickup | No | | `queued` | 2 | Async job has been queued for processing | No | | `downloading` | 3 | Actively downloading from the source URL | No | | `uploading` | 4 | Feeding downloaded chunks to upload system | No | | `complete` | 5 | Upload successfully completed | Yes | | `failed` | 6 | Download or upload failed | Yes | | `canceled` | 7 | User canceled the web upload | Yes | ### Web Upload Limits | Limit | Value | |---|---| | Max active per user | 50 (non-terminal jobs) | | Max file size | Up to 100 GB (subject to plan limits) | | Max retries | 3 (automatic on transient failures) | | Retry delay | 60 seconds between attempts | --- ## Upload Management Endpoints ### List all upload sessions ``` GET /current/upload/details/ Authorization: Bearer {jwt_token} ``` Returns all upload sessions for the current user in any state. **curl example:** ```bash curl -X GET "https://api.fast.io/current/upload/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "sessions": [ { "id": "5uhlp-zzcds-g2ba5-rqnbo-s5ytn-geda", "filename": "document.pdf", "size": 52428800, "status": "uploading", "hash": "e3b0c44298fc1c14...", "hash_algo": "sha256", "created": "2025-01-20 10:30:00 UTC", "updated": "2025-01-20 10:35:00 UTC" } ] } ``` **Response fields:** | Field | Type | Description | |---|---|---| | `results` | integer | Total number of sessions (only present when > 1) | | `sessions` | array | Array of upload session objects -- the same fields as `session` on `GET /current/upload/{upload_id}/details/`, without `chunks`. | ### Delete/cancel an upload session ``` DELETE /current/upload/{upload_id} Authorization: Bearer {jwt_token} ``` **Path parameters:** | Parameter | Type | Required | Description | |---|---|---|---| | `{upload_id}` | string | Yes | The upload session ID to delete (appended to URL path) | Cancel and delete an active upload session. Cleans up temporary chunk files and releases session quota. If the session has an associated web upload job, that job is automatically canceled. Sessions can be deleted in states: `ready`, `uploading`, `assembly_failed`, `store_failed`, `complete`. Sessions in `assemble`, `assembling`, `store`, `storing`, or `stored` states cannot be deleted. **curl example:** ```bash curl -X DELETE "https://api.fast.io/current/upload/5uhlp-zzcds-g2ba5-rqnbo-s5ytn-geda" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true } ``` **Error responses:** | Error Code | HTTP Status | Message | |---|---|---| | `1605 (Invalid Input)` | 406 | "The `id` provided is not found or is not associated with your account." | | `1658 (Not Acceptable)` | 406 | "The session `id` provided is not in a valid state to delete." | | `1693 (Temporarily Unavailable)` | 503 | "The upload session is finalizing. Please retry the delete shortly." (retryable) | | `1654 (Internal Error)` | 500 | "We were unable to delete the requested upload session." | ### Get upload limits ``` GET /current/upload/limits/ Authorization: Bearer {jwt_token} ``` Returns upload limits based on the user's billing plan and the target context. **Query parameters:** | Parameter | Type | Required | Default | Description | |---|---|---|---|---| | `action` | string | No | - | `"create"` or `"update"` to get limits in context of a target | | `org` | string | No | - | Organization ID for limit resolution (used when no `action` specified) | | `instance_id` | string | Required if action=create or update | - | Target workspace or share ID | | `folder_id` | string | No | - | Target folder OpaqueId or `"root"` | | `file_id` | string | Required if action=update | - | File ID for update context | **curl example:** ```bash # General limits (with org context) curl -X GET "https://api.fast.io/current/upload/limits/?org=1234567890123456789" \ -H "Authorization: Bearer {jwt_token}" # Limits for creating a file in a workspace curl -X GET "https://api.fast.io/current/upload/limits/?action=create&instance_id=1234567890123456789" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "limits": { "chunk_size": 262144000, "size": 107374182400, "chunks": 1000, "sessions": 10000, "sessions_size_max": 1099511627776 } } ``` **Response fields:** | Field | Type | Description | |---|---|---| | `limits.chunk_size` | integer | Maximum size of a single chunk in bytes | | `limits.size` | integer | Maximum total file size in bytes | | `limits.chunks` | integer | Maximum number of chunks per upload session | | `limits.sessions` | integer | Maximum concurrent active upload sessions | | `limits.sessions_size_max` | integer | Maximum aggregate size of all active sessions in bytes | ### Get restricted file extensions ``` GET /current/upload/limits/extensions/ ``` Returns restricted and archive file extensions. **Authentication is optional** -- unauthenticated requests fall back to the most restrictive (unpaid) extension policy. This is only the default upload-restriction baseline; it does not grant a usable plan, and new organizations require a paid plan to operate. **Query parameters:** | Parameter | Type | Required | Default | Description | |---|---|---|---|---| | `plan` | string | No | User's plan or `"unpaid"` | Override the billing plan to check restrictions for. Takes a plan id (for example `starter_monthly`); an unknown id is rejected with an input error. The legacy value `"free"` is still accepted and resolves to `"unpaid"`, which is what the response echoes back. | **curl example:** ```bash curl -X GET "https://api.fast.io/current/upload/limits/extensions/" # With specific plan curl -X GET "https://api.fast.io/current/upload/limits/extensions/?plan=starter_monthly" ``` **Response (200 OK):** ```json { "result": true, "restricted_extensions": [".exe", ".apk", ".jar", ".php"], "archive_extensions": [".7z", ".zip", ".rar", ".tar.gz", ".bz2"], "enforcement_enabled": false, "plan": "unpaid", "cache_ttl": 86400 } ``` **Response fields:** | Field | Type | Description | |---|---|---| | `restricted_extensions` | string[] | Extensions blocked for the plan | | `archive_extensions` | string[] | Archive extensions (only populated if the plan restricts archives) | | `enforcement_enabled` | boolean | Whether extension restriction enforcement is currently active. While `false` the lists are informational only: uploads are not refused by extension or file type. | | `plan` | string | The plan used for this response | | `cache_ttl` | integer | Suggested client-side cache TTL in seconds (86400 = 24 hours) | Clients should call this once on startup and cache the results for 24 hours. ### List supported hash algorithms ``` GET /current/upload/algos/ Authorization: Bearer {jwt_token} ``` **curl example:** ```bash curl -X GET "https://api.fast.io/current/upload/algos/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "algos": ["md5", "sha1", "sha256", "sha384", "crc32c"] } ``` `crc32c` is the recommended client integrity checksum -- see *Integrity Checksums (CRC-32C)*. ### Get chunk information ``` GET /current/upload/{upload_id}/chunk/ Authorization: Bearer {jwt_token} ``` Returns information about all uploaded chunks for a session. **Path parameters:** | Parameter | Type | Required | Description | |---|---|---|---| | `{upload_id}` | string | Yes | The upload session ID | To retrieve a specific chunk, append the chunk number to the path: ``` GET /current/upload/{upload_id}/chunk/{order} ``` | Parameter | Type | Required | Description | |---|---|---|---| | `{order}` | integer | No | Specific chunk number to retrieve. If omitted, returns all chunks. | **curl example (all chunks):** ```bash curl -X GET "https://api.fast.io/current/upload/5uhlp-zzcds-g2ba5-rqnbo-s5ytn-geda/chunk/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK, all chunks):** ```json { "result": true, "chunks": { "1": 5242880, "2": 5242880, "3": 2097152 } } ``` **curl example (single chunk):** ```bash curl -X GET "https://api.fast.io/current/upload/5uhlp-zzcds-g2ba5-rqnbo-s5ytn-geda/chunk/1" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK, single chunk):** ```json { "result": true, "chunk": { "1": 5242880 } } ``` **Error responses:** | Error Code | HTTP Status | Message | |---|---|---| | `1609 (Not Found)` | 404 | "The supplied chunk not valid or found." | ### Delete a chunk ``` DELETE /current/upload/{upload_id}/chunk/?order={n} Authorization: Bearer {jwt_token} ``` Delete a specific chunk from an upload session. Session must be in `uploading` state. **Query parameters:** | Parameter | Type | Required | Description | |---|---|---|---| | `order` | integer | Yes | The chunk number to delete | **curl example:** ```bash curl -X DELETE "https://api.fast.io/current/upload/5uhlp-zzcds-g2ba5-rqnbo-s5ytn-geda/chunk/?order=3" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true } ``` **Error responses:** | Error Code | HTTP Status | Message | |---|---|---| | `1658 (Not Acceptable)` | 406 | "The session `id` provided is not in a valid state to delete a chunk." | | `1654 (Internal Error)` | 500 | "We were unable to delete the requested upload session chunk." | --- ## Quick Reference ### Small file (one request, auto-add): ``` POST /current/upload/ multipart: name, size, chunk, action=create, instance_id, folder_id -> 201: {id, new_file_id} # new_file_id usually null (finalization is asynchronous) GET /current/upload/{id}/details/?wait=60 # When new_file_id was null -> 200: {session: {status, new_file_id}} ``` ### Large file (chunked): ``` POST /current/upload/ # Create session form: name, size, action=create, instance_id, folder_id -> 201: {id} POST /current/upload/{id}/chunk/?order=N&size=N # Upload chunks (up to 3 parallel) multipart: chunk query (recommended): hash_algo=crc32c, hash; file_crc32c on the carrier chunk -> 202 POST /current/upload/{id}/complete/ # Trigger assembly -> 202 GET /current/upload/{id}/details/?wait=60 # Poll until complete -> 200: {session: {status, new_file_id}} DELETE /current/upload/{id} # Clean up session -> 200 ``` ### Stream upload (unknown file size): ``` POST /current/upload/ # Create stream session form: name, stream=true, max_size, action=create, instance_id -> 201: {id} POST /current/upload/{id}/stream/ # Stream file body body: raw binary (application/octet-stream) -> 201 (auto-finalizes) ``` ### Manual add to storage (if no instance_id): ``` POST /current/workspace/{id}/storage/{folder}/addfile/ form: name, from={"type":"upload","upload":{"id":"{upload_id}"}} -> 200 ``` ### Batch upload (many small files): ``` POST /current/upload/batch/ # Submit up to 200 small files multipart: instance_id, creator?, manifest (JSON), file_0..file_N (or) application/json: {instance_id, creator?, files: [{filename, content_b64, hash_algo?, hash?}]} -> 200: {batch_id, count_total, count_succeeded, count_errored, results[]} GET /current/upload/batch/{batch_id}/ # Fetch status record (1-hour TTL) -> 200: {batch_id, creator?, count_total, count_succeeded, count_errored, results[], created_ts} ``` ### Web upload (URL import): ``` POST /current/web_upload/ # Create job form: source_url, file_name, profile_id, profile_type -> 201: {web_upload} GET /current/web_upload/ # List jobs query: limit, offset, status -> 200: {web_uploads, total} GET /current/web_upload/{id}/details/ # Get job details -> 200: {web_upload} DELETE /current/web_upload/?id={id} # Cancel job -> 200: {canceled, id} ``` ### Upload management: ``` GET /current/upload/details/ # List all sessions GET /current/upload/limits/ # Get plan limits GET /current/upload/limits/extensions/ # Get restricted extensions GET /current/upload/algos/ # List hash algorithms GET /current/upload/{id}/chunk/ # Get all chunk info GET /current/upload/{id}/chunk/{order} # Get single chunk info DELETE /current/upload/{id}/chunk/?order=N # Delete a chunk ``` --- ## Best Practices - **Check limits first**: Query `/upload/limits/` and `/upload/limits/extensions/` before starting uploads. - **Use hash validation**: Always provide chunk and file hashes to detect corruption early. - **Default to CRC-32C**: Send `hash_algo=crc32c` with each chunk's CRC-32C, and send the whole-file value -- your per-chunk CRCs combined in order, no second pass -- as `file_crc32c` on the first send of the chunk whose CRC completes last (sequential clients: the last chunk), and on that chunk's retries only. Treat `10778` as terminal (re-upload in a new session) and `10779` as a client bug (resend the original value). After completion, compare the file's `crc32c` in node details with your local value. - **Implement retry logic**: Failed chunk uploads can be retried by re-uploading the same `order`. - **Track chunks locally**: Maintain a local record of successfully uploaded chunks for resumability. - **Long-poll for completion**: Use the `wait` parameter on the details endpoint instead of frequent polling. - **Clean up failures**: DELETE failed sessions to free session quota. - **Cache extension restrictions**: Call `/upload/limits/extensions/` once and cache for 24 hours. - **Use auto-finalization**: When all chunks total the declared file size, assembly triggers automatically. Explicit `/complete/` is optional but recommended for reliability. - **Omit `relative_path` when unused**: Do NOT send it as an empty string. > Part of the Fastio API Reference. Overview: https://api.fast.io/current/llms/ # AI, Chat & Metadata Base URL: `https://api.fast.io/current/` All endpoints require authentication unless noted: `Authorization: Bearer {jwt_token}` --- ## Endpoint paths (`/ai/agent/`) The AI agent endpoints are served under `/ai/agent/`, which is the **only** path family. The older `/ai/chat/` paths have been **retired and removed** — they no longer respond, and there is no alias resolving them to the agent handlers. If you have an older integration still calling `/ai/chat/...`, update it to the equivalent `/ai/agent/...` path. The core request and response shapes carry over, but **file attachment changed**: the old `files_attach` string parameter is gone, files and folders are now attached as reference items, and a file that cannot be attached returns an error instead of being silently ignored. See *Attaching Files and Folders* below. --- ## Overview Fastio provides built-in AI capabilities for workspaces and shares: - **RAG-powered chat** -- Ask questions about indexed files with citations to specific pages and snippets - **General chat** -- AI conversation with optional file attachments for one-off analysis - **Auto-summarization** -- AI-generated titles and descriptions for shares - **Metadata extraction** -- AI-powered structured metadata extraction from documents, spreadsheets, images, and code. Runs automatically on upload where the workspace's `intelligence` and `metadata_extraction` settings are both on (see the Workspaces reference, *Automatic Metadata Extraction Setting*); explicit per-file and per-folder extraction is always available on plans with the `metadata` feature - **Semantic search** -- Find files by meaning, not just keywords - **Notes** -- Markdown storage nodes that are indexed for RAG, letting you bank knowledge over time Fastio's AI is a **full agent**, not a read-only Q&A tool. In addition to reading, analyzing, searching, and answering questions about your files, it can take actions on your behalf — for example, creating documents and notes and organizing your content. It always acts within your own permissions and plan entitlements, and its actions consume credits like any other operation. --- ## Plan Requirements AI capabilities are gated by a set of billing-plan features: - **`content_ai`** -- master switch for AI features. Covers the baseline AI surfaces: listing chats, reading chat details and message history, and streaming existing responses. (Notes are ordinary storage nodes and are not gated by it.) Available on all paid plans. - **`ai_agent`** -- narrower gate for interactive agentic flows. Required to **create** chats, **send** chat messages, and **enable** the `intelligence` toggle on a workspace or portal share (the toggle controls the RAG indexing pipeline that feeds the chat surface). - **`ai_autotitle`** -- gates the share **auto-title / description** endpoint (`POST /share/{id}/ai/autotitle/`). A plan without this feature is rejected when calling that endpoint. - **`ai_autoog`** -- gates the AI-generated **OG image** for **private** shares (`GET /share/{id}/ai/autoog/`). A private share on a plan without this feature is served the default private OG image instead of a custom one; public-share OG generation does not require it. **Plan feature coverage:** | Plan | `content_ai` | `ai_agent` | Effect | |------|--------------|------------|--------| | Starter | on | on | Full AI (chat, Deep Indexing, RAG) | | Business | on | on | Full AI (chat, Deep Indexing, RAG) | | Enterprise | on | on | Full AI (chat, Deep Indexing, RAG) | New organizations choose a paid plan (Starter, Business, or Enterprise), all of which include `content_ai` and `ai_agent` for full AI. **Reading the error codes:** 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. Codes shown as five or six digits (and the `9661`-`9669` family) ARE `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. **How gated endpoints respond when a plan lacks the feature:** - Create chat / send message: `1695 (Upgrade Required)` → HTTP 402 - Attempt to set `intelligence=true` on the workspace **update** endpoint: `1605 (Invalid Input)` with a message indicating Deep Indexing requires a plan that supports agentic AI. Workspace *creation* never rejects on this gate — it creates the workspace with Deep Indexing off instead. An organization on a plan that includes `content_ai` but not `ai_agent` can still ingest, store, search, and share files; agentic chat requires a plan with `ai_agent` (Starter, Business, or Enterprise). --- ## Deep Indexing Setting Deep Indexing (API field: `intelligence`) is a boolean on a workspace or portal share that controls whether uploaded files are automatically indexed for RAG. - **`intelligence=true`** -- Files are auto-indexed for semantic search, summarization, and citation. Required for the agent to search the scope's indexed files (RAG) and cite them. **Requires both `content_ai` and `ai_agent` plan features** (see Plan Requirements above). A new workspace defaults to `intelligence` on when the plan carries both features; on a plan without `ai_agent` it is created off, because the indexing pipeline has no consumer. - **`intelligence=false`** -- Files are stored/shared without RAG indexing. You can still chat with the agent and attach specific file references for direct file analysis (when the plan supports chat). **Shared folder restriction:** Deep Indexing is only available on portal shares (independent storage). Workspace folder shares (`storage_mode=workspace_folder`) cannot have Deep Indexing enabled — their files are indexed through the parent workspace instead. The API will return an error if you attempt to enable Deep Indexing on a shared folder share. **At creation:** ``` POST /current/org/{org_id}/create/workspace/ intelligence=true|false (optional; defaults to true, clamped off by plan) ``` **Change it later:** ``` POST /current/workspace/{workspace_id}/update/ intelligence=true|false ``` **Note:** Deep Indexing can be enabled and disabled within time restrictions. Disabling Deep Indexing destroys indexed embeddings (the vector index is flushed). Re-enabling Deep Indexing incurs re-indexing costs as AI credits are consumed to re-index all files. When a plan loses `ai_agent` (e.g. on downgrade), the RAG indexing pipeline stops even if the instance flag remains set; previously indexed embeddings remain but will not be updated. --- ## How the Agent Uses Files There is **no chat "type" to choose** — a single agent surface handles every conversation. You do not pass a `type` (or `personality`) parameter; the agent adapts to what you send: - **General conversation.** Ask anything; the agent answers from its own knowledge. No files are required. - **Grounded in indexed files (RAG).** When the workspace/share `intelligence` setting is enabled, the agent can search the scope's indexed files and return answers with **citations** to specific files, pages, and text snippets. With no attachments it may draw on the entire indexed scope; only files that reach `ai_state: indexed` participate in that search. - **Focused on specific files/folders.** Attach files or folders to a turn by including them as reference items in the `references`, `content_parts`, or `subjects` arrays (see "Attaching Files and Folders" below). Any file with a ready preview or AI summary is eligible; the agent uses the attached content directly (e.g., "Describe this image", "Summarize this PDF"). A single agent turn can combine all of the above — general reasoning, RAG over the indexed scope, and directly attached files. --- ## Attaching Files and Folders Give the agent specific files or folders as context by including them as **reference items** in the `references`, `content_parts`, or `subjects` array of a create-chat or send-message request. You do **not** assemble a file's metadata yourself — send only the node id (and, for a file, an optional version id), and Fastio resolves the full details server-side after verifying your access and the file's AI-readiness. `subjects` pins objects from **inside** the workspace/share as the turn's focus; a separate `uploads` array carries files staged from **outside** the scope. `content_parts` is an ordered stream that interleaves text segments with inline reference pills (the same item shape). All three of `references`, `content_parts`, and `subjects` are resolved and gated by the rules below. ### File reference ```json { "type": "file", "id": "{node_id}", "file_details": { "node_id": "{node_id}", "version_id": "{version_id}" } } ``` - `version_id` is **optional** — omit it to attach the file's current version. Get a `version_id` from the file's `version` field in a storage list/details response. - Only **file** (or note) nodes may be attached as `type: file`. - The file must be AI-eligible — its `ai.attach` field in storage list/details responses is `true`. ### Folder reference ```json { "type": "folder", "id": "{node_id}", "folder_details": { "node_id": "{node_id}" } } ``` - Attaches a folder so the agent can use its contents as context. Only **folder** nodes may be attached as `type: folder`. - The folder's indexed files participate in the agent's RAG search (requires `intelligence` enabled). ### Limits - Up to **20 files** and **2 GB** total per turn (large images and videos count at their smaller preview size). Each file must also be within its file type's per-file size limit. - Up to **100 distinct file/folder references** across all three arrays, and **200** reference occurrences in total (counting repeats). ### Validation is strict — a bad reference fails the request If a referenced file or folder cannot be attached — it does not exist, you cannot access it, it has been deleted, or it is not AI-eligible — the request is **rejected with an error**, not silently dropped: - `1609 (Not Found)` / 404 — the referenced file or folder does not exist, is not accessible, has been deleted, or a pinned version is unavailable (all reported the same way so a caller cannot probe existence). - `1605 (Invalid Input)` / 406 — a reference is malformed, is missing its node id, carries an invalid/conflicting version id, is the wrong node type (a folder referenced as a file, or vice-versa), or the request exceeds the 20-file / 2 GB / per-file size / 100-reference / 200-occurrence limits. - `1680 (Access Denied)` / 401 — folder attachments are not permitted in this share (a restricted-view guest). Does not occur on workspace chats, where members can view all files. ### Choosing what to attach | Use Case | What to attach | |---|---| | Analyze specific files directly | File reference items in `references` / `content_parts` / `subjects` | | Ground answers in a folder's indexed files (with citations) | Folder reference items (requires `intelligence` enabled) | | Ask general questions across all indexed files | Nothing — the agent may search the whole indexed scope | | General conversation, no files | Nothing | --- ## AI State (File Readiness) Files in a workspace with Deep Indexing enabled progress through AI processing states: | State | Meaning | |---|---| | `disabled` | Deep Indexing not enabled for this file/workspace | | `pending` | Queued for AI processing | | `in_progress` | Currently being processed by AI | | `ready` | File can be used with AI chat (attached directly or via scope). The file has been processed enough (e.g., preview/summary generated) to be usable in AI conversations. | | `indexed` | File contents (for documents) have been indexed via RAG. This state is used when Deep Indexing is enabled on the workspace/share. Indexed files are searchable by semantic meaning and their content is used as grounding in scoped AI chats. | | `failed` | AI processing failed | Files with `ai_state: ready` can be used with AI chat. Files with `ai_state: indexed` have additionally had their contents indexed for RAG-powered semantic search. When Deep Indexing is enabled on a workspace/share, files progress to `indexed` automatically. Check a file's AI state in the `ai.state` field of storage list or file details responses. --- ## Controlling Response Length and Style There is no `personality` parameter. Control verbosity and style directly in your question phrasing: - "In one sentence, what is the main conclusion?" - "List only the file names that mention GDPR, no explanations" - "Give me a brief summary -- 2-3 bullet points max" ## Advanced Per-Turn Fields Beyond `question` and the file-reference arrays, a create-chat or send-message request accepts these optional per-turn fields. All are optional; omit them for normal use. | Field | Type | Description | |---|---|---| | `uploads` | JSON array | Focus files staged from **outside** the workspace/share (as opposed to `subjects`, which pins objects from inside it). | | `view` | JSON object | A snapshot of the caller's current UI view, so the agent can reason about what the user is looking at. | | `activity` | JSON array | Recent-activity entries giving the agent short-term context. | | `role_in_org` | string | The acting user's free-text role in the organization, used to tailor the response. | | `idempotency_key` | string | Client-supplied replay guard (max 64 chars). Re-sending the same key returns the already-created turn instead of creating a duplicate. Omit to have one generated. | The acting user's identity is taken from your Bearer token — it is never read from the request body, so a request cannot spoof who the turn runs as. --- ## Notes (Stored Knowledge) Notes are a storage node type (like files and folders) that store markdown content directly on the server. They appear in storage listings with `"type": "note"` and `"mimetype": "text/markdown"`. Notes are **workspace-only** -- they cannot be created in shares. ### Why Notes Matter In a workspace with Deep Indexing enabled, notes are ingested and indexed just like uploaded files. This makes them a way to **bank knowledge over time** -- store interesting facts, research findings, decisions, or project context. In future AI chats that scope the entire workspace (or include the note's folder), the note content will be used as grounding when the AI searches for relevant information. ### Create a note ``` POST /current/workspace/{workspace_id}/storage/{parent_id}/createnote/ ``` | Parameter | Type | Required | Description | |---|---|---|---| | `name` | string | Yes | Note name, must end in `.md` | | `content` | string | Yes | Markdown content, max 100 KB | `{parent_id}` is a folder OpaqueId or `"root"`. Returns the created note as a node resource. ### Update a note ``` POST /current/workspace/{workspace_id}/storage/{node_id}/updatenote/ ``` | Parameter | Type | Required | Description | |---|---|---|---| | `name` | string | No | New name, must end in `.md` | | `content` | string | No | New markdown content, max 100 KB, non-blank (an empty or whitespace-only value is rejected) | At least one of `name` or `content` must be provided. Updating content creates a new version. ### Read note content ``` GET /current/workspace/{workspace_id}/storage/{node_id}/read/ ``` Returns the raw markdown content. ### Linking a user to a note - In workspace context: `https://{org_domain}.fast.io/workspace/{workspace_name}?note={note_opaque_id}` - Direct preview: use the preview URL for the note node --- ## Workspace AI Endpoints --- ### Create a new chat ``` POST /current/workspace/{workspace_id}/ai/agent/ ``` Creates a thread and its first turn; the AI processes it asynchronously. There is **no** `type` or `personality` parameter — the request body is the initial question plus optional file references and the thread create-time fields. **Auth:** Bearer token required. Workspace `view` permission. `content_ai` and `ai_agent` plan features required. **Parameters:** | Parameter | Type | Required | Default | Description | |---|---|---|---|---| | `question` | string | Yes | -- | Initial question, 1-32,000 characters. Always required; when a non-empty `content_parts` is also sent, `content_parts` carries the message text and `question` is ignored. | | `privacy` | string | No | `private` | `private` or `public`. **`public` is currently disabled platform-wide** -- a `privacy=public` request returns `403 Forbidden`; see "Publish a private chat" below. | | `name` | string | No | Auto-generated | Chat name. A default is used if omitted. | | `kind` | string | No | `user` | `user` or `agent`. `agent` flags the chat as agentic. Set at creation, immutable thereafter. | | `references` | JSON array | No | -- | File/folder reference items to attach as context — each a `{type, id}` file or folder item (see Attaching Files and Folders). Up to 20 files / 2 GB / 100 references; the backend resolves each item's full details server-side. | | `content_parts` | JSON array | No | -- | Ordered content stream — text segments plus inline file/folder reference pills (same item shape as `references`). | | `subjects` | JSON array | No | -- | File/folder reference items pinned as focus subjects for the turn (same item shape as `references`). | | `uploads` | JSON array | No | -- | Focus files staged from outside the workspace. | Also accepts the optional `view`, `activity`, `role_in_org`, and `idempotency_key` fields (see "Advanced Per-Turn Fields" above). **Request example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/ai/agent/" \ -H "Authorization: Bearer {jwt_token}" \ -d "question=What were the Q3 revenue figures?" \ -d "privacy=private" ``` **Response (200 OK):** ```json { "result": true, "thread": { "thread_id": "9q7kc-dczsx-jonff-m4apj-5g5q2-milk", "creator": { "type": "user", "id": "1234567890123456789" }, "scope": { "type": "workspace", "id": "1234567890123456789" }, "name": "New Chat", "status": "ready", "kind": "user", "cost": { "credits": 0, "tokens": 0 }, "privacy": { "visibility": "private", "owner": { "type": "user", "id": "1234567890123456789" } }, "created_at": "2026-07-07 16:37:29 UTC", "updated_at": "2026-07-07 16:37:29 UTC" }, "turn": { "turn_id": "95mgw-3avoq-lmzi2-gnqtx-snlay-c4yy", "thread_id": "9q7kc-dczsx-jonff-m4apj-5g5q2-milk", "seq": 1, "status": "pending", "idempotency_key": "3f2a…", "query": { "text": "What were the Q3 revenue figures?" }, "error": null, "cost": { "credits": 0, "tokens": 0 }, "created_at": "2026-07-07 16:37:29 UTC", "updated_at": "2026-07-07 16:37:29 UTC" } } ``` | Field | Type | Description | |---|---|---| | `thread.thread_id` | string | Opaque ID of the created thread (the chat). Use it as `{chat_id}` in the follow-up URLs below. | | `turn.turn_id` | string | Opaque ID of the initial turn (the first message). Use it as the `{message_id}` in the message-details / read URLs. | | `turn.status` | string | Initial turn status — `pending`. The AI processes it asynchronously (see turn statuses under "Get message details"). | The full field lists are in "Chat Session Object Schema" (thread) and "Message Object Schema" (turn) below. **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1605 (Invalid Input)` | 406 | Invalid `privacy`, `kind`, or `name`, or a missing or invalid-length `question` | | `1609 (Not Found)` | 404 | An attached file or folder reference does not exist or is not accessible | | `1605 (Invalid Input)` | 406 | An attached reference is malformed, the wrong node type, or exceeds the 20-file / 2 GB / 100-reference limit | | `1700 (Forbidden)` | 403 | `privacy=public` requested while public chats are disabled platform-wide | | `1660 (Conflict)` | 409 | Thread still committing its first turn (retry with the same idempotency key), or the first message is too large to process | | `1664 (Datastore Error)` | 500 | Thread or turn creation failed | | *(generated per call site)* | 403 | Org AI policy denies the caller Ripley Agent access -- `ai_policy_denied`, `params.feature:"agent"`. See *AI, Deep Indexing & MCP Access Policy* in `llms/orgs.txt`. | | *(generated per call site)* | 403 | The workspace's Deep Indexing switch is on and the org AI policy denies the caller Deep Indexing for it (interim -- see note below) -- `ai_policy_denied` or `ai_policy_workspace_not_allowed` (allowlist arm), `params.feature:"intelligence"`. The `agent` refusal above takes precedence when both apply. | **Interim Deep Indexing refusal on create/send.** Until the AI service supports dropping retrieval mid-turn, a chat **create** or a follow-up **message** call refuses outright (rather than answering without grounding) when the org's AI policy denies the caller Deep Indexing for this workspace/share **and** the target's own Deep Indexing switch is on. With the switch off there is nothing to search, so the call proceeds normally. This does not affect reading existing threads, `publish`, or `update`. --- ### List chats ``` GET /current/workspace/{workspace_id}/ai/agent/list/ ``` Returns the current user's private chats plus any public chats in the workspace, newest-created first, up to 150 per page. Append a numeric offset to page further: `GET .../ai/agent/list/{offset}` (e.g. `.../list/150`). **Auth:** Bearer token required. Workspace `view` permission. `content_ai` plan feature required. **Query parameters:** | Parameter | Type | Required | Default | Description | |---|---|---|---|---| | `kind` | string | No | `user` | Filter by chat kind. Allowed values: `user` (only user-driven chats — the historical default), `agent` (only agentic chats), `all` (user + agent chats). Omit or pass `user` for backwards-compatible behavior. | **Variant:** Append `/deleted` to the path to list chats in `deleted` status: `GET .../ai/agent/list/deleted`. A chat removed with "Delete a chat" is purged outright and does not appear here. **Request example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/ai/agent/list/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "chats": { "count": 1, "items": [ { "thread_id": "9q7kc-dczsx-jonff-m4apj-5g5q2-milk", "creator": { "type": "user", "id": "1234567890123456789" }, "scope": { "type": "workspace", "id": "1234567890123456789" }, "name": "Quarterly report analysis", "status": "ready", "kind": "user", "cost": { "credits": 15, "tokens": 1500 }, "privacy": { "visibility": "private", "owner": { "type": "user", "id": "1234567890123456789" } }, "created_at": "2026-07-07 16:00:00 UTC", "updated_at": "2026-07-07 16:30:05 UTC", "message_count": 5, "continuable": true, "latest_message": { "turn_id": "95mgw-3avoq-lmzi2-gnqtx-snlay-c4yy", "thread_id": "9q7kc-dczsx-jonff-m4apj-5g5q2-milk", "seq": 5, "status": "complete", "idempotency_key": "3f2a…", "query": { "text": "Summarize the quarterly report" }, "error": null, "cost": { "credits": 3, "tokens": 300 }, "created_at": "2026-07-07 16:30:00 UTC", "updated_at": "2026-07-07 16:30:05 UTC" } } ] } } ``` | Field | Type | Description | |---|---|---| | `chats` | object | Collection envelope `{count, items}` | | `chats.count` | integer | Number of chat items returned in `items` | | `chats.items` | array | Array of thread (chat) objects | | `chats.items[].thread_id` | string | Opaque ID of the thread (the chat) | | `chats.items[].creator` | object | `{type, id}` -- the chat creator | | `chats.items[].scope` | object | `{type, id}` -- the workspace or share the chat lives in | | `chats.items[].name` | string | Chat display name | | `chats.items[].status` | string | Chat status: `created`, `ready`, `in_progress`, or `closed` (`deleted` on the `/deleted` variant) | | `chats.items[].kind` | string | `user` or `agent`. Always present; chats created before the field existed default to `user`. | | `chats.items[].message_count` | integer | Total turns (messages) in the chat | | `chats.items[].continuable` | boolean | `true` if the chat can be continued with a new message; `false` if the chat has no resumable conversation state (read-only history, e.g. a legacy chat migrated for history only). Omitted on create/update responses. | | `chats.items[].latest_message` | object/null | Most recent turn (see "Message Object Schema"), or `null` for an empty thread | | `chats.items[].cost.credits` | integer | Credit charge for the chat (raw tokens converted at the meter rate) | | `chats.items[].cost.tokens` | integer | Raw token consumption the credit charge derives from | | `chats.items[].privacy` | object | `{visibility, owner}` | | `chats.items[].created_at` | string | Creation timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `chats.items[].updated_at` | string | Last update timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | --- ### Get chat details ``` GET /current/workspace/{workspace_id}/ai/agent/{chat_id}/details/ ``` Returns the chat with its message history — the first 150 turns, oldest first (`thread.message_count` is the full total; page further with "List messages in a chat"). **Auth:** Bearer token required. `content_ai` plan feature required. **Request example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/ai/agent/9q7kc-dczsx-jonff-m4apj-5g5q2-milk/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** The response is a `thread` object plus a separate `turns` collection (the message history) — there is no `chat` object and no embedded `messages` array. ```json { "result": true, "thread": { "thread_id": "9q7kc-dczsx-jonff-m4apj-5g5q2-milk", "creator": { "type": "user", "id": "1234567890123456789" }, "scope": { "type": "workspace", "id": "1234567890123456789" }, "name": "Quarterly report analysis", "status": "ready", "kind": "user", "cost": { "credits": 5, "tokens": 500 }, "privacy": { "visibility": "private", "owner": { "type": "user", "id": "1234567890123456789" } }, "created_at": "2026-07-07 16:00:00 UTC", "updated_at": "2026-07-07 16:30:05 UTC", "message_count": 1, "continuable": true }, "turns": { "count": 1, "items": [ { "turn_id": "95mgw-3avoq-lmzi2-gnqtx-snlay-c4yy", "thread_id": "9q7kc-dczsx-jonff-m4apj-5g5q2-milk", "seq": 1, "status": "complete", "idempotency_key": "3f2a…", "query": { "text": "Summarize the quarterly report" }, "error": null, "cost": { "credits": 5, "tokens": 500 }, "created_at": "2026-07-07 16:30:00 UTC", "updated_at": "2026-07-07 16:30:05 UTC" } ] } } ``` The `turns.items` entries are the lightweight turn shape (no answer blob). Fetch a single turn's full answer, citations, and action replay via **Get message details** below. `turns` is seq-ascending (oldest first). **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1609 (Not Found)` | 404 | Chat not found or not accessible | | `1680 (Access Denied)` | 401 | You do not have permission to access this thread | --- ### Update a chat ``` POST /current/workspace/{workspace_id}/ai/agent/{chat_id}/update/ ``` Update the name of an existing chat. The chat `kind` is set at creation and cannot be changed via this endpoint — any `kind` value supplied in the body is silently ignored. **Auth:** Bearer token required. `content_ai` plan feature required. Only the chat's creator or a workspace admin may rename it; other members can read a public chat but cannot rename it. | Parameter | Type | Required | Description | |---|---|---|---| | `name` | string | Yes | New chat name | **Request example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/ai/agent/9q7kc-dczsx-jonff-m4apj-5g5q2-milk/update/" \ -H "Authorization: Bearer {jwt_token}" \ -d "name=Updated Chat Name" ``` **Response (200 OK):** The updated `thread` object (without `message_count` / `continuable`). Its `cost` is summed from the chat's turns, as on list and details: ```json { "result": true, "thread": { "thread_id": "9q7kc-dczsx-jonff-m4apj-5g5q2-milk", "creator": { "type": "user", "id": "1234567890123456789" }, "scope": { "type": "workspace", "id": "1234567890123456789" }, "name": "Updated Chat Name", "status": "ready", "kind": "user", "cost": { "credits": 0, "tokens": 0 }, "privacy": { "visibility": "private", "owner": { "type": "user", "id": "1234567890123456789" } }, "created_at": "2026-07-07 16:00:00 UTC", "updated_at": "2026-07-07 16:45:12 UTC" } } ``` **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1658 (Not Acceptable)` | 406 | Chat not found or not accessible | | `1680 (Access Denied)` | 401 | You do not have permission to update this chat (only the creator or a workspace admin may rename a chat) | | `1605 (Invalid Input)` | 406 | Invalid name value | | `1664 (Datastore Error)` | 500 | Update failed | | *(generated per call site)* | 403 | Org AI policy denies the caller Ripley Agent access -- `ai_policy_denied`, `params.feature:"agent"`. | --- ### Delete a chat ``` DELETE /current/workspace/{workspace_id}/ai/agent/{chat_id}/ ``` **Auth:** Bearer token required. `content_ai` plan feature required. **Request example:** ```bash curl -X DELETE "https://api.fast.io/current/workspace/1234567890123456789/ai/agent/9q7kc-dczsx-jonff-m4apj-5g5q2-milk/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true } ``` **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1658 (Not Acceptable)` | 406 | Chat not found or not accessible | | `1680 (Access Denied)` | 401 | You do not have permission to delete this chat (only the creator or a workspace admin may delete a chat) | | `1660 (Conflict)` | 409 | A message in the chat is still processing — cancel it or retry shortly | | `1654 (Internal Error)` | 500 | Delete failed | Deletion is permanent: the chat and its messages are purged, and the chat does not appear in `GET .../ai/agent/list/deleted`. --- ### Send a follow-up message ``` POST /current/workspace/{workspace_id}/ai/agent/{chat_id}/message/ ``` Send a new message to an existing chat. The message is processed asynchronously. **Auth:** Bearer token required. `content_ai` and `ai_agent` plan features required. **Parameters:** | Parameter | Type | Required | Default | Description | |---|---|---|---|---| | `question` | string | Yes | -- | Follow-up question, 1-32,000 characters. Required even when `content_parts` is sent (a non-empty `content_parts` then carries the message instead). | | `references` | JSON array | No | -- | File/folder reference items to attach as context — each a `{type, id}` file or folder item (see Attaching Files and Folders). Up to 20 files / 2 GB / 100 references; the backend resolves each item's full details server-side. | | `content_parts` | JSON array | No | -- | Ordered content stream — text segments plus inline file/folder reference pills (same item shape as `references`). | | `subjects` | JSON array | No | -- | File/folder reference items pinned as focus subjects for the turn (same item shape as `references`). | | `uploads` | JSON array | No | -- | Focus files staged from outside the workspace/share. | Also accepts the optional `view`, `activity`, `role_in_org`, and `idempotency_key` fields (see "Advanced Per-Turn Fields" above). There is no `type` parameter — the turn is appended to the existing thread. **Request example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/ai/agent/9q7kc-dczsx-jonff-m4apj-5g5q2-milk/message/" \ -H "Authorization: Bearer {jwt_token}" \ -d "question=How does that compare to Q2?" ``` **Response (200 OK):** ```json { "result": true, "turn_id": "9togf-axp6b-x5rtm-upjha-m32qf-leqb", "thread_id": "9q7kc-dczsx-jonff-m4apj-5g5q2-milk", "seq": 2, "status": "pending", "idempotency_key": "7c1b…", "query": { "text": "How does that compare to Q2?" }, "error": null, "cost": { "credits": 0, "tokens": 0 }, "created_at": "2026-07-07 16:37:29 UTC", "updated_at": "2026-07-07 16:37:29 UTC" } ``` The created turn's fields are returned at the **top level** (not nested under a `message` object). The `turn_id` is the message id you poll or stream. `status` starts at `pending`; watch it reach a terminal state (see "Get message details"). **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1658 (Not Acceptable)` | 406 | Thread not found, not accessible, or locked | | `1680 (Access Denied)` | 401 | You cannot message this thread; or (share chats only) folder attachment is not permitted for a restricted-view guest | | `1609 (Not Found)` | 404 | An attached file or folder reference does not exist or is not accessible | | `1605 (Invalid Input)` | 406 | An attached reference is malformed, the wrong node type, or exceeds the 20-file / 2 GB / 100-reference limit | | `1660 (Conflict)` | 409 | The conversation has grown too large to continue — start a new chat | | `1664 (Datastore Error)` | 500 | Transient storage error loading an attached file (retryable) | | `1654 (Internal Error)` | 500 | Message creation or queuing failed | | *(generated per call site)* | 403 | Org AI policy denies the caller Ripley Agent access -- `ai_policy_denied`, `params.feature:"agent"`. | | *(generated per call site)* | 403 | The thread's workspace/share has its Deep Indexing switch on and the org AI policy denies the caller Deep Indexing for it (interim -- see "Create a new chat" above) -- `ai_policy_denied` or `ai_policy_workspace_not_allowed`, `params.feature:"intelligence"`. | --- ### Cancel an in-progress message ``` POST /current/workspace/{workspace_id}/ai/agent/{chat_id}/cancel/ ``` Aborts an in-flight AI message instead of waiting for it to finish or time out. The worker stops streaming and the turn transitions to a `cancelled` terminal state, after which a new message can be sent immediately. **Auth:** Bearer token required. Workspace `view` permission. `content_ai` plan feature required (the cancel endpoint does **not** require `ai_agent`, so a tier downgrade mid-stream does not strand the message). **Body:** Empty. **Request example:** ```bash curl -X POST "https://api.fast.io/current/workspace/{workspace_id}/ai/agent/{chat_id}/cancel/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true } ``` The response is always `{ "result": true }` — there is no `success`, `message.id`, or `no_pending_message` field. It is the same whether a pending turn was signalled or there was nothing in flight (idempotent no-op). **Behavior notes:** - **Scoped to the live turn.** The signal targets the thread's lowest-seq non-terminal turn. A thread with no in-flight turn is a clean success no-op. - **Idempotent.** Calling cancel twice in quick succession returns `{ "result": true }` both times. - **Best-effort with bounded latency.** The worker observes the cancel signal between streaming frames; observation typically happens within a few seconds. A turn that is between frames or already in post-processing may complete normally instead of cancelling. - **SSE cancel signal.** A cancelled turn's SSE stream ends with a single terminal `event: cancelled` (its `data` is `{"sentinel":"[CANCELLED]"}`) and then closes — no `done` event follows. SSE clients should listen for the dedicated `cancelled` event (e.g. `eventSource.addEventListener('cancelled', …)`) rather than parsing the payload. - **Turn status.** After a successful cancel the affected turn reaches the `cancelled` terminal state and a new message can be sent immediately. There is no thread-level `cancelled` status. **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1658 (Not Acceptable)` | 406 | Chat could not be loaded or the cancel signal could not be issued | | `1680 (Access Denied)` | 401 | You do not have permission to cancel this thread | --- ### List messages in a chat ``` GET /current/workspace/{workspace_id}/ai/agent/{chat_id}/messages/list/ ``` Returns the chat's messages in chronological order (oldest first), up to 150 per page. **Auth:** Bearer token required. `content_ai` plan feature required. **Request example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/ai/agent/9q7kc-dczsx-jonff-m4apj-5g5q2-milk/messages/list/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "messages": { "count": 1, "items": [ { "turn_id": "95mgw-3avoq-lmzi2-gnqtx-snlay-c4yy", "thread_id": "9q7kc-dczsx-jonff-m4apj-5g5q2-milk", "seq": 1, "status": "complete", "idempotency_key": "3f2a…", "query": { "text": "Summarize the quarterly report" }, "error": null, "cost": { "credits": 5, "tokens": 500 }, "created_at": "2026-07-07 16:30:00 UTC", "updated_at": "2026-07-07 16:30:05 UTC" } ] } } ``` Each item is the lightweight turn shape (no answer blob) — see "Message Object Schema". Fetch a turn's full answer and citations via **Get message details**. | Field | Type | Description | |---|---|---| | `messages` | object | Collection envelope `{count, items}` | | `messages.count` | integer | Number of turn items returned in `items` | | `messages.items` | array | Array of turn objects, ordered oldest-first (seq ascending) | A numeric path segment after `/messages/list/` is a pagination offset (e.g. `.../messages/list/50`). **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1609 (Not Found)` | 404 | Chat not found or not accessible | | `1680 (Access Denied)` | 401 | You do not have permission to access this thread | | `1654 (Internal Error)` | 500 | Genuine internal/datastore failure | --- ### Get message details ``` GET /current/workspace/{workspace_id}/ai/agent/{chat_id}/message/{message_id}/details/ ``` Retrieve detailed information about a specific message, including response text, citations, and cost. **Auth:** Bearer token required. `content_ai` plan feature required. **Request example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/ai/agent/9q7kc-dczsx-jonff-m4apj-5g5q2-milk/message/95mgw-3avoq-lmzi2-gnqtx-snlay-c4yy/details/" \ -H "Authorization: Bearer {jwt_token}" ``` The turn detail is returned under a `message` object on the workspace endpoint and a `turn` object on the **share** endpoint. It carries the full turn shape plus the decompressed answer `result` blob and the replayable `actions` list. **Key response fields** (under `message` / `turn`): | Field | Description | |---|---| | `turn_id` | Opaque ID of this turn (message) | | `thread_id` | Parent thread (chat) opaque ID | | `seq` | Turn sequence number within the thread | | `status` | Turn status. One of `pending`, `running`, `complete`, `failed`, `cancelled`, `lost`, `needs_input`. (`pending`/`running` are non-terminal; the rest are terminal.) | | `idempotency_key` | The per-turn idempotency key | | `query` | The user's submitted question: `{ text, content_parts?, references?, uploads?, subjects? }` (internal server-only storage identifiers are stripped) | | `error` | `{ message, grpc_status }` on a `failed`/`lost` turn; `null` otherwise | | `cost` | `{ credits, tokens }` -- the turn's credit charge and raw token count | | `created_at` / `updated_at` | Timestamps (`YYYY-MM-DD HH:MM:SS UTC`) | | `result` | The decompressed answer blob (see below). `null` until the turn reaches a terminal state | | `actions` | Ordered, replayable action cards (see below). Empty when the turn took no actions | Read the answer only when `status` is `complete` (or handle `needs_input` as a clarifying question — see the SSE `needs_input` event). The `result` blob (when present) includes `answer` — the ordered answer content parts, each a text segment `{ type: "text", value }` or an inline reference pill `{ type: "reference", reference_type, id, text }` (`reference_type` is a numeric object type, e.g. `2` workspace, `3` share, `5` file, `6` folder) — plus `references`, `citations` (each `{ reference: { type, id }, snippet, location, page, timestamp_seconds }`, where `location` is `page`, `timestamp_seconds`, or empty), `products` (objects the turn created, in pill shape `{ reference_type, id, text }`), `navigate_to` (one located object in pill shape, or `null`), a `truncated` boolean (see below), the `thought_transcript` / `commentary_transcript` strings and their ordered `thought_events` / `commentary_events` lists (each event `{ order, text, ts }`), and `clarification` — `null`, or for a `needs_input` turn an object `{ type: "clarification", question }`. `result.truncated` is `true` when the model reached its output-token limit and the answer is the partial response generated before the cutoff; it is `false` on every ordinary complete turn. Surface it (e.g. a "response was cut off" affordance) so users know the answer is incomplete. The `actions` list is ordered by `seq` — each entry has `seq` (action order, from `0`), `order` (position in the turn's combined reasoning/narration/action trace — the same counter as the `thought_events` / `commentary_events` `order`), `label` (human-readable name, e.g. `"Create File"`), `state` (`running`, `done`, `failed`, or `cancelled`), `affected_refs` (ids the action touched), and `started_at` / `ended_at` timestamps: ```json { "message": { "turn_id": "95mgw-3avoq-lmzi2-gnqtx-snlay-c4yy", "thread_id": "9q7kc-dczsx-jonff-m4apj-5g5q2-milk", "seq": 1, "status": "complete", "idempotency_key": "3f2a…", "query": { "text": "Summarize the quarterly report" }, "error": null, "cost": { "credits": 5, "tokens": 500 }, "result": { "answer": [ { "type": "text", "value": "The quarterly report shows revenue growth of 15%..." } ], "references": [], "citations": [ { "reference": { "type": 5, "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4" }, "snippet": "Revenue increased by 15% year over year...", "location": "page", "page": 3, "timestamp_seconds": 0.0 } ], "truncated": false, "products": [], "navigate_to": null, "thought_transcript": "", "commentary_transcript": "", "thought_events": [], "commentary_events": [], "clarification": null }, "actions": [ { "seq": 0, "order": 0, "label": "Create File", "state": "done", "affected_refs": ["9q7kc-dczsx-jonff-m4apj-5g5q2-milk"], "started_at": "2026-07-07 16:37:29 UTC", "ended_at": "2026-07-07 16:37:30 UTC" } ], "created_at": "2026-07-07 16:37:00 UTC", "updated_at": "2026-07-07 16:37:30 UTC" } } ``` **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1609 (Not Found)` | 404 | Chat not found or not accessible | | `1683 (Resource Missing)` | 404 | Message (turn) not found in the chat | | `1680 (Access Denied)` | 401 | You do not have permission to access this thread | | `1654 (Internal Error)` | 500 | Genuine internal/datastore failure | --- ### Stream message response (SSE) ``` GET /current/workspace/{workspace_id}/ai/agent/{chat_id}/message/{message_id}/read/ ``` Returns a Server-Sent Events (SSE) stream of the AI response. **Auth:** Bearer token required. `content_ai` plan feature required. **Request example:** ```bash curl -N -X GET "https://api.fast.io/current/workspace/1234567890123456789/ai/agent/9q7kc-dczsx-jonff-m4apj-5g5q2-milk/message/95mgw-3avoq-lmzi2-gnqtx-snlay-c4yy/read/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Accept: text/event-stream" ``` **SSE stream format:** ``` id: 1783440000000-0 event: status data: {"phase":"invoking_agent","text":"Connecting agent..."} id: 1783440001000-0 event: analysis_data data: {"order":0,"ts":"2026-07-07 16:37:30 UTC","text":"The user wants Q3 revenue; check the quarterly report."} id: 1783440002000-0 event: commentary data: {"order":1,"ts":"2026-07-07 16:37:31 UTC","text":"Looking up the quarterly report."} id: 1783440002500-0 event: action data: {"action_id":"aBcD1234","verb":"Searching","description":"Searching for 'Q3 revenue'","state":"running","seq":0,"order":2,"ts":"2026-07-07 16:37:31 UTC","targets":[]} id: 1783440004000-0 event: data data: {"answer":[{"type":"text","value":"Q3 revenue was $4.2M."}],"references":[],"citations":[],"products":[],"navigate_to":null,"truncated":false} id: 1783440004100-0 event: done data: {"sentinel":"[DONE]"} ``` Frames carry an `id:` line (the resume cursor — see *Behavior*); a few frames the endpoint generates itself, such as the connect-time `status` frame, have none. **SSE event types:** | Event Type | Description | |---|---| | `data` | The assembled answer, sent as **one** frame when the answer is ready: `{"answer", "references", "citations", "products", "navigate_to", "truncated"}` (see *Behavior* below; same shapes as the message-details `result`). The answer is not streamed in pieces on this event — interim narration arrives on `commentary`. A turn that ends with a clarifying question sends a `{"question", "text"}` frame here instead (see `needs_input`). | | `analysis_data` | Live reasoning, streamed as deltas. Payload: `{"order", "ts", "text"}` — append the `text` of frames that share an `order`. When the stream is rebuilt from the stored record (the live stream expired), the reasoning arrives as one `{"text"}` frame. | | `commentary` | Interim narration the AI emits while working. Payload: `{"order", "ts", "text"}` — `text` is the narration (inline object references collapsed to their display text); frames that share an `order` belong to the same narration segment. Expect one segment per work step on multi-step responses. Not included in a stream rebuilt from the stored record — read `result.commentary_events` from the message details instead. | | `action` | An agent tool-call card, sent when an action starts and again when it ends. Start: `{"action_id", "verb", "description", "state": "running", "seq", "order", "ts", "targets"}`. End: `{"action_id", "verb", "description", "state", "order", "ts", "products", "references", "message"}` — `state` is `done` or `failed`, `products` are objects the action created, `references` objects it surfaced (e.g. search hits), `message` a failure description (`null` on success). `targets` / `products` / `references` items are `{"type", "id", "text"}` (`type` uses the same numbers as `reference_type`). Actions can run in parallel, so correlate frames by `action_id`. After the turn, the same cards appear in the message-details `actions` list. | | `status` | Cosmetic turn-progress hint emitted **before** the agent's first output frame. Payload: `{"phase": "...", "text": "..."}` — `phase` is `enhancing` (first turn only, while the question is enhanced/evaluated; clients commonly show "Analyzing your request…") or `invoking_agent` (every turn, just before the agent runs; clients commonly show "Connecting agent…"), `text` a human-readable default you may show or override. Not persisted and not replayed from the durable record — treat as a best-effort indicator and clear it once real output (or a terminal event) arrives. Unknown phases → generic "working". | | `needs_input` | Terminal event: the assistant needs more information and returned a single clarifying question instead of a full response. The question text arrives on a preceding `data` frame (payload includes a `question` field); fetch the message details to read it from the result's `clarification` object. The message reaches a `needs_input` terminal state (not `failed`) — present the question and send the user's answer as a new message in the same chat. Listen for this as its own event (e.g. `eventSource.addEventListener('needs_input', …)`); the stream closes after it. | | `done` | Terminal event: the turn completed. The stream closes after it. | | `failed` | Terminal event: the turn failed or was lost (or the stream itself hit an internal error). Fetch the message details for the turn's `error`. The stream closes after it. | | `cancelled` | Terminal event: the turn was cancelled (see "Cancel an in-progress message"). The stream closes after it. | Exactly one terminal event — `done`, `failed`, `cancelled`, or `needs_input` — ends a turn's stream. Its `data` is `{"sentinel": "[DONE]"}` (respectively `[FAILED]`, `[CANCELLED]`, `[NEEDS_INPUT]`); key off the event name. A stream that closes with no terminal event hit the bounded wait below — reconnect with `Last-Event-ID`. **Behavior:** - The final `data` frame of a turn carries the assembled answer payload (`answer`, `references`, `citations`, `products`, `navigate_to`) plus a **`truncated`** boolean — `true` when the model hit its output-token limit and the answer is the partial response before the cutoff. It is the same value the message-details endpoint returns under `result.truncated`, so a client that renders from the stream does not need a follow-up detail fetch to learn the answer was cut off. Treat a missing or non-boolean value as `false`; only a strict `true` means truncated. Replayed streams carry the identical field, so a reconnect renders the same as the live stream. - For a **terminal** turn (`complete`, `failed`, `cancelled`, `lost`, `needs_input`), the stored events are replayed immediately, followed by the matching terminal event, then the stream closes. - For a **running** (or freshly `pending`) turn, the connection stays open and live-follows, emitting frames as the worker produces them. If the turn has produced no frames yet, a single best-effort `status` frame is sent on connect so the client leaves its "connecting" state immediately. - **Resume is supported.** Pass a `Last-Event-ID` header to resume after the last event you received; when the live stream has expired the events are synthesized from the canonical record. - The connection auto-terminates after a bounded wait while a turn is still running (browsers auto-reconnect via `EventSource` and resume with `Last-Event-ID`). - Headers include `Cache-Control: no-cache, no-store, must-revalidate`. - This endpoint returns an SSE stream, NOT the standard JSON response envelope. **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1683 (Resource Missing)` | 404 | Message (turn) not found in the chat | | `1609 (Not Found)` | 404 | Chat not found or not accessible | | `1680 (Access Denied)` | 401 | You do not have permission to read this thread | | `1654 (Internal Error)` | 500 | Genuine internal/datastore failure | --- ### Publish a private chat ``` POST /current/workspace/{workspace_id}/ai/agent/{chat_id}/publish/ ``` Makes a private chat public (visible to other workspace members). One-way operation -- published chats cannot be made private again. **Currently disabled (platform-wide).** Publishing a chat publicly is turned off for all accounts: this endpoint returns `403 Forbidden` with message "Publishing chats publicly is currently disabled.", and creating a chat with `privacy=public` (`POST /current/workspace/{workspace_id}/ai/agent/`) is refused the same way. Clients can detect availability via the `capabilities.can_publish_agent_chat` boolean on workspace details (currently `false`) and hide the publish control. Chats already published before this change remain public. **Auth:** Bearer token required. `content_ai` plan feature required. **Request example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/ai/agent/9q7kc-dczsx-jonff-m4apj-5g5q2-milk/publish/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "thread": { "thread_id": "9q7kc-dczsx-jonff-m4apj-5g5q2-milk", "...": "..." } } ``` `thread` is the updated chat (same shape as in "Get chat details"). **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1700 (Forbidden)` | 403 | Publishing is disabled platform-wide (currently always returned) | | `1658 (Not Acceptable)` | 406 | Chat not found or not accessible | | `1680 (Access Denied)` | 401 | You do not have permission to publish this thread | | `1660 (Conflict)` | 409 | Chat is already public | | `1664 (Datastore Error)` | 500 | Update failed | | *(generated per call site)* | 403 | Org AI policy denies the caller Ripley Agent access -- `ai_policy_denied`, `params.feature:"agent"`. | --- ### Generate AI Share ``` POST /current/workspace/{workspace_id}/ai/share/ ``` Generates markdown with temporary download URLs for selected files. Designed to be pasted into external AI chatbots (ChatGPT, Claude, etc.) to provide them with file context. **Auth:** Bearer token required. Workspace `view` permission. **Does NOT require** `content_ai` plan feature -- available on all plans. **Parameters:** | Parameter | Type | Required | Description | |---|---|---|---| | `files` | array (JSON) | Yes | JSON array of file (or note) opaque IDs. Min 1, max 25. Folders are refused. | **Request example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/ai/share/" \ -H "Authorization: Bearer {jwt_token}" \ --data-urlencode 'files=["{node_id_1}", "{node_id_2}"]' ``` The endpoint reads form-encoded input (`application/x-www-form-urlencoded`). The `files` field value must be a JSON-encoded array of node opaque IDs. Do NOT send a JSON request body (`Content-Type: application/json`) -- only form-encoded bodies are parsed. **Response (200 OK):** ```json { "result": true, "markdown": "## File Download Request\n\nI am providing 1 file for you to download and analyze.\n\n**Access Information:**\n- Direct download URLs - no authentication required\n- Links are signed and expire in 5 minutes (2026-07-07 16:42:29 UTC)\n- Multiple download attempts are supported\n\n**File Manifest:**\n1. [quarterly-report.pdf](https://downloadai.fast.io/api/current/ai/share/{token}?file=0) (2.5 MB, application/pdf)\n\n**Instructions:**\n..." } ``` | Field | Type | Description | |---|---|---| | `markdown` | string | Generated markdown with file info and temporary download URLs | **Notes:** - Download URLs expire after 5 minutes (300 seconds) - Each token can be used a maximum of 3 times - Individual files limited to 50 MB; total size limited to 100 MB - When more than 5 files: titles only. 5 or fewer: includes full descriptions. - The `files` input is a JSON array (not comma-separated strings) **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1605 (Invalid Input)` | 406 | Empty files array | | `1605 (Invalid Input)` | 406 | More than 25 files | | `1605 (Invalid Input)` | 406 | A malformed ID; a folder or other non-file node; a file over 50 MB; or a total over 100 MB | | `1609 (Not Found)` | 404 | One or more files were not found | | `1654 (Internal Error)` | 500 | The share could not be created (retryable) | | *(generated per call site)* | 403 | Org AI policy denies the caller Ripley Agent access -- `ai_policy_denied`, `params.feature:"agent"`. Applies even though this endpoint needs no `content_ai` plan feature -- the policy gate is independent of plan gating. | --- ### List AI transactions ``` GET /current/workspace/{workspace_id}/ai/transactions/ ``` Returns up to 40 most recent AI token usage transactions for the workspace. **Workspace-only** -- no share equivalent. Results merge two sources into one most-recent-first feed: standalone AI operations (file summaries, title generation, indexing, and other one-off AI tasks) and completed agent conversation turns. Agent-turn entries carry `type` `agent`; standalone operations carry their operation type (e.g. `chat_with_files`, `generate_title`). Agent turns come only from public chats and your own private chats. In-progress turns are not included -- only finished work appears. **Auth:** Bearer token required. Workspace `view` permission. `content_ai` plan feature required. **Request example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/ai/transactions/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "count": 1, "items": [ { "id": "{transaction_id}", "type": "chat_with_files", "status": "complete", "tokens": 1500, "updated": "2025-06-15 10:30:05 UTC", "created": "2025-06-15 10:30:00 UTC" } ] } ``` | Field | Type | Description | |---|---|---| | `count` | integer | Number of transactions returned | | `items[].id` | string | Formatted transaction or turn ID | | `items[].type` | string | Operation type (e.g., `chat_with_files`, `generate_title`) or `agent` for a completed agent conversation turn | | `items[].status` | string | Transaction or turn status (e.g., `complete`, `errored`, `failed`, `cancelled`, `lost`, `needs_input`). `needs_input` marks a turn the assistant answered with a clarifying question instead of a full response (see the SSE `needs_input` event above). | | `items[].tokens` | integer | Tokens consumed (for an `agent` turn, the raw token count — not credits) | | `items[].updated` | string | Last update timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `items[].created` | string | Creation timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1654 (Internal Error)` | 500 | The transaction feed could not be read | --- ## Asking a Question and Getting the Response ### Complete workflow: **1. Create the chat:** ```bash curl -X POST "https://api.fast.io/current/workspace/{workspace_id}/ai/agent/" \ -H "Authorization: Bearer {jwt_token}" \ -d "question=What were the Q3 revenue figures?" ``` The response includes `thread.thread_id` (the chat id) and `turn.turn_id` (the first message id). The AI begins processing asynchronously. **2. Wait for completion using activity polling (do NOT poll the message endpoint in a loop):** ```bash curl -X GET "https://api.fast.io/current/activity/poll/{workspace_id}?wait=95&lastactivity={timestamp}" \ -H "Authorization: Bearer {jwt_token}" ``` Watch for the `ai_chat:{chatId}` activity key. This fires when the message state changes. The server holds the connection for up to 95 seconds and returns immediately when something changes. **3. Check message state:** ```bash curl -X GET "https://api.fast.io/current/workspace/{workspace_id}/ai/agent/{chat_id}/message/{message_id}/details/" \ -H "Authorization: Bearer {jwt_token}" ``` Turn states: `pending` -> `running` -> `complete` (or the terminal `failed`, `cancelled`, `lost`, or `needs_input`). Only read the answer when the status is `complete` (handle `needs_input` as a clarifying question). **4. Stream the response:** ```bash curl -N -X GET "https://api.fast.io/current/workspace/{workspace_id}/ai/agent/{chat_id}/message/{message_id}/read/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Accept: text/event-stream" ``` Returns SSE with event types: `status` (turn-progress hints, before the agent's first frame), `analysis_data` (reasoning deltas), `commentary` (interim narration), `action` (tool-call cards), `data` (the assembled answer, one frame), then one terminal event: `done`, `failed`, `cancelled`, or `needs_input` (a single clarifying question instead of a full answer — present it and send the user's reply as a new message). **5. Send follow-ups:** ```bash curl -X POST "https://api.fast.io/current/workspace/{workspace_id}/ai/agent/{chat_id}/message/" \ -H "Authorization: Bearer {jwt_token}" \ -d "question=How does that compare to Q2?" ``` Same polling flow for the reply. ### Linking a user to an AI chat Construct a workspace URL with a `chat` query parameter: ``` https://{org_domain}.fast.io/workspace/{workspace_name}?c={chat_opaque_id} ``` The `chat_opaque_id` is the thread's opaque id — returned as `thread.thread_id` when creating a chat, and as each item's `thread_id` when listing chats. The older `?chat=` parameter is still accepted. --- ## Share AI Endpoints Share AI endpoints follow the same pattern as workspace AI. Replace `/workspace/{workspace_id}` with `/share/{share_id}`. ### Share-specific AI endpoints #### Auto-generate OG image ``` GET /current/share/{share_id}/ai/autoog/ ``` Generates an Open Graph image for the share. Returns binary JPEG image data (`image/jpeg`, not JSON). **Auth:** Conditional. Public shares (Anyone / RegisteredUsers) need no authentication and get a custom generated image. Private shares always get the default private OG image (HTTP 200 image data) -- a caller with valid permission and the **`ai_autoog`** plan feature receives that same default image, and a caller without them is not refused. On the **authenticated** (private-share) path, the org's AI policy is also checked, and unlike the plan-feature case it is a hard refusal rather than a fallback -- a caller the policy denies `ai_agent` for gets `403 ai_policy_denied`. The unauthenticated public-share path is not policy-checked (there is no caller to evaluate a policy for). | Behavior | Description | |---|---| | Public share | Custom image generated from the share's details and branding; the default public image if generation fails | | Private share | Always the default private image | **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1609 (Not Found)` | 404 | Share is disabled | | `1654 (Internal Error)` | 500 | Default image not found on server | | *(generated per call site)* | 403 | Authenticated (private-share) path only: org AI policy denies the caller Ripley Agent access -- `ai_policy_denied`, `params.feature:"agent"`. | --- #### Auto-generate title and description ``` POST /current/share/{share_id}/ai/autotitle/ ``` AI-generates a title, description, and display type based on the share's contents. Values are applied directly to the share. **Auth:** Bearer token required. Share admin permission required (the credential must carry admin scope on the share). Requires the **`ai_autotitle`** plan feature (not `content_ai`/`ai_agent`) — a plan without it is rejected. | Parameter | Type | Required | Description | |---|---|---|---| | `user_context` | string | No | Optional user-provided context to guide AI generation. Max 64 characters; letters, numbers, and spaces only. | **Request example:** ```bash curl -X POST "https://api.fast.io/current/share/1234567890123456789/ai/autotitle/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "title": "Q4 Financial Reports", "description": "Quarterly financial reports and analysis for fiscal year 2025.", "display_type": "grid" } ``` | Field | Type | Description | |---|---|---| | `title` | string | AI-generated title | | `description` | string/null | AI-generated description. `null` when the share has no files and no existing description | | `display_type` | string | AI-suggested display layout: `list` or `grid` | **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1605 (Invalid Input)` | 406 | Invalid `user_context` (too long or disallowed characters) | | `1680 (Access Denied)` | 401 | Caller is not a share admin | | `1693 (Temporarily Unavailable)` | 503 | The AI provider is temporarily unavailable -- retry shortly | | `1654 (Internal Error)` | 500 | Share update or generation failure | | *(generated per call site)* | 403 | Org AI policy denies the caller Ripley Agent access -- `ai_policy_denied`, `params.feature:"agent"`. | --- ### Share Chat Endpoints Share chat endpoints mirror workspace chat endpoints. Response formats and schemas match the workspace versions documented above, with the envelope exceptions noted in the list below. The key differences are: - Message details returns the turn under the `turn` key on a share, not under `message` as on a workspace - Send a follow-up message returns the new turn nested under a `turn` key on a share, not at the top level as on a workspace - Auth uses share permissions instead of workspace permissions - File scope references share files instead of workspace files - No AI Transactions endpoint (workspace-only) --- #### Create a new chat (Share) ``` POST /current/share/{share_id}/ai/agent/ ``` Creates a chat with an initial message in a share. The AI begins processing asynchronously. **Auth:** Bearer token required. Share `view` permission and chat permission. `content_ai` and `ai_agent` plan features required. **Parameters:** | Parameter | Type | Required | Default | Description | |---|---|---|---|---| | `question` | string | Yes | -- | Initial question, 1-32,000 characters. Always required, even when `content_parts` is sent (the message text then comes from `content_parts` and `question` is ignored). | | `name` | string | No | Auto-generated | Chat name. A default is used if omitted. | | `references` | JSON array | No | -- | File/folder reference items to attach as context — each a `{type, id}` file or folder item (see Attaching Files and Folders). Up to 20 files / 2 GB / 100 references; the backend resolves each item's full details server-side. | | `content_parts` | JSON array | No | -- | Ordered content stream — text segments plus inline file/folder reference pills (same item shape as `references`). | | `subjects` | JSON array | No | -- | File/folder reference items pinned as focus subjects for the turn (same item shape as `references`). | | `uploads` | JSON array | No | -- | Focus files staged from outside the share. | Share-context chats are always private — the `privacy` parameter is not accepted and visibility is fixed so guests do not see each other's AI conversations. Also accepts the optional `view`, `activity`, `role_in_org`, and `idempotency_key` fields. **Request example:** ```bash curl -X POST "https://api.fast.io/current/share/1234567890123456789/ai/agent/" \ -H "Authorization: Bearer {jwt_token}" \ -d "question=What were the Q3 revenue figures?" ``` **Response (200 OK):** Same `{ thread, turn }` shape as the workspace create endpoint. `thread.thread_id` is the chat id; `turn.turn_id` is the first message id. See **Create a new chat** (workspace) above for the full field lists. ```json { "result": true, "thread": { "thread_id": "9q7kc-dczsx-jonff-m4apj-5g5q2-milk", "scope": { "type": "share", "id": "1234567890123456789" }, "status": "ready", "kind": "user", "...": "..." }, "turn": { "turn_id": "95mgw-3avoq-lmzi2-gnqtx-snlay-c4yy", "thread_id": "9q7kc-dczsx-jonff-m4apj-5g5q2-milk", "seq": 1, "status": "pending", "...": "..." } } ``` **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1605 (Invalid Input)` | 406 | Invalid `name`, or invalid `question` length | | `1609 (Not Found)` | 404 | An attached file or folder reference does not exist or is not accessible | | `1605 (Invalid Input)` | 406 | An attached reference is malformed, the wrong node type, or exceeds the 20-file / 2 GB / 100-reference limit | | `1680 (Access Denied)` | 401 | Folder attachment is not permitted in this share (restricted-view guest) | | `1660 (Conflict)` | 409 | Thread still committing its first turn (retry), or the first message is too large | | `1664 (Datastore Error)` | 500 | Thread or turn creation failed | | *(generated per call site)* | 403 | Org AI policy denies the caller Ripley Agent access -- `ai_policy_denied`, `params.feature:"agent"`. See *AI, Deep Indexing & MCP Access Policy* in `llms/orgs.txt`. | | *(generated per call site)* | 403 | The share's Deep Indexing switch is on and the org AI policy denies the caller Deep Indexing for it (interim -- see the workspace "Create a new chat" section above) -- `ai_policy_denied` or `ai_policy_workspace_not_allowed`, `params.feature:"intelligence"`. | --- #### List chats (Share) ``` GET /current/share/{share_id}/ai/agent/list/ ``` Returns all chats created by the current user in the share. Sorted by most recently modified first. **Auth:** Bearer token required. Share `view` permission and chat permission. `content_ai` plan feature required. **Query parameters:** | Parameter | Type | Required | Default | Description | |---|---|---|---|---| | `kind` | string | No | `user` | Filter by chat kind. Allowed values: `user` (default), `agent`, `all`. Same semantics as the workspace list endpoint. Note: share-context chat creation does not accept `kind`, so all share-created chats are `user`. | **Variant:** Append `/deleted` to the path to list deleted chats: `GET .../ai/agent/list/deleted` **Request example:** ```bash curl -X GET "https://api.fast.io/current/share/1234567890123456789/ai/agent/list/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** Same format as workspace chat list. See [List chats](#list-chats) above. --- #### Get chat details (Share) ``` GET /current/share/{share_id}/ai/agent/{chat_id}/details/ ``` Returns chat details with full message history. **Auth:** Bearer token required. Share `view` permission and chat permission. `content_ai` plan feature required. **Request example:** ```bash curl -X GET "https://api.fast.io/current/share/1234567890123456789/ai/agent/9q7kc-dczsx-jonff-m4apj-5g5q2-milk/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** Same format as workspace chat details. See [Get chat details](#get-chat-details) above. **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1609 (Not Found)` | 404 | Chat not found or not accessible | | `1680 (Access Denied)` | 401 | You do not have permission to access this thread | --- #### Update a chat (Share) ``` POST /current/share/{share_id}/ai/agent/{chat_id}/update/ ``` Update the name of an existing chat in a share. **Auth:** Bearer token required. Share `view` permission and chat permission; only the chat's creator or a share admin may rename it. `content_ai` plan feature required. | Parameter | Type | Required | Description | |---|---|---|---| | `name` | string | Yes | New chat name | **Request example:** ```bash curl -X POST "https://api.fast.io/current/share/1234567890123456789/ai/agent/9q7kc-dczsx-jonff-m4apj-5g5q2-milk/update/" \ -H "Authorization: Bearer {jwt_token}" \ -d "name=Updated Chat Name" ``` **Response (200 OK):** the updated chat under `thread` (see Chat Session Object Schema; `message_count` and `continuable` are omitted on this response). ```json { "result": true, "thread": { "thread_id": "9q7kc-dczsx-jonff-m4apj-5g5q2-milk", "name": "Updated Chat Name", "...": "..." } } ``` **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1658 (Not Acceptable)` | 406 | Chat not found or not accessible | | `1680 (Access Denied)` | 401 | You do not have permission to access this chat (only the creator or a share admin may rename a chat) | | `1605 (Invalid Input)` | 406 | Invalid name value | | `1664 (Datastore Error)` | 500 | Update failed | | *(generated per call site)* | 403 | Org AI policy denies the caller Ripley Agent access -- `ai_policy_denied`, `params.feature:"agent"`. | --- #### Delete a chat (Share) ``` DELETE /current/share/{share_id}/ai/agent/{chat_id}/ ``` **Auth:** Bearer token required. Share `view` permission and chat permission; only the chat's creator or a share admin may delete it. `content_ai` plan feature required. **Request example:** ```bash curl -X DELETE "https://api.fast.io/current/share/1234567890123456789/ai/agent/9q7kc-dczsx-jonff-m4apj-5g5q2-milk/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true } ``` **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1658 (Not Acceptable)` | 406 | Chat not found or not accessible | | `1680 (Access Denied)` | 401 | You do not have permission to access this chat (only the creator or a share admin may delete a chat) | | `1660 (Conflict)` | 409 | The chat is busy processing a message -- cancel the active message or retry shortly | | `1654 (Internal Error)` | 500 | Chat in non-deletable state or internal error | Deletion is permanent: the chat and all its messages are purged, so a chat deleted here does not appear in `GET .../ai/agent/list/deleted`. --- #### Send a follow-up message (Share) ``` POST /current/share/{share_id}/ai/agent/{chat_id}/message/ ``` Send a new message to an existing chat in a share. The message is processed asynchronously. **Auth:** Bearer token required. Share `view` permission and chat permission. `content_ai` and `ai_agent` plan features required. **Parameters:** | Parameter | Type | Required | Default | Description | |---|---|---|---|---| | `question` | string | Yes | -- | Follow-up question, 1-32,000 characters. Always required, even when `content_parts` is sent (the message text then comes from `content_parts` and `question` is ignored). | | `references` | JSON array | No | -- | File/folder reference items to attach as context — each a `{type, id}` file or folder item (see Attaching Files and Folders). Up to 20 files / 2 GB / 100 references; the backend resolves each item's full details server-side. | | `content_parts` | JSON array | No | -- | Ordered content stream — text segments plus inline file/folder reference pills (same item shape as `references`). | | `subjects` | JSON array | No | -- | File/folder reference items pinned as focus subjects for the turn (same item shape as `references`). | | `uploads` | JSON array | No | -- | Focus files staged from outside the share. | Also accepts the optional `view`, `activity`, `role_in_org`, and `idempotency_key` fields (see "Advanced Per-Turn Fields" above). There is no `type` parameter — the turn is appended to the existing thread. **Request example:** ```bash curl -X POST "https://api.fast.io/current/share/1234567890123456789/ai/agent/9q7kc-dczsx-jonff-m4apj-5g5q2-milk/message/" \ -H "Authorization: Bearer {jwt_token}" \ -d "question=How does that compare to Q2?" ``` **Response (200 OK):** ```json { "result": true, "turn": { "turn_id": "9togf-axp6b-x5rtm-upjha-m32qf-leqb", "thread_id": "9q7kc-dczsx-jonff-m4apj-5g5q2-milk", "seq": 2, "status": "pending", "idempotency_key": "7c1b…", "query": { "text": "How does that compare to Q2?" }, "error": null, "cost": { "credits": 0, "tokens": 0 }, "created_at": "2026-07-07 16:37:29 UTC", "updated_at": "2026-07-07 16:37:29 UTC" } } ``` On a share the created turn is nested under a **`turn`** key (unlike the workspace endpoint, which returns the turn's fields at the top level). `turn.turn_id` is the message id you poll or stream. `status` starts at `pending`; watch it reach a terminal state (see "Get message details"). **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1658 (Not Acceptable)` | 406 | Thread not found, not accessible, or locked | | `1680 (Access Denied)` | 401 | You cannot message this thread; or (share chats only) folder attachment is not permitted for a restricted-view guest | | `1609 (Not Found)` | 404 | An attached file or folder reference does not exist or is not accessible | | `1605 (Invalid Input)` | 406 | An attached reference is malformed, the wrong node type, or exceeds the 20-file / 2 GB / 100-reference limit | | `1660 (Conflict)` | 409 | The conversation has grown too large to continue — start a new chat | | `1664 (Datastore Error)` | 500 | Transient storage error loading an attached file (retryable) | | `1654 (Internal Error)` | 500 | Message creation or queuing failed | | *(generated per call site)* | 403 | Org AI policy denies the caller Ripley Agent access -- `ai_policy_denied`, `params.feature:"agent"`. | | *(generated per call site)* | 403 | The share's Deep Indexing switch is on and the org AI policy denies the caller Deep Indexing for it (interim) -- `ai_policy_denied` or `ai_policy_workspace_not_allowed`, `params.feature:"intelligence"`. | --- #### Cancel an in-progress message (Share) ``` POST /current/share/{share_id}/ai/agent/{chat_id}/cancel/ ``` Aborts an in-flight AI message in a share chat. Behavior matches the workspace cancel endpoint above: the worker halts streaming and the affected turn reaches a `cancelled` terminal state, after which a new message can be sent. **Auth:** Bearer token required. Share `view` permission and chat permission. `content_ai` plan feature required (the cancel endpoint does **not** require `ai_agent`). **Body:** Empty. **Request example:** ```bash curl -X POST "https://api.fast.io/current/share/{share_id}/ai/agent/{chat_id}/cancel/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** `{ "result": true }` — same as the workspace cancel endpoint (no `success`, `message.id`, or `no_pending_message` field). See [Cancel an in-progress message](#cancel-an-in-progress-message) above for the full behavior notes (idempotency, best-effort latency, no charge for a user-cancelled turn, the single terminal SSE `cancelled` event with no `done` after it, and the affected turn reaching the `cancelled` terminal state). **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1658 (Not Acceptable)` | 406 | Chat could not be loaded or the cancel signal could not be issued | | `1680 (Access Denied)` | 401 | You do not have permission to access this chat | --- #### List messages in a chat (Share) ``` GET /current/share/{share_id}/ai/agent/{chat_id}/messages/list/ ``` Returns all messages in chronological order (oldest first). **Auth:** Bearer token required. Share `view` permission and chat permission. `content_ai` plan feature required. **Request example:** ```bash curl -X GET "https://api.fast.io/current/share/1234567890123456789/ai/agent/9q7kc-dczsx-jonff-m4apj-5g5q2-milk/messages/list/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** Same format as workspace message list. See [List messages in a chat](#list-messages-in-a-chat) above. --- #### Get message details (Share) ``` GET /current/share/{share_id}/ai/agent/{chat_id}/message/{message_id}/details/ ``` Retrieve detailed information about a specific message, including response text, citations, and cost. **Auth:** Bearer token required. Share `view` permission and chat permission. `content_ai` plan feature required. **Request example:** ```bash curl -X GET "https://api.fast.io/current/share/1234567890123456789/ai/agent/9q7kc-dczsx-jonff-m4apj-5g5q2-milk/message/95mgw-3avoq-lmzi2-gnqtx-snlay-c4yy/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** Same turn object as workspace message details, but under the key `turn` instead of `message`. See [Get message details](#get-message-details) above. **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1609 (Not Found)` | 404 | Chat not found or not accessible | | `1683 (Resource Missing)` | 404 | Message (turn) not found in the chat | | `1680 (Access Denied)` | 401 | You do not have permission to access this thread | | `1654 (Internal Error)` | 500 | Genuine internal/datastore failure | --- #### Stream message response (SSE) (Share) ``` GET /current/share/{share_id}/ai/agent/{chat_id}/message/{message_id}/read/ ``` Returns a Server-Sent Events (SSE) stream of the AI response. **Auth:** Bearer token required. Share `view` permission and chat permission. `content_ai` plan feature required. **Request example:** ```bash curl -N -X GET "https://api.fast.io/current/share/1234567890123456789/ai/agent/9q7kc-dczsx-jonff-m4apj-5g5q2-milk/message/95mgw-3avoq-lmzi2-gnqtx-snlay-c4yy/read/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Accept: text/event-stream" ``` **SSE stream format and behavior:** Identical to the workspace version. See [Stream message response (SSE)](#stream-message-response-sse) above. **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1683 (Resource Missing)` | 404 | Message (turn) not found in the chat | | `1609 (Not Found)` | 404 | Chat not found or not accessible | | `1680 (Access Denied)` | 401 | You do not have permission to read this thread | | `1654 (Internal Error)` | 500 | Genuine internal/datastore failure | --- #### Publish a private chat (Share) ``` POST /current/share/{share_id}/ai/agent/{chat_id}/publish/ ``` Makes a private chat public (visible to other share members). One-way operation -- published chats cannot be made private again. **Currently disabled (platform-wide).** Publishing a chat publicly is turned off for all accounts: this endpoint returns `403 Forbidden` with message "Publishing chats publicly is currently disabled." Clients can detect availability via the `capabilities.can_publish_agent_chat` boolean on share details (currently `false`) and hide the publish control. Chats already published before this change remain public. **Auth:** Bearer token required. Share `view` permission and chat permission. `content_ai` plan feature required. **Request example:** ```bash curl -X POST "https://api.fast.io/current/share/1234567890123456789/ai/agent/9q7kc-dczsx-jonff-m4apj-5g5q2-milk/publish/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK, when publishing is enabled):** the updated chat under `thread`. ```json { "result": true, "thread": { "thread_id": "9q7kc-dczsx-jonff-m4apj-5g5q2-milk", "privacy": { "visibility": "public", "owner": null }, "...": "..." } } ``` **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1700 (Forbidden)` | 403 | Publishing chats publicly is currently disabled (platform-wide) | | `1658 (Not Acceptable)` | 406 | Chat not found or not accessible | | `1680 (Access Denied)` | 401 | You do not have permission to access this chat | | `1660 (Conflict)` | 409 | Chat is already public | | `1664 (Datastore Error)` | 500 | Update failed | | *(generated per call site)* | 403 | Org AI policy denies the caller Ripley Agent access -- `ai_policy_denied`, `params.feature:"agent"`. | --- #### Generate AI Share (Share) ``` POST /current/share/{share_id}/ai/share/ ``` Generates markdown with temporary download URLs for selected files. Designed to be pasted into external AI chatbots (ChatGPT, Claude, etc.) to provide them with file context. **Auth:** Bearer token required. Share `view` permission and permission to download **all** files in the share (a download permission limited to the caller's own files is refused). **Does NOT require** `content_ai` plan feature -- available on all plans. **Parameters:** | Parameter | Type | Required | Description | |---|---|---|---| | `files` | array (JSON) | Yes | JSON array of file or note opaque IDs (folders are refused). Min 1, max 25. | **Request example:** ```bash curl -X POST "https://api.fast.io/current/share/1234567890123456789/ai/share/" \ -H "Authorization: Bearer {jwt_token}" \ --data-urlencode 'files=["{node_id_1}", "{node_id_2}"]' ``` The endpoint reads form-encoded input (`application/x-www-form-urlencoded`). The `files` field value must be a JSON-encoded array of node opaque IDs. Do NOT send a JSON request body (`Content-Type: application/json`) -- only form-encoded bodies are parsed. **Response (200 OK):** ```json { "result": true, "markdown": "## File Download Request\n\nI am providing 1 file for you to download and analyze.\n\n**Access Information:**\n- Direct download URLs - no authentication required\n- Links are signed and expire in 5 minutes (2026-07-07 16:42:29 UTC)\n- Multiple download attempts are supported\n\n**File Manifest:**\n1. [quarterly-report.pdf](https://downloadai.fast.io/api/current/ai/share/{token}?file=0) (2.5 MB, application/pdf) - Quarterly revenue summary\n\n**Instructions:**\n1. Use your file fetching tool to download all files immediately.\n..." } ``` | Field | Type | Description | |---|---|---| | `markdown` | string | Generated markdown with file info and temporary download URLs | **Notes:** - Download URLs expire after 5 minutes (300 seconds) - Each token can be used a maximum of 3 times - Individual files limited to 50 MB; total size limited to 100 MB - When more than 5 files: titles only. 5 or fewer: includes full descriptions. - The `files` input is a JSON array (not comma-separated strings) **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1605 (Invalid Input)` | 406 | Empty files array | | `1605 (Invalid Input)` | 406 | More than 25 files | | `1605 (Invalid Input)` | 406 | A file exceeds 50 MB, the total exceeds 100 MB, or an ID names a folder or other non-file node | | `1609 (Not Found)` | 404 | One or more files were not found | | `1680 (Access Denied)` | 401 | Insufficient download permissions (including own-files-only download permission) | | `1654 (Internal Error)` | 500 | The AI share could not be created | | *(generated per call site)* | 403 | Org AI policy denies the caller Ripley Agent access -- `ai_policy_denied`, `params.feature:"agent"`. Applies even though this endpoint needs no `content_ai` plan feature. | --- ### Workspace AI vs. Share AI differences | Feature | Workspace AI | Share AI | |---|---|---| | AI Transactions endpoint | Yes | No | | Auto OG image endpoint | No | Yes | | Auto title endpoint | No | Yes | | `content_ai` feature required | Yes (except AI Share) | Yes (except AI Share, auto OG image, and auto title, which use their own plan features) | | `ai_agent` feature required | Yes, for create-chat and send-message (except AI Share) | Yes, for create-chat and send-message (except AI Share) | | File scope context | Workspace files | Share files | --- ## AI Share File Download ``` GET /current/ai/share/{token}?file={index} ``` Download a file from an AI Share using a temporary token. **No authentication required** -- access is controlled by the token. | Parameter | Type | Required | Description | |---|---|---|---| | `{token}` | string (path) | Yes | Alphanumeric AI share token (generated by the AI Share creation endpoint) | | `file` | integer (query) | Yes | Zero-based file index within the AI Share | **Request example:** ```bash curl -X GET "https://api.fast.io/current/ai/share/ds2nwychi2sk7s5zbvhtnebwhrmoj?file=0" \ -o downloaded_file.pdf ``` **Success response:** Binary file data with appropriate `Content-Type`, `Content-Disposition`, `Content-Length`, and `Accept-Ranges` headers. Supports HTTP range requests. **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1609 (Not Found)` | 404 | Token missing, invalid, expired, or use limit reached | | `1609 (Not Found)` | 404 | File index out of range | | `1693 (Temporarily Unavailable)` | 503 | File processing is incomplete -- retry shortly | | `1654 (Internal Error)` | 500 | Unable to retrieve or read file | All invalid/expired token errors return 404 to prevent token enumeration. --- ## Semantic Search Semantic search runs inside the unified storage search endpoint — there is no separate semantic endpoint. When workspace Deep Indexing (API field: `intelligence`) is enabled, meaning-based matches are blended into the results automatically, alongside filename and summary matches. ### Workspace Semantic Search ``` GET /current/workspace/{workspace_id}/storage/search/ ``` **Auth:** Bearer token required. Workspace `view` permission. The endpoint has no plan-feature gate of its own — only its meaning-based leg depends on the workspace having Deep Indexing enabled. ### Share Semantic Search ``` GET /current/share/{share_id}/storage/search/ ``` The same search, scoped to a share. It is **not** identical to the workspace route: it applies share-specific search and file-view permissions, gates summary access separately, rejects workspace-backed shares, and does not accept `filters`. **Parameters:** the query goes in `search`. The full list — `search`, `files_scope`, `folders_scope`, `search_in`, `name_match`, `case_sensitive`, `details`, `queries`, `limit`/`offset`, the `output` detail tiers, and the workspace-only `filters` — is documented in the *Search* section of the [Storage reference](https://api.fast.io/current/llms/storage/). The notes below cover only the behaviour specific to the meaning-based leg. **🔴 Everything below applies ONLY when the meaning-based leg actually runs** — that is, workspace Deep Indexing is enabled *and* `search_in` is not `filename`. When the leg does not run, `files_scope` / `folders_scope` are not parsed at all: a malformed entry is silently ignored rather than refused, an oversized folder tree is neither expanded nor reported, and the scope has no effect on the results you get back. **A scope that resolves to nothing returns no MEANING-BASED results.** If every reference in `files_scope` / `folders_scope` is dropped during resolution — for example a file that has since been trashed — the meaning-based leg returns nothing. It never falls back to searching everything. ⚠️ **The keyword and summary channels are not scoped**, so the response can still carry files from outside the scope, marked `match_source: "keyword"`. Omit both parameters to search all indexed content. 🔴 **A scope is NOT a result boundary here.** It narrows the meaning-based leg only; the keyword/summary leg is unscoped and its hits come back regardless. If you need a hard boundary — for display, for an access decision, or for anything a user will read as "only these files" — **filter the results yourself on `match_source`**. Treating the scope as a boundary will show out-of-scope files. **`files_scope` takes files AND notes; `folders_scope` takes folders; links cannot be scoped.** Notes are indexed the way files are and are returned by meaning-based search, so `files_scope` accepts a note's `nodeId:versionId` pair exactly as it accepts a file's. A **link** has no stored content to index and is accepted by neither parameter. A node of the wrong type for the parameter it was named in is refused with `1605 (Invalid Input)` / `406`, in a message naming the type the node actually is and, where the other parameter would take it, which one to use instead. **To send no scope, omit the parameter.** Any value that is not a `nodeId:versionId` / `nodeId:depth` pair is refused with `1605 (Invalid Input)` / `406` naming the entry — including a bare `0`, which is not treated as "no scope". **A scope carries at most 100 references in total**, counting every file named, every folder named, and every subfolder reached by expanding a `folders_scope` entry to its `:depth`. Naming more than 100 **files** is refused; a **folder** tree that runs past the limit is **truncated instead, and the truncation is reported** — the response then carries `search_metadata.scope_incomplete: true`, meaning the search covered less than you asked for. The key is absent when nothing was left out. Narrow the `:depth`, or name fewer folders, and retry. **A failed meaning-based leg degrades the request, it does not fail it.** If the semantic lookup cannot be completed, the response is still `200` carrying the keyword results, and `search_metadata.semantic_available` reports `false`. **An org AI policy denial degrades the same way, silently.** When the caller's AI policy denies Deep Indexing — org-wide, or through the `ai_workspaces` allowlist for this workspace — the meaning-based leg is dropped exactly as on any other semantic failure: `200` with keyword-only results and `search_metadata.semantic_available: false`. There is no separate error to catch; treat this identically to "the semantic lookup could not be completed" and do not report it to the caller as an access refusal. This applies to all four search endpoints — workspace and share storage search, and their unified-search twins. ⚠️ **A keyword-only response carries `search_metadata` only when you send `search_in`.** A request that omits it gets `search_metadata` only when the meaning-based leg actually contributed; a keyword-only answer (Deep Indexing off, a policy denial, or a failed semantic leg) takes the historical response shape with no `search_metadata` at all — so the degradation is invisible and a keyword-only answer is indistinguishable from a complete semantic one. **Send `search_in=both` explicitly if you need to detect this**, then read `semantic_available` before treating a short result set as complete. **Examples:** ```bash # Basic search curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/search/?search=quarterly%20revenue&limit=10" \ -H "Authorization: Bearer {jwt_token}" # Search with full node details curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/search/?search=quarterly%20revenue&details=true" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "files": { "2ltsuq4mjacuv7pgc5ydlxnsjwee4": { "name": "quarterly-report.pdf", "parent_id": "2qk7dkri4yyievbq5hrieq4iohij5", "type": "file", "relevance_score": 1.0, "raw_score": 0.87, "score_source": "semantic", "content_snippet": "The quarterly revenue showed a 15% increase...", "match_source": "both", "media_segment": null, "mimetype": "application/pdf", "page": { "start_page": 3, "end_page": 3 } } }, "search_metadata": { "intelligence_enabled": true, "semantic_available": true, "scoped": false } } ``` The response is a **map of node id → file entry**, not a `results` array (the example shows a subset of each entry's fields; the full list is in the Storage reference). Use `details=true` to attach the full node resource to each entry under `node`. A `pagination` block (`total`, `limit`, `offset`, `has_more`) accompanies the map. **Hybrid response fields (Deep Indexing enabled):** When workspace Deep Indexing is enabled, each file entry in the `/storage/search` response includes additional semantic fields: | Field | Type | Description | |---|---|---| | `relevance_score` | float | A **rank-fusion** score over the two retrieval legs — the name/text search and the content search — combined by each file's **position** in each leg and normalised **within this result set**, so the **maximum** score in the set is exactly `1.0` (not necessarily the first row — ordering is tier-first) and its scale is re-derived for every query. Use it to order results — do not threshold it, and do not compare it across queries. | | `raw_score` | float/null | The **un-rescaled** retrieval score, on the scale named by `score_source`. The merge does not divide, clamp or round it against the other hits that came back with it — a statement about rescaling, **not** a promise the number is constant, since a `keyword` score is BM25 and moves with the index statistics. Not bounded to 0.0-1.0. `null` when `score_source` is `filename`. | | `score_source` | string | Identifies the **scale `raw_score` is on** — nothing more. One of `keyword` (a BM25 score, unbounded above and dependent on the index contents and the query terms), `semantic` (the content engine's similarity score for the file's best-scoring passage), `filename` (the row was placed by a **name match**, not by a measured score, so `raw_score` is `null`), or `metadata` (the row was promoted because the query matched its extracted metadata; `raw_score` is still the keyword BM25 score). On a file both legs found, the leg reported is the one that ranked it **higher**, and on an equal position the keyword leg, whose score is always a real measurement. There is **no `both`** — which legs matched is answered by `match_source`. Decided **per hit**, so one response can carry several values: group by `score_source` before comparing any two `raw_score` values. | | `content_snippet` | string/null | The actual matching text from semantic search. NULL for keyword-only matches. Trimmed by the `output` query param — on `/storage/search/` and on the unified find search (`GET /workspace/{id}/search/` and its share twin) alike, to the same budget (`terse` ~200 bytes, `standard` ~600 bytes, `full` untrimmed; truncated values end with `…`). | | `match_source` | string | Which legs of the hybrid search matched: `keyword`, `semantic`, or `both` — `both` means BOTH legs matched, not that several semantic passages did | | `mimetype` | string/null | File MIME type (e.g., `application/pdf`, `audio/mpeg`). `null` when not known for the row (for example a keyword-only match at `output=terse`). | | `media_segment` | object | Only for audio/video matches when Deep Indexing is on. Contains `start_seconds` and `end_seconds` for deep-linking to the exact timestamp range. | | `search_metadata` | object | Additional search metadata: `intelligence_enabled`, `semantic_available`, `scoped`; `scope_incomplete` and `scope_requested` / `scope_resolved` when a scope was applied; plus `content_search_available` and `reason` when `search_in` was supplied. See *Search* in the Storage reference. | `raw_score` and `score_source` are on `/storage/search/` only — the unified `/search/` route does not return them. **Controlling whether the semantic channel runs at all:** `/storage/search/` takes an optional `search_in=filename|content|both` (default `both`). `search_in=filename` matches the file's name only and **skips the semantic lookup entirely** — useful when you know what a file is called and want a fast, predictable answer. `search_in=content` matches the AI's understanding of the file: its AI-generated summary plus the meaning-based index. It is **not** a text scan of the file's bytes — there is no full-text index of file contents. Those are **two** channels, and Deep Indexing gates only the meaning-based one. Switching AI features off stops new semantic matching but does not un-index summaries already written, so `content` still returns hits for files summarized earlier and returns nothing when there are none. Either channel needs the file to have reached `ai.state: indexed` at some point. When neither content channel can serve a request, the response is `200` with an empty result set and `search_metadata.content_search_available: false` plus a `reason` (`intelligence_disabled`, `summary_permission_denied`, or `content_not_indexed`). Do not report that as "no files found" — retry with `search_in=filename`. Note that `content_search_available` reports whether content search *can* work here, not whether it *will* match: it is reachable as `false` only on a share where neither channel is open, and is always `true` on a workspace. For the outcome of a given request, read `search_metadata.semantic_available`. Two companion parameters shape filename matching: `name_match=auto|exact|prefix|contains|glob` and `case_sensitive`. Full details, including the `glob` syntax and the escaping rules, are in the *Search* section of the [Storage reference](https://api.fast.io/current/llms/storage/). **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1605 (Invalid Input)` | 406 | A bad `files_scope` / `folders_scope` entry — not a `nodeId:versionId` / `nodeId:depth` pair at all (a bare `0` included), an id that is not a valid node or version id, a `versionId` that is not a version of that file, a `:depth` that is not an integer from 1 to 10, a folder where a file was expected, or a file where a folder was expected. The message names the offending entry | | `1605 (Invalid Input)` | 406 | `folders_scope` named the `root` or `trash` folder alias. It takes folder node ids only — send the folder's own node id, or omit the scope entirely to search everything | | `1693 (Temporarily Unavailable)` | 503 | A metadata `filters` predicate could not be evaluated — retry shortly. A failed **semantic** leg does not reach here; it degrades to `200` with `search_metadata.semantic_available: false` | --- ## How to Phrase Questions ### With folder/file scope (RAG) Write questions that will match content in indexed files. The AI searches for relevant passages and cites them. Be specific. - Good: "What were the revenue figures for Q3 2025 compared to Q2?" - Good: "Summarize the key findings from the compliance audit reports" - Bad: "Tell me about these files" -- too vague, no searchable content to match ### With file attachments You can be more direct since the AI has the full file content. - Good: "Describe this image in detail" (with an image attached) - Good: "Extract all action items from this meeting transcript" - Good: "Compare these two contracts and list the differences" --- ## Chat Session Object Schema The thread (chat) resource is returned as `thread` by the details endpoint and as each `chats.items[]` entry by the list endpoint. The details endpoint returns the message history separately as a `turns` collection — there is no embedded `messages` array on the thread. | Field | Type | Description | |---|---|---| | `thread_id` | string | Opaque ID of the thread (the chat) | | `creator` | object | `{type: string, id: string}` -- the chat creator | | `scope` | object | `{type: string, id: string}` -- the workspace or share the chat lives in | | `name` | string | Display name of the chat | | `status` | string | Current chat status: `created`, `ready`, `in_progress`, `deleted`, or `closed` | | `kind` | string | `user` or `agent` -- set at creation, immutable thereafter | | `cost` | object | `{credits: int, tokens: int}` -- credit charge and the raw token count it derives from | | `privacy` | object | `{visibility: "private"|"public", owner: {type, id}|null}` | | `created_at` | string | Creation timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `updated_at` | string | Last update timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `message_count` | integer | Total turns (read endpoints only; omitted on mutation acks) | | `continuable` | boolean | Whether the chat can be continued (read endpoints only) | | `latest_message` | object/null | Most recent turn preview (list endpoint only) | There is no `type`, `unique_creators`, or `efficiency` field. --- ## Message Object Schema A message is a **turn**. The lightweight shape (list/details/mutation responses) carries the fields above the divider; the per-turn detail endpoint additionally returns `result` and `actions`. | Field | Type | Description | |---|---|---| | `turn_id` | string | Opaque ID of the turn (the message) | | `thread_id` | string | Parent thread (chat) opaque ID | | `seq` | integer | Turn sequence number within the thread | | `status` | string | `pending`, `running`, `complete`, `failed`, `cancelled`, `lost`, or `needs_input` | | `idempotency_key` | string | Per-turn idempotency key | | `query` | object/null | The user's submitted question: `{ text, content_parts?, references?, uploads?, subjects? }` | | `error` | object/null | `{ message: string, grpc_status: int|null }` on a `failed`/`lost` turn; `null` otherwise | | `cost` | object | `{ credits: int, tokens: int }` -- the turn's credit charge and raw token count | | `created_at` | string | Creation timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `updated_at` | string | Last update timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `result` | object/null | **Detail view only.** Decompressed answer blob: `answer`, `references`, `citations`, `products`, `navigate_to`, `truncated` (bool -- `true` when the answer was cut off at the model's output-token limit), `thought_transcript`, `commentary_transcript`, `thought_events`, `commentary_events`, and (for `needs_input`) `clarification`. `null` until the turn is terminal | | `actions` | array | **Detail view only.** Ordered, replayable action cards (`seq`, `order`, `label`, `state`, `affected_refs`, `started_at`, `ended_at`) | There is no `state`, `personality`, `response`, `author_name`, or top-level `text`/`citations`/`events` field — the answer and its citations live inside the detail view's `result` blob, and the processing state is `status`. ### Citation Format Citations appear inside a completed turn's `result` blob (under `result.citations`), fetched from **Get message details**. Each entry references a specific location in a file that informed the AI response. | Field | Type | Description | |---|---|---| | `reference` | object | `{ type: int, id: string }` -- the cited object. `type` is a numeric reference-type code (e.g. `5` file, `10` file version); `id` is its opaque ID | | `snippet` | string | Relevant text excerpt (empty string when none) | | `location` | string | `page`, `timestamp_seconds`, or an empty string for a whole-document citation | | `page` | integer | Page number when `location` is `page`; otherwise `0` | | `timestamp_seconds` | float | Offset in seconds for audio/video when `location` is `timestamp_seconds`; otherwise `0` | --- ## Activity Polling for AI Chat Completion **Do NOT poll the message details endpoint in a loop.** Use activity long-polling instead. ``` GET /current/activity/poll/{workspace_id}?wait=95&lastactivity={timestamp} ``` For a share chat, poll the share instead: `GET /current/activity/poll/{share_id}`. The server holds the connection for up to 95 seconds and returns immediately when something changes. Watch for the `ai_chat:{chatId}` activity key -- this fires when the message state changes. | Activity Key Pattern | What Changed | |---|---| | `ai_chat:{chatId}` | AI chat message state updated | | `storage:{nodeId}` (some keys append `:{parentId}`) | File or folder contents added, updated, or removed; the key names the affected node or its parent folder | | `preview:{fileId}` | File preview/thumbnail is ready | Pass the returned `lastactivity` timestamp into your next poll to receive only newer changes. **Anti-pattern:** Do not `GET .../ai/agent/{id}/message/{id}/details/` in a loop. Poll once on the workspace activity endpoint and wait for the `ai_chat` key. --- ## Compact Responses (`output=`) Every metadata endpoint that returns records accepts an optional `output` query parameter that selects the shape of each record in the response — object-metadata, node-facts, saved-filter, eligible-node and field-vocabulary records all take it. 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. **Object metadata** (`GET /workspace/{id}/storage/{node}/metadata/details/`): | Level | Fields returned on each metadata record (cumulative) | |-------|-----------------------------------------------------| | `terse` | `metadata_facts` (field NAMES only), `extraction` (when present), `object_id`, `template_id`, `node_id` (narrowed to `{id, name, type}`) | | `standard` | terse + `instance_id`, `node_id` widened to `{id, name, type, parent, mimetype}`, `autoextractable`; `metadata_facts` becomes `field`, an abbreviated `value`, and `value_truncated` | | `full` | everything | The fact payload is the point of a metadata response, so `terse` keeps it. The savings come from trimming the nested node pointer — `terse` carries only the minimum identity fields needed to address the node in a follow-up call, `standard` adds parent and mimetype for list rendering, and `full` keeps the entire node resource. `metadata_facts` survives EVERY tier here for the same reason it does on eligible nodes, and in the same per-tier shapes tabulated below — it is the file's metadata, so a tier that dropped it would leave nothing worth returning. Its position does not move either: it is the first key of the payload at every tier. See *Get file metadata* for the full contract. **Saved filters** (`GET /workspace/{id}/metadata/filters/`) — this replaces the removed saved-views listing: | Level | Fields returned (cumulative) | |-------|------------------------------| | `terse` | `id`, `name`, `description` | | `standard` | terse + `predicate`, `projection`, `template_id`, `created`, `updated` | | `full` | everything | **Eligible nodes** (`GET /workspace/{id}/metadata/eligible/`): | Level | Fields returned (cumulative) | |-------|------------------------------| | `terse` | `metadata_facts` (field NAMES only), `node_id`, `parent_id`, `name`, `mimetype` | | `standard` | terse + `size`, `summary_title`, `summary_short`, `updated`, `templates`; `metadata_facts` becomes `field`, an abbreviated `value`, and `value_truncated` | | `full` | everything | `metadata_facts` survives EVERY tier, including `terse` — what changes per tier is its shape, not whether it is there. That is deliberate: `is_truncated` and `total` live inside the block, and a consumer that cannot see them cannot know there is more to ask for, or how much more. It is also the **first key of every eligible-node row**, at every `output` level, for the same reason it leads the metadata payload: a client that flattens a row by taking the first collection it finds must land on the live corpus and not on the row's `templates` id list. `parent_id` is present at `terse` too, because grouping a large listing by folder is exactly the job the light tier exists for. | Level | `metadata_facts` shape | |-------|------------------------| | `terse` | `{"count": 2, "total": 2, "is_truncated": false, "fields": "customer, invoice_total"}` — names only, no value at any depth | | `standard` | `{"count": 2, "total": 2, "is_truncated": false, "items": [{"field": "customer", "value": "Acme Corp", "value_truncated": false}]}` — values abbreviated, provenance dropped, `value_truncated` always present | | `full` | `{"count": 2, "total": 2, "is_truncated": false, "items": []}` — `total` is the one key that does not move between tiers: it counts what the node HOLDS, so all three rows report the same number | Use `?output=terse` when you want to know WHICH fields a workspace actually holds values for without paying for the values — a field census across a page costs one short string per row. **Field vocabulary** (`GET /workspace/{id}/metadata/fields/`): | Level | Fields returned (cumulative) | |-------|------------------------------| | `terse` | `name`, `declared_type` | | `standard` | terse + `constraints`, `aliases`, `provisional`, `origin`, `file_count` | | `full` | everything | **Two reserved fields have a CLOSED value list.** `category` and `sub_category` are assigned by extraction to every file it classifies, and are the only fields whose values are drawn from a fixed set: - `category` — `Legal`, `Finance`, `Sales`, `Marketing`, `Engineering`, `Product`, `Operations`, `People`, `Research`, `Media`, `Personal`, `Other` - `sub_category` — `Contract`, `Agreement`, `Invoice`, `Receipt`, `Statement`, `Report`, `Proposal`, `Specification`, `Policy`, `Plan`, `Correspondence`, `Presentation`, `Record`, `Resume`, `Identifier`, `Image`, `Video`, `Audio`, `Dataset`, `Other` Every other field is open vocabulary. These two are closed so a filter can be written without discovering the workspace first. Extraction never writes a value outside the list — an off-list answer is replaced with `Other` before it is stored, and the stored spelling is always the one above, so a filter on `Finance` needs no `finance` variant. **The list is published as `constraints.allowed`** on each of those two fields in the field-vocabulary response, computed from the same list the writer enforces — so build a picker from that rather than hardcoding it. It constrains what is written **from now on**; rows that predate it can still hold other values, so keep tolerating an unknown one. Three sets of rows are outside that guarantee: values migrated from the template system, files extracted before the list was closed on 2026-08-23, and **values inherited by a COPY** — a copy reuses the original's stored bytes and inherits its metadata rather than being re-extracted, so it can inherit a classification recorded before the list existed. Both were recorded before the list existed — the migrated set includes human-typed values AND the previous extractor's output — and both can hold spellings the list does not contain. `Other` is a correct answer for a document that fits nothing, not an error. Read `?field=category` on the vocabulary route for the values a particular workspace actually holds, with occurrence counts. **A value that does not fit its declared type is DROPPED, not corrected.** In an extraction run the field keeps its previous value, the rest of the run lands, and the job still succeeds — so a malformed extracted value is indistinguishable from a field the document never mentioned. Extraction emits these shapes and so should you: - `string` — `Acme Holdings Ltd` — plain text, at most 4096 characters - `int` — `1234`, `-7` — a whole number, optionally signed. Never `1,234`, `$1234`, `12 units`, `3.5` - `float` — `1234.56`, `-0.5`, `1234` — a number, optionally signed. Write a whole number plainly — adding `.0` to a large one silently stores a different number. Never `1,234.56`, `$1234.56`, `1234,56`, `9007199254740993` - `bool` — `true`, `false` — exactly `true` or `false`. Never `maybe` - `datetime` — `2026-01-02T15:04:05Z`, `2026-03-15` — ISO 8601. Use the date-only form when the document gives no time of day; it is stored as midnight UTC. Never `January 2, 2026`, `02/01/2026`, `yesterday`, `1767358800` - `url` — `https://example.com/path` — an absolute `http`/`https` URL. Never `example.com`, `/path/only`, `ftp://example.com` - `json` — `{"key":"value"}` — a JSON object or array — not a bare string or number. Never `{key: value}`, `hello` **Money is two fields.** Give the amount as a plain number declared `float` — always `float`, even for a whole amount, because a field's type is fixed by its first value and an `int` field silently drops every later fractional one. Give the ISO 4217 code as a `string` in a field named after the amount with `_currency` appended — `invoice_total` = `1234.56`, `invoice_total_currency` = `USD`. Two caveats: the pair is not atomic, so either half can be refused while the other lands; and a range over the amount alone still compares USD against EUR as one number, so predicate on the currency field too. **The same shapes bind YOUR writes, but a bad one FAILS DIFFERENTLY.** Where extraction skips what it cannot fit and carries on, `POST .../metadata/facts/` refuses the whole request: nothing is written, every field keeps what it had, and `error.params` names each offending field. So a malformed value in your own write is loud rather than silent, and the fix is to correct the entry and resend the whole payload. **Verify a write by reading the values back anyway**, since a value can be accepted and still stored in a shape you did not intend. A `datetime` with no time of day is written date-only and stored as midnight UTC. Values are normalised to UTC on the way in, and so is a filter literal, so any accepted spelling of the same instant matches. **Node facts** (`GET` and `POST /workspace/{id}/storage/{node_id}/metadata/facts/` -- the write answers in the read's shape, so the tiers apply to both): | Level | Fields returned on each item (cumulative) | |-------|--------------------------------------------| | `terse` | `field`, `value` | | `standard` | terse + `declared_type`, `stored_type`, `source`, `confidence` | | `full` | everything | 🔴 **A fact has TWO shapes, and which one you get depends on where you read it. They are identical at `full` and they diverge at every tier below it.** - The **dedicated node-facts endpoint** (`GET` and `POST /workspace/{id}/storage/{node_id}/metadata/facts/`) uses the table directly above. `value` survives every tier: `terse` is `{field, value}`, and `standard` adds the type and provenance keys. Its wrapper is `{object_id, count, items}` and carries **no** `is_truncated` and **no** `total`, because the read is uncapped and returns the file's whole set — its `count` already IS the total. - The **embedded `metadata_facts` block** — the one on the eligible-nodes listing, the metadata details payload, metadata search results and storage listings — uses the eligible-nodes table above. It is a capped PREVIEW inside a bigger row, so `terse` drops per-item objects entirely for a single `fields` STRING of joined names with no values, and `standard` is `{field, value, value_truncated}` with the value abbreviated: a string over 64 characters is cut to 64 with `…` appended and `value_truncated: true`; a complete string, number, boolean or `null` is unchanged with `value_truncated: false`; a `json`-typed list is a real array — whole (`value_truncated: false`) when its JSON encoding is 64 characters or shorter (counted on the value's own characters, unicode and `/` unescaped rather than on an escaped-for-transmission byte form), otherwise the longest leading run of elements that fits that same budget, with the first element itself cut, when it alone does not fit, so the one-element array's own encoding fits the 64-character budget — the kept text comes out shorter than 64 characters (for example 59 plus `…` for a plain string), cutting the string itself for a string element or the element's JSON encoding for any other type — whatever the element's own type; a `json`-typed object is the structure itself when its encoding fits by the same measure and otherwise a cut JSON preview string — and provenance is dropped throughout. Its wrapper is `{count, total, is_truncated, items}` — or `fields` in place of `items` at `terse`. Both shapes carry the same nine item keys, in the same order, at `?output=full`. **Write your parser against one tier of one surface and it will break on the other**: request `full` if you need one parser to serve both, or branch on which surface the block came from. 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 in `llms.txt` for the full contract. --- ### Eligible nodes ``` GET /current/workspace/{workspace_id}/metadata/eligible/ ``` Paginated list of files and notes eligible for metadata extraction — nodes whose **AI summary** is ready. A preview is **not** required: a file with a summary and no preview is eligible. Folders and links are excluded. Each item carries the node's extracted metadata values inline, so a workspace-wide metadata view renders from this listing alone — you do not need to follow each row with a per-node call. **Auth:** Bearer token required. Workspace member. Metadata billing feature required. **Cost:** none. Listing eligible nodes consumes no credits, however many pages you walk. Like every endpoint it is rate limited — read the `x-ve-limit-*` response headers rather than assuming a fixed budget. **Refusals on this endpoint are `406`, not `400`.** An off-list `category`, a malformed `parent_id` and a cursor that does not match the request's filters all return `406` with a machine-readable `error.code`. Branch on the status and on `error.code`, not on the message text. | Parameter | Type | Required | Description | |---|---|---|---| | `page_size` | integer | No | Records per page **on this endpoint**. Three steps, in this order: anything above 250 is first reduced to 250; **zero or a negative value then becomes 100** — the default, not the smallest size; the result is snapped to the nearest of 25, 100 or 250, never to an arbitrary value in that range. Boundaries for positive values: 62 or below snaps to 25, 63-175 snaps to 100, 176 or above snaps to 250 — a tie keeps the lower size, so 175 gives 100 and 176 gives 250. The snap moves in both directions — ask for 50 and you get half what you asked; ask for 200 and you get more. Those three are the only sizes this endpoint can return. Default 100. The response's `page_size` field reports the size actually used. The snap is specific to this endpoint; a `page_size` elsewhere in the API is not necessarily quantized, so do not carry this rule across | | `cursor` | string | No | Opaque cursor from a previous response's `cursor`. Treat it as meaningless text and send it back unchanged; omit for the first page | | `mimetype` | string | No | Return only nodes with this MIME type | | `extension` | string | No | Return only nodes with this file extension | | `parent_id` | string | No | Return only nodes whose parent folder is this one. **Direct children only** — the folder's own contents, not everything beneath it. Takes a folder's node id, or `root` for the workspace root — the same values each row's own `parent_id` reports, so a value read off this listing can be sent straight back. `trash` is accepted and matches nothing, since trashed files are never eligible. An id naming no folder in this workspace also simply matches nothing; a value that is not a folder reference at all is refused with a `406`. Sending it empty is the same as not sending it | | `category` | string | No | Return only nodes whose `category` is this value. Send one of `Legal`, `Finance`, `Sales`, `Marketing`, `Engineering`, `Product`, `Operations`, `People`, `Research`, `Media`, `Personal`, `Other` — **spelled exactly as listed**; `finance` is refused with a `406` that names the accepted values. Any other non-empty value is refused too, never quietly ignored, so a filter that comes back with results really did filter. Sending it empty is the same as not sending it. Matching against stored values is a separate question and is **case- and accent-insensitive**, so files stored under an older spelling of the same category are still found — `legal`, `LEGAL` and `Légal` are all returned by `category=Legal`. **Whitespace is not folded**: a value stored with surrounding spaces is a different value and is not returned. Adds a `metadata_filter` block to the response | Both narrowing filters are applied before the page is cut, so a page is short only when the workspace genuinely holds no more matching files — never because rows were dropped from a page after it was sized. They combine: sending both returns the files in that folder that also carry that category. **An empty value means "not sent".** `?parent_id=` and `?category=` return the unfiltered listing — not a `406`, and not an empty page — and `mimetype`, `extension` and `cursor` behave the same way, so a client that always sends the key and templates an unset variable into it gets the whole listing. This is a different case from a wrong value: an empty `category` is no filter at all, while an off-list `category` is refused. Filters are bound into the `cursor`. Changing `parent_id`, `category`, `mimetype` or `extension` while sending a cursor issued under different ones is refused with a `406` rather than silently answered with the old filters or with none. Drop the cursor and start the narrowed listing again. **Response (200 OK):** ```json { "result": true, "count": 1, "page_size": 100, "cursor": null, "has_more": false, "items": [ { "metadata_facts": { "count": 2, "total": 2, "is_truncated": false, "items": [ { "field": "customer", "value": "Acme Corp", "declared_type": "string", "stored_type": "string", "source": "ai", "confidence": "high", "rationale": "Named in the invoice header block.", "updated": "2026-01-15 09:12:44 UTC", "actor": { "user_id": null, "kind": "unknown", "agent_name": null, "name_source": null, "credential_type": null, "verified": false } }, { "field": "invoice_total", "value": 1420.0, "declared_type": "float", "stored_type": "float", "source": "ai", "confidence": "high", "rationale": null, "updated": "2026-01-15 09:12:44 UTC", "actor": { "user_id": null, "kind": "unknown", "agent_name": null, "name_source": null, "credential_type": null, "verified": false } } ] }, "node_id": "{node_id}", "parent_id": "root", "name": "invoice-2026-01.pdf", "mimetype": "application/pdf", "size": 40000, "summary_title": "Invoice #1234", "summary_short": "Invoice for services rendered in January 2026.", "templates": [] } ] } ``` **`metadata_facts` is the first key of every row**, ahead of `node_id` and ahead of `templates`, so a client that flattens a row by taking the first collection it finds lands on the live corpus. The same ordering holds at every `?output=` level. **A fact here is byte-identical to a fact from `GET /workspace/{id}/storage/{node_id}/metadata/facts/` at `?output=full`, and NOT at the tiers below it.** The two surfaces reduce differently — this one strips values first because it is a preview inside a listing row, the dedicated endpoint keeps them because values are all it returns — so one parser covers both only if it asks for `full`. See *Compact Responses* above for both shapes side by side. `value` is in its native JSON type; `declared_type` is what the field MEANS and `stored_type` is how it is held, and the two differ for `url` and `datetime` fields. `source` is one of `ai`, `user`, `exif`, `mediainfo`, `validated_server`. `confidence` is `low`, `medium`, `high`, `certain`, or `null` — null means "unknown or not applicable", which is a real answer rather than a missing field. Facts carry no id of any kind: a field is identified by its name. **`metadata_facts` is always present.** A node with no metadata returns `{"count": 0, "total": 0, "is_truncated": false, "items": []}` — never a missing key, never null — so every row can be rendered without branching. A read failure does not degrade this block; it fails the whole request, so `count: 0` always means "this node has no metadata", never "we could not tell". **`count` is what the payload carries, not what the node holds — `total` is what the node holds.** `is_truncated` says whether anything was left out; `total` says how much there was, counted before any cap, and is never less than `count`. Read the pair as "2 shown of 9", and note that `total` is stable across `?output=` levels where `count` is not. When `is_truncated` is `true` the node has more values than this response carries: fetch the complete set for that one file from `GET /workspace/{id}/storage/{node_id}/metadata/facts/`. The cap binds per node, so one metadata-heavy file never consumes another file's share of the response. **Each node's `metadata_facts.items` is in a fixed priority order**: typed values first (numbers, dates, booleans), then identifier fields (names ending `_number`, `_id`, `_code`, `_reference`), then everything else, alphabetical by field name within each group — so for a GIVEN file the order is deterministic, and the same facts survive a `standard`-tier cap on every read of that file. It is not a promise ACROSS files: two files with different fields legitimately surface different facts, so a column layout inferred from one page may need widening on the next. The outer `items` — the node list itself — is ordered newest-updated first, not by name. **`parent_id`** is the folder this file currently lives in, as the same identifier you would use in a path. A file sitting directly in the workspace root reports the literal `"root"`, and one in the trash reports `"trash"`, matching the storage listing's `parent` field exactly — so you can group an eligible listing by folder without a second call per row. It reflects where the file is **now**, not where it was when its metadata was extracted, so a file that has been moved groups under its current folder. It is present at `terse` as well, because grouping a large listing by folder is exactly the job the light tier exists for. **`metadata_filter` appears only when you sent `category`**, and it reports what that filter actually did: ```json { "metadata_filter": { "applied": true, "matched": 42, "truncated": false } } ``` Those three keys are the whole block. There is **no `scope_incomplete`** here, unlike the `metadata_filter` the storage search publishes — a candidate lookup that fails ends this request instead of continuing with a partial set, so there is nothing partial to report. **Its presence means the filter ran, and nothing more.** The block appears whenever you sent `category`, including when nothing matched at all (`"matched": 0`), and it is absent whenever you did not. So the response shape is a reliable answer to "did my category filter apply?" — but it is not a signal that anything was found. 🔴 **`matched` is workspace-wide. It is NOT narrowed by `parent_id`, `mimetype` or `extension`.** It counts every file in the workspace carrying that category, which is the pool the listing then pages through; the other filters cut that pool afterwards. So this response is correct and normal: ```json { "count": 0, "items": [], "metadata_filter": { "applied": true, "matched": 4, "truncated": false } } ``` Four files in the workspace are `Legal`; none of them is in the folder you scoped to. `count` describes the page, `matched` describes the category pool, and they answer different questions — do not read `matched` as a promise that a narrowed listing will return anything. `truncated` is the one to check: the pool is bounded at **1000 files**, so on a workspace with more than that in one category it is capped and `truncated` becomes `true`. Then, and only then, the listing may omit matching files. Narrow further — add `parent_id`, `mimetype` or `extension` — rather than paging to the end and assuming you saw everything. When `truncated` is `false` the pool was complete and paging reaches every match. --- ### Field vocabulary ``` GET /current/workspace/{workspace_id}/metadata/fields/ ``` Paginated list of the field names this workspace stores metadata under, with the type and constraints governing each one. Use it to populate a field picker, or to tell a model which fields it may fill in — a field that cannot be enumerated cannot be named. Fields are identified by `name`; there is no field id. A field that has been merged into another is not listed separately — its name appears in the surviving field's `aliases`, so a name stored earlier still resolves. The vocabulary is per workspace: the same name in another workspace is an unrelated field. **This GET is read-only** — it never creates a field, edits its name or type, or deletes one. Nothing edits a field's name or type or deletes one anywhere. But fields DO get created, by three writers: `POST .../metadata/fields/` (below) declaring one explicitly and choosing its type; extraction proposing a name; and a **node-facts write** (`POST .../storage/{node_id}/metadata/facts/`) using a name this workspace has not seen — that creates the field and infers its type from the value. So a facts write consumes vocabulary capacity; do not assume you must wait for extraction before using a new name. The other write on the vocabulary itself is `POST .../metadata/fields/merge/` below. **Auth:** Bearer token required. Workspace member. Metadata billing feature required. | Parameter | Type | Required | Description | |---|---|---|---| | `page_size` | integer | No | Records per page (1-250, default: 100) | | `cursor` | string | No | Opaque cursor from a previous response's `cursor`. Treat it as meaningless text and send it back unchanged; omit for the first page | | `field` | string | No | A field name (an alias resolves to its canonical field). Switches the response to that field's observed **values** instead of the vocabulary: `{"field": {}, "values": [{"value": ..., "count": N}], "count", "page_size", "cursor", "has_more"}`, paged by `page_size` and `cursor` the same way. A request the server cannot answer for that field is refused with `406` | | `value_prefix` | string | No | With `field` only: narrows the values to those starting with this prefix (typeahead). Sent without `field` it is refused with `406` | **Response (200 OK):** ```json { "result": true, "count": 2, "page_size": 100, "cursor": "invoice_total", "has_more": true, "items": [ { "name": "author", "declared_type": "string", "constraints": null, "aliases": ["auth_or"], "provisional": false, "origin": "user", "revision": 3, "created": "2026-08-14 10:22:01 UTC", "updated": "2026-08-16 09:03:44 UTC", "file_count": 128 }, { "name": "invoice_total", "declared_type": "float", "constraints": null, "aliases": [], "provisional": true, "origin": "ai", "revision": 1, "created": "2026-08-16 08:00:00 UTC", "updated": "2026-08-16 08:00:00 UTC", "file_count": 0 } ] } ``` | Field | Type | Description | |---|---|---| | `name` | string | The field's canonical name — the key values are stored under | | `declared_type` | string | One of `string`, `bool`, `int`, `float`, `json`, `url`, `datetime` | | `constraints` | object or null | `{"allowed": [...]}` on the two reserved classification fields (`category`, `sub_category` — see *Compact Responses* above); `null` on every other field, since no write path sets constraints | | `aliases` | array | Earlier names that resolve to this field | | `provisional` | boolean | `true` while the field is a suggestion nobody has confirmed | | `origin` | string | `ai` if the field was proposed by extraction, `user` if a person created it | | `revision` | integer | Increments each time the field's definition changes | | `file_count` | integer | How many NODES hold a value for this field — files AND notes (see below). **ABSENT — not `0` — when the count could not be read.** Returned from `standard` upward | | `created` | string | When the field definition was created (`YYYY-MM-DD HH:MM:SS UTC`) | | `updated` | string | When the field definition last changed (`YYYY-MM-DD HH:MM:SS UTC`) | **`file_count` is ABSENT rather than zero when it is unknown.** A `0` is a confident claim that nothing uses the field — the sort of claim a cleanup or merge UI acts on — so a count that could not be read drops the key instead. Key present with `0` means genuinely unused; key missing means nobody counted, and the rest of the record is still good. It is the same number the merge pre-flight reports as `files_affected`. ⚠️ **It counts NODES, not files, despite the name.** Notes carry metadata too and are counted alongside files, and the count cannot tell the two apart — a workspace with ten annotated notes and no annotated files reports `10`. Trashed nodes are included as well. Do not read the name as a promise that the number is files-only; the field name is pinned for compatibility and the meaning is stated here instead. --- ### Declare a field ``` POST /current/workspace/{workspace_id}/metadata/fields/ ``` Adds a name to the workspace's vocabulary **before** any file holds a value for it, and lets you choose the type rather than having one inferred. This is what makes "add a column, then have AI fill it in" a single flow: scoped extraction (`fields` on the extract routes) only accepts names the vocabulary already holds, and a field declared here is accepted **immediately** — it does not need a value first. **The call is idempotent.** A name already in use returns the existing definition instead of failing, and a name that was merged away returns the field that now governs it. `field_created` tells you which happened, so you can say "added" or "already there" without guessing. Field names are compared **case-insensitively**: `invoice total` and `Invoice Total` are one field. Only the spelling stored first is kept, which is why the response echoes the stored definition rather than the name you sent — always render what comes back. ⚠️ **A declaration is permanent.** Names are write-once and there is no delete: nothing in this API renames a field or removes it. Declaring a misspelled name leaves it in the vocabulary for good — the only remedy is `POST .../metadata/fields/merge/` to fold it into the right one. There is no `constraints` parameter. Constraints are reported by the listing but are not caller-settable here, and a value that would be silently discarded is refused rather than accepted. **Auth:** Bearer token required. Workspace **admin**. Metadata billing feature required. | Parameter | Type | Required | Description | |---|---|---|---| | `name` | string | Yes | The field name, up to 64 characters. Leading and trailing whitespace is trimmed before it is stored and validated; a name that is empty or only whitespace is rejected | | `declared_type` | string | Yes | One of `string`, `bool`, `int`, `float`, `json`, `url`, `datetime`. Any other value is rejected — including type names used elsewhere in the platform that a field definition cannot store | | Response field | Type | Description | |---|---|---| | `field` | object | The stored definition, in exactly the shape the listing returns — including `name`, `declared_type`, `aliases`, `origin`, `created` and `updated`. `file_count` is `0` when this call created the field and absent when it already existed | | `field_created` | boolean | `true` when this call added the field, `false` when it already existed and was returned as-is | ⚠️ **`field_created` is a boolean; `field.created` is a timestamp.** They are different keys at different levels and it is easy to read one for the other. **Refusals, and which of them are worth retrying.** The error body carries `code` (a unique per-call-site identifier), `text`, `documentation_url` and `params` — there is no class or reason field, so **the HTTP status is what tells you how to react**: | Status | Cause | What to do | |---|---|---| | `406` | The name is blank, longer than 64 characters after trimming, refused by the name policy, or you sent `constraints` | The request is wrong. Fix it — **do not retry** | | `413` | The workspace has reached its metadata field limit | Permanent. Fold near-duplicates with `.../metadata/fields/merge/` or raise the limit — **do not retry** | | `500` | The vocabulary write did not complete — most often a brief lock contention with a concurrent declaration or an in-flight extraction | **Retry with backoff.** Nothing was created | | `429` | Rate limited | **Retry after backoff**, honouring the rate-limit headers | 🔴 **Do not branch on the `code` value.** It identifies the call site that produced the error, not a category, and it changes when that handler is edited. Branch on the status; read `text` for a message to show. `field.origin` is `user` for a field declared this way and `ai` for one proposed by extraction, so the listing can distinguish them afterwards. (`provisional` does not make that distinction — treat it as uninformative.) ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/metadata/fields/" \ -H "Authorization: Bearer {token}" \ -d "name=Invoice Total" \ -d "declared_type=float" ``` ```json { "result": true, "field": { "name": "Invoice Total", "declared_type": "float", "constraints": null, "aliases": [], "provisional": true, "origin": "user", "revision": 1, "created": "2026-08-29 02:41:12 UTC", "updated": "2026-08-29 02:41:12 UTC", "file_count": 0 }, "field_created": true } ``` --- ### Merge two fields ``` POST /current/workspace/{workspace_id}/metadata/fields/merge/ ``` Folds one field into another: `source` stops being its own entry in the vocabulary and its name resolves to `target` from then on, turning up in the target's `aliases` like any other retired name. One call folds exactly one source, and **this API cannot undo it.** Both sides are named BY NAME — there are no field ids on this API. **The two sides are matched differently, and the asymmetry is deliberate.** `target` is resolved the way a filter resolves a name, so naming a field an earlier merge retired lands on the field that name resolves to now. `source` is not: it must name a field that is still its own entry in the vocabulary. A retired name given as `source` is **REFUSED** (`merge_source_already_merged`) rather than quietly retargeted onto the field it resolves to — which would irreversibly retire a live field the caller never named. **Auth:** Bearer token required. Workspace **ADMIN** — a higher bar than the listing's member, because a fold retires a name from every member's vocabulary permanently. Metadata billing feature required. Rate-limited more tightly than the listing, so debounce a dialog that re-previews as the user types; see the rate-limit headers in `llms.txt`. ⚠️ **`confirm` IS A BOOLEAN HERE, and that is unlike every other `confirm` in this API.** On workspace delete, share delete and org close, `confirm` is a STRING that must equal the resource's own name or id. Following that idiom here — sending the field name as `confirm` — is **rejected** with an invalid-input error naming the mistake. It is never read as truthy and it never performs the merge. 🔴 **The accepted grammar depends on the ENCODING, and over JSON a string does NOT confirm.** - **JSON body** — only a real JSON boolean, `true` or `false`. A JSON **string** is REJECTED, `"true"`, `"1"`, `"yes"` and `"on"` included. This is the trap the boolean rule exists for: the house idiom teaches callers to send a string, and here a string is an error rather than a confirmation. An explicit `"confirm": null` is rejected too — omit the key if you mean pre-flight. - **Form-encoded body** — a form cannot carry a real boolean, so a closed set of spellings is accepted, case-insensitively: `true`/`false`, `1`/`0`, `yes`/`no`, `on`/`off`. An empty value is REJECTED, not read as `false`. **Which grammar applies is decided from the request's `Content-Type`, never from what the body looks like.** A request declaring JSON whose body will not parse as a JSON object is an error and is never re-read as a form. Omitting `confirm` is the pre-flight, on either encoding. | `confirm` | What happens | |---|---| | absent, or `false` | **Pre-flight.** Nothing is written. Reports what the fold would do and whether it would be refused. | | `true` | **Performs the fold**, synchronously, answering in the same shape. No job, nothing to poll. | The pre-flight is not a separate estimate — it takes the same lock and evaluates the same refusals in the same order, then rolls back before the first write. **The VERDICT therefore cannot drift:** the refusal a dialog shows is the refusal the confirm will reach. **The COUNTS are a weaker claim, and the difference matters.** They are a reading taken while the merge holds its workspace lock, not a reservation. A metadata write landing between a pre-flight and a later confirm still moves them, and `snapshot` CANNOT detect that — its revisions and group size track changes to the vocabulary, not changes to the values. Treat the numbers as true at the moment they were read. Parameters go in the request **body** — a JSON object or form-encoded fields. They are not read from the query string, deliberately: a query-string form of an irreversible write is clickable and lands in logs and referrers. | Parameter | Type | Required | Description | |---|---|---|---| | `source` | string | Yes | The field to retire, by name. At most 64 characters | | `target` | string | Yes | The field it folds into, by name. At most 64 characters | | `confirm` | boolean | No | `true` performs the fold; absent or `false` is a pre-flight. NOT a name | **Request example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/metadata/fields/merge/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"source": "vendor", "target": "vendor_name", "confirm": false}' ``` **Response (200 OK)** — both modes answer this shape: ```json { "result": true, "source": "vendor", "target": "vendor_name", "files_affected": 128, "target_files_before": 940, "would_refuse": null, "merged": false, "already_merged": false, "snapshot": { "source_revision": 3, "target_revision": 7, "group_size": 2 } } ``` | Field | Type | Description | |---|---|---| | `source` | string | The field retired (or that would be), named as the vocabulary stores it — NOT resolved through a fold, see below | | `target` | string | The field it folds into, by its **canonical** name | | `files_affected` | integer | How many NODES hold a value for `source` — files AND notes — counted before the fold | | `target_files_before` | integer | How many NODES hold a value for `target` — files AND notes — counted before the fold | | `would_refuse` | object or null | `null` when the fold would succeed (or already has); otherwise `{reason, message}` | | `merged` | boolean | `true` only when THIS call performed the fold | | `already_merged` | boolean | `true` when the two names already resolve to one field — nothing to do | | `snapshot` | object or null | `{source_revision, target_revision, group_size}` when the group was locked and walked; `null` otherwise | **Neither name is a plain echo, and the two are settled differently.** `target` comes back CANONICAL — pass a name an earlier merge retired and `target` names the field it resolves to now, not what you typed. `source` is NOT canonical: it comes back as the STORED SPELLING of the field that carries the name you sent (names match case-, accent- and width-insensitively, so `catégory` returns as `category` when that is how it is stored), and never as some other field it resolves to — a `source` that resolves elsewhere is refused instead. Either side returns EXACTLY as sent when no field matches at all. Build a confirmation from what came back, not from what you sent. ⚠️ **Names are never trimmed, and a leading space is significant.** `" author"` and `"author"` are different fields, and the one you send is the one that is looked up. The 64-character limit is measured on what you send, padding included, so a padded over-length name is rejected rather than shortened into a name that would match a different field. A name that is only whitespace is rejected as blank. `snapshot` is present only where the group was actually locked and walked, so it is `null` on every refusal, and `null` on an `already_merged` answer where the two names had already collapsed to one field before anything was locked. `group_size` is how many field definitions the fold locked and guarded — the source, the target, and any names already folded into either — so anything above `2` means one side already carries aliases, which is when a pre-flight's counts are most likely to move underneath it. The revisions are what the guards were evaluated against, read before the fold's own increment: **on a call that folded, the source field's stored `revision` is one higher than the `source_revision` reported.** **A fold does NOT rewrite the values stored under the source field.** They stay filed under the retired field, which nothing resolves to any more — so `files_affected` is "how many NODES' values for this field stop being reachable by name", not a count of values that move. That is what the confirmation is about. A filter on the retired name now resolves to the target and will not find them. **Refusals:** `would_refuse.reason` is a STRING clients branch on, never a numeric code; the `message` is prose and may be reworded. | `reason` | Meaning | |---|---| | `merge_refused_type_mismatch` | The two fields hold values of different declared types, so folding them would mis-read half of them | | `merge_refused_user_authored` | A value was entered by hand on one of them, and a hand-entered value is never folded away automatically | | `merge_refused_reserved_name` | The source is a reserved classification field (`category`, `sub_category`), which is never folded into another field | | `merge_source_unknown` | No field in this workspace is named as given by `source` | | `merge_source_already_merged` | The field named as `source` has already been merged into another field, so it no longer names a field of its own and cannot itself be merged | | `merge_target_unknown` | No field in this workspace resolves to the name given as `target` | **`merge_source_already_merged` carries a THIRD key.** Only this reason adds `source_resolves_to` to the `would_refuse` object — the name the source now resolves to — so a caller can retry immediately with the field it actually meant. It is `null` in the rare case that field has no usable name to report, and the `message` drops its retry suggestion to match. It is the refusal an interactive picker never hits and an API or agent caller reaches easily: the vocabulary listing only ever offers CANONICAL names (a retired name appears inside another field's `aliases`, never as an entry of its own), so a picker cannot select one — but a caller working from a remembered name, or from a name read before someone else's fold, sends one readily. **A refusal is HTTP `200` in BOTH modes** — a refused `confirm: true` answers exactly as a pre-flight does, with `merged: false`, and performs nothing. Read `would_refuse` and `merged`, never the status code. **Source-side refusals are reported before target-side ones**, and an unknown source before an already-merged one, because a caller fixes one name at a time and the source is the destructive side. On all three name-side refusals nothing was counted, so `files_affected` and `target_files_before` are `0` because no pair of fields was found — not because the fields are empty. **Errors:** | HTTP | Code | Reason | |---|---|---| | 406 | 1605 | `source` or `target` missing, not a string, blank or over 64 characters; `confirm` present but not a boolean; or a permanent refusal with no published `reason` ("These two fields cannot be merged.") | | 503 | 1693 | The vocabulary is busy, or the file counts could not be read. Nothing was written — back off briefly and resend unchanged | A count that cannot be read fails the request rather than reporting zero: the counts are the entire content of a confirmation, and a fabricated `0` would say an irreversible fold touches nothing. --- ### Merge candidates ``` GET /current/workspace/{workspace_id}/metadata/fields/merge-candidates/ ``` Which of this workspace's fields are **one field recorded twice**. The comparison that always runs is the one for the same name written differently — `file_type` and `File Type` — though a workspace with no such pair in it, or a page past the last one, will carry none. A workspace **may also** be offered pairs whose names are spelled differently but mean the same thing, like `contract_value_currency` and `currency`. Each pair arrives already oriented as the `source` and `target` the merge endpoint above takes, so a result can be handed straight to it, and `items[].reason` says which kind you are looking at. Read BOTH values as ones that may appear: neither is guaranteed to be in any given answer, and a page can hold only one kind. It exists because the merge endpoint has no other way to be offered safely. A picker listing every field in the workspace invites exactly the sound-alike fold that cannot be undone, and `aliases` answers the opposite question — those are names a merge has already absorbed. 🔴 **A pair proposed for its MEANING is a suggestion to review, not a conclusion — and the merge it feeds cannot be undone.** Two kinds of pair can arrive here, and they do not carry the same weight. A `separator_or_case_variant` pair is a statement about characters: the two names are the same letters once capitalisation and every space / `_` / `-` are folded away, so `file_type`, `File Type` and `filetype` are one name written three ways. A `semantic_same_concept` pair is a judgment: the platform compared what the two names mean and read them as the same concept, which rests on meaning rather than spelling and can be wrong in ways a spelling check cannot be. Put a `semantic_same_concept` pair in front of a person before folding it, present the two kinds differently rather than as one list, and describe neither to a user as a "duplicate" the system found. 🔴 **Neither rule finds every duplicate, so a short or empty list is not proof the vocabulary is clean.** Between them the two rules surface the pairs most worth folding; they are not an inventory of every duplicate a workspace holds, and a pair neither one proposes is not evidence that the two names are different fields. Reading the field listing by hand is still the only reliable way to find every duplicate in a workspace — use this endpoint to work through the obvious ones safely, not to certify that there are none left. 🔴 **An empty list has three different meanings. Read the counts before reporting any of them.** | What you see | What it means | What to do | |---|---|---| | `vocabulary_scanned: false` | The comparison did not run — the workspace holds more fields than it will scan | Report "not computed", never "nothing found" | | `proposals_examined` < `proposals_total` | Pairs were found, but this call only checked some of them and every one it checked was refused by the merge | Call again with `offset` set to `next_offset` | | `proposals_examined` == `proposals_total` | Everything found was checked | This is the only empty list that means nothing is mergeable | **Even the third case is not a clean bill of health.** Every pair must also survive the merge's own refusals, which include any field carrying a hand-entered value. A workspace where people have typed values by hand, or whose metadata arrived from an earlier system, can report nothing and still hold plenty of near-duplicate names — and it is exactly that workspace whose top-ranked pairs get refused, which is why `next_offset` matters there most. **Auth:** Bearer token required. Workspace **ADMIN** — the same bar as the merge it feeds, rather than the listing's member, because a proposal shown to somebody who cannot act on it is only a suggestion to ask an admin for an irreversible favour. Metadata billing feature required. Rate-limited well below the vocabulary listing, because every pair offered is checked against the real merge first; debounce accordingly and see the rate-limit headers in `llms.txt`. **Parameters:** | Parameter | Type | Description | |---|---|---| | `offset` | integer | Optional, default `0`. Rank to begin checking from — send the `next_offset` of a previous response to see further down the list. Values past the end are clamped, not rejected. | The comparison always covers the whole workspace; what `offset` moves is which slice of the ranked pairs this call spends its checking budget on. There is deliberately no page-size parameter: the number checked per call is fixed server-side, because every pair offered is verified against the real merge first. **Response (200 OK):** ```json { "result": true, "count": 2, "vocabulary_scanned": true, "fields_scanned": 62, "proposals_total": 2, "proposals_examined": 2, "next_offset": null, "items": [ { "source": "File Type", "target": "file_type", "reason": "separator_or_case_variant", "direction_certain": true, "files_affected": 3, "target_files_before": 190 }, { "source": "contract_value_currency", "target": "currency", "reason": "semantic_same_concept", "direction_certain": false, "files_affected": 7, "target_files_before": 41 } ] } ``` | Field | Type | Description | |---|---|---| | `count` | integer | Pairs in `items` | | `vocabulary_scanned` | boolean | `false` when the comparison did not run because the workspace holds too many fields — see below | | `fields_scanned` | integer | Fields compared to produce this answer; `0` when `vocabulary_scanned` is `false` | | `proposals_total` | integer | Pairs the comparison found across the whole workspace, before any were checked against the merge | | `proposals_examined` | integer | How many of those this call checked, counted from the top of the ranking | | `next_offset` | integer or null | Send as `offset` to check further down the list; `null` when every proposal has been checked | | `items[].source` | string | The field a merge would retire — send as `source` | | `items[].target` | string | The field it would fold into — send as `target` | | `items[].reason` | string | Which rule proposed the pair. `separator_or_case_variant` — the two names are the same letters once capitalisation and separators are folded away. `semantic_same_concept` — the two names are spelled differently but were read as meaning the same thing, and such a pair is always `direction_certain: false`; an answer may contain none of these at all. Branch on the string rather than assuming the set is closed, so a rule added later arrives as a value to ignore | | `items[].direction_certain` | boolean | `false` when nothing decided which of the two names should survive; always `false` on a `semantic_same_concept` pair — see below | | `items[].files_affected` | integer | Nodes holding a value for `source`, the same number the merge pre-flight reports | | `items[].target_files_before` | integer | Nodes holding a value for `target`, the same number the merge pre-flight reports | **`vocabulary_scanned: false` means "not computed", NOT "nothing found".** A workspace with more fields than the comparison will scan is answered with an empty list and this flag, rather than with pairs chosen from part of its vocabulary — where the better survivor for a pair could be a field the scan never reached. Read the flag before reporting a clean result. **`direction_certain: false` is not a warning that the pair is wrong — but it says a different thing on each kind of pair.** On a `separator_or_case_variant` pair the two names are still the same letters, and the flag means only that the two are used about equally, so nothing in the data says which spelling should survive; both orientations are valid merges and the choice belongs to whoever is asked. On a `semantic_same_concept` pair it is **always** `false`, and not because the counts came out level: nothing character-based decided that pair in the first place, so which name should survive is a judgment about the vocabulary rather than a fact about the strings, and no count can settle it. When it is `true`, the surviving field is the one more files use — or the classification field `category` / `sub_category`, which always survives, because a merge refuses to retire one. 🔴 **`direction_certain` never contradicts the counts printed beside it.** `files_affected` and `target_files_before` are the counts taken at the moment the pair was verified, and the flag is judged against THOSE numbers rather than against an earlier reading — so a row will never tell you to retire the field holding **more** files. When the verified counts REVERSE the orientation the pair was given, it is left out of the answer entirely rather than published with a direction its own numbers contradict; when they LEVEL it, the pair is still returned, with `direction_certain: false`, and the choice of spelling is yours rather than the API's. **Certainty is only ever LOWERED by that check, never raised.** A pair the rules could not decide stays undecided however the counts fall, so `direction_certain: true` always means a rule decided the direction — it is never an artefact of the second reading. A `semantic_same_concept` pair is therefore never raised to `true` by the counts either. **A reserved classification field named as `target` is the one exception, whatever `reason` proposed the pair.** No count decided that direction: `category` and `sub_category` always survive because a merge refuses to fold one away, so the other orientation cannot be performed at any counts. Such a row keeps whatever `direction_certain` it was given however the counts fall — including when `files_affected` is the larger of the two — because the level-counts rule that would otherwise lower it never applies. In practice that means a `separator_or_case_variant` pair naming a reserved field keeps `direction_certain: true`, while a `semantic_same_concept` pair naming one is still `false`: the exemption protects a flag from being lowered by the counts, and that pair's flag was never derived from the counts in the first place. A pair the verified counts turned around is not gone for good — it is simply absent from THIS answer, and a later call re-reads the counts and offers it oriented the other way. Because the list is a reading rather than a reservation, re-read the candidates before acting if the answer has been sitting on screen. **Every pair is pre-checked against the real merge**, in the direction given, whichever rule proposed it, so a candidate is one the merge is expected to perform rather than merely one that looks foldable. A `semantic_same_concept` pair takes the same checking window, the same ordering guarantees and the same pre-flight as a letter pair — the merge's refusals (reserved names, mismatched types, hand-entered values) apply to it unchanged, so a pair the merge would refuse is never proposed. A pair whose `source` turns out to hold MORE files than its `target` is dropped too, but by a different rule and at a different moment: the merge would happily perform that fold, and it is this endpoint that declines to recommend retiring the more-used field. It is never re-oriented and offered the other way round. There is still no automatic merge: everything here is a suggestion, and folding stays an explicit call you make and cannot take back. That check is a reading at a moment, not a reservation — still send the merge pre-flight before confirming, because someone else's write can change the answer in between. **The pairs never chain.** No field is offered as a `source` twice, and no field is both a `source` and a `target`, so the merges can be performed in any order or partially. Folding one pair changes the vocabulary, so ask again afterwards for a fresh, re-ranked list. **Asking again is not how you see more of the list, and two calls are not guaranteed to agree.** Use `offset` / `next_offset` to move down the ranking; `offset` is an ordinal into a ranking the server re-derives on every call, not a stable cursor. The two kinds of pair behave differently under that re-derivation. A `separator_or_case_variant` pair is steady: on an unchanged vocabulary it ranks the same way every time. A `semantic_same_concept` pair is judged afresh on every request, so both its position and whether it appears at all can differ between two calls even when nothing in the workspace has changed — a walk can therefore show you the same pair twice, or step over one entirely. **Walk `next_offset` in one sitting, and if you must not double-count, key on the pair itself — its `source` and `target` — never on its position in the list.** What no call can do is show you a pair the merge would refuse: every pair returned is checked at the moment it is returned. **Ordering.** Every `separator_or_case_variant` pair ranks above every `semantic_same_concept` pair. Within the letter pairs the order is unchanged: a certain direction first, then the fewest `files_affected` — the least destructive merge is offered first — then the most `target_files_before`, then by `source`. The synonym pairs follow, strongest match first, then by `source`. **Errors:** | HTTP | Code | Reason | |---|---|---| | 406 | 1605 | The candidates could not be listed for this request | | 503 | 1693 | The vocabulary is busy, or the file counts could not be read. Back off briefly and resend | A busy workspace fails the request rather than returning the pairs that happened to check out. A list that is short because the vocabulary was busy is indistinguishable from a vocabulary that is nearly clean, and only one of those is safe to act on. --- ## Node Metadata Metadata stored on individual files, as **`metadata_facts`** — typed values, each carrying the `source` that set it. This is the corpus every metadata read, filter and search answers from, and the only one you should build against. One narrow exception, so it does not surprise you: `GET .../storage/{node_id}/metadata/list/` still returns older key/value rows under a generic `metadata` key. Those are the pre-facts corpus, kept readable so nothing written before the migration becomes unreachable. They are not `metadata_facts`, nothing filters or searches them, and no new value is ever written there. 🔴 **The older `template_metadata` and `custom_metadata` response blocks have been WITHDRAWN.** They split the same idea in two — values stored against a template, and everything else — and no Fastio endpoint returns either key any longer. The route that wrote them, `POST .../storage/{node_id}/metadata/update/`, is retired and answers `410 Gone`. If your client still reads those keys, or branches on their absence, that code is dead: treat a response without them as normal, not as an empty file or an error. --- ### Why a file has no metadata (`extraction`) An empty fact list used to mean two different things at once — nothing had ever tried to extract this file, or an extraction ran and failed — and nothing in the response told them apart. The `extraction` block does. Two reads carry it, as a **top-level key** alongside the keys they already returned: `GET` and `POST .../storage/{node_id}/metadata/facts/`, and the **single-node** form of `GET .../storage/{node_id}/metadata/details/`. It survives every `?output=` tier unchanged. ```json { "result": true, "object_id": "{node_id}", "count": 0, "items": [], "extraction": { "state": "failed" } } ``` | `state` | What it means | |---|---| | `extracted` | The file holds metadata, **or** an extraction ran to completion and found nothing. Either way it worked. | | `failed` | An extraction reached a terminal failure, **or** the file was refused before extraction ever ran — an unsupported format, over the size limit, or in the trash at the time — and the file has no metadata. | | `in_progress` | An extraction has been dispatched for this file and has not settled yet. | | `never_attempted` | Nothing has ever tried to extract this file. | 🔴 **`state` is an OPEN-ENDED value set — a value you do not recognise is *unknown*, and must NOT be an error.** New states may be added, and adding one is explicitly **not** a breaking change. Branch on the values you know and let anything else fall through to a neutral "unknown" rendering; a client that switches exhaustively over the four above and fails closed on a fifth has mis-implemented this contract. 🔴 **`extraction` is OMITTED when the state could not be read, and an absent block means "not reported" — NEVER `never_attempted`.** `never_attempted` is a positive claim that nothing has ever tried this file. A block that is not there makes no claim at all, in either direction. Test that the key is present before reading `state`, and treat its absence the way you treat a degraded `metadata_facts` block: ask again later rather than caching it as an answer. **There is no failure cause, deliberately.** `failed` says an extraction ended badly; nothing on this surface says why, and no field carries a reason string. A `failed` file may have been refused before extraction ever ran — for its format, its size, or because it was in the trash — and re-running it returns the same answer. Re-run only after the file itself has changed: a new version, a supported format, or restored from the trash. **`extracted` is not a promise that `items` is non-empty**, and that is the point of the pairing: a completed extraction that found nothing to record reports `extracted` with `count: 0`, which is a different answer from the same empty list under `failed`. Read the two keys together. --- ### Get file metadata ``` GET /current/workspace/{workspace_id}/storage/{node_id}/metadata/details/ ``` Returns everything Fastio knows about one file's metadata. The response carries **one set of values**, `metadata_facts`, alongside the pointers that identify what it belongs to. | Key | What it is | Status | |---|---|---| | `metadata_facts` | Extracted and user-corrected field values for this file | **Live.** This is the corpus. | **`metadata_facts` is the first key of the payload, and that ordering is part of the contract.** A client that flattens the response by taking the first metadata collection it finds in key order lands on the values. Fastio will not move it. **When Fastio cannot read the facts, the key stays put and says so.** A key that simply vanished on a bad day would be indistinguishable from a file holding nothing, so a fact read that fails returns the key with an explicit marker instead: ```json { "result": true, "metadata_facts": { "unavailable": true, "reason": "read_failed" } } ``` Test for it with `metadata_facts.unavailable`. It is present **only** on the degraded block — a healthy block never carries the field — so `if (metadata_facts.unavailable)` is the correct check in both cases. Do not test for an `available` flag; there isn't one, and its absence on a healthy block would make the inverted check misreport every good response. The degraded block shares **no field** with a healthy one: no `count`, no `total`, no `items`, no `is_truncated`. That is deliberate. `"count": 0` is a positive statement that the file has no extracted metadata, which a read that failed has no standing to make and which you may safely cache; the marker makes no statement at all. Treat it as "ask again later", never as "this file has none". The response is still `200`, and everything else in it — the file pointer, the template id, the extraction flag — was read successfully and is complete. Only the facts are missing. The marker survives every `?output=` tier unchanged, so a `terse` or `standard` response reports it in the same shape rather than reducing it to an empty field list. **What the key can carry.** Three of these are states of this endpoint; the fourth is what a *different* endpoint's silence looks like, listed so the two are never confused: | What you receive | What it means | |---|---| | `"metadata_facts": {"count": 2, "total": 2, "is_truncated": false, "items": [...]}` | These are the file's facts. `count` is how many are in **this** payload; `total` is how many the file holds; `is_truncated` says whether more exist. | | `"metadata_facts": {"count": 0, "total": 0, "is_truncated": false, "items": []}` | This file genuinely has no extracted metadata. A positive answer, safe to cache. | | `"metadata_facts": {"unavailable": true, "reason": "read_failed"}` | Fastio could not read them this time. Not an answer about the file — retry. | | the key is **absent entirely** | This endpoint does not serve facts at all — see *Where `metadata_facts` is and is not served* below. It is saying nothing about the file. | **Auth:** Bearer token required. Workspace member. Metadata billing feature required. **Request example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/{node_id}/metadata/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "metadata_facts": { "count": 2, "total": 2, "is_truncated": false, "items": [ { "field": "category", "value": "Finance", "declared_type": "string", "stored_type": "string", "source": "ai", "confidence": "high", "rationale": "The document is headed with an invoice number and a payment due date.", "updated": "2026-08-26 15:00:47 UTC", "actor": { "user_id": "9876543210987654321", "kind": "agent", "agent_name": "Ripley", "name_source": "platform", "credential_type": "platform", "verified": true } }, { "field": "invoice_total", "value": 1500.00, "declared_type": "float", "stored_type": "float", "source": "user", "confidence": null, "rationale": null, "updated": "2026-08-26 16:12:03 UTC", "actor": { "user_id": "9876543210987654321", "kind": "human", "agent_name": null, "name_source": null, "credential_type": "session", "verified": false } } ] }, "extraction": { "state": "extracted" }, "instance_id": "1234567890123456789", "object_id": "{node_id}", "template_id": "{template_id}", "node_id": { "id": "{node_id}", "name": "invoice_2025.pdf", "type": "file", "size": 245760 }, "autoextractable": true } ``` Those seven keys are the whole response — six of them always, plus `extraction`, which is omitted when Fastio could not read the file's extraction state. | Field | Type | Description | |---|---|---| | `metadata_facts` | object | The fact corpus for this file: `count`, `total`, `is_truncated`, and `items`. Item shape matches the node facts endpoint at `?output=full`; below that the two reduce differently — see *Compact Responses* above. **Never `null`.** When the facts could not be read for that request the key is still there, carrying the degraded marker `{"unavailable": true, "reason": "read_failed"}` instead; see *`metadata_facts`* below | | `instance_id` | string | The owning workspace's id | | `object_id` | string | The node's id, echoed back | | `template_id` | string/null | Associated template ID, or null. Single-file extraction no longer uses templates; a non-null value records the workspace's active legacy template that a folder-level `extract-all` ran against | | `node_id` | object | Storage node resource with file details | | `autoextractable` | boolean/null | Whether the file is eligible for automatic extraction | | `extraction` | object | Why this file has or has no metadata: `{"state": …}`, one of `extracted`, `failed`, `in_progress`, `never_attempted` — an **open-ended** set. **Single-node form only** (the bulk comma-separated-ids form does not carry it), and **omitted** when the state could not be read, which means *not reported* rather than `never_attempted`. Survives every `?output=` tier. See *Why a file has no metadata* above | #### `metadata_facts` - `count` is how many facts this payload carries, not how many the file has — `total` is how many the file has, counted before any cap, and is never less than `count`. `is_truncated` says something was left out; `total` says how much there was. Unlike `count`, `total` does not move between `?output=` levels, so `count` of `total` reads as "8 shown of 14" and is what tells you whether re-reading at `full` is worth a second call. - `source` is the provenance of the value: `ai`, `exif`, `mediainfo`, `user`, or `validated_server`. - `confidence` is `low`, `medium`, `high`, `certain`, or `null`. A value a person entered has no extraction confidence, so `null` is a real answer rather than a missing field. - A field that was **cleared** is present with an empty value. A field that was **never extracted** has no entry at all. The two are deliberately distinct. - The item shape is identical to the `metadata_facts` block on the eligible-nodes listing, on metadata search results and on storage listings — those four are one dialect at every `?output=` level. It matches `GET /current/workspace/{workspace_id}/storage/{node_id}/metadata/facts/` at `?output=full` **only**: that endpoint is the other dialect and reduces differently below `full`. See *Compact Responses* above. **Empty versus degraded.** A file with no facts returns `{"count": 0, "total": 0, "is_truncated": false, "items": []}` — a positive statement that the file has none. If the block carries `unavailable`, Fastio could not vouch for the answer (the metadata read did not complete). Treat the marker as *unknown*, not as *none*, and retry rather than caching it. The rest of the response is still owed to you and is still correct, so a degraded block does not fail the request. The same block also appears **nested** inside the `node_id` sub-object, because every storage node projection carries it. That nested copy is the node's field, not a second API — read the top-level key. The nested copy keeps the older rule and is **omitted entirely** rather than degraded when it cannot be read, so the marker is a top-level thing only. #### Where `metadata_facts` is and is not served `metadata_facts` is served by the endpoints built for it: the per-file metadata details call (single and multi-id) and the workspace eligible-nodes listing. It is **deliberately absent** from two other places, and the absence is a contract rather than an omission — in each case the response is saying nothing about the file's facts either way: - the **metadata version history**, which replays snapshots of the older key/value model and answers a different question; - **share-link and File Share metadata reads**, which never serve facts. Such a link can admit anonymous visitors, and the extracted values plus the field names themselves describe the owning workspace's private taxonomy. Read facts from the workspace endpoints, as a workspace member. These are absences, not the degraded marker above — a missing key is a refusal to answer, while the marker means Fastio tried and failed. #### The withdrawn key/value sets `template_metadata` and `custom_metadata` carried the older key/value model — one for values stored against a template, one for everything else. **Neither key is returned by any endpoint now**, and the route that wrote them answers `410 Gone` (see *Update file metadata* below). Historical values that were migrated forward read back as ordinary facts. The rest are still reachable, just not under those two keys: `GET .../storage/{node_id}/metadata/list/` returns them under a generic `metadata` key, and the metadata version history replays stored snapshots in the old `{key, value, value_type, is_auto}` shape. Neither is a filterable or searchable corpus — read them to recover a historical value, not to build against. Two habits from that model do not carry over. **`is_auto` is not a fact field** — a fact records `source` instead (`ai`, `user`, `exif`, `mediainfo`, `validated_server`), written at the time of the write rather than derived, so a client testing `is_auto` on a fact is testing a key that is not there. And **there is no second corpus to reconcile**: no response carries two sets, so there is no precedence ladder to run between them and no de-duplication for you to perform. Read `metadata_facts` and take it as the answer. **A value a person wrote is never replaced by extraction.** A hand-written fact carries `source: "user"`, which outranks the `ai` an extraction writes, so extraction **skips that field entirely** -- the stored value stands, nothing is written, and no second copy of the field is created beside it. Re-running extraction over a field you have curated therefore cannot overwrite it, and a `200` from an extraction job does not mean every field in scope was rewritten. 🔴 **ONE thing hands a curated field back to extraction, and it is the DELETE:** - **Delete it** -- `DELETE .../storage/{node_id}/metadata/` with `keys: ["field_name"]`, which removes every stored value behind that key. No row remains, which is the one state a later extraction may fill. **No write reopens a field.** A `null` sent to `metadata/facts/` is a complete no-op -- it clears nothing and creates nothing -- so it cannot produce the empty row that used to invite extraction back. And `""`, `0` and `false` are worse than a no-op for this purpose: unchecking a box, or setting a count to zero, is an answer a person chose rather than the absence of one, so each stores at `source: "user"` and pins the field **permanently** beyond extraction's reach. The route that once cleared a field by writing `null` was `metadata/update/`, which is retired; that behaviour went with it. If you mean "I have no answer for this field", delete it. **Two consequences of deleting are worth designing around.** Removing a value does **not** remove the field: the name stays in the workspace vocabulary, still resolves for a later write, and still counts against the plan's field cap -- the vocabulary has no delete. And a delete does **not** by itself queue a re-extraction: a full re-extract of a file whose current version was already extracted by the current extraction version answers `already_extracted` and queues nothing, so name the field in a `fields`-scoped `metadata/extract/` call when you want it filled again. #### Bulk Form The metadata `details` endpoint accepts a comma-separated list of node ids in place of a single `{node_id}`: ``` GET /current/workspace/{workspace_id}/storage/{id1},{id2},{id3}/metadata/details/ ``` Up to **25** ids per call. Duplicate ids are silently deduplicated. Empty segments (e.g. trailing comma) return `406`. Each resolved object carries its own `metadata_facts` block, first, exactly as the single-id form does — including the empty-versus-degraded rule. The facts for a page are read as one batch, so a read failure degrades the page: every object's block carries the `unavailable` marker rather than the page failing or any object silently reporting `count: 0`. Test each object's own block; do not assume the whole page is healthy because the first one is. **The bulk form does NOT carry `extraction`.** That block is on the single-id form only, so a bulk object never reports why a file has no metadata — and its absence there is the ordinary "not reported", not `never_attempted`. Ask for the ids you need the state of one at a time, or read it from `.../metadata/facts/`. The bulk response shape differs from the single-id form. Successfully resolved objects appear under `objects`; per-id failures appear in a parallel `errors` array. Template definitions are hoisted to a top-level `templates` map keyed by `template_id` so a template shared by N objects is returned once instead of N times — clients look up each object's template via the `template_id` it carries. HTTP status is `200` when at least one object resolves and `404` when every requested id errored. ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/{node_id_1},{node_id_2},{node_id_3}/metadata/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (HTTP 200):** ```json { "result": true, "format": "multi", "objects": [ { "metadata_facts": { "count": 1, "total": 1, "is_truncated": false, "items": [ { "field": "category", "value": "Finance", "declared_type": "string", "stored_type": "string", "source": "ai", "confidence": "high", "rationale": null, "updated": "2026-08-26 15:00:47 UTC", "actor": { "user_id": "9876543210987654321", "kind": "agent", "agent_name": "Ripley", "name_source": "platform", "credential_type": "platform", "verified": true } } ] }, "instance_id": "1234567890123456789", "object_id": "{node_id_1}", "template_id": "{template_id}", "node_id": { "id": "{node_id_1}", "name": "invoice_2025.pdf", "type": "file" }, "autoextractable": true }, { "metadata_facts": { "count": 0, "total": 0, "is_truncated": false, "items": [] }, "instance_id": "1234567890123456789", "object_id": "{node_id_2}", "template_id": "{template_id}", "node_id": { "id": "{node_id_2}", "name": "invoice_2026.pdf", "type": "file" }, "autoextractable": true } ], "templates": { "{template_id}": { "template_id": "{template_id}", "name": "Invoice", "fields": [ { "name": "invoice_number", "type": "string", "description": "Invoice number" } ] } }, "errors": [ { "node_id": "{node_id_3}", "code": 191049, "message": "Storage node not found" } ] } ``` `errors` is always an array (possibly empty); `templates` is always an object/map (possibly empty `{}`). **Request-level errors** (whole request fails): | Error Code | Sub-code | HTTP Status | Description | |------------|----------|-------------|-------------| | `1605 (Invalid Input)` | `160655` | 406 | Empty segment between commas | | `1605 (Invalid Input)` | `109184` | 406 | More than 25 unique ids in one request | | `1609 (Not Found)` | — | 404 | Every requested id errored (errors array is populated) | **Per-id error codes** (returned inside each `errors[]` entry; the request itself is HTTP 200 unless every id errored): | Code | Meaning | |------|---------| | `147196` | Invalid storage node id format | | `196136` | The literal `root` sentinel was supplied (only files/notes are valid) | | `191049` | Storage node not found | | `190770` | Backend error retrieving the storage node (any non-`not-found` failure) | | `150183` | Storage node exists but is not a file or note (e.g. a folder) | | `157684` | Backend failure retrieving the metadata key/value rows | --- ### Get node metadata facts ``` GET /current/workspace/{workspace_id}/storage/{node_id}/metadata/facts/ ``` Reads every metadata fact stored on one file or note, each joined to its canonical field name. This is the per-node counterpart of the field vocabulary above (which lists a workspace's field NAMES) -- this endpoint lists the typed VALUES one node actually holds. Not paginated: the response returns every fact on the node in one call. **Auth:** Bearer token required. Workspace member. Metadata billing feature required. | Parameter | Type | Required | Description | |---|---|---|---| | `output` | string | No | Detail level for each item: `terse`, `standard`, or `full` (default). See Compact Responses above. | **Response (200 OK):** ```json { "result": true, "object_id": "{node_id}", "count": 3, "items": [ { "field": "author", "value": "Jane Doe", "declared_type": "string", "stored_type": "string", "source": "ai", "confidence": "high", "rationale": "Identified from the document byline", "updated": "2026-08-16 09:03:44 UTC", "actor": { "user_id": "9876543210987654321", "kind": "agent", "agent_name": "Ripley", "name_source": "platform", "credential_type": "platform", "verified": true } }, { "field": "invoice_total", "value": 1250.5, "declared_type": "float", "stored_type": "float", "source": "user", "confidence": null, "rationale": null, "updated": "2026-08-16 10:15:00 UTC", "actor": { "user_id": "9876543210987654321", "kind": "human", "agent_name": null, "name_source": null, "credential_type": "session", "verified": false } }, { "field": "source_url", "value": "", "declared_type": "url", "stored_type": "string", "source": "user", "confidence": null, "rationale": null, "updated": "2026-08-16 11:00:00 UTC", "actor": { "user_id": "9876543210987654321", "kind": "human", "agent_name": null, "name_source": null, "credential_type": "session", "verified": false } } ], "extraction": { "state": "extracted" } } ``` | Field | Type | Description | |---|---|---| | `object_id` | string | The node's id, echoed back | | `count` | integer | Number of entries in `items` | | `extraction` | object | Why this file has or has no metadata: `{"state": …}`, one of `extracted`, `failed`, `in_progress`, `never_attempted` — an **open-ended** set, so treat an unrecognised value as *unknown* rather than as an error. **Omitted** when the state could not be read, which means *not reported* and never `never_attempted`. See *Why a file has no metadata* above | | `items[].field` | string | The field's canonical name -- there is no fact id and no field id, only the name | | `items[].value` | mixed | The value in its native JSON type -- a number for an `int`/`float` fact, a boolean for a `bool` fact, a string for `string`/`url` facts, the canonical `Y-m-d H:i:s UTC` string for a `datetime` fact, or the decoded structure for a `json` fact | | `items[].declared_type` | string | What the field MEANS: one of `string`, `bool`, `int`, `float`, `json`, `url`, `datetime` | | `items[].stored_type` | string | The storage column the value actually landed in: one of `string`, `int`, `float`, `bool`, `date`, `json`. Differs from `declared_type` in two cases, in different columns: a `url` field stores as `string` (the text column), a `datetime` field stores as `date` (a real datetime column) -- both are reported so a disagreement is visible | | `items[].source` | string | Provenance: one of `ai`, `user`, `exif`, `mediainfo`, `validated_server` | | `items[].confidence` | string or null | Extraction confidence band, one of `low`, `medium`, `high`, `certain`; `null` means unknown or not applicable (a value entered by a person has no extraction confidence). The `confidence_gte` filter operator takes the matching integer — `low` = `0`, `medium` = `1`, `high` = `2`, `certain` = `3`. `certain` appears only on deterministic sources (`exif`, `mediainfo`, `validated_server`); AI-extracted values cap at `high` | | `items[].rationale` | string or null | Optional model justification for an AI-extracted value; `null` when none | | `items[].updated` | string | When this fact was last written (`YYYY-MM-DD HH:MM:SS UTC`) | | `items[].actor` | object | Who wrote this value: `user_id`, `kind` (`human`, `agent`, `api_key`, `app`, `system`, `unknown`), `agent_name`, `name_source`, `credential_type`, `verified`. An AI-extracted value is credited to Fastio's verified platform agent (`verified: true`) acting for the user who triggered extraction; any other `agent_name` is self-declared. Returned on `full`. Full reference: *Actor Attribution* in the Storage reference | **A cleared field is not the same as one that was never extracted.** Clearing a field writes a fact with an empty value: for a text field `value: ""` and `source: "user"`, and it still appears in `items`. A field nobody has ever extracted has no row at all -- it is simply absent from `items`. Absence means "no fact", not "empty fact". --- ### Write node metadata facts ``` POST /current/workspace/{workspace_id}/storage/{node_id}/metadata/facts/ ``` Writes metadata facts a PERSON asserts on one file or note -- the write half of the resource the GET above reads. Every value stored here is recorded with `source: "user"`, which OUTRANKS `ai`, `exif` and `mediainfo`, so a later automatic re-extraction of the same file finds the human value in place and leaves it alone. **This is how you CORRECT a wrong extracted value.** Before it, a wrong value could only be deleted, never replaced with the right one. **Auth:** Bearer token required. Workspace member. Metadata billing feature required -- the same bar as the GET on this path. Reads and writes on this path are rate-limited on SEPARATE allowances, so polling the read never consumes a save's; see the rate-limit headers in `llms.txt`. Parameters go in the request **body** as a JSON object. | Parameter | Type | Required | Description | |---|---|---|---| | `facts` | object | Yes | JSON **object** mapping field NAME to the value being asserted. At most 100 entries | | `output` | string | No | Detail level for each item in the response: `terse`, `standard`, or `full` (default). See Compact Responses above | `facts` must be a JSON **object**. A JSON **array** is refused, and so is the empty object `{}` -- name at least one field. 🔴 **A `null` value is a COMPLETE no-op: it clears nothing and it creates nothing.** Null entries are dropped before the request resolves a single field, so a `null` neither overwrites the value a field already holds nor brings a field into existence for a name this workspace has never seen. Read the field back after a `null` write and you get the previous value, unchanged, with its previous source; read your field list back and it has not grown. This is by design, so that a partial payload -- a form echoed back with its untouched cells left null -- can never silently wipe a field, spend a field slot, or invent vocabulary the caller did not ask for. **If EVERY entry in `facts` is `null` the request writes nothing at all** -- no value, no field definition, no change event -- and answers `200` with the file's stored metadata exactly as it already stood. That is the same body a `GET` on this path would have returned. Field NAMES are still checked on a null entry: `{"facts": {"__proto__": null}}` is refused rather than quietly ignored, so an unusable name can never ride along unmentioned inside a payload whose other entries do save. **To remove a value, use `DELETE /current/workspace/{workspace_id}/storage/{node_id}/metadata/`** with the field name in `keys`. Setting a value and clearing one are deliberately different calls. ⚠️ **If you are migrating from the retired `metadata/update/` route, `null` is the behaviour that does NOT carry over.** There, a `null` cleared the field and handed it back to extraction. Here it does nothing at all, and no other value reproduces the old effect — a clear becomes a `DELETE`. Code that sent `null` to blank a cell will now succeed while changing nothing. ⚠️ **Field names are never trimmed or normalised, but they ARE matched case- and accent-insensitively.** An inner space is a perfectly good name character, so `invoice total` is a valid field name, and the 64-character limit is measured on what you send, padding included. 🔴 **`Author` and `author` are the SAME field, not two.** Names are compared without regard to case or accents, so a value sent as `author` lands on an existing `Author` rather than creating a second field. Sending both spellings in one request is **refused as a duplicate** — the request fails and neither value is written — rather than applied twice or silently collapsed to whichever came last. Pick one spelling per request. 🔴 **The one exception: a name with LEADING or TRAILING whitespace is REFUSED, not accepted and not silently trimmed.** `" author"` and `"author "` are both rejected with `reason: field_name_not_canonical`; send the name without the padding. The refusal exists because a padded name used to resolve onto the field of the same trimmed name and overwrite a value the request never named and could not see in its own payload. A refusal costs you one round trip; the overwrite cost the value. Nothing legitimate is blocked -- no stored field name can carry surrounding whitespace, so no existing field is unreachable this way. **Request example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/{node_id}/metadata/facts/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"facts": {"invoice_total": 1250.50, "vendor_name": "Acme Supply", "reviewed": true}}' ``` **A name this workspace already uses keeps that field's declared type; a name it has never seen creates a new field.** A canonical name and a retired one both resolve to the field that governs the name now, so a value sent under a name an earlier merge folded away lands on the surviving field. For a genuinely new name there is no declaration to check against, so the type is inferred from the value's JSON type: | You send | The new field is declared | |---|---| | a string | `string` -- including a date-shaped one. A string is **never** inferred as `datetime` | | a number | `int` or `float` | | `true` / `false` | `bool` | | an object or an array | `json` | | `null` | **nothing is created** -- the entry is dropped, so no field is declared and no value is written | The inference is deliberately shallow: guessing `datetime` or `url` from text would declare a constraint you never asked for and hold every later write to that field to it. If you need a field to be one of those types, that declaration has to already exist. 🔴 **ALL-OR-NOTHING.** The whole payload is validated before anything is written, and **no metadata VALUE is ever written by a refused request** -- not the offending entry, and not the usable ones beside it. That guarantee is unconditional. A refusal is a refusal, and the response names every offending field, so a partial save is not something you have to detect by diffing your request against what came back. (The retired `metadata/update/` route behaved the other way -- it SKIPPED a field it could not coerce, named each one in a `skipped` array, applied the rest, and still answered `200`. Code written to inspect `skipped` has nothing to inspect here; check for a refusal instead.) 🔴 **A refused write changes nothing -- including your FIELD LIST.** The guarantee covers the field DEFINITIONS the request would have created, not only its values. Naming a field this workspace has never seen creates it, and a request that names two new fields but is refused on the second one leaves **neither** behind. That matters because a workspace's field list is capped by its plan and a field definition cannot be deleted through the API: a partially-applied write would have spent a slot on a request that returned an error, with nothing able to reclaim it. It holds for every way the request itself can be refused -- an unusable field name, a value that will not fit its field's declared type, two names resolving to one field, a field whose declared type moved underneath the request, the plan's field limit, and the workspace being momentarily busy. (The one error that is NOT a refusal -- a write that lands and then cannot be read back -- is called out under *Errors* below.) **The corollary is that retrying is safe.** A refused write leaves the file and the workspace exactly as it found them, so fixing the offending entry and sending the whole payload again is the intended recovery -- there is nothing to clean up first, and no half-created field to reuse or dispose of. **A workspace at its field limit answers with a PLAN-LIMIT error, not a validation error** -- a different status and a different code, so the two never need telling apart from the message text. Merge two spellings of one field together, or raise the plan, then retry. Values written to fields that **already** exist are never refused by that limit. **Response (200 OK)** -- exactly the shape the GET on this path returns, so you confirm the stored state rather than assuming it: ```json { "result": true, "object_id": "{node_id}", "count": 4, "items": [ { "field": "invoice_total", "value": 1250.5, "declared_type": "float", "stored_type": "float", "source": "user", "confidence": null, "rationale": null, "updated": "2026-08-26 16:37:29 UTC", "actor": { "user_id": "9876543210987654321", "kind": "human", "agent_name": null, "name_source": null, "credential_type": "session", "verified": false } }, { "field": "vendor_name", "value": "Acme Supply", "declared_type": "string", "stored_type": "string", "source": "user", "confidence": null, "rationale": null, "updated": "2026-08-26 16:37:29 UTC", "actor": { "user_id": "9876543210987654321", "kind": "human", "agent_name": null, "name_source": null, "credential_type": "session", "verified": false } }, { "field": "reviewed", "value": true, "declared_type": "bool", "stored_type": "bool", "source": "user", "confidence": null, "rationale": null, "updated": "2026-08-26 16:37:29 UTC", "actor": { "user_id": "9876543210987654321", "kind": "human", "agent_name": null, "name_source": null, "credential_type": "session", "verified": false } }, { "field": "author", "value": "Jane Doe", "declared_type": "string", "stored_type": "string", "source": "ai", "confidence": "high", "rationale": "Identified from the document byline", "updated": "2026-08-26 09:03:44 UTC", "actor": { "user_id": "9876543210987654321", "kind": "agent", "agent_name": "Ripley", "name_source": "platform", "credential_type": "platform", "verified": true } } ], "extraction": { "state": "extracted" } } ``` The body is the node's COMPLETE set of facts after the write, not an echo of what you sent -- fields you did not name come back untouched, with whatever provenance they already had. Every field in each item means exactly what it means on the GET above. **A value this endpoint wrote comes back with `source: "user"` and `confidence: null`.** The null is not missing data: a value a person typed has no extraction confidence to report, and reporting one would invent a measurement nobody took. `rationale` is `null` for the same reason. 🔴 **`category` and `sub_category` hold only their published values — an off-list value is SILENTLY REPLACED with `Other`, not rejected.** These two fields are the only ones whose VALUES come from a fixed list (the published list itself is under **Two reserved fields have a CLOSED value list**, above); every other field is open vocabulary. Sending `"category": "Financial"` **succeeds** with `200`, and what is stored is `Other` — the same normalisation AI extraction applies to an answer it cannot place. There is no error, no warning field and no rejected entry. ⚠️ **So the value you sent is not necessarily the value stored, and nothing in the response status tells you that.** The response echoes the STORED state, so read the classification back from it rather than assuming your input survived. This is the one place on this endpoint where a `200` does not mean "what you sent is what is there" — every other refusal on this route fails loudly instead. **A different SPELLING is not a different value.** `"finance"`, `"FINANCE"` and `" Finance "` are all accepted and stored as `Finance`, the published spelling, which is the same normalisation extraction applies. That is what lets you filter on `Finance` without also checking for a `finance` variant. The response echoes the stored state, so the canonical spelling comes straight back to you. A `null` classification is **not** off-list. `null` asserts nothing and is dropped before this check runs, so a client echoing a whole form back with its untouched cells left `null` is never told its `category` is invalid. 🔴 **One consequence worth designing for, and it is SILENT.** A few files hold a classification that predates the list -- extracted before it was closed, migrated from the template system, or inherited by a copy (see above, under **Two reserved fields have a CLOSED value list**, for the exempt sets). Re-sending such a file's STORED `category` back through this endpoint **succeeds with `200` and quietly replaces that historical value with `Other`**, because the value is not on the list. Nothing in the status code reports it; the response is the stored state read back, which is the only place it shows. **Send only the fields you are actually changing, or `null` for untouched cells** -- not because the request would fail, but because it would quietly succeed at something you did not ask for. **Refusals name the field.** A refused request carries the offending names in the error message AND repeats them as structured `error.params` entries -- a **list** of `{name, kind, message, code, reason}`, one per field, where `name` is the FIELD name and `kind` is `invalid`. Branch on `reason`, never on the message text: | `reason` | Meaning | |---|---| | `field_name_blank` | The field name is empty or only whitespace | | `field_name_too_long` | The field name is longer than 64 characters | | `field_name_unsafe` | That name cannot be used as a field name | | `field_name_not_canonical` | The field name has leading or trailing whitespace. Send it without the padding -- it is refused rather than trimmed for you | | `value_type_mismatch` | The value cannot be stored as the field's declared type -- e.g. text sent to an `int` field. `message` says what the value would have had to be | | `duplicate_field` | Two names in this payload resolve to the SAME field, which happens when one has been merged into the other. Both names are listed, each naming the other; send one of them | **Errors:** | HTTP | Code | Reason | |---|---|---| | 406 | 1605 | The body is not a JSON object, `facts` is missing, `facts` is not a JSON object, `facts` is empty, or it carries more than 100 entries. Nothing was written | | 406 | 1605 | At least one entry could not be stored -- an unusable name, a value that will not fit its field's declared type, two names resolving to one field. `error.params[]` names every one. No value was written; see *A refused write changes nothing* above | | 404 | 1609 | The node does not exist in this workspace. A node in another workspace answers identically | | 406 | 1605 | The node exists but is not a file or note (e.g. a folder). No value was written | | 412 | 1685 | This workspace's field vocabulary is at its plan limit. Merge or remove a field, or upgrade the plan, then retry. No value was written; see *A refused write changes nothing* above | | 503 | 1693 | This workspace's metadata is momentarily busy with another metadata operation. No value was written -- back off briefly and resend the request unchanged; see *A refused write changes nothing* above | | 500 | 1664 | The facts could not be written, or could not be read back afterwards | `503` is the answer to contend with, not to treat as a failure: metadata writes on one workspace take their turn against field merges and extraction runs, and the request that loses that race has stored nothing. Resending it unchanged is correct. **Every error above means no value was written EXCEPT a failure to read the facts back.** The values are committed before the response body is assembled, so a request that stores its facts and then cannot produce the confirming read fails rather than answering `200` with an empty set -- which would be indistinguishable from a node holding no metadata. If a write errors on the read side, re-read the node with the GET on this path to see what landed. 🔴 **That exception has TWO forms, and one of them shares a status code with an ordinary refusal.** A read-back failure that may clear on its own answers `500` (`1664`). One that will not answers `406` (`1605`) -- **the same pair every validation refusal uses** -- so the status alone cannot tell you whether your write landed. **The discriminator is `error.params`:** every validation refusal carries it, naming each offending field, because the request never got as far as writing. A read-back failure carries **no `error.params` at all**, because no field was at fault. So a `406` with `error.params` means nothing was written and the named fields need fixing; a `406` **without** it means the write may have landed and could not be confirmed -- **do not retry it, re-read the node with the GET on this path.** **Resending the same request is always safe.** Writing a value you have already asserted produces exactly the same stored state -- a second identical write does not stack, duplicate, or change anything, down to the `updated` timestamp. That is what makes retrying the right response both to a `503` and to the one error that can leave the values committed. --- ### Update file metadata (RETIRED — `410 Gone`) ``` POST /current/workspace/{workspace_id}/storage/{node_id}/metadata/update/ ``` 🔴 **RETIRED. This path answers `410 Gone` for every request that reaches it, and it is never coming back.** (A request the shared header rejects first — a malformed `x-ve-idempotency`, an invalid `?output=` — still answers `406` here as it would on any endpoint. If you are probing whether this route is retired, send a well-formed request, or you will read a header refusal as a live route.) Write node metadata at `POST /current/workspace/{workspace_id}/storage/{node_id}/metadata/facts/` instead — see *Write node metadata facts* above. The response body names the replacement: ```json { "result": false, "error": { "code": 191138, "text": "This endpoint has been retired. Do not retry it; the path will not return. Write node metadata by POSTing to .../storage/{node_id}/metadata/facts/ with a JSON body of the form {\"facts\":{\"\":}}; the key_values form this route accepted is not read there.", "documentation_url": "https://api.fast.io/llms.txt", "resource": "POST Workspace [param] Storage [param] Metadata Update" } } ``` **It is retired, not deprecated-but-working.** Nothing you send is read — not `key_values`, not `template_id`, not a trailing `{template_id}` path segment, not the method. Every request shape gets the same `410`. **Do not retry it, do not vary the payload, and do not report it as an outage:** a `410` is the path telling you, on its own, that it is finished, which is exactly what a `404` cannot do. If a client library still offers a "set file metadata" call, check which path it sends before treating a failure as a service problem — one built against this route fails on every call until it is updated. **Three behaviours went with it, and code written against them needs changing, not just repointing:** - **`null` no longer clears a field.** On this route a `null` cleared the value and handed the field back to extraction. On `metadata/facts/` a `null` is a complete no-op. Clearing is now `DELETE .../storage/{node_id}/metadata/` naming the field in `keys`. - **A bad value no longer skips one field — it refuses the whole request.** This route coerced what it could, skipped what it could not, named the casualties in a `skipped` array and still answered `200`. `metadata/facts/` validates everything first and writes nothing on a refusal, naming each offending field in `error.params`. There is no `skipped` array to read. - **There is no `template_id` and no custom-versus-template distinction.** `metadata/facts/` writes into the workspace's field vocabulary, which is flat. A `template_id` still appears on read responses as historical provenance and is inert. The stored-state echo this route returned — `template_metadata` and `custom_metadata` — is gone with it. Confirm a write from what `metadata/facts/` returns, which is the same read its `GET` would give you. --- ### Delete file metadata ``` DELETE /current/workspace/{workspace_id}/storage/{node_id}/metadata/ ``` Delete metadata keys from a file. **Auth:** Bearer token required. Workspace member. Metadata billing feature required. | Parameter | Type | Required | Description | |---|---|---|---| | `keys` | string (JSON) | No | JSON-encoded **array** of key names to delete. Omit the parameter entirely, or send the empty array `[]`, to delete every value on the file. Sending it blank (`?keys=`) is a client error, not a shorthand for "everything". | `keys` must be a JSON **array**. A JSON **object** — including the empty object `{}` — and a bare scalar are refused as a client error, before anything is deleted. That distinction is load-bearing rather than pedantic: `{}` used to read as "no keys named" and deleted **every** value on the file, and `{"title":"summary"}` used to read as the one-element list `["summary"]` and deleted the field named `summary` — a field the request never mentioned. Only an omitted `keys`, or the empty array `[]`, means delete-everything. A `keys` that is PRESENT but blank is refused for the same reason: it is a request that did not say what it meant, and "everything" is the most destructive way to guess. Naming a key the file does not carry is not an error; deleting an absent key succeeds, as a delete should. A named key is removed **completely**: a field name can stand behind more than one stored value (one per template that declares it, plus an untemplated one), and all of them go. "Completely" covers both kinds of value a file can carry — the ones you set yourself and the ones extraction produced — so a deleted key stops appearing in the file's metadata, in the field and value listings, and in metadata search. **A `200` means the removal finished. A failure does NOT mean it never started.** A delete removes what you named in more than one step, and there is no all-or-nothing guarantee across them. If the request fails partway with `1664 (Datastore Error)`, part of what you named may already be gone while the rest is still stored — and the remainder stays **visible**, so reading the file's metadata back afterwards can show a partially deleted set. Do not read a failure as "the delete was rejected, nothing changed". **Retry the same request.** Deleting is idempotent — naming a key that is already gone succeeds — so re-sending the identical call is the right response to a failure and finishes the removal. Confirm by reading the file's metadata back, not from the status code alone. A delete also has to take its turn against extraction and field merges running in the same workspace, so a request can be answered as temporarily unavailable when that workspace is busy. **That case is different: nothing at all was removed** — the work never began. Retry the same request. Only files and notes support metadata -- folders return an error. --- ### Extract metadata (single file) ``` POST /current/workspace/{workspace_id}/storage/{node_id}/metadata/extract/ ``` Enqueues an AI extraction job for a single file. **Asynchronous** — returns HTTP 202 Accepted with a job descriptor. Poll `GET /current/workspace/{workspace_id}/jobs/status/` to track progress, then read the values from `GET /current/workspace/{workspace_id}/storage/{node_id}/metadata/details/` once the job reports `completed`. Supports documents, spreadsheets, images (PNG, JPEG, WebP), and code files. Extracted values join the file's `metadata_facts` corpus and are stored with `source: "ai"`. **Fields you have edited by hand are skipped, not overwritten.** A hand-written fact carries `source: "user"`, which outranks `ai`, so extraction leaves that field exactly as it is and creates no second copy beside it. The job still reports success. **Only one thing hands such a field back: deleting it** (`DELETE .../storage/{node_id}/metadata/` with `keys: ["field_name"]`), which leaves no row for the field on that node. No write reopens it -- a `null` sent to `metadata/facts/` does nothing at all, and `""`, `0` and `false` are answers a person chose, so they store at `source: "user"` and keep the field protected. Deleting alone does not queue a re-run either: a full re-extract of a file version already extracted by the current extraction version answers `already_extracted`, so name the field in `fields` below to have it filled again. **Auth:** Bearer token required. Workspace member. Metadata billing feature required. | Parameter | Type | Required | Description | |---|---|---|---| | `template_id` | — | — | **Retired. Sending it is an error.** A request that includes a non-empty `template_id` is rejected with `406` and error code `179390`; omit the parameter. It is refused rather than ignored on purpose: extraction no longer has any notion of a template, so honouring it is impossible, and silently accepting it would run a **full, billed** extraction while you believed you had narrowed it. Use `fields` to name the metadata fields you want. It is refused wherever it arrives — body or query string. | | `fields` | array of strings (JSON) | No | Read from the POST body **or** the query string (the body wins when both are present); either way the value is JSON — `?fields=["amount","invoice_date"]`. JSON-encoded array of field names scoping the extraction (see below — the scope is exclusive, so anything else the model returns is discarded). **Must be a JSON array of non-null strings**; a bare scalar, a `null`, an object, or a `null` member is rejected. A present-but-empty array is rejected too — omit `fields` to extract everything. At most 50 distinct names per request; duplicates are collapsed before that limit applies. **Every name must already be in this workspace's vocabulary** — either declared with `POST /current/workspace/{workspace_id}/metadata/fields/` or produced by an earlier extraction — read the current list from `GET /current/workspace/{workspace_id}/metadata/fields/`. An unrecognised name is **rejected, not ignored**, so a `202` always means the scope you sent is the scope that was accepted. Omit `fields` to extract everything available. | **If you are still sending `template_id`, stop — that request now fails.** The response is `HTTP 406`, carrying the standard error envelope with: ```json { "code": 179390, "text": "The `template_id` parameter is retired and no longer selects what this extraction reads: metadata is no longer associated with templates. Remove the parameter to extract everything available, or use `fields` to name the metadata fields you want." } ``` Removing the parameter is the whole fix; nothing else about the call changes. A caller that never sent it is unaffected. **There is one extraction path.** Every extraction reads the workspace's own field vocabulary and writes the same kind of result. `template_id` is always `null` in the response — it is kept in the payload so a client that reads the field keeps receiving it, so treat it as **nullable** and do not branch on it. `fields` echoes the scope you asked for, or `null` when you asked for none. **What `fields` does, precisely.** Omitting `fields` is a **discovery pass**: the fields this workspace already uses are offered to the model as context, and it may add new ones or correct existing ones. An unscoped request for a file already extracted at its current version **by the current extraction version** answers `already_extracted` and does no work. When Fastio's extraction engine is upgraded, a plain re-extract on a file processed by an earlier version runs again and is billed as a new extraction — that is how existing files pick up newly supported fields. *Naming `fields` makes the request EXCLUSIVE.* The model is asked for exactly those fields and nothing else, and **any other field it returns anyway is discarded rather than written** — so a targeted request cannot quietly rewrite columns you did not ask about. It also makes the request distinct from the full extraction, so a file that was already extracted runs again for the fields you name; that is how a field added to your vocabulary later reaches files that predate it. The file is still read in full, and a named field is only written if the document actually contains it — the model omits what it cannot find rather than guessing. Requesting the *same* fields twice does not run twice: the second request matches the first one's record and does no work. **Response (HTTP 202 Accepted):** ```json { "result": true, "job_id": "{job_id}", "template_id": null, "node_id": "{node_id}", "fields": ["amount", "due_date"], "status": "queued", "status_uri": "https://api.fast.io/current/workspace/{workspace_id}/jobs/status/" } ``` 🔴 **`node_id` comes back UNHYPHENATED. String equality against the id you put in the URL is FALSE.** A node opaque id is written with hyphens in a path, and this response reports the same id with the hyphens stripped: ``` you called POST .../storage/26t7z-x432g-nzlqq-pgqz7-r5i2h-lawn/metadata/extract/ the 202 answers "node_id": "26t7zx432gnzlqqpgqz7r5i2hlawn" the status entry "node_id": "26t7zx432gnzlqqpgqz7r5i2hlawn" ``` A client that compares the jobs-status entry against the hyphenated id it sent matches **nothing**, and it does so **silently** — no error is raised, the `metadata_extract` array simply never appears to contain your file, and the poll runs until you give up. **Correlate using the `node_id` from this response body, not the one you wrote into the path**: the `202`, the `already_extracted` `200` and the jobs-status entry all report the same unhyphenated form, so they agree with each other and only the URL form differs. If you must compare against your own copy, strip the hyphens from both sides first. **`status_uri` is an absolute URL, and you can follow it verbatim.** It used to be emitted as a bare path with no version segment and no trailing slash, which did not route — a client following it as given got a "resource not found" platform error. It is now a full URL whose host is the API host for the environment the call was made against, so treat any relative form you have hardcoded as stale. **Response (HTTP 200 OK)** — this version was already extracted by the current extraction version. Re-requesting extraction for a file version that has already been processed by that same extraction version does **not** queue duplicate work and is **not** billed again; you get this instead of a `202`, with no `job_id`. Once Fastio's extraction engine is upgraded, a plain re-extract on a file processed by an earlier version returns `202` instead: it runs again and is billed as a new extraction, which is how existing files pick up newly supported fields. **This 200 is only returned for an UNSCOPED request.** A request naming `fields` is a different unit of work, so it is not answered from the full extraction's record — it returns `202` as normal. If that exact scope has already been run, the duplicate is detected later, by the worker, which does no work and does not bill; you will simply see the job finish without new facts. So do not treat `202` as proof that work was performed, and do not treat the absence of a `200` as proof that it was not. ```json { "result": true, "job_id": null, "template_id": null, "node_id": "{node_id}", "fields": null, "status": "already_extracted" } ``` `status` is `queued` on a `202`, or `already_extracted` on a `200`. This route emits no other status — in particular it never answers `in_progress`. **Repeat requests are safe and are not double-billed, and there is no time limit on that.** The protection is keyed on the file's **current version**, the **set of fields** you named, and the **extraction version** that processed it — not on a window — so a retry an hour later is exactly as safe as one a second later, while uploading a new version of the file, or an upgrade to Fastio's extraction engine, makes the next request a genuinely new unit of work. Field names are compared as a set: order does not matter and duplicates are collapsed, so `["amount","due_date"]` and `["due_date","amount","amount"]` are the same scope. A duplicate request always receives a **new** `job_id`. This route never hands back the `job_id` of a job already in flight, so two `202`s carrying different `job_id`s do not mean two extractions were performed. **Polling flow.** Every extraction returns a `status_uri`. ``` POST /current/workspace/{id}/storage/{node}/metadata/extract/ -> HTTP 202 { "job_id": "{job_id}", "status": "queued", "status_uri": "https://api.fast.io/current/workspace/{id}/jobs/status/" } GET /current/workspace/{id}/jobs/status/ -> jobs.metadata_extract[] includes { "kind": "single", "active": true, "node_id": "{node_id}", "template_id": null, "job_id": null, "status": "queued", "progress_percent": 0 } GET /current/workspace/{id}/jobs/status/ (later) -> entry now { "status": "completed", "progress_percent": 100, "completed_at": ... } GET /current/workspace/{id}/storage/{node}/metadata/details/ -> extracted values under `metadata_facts` ``` 🔴 **An extraction started by this route goes `queued` → `completed` (or `errored`). It never reports `in_progress`.** There is no intermediate republish on this path: the entry is seeded `queued` when the request is accepted and rewritten once, at the outcome. **A client that waits to *see* `in_progress` before it starts watching for the result waits forever.** (`in_progress` is a real value on this surface, but only on the per-file entries of a folder-level `extract-all` that ran against a template — see the `status` field description under *Job Status* below. It is a property of which route started the work, not of `kind`, and it always arrives with a non-null `template_id`.) An entry can return to `queued` after having been picked up: a single-file extraction that is interrupted by a transient condition is re-queued rather than failed, and re-reports `queued` until it runs again. **For an extraction started on this route**, treat `completed` and `errored` as the only stopping points — polling until the entry simply stops being `active` is equivalent, but polling until `status` changes at all is not. **That stopping rule is specific to THIS route.** It holds because a transient failure here republishes `queued` rather than `errored`, which makes `errored` genuinely final. The templated folder-level arm does the opposite — it publishes `errored` on a retryable failure and can resume afterwards — so do not carry this rule across to an entry you did not start here. See the `status` field description under *Job Status* below. **`job_id` is `null` while the extraction is in flight, and carries the `202`'s exact id once the entry is terminal.** The status snapshot is seeded before the job row exists, so a `kind: "single"` entry started by this route reports `job_id: null` for as long as it is `queued`; at `completed` or `errored` the entry reports the same `job_id` the `202` gave you, byte for byte. So it *is* usable to correlate — it is in fact the only key that identifies **your request**, where `node_id` identifies only the **file** — but a match on it succeeds only from the terminal entry onward. Choose accordingly: `node_id` if you need to see the entry while it is still running, `job_id` if you need to be sure the terminal entry is the one you started. (Nothing republishes the entry between the seed and the outcome, so no intermediate `job_id` value exists to observe.) **And never on `template_id`.** The status array is workspace-wide, and `template_id` is filled in by the server, so you have nothing of your own to compare it against: this endpoint always reports `null` there, while a folder-level run happening at the same time contributes entries naming its own template. Matching on it therefore selects unrelated entries or none, depending on what else the workspace is doing. **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1605 (Invalid Input)` | 406 | Node is root, `fields` references an unknown field, or payload is malformed | | `1605 (Invalid Input)` | 406 | The file's format is excluded from metadata extraction (SVG, ICO icons) — deterministic, retrying cannot succeed | | `179390` | 406 | `template_id` was sent — the parameter is retired and is refused, not ignored | | `1609 (Not Found)` | 404 | Node not found | | `1656 (Limit Exceeded)` | 413 | `fields` names more than 50 distinct fields, or the file exceeds the extraction size limit for its type | | `1664 (Datastore Error)` | 500 | Failed to enqueue extraction job | | `1696 (Credits Exhausted)` | 402 | No AI credits remaining | | *(generated per call site)* | 403 | Org AI policy denies the caller metadata extraction -- `ai_policy_denied` or `ai_policy_workspace_not_allowed` (allowlist arm), `params.feature:"metadata"`. See *AI, Deep Indexing & MCP Access Policy* in `llms/orgs.txt`. | --- ### Batch extract metadata for a folder ``` POST /current/workspace/{workspace_id}/storage/{node_id}/metadata/extract-all/ ``` Enqueue an async job that runs metadata extraction on **every file in a folder**. This is the folder-level counterpart to the single-file `/metadata/extract/`. **A template is not required.** If the workspace has an active template the run extracts against it and establishes each file's assignment as it goes, as described below. If the workspace has **no** active template, the run extracts each file for whatever metadata it supports instead — this previously answered `404 "No template configured for this workspace"`, so a workspace that had never configured a template could not run folder-wide extraction at all. On that path `template_id` in the response is `null`, and none of the template-assignment or per-template cap behaviour below applies. **Auth:** Bearer token required. Workspace **admin** permission. Metadata billing feature required. Conservative throttle (these operations are expensive). `{node_id}` is a folder node opaque id, or the literal `root` alias for the workspace root. **Parameters:** | Parameter | Required | Description | |---|---|---| | `fields` | optional | JSON array of field names to restrict this run to, e.g. `["Invoice Total","Vendor"]`. Read from the POST body **or** the query string (the body wins when both are present); either way the value is JSON — `?fields=["Invoice Total","Vendor"]`. Omit to extract everything available. | **Scoping a run to named fields.** `fields` answers "I just added a field — fill it in for this folder" without re-extracting everything. It is the folder-level counterpart to the same parameter on the single-file `/metadata/extract/`. - Only fields already in the workspace vocabulary can be named — declared or produced by an earlier extraction. A name the workspace has never produced is **rejected**, not ignored — silently dropping it would empty the scope, and an empty scope means a full folder run. - The scope is **exclusive**: those fields alone are extracted. - Per file, only the named fields that file is **missing** are extracted, so re-running a scope over a mostly-filled folder is normally cheap. A file that already carries all of them is normally skipped with no model call. **This is a strong tendency, not a guarantee** — if the "what does this file already have?" read fails, the run deliberately falls back to the full named scope for that file rather than risk leaving a silent gap, and that file is extracted and charged even though it may already hold the values. - **The two routes do not share a billing claim.** Naming the same fields here and on the single-file route can be charged twice for the same file: this route bills what each file was actually **missing**, the single-file route bills the **whole** set you named. Pick one route per file, rather than retrying a file here after asking for it there. - A scoped run always uses template-free extraction, so `template_id` comes back `null` and none of the template-assignment or per-template cap behaviour below applies — even in a workspace that has an active template. - At most 50 distinct field names per request. **Response (200 OK):** ```json { "result": true, "job_id": "{job_id}", "template_id": "{template_id}", "fields": null } ``` `fields` echoes back the scope the run was narrowed to, or `null` for an unscoped run. A scoped request that comes back with `fields: null` means the scope did not take effect. The job runs asynchronously. On an UNSCOPED call it runs against the workspace's active template when there is one; a `fields`-scoped call is always template-free (see above), so nothing below about templates applies to it. Poll `GET /current/workspace/{workspace_id}/jobs/status/` (the `jobs.metadata_extract[]` array) for progress. **The run establishes each file's template assignment as it goes, and the per-template file cap bounds those assignments.** A file the run reaches that is not yet mapped to the template is mapped before it is extracted, and that mapping is counted against the plan's per-template file cap like any other. When the cap is full, a file that would need a **new** mapping is **skipped** and the walk continues: the cap bounds mappings, not extraction, so a file already mapped to the template needs no slot and is still extracted wherever it appears in the walk. A run that skipped at least one file for this reason reports `stop_reason: "template_node_cap"` in both the progress snapshot and job status. Files already extracted in that run keep their values. Raise the cap (or unmap files you no longer need) and re-run to pick up the skipped ones. **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1605 (Invalid Input)` | 406 | Missing/invalid folder id, the node is not a folder, `fields` is present but empty, `fields` names a field this workspace has never produced | | `1656 (Limit Exceeded)` | 413 | `fields` names more than 50 distinct fields | | `1609 (Not Found)` | 404 | Folder not found | | `1664 (Datastore Error)` | 500 | Failed to list templates, resolve a requested field, or enqueue the job | | `1696 (Credits Exhausted)` | 402 | No AI credits remaining | | *(generated per call site)* | 403 | Org AI policy denies the caller metadata extraction -- `ai_policy_denied` or `ai_policy_workspace_not_allowed` (allowlist arm), `params.feature:"metadata"`. | --- ### Metadata versions ``` GET /current/workspace/{workspace_id}/storage/{node_id}/metadata/versions/ ``` List metadata version snapshots for a file. **These snapshots are historical only.** Each entry replays a stored snapshot in the older key/value shape (`key`, `value`, `value_type`, `is_auto`) — the shape no other endpoint serves any more — and no current write records a new one, so this reports what was captured under the previous model rather than a running history of today's fact writes. To see what a file holds now, read `metadata/details/` or `metadata/facts/`. --- ### Saved metadata filters A **saved filter** is a named, workspace-shared query over extracted metadata: a predicate plus an optional display projection. It replaces the per-user "saved views" that used to live at `metadata/view/` and `metadata/views/` — those endpoints have been REMOVED. Filters are shared across the workspace rather than private to one user, and are identified by a server-owned `filter_id`. ``` POST /current/workspace/{workspace_id}/metadata/filters/ GET /current/workspace/{workspace_id}/metadata/filters/ GET /current/workspace/{workspace_id}/metadata/filters/{filter_id}/ PUT /current/workspace/{workspace_id}/metadata/filters/{filter_id}/ DELETE /current/workspace/{workspace_id}/metadata/filters/{filter_id}/ GET /current/workspace/{workspace_id}/metadata/filters/{filter_id}/nodes/ ``` **Auth:** Bearer token required. Workspace member. Metadata billing feature required. This surface is metadata-only — it does not read AI content, so no content-AI feature is required. Any workspace member may create, edit and delete filters. Filters are shared, and there is **no per-filter ownership** — a member can edit or delete a filter another member created. If that matters in your interface, guard it yourself. #### Request encoding | Method | Where parameters go | |---|---| | `POST` create, `PUT` update | a **JSON object request body**, read whole | | `GET` list, `GET` execute | **query-string** parameters | | `GET` one, `DELETE` | no parameters | A `POST` or `PUT` body that is not a JSON object is `1605 (Invalid Input)`, HTTP 406. These two routes do **not** read form-encoded fields — the predicate and projection are structured JSON, so the whole body is parsed as one object. #### Create (`POST`) and update (`PUT`) body | Name | Type | Required | Notes | |---|---|---|---| | `name` | string | **yes** | Trimmed; must be non-empty; max **100** characters. Unique per workspace — a collision is HTTP **409** | | `predicate` | array | **yes** | The clause list described below. **Send `[]` explicitly** for match-all; omitting the key is an error, not a default | | `description` | string | no | Max **255** characters | | `projection` | object or array | no | Stored as sent; only `sort` is interpreted (below) | | `template_id` | string | no | Create only — records which template this filter succeeds. **Ignored on update** | Length and clause-cap violations (`name` over 100, `description` over 255, predicate over 5 clauses) all return the same `1605 (Invalid Input)`, HTTP 406, with one generic message — the response does not say which of the three you hit, so validate them before sending if you need to tell a user which one to fix. **`PUT` REPLACES the filter — it is not a partial patch.** Every field is written from the request body on every call: - `name` and `predicate` are **required on every update**. Omitting either is rejected outright, so a partial update cannot silently widen your filter. - `description` and `projection` are **optional, and omitting one CLEARS it** — silently, with a `200`. There is no error and no warning. So to change one field, **read the filter first, then send the whole object back** with your edit applied. Sending only `{"name": "..."}` is rejected (no `predicate`); sending `{"name": "...", "predicate": [...]}` succeeds and wipes the description and projection. `template_id` is accepted only on create. On update it is ignored — a filter's provenance and creation time are carried over from the stored record and cannot be rewritten by a caller, and an update can never move a filter to another workspace. #### Responses Every response carries the platform's top-level `"result": true` alongside the payload, exactly as elsewhere in this API: | Operation | Body | |---|---| | create · get one · update | `{"result": true, "filter": { … }}` | | list | `{"result": true, "count": 2, "items": [ … ], "cursor": null, "has_more": false}` | | delete | `{"result": true}` — no payload beyond it | | execute | `{"result": true, "items": [ … ], "scope": { … }}` | Note the list keys are **flat** — `count`, `items`, `cursor` and `has_more` sit beside `result`, not nested under a wrapper — and the collection key is **`items`**, not `filters`. A filter object carries `id`, `name`, `description`, `predicate`, `projection`, `template_id`, `created` and `updated`. Timestamps use `Y-m-d H:i:s UTC` (for example `2026-04-27 16:37:29 UTC`). `description`, `projection` and `template_id` are `null` when unset. **`output=terse` returns `id`, `name` and `description` only** — the `predicate`, `projection`, `template_id` and timestamps are absent, not null. Supplying no `output` gives you the default, `full`, which carries them; `standard` carries them too. Only `terse` drops them, so send `terse` only where you genuinely need nothing but the filter's identity. #### Listing Cursor-paginated. `page_size` accepts **1–250** and defaults to **100**; a value above 250 is silently reduced to 250, while `0` or a negative value is rejected. Pass the `cursor` from the previous response to continue. **Page with `has_more` and `cursor`, never with `count`** — `count` is the number of items in the page you are holding, so a short page is not a signal that the listing is finished. #### Deleting `DELETE` is **idempotent and deliberately uninformative**: an id that never existed, one already deleted, and one belonging to a workspace you cannot see all return the same `200 {"result": true}`. This is so the response cannot be used to discover which filter ids exist elsewhere. **A `200` therefore does not prove anything was deleted** — do not report "deleted" as a confirmed outcome on the strength of it. There is no confirmation parameter. A `503` means the delete may or may not have happened and is safe to retry. Reading a filter behaves the same way: a nonexistent, deleted, or foreign `filter_id` is one indistinguishable `404`. #### Limits Each plan caps how many saved filters a workspace may hold. Exceeding it is a **denial**, not a validation error and not retryable, and the response body names the limit — so surface it as "no filter slots left" rather than "invalid filter". **It answers HTTP `401`, which does not mean your credentials are wrong.** This platform maps denials onto `401`, so re-authenticating or refreshing a token will not help and the request will keep failing. Read the returned error code and message rather than the status alone before deciding a `401` is an auth problem. Plans also cap how many nodes one execution returns; see `scope` below. **The predicate** is a JSON array of `{field, operator, value}` clauses, AND-chained, at most **5** per filter. Operators: `=` `!=` `<` `<=` `>` `>=` (value required), `in` (non-empty list), `exists` / `not_exists` (no value), and `confidence_gte` (int `0`-`3`, the stored value's confidence band: `0` = `low`, `1` = `medium`, `2` = `high`, `3` = `certain`). **Two traps in `confidence_gte`.** `confidence_gte: 3` matches **deterministic sources only** (`exif`, `mediainfo`, `validated_server`) — AI-extracted values are capped at `high` on write, so `3` excludes them rather than selecting the best of them. And `confidence_gte: 0` is **not** "no minimum": a value entered by a person has no extraction confidence, and a `null` confidence satisfies no level including `0`, so `0` drops every hand-entered value. Use `exists` to match a field however its value was obtained. Send the integer — a decimal string such as `"2"` is accepted, but a band name such as `"high"` is rejected, as is any non-integer including `2.0`. A value the server cannot use is refused when the filter **runs**, not when it is saved: a filter carrying one is created successfully and then fails with HTTP 406 every time its nodes are listed. **An EMPTY predicate is valid** and means "everything" — a filter with no clauses is a saved column layout over the whole workspace, which is the natural starting point before narrowing. It is accepted on create and update, and **EXECUTING it returns the workspace's nodes that carry any extracted metadata**, bounded by the same result cap, `scope_truncated` flag and plan node cap as any other filter execution. A MALFORMED predicate — a clause that is not an object, or one missing its `field` or `operator` — is still rejected with `1605 (Invalid Input)`. Names are unique per workspace; the field vocabulary a predicate references is per workspace, so the same field name in another workspace is unrelated. A `value` may be a bare JSON integer and is compared exactly as sent. **Do not round-trip one through a JavaScript `Number`** — anything above `Number.MAX_SAFE_INTEGER` (`9007199254740991`) silently loses precision if you `JSON.parse` it and re-serialize. Pass it through unmodified. **Create and update validate STRUCTURE only** — that each clause names a field and an operator from the list above (an unrecognised operator is a `406` at save time), within the clause cap. Whether the field exists, whether the operator is legal for that field's declared type, and whether the value renders are all checked when the filter is EXECUTED. A filter can therefore be created successfully and still return `406` when its nodes are listed. **The projection** is an optional ordered field list plus an optional sort spec, stored and returned exactly as sent. Only the SORT is applied today: a spec of the form `{"sort": {"field": "updated", "dir": "desc"}}` naming a sortable FILE column (`name`, `size`, `updated`) becomes the default sort for the execute endpoint, and an explicit `sort_field` query parameter always overrides it. The stored hint names file columns only — it is never resolved against your metadata vocabulary, so to order by a metadata value pass `sort_metadata_field` on the request (below). The field list is stored for forward compatibility but does not shape the response — per-field verbosity is governed by `output`, not the projection. **`sort` is the only key we interpret; the rest of the projection is opaque** — stored byte-for-byte and returned unchanged, never validated. You choose the field-list shape, so do not assume a filter you did not create uses yours. Filters produced by the saved-view and template migrations carry a `columns` list (`{"columns": [{"field": "status"}], "sort": …}`), because they hold per-column display state a flat list cannot express. Read defensively: look for `sort` if you care about ordering, and treat the rest as data you may not have written. **Execute** (`.../{filter_id}/nodes/`) returns the matching storage nodes plus a `scope` object naming every way the answer was bounded, so a truncated result is never silently presented as complete. **Ordering the execution** uses one of two axes, and they are separate parameters: | Parameter | Orders by | Values | |---|---|---| | `sort_field` | a file column | `name` · `size` · `updated` | | `sort_metadata_field` | the VALUE of one of your metadata fields | any field name in this workspace's vocabulary | | `sort_dir` | direction, for whichever axis you used | `asc` · `desc` | They are two parameters because a workspace is free to declare a field called `name` or `size`, and one parameter could not tell that field from the built-in column — you would get the wrong order with nothing in the response to show it. Sending both is `1605 (Invalid Input)`: one list cannot have two orders. `sort_dir` defaults to `desc` for `updated` and `asc` for everything else, including every metadata field. `sort_metadata_field` must name a field that exists in this workspace and whose type can be ordered. An unknown name, or a `json` field (a JSON document has no ordering — what would be compared is its encoding), is `1605 (Invalid Input)` rather than being quietly ignored. Two guarantees worth relying on: - **Files with no value for that field sort LAST, in BOTH directions.** Flipping `sort_dir` changes the order of your results, never which ones appear first. - **Ties are broken consistently**, so two identical requests return the same list in the same order. When the match set exceeds the result cap, the axis you sorted on decides what gets kept. With `sort_metadata_field` the cap is applied IN that order, so a capped page really is the top N by that value. With `sort_field` the cap is applied first and the surviving nodes are then ordered, so a capped page is a sample re-sorted — check `scope.scope_truncated` before reading it as a top-N. ## Jobs Status (Unified Async Processing) A single endpoint to check the status of all async processing jobs (AI indexing, metadata extraction) for a workspace or share. Replaces the removed `metadata/intelligence/status` and `metadata/templates/{id}/extract-status` endpoints. ### Workspace jobs status ``` GET /current/workspace/{workspace_id}/jobs/status/ ``` **Auth:** Workspace member. **Feature gate:** AI feature must be enabled on the organization plan. Returns every async job family running in the workspace, so one poll answers "is anything happening here": `intelligence`, `metadata_extract`, `template_match`, `upsert_file`, `summarize`, and `import_sync`. `import_sync` is a **list** (a workspace can sync several cloud sources at once), newest first, each entry carrying `active`, `status` (`pending` · `running` · `completed` · `failed` · `canceled` · `stalled`), `job_id`, `import_source_id`, `job_type`, `files_added`, `files_updated`, `files_deleted`, `bytes_transferred`, `started_at`, `completed_at`, `duration_seconds` and `error_message`. Finished entries drop off after an hour; an `active` entry whose worker stopped reporting is demoted to `stalled` rather than spinning forever. Cloud-sync **discovery** jobs are deliberately absent — browsing your own cloud account is not workspace activity, and those results stay owner-only. Use `GET /current/cloudsync/details/discovery/jobs/{job_id}/` for those, and `GET /current/cloudsync/details/{source_id}/jobs/` for one source's full history. `upsert_file` reports the search indexing of the workspace's files, and it is an OBJECT (or `null` when nothing has been indexed recently), not a list: | Field | Type | Meaning | |-------|------|---------| | `active` | boolean | Indexing is running right now | | `status` | string | `queued` · `processing` · `completed` · `failed` | | `node_id` | string \| null | The file the snapshot describes | | `node_name` | string \| null | Reserved — currently always `null` | | `file_count` | integer | Reserved for a batch size — currently always `0` | | `processed_count` | integer | Files finished so far | | `failed_count` | integer | Files that could not be indexed | | `current_file` | string \| null | The file being indexed; `null` once the run is finished or failed | | `current_file_units_indexed` | integer \| null | How much of `current_file` is indexed | | `current_file_units_total` | integer \| null | How large `current_file` is | | `progress_percent` | integer | `0` while active, `100` once finished — with no batch size recorded there is no in-between value | | `started_at`, `updated_at`, `completed_at` | integer \| null | Unix seconds; `completed_at` is set only on `completed` — `null` while running and on `failed` | The two `current_file_units_*` fields are WITHIN-FILE progress, measured in the units the indexer measured that file in — pages, for a document. A file large enough to need several indexing passes is one file, so **these are the fields that move while `processed_count` and `progress_percent` stand still**: a client showing progress through a large document polls them, not the file counters. Both are always present and both are `null` when the indexer reported no measurement and once the file is finished or failed. The snapshot is one entry per workspace or share, so while several large files are indexed at once the pair describes whichever one reported most recently. ```json { "active": true, "status": "processing", "node_id": "{node_id}", "node_name": null, "file_count": 0, "processed_count": 0, "failed_count": 0, "current_file": "{node_id}", "current_file_units_indexed": 1000, "current_file_units_total": 2500, "progress_percent": 0, "started_at": 1711500000, "updated_at": 1711500420, "completed_at": null } ``` `template_match` is a list (empty when nothing is recent), one entry per template, each carrying `active`, `template_id`, `status`, `total_files`, `matched`, `processed`, `failed`, `progress_percent`, `started_at`, `updated_at`, `completed_at` and `stop_reason`. `summarize` is an object or `null`, with the same keys as `upsert_file` minus the two `current_file_units_*` fields. ### Share jobs status ``` GET /current/share/{share_id}/jobs/status/ ``` **Auth:** Share viewer. **Feature gate:** AI feature must be enabled on the organization plan. ### Response ```json { "result": true, "jobs": { "intelligence": { "active": true, "status": "ingesting", "direction": "enable", "total_files": 100, "eligible_files": 80, "processed": 30, "skipped": 5, "failed": 0, "progress_percent": 37, "started_at": 1711500000, "updated_at": 1711500300, "completed_at": null, "stop_reason": null }, "metadata_extract": [ { "kind": "batch", "active": true, "template_id": "1234567890123456789", "node_id": null, "job_id": "{batch_job_id}", "status": "extracting", "total_files": 50, "eligible_files": 40, "processed": 24, "skipped": 2, "failed": 0, "progress_percent": 60, "started_at": 1711500000, "updated_at": 1711500200, "completed_at": null, "stop_reason": null, "error_message": null, "fields_scope": ["name", "date", "amount"] }, { "kind": "single", "active": true, "template_id": "1234567890123456789", "node_id": "{node_id}", "job_id": "{job_id}", "status": "in_progress", "total_files": 1, "eligible_files": 1, "processed": 0, "skipped": 0, "failed": 0, "progress_percent": 0, "started_at": 1711500400, "updated_at": 1711500400, "completed_at": null, "stop_reason": null, "error_message": null, "fields_scope": ["amount", "due_date"] } ], "template_match": [], "upsert_file": null, "summarize": null, "import_sync": [] } } ``` **The second entry above belongs to a folder-level run, which is why it can show `in_progress` and a non-null `job_id` while still active — and why its `template_id` is NOT null.** `in_progress` is written only on the templated arm: a folder-level `extract-all` in a workspace that still holds an active template and was run **without** a `fields` scope, or the template auto-match and schema-change surfaces. Every writer of that status carries the run's template id into the same record, so 🔴 **`template_id: null` together with `status: "in_progress"` is a combination this API does not produce** — do not write a client branch for it. Note the direction of that rule: a `fields`-scoped `extract-all` is deliberately pushed **off** the templated arm and runs template-free, which is exactly why a scoped run reports `template_id: null` and never reaches `in_progress`. A non-null `fields_scope` beside `in_progress` is fine on its own, though — the templated arm carries a resolved scope. **An entry started by the single-file `metadata/extract/` endpoint never looks like this while it is running** — that route is template-free, so its entry reports `status: "queued"` with `job_id: null` and `template_id: null` until it goes terminal. `kind` does not tell the two apart; only knowing which call you made does. **Response fields:** | Field | Type | Description | |---|---|---| | `jobs.intelligence` | object/null | AI indexing job status, or `null` if no job exists | | `jobs.intelligence.active` | boolean | Whether the job is currently processing | | `jobs.intelligence.status` | string | `starting`, `ingesting`, `flushing`, `draining`, `completed`, `failed`, or `stopped` | | `jobs.intelligence.direction` | string | `enable` (indexing files) or `disable` (removing embeddings) | | `jobs.intelligence.total_files` | integer | Total files in the workspace/share | | `jobs.intelligence.eligible_files` | integer | Files eligible for processing | | `jobs.intelligence.processed` | integer | Files processed so far | | `jobs.intelligence.skipped` | integer | Files skipped | | `jobs.intelligence.failed` | integer | Files that failed processing | | `jobs.intelligence.progress_percent` | integer | 0-100 progress based on processed/eligible | | `jobs.intelligence.started_at` | integer | Unix timestamp when job started | | `jobs.intelligence.updated_at` | integer | Unix timestamp of last progress update | | `jobs.intelligence.completed_at` | integer/null | Unix timestamp when completed, or `null` | | `jobs.intelligence.stop_reason` | string/null | Reason the job stopped early, or `null` | | `jobs.metadata_extract` | array | Mixed extraction statuses (empty array if none). Each entry is either a folder-level batch job or a per-node single-file job. **A SCOPED (template-free) folder run appears here too, with `template_id: null`** — it is not limited to runs driven by a template. `stop_reason` on a folder-level run says why the walk ended: `completed`; **`continued`** (the run hit its per-run file budget, checkpointed, and a SUCCESSOR job is already queued — **more is coming, keep polling**); `credits_exhausted`; `entitlement_lost`; `template_node_cap` (at least one file was skipped because the per-template file cap left no room to map it); `paused_policy` (an Enterprise org's AI policy paused metadata extraction mid-sweep — see *AI, Deep Indexing & MCP Access Policy* in `llms/orgs.txt` — resubmit once the org re-allows it); or `policy_unreadable` (the org's AI policy could not be confirmed mid-sweep — resubmit later, once the policy is readable again). A run that FAILS reports the reason it failed instead — for example `node_cap_unresolved`, when the plan's per-template file cap could not be read and the run refused to walk rather than run uncapped. Treat the value as an open set: match the values you handle and fall back for the rest, rather than switching exhaustively. | | `jobs.metadata_extract[].stop_reason` | string/null | 🔴 **`continued` is the one that changes how you poll.** A large folder is swept across SEVERAL jobs, each with its own `job_id`, so the id returned when you started the run covers only the first slice. Treating that first job reaching a terminal state as "the sweep finished" reports completion while values are still landing. `continued` is set only when the successor was actually enqueued, so it never promises a job that is not coming | | `jobs.metadata_extract[].kind` | string | `"batch"` for a folder-level extraction reported as one unit, `"single"` for a per-file entry. **`kind` tells you the SHAPE of the entry, not whether a template was involved** — a folder-level run also publishes one `kind: "single"` entry per file it processes, so a workspace running a folder extraction shows one `"batch"` entry alongside many `"single"` ones that belong to it | | `jobs.metadata_extract[].active` | boolean | Whether extraction is currently running | | `jobs.metadata_extract[].template_id` | string/null | The template the extraction ran under, or `null` when none did. **This is server-populated, so do not use it to find your job** — see the correlation note below. It is `null` on every entry produced by the single-file extract endpoint, which is template-free. It is a real identifier on a folder-level run driven by one of the surviving templates, **including on that run's per-file `kind: "single"` entries** — those inherit the run's template, so `"single"` does not imply `null` here. When there is no template the value is `null` — **never the empty string**, so one `=== null` test covers every absence | | `jobs.metadata_extract[].node_id` | string/null | Node identifier for `kind: "single"` entries; `null` for `kind: "batch"`. 🔴 **Reported UNHYPHENATED** — `26t7zx432gnzlqqpgqz7r5i2hlawn`, not the `26t7z-x432g-nzlqq-pgqz7-r5i2h-lawn` form you write into a URL path. It matches the `node_id` the extract endpoint returned in its own response body, so correlate against that; comparing against the hyphenated id you put in the path matches nothing, silently | | `jobs.metadata_extract[].job_id` | string/null | Async job identifier. On an entry started by the single-file extract endpoint it is **`null` for as long as the entry is `queued`** — the snapshot is seeded before the job row exists — and at `completed` or `errored` it carries **the exact `job_id` that endpoint's `202` returned**. Nothing republishes the entry in between, so there is no intermediate value. It is the only key that identifies **your request** rather than the file, so it is worth matching on once the entry is terminal; use `node_id` when you need to find the entry while it is still running. Entries belonging to a folder-level run carry a `job_id` throughout, but it is each file's own job id, not the one the folder request returned | | `jobs.metadata_extract[].status` | string | Batch: `queued`, `starting`, `walking`, `extracting`, then `completed`, `stopped` or `failed`. Per-file entries use `queued`, `in_progress`, `completed` and `errored` — but **which of them you can actually see depends on the route that started the work, not on `kind`**. 🔴 **An extraction started by the single-file `metadata/extract/` endpoint goes `queued` → `completed` (or `errored`) and NEVER reports `in_progress`**, because that path publishes no intermediate update; a client waiting to observe `in_progress` on it waits forever. `in_progress` does appear on the per-file entries of a folder-level `extract-all` that ran **against a template** — an unscoped run in a workspace that still holds an active one — which is why the value exists on this surface at all. A `fields`-scoped run does not qualify: naming a scope selects template-free extraction, and the template-free path never reports `in_progress`. So `in_progress` always arrives with a non-null `template_id`. **A failed extraction reports `errored`, never `failed`.** 🔴 **Whether `errored` is TERMINAL depends on the route too, and the two arms behave OPPOSITELY on retry.** On the single-file `metadata/extract/` route it is terminal: a transient failure there republishes `queued` instead (so a `queued` entry may be a first attempt or a retry), and `errored` means nothing further will run. **On the templated arm it is not terminal.** A retryable failure there publishes `errored` **with `completed_at` set** — which looks exactly like a final failure — and the entry can then return to `in_progress` with `completed_at` cleared back to `null` and the **same `job_id`**, because the same job is re-dispatched. 🔴 **Nothing in the payload distinguishes "errored, will retry" from "errored, final"** — there is no retry count, attempt number or will-retry flag on the entry — so on a templated run do not tear down on the first `errored`; keep polling and watch whether it resumes. | | `jobs.metadata_extract[].error_message` | string/null | Human-readable error message, or `null`. 🔴 **Set on EVERY `errored` entry, not only a terminal one.** The errored transition always writes the message, alongside `completed_at`, whether or not the job will be re-dispatched — so on the templated arm an entry that is merely waiting to run again carries a **non-null** `error_message`. ⇒ **This field is not a way to tell "errored, will retry" from "errored, final" either**, and neither is `completed_at`; nothing on the entry distinguishes them. It returns to `null` only when a later attempt publishes `in_progress`, or on `completed`. | | `jobs.metadata_extract[].fields_scope` | array/null | The field names being extracted, or `null` when the run is unscoped. **`null` means "everything available", not "unknown".** The scope is reported on the terminal entry as well as the pending one, so a finished run still tells you what it covered | **Finding YOUR job in this array.** `metadata_extract` is workspace-wide: it lists every extraction in flight, not only the one you started, so the first step of reading it is always picking your entry out. Match on an identifier **you supplied or were handed**, never on one the server fills in on your behalf — a server-populated field has no value on your side to compare against until the server has already told you what it chose. | Match on | When it is usable | Notes | |---|---|---| | `node_id` | Immediately, for a single-file extraction | The extract call returns it in both its `202` and its `already_extracted` `200`, and every per-file entry carries it from the moment the entry appears. This is the key to use while the extraction is still running. 🔴 **Compare against the `node_id` from the response body, NOT the one you wrote into the URL path** — both the response and the entry report it unhyphenated (`26t7zx432gnzlqqpgqz7r5i2hlawn`), the path form is hyphenated (`26t7z-x432g-nzlqq-pgqz7-r5i2h-lawn`), and a string comparison between the two is false with no error to tell you so. It also identifies the **file**, not your request: two extractions of the same file share one entry | | `job_id` | For a single-file extraction, once its entry is terminal | The `202` gives you the real job id. The entry is seeded before the job row exists and nothing republishes it between `queued` and the outcome, so it reports `job_id: null` for as long as it is `queued` and then reports that exact id at `completed` or `errored`. **This is the only field that identifies YOUR request** — `node_id` names the file — so it is the right key for confirming a terminal entry is the one you started; it just cannot find the entry earlier than that. Entries belonging to a folder-level run do carry a `job_id` throughout, but it is each file's own job id, not the one the folder request returned | | `template_id` | **Never** | Server-populated. It is `null` for everything the single-file endpoint starts, so a client holding no template compares `null` against `null` and matches whichever unrelated entries happen to share it — or, in a workspace where a folder run is active, matches nothing at all because every entry names that run's template | Other fields in each `metadata_extract` entry match the `intelligence` (Deep Indexing) fields (`total_files`, `eligible_files`, `processed`, `skipped`, `failed`, `progress_percent`, `started_at`, `updated_at`, `completed_at`, `stop_reason`). For `kind: "single"` entries, `total_files` and `eligible_files` are always `1` and `progress_percent` is `0` while pending or `100` on completion. Completed or errored entries older than one hour are hidden from the listing. **`completed_at` is the signal to stop polling on the single-file `metadata/extract/` route**, where it is only ever set on a terminal entry and an entry waiting to run again carries `completed_at: null` with `active: true`. 🔴 **On the templated arm it is a weaker signal:** a retryable failure there stamps `completed_at` alongside `errored`, and a later retry clears it back to `null` — so a stamped `completed_at` on a templated entry does not prove the run is over. 🔴 **`started_at` is REWRITTEN when the entry reaches a terminal state, and the original value is lost.** The terminal record is written whole rather than patched onto the queued one, so it stamps all three timestamps with the same moment: on a terminal single-file entry `started_at == updated_at == completed_at`. Two consequences, both of which bite: - **You cannot compute a duration from a terminal entry.** `completed_at - started_at` is `0` on every finished single-file extraction, however long it actually took. If you need elapsed time, record your own clock when the `202` comes back. - **A cached `started_at` jumps forward.** A client that read `started_at` from the `queued` entry and re-reads it after completion sees a *later* value for the same run — not a second run, and not a clock problem. Observed on one extraction: seeded `1787921822`, terminal `1787921832`. **No key is ever absent from a `metadata_extract` entry** — every difference between a pending snapshot and a terminal one is null-versus-value, never present-versus-missing. Each entry carries the same 18 keys (`kind`, `active`, `template_id`, `node_id`, `status`, `total_files`, `eligible_files`, `processed`, `skipped`, `failed`, `progress_percent`, `started_at`, `updated_at`, `completed_at`, `stop_reason`, `fields_scope`, `job_id`, `error_message`) whatever state it is in. So you can test `=== null` and will never have to distinguish that from an undefined key. This is a statement about **this payload only** — do not generalise it to other responses. The timestamps in this payload are **integer Unix seconds**, not the `YYYY-MM-DD HH:MM:SS UTC` strings most of the API returns. Parse them as integers. 🔴 **`import_sync` is the exception *inside* this same payload** — its `started_at` and `completed_at` are `YYYY-MM-DD HH:MM:SS UTC` strings, not integers, so a parser written against the other families breaks on that one entry. Branch on the family, not on the response. ⚠️ **And this payload is not the only exception** — some billing and upload fields are integer timestamps too — so do not infer a format from a neighbouring endpoint; read each field's documented type. **Real-time updates:** Both job types broadcast via the Activity/WebSocket system. Clients subscribed to the workspace or share WebSocket channel receive activity notifications when progress changes, reducing the need for polling. --- ## Supported Field Types | Type | Description | Stored as | |---|---|---| | `string` | Text values (max 4,096 characters) | `string` | | `int` | Integer numbers, signed 64-bit | `int` | | `float` | Decimal numbers. Whole numbers are exact to ±9007199254740992 (2^53); a larger integer is refused rather than rounded — send it to an `int` field | `float` | | `bool` | Boolean true/false | `bool` | | `json` | JSON documents (max 4,096 characters) | `json` | | `url` | Absolute `http` or `https` URLs (max 4,096 characters) | `string` | | `datetime` | Date and time values, normalized to UTC and returned as `YYYY-MM-DD HH:MM:SS UTC` | `datetime` | The "Type" column is what a fact reports as `declared_type`; the storage column the value actually lands in is reported as `stored_type`. For the exact `stored_type` spellings, read the item table under *Get node metadata facts* — notably a `datetime` field's storage column is reported there as `date`. The 4,096-character limit applies to every type stored as text (`string`, `json`, `url`) and counts **characters**, not bytes — a multi-byte character counts once. A longer value is rejected for that field rather than shortened. What each declared type accepts: - `int` — an integer, or a decimal integer string (optionally signed), within the signed 64-bit range (`-9223372036854775808` to `9223372036854775807`). A number carrying a fractional part, a value beyond that range, and non-numeric text are all refused rather than rounded or wrapped. Only plain decimal is read as a number: a leading zero (`042`), hexadecimal (`0x2A`) and exponent notation (`1e3`) are refused. - `float` — a number, or a numeric string. Infinity and NaN are refused. - `bool` — a boolean, `1`/`0`, or a string spelling (`true`, `false`, `yes`, `no`, `on`, `off`), in any case. An empty or whitespace-only value is not one of those spellings and is refused, so an unanswered field cannot overwrite a stored `true` with `false`. - `string` — text, or a number rendered as text. - `url` — an absolute URL carrying a host and an `http` or `https` scheme. Other schemes, relative references, and bare host names (`example.com`) are refused. - `json` — a valid JSON document. A string is stored exactly as written, apart from surrounding whitespace (space, tab, carriage return and line feed — the characters JSON itself allows between tokens), which is trimmed: it parses as JSON or the write is refused for that field. **Nothing is repaired or converted.** A bare word (`hello`), a comma-, semicolon- or newline-separated list, and a document truncated mid-structure (`[1,2`) are all refused — none of them is turned into JSON that would parse but mean something other than what was sent. A value sent as a structure rather than a string (an array or object in a JSON request body) is encoded as sent; one that cannot be encoded without altering it — text that is not valid UTF-8, for instance — is refused for the same reason. - `datetime` — one of: `YYYY-MM-DDTHH:MM:SS` with an offset (`Z`, or `±HH:MM` with hours `00`–`23` and minutes `00`–`59`); the same with fractional seconds (one to six digits); the same with no offset; `YYYY-MM-DD HH:MM:SS`; or `YYYY-MM-DD`. Fractional seconds are accepted but not retained. A value carrying no offset is read as UTC. Those forms are the whole grammar, matched exactly: every field is as wide as shown, so a single-digit month, day or hour (`2026-1-2`) is refused, as is an offset written any other way (`+0100`, `+01`, `UTC`, `America/New_York`) or one outside the range above (`+99:99`). Relative expressions (`now`, `yesterday`, `+1 day`) and Unix timestamps are **not** accepted. Every stored `datetime` is converted to UTC and **returned as `YYYY-MM-DD HH:MM:SS UTC`** — the same date/time spelling every other field in this API uses, and the spelling this API also accepts back as a filter value. Do not parse for a `T` separator or a `+00:00` offset; neither appears in a returned value. A `datetime` is held as a real date/time value rather than as text, so `<`, `<=`, `>` and `>=` filters on one compare **chronologically** — the ordering does not depend on how the value was spelled when it was written. Two spellings of the same instant — `2026-01-02T10:00:00+00:00` and `2026-01-02T05:00:00-05:00` — are the same instant, so they store identically, match the same equality filter, and sort as one value. --- ## Quick Reference ### Create a chat and get the answer: ``` POST /current/workspace/{id}/ai/agent/ question=... -> thread.thread_id, turn.turn_id GET /current/activity/poll/{id}?wait=95&lastactivity=... -> wait for ai_chat:{chatId} GET /current/workspace/{id}/ai/agent/{chat_id}/message/{turn_id}/details/ -> check status == complete GET /current/workspace/{id}/ai/agent/{chat_id}/message/{turn_id}/read/ -> SSE stream: status, commentary, analysis_data, action, data, then one of done / failed / cancelled ``` ### Create a note (bank knowledge for RAG): ``` POST /current/workspace/{id}/storage/{parent}/createnote/ name=research-notes.md&content=... ``` ### Extract metadata from a file (async): ``` POST /current/workspace/{id}/storage/{node}/metadata/extract/ -> HTTP 202 { job_id, status: "queued", status_uri } -> HTTP 200 { job_id: null, status: "already_extracted" } (unscoped, already done) GET /current/workspace/{id}/jobs/status/ -> jobs.metadata_extract[] (kind: "single") reaches status: "completed" GET /current/workspace/{id}/storage/{node}/metadata/details/ -> extracted values ``` ### List eligible nodes for metadata (files and notes): ``` GET /current/workspace/{id}/metadata/eligible/ ``` ### Check status of all async jobs (Deep Indexing + metadata extraction): ``` GET /current/workspace/{id}/jobs/status/ GET /current/share/{id}/jobs/status/ -> returns jobs.intelligence, metadata_extract[], template_match[], upsert_file, summarize, import_sync[] ``` ### Semantic search (runs inside `/storage/search`): ``` GET /current/workspace/{id}/storage/search/?search=quarterly+revenue&limit=10 GET /current/share/{id}/storage/search/?search=quarterly+revenue&limit=10 GET /current/workspace/{id}/storage/search/?search=quarterly+revenue&details=true ``` Optional `details=true` includes full node resource (previews, AI state, metadata, size) per result. Default limit drops to 10 when details enabled. Returns **one result per file** with its best-matching passage only — a document matching in several places still yields a single row. ### Searching extracted metadata (a different route — `/storage/search/` does not do this): ``` GET /current/workspace/{id}/metadata/search/?q=invoice&limit=25 GET /current/workspace/{id}/metadata/filters/ GET /current/workspace/{id}/metadata/filters/{filter_id}/nodes/ POST /current/workspace/{id}/metadata/compound-search/ ``` `/storage/search/` has never searched extracted metadata. Use `/metadata/search/` for metadata **values**, or a saved metadata filter to retrieve by **exact field value** with no text query. **Workspace-only — there is no share equivalent, and a share request that sends `filters` returns 200 with unfiltered results.** To answer a question that has a metadata half *and* a content half in one call — "the contracts signed last quarter that mention early termination" — use `/metadata/compound-search/`, which intersects a `filters` predicate with a semantic `content_query` and reports how the answer was bounded in a mandatory `scope` object. It needs Member, the `metadata` **and** `content_ai` plan features, and Deep Indexing enabled on the workspace. The org AI policy is also checked -- a caller the policy denies metadata for gets `403 ai_policy_denied`/`ai_policy_workspace_not_allowed`, `params.feature:"metadata"`; the same refusal fires separately with `params.feature:"intelligence"` when the policy denies the semantic-retrieval stage (the second, content-ranking half of this endpoint) for the caller. Full contract: *Compound Search* in the [Storage reference](https://api.fast.io/current/llms/storage/). ### Share AI chat (same workflow as workspace): ``` POST /current/share/{id}/ai/agent/ question=... -> thread.thread_id, turn.turn_id GET /current/share/{id}/ai/agent/{chat_id}/message/{turn_id}/details/ -> check status == complete GET /current/share/{id}/ai/agent/{chat_id}/message/{turn_id}/read/ -> SSE stream: status, commentary, analysis_data, action, data, then one of done / failed / cancelled ``` ### Share-specific AI: ``` GET /current/share/{id}/ai/autoog/ -> binary PNG image (OG image) POST /current/share/{id}/ai/autotitle/ -> title, description, display_type POST /current/share/{id}/ai/share/ files=["opaqueId1","opaqueId2"] -> markdown with download URLs ``` > Part of the Fastio API Reference. Overview: https://api.fast.io/current/llms/ # How-To API Base URL: `https://api.fast.io/current/` Auth: `Authorization: Bearer {jwt_token}` (JWT, OAuth, or API key) Ask a natural-language "how do I…" question about Fastio and get a grounded, product-aware answer back in a single call. The answer is generated by Fastio's built-in AI over the product's how-to knowledge, so you get usage guidance without having to scrape these docs yourself. When a question is too vague to answer well, the endpoint asks you a short clarifying question instead of guessing. This is a **top-level, user-authenticated endpoint** — there is no org in the URL. It is **open access**: any authenticated, available user may call it with no org-membership requirement, no AI-Agent plan-feature gate, no active-subscription requirement, and no billable entity. How-to is **free**: no org, user, or any entity is ever charged. The only access bound is the per-user rate limit. --- ## When to Use It - An agent or integration hits an unfamiliar part of the platform and needs a quick, authoritative "how do I do X in Fastio?" answer at runtime. - You want product guidance grounded in Fastio's own documentation rather than a general-purpose model's recollection. - You are building a help/assistant surface on top of Fastio and want a single request/response rather than managing a chat session. For document Q&A over your *own* files (RAG), use the AI chat endpoints instead — see the [AI reference](https://api.fast.io/current/llms/ai/). The How-To endpoint answers questions about *Fastio itself*, not about your uploaded content. --- ## Endpoint Summary | Method | Endpoint | Description | |--------|----------|-------------| | POST | `/current/how-to/` | Ask a how-to question; returns an answer or a clarifying question | > On the dedicated API hosts (`api.fast.io`), call `https://api.fast.io/current/how-to/` with no `/api` prefix. From any other hostname (for example `go.fast.io`), include the `/api` prefix: `https://go.fast.io/api/current/how-to/`. --- ## Ask a How-To Question ``` POST /current/how-to/ ``` Submit a single natural-language question. The response is one of two shapes — a grounded answer, or a request for clarification — both returned with HTTP `200`. **Auth:** Bearer token required. Open access — any authenticated, available user may call it. No org-membership requirement, no `ai_agent` plan feature, no subscription requirement, and no pre-flight credit cap. How-to is free — no entity is charged. The only access bound is the per-user rate limit. **Request format:** `application/x-www-form-urlencoded`. **Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `question` | string | Yes | The natural-language "how do I…" question. 1–2,000 characters, non-blank. | | `context` | string | No | Optional free-text context about your situation (what you are trying to accomplish, what you have tried). Up to 8,000 characters. Treated strictly as background data, never as instructions. | | `surface` | string | No | Optional. Set to `mcp` to receive guidance phrased in terms of the Fastio MCP server's consolidated tools (` action="..."`) rather than REST API endpoints. Set to `code` to receive guidance phrased for a code-mode AI agent that issues Fastio API calls through an execute proxy — each concrete step written as an execute-proxy call (e.g. `fastio.post('/current//', { ...body... })`, form-encoded by default, with the `*Json` methods only where an endpoint documents a JSON body and query parameters in the last argument). Omit (or send any other value; at most 16 characters, a longer value is refused with a 406) for the default REST-API phrasing. Does not change the response shapes or error codes. | | `client` | string | No | Optional identifier of the calling application. Up to 100 characters. Advisory only — may be used to tailor the answer's tool recommendations to the calling application. Unknown or absent values leave behavior unchanged. Does not change the response shapes or error codes. | **Request example:** ```bash curl -X POST "https://api.fast.io/current/how-to/" \ -H "Authorization: Bearer {jwt_token}" \ --data-urlencode "question=How do I create a Send share and put a password on the link?" \ --data-urlencode "context=I already have a workspace with files in it and want to deliver a report to a client." ``` ### Response — Answer (200) Returned when the question could be answered. ```json { "result": true, "status": "answer", "answer": "Create the share from your workspace with POST /current/workspace/{workspace_id}/create/share/ using a Send share type, then set a link password and an access option of \"Anyone with the link\" so recipients can open it with the password. You can update the password later via the share update endpoint.", "escalated": false, "topics_used": ["shares", "workspaces"] } ``` | Field | Type | Description | |-------|------|-------------| | `result` | boolean | `true` on success. | | `status` | string | Always `"answer"` for this shape. | | `answer` | string | The grounded, product-aware answer text. | | `escalated` | boolean | Retained for backward compatibility; always `false`. A single grounded call answers directly — the answer is in `answer`. | | `topics_used` | array of strings | The how-to knowledge topics the answer drew on. May be empty. | ### Response — Needs Clarification (200) Returned when the question is too ambiguous to answer well. This is a normal, expected outcome — **not** an error — so the HTTP status is still `200`. Ask the user the returned question(s), then resend with a more specific `question` (and optionally `context`). ```json { "result": true, "status": "needs_clarification", "questions": [ "Are you trying to share a single file, or a whole folder of files?" ] } ``` | Field | Type | Description | |-------|------|-------------| | `result` | boolean | `true`. | | `status` | string | Always `"needs_clarification"` for this shape. | | `questions` | array of strings | One or more short follow-up questions to put to the user. Never empty. | **Handling tip:** branch on `status` first. If `status` is `"answer"`, read `answer`. If `status` is `"needs_clarification"`, surface `questions` to the user and ask again — do not treat it as a failure. --- ## Error Responses | Error Code | HTTP Status | Cause | |------------|-------------|-------| | `10011 (Authentication Invalid)` | 401 | Missing or invalid bearer token. | | (validation) `(Not Acceptable)` | 406 | `question` fails basic input validation — empty/blank, or longer than the 2,000-character maximum. Returned with a validation `error.code` (not `147185`). | | `147185 (Not Acceptable)` | 406 | `question` passes basic validation but is rejected by the answer engine as malformed (for example, invalid UTF-8). | | `10368 (Rate Limited)` | 429 | Too many how-to requests in the current window. Back off and retry (see Rate Limiting). | | `145858 (Rate Limited)` | 429 | A how-to request for the same user is still in progress. These two 429s are distinguished by a different `error.code`; retry the in-progress case once the previous request completes. | | `163750 (User Not Found)` | 404 | The current user could not be resolved. | | `195614 (Temporarily Unavailable)` | 503 | Corpus index unavailable — service temporarily unavailable; retry shortly. | | `152339 (Temporarily Unavailable)` | 503 | Answer-generation failure — service temporarily unavailable; retry shortly. | | `188243 (Internal Error)` | 500 | Unexpected internal error. | Errors use the standard envelope: `{"result": false, "error": {"code": …, "text": …, "resource": …}}`. See [Error Codes](https://api.fast.io/current/llms/#error-codes) in the overview for the envelope shape and the global code list. --- ## Billing How-to is **free** — no org, user, or any entity is ever charged. The LLM call runs with skip-billing; no credits are consumed. - **Answers are briefly cached.** A repeat of a recently asked question can be served from cache, which involves no AI call and is also free. - **A clarifying response** involves a normal AI call and is also free. - There is **no pre-flight credit cap** — the only access / abuse bound is the per-user rate limit. --- ## Notes - **MCP surface (`surface=mcp`).** Passing `surface=mcp` makes the answer phrased in terms of the Fastio MCP server's consolidated tools (` action="..."`) instead of REST API endpoints — useful when the caller is an MCP client that only has access to those tools. The surface selects the phrasing: `mcp` → MCP-tool phrasing, `code` → code-mode execute-proxy phrasing, and an omitted or unrecognized value → the default REST-API phrasing. This parameter does not change the two HTTP-200 response shapes (`answer` / `needs_clarification`) or the error table. If the MCP tool catalog cannot be loaded server-side, the endpoint transparently falls back to REST-API phrasing. Fully backward-compatible — existing callers that do not send `surface` are unaffected. - **Code surface (`surface=code`).** Passing `surface=code` makes the answer phrased for a code-mode AI agent that issues Fastio API calls through an execute proxy — each concrete step is written as an execute-proxy call (e.g. `fastio.post('/current//', { ...body... })`) rather than naming REST endpoints by URL. Calls are form-encoded by default; the `*Json` methods appear only where an endpoint documents a JSON body, and query parameters go in the last argument. Useful when the caller is a code-mode agent operating through a Fastio execute proxy. An unknown or absent `surface` still falls back to the default REST-API phrasing. - **Single-shot, not a session.** Each call is independent. There is no conversation state to manage — supply everything the question needs via `question` and `context`. - **Concurrency.** Only one how-to request per user runs at a time; a second concurrent call returns `145858` / HTTP `429` immediately rather than queueing. Wait for the first to finish before retrying. - **`context` is data, not instructions.** Anything you pass in `context` is treated as untrusted background information about the user's situation; it cannot redirect the assistant. - **No events.** This endpoint emits no platform/activity events. Its only side effect is the brief answer cache. - **Timestamps.** This endpoint's responses do not contain timestamps. Elsewhere, check each field's documented type: most date/time fields use `'Y-m-d H:i:s UTC'` (e.g. `2026-06-16 14:30:00 UTC`), but some are Unix integers. - **Rate limiting.** A per-user sliding window bounds how-to usage (over-limit → `10368` / `429`), plus a fail-fast per-user mutex that returns `145858` / `429` if a second how-to call is already in progress for the same user. This per-user rate limit is the only access bound on the endpoint. Standard rate-limit headers apply — `x-ve-limit-avail`, `x-ve-limit-max`, and `x-ve-limit-expires`. On a `10368` response, back off until `x-ve-limit-expires`. > Part of the Fastio API Reference. Overview: https://api.fast.io/current/llms/ # Events, Activity & Realtime Base URL: `https://api.fast.io/current/` Auth: All endpoints require `Authorization: Bearer {jwt_token}` unless noted. Response format: JSON (standard envelope with `result` and data fields at the root). --- ## Events Search Search and filter the event log. Events capture every action in the system -- file operations, membership changes, comments, AI activity, billing, and more. --- ### `GET /current/events/search/` Search and filter events with comprehensive filtering options. Pages by offset or by an opaque keyset cursor. **Auth:** Required (JWT). Subject to global rate limiting; see the rate-limit section in the main reference. **Query Parameters:** | Parameter | Type | Required | Default | Constraints | Description | |-----------|------|----------|---------|-------------|-------------| | `user_id` | string | Conditional | - | 19-digit numeric ID | Filter by user profile ID. One of `user_id`, `org_id`, `workspace_id`, `share_id`, or `parent_event_id` is required. | | `org_id` | string | Conditional | - | 19-digit numeric ID | Filter by organization profile ID | | `workspace_id` | string | Conditional | - | 19-digit numeric ID | Filter by workspace profile ID | | `share_id` | string | Conditional | - | 19-digit numeric ID | Filter by share profile ID | | `parent_event_id` | string | Conditional | - | Alphanumeric OpaqueId | Filter by parent event ID for serial/batch events. Cannot combine with filters other than `acknowledged`, `visibility`, `limit`, `offset`. | | `event` | string | No | - | Max 100 characters | Filter by specific event name (e.g., `workspace_storage_file_added`) | | `category` | string | No | - | See Event Categories | Filter by event category | | `subcategory` | string | No | - | See Event Subcategories | Filter by event subcategory | | `calling_user_id` | string | No | - | 19-digit numeric ID | Filter by the user who triggered the event | | `object_id` | string | No | - | Alphanumeric OpaqueId | Filter by related object ID (file, folder, etc.) | | `acknowledged` | string | No | - | `"true"` or `"false"` | Filter by acknowledgment status | | `visibility` | string | No | All non-internal | `"external_audit_log"` or `"external"` | Filter by event visibility level | | `created-min` | string | No | - | Accepts ISO 8601 (e.g., `2025-12-01T06:00:00Z`) or `YYYY-MM-DD HH:MM:SS` format | Lower bound for event creation time | | `created-max` | string | No | - | Same format as `created-min`; must not be earlier than `created-min` | Upper bound for event creation time | | `limit` | integer | No | `100` | 1-250 | Maximum number of results | | `offset` | integer | No | `0` | 0+ | Number of results to skip for pagination. Ignored in cursor mode; sending a non-zero `offset` together with `cursor` is rejected. | | `cursor` | string | No | - | Opaque string from a previous response | Keyset cursor for the next page. Pass back the `pagination.next_cursor` you were given. Cannot be combined with a non-zero `offset` or with `parent_event_id`. See "Paging a search" below. | | `output` | string | No | - | Comma-separated tokens (e.g. `terse`, `standard`, `full`) | Select the response shape. See "Compact Responses" below for the three detail levels and which event fields appear in each. | **Profile filter priority:** If multiple profile filters are supplied, priority is: `user_id` > `org_id` > `workspace_id` > `share_id`. Only the highest-priority filter is applied. **curl Example:** ```bash curl -X GET "https://api.fast.io/current/events/search/?workspace_id=1234567890123456789&category=workspace&subcategory=transfer&limit=50" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "events": [ { "event_id": "ancou-ywgcx-iff7k-pbijp-l4ysg-n43j", "category": "workspace", "sub_category": "transfer", "visibility": "external", "permission": "member", "severity": "medium", "notification": "affected_user", "description": "The file 'quarterly_report.pdf' has been added to the workspace 'Engineering' by 'Jane Smith'.", "event": "workspace_storage_file_added", "required_params": ["file_added", "workspace"], "node_type": "file", "operation_type": "add", "requires_parent_node": false, "event_profile": "1234567890123456789", "calling_user": "9876543210987654321", "object_id": "2lavr2y6uogubeyvfmqv2ur6k6ezt", "template": { "description": "The file '{{#name file_added}}' has been added to the workspace '{{#name workspace}}'{{#if calling_user}} by '{{#fullname calling_user}}'{{/if}}.", "params": ["2lavr2y6uogubeyvfmqv2ur6k6ezt", "1234567890123456789", "9876543210987654321", "9876543210987654321", null] }, "actor": { "user_id": "9876543210987654321", "kind": "agent", "agent_name": "Dobby", "name_source": "api_key_label", "credential_type": "api_key", "verified": false }, "created": "2025-01-20 10:30:45 UTC", "org_id": "1111111111111111111", "workspace_id": "1234567890123456789", "user_id": "9876543210987654321", "acknowledged": false } ], "pagination": { "has_more": true, "next_cursor": "{opaque_cursor}", "page_size": 50 } } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `result` | boolean | `true` on success | | `events` | array | Array of event objects | | `events[].event_id` | string | Unique event identifier (OpaqueId, emitted in the hyphenated form) | | `events[].created` | string | Event timestamp (`Y-m-d H:i:s UTC`) | | `events[].acknowledged` | boolean | Whether the current user has acknowledged this event | | `events[].event` | string | Event name identifier (e.g., `workspace_storage_file_added`) | | `events[].category` | string | Event category name | | `events[].sub_category` | string | Event subcategory name | | `events[].object_id` | string | Related object OpaqueId (if applicable) | | `events[].calling_user` | string | 19-digit numeric ID of the attributed user, when one was recorded | | `events[].actor` | object | Who acted: `user_id`, `kind` (`human`, `agent`, `api_key`, `app`, `system`, `unknown`), `agent_name`, `name_source`, `credential_type`, `verified`. Absent on events recorded before attribution existed. See the note below | | `events[].org_id` | string | Organization ID context (if applicable) | | `events[].workspace_id` | string | Workspace ID context (if applicable) | | `events[].share_id` | string | Share ID context (if applicable) | | `events[].user_id` | string | The event's subject user: the user the event names (for example a member added or mentioned), otherwise the owner of the org, workspace, or share the event belongs to. Absent on events recorded without one | | `pagination.has_more` | boolean | Whether more matching events exist. The **only** end-of-data signal | | `pagination.next_cursor` | string \| null | Pass back as `cursor` for the next page. `null` whenever `has_more` is `false`, and always `null` when paging by `parent_event_id` | | `pagination.page_size` | integer | The `limit` you requested (not the number of events returned) | Rows also carry the event's definition and rendering fields: `visibility`, `permission`, `severity`, `notification`, `required_params`, `event_profile` (the profile the event was recorded against), the rendered `description`, and `template` (the `description` with `{{...}}` tokens plus their positional `params`). File and folder events add `node_type`, `operation_type` and `requires_parent_node`. A few events promote extra fields to the row (for example `policy_changes` on `org_updated`, or the fields listed under *Compliance & Audit* below). Other data supplied when the event was recorded, such as file names or sizes, is not returned as separate fields; names appear only inside `description`. **Audit-mode-only fields.** When `visibility=external_audit_log` is requested, every row additionally carries `ip` (string|null) and `country` (string|null, ISO-3166 alpha-2, or `XX`/`T1`) — the client IP and country the event was recorded under. Both are `null` for historical rows and for events written without a request (background jobs). These two keys are **absent** in `external` or default-mode rows, so ordinary members never see a peer's IP through this endpoint. The sign-in telemetry fields (`method`, `mfa`, `new_country`, `client_id`, `session_id`, `token_id`, `agent_name`, `device_name`, `user_agent`) are audit-mode only in the same way. **Actor attribution.** `actor` is the same object carried by storage nodes, versions, locks, comments and metadata facts (full field reference: *Actor Attribution* in the Storage reference). It says who the event is credited to and whether an agent acted for them. `verified: true` appears only on Fastio's own built-in agent; every other `agent_name` is **self-declared** by the credential's owner or client, so display it but never treat it as verified identity. The older `agent_delegation` field is **legacy** -- still returned for compatibility, but prefer `actor`. Events recorded before attribution existed carry no `actor`; a client may fall back to `agent_delegation` on those rows. Events from platform editor and pipeline sessions carry no `actor`, and no longer carry `agent_delegation` either. **Paging a search:** The `pagination` block is returned on every response, whether you page by `offset` or by `cursor`. It is additive — a client that ignores it and keeps stepping `offset` keeps working unchanged. - **Stop only when `has_more` is `false`. Never stop on a short or empty page.** Events you are not permitted to see are removed *after* the page is read, so a page can come back with fewer events than you asked for — or with none at all — while `has_more` is `true` and `next_cursor` is non-null. That page is not the end of the log; follow the cursor. Use `has_more` **instead of** a page-length check, not in addition to one. - **Cursor mode is the efficient way to walk a long result set** (an audit-log export, for example). Request the first page normally, then send the returned `next_cursor` back as `cursor` on each subsequent request, keeping every other filter identical. - **Cursors are opaque and forward-only.** They encode the page boundary plus a binding to the requesting user and to the request's filters. They are signed so tampering is detected, but they are not encrypted — do not treat one as secret. Do not build, parse, or modify one — pass it back exactly as received, with the same filters. A cursor is valid only for the same user and the same filter set that produced it; change a filter and start again from page one. - **`limit` is not locked to the cursor.** Sending a different `limit` on a later page is honoured. - **Ordering is newest-first and identical in both modes**, so an offset walk and a cursor walk visit the same events in the same order. - **`parent_event_id` is offset-only.** Cursors are not available on that path: `next_cursor` is always `null` there, and `has_more` is still authoritative, so keep stepping `offset`. **Rate limiting:** if a search is throttled you get `429`, which may carry a `Retry-After` header giving the number of seconds to wait before retrying — honour it when present. When it's absent, back off using the `x-ve-limit-*` headers instead. The header is exposed to cross-origin browser clients. **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. Codes shown as five or six digits (and the `9661`-`9669` family) ARE `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 | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | No profile filter or `parent_event_id` provided ("Event profile was missing.") | | `1605 (Invalid Input)` | 406 | `created-min` is greater than `created-max` | | `1605 (Invalid Input)` | 406 | `parent_event_id` combined with disallowed filters | | `1605 (Invalid Input)` | 406 | `cursor` combined with a non-zero `offset`, or with `parent_event_id` | | `1605 (Invalid Input)` | 406 | Invalid pagination cursor — malformed, altered, over-long, or issued for a different user or filter set | | `1605 (Invalid Input)` | 406 | Invalid datetime format for `created-min` or `created-max` | | `1605 (Invalid Input)` | 406 | A profile filter carries an id of a different type (e.g. a workspace id passed as `org_id`) | | `1680 (Access Denied)` | 401 | An audit-log query that includes `user_id` or is anchored only on `parent_event_id` (see Notes) — no `reason` field | | `1700 (Access Forbidden)` | 403 | The token's scope does not include the `org_id`, `workspace_id`, or `share_id` you filtered on — no `reason` field | | `1700 (Access Forbidden)` | 403 | An audit-log query made with a credential that is not admin-capable (`rwa`) on the anchor — `params.reason:"scope_admin_required"` | | `1600 (Query Error)` | 500 | Internal error during event search | | `1650 (Authentication Invalid)` | 401 | Missing or invalid JWT token | | `1651 (Invalid Request Type)` | 405 | Wrong HTTP method (only GET accepted) | | `1700 (Access Forbidden)` | 403 | An anchored audit-log query's caller is neither admin+ nor an entitled compliance auditor — `params.reason:"compliance_access_required"` | **Notes:** - Results may be slightly delayed due to caching. - OAuth scoped tokens enforce entity-level access: events are filtered to only entities within the token's scope. - Events the user cannot access are automatically excluded. - **Audit-log queries are gated, in two steps.** Requesting `visibility=external_audit_log` first needs an anchor: an `org_id`, `workspace_id`, or `share_id` filter. An audit-log query that includes `user_id` (even together with `org_id`/`workspace_id`/`share_id` — `user_id` takes precedence) or that is anchored only on `parent_event_id` is `1680`/401 with **no `reason` field** — this is not the compliance gate below; retry with an entity-anchored filter and no `user_id`. Two shapes fail input validation first (`1605`/406) and never reach this check: `parent_event_id` combined with `user_id` or any other profile filter, and a query with no profile filter and no `parent_event_id`. Given an anchor, the caller must hold admin permission on that profile, **or** — only when the anchor is an `org_id` — hold the org's `compliance_auditor` flag (Enterprise only — see *Compliance & Audit* in `llms/orgs.txt`; the auditor flag does not extend to a `workspace_id`/`share_id` anchor, only admin permission does there). That second check's refusal is 403 with `params.reason:"compliance_access_required"` — branch on `reason`, not the code. An auditor sees every org-owned audit row even for a workspace they are not a member of; legal-hold events (`legal_hold_created`/`legal_hold_released`) are the one exception and stay owner/auditor-only regardless of workspace admin status. - `user_id` is **refused** in audit mode — any audit-log query that includes `user_id`, with or without another profile filter, is `1680`/401 with no `reason` (see above; `user_id` with `parent_event_id` is the `1605`/406 input error instead) — use `calling_user_id` (the actor) instead. This matters for login history: `visibility=external_audit_log&event=user_login&calling_user_id={uid}`, not `user_id`. --- ### `GET /current/events/search/summarize/` Search events and generate an AI-powered natural language summary. Accepts all parameters from `/events/search/` plus `user_context`. **Auth:** Required (JWT). Subject to global rate limiting; shares the events search rate limit bucket. **Additional Query Parameters:** | Parameter | Type | Required | Default | Constraints | Description | |-----------|------|----------|---------|-------------|-------------| | `user_context` | string | No | `""` | Max 64 chars; letters, numbers, spaces, `. , ! ? ' -` only | Focus guidance for the AI summary (e.g., `"Focus on uploads"`) | All other parameters are accepted with the same names and meanings as `GET /current/events/search/`. As there, `user_id`, `org_id`, `workspace_id`, and `share_id` are type-checked: each must carry an id of the kind the parameter names — a workspace id passed as `org_id` returns `1605 (Invalid Input)` rather than silently matching nothing. This applies to every profile filter you send, including ones that do not end up selecting the search (see Notes). This endpoint does not accept `cursor` and returns no `pagination` block. **It is a rollup, not a pager.** `events` and the summary cover up to `limit` events you can see, taken from the matching activity starting at `offset`. Events you cannot see are skipped and the scan continues, but it stops after examining 2,000 matching events, so a short (or empty) `events` array is **not** proof that nothing else matched. `offset` skips matching events before visibility is applied, so stepping `offset` by `limit` does not walk the log — to read every event, page `GET /current/events/search/` with `cursor`. **curl Example:** ```bash curl -X GET "https://api.fast.io/current/events/search/summarize/?workspace_id=1234567890123456789&user_context=Focus%20on%20file%20uploads&limit=100" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "summary": { "text": "@[user:9876543210987654321:Jane Smith] uploaded 12 files to the project folder, including quarterly reports and design assets. @[user:5555555555555555555:John Doe] added 3 new members to the workspace.", "metrics": { "total_events": 50, "unique_actors": 5, "date_range": { "start": "2025-01-01 00:00:00 UTC", "end": "2025-01-20 10:30:45 UTC" }, "categories": { "workspace": 38, "share": 12 } } }, "events": [] } ``` `events` carries the same event objects as `/events/search/` (left empty above for brevity). **Response Fields (additional to events search):** | Field | Type | Description | |-------|------|-------------| | `summary` | object or null | AI-generated summary, or `null` if no events, generation failed, or the billing org's AI policy denies it (see `summary_reason`) | | `summary_reason` | string | Present **only** when `summary` is withheld for a known reason. Currently one value: `ai_policy_denied` -- every organization whose events were candidates for the summary denies the caller's Ripley Agent access. The response is still `200` with the events array populated; only the summary is withheld. | | `summary.text` | string | Natural language summary with `@[type:ID:name]` mention pills | | `summary.metrics.total_events` | integer | Total events summarized (at most `limit`; not a count of everything that matched) | | `summary.metrics.unique_actors` | integer | Distinct users who triggered events | | `summary.metrics.date_range.start` | string | Earliest event timestamp (`Y-m-d H:i:s UTC`) | | `summary.metrics.date_range.end` | string | Most recent event timestamp (`Y-m-d H:i:s UTC`) | | `summary.metrics.categories` | object | Map of category names to event counts | **Summary Mention Pill Formats:** | Entity | Format | Example | |--------|--------|---------| | User | `@[user:USER_ID:Display Name]` | `@[user:9876543210987654321:Jane Smith]` | | File | `@[file:FILE_ID:filename.ext]` | `@[file:ancouywgcxiff7kpbijpl4ysgn43j:report.pdf]` | | Folder | `@[folder:FOLDER_ID:foldername]` | `@[folder:2lavr2y6uogubeyvfmqv2ur6k6ezt:Projects]` | | Workspace | `@[workspace:WS_ID:name]` | `@[workspace:1234567890123456789:Engineering]` | | Share | `@[share:SHARE_ID:name]` | `@[share:5555555555555555555:Client Files]` | **Error Responses:** All errors from `/events/search/` apply. Summary generation failures are non-fatal: `summary` is `null` but events are still returned. Additional errors specific to this endpoint: | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1680 (Access Denied)` | 401 | The `org_id`, `workspace_id`, or `share_id` filter names a profile you are not a member of | | `1609 (Not Found)` | 404 | The `org_id`, `workspace_id`, or `share_id` filter names a profile that does not exist | | `1605 (Invalid Input)` | 406 | The id supplied is not of the type the parameter expects (e.g. a workspace id passed as `org_id`) | | *(generated per call site)* | 503 | `access_policy_unavailable` -- an owning org's Ripley Agent policy verdict could not be read; retry. | **Notes:** - **A `user_id` or `parent_event_id` query can span more than one organization.** Each candidate event's owning org is asked, independently, whether its AI policy allows the caller Ripley Agent access; events belonging to an org that denies it are left out of what the summarizer reads, even though the returned `events` array itself is unaffected. If every organization in the candidate set refuses, `summary` comes back `null` with `summary_reason: "ai_policy_denied"` — the same shape as the single-org case. An `org_id`/`workspace_id`/`share_id`-scoped query only ever touches one org, so this only matters for the two profile-less anchors. - **You must belong to the profile you filter on.** This endpoint consumes AI tokens that are billed to the filtered profile's organization, so `org_id`, `workspace_id`, and `share_id` are authorized before any summary is generated: the profile must exist, the id must be of the matching type, and you must hold an active membership on it. `/events/search/` (the same query without the summary) is unchanged. - **The account billed is the one you filtered on.** The summary is charged to the organization that owns the profile the query is scoped to — the same profile that selects which events are searched. A query scoped to yourself (`user_id`, or a `parent_event_id` query) is charged to your own billing account. Where more than one filter is supplied, the one that selects the events (in the order `user_id`, `org_id`, `workspace_id`, `share_id`) is the one authorized and billed; the others do not select or bill anything, but they are still validated, so a malformed or wrong-type value in any of them fails the request. - If the billing organization cannot be resolved, the request still succeeds and returns the events with `summary` set to `null`; it is never charged to a different account. - The same two-step audit-log gate as `/events/search/` applies (see its Notes): an audit-log query that includes `user_id` (even together with `org_id`/`workspace_id`/`share_id` — `user_id` takes precedence) or that is anchored only on `parent_event_id` is `1680`/401 with no `reason`; `parent_event_id` combined with `user_id` or any other profile filter, and a query with no profile filter and no `parent_event_id`, fail input validation first (`1605`/406); an anchored query from a caller who is neither admin+ on that profile nor — on an `org_id` anchor — an entitled compliance auditor is `1700`/403 `compliance_access_required`. --- ### `GET /current/event/{event_id}/details/` Get full details for a single event. **Auth:** Required (JWT). Default rate limiting. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{event_id}` | string | Yes | Alphanumeric OpaqueId of the event | **curl Example:** ```bash curl -X GET "https://api.fast.io/current/event/ancouywgcxiff7kpbijpl4ysgn43j/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "event": { "event_id": "ancou-ywgcx-iff7k-pbijp-l4ysg-n43j", "category": "workspace", "sub_category": "transfer", "visibility": "external", "permission": "member", "severity": "medium", "notification": "affected_user", "description": "The file 'quarterly_report.pdf' has been added to the workspace 'Engineering' by 'Jane Smith'.", "event": "workspace_storage_file_added", "required_params": ["file_added", "workspace"], "node_type": "file", "operation_type": "add", "requires_parent_node": false, "event_profile": "1234567890123456789", "calling_user": "9876543210987654321", "object_id": "2lavr2y6uogubeyvfmqv2ur6k6ezt", "template": { "description": "The file '{{#name file_added}}' has been added to the workspace '{{#name workspace}}'{{#if calling_user}} by '{{#fullname calling_user}}'{{/if}}.", "params": ["2lavr2y6uogubeyvfmqv2ur6k6ezt", "1234567890123456789", "9876543210987654321", "9876543210987654321", null] }, "created": "2025-01-20 10:30:45 UTC", "org_id": "1111111111111111111", "workspace_id": "1234567890123456789", "user_id": "9876543210987654321", "acknowledged": false } } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `result` | boolean | `true` on success | | `event` | object | Full event object (same fields as event search results) | **Access Rules:** | Condition | Access | |-----------|--------| | Event is `internal` visibility | Always denied | | Event has `targeted` permission | Granted only to the event's `user_id` (the target); denied to everyone else, including the `calling_user` | | User is the `calling_user` | Granted | | User is the event's `user_id` | Granted | | User has appropriate profile-level permissions | Granted based on permission level | **Error Responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | Event ID missing, empty, or not a valid OpaqueId | | `1609 (Not Found)` | 404 | No event exists with the provided ID | | `1605 (Invalid Input)` | 406 | Event has `internal` visibility | | `1605 (Invalid Input)` | 406 | User lacks permission to view the event | | `1700 (Access Forbidden)` | 403 | The event's organization blocks your location or network — `params.reason:"geo_restricted"` | | `1650 (Authentication Invalid)` | 401 | Missing or invalid JWT token | | `1651 (Invalid Request Type)` | 405 | Wrong HTTP method (only GET accepted) | --- ### `POST /current/event/{event_id}/ack/` Acknowledge (mark as read) an event for the current user. Idempotent: acknowledging an already-acknowledged event succeeds silently. **Auth:** Required (JWT). Default rate limiting. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{event_id}` | string | Yes | Alphanumeric OpaqueId of the event to acknowledge | **curl Example:** ```bash curl -X POST "https://api.fast.io/current/event/ancouywgcxiff7kpbijpl4ysgn43j/ack/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true } ``` **Error Responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | Event ID missing or invalid | | `1609 (Not Found)` | 404 | Event not found | | `1605 (Invalid Input)` | 406 | Event has `internal` visibility | | `1605 (Invalid Input)` | 406 | User lacks permission to view the event | | `1700 (Access Forbidden)` | 403 | The event's organization blocks your location or network — `params.reason:"geo_restricted"` | | `1664 (Datastore Error)` | 500 | Failed to persist the acknowledgment | | `1650 (Authentication Invalid)` | 401 | Missing or invalid JWT token | **Notes:** - Acknowledgment is per-user. Acknowledging for one user does not affect others. - Same access rules as event details apply. An entitled compliance auditor who is not an org admin may also acknowledge an `org_security_alert` row (see *Security Alerts* in `llms/orgs.txt`). --- ## Compact Responses (`output=`) Every endpoint that returns event objects (search, details) 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 (cumulative) | |-------|------------------------------| | `terse` | `event_id`, `event`, `category`, `object_id`, `created` | | `standard` | terse + `sub_category`, `calling_user`, `actor`, `org_id`, `workspace_id`, `user_id`, `share_id`, `acknowledged`, `template` (containing `description` + `params`), `severity`, `visibility`, `notification` (plus `ip` / `country` on audit-log searches) | | `full` | Every field the event carries: standard + `permission`, `description`, `required_params`, `event_profile`, `node_type` / `operation_type` / `requires_parent_node` (file and folder events), `agent_delegation`, and any event-specific promoted fields | Use `terse` for activity feed tickers and unread-count polling — it carries the event identity (`event_id`), name (`event`), category, target object, and a timestamp (`created`), which is the minimum a feed row needs to render without a follow-up fetch. Use `standard` for the most common event list/detail views — it adds subcategory, every profile-link id (calling user, owning org/workspace/share), the acknowledgment flag, the `template` object (which carries the human-readable `description` and any template `params`), and the render-hint enums: `severity` (drives feed-row color/icon), `visibility` (distinguishes the Activity, Audit-Log, and internal tabs), and `notification` (drives bell/notification rendering). Use `full` (or omit the parameter) for audit-log exports and event schema introspection. 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 in `llms.txt` for the full contract. --- ## Event Categories | Category | API Value | Description | |----------|-----------|-------------| | Upload | `upload` | File upload operations | | User | `user` | User account events | | Organization | `org` | Organization events | | Workspace | `workspace` | Workspace operations — **and workspace-scoped file/folder activity.** See the note below | | Share | `share` | Share operations — **and share-scoped file/folder activity.** See the note below | | AI | `ai` | AI/ML operations | | Invitation | `invitation` | Invitation events | | Email | `email` | Email-related events | | Billing | `billing` | Billing events (`billing_free_trial_ended`). The `subscription_*` events are category `org`, sub-category `billing` | | Metadata | `metadata` | Metadata operations | | Apps | `apps` | Application/integration events | | Node | `node` | **AI indexing pipeline only — NOT file operations.** See the note below | | Server | `server` | Server/system-level events | | Import | `import` | Import operations | ### Where file and folder activity lives **File and folder activity is NOT category `node`.** Despite the name, `node` covers only the AI indexing pipeline (for example `node_ai_summary_created`), and most of its events are internal and never returned by the API. It lives in **two** categories, and filtering only one of them silently omits the other: **`category=workspace`** — workspace-scoped storage: ``` workspace_storage_file_added workspace_storage_folder_created workspace_storage_file_updated workspace_storage_folder_updated workspace_storage_file_deleted workspace_storage_folder_deleted workspace_storage_file_purged workspace_storage_folder_purged workspace_storage_file_moved workspace_storage_folder_moved workspace_storage_file_restored workspace_storage_folder_restored workspace_storage_file_copied workspace_storage_folder_copied workspace_storage_lock_overridden ``` **`category=share`** — the share-scoped counterpart, a full parallel set: ``` share_storage_file_added share_storage_folder_created share_storage_file_updated share_storage_folder_updated share_storage_file_deleted share_storage_folder_deleted share_storage_file_purged share_storage_folder_purged share_storage_file_moved share_storage_folder_moved share_storage_file_restored share_storage_folder_restored share_storage_file_copied share_storage_folder_copied share_storage_lock_overridden ``` **A consumer that filters only `workspace` sees no activity in any share.** Query both categories for a complete picture of what happened to files. ### `calling_user_id` vs `user_id` — actor vs subject These two filters answer different questions and are not interchangeable: | Filter | Matches on | Answers | |--------|-----------|---------| | `calling_user_id` | the user who **performed** the action | "what did this person do" | | `user_id` | the event's **subject** user — the user the event names where it names one, otherwise the owner of the org, workspace, or share the event belongs to | "what happened to / about this person, or in what they own" | `calling_user_id` matches the **attributed** actor. Usually that is the person who performed the action. Where a background workflow has a responsible party, it can be that party instead — a file that a scheduled sync pulls in is attributed to the user who connected the synced folder, even though nobody clicked anything at the time, so it appears in the activity feed under their name. ⚠️ **But an absent `calling_user_id` does NOT mean nobody was responsible.** Attribution is recorded per event type, and several types that a user plainly caused still carry no actor: - **Usually attributed** — file and folder activity, including files landed by a scheduled sync, and connecting, updating or disconnecting a synced folder. Treat this as the common case, not a guarantee: the actor is omitted whenever it cannot be resolved — for instance once the connecting member has left the workspace, or when the same action completes from a background job rather than the request that asked for it. - **Never attributed** — the **lifecycle** events for syncs, imports and write-backs (a sync starting, completing or failing; an import job; a write-back push). These carry no `calling_user_id` at all, even though a specific user connected the folder or triggered the edit. - **Connecting or disconnecting a storage provider is attributed from 2026-08-24**, and the two differ: - `provider_identity_created` — always the person who connected the account, which is always its owner. No admin-on-behalf-of path exists here. - `provider_identity_revoked` — the person who initiated it. Revoking is now owner-only, so a person-initiated revoke names the account's owner; older rows **may name a workspace admin acting on another member's connected account**. `user_id` is the account's owner; `calling_user` is whoever acted. Omitted entirely when the platform disconnects the account automatically because the owning member was removed from the workspace. ⚠️ **Do not read a missing actor as "automatic" on rows recorded before 2026-08-24.** Attribution did not exist then, so older events of both kinds carry no actor no matter who acted. On an older row, absent means *"not recorded"*. So `calling_user_id` answers *"what is attributed to this person"* — right for an actor-scoped audit view, and wrong for *"everything that happened in this scope"*, where you should omit it. Do not assume it excludes all background activity, and do not read its absence as "the system did this on its own". Filter on `user_id` when you want what happened **to** a person. --- ## Event Subcategories | Subcategory | API Value | Description | |-------------|-----------|-------------| | Storage | `storage` | File and folder operations: move, copy, delete, restore, version restore, folder create/update, trash emptied, lock override. Uploads and file updates are `transfer` | | Comments | `comments` | Comment activity | | Members | `members` | Membership changes | | Lifecycle | `lifecycle` | Create and delete events (plus File Share and signing-envelope lifecycle). Profile updates are `settings`; archive/unarchive is `archive` | | Settings | `settings` | Configuration changes (`user_updated`, `org_updated`, `workspace_updated`, `share_updated`) | | Security | `security` | Security-related events, including sign-ins (`user_login`, `org_sso_login`) and two-factor changes | | Authentication | `authentication` | SSO sign-up (`user_sso_signup`). Sign-ins are `security` | | AI | `ai` | AI processing events | | Invitations | `invitations` | Invitation management | | Billing | `billing` | Subscription and payment | | Assets | `assets` | Asset (avatar, branding) updates | | Upload | `upload` | Upload events | | Transfer | `transfer` | Files added, updated or transferred into storage (uploads, sync, cross-profile), plus download- and preview-token issuance and ZIP downloads. Ownership changes are `ownership_transferred` under `members`. | | Import/Export | `import_export` | Import/export operations | | Quick Share | `quickshare` | Quick share events | | Metadata | `metadata` | Metadata operations | | API | `api` | API-related events | | Archive | `archive` | Archive operations | | Email | `email` | Email events | | Render | `render` | Preview/thumbnail rendering events (internal; never returned by the API) | | Cloud Import | `cloud_import` | Cloud import operations | --- ## Event Visibility Levels | Visibility | API Value | Description | |------------|-----------|-------------| | Internal | `internal` | System events. Never accessible via API. | | Audit Log | `external_audit_log` | Audit/compliance events. Targeted permission checks bypassed for admins. | | External | `external` | Standard user-facing events. | Default (no `visibility` parameter): returns both `external_audit_log` and `external` events, excludes `internal`. --- ## Event Permission Levels | Permission | Description | |------------|-------------| | `member` | Any member of the profile can view | | `admin` | Only admins of the profile can view (plus the event's own `calling_user` and `user_id`) | | `targeted` | Only the target user (the event's `user_id`) can view — not the calling user | When querying with `visibility=external_audit_log`, targeted permission checks are bypassed — but the query itself is gated: it requires an `org_id`/`workspace_id`/`share_id` context filter AND (admin permission on that profile, or — only when the filter is `org_id` — the org's `compliance_auditor` flag) (see the Audit-log note under `/events/search/`). --- ## Event History Retention Event history is kept for a bounded window that depends on the organization's plan -- higher plans retain history for longer. Events older than the organization's window are removed automatically, so a search over an older period returns fewer results rather than an error. Two things follow for integrations: - **Export anything you need to keep.** If your compliance process requires a longer archive than your plan retains, pull the events you care about through `/current/events/search/` and store them yourself. - **An empty result is not proof that nothing happened.** A search over a period older than the retention window returns no events because the records are gone, not because there was no activity. Check `created-min` against your plan's window before treating a gap as meaningful. An organization that has held a paid subscription keeps a longer minimum window than its current plan alone would give, so billing and dispute history stays available after a cancellation. --- ## Event Names Reference ### Workspace Storage - `workspace_storage_file_added` -- File uploaded - `workspace_storage_file_deleted` -- File trashed - `workspace_storage_file_purged` -- File permanently deleted (single purge; emptying the trash does not emit one per item) - `workspace_storage_file_moved` -- File moved - `workspace_storage_file_copied` -- File copied - `workspace_storage_file_updated` -- File updated (renamed, details edited, or content replaced) - `workspace_storage_file_restored` -- File restored from trash - `workspace_storage_file_version_restored` -- File version restored - `workspace_storage_folder_created` -- Folder created - `workspace_storage_folder_deleted` -- Folder trashed - `workspace_storage_folder_purged` -- Folder permanently deleted (single purge; emptying the trash does not emit one per item) - `workspace_storage_folder_moved` -- Folder moved - `workspace_storage_download_token_created` -- Download token issued - `workspace_storage_zip_downloaded` -- ZIP download completed - `workspace_storage_link_added` -- Link added - `workspace_storage_lock_overridden` -- A file's edit lock was taken over by another user with write access. Does not name the displaced holder - `storage_direct_read_summary` -- Enterprise orgs only; audit-log only, not a per-download event. See *Compliance & Audit* below. ### Share Storage - `share_storage_file_added` -- File uploaded - `share_storage_file_deleted` -- File trashed - `share_storage_file_purged` -- File permanently deleted (single purge; emptying the trash does not emit one per item) - `share_storage_file_moved` -- File moved - `share_storage_file_copied` -- File copied - `share_storage_file_updated` -- File updated (renamed, details edited, or content replaced) - `share_storage_file_restored` -- File restored - `share_storage_folder_created` -- Folder created - `share_storage_folder_deleted` -- Folder trashed - `share_storage_folder_purged` -- Folder permanently deleted (single purge; emptying the trash does not emit one per item) - `share_storage_folder_moved` -- Folder moved - `share_storage_download_token_created` -- Download token issued - `share_storage_lock_overridden` -- A file's edit lock was taken over by another user with write access. Does not name the displaced holder - `share_storage_zip_downloaded` -- ZIP download completed ### Comments - `comment_created` -- Comment created - `comment_updated` -- Comment updated - `comment_deleted` -- Comment deleted - `comment_mentioned` -- User mentioned in comment (targeted permission) - `comment_replied` -- Reply to a comment - `comment_reaction` -- Reaction added ### Membership - `added_member_to_org` / `removed_member_from_org` -- Org membership - `added_member_to_workspace` / `removed_member_from_workspace` -- Workspace membership - `added_member_to_share` / `removed_member_from_share` -- Share membership - `membership_updated` -- Permission changes ### Workspace Lifecycle - `workspace_created` / `workspace_updated` / `workspace_deleted` - `workspace_archived` / `workspace_unarchived` ### Share Lifecycle - `share_created` / `share_updated` / `share_deleted` - `share_archived` / `share_unarchived` - `share_imported_to_workspace` -- Share imported into workspace - `workspace_folder_share_created` / `workspace_folder_share_deleted` -- A workspace folder was shared as a share, or that folder share was removed; carries both the workspace and the share ### File Share Lifecycle Durable single-file File Share events. Category `share`, external visibility, member permission. Anchored to the owning workspace; the affected File Share id travels in the event data as `file_share_id`. - `file_share_created` -- A durable File Share was created - `file_share_updated` -- A File Share's settings (title / access tier / password) were updated - `file_share_content_updated` -- The shared file's content was replaced (external edit / write-back) - `file_share_deleted` -- A File Share was deleted - `file_share_access_granted` -- A per-user grant (view / download / edit) was added or raised - `file_share_access_revoked` -- A per-user grant was revoked ### Cloud Sync Import Events Category `import`, sub-category `cloud_import`, external visibility. **Permission is SPLIT, and the split is the useful part:** the three `import_source_sync_*` events are `member`, because whether a graft synced is operational status a workspace member can already infer from files appearing. Everything else in this family -- including both `provider_identity_*` events, which carry the connected account's email address -- is `admin`, because whose cloud account is attached is a disclosure question rather than a status one. These are the connect / configure / sync half of cloud sync: linking a provider account, creating a source against it, and running the jobs that pull provider content in. Anchored to the owning workspace via `profile_id`. **Provider identity** (a connected cloud account): - `provider_identity_created` -- A provider account (Dropbox / Box / OneDrive / Google Drive) finished OAuth connect, or was provisioned directly. `identity_email` is the connected account's email address, **masked** (first three characters of the local part, then `***@` and the domain) for every reader, and interpolated into the description in that masked form; rows recorded before masking was introduced carry the full address. **From 2026-08-24 this event carries `calling_user`.** Unlike revocation there is no admin-on-behalf-of path here -- a connection is always made by the person who will own it -- so `calling_user` and `user_id` are the same person. There is no automatic creation path either, so an absent actor on this event always means it predates that date. - `provider_identity_revoked` -- Revocation of a connected provider account was INITIATED -- by its owner (a workspace admin could also revoke before revoking became owner-only), or automatically when the connecting member leaves or is removed from the organization (or from a workspace with no organization) -- leaving one workspace of an organization does not revoke the account. `identity_email` is masked, as on `provider_identity_created`. **From 2026-08-24 this event carries `calling_user` when a person initiated it, and OMITS it for the automatic membership-cleanup path.** Because the endpoint admitted a workspace admin as well as the owner before revoking became owner-only, on those older rows the actor may be someone OTHER than the account's owner -- `user_id` is the owner, `calling_user` is whoever acted. Nothing in the description names an actor, so read the field, not the text. ⚠️ Events recorded BEFORE that date carry no actor regardless of who acted, so on an older row an absent actor means "not recorded", not "automatic". Treat it as the START of revocation, not proof it finished. Teardown at the provider is ASYNCHRONOUS and its ordering against this event is not fixed -- it may still be pending, or may already have completed, when you see this. The identity's own status is the authoritative signal; do not infer completion either way from the event. **Import sources** (a configured sync connection -- one remote folder synced into a workspace): - `import_source_created` -- A source was created against a connected identity. - `import_source_updated` -- A source's settings changed -- from the update endpoint, or automatically when its identity is revoked or its connecting member's workspace membership ends. **The two automatic paths differ in actor and you cannot treat them alike:** an identity revocation carries the acting user through, while membership cleanup is system-initiated and sends an EMPTY `calling_user`. So an empty actor means "the platform did this", not "nobody did this". - `import_source_deleted` -- A source was deleted. - `import_source_sync_started` -- A sync run started for a source. - `import_source_sync_completed` -- A sync run finished; `file_count` and `total_size` describe the source's post-run state. - `import_source_sync_failed` -- A source entered the error state. Usually a sync run failed, but it also fires when the platform's periodic recovery moves a source that was stranded mid-operation -- so it means "this source is now in error", not necessarily "a run was attempted and failed". `error_message` is filtered before it is stored, but the filter is not a guarantee: text supplied by the cloud provider can reach it verbatim. - `import_source_disconnected` -- A source was disconnected. For a source with <=1000 files this fires synchronously from the disconnect endpoint with the requesting `calling_user`; for a larger source it fires later from the async disconnect job with `calling_user: ''`. **Files arriving from a sync** (these are `workspace` events, not `import` ones -- listed here because cloud sync is what produces them): - `workspace_storage_file_sync_added` -- A file was added to the workspace by a cloud-folder sync. - `workspace_storage_file_sync_updated` -- A synced file's content changed at the provider and the workspace copy was refreshed. Category `workspace`, sub-category `transfer`, external visibility, **`member` permission**. A large first sync produces many — roughly one per file. They are attributed to the owner of the connection that grafted the folder, so a synced file reads like an upload by that user rather than appearing authorless. **Treat them as AT MOST ONE BEST-EFFORT emission per detected add or content change — not as a guarantee of one-per-change.** Two edges make the stronger reading wrong in opposite directions. Emission is best-effort: if the workspace cannot be resolved at emit time nothing is sent and nothing retries, so a real change can produce ZERO events. And change detection is deliberately conservative: when the stored content fingerprint is missing or unreadable the file is assumed changed, so an event can arrive for a file whose content did not actually change. **Do not use these as a ledger of what changed** — use them as a prompt to re-read, and let the folder listing be the truth. **There is NO delete counterpart.** Nothing is emitted when a sync removes a file that disappeared at the provider. Do not infer from silence that a file still exists -- re-read the folder to establish that. This is the single most important line in this section: an absent event here means "no signal", never "no change". **Import & discovery jobs** (the job record behind a sync run, or behind a pre-source folder listing): - `import_job_started` -- A job started. `job_type` is `full_sync`, `incremental`, or `discovery` (listing a connected identity's shared folders before any source exists -- no source row yet, so `source_name` is the provider name rather than a folder name, and there is no paired `import_source_sync_started`). Does not fire for `disconnect`-type jobs. - `import_job_completed` -- A job completed; `files_added` / `files_updated` / `files_deleted` are per-job counts. - `import_job_failed` -- A job failed; `error_message` carries the same caveat as `import_source_sync_failed` -- filtered, but able to carry provider-supplied text. **`source_name` is MUTABLE, CLIENT-SETTABLE display text, and it is never scrubbed.** It starts as the remote folder or library path inside the user's connected cloud account (e.g. `Imported to Fastio/Images`), but the update endpoint lets a caller change it afterwards, so it is untrusted input rather than a reliable provider identifier -- do not key anything on it. On discovery events, before a source exists, it carries the provider name instead of a folder name. It is interpolated directly into the description of every event above except the two `provider_identity_*` events. `identity_email` on those two is the connected account's address, masked to its first three characters and domain. Both can name something outside the workspace's own content, and `source_name` is not redacted before the event is persisted or read. Summarise, don't relay verbatim -- same guidance as the diagnostic-field note under Cloud Sync Write-Back below. ### Cloud Sync Write-Back Category `import`, sub-category `cloud_import`, external visibility, admin permission. Emitted when a local change is pushed back to the connected cloud provider. Anchored to the owning workspace. - `import_writeback_started` -- A write-back to the provider was queued for a node - `import_writeback_completed` -- The local change was successfully written back to the provider - `import_writeback_failed` -- The write-back failed permanently; `error_message` carries a coarse failure class - `import_writeback_conflict` -- The remote object changed since the local edit began, so the push was not applied Event data carries `profile_id`, `source_id`, `node_id` and `wb_id` (plus `error_message` on `_failed`). `wb_id` identifies the write-back itself and is the same across all four events for one push, so it is what correlates a `_started` with its eventual `_completed`, `_failed` or `_conflict`. `node_id` cannot do that job: a node edited twice produces two write-backs sharing one node id. **These events do NOT wake a client.** They raise no realtime signal -- a client must READ the events feed to see them and will not be nudged. This is deliberate: they fire per FILE, so a large sync would otherwise notify once per file. **Treat diagnostic fields as sensitive and untrusted.** `error_message` is a coarse failure class, but diagnostic text in this family can carry provider-supplied content, including the names of files inside a user's connected cloud account. Summarise it; never relay it verbatim into a chat, ticket, or commit message, and never parse it for control flow. ### AI - `ai_chat_created` / `ai_chat_updated` / `ai_chat_deleted` -- AI agent conversation lifecycle - `ai_chat_new_message` -- New message in an AI agent conversation - `ai_chat_published` -- AI agent conversation published (chat publish is currently disabled platform-wide -- `capabilities.can_publish_agent_chat` is `false`; fires only for chats published before the disable) - `node_ai_summary_created` -- AI summary generated for a file - `workspace_ai_share_created` -- AI share links created in a workspace or share (`file_count`) - `workspace_ai_file_downloaded` -- A file was downloaded through an AI share link (visible to admins only) ### Metadata - `metadata_kv_update` / `metadata_kv_delete` / `metadata_kv_extract` - `metadata_fact_update` -- A file's extracted metadata was updated. Emitted by the machine-extraction write path, so it fires per file across an extraction run - `metadata_field_merged` -- One field was folded into another in a workspace's field vocabulary #### Field vocabulary merges Category `metadata`, sub-category `metadata`, external visibility, member permission. Emitted when a workspace's field vocabulary changes shape: one field is folded into another, so the folded name stops being its own entry and resolves to the surviving field from then on. The event data carries `workspace` plus both NAMES -- `alias_field_name`, the field that was retired, and `canonical_field_name`, the field it now resolves to. That pair is the point of the event: a consumer holding persisted selections that named the retired field can REWRITE them to the surviving name instead of dropping them, and a cached copy of the vocabulary can be corrected without re-reading the whole listing. **Nothing reaches anyone on its own when this fires** -- no email, no notification, no webhook, no realtime nudge. The fold is recorded and nothing more, so a consumer that wants to react to one must POLL `GET /current/events/search/` for it. Do not wait to be woken; you will not be. **It fires only when a fold actually WRITES.** A pre-flight of a merge emits nothing, and neither does a merge that finds the two fields already folded together -- so receiving this event always means the vocabulary really changed. **The converse does NOT hold.** Emission is best-effort and nothing retries, so a real fold can produce no event at all. Do not treat the stream as a ledger of vocabulary changes: treat an event as a prompt to re-read the field vocabulary, and let that listing be the truth. #### Templates and saved views (retired — no longer emitted) Metadata templates have been replaced by the workspace field vocabulary, and per-user saved views have been folded into metadata filters. Both sets of endpoints are gone. These event types are retained only so historical activity stays readable; **none of them is ever emitted now**, so do not build a subscription or a workflow that waits on one. - `metadata_template_update` / `metadata_template_delete` / `metadata_template_select` - `metadata_view_create` / `metadata_view_update` / `metadata_view_delete` ### Quick Shares (deprecated — see File Share Lifecycle) QuickShare creation is deprecated in favor of the durable File Share; these events fire only for the draining QuickShare population. New single-file sharing emits the `file_share_*` events above. - `workspace_quickshare_created` / `workspace_quickshare_updated` / `workspace_quickshare_deleted` - `workspace_quickshare_file_downloaded` / `workspace_quickshare_file_previewed` ### Invitations - `invitation_email_sent` / `invitation_accepted` / `invitation_declined` ### User - `user_created` / `user_updated` / `user_deleted` - `user_email_reset` / `user_asset_updated` - `user_login` -- category `user`, sub-category `security`, audit-log visibility only. Written once per **Enterprise** org the user is a live member of (nothing is recorded for a user in no Enterprise org). See *Compliance & Audit* below for the full field list and the login-history query. ### Organization - `org_created` / `org_updated` / `org_closed` -- `org_updated`'s `policy_changes` map now also carries the three collaboration-policy keys (`external_invites_shares`/`_portals`/`_workspaces`) as `{before, after, overrides: {added, removed, changed}}` -- see *Collaboration Policies* in `llms/orgs.txt`. It further gains `ai_agent`, `ai_intelligence`, `ai_metadata`, `ai_summaries`, `mcp_access` (same envelope-diff shape), `access_policy` (`{before, after}` per role, each reduced to `{countries: {mode, count}|null, ips: {count}|null}` -- no country codes and no IP/CIDR values are carried in the event; `overrides` stays the usual counts-only `{added, removed, changed}` diff) and `ai_workspaces` (`{before: count|null, after: count|null, added, removed}` -- `null` means "every workspace"; the diff never lists workspace ids) -- see *Access Policy (Geo / IP Restrictions)* and *AI, Deep Indexing & MCP Access Policy* in `llms/orgs.txt`. It further gains `security_alerts` (`{before, after}`, each the parsed envelope or `null` for the defaults) when that setting changes -- see *Security Alerts* in `llms/orgs.txt`. ### Compliance & Audit Mostly category `org`, sub-category `security`. Exceptions: `org_compliance_auditor_changed` and `org_member_transfer_started` / `org_member_transfer_completed` are sub-category `members`; `user_login` and `oauth_session_created` are category `user` (sub-category `security`); `storage_direct_read_summary` is category `workspace`, sub-category `storage`; `ownership_transferred` is noted on its own entry. All use `external_audit_log` visibility, visible to org admins and to a member holding the `compliance_auditor` flag — see *Compliance & Audit* in `llms/orgs.txt` for the endpoints that read and write these. - `user_login` -- a sign-in was completed. Row-root fields: `method` (`password` \| `password_2fa` \| `social:google` \| `social:apple` \| `social:microsoft` \| `sso` \| `oauth_code` \| `oauth_refresh` \| `api_key`), `mfa` (bool -- `true` only when Fastio itself verified a second factor; an SSO login is always `false`, since the identity provider's own MFA is invisible to us), `new_country` (bool -- first login from this country within the org's retained history), `client_id`, `session_id`, `token_id`, `agent_name`, `device_name`, `user_agent` (raw, ≤256 chars), plus the audit-mode `ip` / `country` every row carries. Login **failures are never recorded**. Machine logins (`api_key`, `oauth_refresh`) are **deduplicated** — not every machine-credential request is recorded, so a row's absence does not mean the credential was unused. Query a user's login history with `events/search?org_id={org}&visibility=external_audit_log&event=user_login&calling_user_id={uid}` (`user_id` is refused in audit mode). - `org_credential_revoked` -- an admin revoked a member's API key or OAuth grant. Fields: `type` (`api_key`\|`oauth`), `credential_id`, `reach` (`org_only`\|`user_wide`\|`mixed`\|`null`), `target_user_id`. - `org_member_signed_out` -- an admin force-signed-out a member. Fields: `target_user_id`, `revoked_count`, `skipped_count`. - `org_audit_exported` -- an audit-log export ran (partially or to completion). Fields: `from`, `to`, `format`, `filters`, `rows`, `truncated`, `complete`. - `org_compliance_auditor_changed` -- the owner granted or revoked a member's `compliance_auditor` flag. Fields: `target_user_id`, `enabled`. - `legal_hold_created` / `legal_hold_released` -- a legal hold was placed or lifted (see *Legal Holds* in `llms/orgs.txt`). Fields: `hold_id`, `target_type` (`workspace`\|`user`), `target_id`, `name` -- **never** the hold's `reason`. Unlike every other row on this page, these two events are visible **only** to the org owner or an entitled auditor -- never a plain admin -- in the audit log, event details, summarize and export alike, **and only through a login session or an API key/OAuth token that itself carries 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, even when the key's owner is the org owner or an auditor. A search filtered to one of these two event types by a caller who does not qualify comes back as an empty page (`has_more: false`, `next_cursor: null`), not an error. `event` names are matched exactly — a case, whitespace, or accent variant of a hold-event name, or any `event` value that is not itself a plain lowercase name, also returns an empty page rather than falling through to a broader match. They are never forwarded to a configured SIEM stream. - `org_policy_denied` -- a request was blocked by the org's geo/IP access policy or its MCP access policy (see *Access Policy (Geo / IP Restrictions)* and *AI, Deep Indexing & MCP Access Policy* in `llms/orgs.txt`). Fields: `reason` (`geo_restricted` \| `mcp_access_denied`), `rule` (`country` \| `ip`, geo only -- absent for an MCP denial), `user_id` (the blocked user), `ip`, `country`, `credential_type`, `route`. Rate-limited, so it may not appear for every blocked request. - `org_access_policy_breakglass` -- the org owner's own access-policy break-glass path was actually used to reach `org/{org_id}/details/` or `.../update/` from a location the policy would otherwise block. Fields: `user_id` (the owner), `ip`, `country`, `route`. Rate-limited. - `oauth_session_created` -- an OAuth authorization-code exchange completed (never fires on a token refresh). Written as one row in the user's own activity (no org -- only the user can read it), plus one row per **Enterprise** org the user is a live member of **and** the grant's scopes can reach (a full-account grant or an org, workspace or share wildcard scope reaches every such org). Those org rows appear in that org's audit-log search and SIEM stream, visible to org admins and auditors. Row-root fields: `session_id`, `client_id`, `agent_name`, `mcp` (boolean -- the grant's audience is the Fastio MCP), plus the audit-mode `ip` / `country`. `session_id`, `client_id` and `agent_name` are audit-only, returned only in an audit-log search, so the user's own row shows `mcp` alone. Feeds the `credential_created` security alert, raised on those same orgs with `source_event_id` set to that org's row -- see *Security Alerts* in `llms/orgs.txt`. - `storage_direct_read_summary` -- a rolled-up count of one member's **authenticated** direct `/read/` downloads on workspace and share storage, aggregated per member per minute (Enterprise orgs only). Fields: `user_id`, `count`, `window_seconds` (always `60`). `count` is the number of **distinct files** read in the minute, not the number of requests -- repeated or byte-range reads of the same file within the window count once. Download-token and public-link reads are **not** counted here -- the token mint is already audited by the existing `*_download_token_created` events. Feeds the `mass_download` security alert. - `ownership_transferred` -- ownership of an org, workspace, or share was transferred to another member (see *Transfer Org/Workspace Ownership* in `llms/orgs.txt` / `llms/workspaces.txt` and *Transfer Ownership* in `llms/shares.txt`). Category `org`\|`workspace`\|`share`, sub-category `members`. The transferred profile is the row's own `event_profile` (and `org_id` / `workspace_id` / `share_id`). Fields: `profile_type` (`org`\|`workspace`\|`share`), `from_user`, `to_user`, `transfer_id` (string, only when raised as part of a *Bulk Access Transfer* below -- `null` for a direct ownership transfer). - `org_member_transfer_started` / `org_member_transfer_completed` -- a *Bulk Access Transfer* (`llms/orgs.txt`) was queued, and later finished. Fields (both events): `transfer_id`, `counts`, `complete`, `offboard_status`. On the started event they describe the preview plan (plan counts, whether it was fully enumerated) and `offboard_status` is always `not_requested`; the completed event carries the final report counts and offboard outcome. - `org_audit_stream_updated` / `org_audit_stream_deleted` / `org_audit_stream_paused` -- the org's SIEM audit stream (see *SIEM Audit Stream* in `llms/orgs.txt`) was created/changed, removed, or auto-paused after sustained delivery failure. Fields: `url` (host only -- never the full URL with path/query), `enabled`, `change` (`created`\|`updated`\|`enabled`\|`disabled`\|`secret_rotated`\| `deleted`\|`paused_failing`), `gap_from`/`gap_to` (set only when events aged out while paused). - `org_security_alert` -- one of the *Security Alerts* (`llms/orgs.txt`) fired. Severity high, audit log only, **no event user** -- the subject of the alert (if any) rides in `subject_user_id` only, so the alert never appears in the subject's own activity. It also carries **no `calling_user` and no `actor`** -- the platform raises it, not the request that tripped it; render the actor as "System". Fields: `alert_type`, `subject_user_id` (string/null), `count` (mass alerts only), `window_minutes` (mass alerts only), `source_event_id`, `method`/`client_id`/`agent_name` (`login_new_country`: how the member authenticated, as on `user_login` -- an `oauth_code`/`oauth_refresh` or `api_key` login is a connected app or API key, not an interactive sign-in, and its location is where that app runs; `credential_created`: `oauth_code` for a connected app, `api_key` for a new API key; null elsewhere), `memo` (`credential_created` API keys only -- the key's description), `mcp` (`credential_created` connected apps only -- true for an MCP connection, which is how AI assistants and agents connect), `access` (`credential_created` only -- the broadest access the credential grants: `r`, `rw` or `rwa`), plus the audit-mode `ip`/`country` of the source event. ### Enterprise SSO Category `org`, sub-category `security`, `external_audit_log` visibility, `admin` permission — visible only to org admins, in the audit log. Covers an org's single sign-on configuration and domain claims (`GET/POST /current/org/{org_id}/sso/...`). - `org_sso_updated` -- The org's SSO configuration (protocol, mode, provider values, role mapping) was written; also raised when a domain is claimed, a configuration check runs, or the SCIM token is minted or revoked. `updates` lists the changed field names only (never values); `policy_changes` carries `mode: {before, after}` when the mode moved and `domains: {added, removed}` when a domain was claimed - `org_sso_deleted` -- The org's SSO configuration was removed (verified domains are kept) - `org_sso_domain_verified` -- A claimed domain passed DNS verification - `org_sso_domain_unverified` -- A previously verified domain stopped being verified -- either an admin released the claim, or the periodic reverification sweep found the DNS record missing across three consecutive checks. **The sweep-triggered case carries no `calling_user`** -- the actor is the platform, not a person; render it as "System". - `org_sso_login` -- A user completed sign-in through the org's identity provider. Carries `calling_user` (the signing-in user) - `org_sso_certificate_expiring` -- Defined for a SAML signing-certificate expiry warning, but **not currently raised**. When it is, it carries no `calling_user` (render the actor as "System"). Read `certificate_warning` on the SSO configuration for expiry state today ### SCIM Provisioning Category `org`, sub-category `members`, `external_audit_log` visibility, `admin` permission — visible only to org admins, in the audit log. Raised by an identity provider's SCIM client (`/current/scim/v2/...`), never by a person, so **none of these three carry a `calling_user`** -- render the actor as "Identity provider". - `org_scim_provisioned` -- A user was added to the org by the identity provider - `org_scim_deprovisioned` -- A user was removed from the org by the identity provider (including the async cascade that follows a SCIM deprovision) - `org_scim_group_updated` -- A provisioning group's membership was created, updated, or deleted by the identity provider ### Billing - `subscription_created` — fires when a new subscription is initiated - `subscription_cancel_scheduled` — fires when a customer schedules cancellation; the subscription remains active until `cancel_at` - `subscription_cancelled` — fires when the subscription is actually terminated by the payment provider (at `cancel_at`) - `billing_free_trial_ended` --- ## Event Search Examples **Recent comments in a workspace:** ``` GET /current/events/search/?workspace_id={id}&subcategory=comments ``` **File uploads to a share in a date range:** ``` GET /current/events/search/?share_id={id}&event=share_storage_file_added&created-min=2025-12-01T06:00:00Z ``` **Membership changes in an org:** ``` GET /current/events/search/?org_id={id}&subcategory=members ``` **AI activity in a workspace:** ``` GET /current/events/search/?workspace_id={id}&category=ai ``` **Unacknowledged events for a user:** ``` GET /current/events/search/?user_id={id}&acknowledged=false ``` **Audit log events only:** ``` GET /current/events/search/?workspace_id={id}&visibility=external_audit_log&limit=100 ``` **Child events of a batch operation:** ``` GET /current/events/search/?parent_event_id=ancouywgcxiff7kpbijpl4ysgn43j&limit=100 ``` --- ## Org Storage Change Feed A single endpoint that returns every storage change across every workspace and share of an org the caller can read, since a cursor -- the org-scale alternative to calling `/events/search/` or `/activity/poll/` once per workspace and once per share. --- ### `GET /current/org/{org_id}/events/changes/` Return the org's storage changes (file/folder add, update, delete, move, copy, restore, rename, transfer, version restore, trash emptied, links, and cloud-sync file adds/updates) across every workspace and org share the caller can read, in order, after a cursor. **Auth:** Required (JWT). Default rate limiting. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{org_id}` | string | Yes | 19-digit numeric ID of the organization | **Query Parameters:** | Parameter | Type | Required | Default | Constraints | Description | |-----------|------|----------|---------|-------------|-------------| | `cursor` | string | No | - | Opaque string from a previous response | Position to resume from. Omit to bootstrap | | `limit` | integer | No | `250` | 1-1000 | Maximum number of changes to return | **curl Example:** ```bash curl -X GET "https://api.fast.io/current/org/1111111111111111111/events/changes/?limit=500" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "changes": [ { "event_id": "ancou-ywgcx-iff7k-pbijp-l4ysg-n43j", "event": "workspace_storage_file_added", "profile_id": "4829105738291047362", "profile_type": "workspace", "object_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "created": "2026-09-25 21:40:11 UTC", "calling_user_id": "1382049571038475629" } ], "cursor": "{opaque_cursor}", "has_more": false, "profiles": { "version": "9b1f0c...", "items": [ {"id": "4829105738291047362", "type": "workspace"}, {"id": "5510392857104938271", "type": "share"} ] } } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `result` | boolean | `true` on success | | `changes` | array | Storage changes since `cursor`, oldest first | | `changes[].event_id` | string | Unique event identifier. **Dedupe by this** -- an updated row can reappear with the same `event_id`; the latest delivery wins | | `changes[].event` | string | Event name (e.g. `workspace_storage_file_added`) -- see *Event Names Reference* above for the full storage set | | `changes[].profile_id` | string | 19-digit ID of the workspace or share the change belongs to | | `changes[].profile_type` | string | `workspace` or `share` | | `changes[].object_id` | string \| null | Affected object OpaqueId (file, folder, or link). `null` for a change that is not about a single item (e.g. trash emptied) | | `changes[].created` | string | Event timestamp (`Y-m-d H:i:s UTC`) | | `changes[].calling_user_id` | string | 19-digit ID of the attributed actor. Omitted where the caller may not see who acted | | `cursor` | string | Opaque, signed, forward-only. Pass back unchanged as `cursor` on the next call | | `has_more` | boolean | `true` means call again. Can be `true` even when `changes` is empty. If `has_more` is `true` **and the returned `cursor` is identical to the one you sent**, the newest changes are still settling -- call again after about 10 seconds, not immediately | | `profiles` | object | The caller's current readable set | | `profiles.version` | string | Changes when the caller joins/leaves a workspace or share, or their file visibility on a share changes. Re-list `profiles.items` when it does | | `profiles.items` | array | `{id, type}` for every workspace and share the caller can currently read (a workspace-folder share shadowed by its readable workspace is omitted) | **Bootstrap (no `cursor`):** returns `changes: []`, `has_more: false`, and a head `cursor`. Only changes recorded after this call are reported, so list `profiles.items` in full immediately after taking the head cursor -- that listing plus the feed from here forward is the complete picture. The head cursor sits slightly behind the very newest changes (about 10 seconds), so the first follow-up call may return changes your listing already reflects; dedupe by `event_id`. **Error Responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1700 (Access Forbidden)` | 403 | Caller has no readable workspace or share in this org | | `1605 (Invalid Input)` | 406 | Cursor malformed, tampered, or minted for a different caller / org / token scope (for example after the token's scopes changed). Re-bootstrap: take a fresh head cursor, then re-list `profiles.items` | | `1605 (Invalid Input)` | 406 | Cursor expired -- `params.reason: "cursor_expired"`. Do a full resync: take a fresh head cursor, then re-list `profiles.items` | | `1693 (Temporarily Unavailable)` | 503 | Transient read failure. Retry with the same `cursor` | **Notes:** - **Replaces one `/events/search/` or `/activity/poll/` call per workspace and per share** for a client that wants "every storage change in this org" -- one call and one cursor cover the whole org. - **A very recent change may be redelivered.** The feed briefly holds back the newest rows so a concurrently-committing write is not skipped, so those rows can be returned again on the next call. **Always dedupe by `event_id`, keeping the latest delivery.** - **Access is per-caller, not per-org-membership.** Any user with at least one readable workspace or share in the org is served, whether or not they are an org member; a caller who cannot read anything in the org gets `403`. Unscoped tokens and workspace-scoped tokens are supported; a workspace-scoped token sees only its workspaces. **Share-scoped tokens are supported too:** a token scoped to shares is served when at least one of its shares belongs to this org, and sees only its scoped shares' changes (reported as share changes); a token whose shares are all in other orgs gets `403`. - **Coverage gaps a client should reconcile with a periodic full listing:** permanent purges are not reported; a rename arrives as the `_updated` event; some share-only operations (a note create, a version restore, a cloud-sync add, a move out of the share, an upload's auto-created folder, a share-side trash-empty through a folder share) have no share-side event; and a very late-committing write can be missed. Treat the feed as a low-latency accelerant, not a replacement for occasionally re-listing storage in full. - **A change inside a workspace folder share is reported once** -- as the workspace's change to a caller who can read the workspace, and as the share's change to a caller who can only read the share. - **Realtime nudge:** see the org channel `storage` field under *WebSocket (Real-Time)* below -- it tells you when to call this endpoint, without naming what changed. --- ## Activity Polling Long-poll endpoints for efficient change detection. The server holds the connection open and returns immediately when something changes, avoiding expensive resource polling. --- ### `GET /current/activity/poll/` Poll for activity updates on the current user's profile. **Auth:** Required (JWT). Subject to global rate limiting; see the rate-limit section in the main reference. **Query Parameters:** | Parameter | Type | Required | Default | Constraints | Description | |-----------|------|----------|---------|-------------|-------------| | `wait` | integer | No | `0` | 0-95 | Long-poll timeout in seconds. Server holds connection open until update or timeout. | | `lastactivity` | string | No | Current time | Micro-precision datetime (e.g., `2025-01-20 10:30:45.123456 UTC`) | Only return activity newer than this timestamp | | `updated` | any | No | - | Any non-empty value other than `0` enables it | If enabled, only return activity fields updated since `lastactivity` | | `fields` | string | No | All fields | Comma-delimited; max 30 fields | Activity field names to check. Supports ID qualifier via colon (e.g., `storage:12345`). | **curl Example:** ```bash curl -X GET "https://api.fast.io/current/activity/poll/?wait=30&lastactivity=2025-01-20%2010:30:45.123456" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK -- activity found):** ```json { "result": true, "results": 3, "activity": { "storage": "2025-01-20 10:30:45.123456 UTC", "members": "2025-01-20 09:15:22.654321 UTC", "settings": "2025-01-19 14:00:00.000000 UTC" }, "lastactivity": "2025-01-20 10:30:45.123456 UTC" } ``` **Response (200 OK -- no activity):** ```json { "result": true, "results": 0, "activity": [] } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `result` | boolean | `true` on success | | `results` | integer | Number of activity fields returned | | `activity` | object or array | Map of activity field names to micro-precision UTC timestamps. When `results` is 0 this is an empty array `[]`, not `{}`. | | `lastactivity` | string | Most recent timestamp; pass as `lastactivity` in next poll. Omitted when `results` is 0. | **Error Responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | Invalid profile ID format | | `1605 (Invalid Input)` | 406 | User lacks permissions for the specified profile | | `1605 (Invalid Input)` | 406 | Invalid field names or more than 30 fields | | `1665 (Object Init Failed)` | 500 | Internal error | | `1650 (Authentication Invalid)` | 401 | Missing or invalid JWT token | --- ### `GET /current/activity/poll/{profile_id}/` Poll for activity updates on a specific workspace, share, org, File Share, or Sign Envelope. **Auth:** Required (JWT). Same rate limits and parameters as `GET /current/activity/poll/`. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{profile_id}` | string | Yes | Profile ID to subscribe to (org, workspace, share, File Share, or Sign Envelope) — a 19-digit numeric profile ID; a **File Share may also be addressed by its opaque `id_alt`**. For upload progress, use your user ID. | **Access Requirements:** | Profile Type | Permission Required | |--------------|-------------------| | User (self) | Authenticated | | Organization | View permission on the org | | Workspace | View permission on the workspace | | Share | Permission to view the share's details | | File Share | The same access decision as the File Share's public read surface (access tier + password + named grant). A signed-in recipient granted view/download/edit receives a recipient-scoped feed (comment activity plus a bare content/lifecycle nudge — never the owner's internal activity); an anonymous anyone-with-link visitor cannot poll. | | Sign Envelope | The e-sign creator-side realtime channel. Requires view access to the sign envelope. | **curl Example:** ```bash curl -X GET "https://api.fast.io/current/activity/poll/1234567890123456789/?wait=30&fields=storage,members&updated=1&lastactivity=2025-01-20%2010:30:45.123456" \ -H "Authorization: Bearer {jwt_token}" ``` Response format is identical to `GET /current/activity/poll/`. **Org access/AI policy.** For an Org, Workspace, or Share profile owned by an Enterprise org with a restriction configured, a blocked caller gets `403 geo_restricted` and an MCP-classified caller blocked by `mcp_access` gets `403 mcp_access_denied`, ahead of the usual profile-lookup error; a policy verdict that cannot be read is `503 access_policy_unavailable`. See *Access Policy (Geo / IP Restrictions)* and *AI, Deep Indexing & MCP Access Policy* in `llms/orgs.txt`. --- ### Polling Workflow 1. Make initial poll request (no `lastactivity` parameter) 2. Receive response with `activity` fields and `lastactivity` timestamp 3. Process changes by fetching updated resources based on activity field names 4. Make next poll with `lastactivity` from previous response 5. Repeat -- server returns immediately on change, or after `wait` seconds timeout ### Activity Key Patterns | Key Pattern | What Changed | |-------------|-------------| | `storage:{fileId}` | File added, updated, or removed | | `preview:{fileId}` | File preview/thumbnail is ready | | `metadata:{fileId}` | File's extracted metadata fields were written (use to refresh a single row during a template extraction) | | `ai_chat:{chatId}` | AI chat message updated | | `comments:{nodeId}` | Comment added or updated | | `membership` | A member was added, removed, or had their role changed | ### Anti-Patterns - **Do NOT** loop on resource detail endpoints to wait for previews or processing. - **Instead**, poll on the workspace/share and watch for the relevant activity key. - **For uploads**, use your user ID as the `profile_id` since upload progress is tied to the user, not a workspace. --- ## WebSocket (Real-Time) Optional real-time delivery (~300ms latency vs ~1s for polling). Sends both `activity` messages (field names for change detection) and enriched `event` messages (full event details) via WebSocket. Backwards compatible -- existing clients continue to work without changes. --- ### `GET /current/websocket/auth/{profile_id}` Generate a WebSocket authentication JWT for a specific profile. User, organization, and workspace tokens are valid for 1 hour, Sign Envelope tokens for 24 hours. Share and File Share tokens use a shorter TTL (30 minutes) — always check the `expires_in` field on every response and refresh before it expires. A File Share realtime channel is gated by the same access decision as its public read surface (access tier + password + named grant), the same requirement as its activity poll. A signed-in recipient granted view/download/edit can mint a token and receives a recipient-scoped feed — comment activity plus a bare content/lifecycle nudge, never the owner's internal activity; an anonymous anyone-with-link visitor cannot mint a realtime token. **Auth:** Required (JWT). No credit consumption. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{profile_id}` | string | Yes | ID of the user, org, workspace, share, file share, or sign envelope to subscribe to — a 19-digit numeric ID; a **File Share may also be addressed by its opaque `id_alt`** | **curl Example:** ```bash curl -X GET "https://api.fast.io/current/websocket/auth/1234567890123456789" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "expires_in": 3600, "auth_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `result` | boolean | `true` on success | | `expires_in` | integer | Effective token lifetime in seconds (3600 = 1 hour for user/org/workspace; 1800 = 30 minutes for share and file-share; 86400 = 24 hours for sign envelope) | | `auth_token` | string | Signed JWT with `websocket` scope, bound to the requested profile | **Access Requirements:** | Profile Type | Permission Required | |--------------|-------------------| | User (self) | None beyond authentication | | Organization | View permission on the org | | Workspace | View permission on the workspace | | Share | Permission to view the share's details | | File Share | The same access decision as the File Share's public read surface (access tier + password + named grant), the same gate as its activity poll. A signed-in view/download/edit recipient mints a recipient-scoped token (comment activity plus a bare content/lifecycle nudge only); an anonymous anyone-with-link visitor cannot mint one. | | Sign Envelope | View access to the sign envelope (the e-sign creator-side realtime channel) | **Token Lifetime:** The default `expires_in` for User, Organization, and Workspace tokens is 3600 (1 hour). **Share and File Share tokens are shorter-lived (1800 seconds / 30 minutes)** — always inspect the `expires_in` field on the response and refresh the token before it expires. These channels use a short TTL because the recipient's standing can change during a session, and the token freezes that standing at mint time: a share guest can be removed or have their access level downgraded, and a File Share recipient's grant or the file's access tier/password can change. The short TTL bounds how long an open connection can keep delivering events under a now-stale authorization; on reconnect the freshly minted token reflects the current standing. User, Organization, and Workspace tokens are bounded to an hour for the same reason: once a sign-in session ends (sign out, sign out everywhere, or a password change), requests made with it can no longer mint tokens, so a connection opened through it ends shortly after its token expires. When a user, organization, workspace, share or file-share token expires the server closes the open connection with a `denied` frame; mint a fresh token and reconnect. **Error Responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | No profile ID provided or invalid format | | `1605 (Invalid Input)` | 406 | Profile type unsupported, not found, or user lacks permissions | | `1650 (Authentication Invalid)` | 401 | Missing or invalid JWT, or internal JWT generation failure | | *(generated per call site)* | 403 | `geo_restricted` -- the Org/Workspace/Share profile is owned by an Enterprise org whose access policy blocks the caller's location or network. See *Access Policy (Geo / IP Restrictions)* in `llms/orgs.txt`. | | *(generated per call site)* | 403 | `mcp_access_denied` -- an MCP-classified caller is blocked by the org's `mcp_access` policy. See *AI, Deep Indexing & MCP Access Policy* in `llms/orgs.txt`. | | *(generated per call site)* | 503 | `access_policy_unavailable` -- the access-policy verdict could not be read; retry. | --- ### WebSocket Connection Connect to: `wss://{host}/api/websocket/?{auth_token}` Where `{auth_token}` is the JWT returned from the auth endpoint above — it is the entire query string (no `token=` key, no other query parameters). ### WebSocket Message Types The server pushes two types of JSON messages: #### `activity` Messages Indicate which resource categories changed. Use these to know what to re-fetch: ```json { "response": "activity", "activity": ["storage:2abc...", "preview:2abc..."] } ``` The `activity` array contains the same activity key patterns as the polling endpoint. **Org channel `storage` field.** On an **organization** channel only, a bare `storage` key (no `:{id}` suffix) is pushed at most about once a second per org whenever a storage change occurs in any workspace or share of the org: ```json { "response": "activity", "activity": ["storage"] } ``` It is deliberately content-free -- the org channel reaches every org member, including members who cannot read the workspace or share that actually changed, so it never names the change. On receipt, call `GET /current/org/{org_id}/events/changes/` (see *Org Storage Change Feed* above) to learn what happened, and call it again roughly a second later to catch anything that committed just after the ping (there is no trailing signal). Keep a slow periodic call to the same endpoint as a safety net alongside the org channel. #### `event` Messages Sent alongside `activity` messages when structured event data is available. Provide full event details for immediate UI updates without a follow-up API call: ```json { "result": true, "response": "event", "time": "2026-03-22 14:30:45.1234", "timestamp": "1711123456.1234", "event": "workspace_storage_file_added", "category": "workspace", "subcategory": "storage", "object_id": "23s5ktto3hoomtz3fbgrmhurl2mi6", "calling_user_id": "1234567890123456789", "activity_field": "storage", "data": { "name": "report.pdf", "parent_node_id": "xyz789...", "size": 1048576 } } ``` **`event` message fields:** `result` (boolean, always true), `response` (always `"event"`), `time` (server send time, `YYYY-MM-DD HH:MM:SS.` with no ` UTC` suffix and a variable number of fraction digits, e.g. `"2026-03-22 14:30:45.1234"`), `timestamp` (event trigger time as microtime float string, e.g. `"1711123456.1234"`), `event` (event name), `category`, `subcategory`, `object_id` (affected object OpaqueId, in its unhyphenated form; empty string when the event has none), `calling_user_id` (actor's 19-digit ID; empty string when none was recorded), `activity_field` (corresponding activity field name), `data` (event-specific details, shape varies by event type). **Backwards compatible:** `activity` messages are sent for every change a recipient is permitted to observe. `event` messages are supplementary. Existing clients need no changes. Payload kept under ~4KB. **Permission gating:** Enriched `event` messages are only sent for member-level events. Admin and targeted events receive only the `activity` message. **Per-recipient scoping (share channels):** On a **share** channel, each outgoing `activity`/`event` frame is scoped to the receiving guest's share access before it is delivered. A guest receives only the changes their access level permits them to observe — frames they may not see are suppressed, and content detail (file names, node identifiers, enriched `data`) is collapsed to a bare category or dropped for guests with restricted file visibility. Members and all non-share channels (user / organization / workspace) are unaffected and receive the full frame. Do not assume a share guest will observe every change on the channel, or that a delivered `activity` frame will always carry an enriched `event` companion. ### WebSocket Fallback If the WebSocket connection drops, fall back to long-polling (`GET /current/activity/poll/{profile_id}/`). Activity polling returns field names and timestamps. To retrieve full event details after reconnection, use `GET /current/events/search/` with a `created-min` filter. --- ## Realtime Auth (Collaborative Rooms) Separate from WebSocket activity channels, these endpoints provide authentication for collaborative editing rooms. --- ### `GET /current/realtime/auth/{room_id}` Generate a realtime JWT for a workspace or share collaborative room. Tokens are valid for 24 hours. **Auth:** Required (JWT). Subject to global rate limiting; see the rate-limit section in the main reference. No credit consumption. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{room_id}` | string | Yes | 19-digit numeric ID of the workspace or share to join | **curl Example:** ```bash curl -X GET "https://api.fast.io/current/realtime/auth/1234567890123456789" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "expires_in": 86400, "auth_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `result` | boolean | `true` on success | | `expires_in` | integer | Token lifetime in seconds (86400 = 24 hours) | | `auth_token` | string | Signed JWT with `realtime` scope, bound to the requested room | **Access Requirements:** | Profile Type | Permission Required | |--------------|-------------------| | Workspace | At least View permission | | Share | Permission to view the share's details + multiplayer enabled (multiplayer status is automatically determined based on share configuration -- not directly togglable via API) | **Error Responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | Missing room ID | | `1605 (Invalid Input)` | 406 | Room ID is not numeric | | `1605 (Invalid Input)` | 406 | Room ID does not correspond to a workspace or share | | `1680 (Access Denied)` | 401 | User lacks permissions on the room | | `1650 (Authentication Invalid)` | 401 | Missing or invalid JWT, or internal JWT generation failure | **Notes:** - Only workspace and share profile types are accepted as room IDs. --- ### `GET /current/realtime/note-auth/{profile_id}/{note_id}` Generate a realtime token for collaborative editing of a single Note. The token is bound to the calling user, the note's workspace, and the note itself, and carries the caller's edit/view standing for the note. Tokens are valid for 900 seconds (15 minutes); refresh before expiry by calling the endpoint again, which re-resolves your current standing on the note. **Auth:** Required (JWT). Subject to global rate limiting; see the rate-limit section in the main reference. No credit consumption. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{profile_id}` | string | Yes | 19-digit numeric ID of the workspace the note lives in | | `{note_id}` | string | Yes | ID of the Note to edit | **curl Example:** ```bash curl -X GET "https://api.fast.io/current/realtime/note-auth/{profile_id}/{note_id}" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "expires_in": 900, "auth_token": "{realtime_note_token}" } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `result` | boolean | `true` on success | | `expires_in` | integer | Token lifetime in seconds (900 = 15 minutes) | | `auth_token` | string | Signed realtime-note token, bound to the user, workspace, and note | **Access Requirements:** The token's granted capabilities are frozen at mint time from your current permission on the workspace: | Workspace permission | Granted capability | |----------------------|--------------------| | Edit (or higher) | Read and edit the note | | View | Read the note only | | Below View | Denied | Because the capabilities are frozen for the token's lifetime, a permission change made after a token is issued takes effect on the next token refresh (at most ~15 minutes later). **Error Responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | Missing or invalid note ID, or the node is not a note | | `1609 (Not Found)` | 404 | No such note exists, or the note is in the trash | | `1680 (Access Denied)` | 401 | You lack permission on the workspace | | `1650 (Authentication Invalid)` | 401 | Missing or invalid JWT, or internal token generation failure | --- ### `GET /current/realtime/auth/validate/` Validate a realtime JWT and extract the room ID. Intended for backend services to verify tokens. **Auth:** Bearer token in Authorization header (the realtime JWT to validate, not a user JWT). **curl Example:** ```bash curl -X GET "https://api.fast.io/current/realtime/auth/validate/" \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." ``` **Response (200 OK):** ```json { "result": true, "room_id": "1234567890123456789" } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `result` | boolean | `true` on success | | `room_id` | string | The workspace or share profile ID the token is bound to | **Error Responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1650 (Authentication Invalid)` | 401 | Missing Authorization header | | `1605 (Invalid Input)` | 406 | Malformed Authorization header | | `1605 (Invalid Input)` | 406 | Authorization header does not use Bearer scheme | | `1605 (Invalid Input)` | 406 | Bearer keyword present but no token follows | | `1605 (Invalid Input)` | 406 | Token is not valid JWT format | | `1680 (Access Denied)` | 401 | Token signature verification or expiration check failed | | `1610 (Internal Error)` | 500 | Token payload is malformed or missing required fields | | `1605 (Invalid Input)` | 406 | Token scope is not `realtime` | **Notes:** - Only validates tokens with `realtime` scope. WebSocket-scoped tokens are rejected. - Validation is performed using the JWT alone. --- ### `GET /current/realtime/note-auth/validate/` Validate a realtime-note token (minted by `GET /current/realtime/note-auth/{profile_id}/{note_id}`) and read back the note, workspace, and permission it is bound to. Intended for the collaborative-editing backend to confirm a token on connect. **Auth:** Bearer token in Authorization header (the realtime-note token to validate, not a user JWT). **curl Example:** ```bash curl -X GET "https://api.fast.io/current/realtime/note-auth/validate/" \ -H "Authorization: Bearer {realtime_note_token}" ``` **Response (200 OK):** ```json { "result": true, "profile": "1234567890123456789", "node": "2ik5q-a43cm-uixi2-van5r-3eolo-7mue", "perm": "edit", "file_share_id": null } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `result` | boolean | `true` on success | | `profile` | string | The workspace profile ID the token is bound to | | `node` | string | The Note node ID the token is bound to | | `perm` | string | The frozen permission: `"edit"` (read and edit) or `"view"` (read only) | | `file_share_id` | string or null | The File Share ID the token was minted for when it is a File Share-surface note token; `null` for a workspace-native token. The realtime backend uses this to route note reads/saves to the File Share-specific endpoints rather than the workspace-native ones. | **Error Responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1650 (Authentication Invalid)` | 401 | Missing Authorization header, or the token is invalid, expired, wrong-scope, wrong-audience, or malformed | **Notes:** - Only validates tokens with the `realtime-note` scope; any other token is rejected. - Validation is performed using the token alone — signature, expiry, scope, audience, and the perm/capability binding are all checked. > Part of the Fastio API Reference. Overview: https://api.fast.io/current/llms/ # Comments API Base URL: `https://api.fast.io/current/` Auth: All endpoints require `Authorization: Bearer {jwt_token}`. Response format: JSON (standard envelope: `result` and data fields at the root). Content-Type: Comments use **JSON request bodies** (`Content-Type: application/json`), unlike most other Fastio endpoints which use `application/x-www-form-urlencoded`. --- ## Endpoint Summary | Method | Path | Description | |--------|------|-------------| | GET | `/current/comments/{entity_type}/{parent_id}/` | List all comments in a workspace or share | | GET | `/current/comments/{entity_type}/{parent_id}/{node_id}/` | List comments for a specific node | | POST | `/current/comments/{entity_type}/{parent_id}/` | Create or update a comment on an entity | | POST | `/current/comments/{entity_type}/{parent_id}/{node_id}/` | Create or update a comment on a node | | GET | `/current/comments/fileshare/{fileshare_id}/{node_id}/` | List a File Share recipient's comments on a node (see File Share Comments) | | POST | `/current/comments/fileshare/{fileshare_id}/{node_id}/` | Create or update a comment as a File Share recipient | | POST | `/current/comments/{comment_id}/update/` | Edit a comment by ID | | GET | `/current/comments/{comment_id}/details/` | Get a single comment's details | | DELETE | `/current/comments/{comment_id}/delete/` | Soft-delete a comment and its replies | | POST | `/current/comments/bulk/delete/` | Bulk soft-delete multiple comments | | POST | `/current/comments/{comment_id}/reactions/` | Add or change an emoji reaction | | DELETE | `/current/comments/{comment_id}/reactions/` | Remove an emoji reaction | | GET | `/current/comments/{comment_id}/attachments/` | List a comment's attachments (hydrated, access-gated) | | POST | `/current/comments/{comment_id}/attachments/` | Attach one or many objects to a comment | | POST | `/current/comments/{comment_id}/attachments/detach/` | Detach an object from a comment | | POST | `/current/comments/{comment_id}/unlink/` | Unlink a comment from the entity it was linked to | > **Searching comments:** To search comments by keyword, use the unified search > endpoint — `GET /current/workspace/{workspace_id}/search/` (or > `/current/share/{share_id}/search/`) returns a `comments` bucket alongside > files and other types, each with its own pagination. See *Unified Search* in > the Storage Operations reference. Comment search results are permission-filtered > to comments the caller can see. --- ## Path Parameters Comments are scoped to a workspace or share, and optionally to a specific node (file or folder) within it. When no node ID is specified, all comments across the entire workspace or share are returned. | Parameter | Type | Format | Description | |-----------|------|--------|-------------| | `{entity_type}` | string | `"workspace"`, `"share"`, or `"fileshare"` | Entity type discriminator. For `"fileshare"` the `{node_id}` segment is **required** (see *File Share Comments*) | | `{parent_id}` | string | 19-digit numeric (a File Share also accepts its opaque `id_alt`) | Workspace, share, or File Share ID. **Note:** Do not confuse with `parent_id` in the POST request body, which is the parent *comment* ID for threading | | `{node_id}` | string | Alphanumeric opaque ID | File or folder ID within the entity (optional for workspace/share, required for `fileshare`) | | `{comment_id}` | string | Alphanumeric opaque ID | Comment identifier | --- ## Comment Object Every comment returned by the API has this structure: | Field | Type | Description | |-------|------|-------------| | `id` | string | Alphanumeric opaque ID of the comment | | `entity` | string | Opaque ID of the entity the comment belongs to (workspace, share, or node) | | `user_id` | string | 19-digit numeric ID of the comment author's account. Always present — File Share recipients comment as signed-in accounts too | | `actor` | object | Who wrote the comment and whether an agent acted for them: `user_id`, `kind` (`human`, `agent`, `api_key`, `app`, `system`, `unknown`), `agent_name`, `name_source`, `credential_type`, `verified`. `verified` is `true` only for Fastio's own built-in agent; any other `agent_name` is **self-declared** -- display it (e.g. "Dobby (via API key) for Derek"), never treat it as verified. Comments created before actor attribution existed report `kind: "unknown"` credited to the comment's author, unless an earlier record names that same author, and are never `verified`. Full reference: *Actor Attribution* in the Storage reference | | `parent_id` | string\|null | Opaque ID of the parent comment (for replies), or null for top-level | | `body` | string | Comment text content (may include mention markup) | | `profile_type` | string\|null | The container type (`"workspace"` or `"share"`) that owns the entity | | `profile_id` | string\|null | 19-digit numeric ID of the workspace or share that owns the entity | | `scope_id` | string\|null | When a comment originated under a File Share and is surfaced in a workspace context, the 19-digit numeric ID of that File Share — a present (non-null) value marks the comment as "via File Share". Null/omitted for comments that originated directly in the workspace or share | | `linked_entity_type` | string\|null | Type of the entity the comment is linked to (see Unlink Comment), or null when unlinked. New comments are never linked | | `linked_entity_id` | string\|null | Opaque ID of the linked entity, or null | | `external_author_email` | string\|null | Email of an external author on a legacy externally-authored comment; otherwise omitted/null | | `external_author_name` | string\|null | Display name of the external author, when present; otherwise omitted/null | | `external_author_token_id` | string\|null | Opaque ID of the access token the external author used, when present; otherwise omitted/null | | `reference` | object\|null | Anchoring reference to a position in a file (see Reference Anchoring) | | `mentions` | array | The server-validated set of mentioned profile IDs that took effect (19-digit numeric strings). Always present; empty when no mention took effect. This is the authoritative took-effect set — mentions in the body markup that fail validation (e.g. a non-member) are excluded here | | `reactions` | object | Map of emoji character to total reaction count (e.g., `{"👍": 3}`). With no reactions it serializes as an empty array `[]`, not `{}` | | `user_reaction` | string\|null | The current authenticated user's emoji reaction, or null | | `version_hash` | string\|null | Hash representing the current comment content version | | `version` | integer | Monotonically increasing version counter (starts at 1) | | `attachment_count` | integer | Number of objects attached to the comment (see Comment Attachments). Present on comment **list** rows and the comment **detail** response | | `attachments` | array | Hydrated, access-gated attachment rows (see Comment Attachments). **Detail endpoint only** | | `edited_at` | string\|null | Timestamp of last edit if the comment has been edited (`YYYY-MM-DD HH:MM:SS UTC`), or null | | `can_edit` | boolean | Whether the requesting user can edit this comment | | `can_delete` | boolean | Whether the requesting user can delete this comment | | `can_reply` | boolean | Whether the comment can be replied to (top-level only) | | `created` | string | Creation timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `updated` | string | Last-updated timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `deleted` | string\|null | Deletion timestamp (`YYYY-MM-DD HH:MM:SS UTC`), or null if active. Included only on the details and delete responses — list rows never carry it, even with `include_deleted=true` | **Null fields are omitted on most responses.** On the list, create/update, update-by-ID and delete responses, a field whose value is null is **left out** rather than sent as `null` — `parent_id`, `profile_type`, `profile_id`, `scope_id`, `linked_entity_*`, `external_author_*`, `reference`, `user_reaction`, `version_hash` and `edited_at`. The details and unlink responses send them as `null`. Treat a missing key and `null` the same. Note: the server-validated `mentions` set is surfaced as the top-level `mentions` array described above. The raw internal `properties` object (threading, pinning, content-filter flags, etc.) is never returned — only the curated `mentions` list is exposed. --- ## List Comments ### `GET /current/comments/{entity_type}/{parent_id}/` ### `GET /current/comments/{entity_type}/{parent_id}/{node_id}/` Without `{node_id}`: returns all comments across the entire workspace or share. With `{node_id}`: returns only comments on that specific file or folder. **Auth:** Required (JWT). Rate limited. **Query Parameters:** | Parameter | Type | Required | Default | Constraints | Description | |-----------|------|----------|---------|-------------|-------------| | `sort` | string | No | `asc` | `"asc"` or `"desc"` | Sort order by creation time | | `limit` | integer | No | -- | Min: 2, Max: 200 | Number of comments to return | | `offset` | integer | No | `0` | Min: 0 | Number of comments to skip | | `page` | integer | No | -- | Min: 1 | Page number (alternative to offset-based pagination) | | `include_deleted` | boolean | No | `false` | -- | Include soft-deleted comments in results | | `reference_type` | string | No | -- | -- | Filter by reference anchor type: `"video"`, `"image"`, `"document"`, `"general"`, or `"audio"` (see Reference Anchoring) | | `include_total` | boolean | No | `false` | -- | Include total count and pagination metadata in response | **Request Example:** ```bash curl -X GET "https://api.fast.io/current/comments/workspace/1234567890123456789/?limit=50&include_total=true" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "comments": [ { "id": "abc123opaqueid", "entity": "xyz789opaqueid", "user_id": "1234567890123456789", "actor": { "user_id": "1234567890123456789", "kind": "human", "agent_name": null, "name_source": null, "credential_type": "session", "verified": false }, "body": "This looks great!", "profile_type": "workspace", "profile_id": "1234567890123456789", "mentions": [], "reactions": {"👍": 2, "❤️": 1}, "user_reaction": "👍", "version": 1, "created": "2025-01-15 10:30:00 UTC", "updated": "2025-01-15 10:30:00 UTC", "can_edit": true, "can_delete": true, "can_reply": true, "attachment_count": 0 } ], "count": 1, "allowed": true, "remaining": 95, "total": 5, "limit": 50, "offset": 0 } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `comments` | array | Array of comment objects | | `count` | int | Number of comments in this response | | `allowed` | bool | Whether the current user can post new comments (based on plan limits) | | `remaining` | int | Remaining comments allowed under plan limit (only present if plan has a limit) | | `total` | int | Total number of comments (only if `include_total=true`) | | `limit` | int | Limit used in query (only if `include_total=true`) | | `offset` | int | Offset used in query (only if `include_total=true`) | **Access Levels:** | Role | Access | |------|--------| | Workspace Owner/Member | Full access -- sees all comments | | Share Owner | Full access -- sees all comments | | Share Guest | Filtered -- sees own comments; visibility of owner and other guest comments depends on share permissions | **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. Codes shown as five or six digits (and the `9661`-`9669` family) ARE `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 | "Invalid API format..." | Missing entity type or parent ID | | `1605 (Invalid Input)` | 406 | "Entity type must be \"workspace\", \"share\", or \"fileshare\"" | Invalid entity type | | `1680 (Access Denied)` | 401 | "Invalid entity or insufficient permissions" | User lacks access | | `1609 (Not Found)` | 404 | "Invalid entity or insufficient permissions" | Entity not found | | `1610 (Internal Error)` | 500 | "Failed to retrieve comments" | Internal error | **Notes:** - **Scope-level queries:** When called without a `{node_id}`, this endpoint returns comments from all files and folders within the workspace or share. Only comments created after 2026-03-15 are included in scope-level results; older comments are accessible via the node-level endpoint. - For share entities, comments are filtered based on the user's share permissions. - **Workspace node reads include File Share comments.** A file shared through a File Share is the same node that lives in the owning workspace, so a workspace node read (`GET /current/comments/workspace/{workspace_id}/{node_id}/`) also returns comments left by File Share recipients on that file. Those File Share comments appear **read-only** in the workspace view: `can_reply`, `can_edit`, and `can_delete` are `false` (replies and edits to a File Share comment go through the File Share's own surface). The `total` count reflects everything visible in the listing (the workspace's own comments plus the File Share comments), while the `allowed` / `remaining` fields reflect only the workspace's own writable comments — so `remaining` is not reduced by File Share comments you cannot edit. This inclusion is **one-directional**: workspace members see File Share comments, but File Share recipients never see the workspace's internal comments. --- ## Create or Update Comment ### `POST /current/comments/{entity_type}/{parent_id}/` ### `POST /current/comments/{entity_type}/{parent_id}/{node_id}/` Create a new comment or update an existing one. **Auth:** Required (JWT). Rate limited. **Content-Type:** `application/json` **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `body` | string | Yes | 1-8192 UTF-8 characters (raw, not bytes); display text excluding mentions max 500 UTF-8 characters — a mention inside markdown code counts in full (see Mentions). Over-limit is rejected, never truncated | Comment text content | | `comment_id` | string | No | Valid opaque ID | Include to update an existing comment; omit to create new | | `parent_id` | string | No | Valid opaque ID | Parent comment ID for threaded reply (single-level only). **Note:** This is the parent *comment's* opaque ID, not the workspace/share ID from the URL path `{parent_id}` | | `properties` | object | No | -- | Arbitrary key-value metadata to attach | | `reference` | object | No | See Reference Anchoring | Anchoring reference to a position in a file | | `target_id` | string | No | Valid object ID | Inline attachment on a **new** comment — a single object to attach (see Comment Attachments). Ignored on update | | `target_ids` | array\ | No | Non-empty array | Inline attachments on a **new** comment — multiple objects to attach, up to 25 per comment (see Comment Attachments). Ignored on update | **Request Example -- Create a new comment:** ```bash curl -X POST "https://api.fast.io/current/comments/workspace/1234567890123456789/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{ "body": "Great work on this document!" }' ``` **Request Example -- Reply to a comment:** ```bash curl -X POST "https://api.fast.io/current/comments/workspace/1234567890123456789/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{ "body": "Thanks for the feedback!", "parent_id": "abc123opaqueid" }' ``` **Request Example -- Comment with mention and page reference:** ```bash curl -X POST "https://api.fast.io/current/comments/workspace/1234567890123456789/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{ "body": "Hey @[user:9876543210987654321:Jane Smith], can you review page 3?", "reference": {"type": "document", "page": 3} }' ``` **Request Example -- Update an existing comment:** ```bash curl -X POST "https://api.fast.io/current/comments/workspace/1234567890123456789/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{ "comment_id": "def456opaqueid", "body": "Updated: This looks great after the revision." }' ``` **Response:** ```json { "result": true, "comment": { "id": "abc123opaqueid", "entity": "xyz789opaqueid", "user_id": "1234567890123456789", "actor": { "user_id": "1234567890123456789", "kind": "agent", "agent_name": "Dobby", "name_source": "api_key_label", "credential_type": "api_key", "verified": false }, "body": "Great work on this document!", "profile_type": "workspace", "profile_id": "1234567890123456789", "mentions": [], "reactions": [], "version": 1, "created": "2025-01-15 10:30:00 UTC", "updated": "2025-01-15 10:30:00 UTC", "can_edit": true, "can_delete": true, "can_reply": true }, "attachments": [], "attachment_count": 0 } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `comment` | object | The created or updated comment object (full schema above) | | `attachments` | array | **Create only** (omitted on an update). The hydrated attachments written with the new comment, returned at the top level beside `comment`, not inside it | | `attachment_count` | integer | **Create only** (omitted on an update). Number of objects attached to the new comment, at the top level beside `comment` | **Access Levels:** | Role | Access | |------|--------| | Workspace Owner/Member | Can create and edit own comments | | Share Owner | Can create and edit own comments | | Share Guest | Can only post if comments are enabled on the share | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1680 (Access Denied)` | 401 | "Authentication required to post comments" | User not authenticated | | `1605 (Invalid Input)` | 406 | "Invalid JSON in request body" | Malformed JSON | | `1605 (Invalid Input)` | 406 | "Comment body must be between 1 and 8192 characters" | Body length out of range | | `1605 (Invalid Input)` | 406 | "Comment text (excluding mentions) must not exceed 500 characters" | Display text too long — note that a mention inside markdown code counts in full (see Mentions) | | `1605 (Invalid Input)` | 406 | "Your comment appears to contain spam content..." | Spam detected | | `1605 (Invalid Input)` | 406 | "Invalid parent comment ID format" | Malformed parent_id | | `1609 (Not Found)` | 404 | "Parent comment not found" | Referenced parent does not exist | | `1609 (Not Found)` | 404 | "Parent comment not found" | Parent is on a different entity or scope (reported the same as a missing parent) | | `1605 (Invalid Input)` | 406 | "Invalid reference data: ..." | Reference failed validation | | `1605 (Invalid Input)` | 406 | "Comment limit exceeded for your plan" | Plan comment limit reached | | `1609 (Not Found)` | 404 | "Comment not found" | Comment ID for update not found | | `1680 (Access Denied)` | 401 | "You do not have permission to edit this comment" | User is not the comment author | | `1610 (Internal Error)` | 500 | "Failed to save comment" | Internal error | **Notes:** - **Body fidelity:** The body is trimmed of leading and trailing whitespace and then stored unmodified — HTML is not stripped or escaped — so code, generic type parameters (`Array`), path templates (``), and structured data such as `data: {count: 5}` survive a round trip intact. Treat a returned `body` as untrusted input and escape it in your own client before rendering it as HTML. - **Content filtering:** No profanity, spam, or PII filtering is applied to bodies today. The spam-rejection response documented under Content Filtering remains part of this endpoint's contract and is not currently triggered. - **Mentions:** Mentioned users are parsed and validated against entity membership before the comment is saved. A mention that sits inside markdown code is treated as quoted text — it notifies nobody and counts in full against the 500-character display cap (see *Mentions inside code*). - **Plan limits:** Number of comments per entity is limited by the organization's plan. - **Updating:** Only the original author can edit. Editing populates the top-level `edited_at` timestamp on the returned comment. --- ## File Share Comments ### `GET /current/comments/fileshare/{fileshare_id}/{node_id}/` ### `POST /current/comments/fileshare/{fileshare_id}/{node_id}/` `fileshare` is a third `{entity_type}` on the comment surface, used by **File Share recipients** to read and post comments on a file inside a File Share. It shares the request/response shapes of the workspace/share List and Create endpoints above — the only differences are the `fileshare` discriminator and that the `{node_id}` segment is **required** (a File Share comment always targets a specific node, never the whole container). **Auth:** Required. The caller must have access to the File Share (a signed-in recipient); anonymous callers are denied. Comments left here are the File Share recipient's own comments on the node. **Path Parameters:** | Parameter | Type | Format | Description | |-----------|------|--------|-------------| | `{fileshare_id}` | string | Opaque `id_alt` (preferred) or 19-digit numeric | File Share ID: the File Share's `id_alt` (the id in its share link), or its legacy numeric profile ID | | `{node_id}` | string | Alphanumeric opaque ID | The file or folder node within the File Share. **Required** | **Request Example -- list:** ```bash curl -X GET "https://api.fast.io/current/comments/fileshare/adheih5r326qjiqvk4wfamvt4rqeh/23d4zvoprfmonyhy2wwjpoccqjaiz/" \ -H "Authorization: Bearer {jwt_token}" ``` **Request Example -- create:** ```bash curl -X POST "https://api.fast.io/current/comments/fileshare/adheih5r326qjiqvk4wfamvt4rqeh/23d4zvoprfmonyhy2wwjpoccqjaiz/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"body": "Received — thanks for sending this over."}' ``` The list and create responses use the same envelope and Comment Object schema as the entity-scoped endpoints above. A File Share comment carries the File Share's ID in `scope_id`; `user_id` is the recipient's account ID. **Notes:** - File Share comments are visible **read-only** in the owning workspace's node thread (`can_reply` / `can_edit` / `can_delete` are `false` there); replies and edits happen through this File Share surface. See the *List Comments* notes for the one-directional visibility rules. - The invalid-`entity_type` error message for the whole comment surface is `Entity type must be "workspace", "share", or "fileshare"`. **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Entity type must be \"workspace\", \"share\", or \"fileshare\"" | Invalid entity type | | `1680 (Access Denied)` | 401 | "Invalid entity or insufficient permissions" | Caller cannot access the File Share | | `1609 (Not Found)` | 404 | "Invalid entity or insufficient permissions" | File Share or node not found | --- ## Update Comment by ID ### `POST /current/comments/{comment_id}/update/` Edit an existing comment by its ID. Author-only. Works for **every** comment surface — workspace, share, node, and File Share. **Auth:** Required (JWT). Rate limited. **Content-Type:** `application/json` **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `body` | string | Yes | 1-8192 UTF-8 characters (raw, not bytes); display text excluding mentions max 500 UTF-8 characters — a mention inside markdown code counts in full (see Mentions). Over-limit is rejected, never truncated | Replacement comment text | | `properties` | object | No | -- | Arbitrary key-value metadata to merge | | `reference` | object | No | See Reference Anchoring | Replacement anchoring reference | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/comments/abc123opaqueid/update/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{ "body": "Updated: this looks great after the revision." }' ``` **Response:** the full updated comment object (same shape as Create or Update Comment above), with `edited_at` populated. **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Comment body must be between 1 and 8192 characters" | Body length out of range | | `1605 (Invalid Input)` | 406 | "Comment is deleted" | The comment has been deleted | | `1609 (Not Found)` | 404 | "Comment not found" | Unknown comment ID, or the comment's surface is not visible to you | | `1680 (Access Denied)` | 401 | "You do not have permission to edit this comment" | Caller is not the comment author | **Notes:** - Only the original author can edit; mentions are re-validated from the edited body and the comment's `edited_at` timestamp is populated. - An edit can never move the comment: its entity, scope, and threading are immutable here. - Server-managed `properties` keys (`reactions`, `version`, `version_hash`, `edited_at`, `content_filtered`, `mentions`, `actor`) supplied in the request are ignored — they are written only by the server (`actor` is set from the credential that made the request). --- ## Get Comment Details ### `GET /current/comments/{comment_id}/details/` Get a single comment's full details. **Auth:** Required (JWT). Rate limited. **Request Example:** ```bash curl -X GET "https://api.fast.io/current/comments/abc123opaqueid/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "comment": { "id": "abc123opaqueid", "entity": "xyz789opaqueid", "user_id": "1234567890123456789", "actor": { "user_id": "1234567890123456789", "kind": "human", "agent_name": null, "name_source": null, "credential_type": "session", "verified": false }, "parent_id": null, "body": "This looks great!", "profile_type": "workspace", "profile_id": "1234567890123456789", "scope_id": null, "linked_entity_type": null, "linked_entity_id": null, "external_author_email": null, "external_author_name": null, "external_author_token_id": null, "reference": null, "mentions": [], "reactions": [], "user_reaction": null, "version_hash": null, "version": 1, "edited_at": null, "created": "2025-01-15 10:30:00 UTC", "updated": "2025-01-15 10:30:00 UTC", "deleted": null, "can_edit": true, "can_delete": true, "can_reply": true, "attachment_count": 0, "attachments": [] } } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Comment ID is required" | Missing comment ID | | `1605 (Invalid Input)` | 406 | "Invalid comment ID format" | Malformed opaque ID | | `1609 (Not Found)` | 404 | "Comment not found" | Comment does not exist | | `1680 (Access Denied)` | 401 | "You do not have permission to view this comment" | User lacks access to the entity | --- ## Delete Comment ### `DELETE /current/comments/{comment_id}/delete/` Soft-delete a comment. **Recursive** -- also deletes all replies to this comment. **Who can delete:** the comment's author, and workspace/share **admins or owners** (moderation — admins can remove any comment on their workspace or share). File Share comments can only be deleted by their author; they are never moderator-deletable, including from the workspace view. The `can_delete` flag on every returned comment reflects this, so clients should drive the delete affordance from `can_delete`. **Auth:** Required (JWT). Rate limited. **Request Example:** ```bash curl -X DELETE "https://api.fast.io/current/comments/abc123opaqueid/delete/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "message": "Comment deleted successfully", "comment": { "id": "abc123opaqueid", "entity": "xyz789opaqueid", "user_id": "1234567890123456789", "actor": { "user_id": "1234567890123456789", "kind": "human", "agent_name": null, "name_source": null, "credential_type": "session", "verified": false }, "body": "This looks great!", "profile_type": "workspace", "profile_id": "1234567890123456789", "mentions": [], "reactions": [], "version": 1, "created": "2025-01-15 10:30:00 UTC", "updated": "2025-01-16 08:00:00 UTC", "deleted": "2025-01-16 08:00:00 UTC", "can_edit": false, "can_delete": false, "can_reply": true } } ``` **Access Levels:** | Role | Access | |------|--------| | Comment Author | Can delete own comments | | Workspace/Share Owner or Admin | Can delete any comment on their workspace or share (moderation), except File Share comments | | Other Users | Denied | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Comment ID is required" | Missing comment ID | | `1605 (Invalid Input)` | 406 | "Invalid comment ID format" | Malformed opaque ID | | `1609 (Not Found)` | 404 | "Comment not found" | Comment does not exist | | `1605 (Invalid Input)` | 406 | "Comment is already deleted" | Comment was previously deleted | | `1680 (Access Denied)` | 401 | "You do not have permission to delete this comment" | Not author, entity owner, or admin | | `1610 (Internal Error)` | 500 | "Failed to delete comment" | Internal error | --- ## Bulk Delete Comments ### `POST /current/comments/bulk/delete/` Bulk soft-delete multiple comments. Each comment is processed independently -- partial success is possible. **NOT recursive** -- does not delete replies. ⚠️ **Per-item isolation holds only when every element of `comment_ids` is a STRING.** A non-string element (a JSON number, boolean, or `null`) is not isolated to its own result entry -- it aborts the entire batch. **The abort is not a rollback:** ids are processed in array order and each delete is committed as it is made, so every id BEFORE the offending element is already deleted and stays deleted. Processing stops at the offending element, and you get a generic `500` instead of the per-item `results` report -- so you cannot tell from the response which ids went through. Nothing is deleted only when the offending element is the first one. Serialise every id as a JSON string. **Auth:** Required (JWT). Rate limited. **Content-Type:** `application/json` **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `comment_ids` | array\ | Yes | Max 100 items | Array of comment opaque IDs to delete | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/comments/bulk/delete/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"comment_ids": ["abc123opaqueid", "def456opaqueid", "ghi789opaqueid"]}' ``` **Response:** ```json { "result": true, "success": true, "deleted_count": 3, "failed_count": 0, "total_count": 3, "results": { "abc123opaqueid": {"id": "abc123opaqueid", "success": true, "error": null}, "def456opaqueid": {"id": "def456opaqueid", "success": true, "error": null}, "ghi789opaqueid": {"id": "ghi789opaqueid", "success": true, "error": null} } } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `success` | bool | `true` only if all comments were deleted (`failed_count === 0`) | | `deleted_count` | int | Number of successfully deleted comments | | `failed_count` | int | Number that failed to delete | | `total_count` | int | Total number of IDs provided | | `results` | object | Per-comment results keyed by comment ID | | `results.{id}.success` | bool | Whether this comment was deleted | | `results.{id}.error` | string\|null | Error message if failed, null on success | **Per-Comment Error Messages:** | Error | Cause | |-------|-------| | `"Invalid comment ID format"` | Not a valid opaque ID | | `"Comment not found"` | No comment with this ID | | `"Comment already deleted"` | Previously soft-deleted | | `"Permission denied"` | User is not author, entity owner, or admin | | `"Failed to delete comment"` | Internal error | | `"Processing error"` | Unhandled error while processing this comment | **Request-Level Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Invalid JSON request body" | Malformed JSON | | `1605 (Invalid Input)` | 406 | "comment_ids array is required" | Missing or non-array field | | `1605 (Invalid Input)` | 406 | "comment_ids array cannot be empty" | Empty array | | `1605 (Invalid Input)` | 406 | "Maximum 100 comments can be deleted at once" | Exceeds bulk limit | **Important:** Unlike single delete, bulk delete does **not** recursively delete replies. --- ## Add or Change Reaction ### `POST /current/comments/{comment_id}/reactions/` Add an emoji reaction to a comment. One reaction per user per comment -- sending a new reaction replaces any previous one. **Auth:** Required (JWT). Rate limited. **Content-Type:** `application/json` **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `emoji` | string | Yes | Single emoji character; max 2 UTF-8 characters; must match Unicode emoji ranges | Emoji to react with | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/comments/abc123opaqueid/reactions/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"emoji": "👍"}' ``` **Response:** ```json { "result": true, "reactions": {"👍": 3, "❤️": 1}, "user_reaction": "👍" } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `reactions` | object | Map of emoji to total reaction count across all users (`[]` when there are none) | | `user_reaction` | string\|null | The current user's active reaction emoji | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Comment ID is required" | Missing comment ID | | `1605 (Invalid Input)` | 406 | "Invalid comment ID format" | Malformed opaque ID | | `1609 (Not Found)` | 404 | "Comment not found" | Comment does not exist or is deleted | | `1680 (Access Denied)` | 401 | "You do not have permission to react to this comment" | User lacks entity access | | `1605 (Invalid Input)` | 406 | "emoji parameter is required" | Missing or non-string emoji | | `1605 (Invalid Input)` | 406 | "Invalid emoji character" | Does not match Unicode emoji pattern | | `1605 (Invalid Input)` | 406 | "Only single emoji allowed" | String exceeds 2 UTF-8 characters | | `1610 (Internal Error)` | 500 | "Failed to save reaction" | Internal error | --- ## Remove Reaction ### `DELETE /current/comments/{comment_id}/reactions/` Remove an emoji reaction from a comment. **Auth:** Required (JWT). Rate limited. **Content-Type:** `application/json` **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `emoji` | string | No | Must match Unicode emoji ranges if provided | Specific emoji to remove. Omit to remove any reaction by the current user | **Request Example -- Remove specific emoji:** ```bash curl -X DELETE "https://api.fast.io/current/comments/abc123opaqueid/reactions/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"emoji": "👍"}' ``` **Request Example -- Remove any reaction:** ```bash curl -X DELETE "https://api.fast.io/current/comments/abc123opaqueid/reactions/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{}' ``` **Response:** ```json { "result": true, "reactions": {"❤️": 1}, "user_reaction": null } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `reactions` | object | Updated map of emoji to total reaction count (`[]` when none remain) | | `user_reaction` | string\|null | Current user's reaction after removal (null if removed) | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Comment ID is required" | Missing comment ID | | `1605 (Invalid Input)` | 406 | "Invalid comment ID format" | Malformed opaque ID | | `1609 (Not Found)` | 404 | "Comment not found" | Comment does not exist or is deleted | | `1680 (Access Denied)` | 401 | "You do not have permission to react to this comment" | User lacks entity access | | `1605 (Invalid Input)` | 406 | "emoji must be a string" | Non-string emoji parameter | | `1605 (Invalid Input)` | 406 | "Invalid emoji character" | Does not match Unicode emoji pattern | | `1610 (Internal Error)` | 500 | "Failed to remove reaction" | Internal error | **Notes:** - Removing a reaction that does not exist returns success (idempotent). - A "removed" event is triggered only when a reaction was actually removed. --- ## Unlink Comment ### `POST /current/comments/{comment_id}/unlink/` Clear a comment's link to the entity it was linked to. The comment itself is neither deleted nor moved — only its link is cleared. **Auth:** Required (JWT). Rate limited. Authorization re-runs against the comment's **own scope** — the workspace, share, or File Share that owns it — not against whatever context you are calling from. Any caller who may write comments on that scope may unlink; unlike editing, this is **not** restricted to the comment's author. **Request body (JSON, optional):** | Field | Type | Required | Description | |-------|------|----------|-------------| | `entity_id` | string | No | The entity the comment was linked to. Supply it to make a repeat call **idempotent**: if the comment is already unlinked, the call returns success instead of the "not linked" error below. Without it, unlinking an already-unlinked comment is an error. | **Response:** the updated comment, in the standard shape (see *Comment Object*), with its link cleared. **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `152022` | 406 | "Comment ID is required" | `{comment_id}` missing | | `176691` | 406 | "Invalid comment ID format" | `{comment_id}` is not a valid opaque ID | | `123525`, `100562` or `148282` | 404 | "Comment not found" | Unknown comment ID — distinct load checks, each with its own code | | `148282` | 406 | "Comment is not linked to any entity" | Nothing to clear, and no `entity_id` was sent | | varies per call site — read `error.code` from the response | 401, or 404 when the scope hides the comment | "You do not have permission to unlink this comment", or "Comment not found" | The access check on the comment's own scope refused. The code is assigned by whichever check failed, and the status follows that check's category | | `148282` or `151137` | 500 | "Failed to unlink comment" | Internal error — two distinct failure paths | **Notes:** - A comment-unlinked event fires only when the comment actually had a link to clear. --- ## Comment Attachments Attach arbitrary platform objects to a comment to give it context — a file or folder, a sign envelope, a share, a File Share, or a workspace. An attachment is a lightweight **reference**: it records what is attached, not a copy of it. - A comment can hold up to **25** attachments. - Attaching is **idempotent** — re-attaching an object already attached is a no-op and does not count against the cap. - Attaching does **not** verify the target exists or is accessible; display names are resolved (and access-gated) on read. - **Attaching/detaching is author-only** — only the comment's author can add or remove attachments (a moderator who can delete a comment cannot mutate its attachments). - A soft-deleted comment returns `404`. ### Attachment Object (hydrated) Every attachment returned by the API is hydrated and **access-gated to the caller**: an object the caller cannot see (or that no longer exists) is returned with `available: false` and `display_name: null` rather than leaking its name. | Field | Type | Description | |-------|------|-------------| | `target_id` | string | The attached object's ID (19-digit numeric for a workspace/share/envelope/File Share, or an opaque ID for a file/folder node) | | `target_type` | string | Coarse object type: `"node"`, `"envelope"`, `"share"`, `"workspace"`, or `"fileshare"` | | `kind` | string | Alias of `target_type` (same value) | | `display_name` | string\|null | The object's resolved display name, or `null` when the caller cannot see it or it has no name | | `available` | bool | `true` when the caller can access the object (and `display_name` is authoritative); `false` when it is inaccessible, missing, or could not be resolved | > **Render defensively:** when `available` is `false`, never show a name — show a generic placeholder, and never assume `display_name` is non-null. ### List Comment Attachments #### `GET /current/comments/{comment_id}/attachments/` List all objects attached to a comment, hydrated and access-gated to the caller. **Auth:** Required (JWT). Rate limited. **Response:** ```json { "result": true, "comment_id": "abc123opaqueid", "attachments": [ { "target_id": "23d4zvoprfmonyhy2wwjpoccqjaiz", "target_type": "node", "kind": "node", "display_name": "spec.pdf", "available": true }, { "target_id": "9876543210987654321", "target_type": "share", "kind": "share", "display_name": null, "available": false } ], "count": 2 } ``` ### Attach Objects to a Comment #### `POST /current/comments/{comment_id}/attachments/` Attach one object (`target_id`) or many (`target_ids`) to a comment. Idempotent (already-attached objects are skipped); the new objects are committed atomically (a partial-batch failure attaches nothing). Returns the full, updated hydrated attachment list. **Auth:** Required (JWT). Rate limited. **Content-Type:** `application/json` **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `target_id` | string | One of `target_id` / `target_ids` is required | Valid object ID | A single object to attach | | `target_ids` | array\ | One of `target_id` / `target_ids` is required | Non-empty array | Multiple objects to attach | A request whose new attachments would push the comment over the 25-attachment cap is rejected before anything is written. **Request Example -- single:** ```bash curl -X POST "https://api.fast.io/current/comments/abc123opaqueid/attachments/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"target_id": "23d4zvoprfmonyhy2wwjpoccqjaiz"}' ``` **Request Example -- bulk:** ```bash curl -X POST "https://api.fast.io/current/comments/abc123opaqueid/attachments/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"target_ids": ["23d4zvoprfmonyhy2wwjpoccqjaiz", "9876543210987654321"]}' ``` **Response:** ```json { "result": true, "comment_id": "abc123opaqueid", "attached": 1, "attachments": [ { "target_id": "23d4zvoprfmonyhy2wwjpoccqjaiz", "target_type": "node", "kind": "node", "display_name": "spec.pdf", "available": true } ], "count": 1 } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `comment_id` | string | The comment the attachments belong to | | `attached` | int | Number of objects newly attached by this request (0 when every target was already attached) | | `attachments` | array | The full hydrated attachment list after the operation | | `count` | int | Number of attachments in the list | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "A target_id or non-empty target_ids array is required" | No target supplied | | `143686` | 406 | "target_ids exceeds the maximum allowed input size of 200" | More than 200 raw entries supplied. This is a raw-input bound, separate from -- and looser than -- the 25-attachments-per-comment cap, which is applied afterwards to the deduplicated set | | `1605 (Invalid Input)` | 406 | "Invalid attachment target" | A target ID could not be decoded | | `1605 (Invalid Input)` | 406 | "Attachment limit reached (maximum 25 per comment)" | The new attachments would exceed the cap | | `1680 (Access Denied)` | 401 | "You do not have permission to modify attachments on this comment" | Caller is not the comment author | | `1609 (Not Found)` | 404 | "Comment not found" | Comment does not exist, is not visible to you, or is trashed | ### Detach an Object from a Comment #### `POST /current/comments/{comment_id}/attachments/detach/` Remove an attachment by its `target_id`. Idempotent — detaching an object that is not attached returns success with `removed: false`. Returns the updated hydrated attachment list. **Auth:** Required (JWT). Rate limited. **Content-Type:** `application/json` **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `target_id` | string | Yes | Valid object ID | The object to detach | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/comments/abc123opaqueid/attachments/detach/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"target_id": "23d4zvoprfmonyhy2wwjpoccqjaiz"}' ``` **Response:** ```json { "result": true, "comment_id": "abc123opaqueid", "removed": true, "attachments": [], "count": 0 } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `comment_id` | string | The comment the attachment belonged to | | `removed` | bool | `true` if an attachment was actually removed; `false` if the object was not attached | | `attachments` | array | The full hydrated attachment list after the operation | | `count` | int | Number of attachments remaining | ### Attaching at comment-create time (inline) You can attach objects when creating a comment by including `target_id` or `target_ids` in the comment-create body (see Create or Update Comment). Inline attachments apply **only to a new comment** — they are ignored on an update, and the comment plus its attachments are written together so the comment never appears without them. An invalid target or one that would exceed the cap rejects the whole create. ### Counts on reads `attachment_count` is returned on every **comment list** row and on the **comment detail** response. The hydrated `attachments` array is returned **only** on the comment detail response (`GET /current/comments/{comment_id}/details/`). All counts are computed live on each read. --- ## Threading Comments support **single-level threading only**. - Set `parent_id` in the request body to the opaque ID of the comment you are replying to. This is distinct from `{parent_id}` in the URL path, which identifies the workspace or share container. - Replies to a top-level comment appear as children of that comment. - Replies to a reply are **auto-flattened** -- they become siblings of the original reply (children of the same top-level parent). The API does not support nested threading beyond one level. ``` Comment A (top-level) ├── Comment B (reply to A) ├── Comment C (reply to B → auto-flattened to reply to A) └── Comment D (reply to A) ``` --- ## Mentions Use mention markup in the `body` field to tag other users: ``` @[user:USER_ID:Display Name] ``` | Component | Description | |-----------|-------------| | `user` | Literal prefix (always `user`) | | `USER_ID` | The user's 19-digit numeric profile ID | | `Display Name` | Display name to show in the UI | **Character limit details:** - The **full mention tag markup** (e.g., `@[user:1234567890123456789:Jane Smith]`) counts toward the 8192-character body limit. Both the 8192 raw cap and the 500 display cap count UTF-8 **characters**, not bytes — a CJK or emoji character costs exactly one, the same as an ASCII one. - Display text (excluding all mention markup) is separately limited to 500 characters. A mention inside markdown code is **not** discounted — it counts in full (see *Mentions inside code*, below). - Mentioned users are validated against entity membership at save time. Invalid mentions (users without access to the entity) are dropped silently. **Example body with mentions:** ``` Hey @[user:1234567890123456789:Jane Smith] and @[user:9876543210987654321:Bob Jones], please review this file. ``` ### Mentions inside code **This is a live behaviour change.** A mention that sits inside markdown code — a fenced block, or a single-line inline backtick span — is treated as text the author is **showing**, not as a hail: - It **does not notify** the mentioned user. - It **counts in full** against the 500-character display cap, instead of being discounted as mention markup. Two consequences to plan for: someone who is notified today by a fenced mention **stops being notified**, and a long body that hid mention markup inside a fence **may now be rejected** by the display cap that previously discounted it. **What counts as code — deliberately narrow, and matched per line:** - A **fenced block** opened by a run of three or more backticks or three or more tildes, indented at most 3 spaces, closing on a run of the same character at least as long, alone on its line. An unclosed fence runs to the end of the body. - A **single-line inline backtick span** — a run of N backticks closing on a run of exactly N backticks on the **same** line. **What is NOT recognised as code.** A mention in any of these still notifies, and is still discounted from the display cap, exactly as before: - A fence carrying a blockquote or other container prefix (a `>` marker ahead of the fence characters). - A fence indented four or more spaces. - Indented code blocks generally — four spaces or a tab, with no fence markers at all. - A backtick span whose opening and closing runs sit on different lines. **The bias is deliberate.** Failing to recognise code is safe — it is the behaviour that was already live. Inventing code where there is none would silently drop a real person's notification. When in doubt, it is not code. --- ## Reference Anchoring Comments can be anchored to a specific position within a file using the `reference` object. The **`type`** field is required and selects which additional fields are valid. **`type` values:** | `type` | Applies to | Valid anchor fields | |--------|------------|---------------------| | `"video"` | Video files | `timestamp` (or `timestamp_start` + `timestamp_end`); optional `region` | | `"audio"` | Audio files | `timestamp` (or `timestamp_start` + `timestamp_end`) | | `"document"` | Documents / text | `page`, `text_snippet`, `section`, and the text-anchor fields `exact` / `prefix` / `suffix` / `start_offset` / `end_offset` | | `"image"` | Images | `region` | | `"general"` | Any file | none (a bare `{"type": "general"}` anchor) | **Anchor fields:** | Field | Type | Used with | Description | |-------|------|-----------|-------------| | `timestamp` | number | `video`, `audio` | Position in seconds (non-negative). Mutually exclusive with the range fields | | `timestamp_start` / `timestamp_end` | number | `video`, `audio` | A time range in seconds (both non-negative, `end` > `start`) | | `page` | integer | `document` | Page number (positive integer) | | `text_snippet` | string | `document` | The referenced text passage (max 500 characters) | | `section` | object | `document` | A line/character range: `{"start": {"line", "char"}, "end": {"line", "char"}}`; `line` is a positive integer | | `exact` / `prefix` / `suffix` | string | `document` | Text-selection anchor: the selected text (`exact`, 1-500 characters) plus the text just before and after it (`prefix` / `suffix`, max 100 characters each). `exact` requires both `prefix` and `suffix`, and they require `exact` | | `start_offset` / `end_offset` | integer | `document` | Character offsets of the selection (non-negative; both or neither; `end_offset` > `start_offset`) | | `region` | object | `video`, `image` | Rectangular area with keys `x1`, `y1`, `x2`, `y2`, each a number **0–100** (percent of width/height), with `x2` > `x1` and `y2` > `y1` | Any unrecognized `type` is rejected with `"Invalid reference type: {value}"`. The whole `reference` object, JSON-encoded, may not exceed 2048 bytes. **Example -- Video timestamp:** ```json { "body": "The transition at this point needs work.", "reference": {"type": "video", "timestamp": 42.5} } ``` **Example -- Document page:** ```json { "body": "Typo in the second paragraph.", "reference": {"type": "document", "page": 7} } ``` **Example -- Image region:** ```json { "body": "This area needs higher contrast.", "reference": {"type": "image", "region": {"x1": 20, "y1": 15, "x2": 55, "y2": 40}} } ``` **Example -- Document text snippet:** ```json { "body": "This sentence should be rephrased.", "reference": {"type": "document", "text_snippet": "The quick brown fox jumps over the lazy dog."} } ``` --- ## Content Filtering Comment bodies are stored as sent — the filtering stages below are currently inactive: - **HTML handling:** HTML is **not** stripped or escaped — the body is trimmed of leading and trailing whitespace and then stored unmodified, so code and markup-shaped text survive intact. Escape or sanitize the body in your own client before rendering it as HTML. - **Spam detection:** No spam detection is applied today. The spam-rejection response (`406`) remains part of this endpoint's documented contract and may become active again, so clients should continue to handle it. - **Profanity/PII filtering:** No profanity or PII filtering is applied today; the returned `body` matches what was stored. > Part of the Fastio API Reference. Overview: https://api.fast.io/current/llms/ # Signing / E-Signature API Base URL: `https://api.fast.io/current/` Auth: Two surfaces. - **Sender / admin surface** — `Authorization: Bearer {jwt_token}` (JWT, OAuth, or API key). Signing is enabled on every plan; the org resource exposes `capabilities.signing` (boolean) to confirm availability. - **Signer surface (public)** — signer session token carried in the URL path (`/sign_envelopes/signer/{token}/...`). No Fastio user session is required; the token is bound to a single `(envelope, recipient)` and is the only authentication on the signer surface. Response format: JSON (standard envelope with `result` and data fields). Errors use the standard envelope with `error.code`, `error.text`, and (on 406 validation errors and some 409 conflicts) `error.params[]`. Permission model: Workspace membership covers read endpoints, `/send`, document downloads (original and signed PDFs) and the audit-certificate download; workspace admin is required only for `/void` and `/retry`. Signer-surface endpoints are authenticated by the path token only. ## What This Is A SignEnvelope is an audit-archive Profile holding up to twenty PDFs sent to one or more recipients for electronic signature. Every envelope is parented to a Workspace. The platform's internal PAdES-LT signing engine produces a long-term-validation cryptographic signature on every completed document; an envelope-level audit certificate captures the chain of evidence (consent acceptance, OTP authentication where required, per-recipient sign events, and document hashes). The audit chain is hash-linked and verifiable, and a downloadable audit certificate is available once an envelope reaches a terminal state (completed, declined, voided, expired, or failed). Each envelope flows through a small lifecycle state machine — Draft, Sent, InProgress, then one of Completed / Declined / Voided / Expired / Failed — and emits a global activity-stream event on every transition so comments and notification subscribers can react. --- ## Endpoint Summary ### Sender / Admin Surface — Workspace-Parented Envelopes | Method | Path | Description | |--------|------|-------------| | POST | `/current/workspace/{workspace_id}/sign_envelopes/create/` | Create a draft envelope | | GET | `/current/workspace/{workspace_id}/sign_envelopes/list/` | List envelopes owned by the workspace (offset-paginated) | | GET | `/current/workspace/{workspace_id}/sign_envelopes/{envelope_id}/details/` | Get an envelope (with documents/recipients/fields sub-collections) | | POST | `/current/workspace/{workspace_id}/sign_envelopes/{envelope_id}/update/` | Update mutable fields on a draft envelope (POST or PATCH; POST recommended) | | POST | `/current/workspace/{workspace_id}/sign_envelopes/{envelope_id}/send/` | Send a draft envelope (Draft → Sent; routes to first recipient slot) | | POST | `/current/workspace/{workspace_id}/sign_envelopes/{envelope_id}/void/` | Void a non-terminal envelope (cascade to Voided; reason required) | | POST | `/current/workspace/{workspace_id}/sign_envelopes/{envelope_id}/retry/` | Manually re-drive a stuck envelope through the self-healing recovery routine (admin; idempotent + no-op-success; no body; permanent failures cascade to Failed) | | GET | `/current/workspace/{workspace_id}/sign_envelopes/{envelope_id}/documents/{document_id}/download/` | Stream the source PDF bytes directly (Bearer-authed; attachment disposition) | | GET | `/current/workspace/{workspace_id}/sign_envelopes/{envelope_id}/documents/{document_id}/preview/` | Stream the source PDF bytes directly for in-app rendering (Bearer-authed) | | GET | `/current/workspace/{workspace_id}/sign_envelopes/{envelope_id}/documents/{document_id}/signed/download/` | Stream the signed PDF bytes directly (Bearer-authed; `404` code `146422` until the document completes) | | GET | `/current/workspace/{workspace_id}/sign_envelopes/{envelope_id}/audit/download/` | Stream the envelope's audit-certificate bytes directly (JSON; Bearer-authed; `404` code `128301` until the envelope reaches a terminal state) | | GET | `/current/workspace/{workspace_id}/sign_envelopes/{envelope_id}/audit/pdf/download/` | Stream the rendered Certificate-of-Completion PDF directly (Bearer-authed; `404` code `121249` until the envelope is terminal and the PDF has rendered) | | POST | `/current/workspace/{workspace_id}/sign_envelopes/{envelope_id}/my_sign_link/` | Mint an action-capable signer link for the calling workspace member when they are themselves a currently-actionable pending signer (else a structured blocked / terminal / reauth response) | ### Signer Surface (Public — Path Token Auth) The recipient's notification links to the Fastio web signing page (`https://go.fast.io/sign/{token}?envelope={envelope_id}`), which drives the endpoints below with that `{token}`. The `{token}` is a short-lived JWT bound to a `(sign_envelope_id, recipient_id)` pair; no Fastio session is required to call these endpoints. | Method | Path | Description | |--------|------|-------------| | GET | `/current/sign_envelopes/signer/{token}/view/` | Landing — returns the envelope, recipient state, document list with per-document source `download_url`s, the recipient's fields, and the in-force consent disclosure | | GET | `/current/sign_envelopes/signer/{token}/authenticate/` | Issue an OTP (recipients with `auth_method=email_otp` or `sms_otp`) | | POST | `/current/sign_envelopes/signer/{token}/authenticate/` | Verify an OTP and elevate the session token | | POST | `/current/sign_envelopes/signer/{token}/sign/` | Submit consent + field values; queues the async PAdES signing job | | GET | `/current/sign_envelopes/signer/{token}/status/` | Poll the signing pipeline; returns adaptive `next_poll_seconds` | | POST | `/current/sign_envelopes/signer/{token}/decline/` | Decline to sign (envelope cascades to Declined) | | GET | `/current/sign_envelopes/signer/{token}/completed/` | Once the envelope is terminal — returns recipient state, per-document completion status, and audit-certificate availability | | GET | `/current/sign_envelopes/signer/{token}/documents/{document_id}/download/` | Stream the **source** PDF for one document (the original, pre-signature file — for rendering the signing UI's field overlay). The signer token is the sole credential — no separate read token | | GET | `/current/sign_envelopes/signer/{token}/documents/{document_id}/signed/download/` | Stream the **signed** PDF for one document. Signer token is the sole credential. Returns `1609 (Not Found)` until the signed PDF exists; `1680 (Access Denied)` / 401 until the envelope is fully completed | | GET | `/current/sign_envelopes/signer/{token}/audit/download/` | Stream the envelope's audit certificate (JSON). Signer token is the sole credential. `1609 (Not Found)` until the certificate exists; `1680 (Access Denied)` / 401 while the envelope is still active | | GET | `/current/sign_envelopes/signer/{token}/audit/pdf/download/` | Stream the rendered Certificate-of-Completion PDF. Signer token is the sole credential. `1680 (Access Denied)` / 401 while the envelope is still active; `1609 (Not Found)` once terminal but the PDF has not yet rendered | ### Sign Template Surface — Workspace-Parented Templates Reusable signing templates that capture recipient role slots, document slots, field placements, and envelope policy. A template is instantiated to produce a draft envelope with concrete bindings applied. Signing is enabled on every plan. Template ids use the `sa` OpaqueId family (30-character self-describing string). | Method | Path | Description | |--------|------|-------------| | POST | `/current/workspace/{workspace_id}/sign_templates/create/` | Create a signing template (workspace member) | | GET | `/current/workspace/{workspace_id}/sign_templates/list/` | List non-deleted templates in the workspace (view access; offset-paginated) | | GET | `/current/workspace/{workspace_id}/sign_templates/{template_id}/details/` | Fetch a template with full snapshot (view access) | | POST | `/current/workspace/{workspace_id}/sign_templates/{template_id}/update/` | In-place update under optimistic CAS — `expected_version` required (workspace member) | | POST | `/current/workspace/{workspace_id}/sign_templates/{template_id}/delete/` | Soft-delete a template — tombstoned, not purged; advisory referrer scan returned (workspace admin) | | POST | `/current/workspace/{workspace_id}/sign_templates/{template_id}/instantiate/` | Apply a template: bind role slots to concrete people, resolve document slots, create a draft envelope (workspace member) | ### Availability Signing is enabled on every plan; `capabilities.signing` on the org resource confirms availability. Should an org's plan ever not grant signing, the signing endpoints reject calls with a feature-disabled error and new envelopes cannot start or advance, while any in-flight envelopes still drain to a terminal state -- background lifecycle processing (expiry sweeps, stuck-envelope recovery) keeps running regardless. --- ## Concepts ### SignEnvelope Profile A SignEnvelope is a Profile — the same hierarchy primitive used by Workspaces and Shares. Each envelope carries: - A lifecycle `envelope_status` (see Lifecycle below). - A `provider` identifier and an opaque `provider_envelope_id` correlating the envelope to an external signing connector. Both are `null` on the default internal PAdES signing path. - A `policy` object configured at create time (`auth_method`, reminder cadence, retention hints). - Lifecycle timestamps (`sent_at`, `completed_at`, `voided_at`, `expires_at`). - A `revision_number` that increments on each mutating write. - An optional `audit_certificate_node_id` populated once the envelope reaches any terminal state (`completed`, `declined`, `voided`, `expired`, or `failed`). - Four boolean lifecycle flags surfaced from the Profile base: `archived`, `closed`, `locked`, `suspended`, plus a `legal_hold` flag. - A read-only `_trust_class` field set to `system_trusted` — downstream consumers (audit pipelines) read this to classify envelope output. Documents, recipients, and fields are stored as sub-collections of the envelope and are returned inline on the `/details/` response when present (not on the create response). ### Lifecycle ``` draft -> sent | voided sent -> in_progress | declined | voided | expired in_progress -> completed | declined | voided | expired | failed (terminal) -> nothing ``` `completed`, `declined`, `expired`, `voided`, and `failed` are terminal. Sending a draft transitions `draft -> sent` and activates the first recipient's slot (sequential routing) or every recipient at once (parallel routing). A single decline cascades the envelope to `declined`. A void is sender-initiated and cascades the envelope to `voided` (a reason string is required; non-refundable per industry convention). Expiry runs from the `expires_at` policy and cascades to `expired`. `failed` is reserved for signing-pipeline errors that exhaust retries. A past-deadline envelope that is not yet complete is rejected for signing and declining, and is automatically transitioned to expired; an envelope whose required recipients have all signed is allowed to finish (it is not expired). Setting `envelope_status` directly via the update endpoint is not supported; lifecycle transitions only happen through dedicated endpoints (`/send/`, `/void/`) and through the signer-surface actions. ### Documents, Recipients, Fields A draft envelope is created with up to twenty source documents, at least one recipient, and zero or more fields. Documents are referenced by their `source_node_id` (a node in storage) and optionally a `source_version_id` (the document resource returns `source_version_id: null` when no specific version was pinned at create time); the create flow copies the file bytes into the envelope's own private storage instance so the envelope is a self-contained archive. Each document carries a `display_order`, a `source_sha256`, a `signed_pdf_node_id` (populated when signing completes), a `completed_sha256`, and a `signed_at` timestamp. The authoritative "the signed PDF exists and is downloadable" signal for a document is a non-empty `signed_pdf_node_id`, surfaced as the boolean `signed_document_available`. The envelope resource rolls this up across all documents: `signing_complete` is `true` only once every document has a signed artifact (it is derived from the signed artifacts, so a terminal but unsigned envelope — declined, voided, expired, or failed — is NOT `signing_complete`), `documents_progress` reports `{ total, completed, in_progress, failed }` counts (a document is `completed` when its signed artifact exists), and `audit_certificate_available` is `true` once the terminal-envelope audit certificate has been written (the certificate is generated on any terminal outcome — completed, declined, voided, expired, or failed). `signing_failed` is `true` when at least one document's signing attempt has errored without yet producing a signed artifact — the failure may be transient (it will retry) or permanent — and is independent of `signing_complete`; gate downloads on `signed_document_available` / `signing_complete`, not on `document_copyback_status`. Recipients carry a `role` (one of `signer`, `cc`, `viewer`, `approver`, `certified_recipient`), a `routing_order` (non-negative integer for sequential routing, lowest first; identical numbers run in parallel), an `auth_method` (`none`, `email_otp`, `sms_otp`), and per-recipient lifecycle timestamps. Recipient status flows `pending -> sent -> viewed -> authenticated -> signing_in_progress -> signed` with `declined` / `expired` / `voided` / `failed` as terminal short-cuts. Fields are placed on a `(document_id, page)` using normalized `0..1` coordinates (`x_norm` / `y_norm` for the top-left corner, `w_norm` / `h_norm` for the bounding box). The supported field `type` values (the same set on envelopes and templates) are `signature`, `initial`, `date`, `text`, `checkbox`, `radio`, `dropdown`, `attachment`, `title`, `company`, `full_name`, `email`, `approve`, and `decline`. Text-style and date fields accept a `validation` object (below); the other types take none. A field belongs to exactly one recipient and is rendered only when that recipient signs. A field can optionally carry a **`validation`** object that constrains the value a signer may submit. It is echoed back as `validation_json` on the field resource (the `/details/` and signer `/view/` responses) so the signing UI can enforce the same rules client-side. Supported keys (all optional): - **Text-style fields** (`text`, `title`, `company`, `full_name`, `email`, `radio`, `dropdown`): `min_length` (integer ≥ 0), `max_length` (integer ≥ 0; must be ≥ `min_length` when both are present), and `pattern` (a regular-expression source — no delimiters, up to 512 characters — enforced as a full-string anchored match). - **Date fields** (`date`): `date_min` and `date_max`, each a `YYYY-MM-DD` calendar date (`date_min` must be ≤ `date_max` when both are present). Any other key, or a non-empty `validation` object on a `signature` / `initial` / `checkbox` / `attachment` / `approve` / `decline` field, is rejected at create/update time with `1605 (Invalid Input)`. The object is also size-capped. At `/sign/` time the platform enforces these rules against the submitted value before recording the signature; a violation is rejected with `1605 (Invalid Input)` and lists the offending fields. Date fields are always validated as real calendar dates at sign time even without a `validation` object. ### Trust Class Every envelope-resource response carries a read-only `_trust_class` field set to `system_trusted`. Outputs that pass through a SignEnvelope are platform-derived, so downstream consumers (audit pipelines, AI agents) can rely on the value without independent verification. `_trust_class` is display metadata; it is never accepted as an input. ### Audit Chain and Certificate Every state change on an envelope appends a hash-chained audit row. Rows record a per-envelope `sequence`, an `event_type` (see Activity Events below), the actor's identifiers, and an `event_hash` that chains to the immediately preceding row's hash (`prior_event_hash`). The chain is keyed by a per-envelope HMAC secret. When an envelope reaches a terminal state — `completed`, `declined`, `voided`, `expired`, or `failed` — the platform builds a structured audit certificate (JSON — a long-form evidence record with a self-anchoring HMAC) and copies it into the envelope's storage instance. The `audit_certificate_node_id` field on the envelope resource carries the node reference. Both the owner `/audit/download/` and signer `/sign_envelopes/signer/{token}/audit/download/` endpoints stream the certificate bytes directly. Until the certificate exists, the owner endpoint returns `404` with code `128301` and the signer endpoint returns `1609 (Not Found)` — so a voided envelope's certificate IS downloadable; do not poll "until completed". ### Internal PAdES-LT Signing The default signing path uses the platform's internal PAdES-LT engine — a long-term-validation cryptographic signature embedded directly in each completed PDF. Signed PDFs include an embedded validation certificate chain so the signature remains verifiable offline without contacting the signing service. The signing pipeline runs asynchronously after `/sign`; the signer client polls `/status/` for completion. ### Recipient Authentication (OTP) When a recipient's `auth_method` is `email_otp` or `sms_otp`, the recipient must complete an OTP gate before submitting field values. The flow is: 1. The recipient's client calls `GET /sign_envelopes/signer/{token}/authenticate/` — the platform issues a fresh 6-digit code and sends it to the recipient. The code is short-lived and code-issuance is throttled per `(envelope, recipient)`. 2. The recipient submits the code via `POST /sign_envelopes/signer/{token}/authenticate/`. On match, the platform records the authenticated timestamp, re-mints the session token with the elevated `auth_level`, and returns the new token. On miss, the response includes `attempts_remaining` and verification is throttled per `(envelope, recipient)`. 3. The recipient's subsequent `/sign/` call carries the elevated token; the platform refuses to accept signatures from a token whose `auth_level` does not satisfy the recipient's configured `auth_method`. Recipients with `auth_method=none` skip the authenticate step entirely. A recipient who already authenticated but no longer holds the elevated token — for example a returning signer who reopened the email link in a fresh browser session — may re-invoke `GET /sign_envelopes/signer/{token}/authenticate/` to receive a new code and re-elevate. The prior authentication is preserved (re-issuing a code does not reset recipient state); only `signed`, `declined`, and otherwise-terminal recipients are refused. ### Consent Acceptance Before submitting field values, a recipient must accept the in-force ESIGN/UETA consent disclosure surfaced on the `/view` response. The acceptance is recorded in the audit chain along with the hash of the consent body the recipient was shown. The `/sign/` request carries `consent_body_hash` and `consent_text_version` to prove the acceptance; a mismatch is rejected with `1605 (Invalid Input)`. ### Document Byte Access All document byte endpoints — source `download`/`preview`, the signed-PDF `signed/download`, and the envelope `audit/download` — **stream the bytes directly through the API** under standard Bearer/session auth. There is no read-token round-trip. The bytes live in the envelope's own storage instance and are fetched server-side, so a document's `source_node_id` / `signed_pdf_node_id` / `audit_certificate_node_id` must NOT be passed to the generic `/storage/{node_id}/read/` endpoint (it resolves nodes in the workspace instance and returns not-found). Because the response is Bearer-gated, fetch it with the `Authorization` header and render the resulting blob — the URL cannot be used as a bare `