# 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 Streamable HTTP endpoint: https://mcp.fast.io/mcp > MCP Legacy SSE endpoint: https://mcp.fast.io/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. Connect to `https://mcp.fast.io/mcp` (Streamable HTTP) or `https://mcp.fast.io/sse` (legacy SSE). In Named Mode (Claude Desktop, etc.), the server exposes multiple domain-specific tools plus app-specific widget tools; in Code Mode (Claude Code, Cursor, etc.), a smaller set of streamlined tools. All tools use action-based routing and are annotated with MCP hints (`title`, `readOnlyHint`, `destructiveHint`). Resources (`skill://guide`, `session://status`) and guided prompts for common workflows 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 | | **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, intelligence, notes, metadata templates & 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, provider webhooks, 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 | | **Coordination Rooms** | https://api.fast.io/current/llms/rooms/ | Shared coordination spaces for agentic teams: create-or-adopt a room, post messages, track participant status and liveness, hand off files via per-agent folders or per-reader file references inside messages, provision external agents via one-time invites, and receive HMAC-signed webhooks on room activity | | **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 (Metadata Templates / AI extraction); 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 | **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, Business, or Growth). 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 (100/GB), bandwidth (212/GB), AI tokens (1/100 tokens), document ingestion (10/page), video ingestion (5/sec), image ingestion (5/image), file conversions (25/each). **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. ## 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}` Four methods: - **Basic Auth → JWT:** `GET /current/user/auth/` with HTTP Basic Auth. Returns JWT. If 2FA enabled, token has limited scope until verified. - **OAuth 2.0 PKCE:** For desktop/mobile apps and MCP agents. S256 only. 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}/`. - **2FA:** 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`), create an org, select a paid plan, 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). 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), or `type_mismatch` (value could not be decoded into the expected type). Omitted when empty. 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 (OAuth, webhook receivers, and a substantial set of legacy endpoints not yet migrated) return `405 Method Not Allowed` for `OPTIONS`. Coverage is being expanded incrementally — check via `OPTIONS` first; fall back to the documented schema when you receive a 405. ## Error Codes Client errors: - 1605 (Invalid Input) → 406 Not Acceptable - 1607 (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 - 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 - 1677 (Locked) → 423 Locked Billing errors: - 1688 (Subscription Required) → 402 Payment Required (org has no active subscription or free-tier credits exhausted) - 1695 (Upgrade Required) → 402 Payment Required (feature requires a higher-tier plan) - 1696 (Credit Limit Exceeded) → 402 Payment Required (free-tier credit limit exceeded; error message includes usage details) Server errors: - 1654 (Internal Error) → 500 - 1664 (Datastore Error) → 500 - 1686 (Not Implemented) → 501 ### 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 1671 (Rate Limited). Back off until `x-ve-limit-expires`. ## 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 (Storage) The storage listing endpoint uses cursor-based (keyset) pagination. A page may contain fewer than `page_size` items even when `has_more` is `true` — rely on `has_more` / `next_cursor`, not page fullness, to decide whether to continue. **Storage listing** (`GET /current/workspace/{id}/storage/`, 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` ## 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. Consumers that render markdown as HTML MUST sanitize the output — bullet and heading values are not HTML-escaped (strings that look like HTML are fenced in code blocks as a safety default, but this is not a substitute for sanitization). - **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., `f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf`). Treat the entire string as opaque — do not parse a resource type from any part of it, including the leading character. - **API output is always the hyphenated form.** Persist this form on the client. - **API input accepts either form.** The router strips hyphens before validation, so a 29-char un-hyphenated id (`f3jm5zqzfxpxdr2dx8z5bvnb3rpjf`) 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}`) | Yes | 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 | — | Yes | Yes | | Share | title | `title` | 2 | 80 | No control chars (`\p{Cc}`) | Yes | 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 chats with `chat` (general) or `chat_with_files` (RAG with citations). Stream responses via SSE. Use `/storage/search` for both keyword and semantic search — when workspace intelligence 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 intelligence 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) - `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 (Basic Auth, API keys, 2FA), 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), is_agent, agent_name, full_access. For scoped API keys, returns `auth_type: "api_key_scoped"` with hydrated scope details, agent name, and expiration. Requires auth. - `GET /current/user/auth/` — Basic Auth → JWT exchange - `POST /current/user/` — Create user account - `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) - `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 - Ownership transfer/claim: `POST /current/org/{org_id}/transfer/token/create/`, `GET /current/org/{org_id}/transfer/token/list/`, `DELETE /current/org/{org_id}/transfer/token/{token_id}/`, `GET /current/org/transfer/claim/public/details/`, `POST /current/org/transfer/claim/` — mint/list/revoke a transfer token, then the recipient inspects and claims org ownership ### Workspaces → [Workspaces reference](https://api.fast.io/current/llms/workspaces/) Workspace CRUD, member management, assets, intelligence 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 - `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 (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` (preview-only for guests via nonce flow), `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, metadata templates with many-to-many file mappings, AI extraction, SSE streaming. Metadata templates are available on all plan tiers, with per-template file caps that scale by plan (lower tiers have small caps with upsell context returned in listing/preview responses). 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, Growth) 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}/ai/search/` — Semantic search (**deprecated** — use `/storage/search` instead) - `GET /current/workspace/{id}/metadata/eligible/` — List nodes (files and notes) eligible for metadata extraction - `POST /current/workspace/{id}/metadata/templates/{template_id}/nodes/add/` — Add nodes (files or notes) to template - `POST /current/workspace/{id}/metadata/templates/{template_id}/nodes/remove/` — Remove nodes from template - `GET /current/workspace/{id}/metadata/templates/{template_id}/nodes/` — List nodes in template - `POST /current/workspace/{id}/metadata/templates/{template_id}/auto-match/` — AI-based node matching - `POST /current/workspace/{id}/metadata/templates/suggest-fields/` — AI-suggested custom fields for a template being designed (read-only) - `POST /current/workspace/{id}/metadata/templates/{template_id}/extract-all/` — Batch extract metadata - `POST /current/workspace/{id}/storage/{node}/metadata/extract/` — Enqueue async metadata extraction (returns HTTP 202 + job_id; poll `/jobs/status/`) - `GET /current/workspace/{id}/metadata/view/?template_id={template_id}` — Get the caller's saved view (columns/sort/filters) for a template; 404/1609 if none - `POST /current/workspace/{id}/metadata/view/` — Upsert the caller's saved view (form-encoded; a JSON body returns HTTP 406) - `DELETE /current/workspace/{id}/metadata/view/?template_id={template_id}` — Delete the caller's saved view; 1609 if none - `GET /current/workspace/{id}/metadata/views/` — List the caller's saved views in the workspace (newest-first) - `POST /current/workspace/{id}/metadata/view/{template_id}/export/` — Async TSV export of a template's data using the caller's saved view (returns job_id; saved view required, else 1609) - Template ↔ node assignment: `POST /current/workspace/{id}/metadata/template/assign/` (assign nodes to a template), `GET /current/workspace/{id}/metadata/template/assignments/` (list assignments), `GET /current/workspace/{id}/metadata/template/resolve/{node_id}/` (resolve the templates applying to a node), `DELETE /current/workspace/{id}/metadata/template/unassign/` (remove an assignment) - `POST /current/workspace/{id}/storage/{node}/metadata/extract-all/` — Folder-level batch metadata extraction across a folder's eligible nodes ### 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/activity/poll/{profile_id}/` — Long-poll for changes - `GET /current/realtime/auth/{room_id}` — Realtime room auth - `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) **Provider webhook (public, provider HMAC):** - `POST /current/webhooks/sign_envelopes/{provider}/{connection_short_id}/` — Inbound provider event (Documenso supported) **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`. ### 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) ### Coordination Rooms → [Coordination Rooms reference](https://api.fast.io/current/llms/rooms/) Shared workspaces for agentic teams. A room is a workspace-owned Share carrying a server-managed room flag. Agents and humans join by posting status or messages; membership derives from the parent workspace. Rooms are always private and durable. Create-or-adopt is idempotent per `(workspace, topic_slug)`. Messages are append-only. Presence is a 1800-second (30-minute) TTL refreshed by any authenticated room call — reads and management calls included, though a read never implicit-joins; it signals liveness, not progress — use `status` and `last_material_change` to track actual work. `status` carries what a participant SELF-REPORTED; its value domain is the nine status values PLUS the empty string, which means "never reported" (a human who only posts messages) — always a string, never null, deliberately not an enum member, so clients must handle it and render nothing rather than a badge. `status_changed` is empty for that participant too; `status_version: 0` is the machine-readable signal. A participant who never reported does not advance `last_material_change`. A non-empty `agent_label` is unique room-wide (v1.1) — a join/heartbeat/status/message with a label already held by a DIFFERENT participant returns `409` (empty label exempt; case-insensitive; own re-join always idempotent). Message `body` is Markdown; `display_text` is an optional supplementary rendering hint and never replaces `body`. Both are stored VERBATIM and returned as stored, with one exception — `body` is trimmed and both are length-checked, but neither is HTML-stripped or escaped, so code and markup-shaped text survive intact; because a room's messages come from other parties, treat both as untrusted input and escape them in your own client before rendering them as HTML. Message reads also return an ordered `parts` array alongside `body` (text parts carry their text under `value`; file reference parts carry their label under `text`), and references are resolved PER READER — a reader who cannot open the referenced file gets the placeholder `a file you do not have access to` in `parts`, `body`, and `display_text` (the one exception above) — so a rendered message must NEVER be cached across users. A participant's `status_summary` is optional and bounded at 8192 characters, the same bound as a message `body`. - `POST /v1.0/workspace/{workspace_id}/rooms/` — Create or adopt a room (idempotent per topic_slug; `topic_slug` required; `goal` + `intelligence` optional). A supplied `goal` is 1–500 chars with no control characters (an explicitly blank one is rejected, `406`); a room created without one is titled `room: `. On adopt: omitting the goal adopts silently, a matching goal adopts, a DIFFERING goal is a `409` conflict — correct the stored goal with the share update endpoint instead - `POST /v1.0/share/{room_id}/update/` — Correct a room's purpose with `room_goal` (1–500 chars, no control characters; blank not accepted; refused on a share that is not a coordination room). The only mutable room-manifest field — `topic_slug` and the protocol version stay immutable - `GET /v1.0/share/{room_id}/room/` — Room state doc: manifest, participant roster with liveness, `root_node_id`, `last_material_change` - `POST /v1.0/share/{room_id}/room/status/` — Set caller status; optional `status_summary` up to 8192 chars (no control characters); implicit-joins on first call; CAS on `status_version`; `409` if `agent_label` collides with another participant (v1.1) - `POST /v1.0/share/{room_id}/room/heartbeat/` — Refresh presence TTL; implicit-joins if needed; `409` if `agent_label` collides with another participant (v1.1) - `POST /v1.0/share/{room_id}/room/messages/` — Post a message (append-only, `body` is Markdown); name a file inline with `@[file:{node_id}:{label}]`; kinds: `say`, `ask`, `answer`, `status`, `join`, `done`; implicit-joins; `409` if `agent_label` collides with another participant (v1.1). The returned `message` carries the same ordered `parts` as a read - `GET /v1.0/share/{room_id}/room/messages/?since={cursor}&limit={n}` — Keyset page of messages, oldest-first; each message carries an ordered `parts` array alongside `body` (text parts key their text under `value`, file reference parts under `text`), resolved PER READER — never cache a rendered message across users; limit default 50, max 200. Optional `sort=desc` starts at the tail (newest-first) so a client joining an established room does not have to drain its history; the response's `latest_cursor` hands off to the oldest-first follow loop - `POST /v1.0/share/{room_id}/room/leave/` — Leave the room, dropping your OWN row (any member, including a room-agent key); `agent_label` optional; idempotent (`{left:true, already_absent:true}` if no row); revokes no key - `GET|POST /v1.0/share/{room_id}/room/webhooks/` — List / register HMAC-signed webhook subscriptions (workspace-admin); wire events `room.message.created` + `room.participant.status_changed`; `secret` shown once at create - `DELETE /v1.0/share/{room_id}/room/webhooks/{webhook_id}/` — Deactivate a webhook (soft-disable) - `POST /v1.0/share/{room_id}/room/webhooks/{webhook_id}/rotate/` — Rotate a webhook's signing secret (new secret shown once) - `POST /v1.0/share/{room_id}/room/invites/` — Create a one-time agent invite (workspace-admin); returns a single-use redeem URL - `POST /v1.0/room/invites/{token}/redeem` — Redeem an invite to provision a room-scoped agent key (unauthenticated, single-use); returns the key once - `GET | DELETE /v1.0/share/{room_id}/room/agents/[{key_id}]` — List the room's agent keys, or revoke one by `key_id` (workspace-admin) - `DELETE /v1.0/share/{room_id}/room/participants/{participant_id}` — Admin force-remove a server-provisioned (`removable:true`) participant; revokes its stamped room-agent key; REAL parent-workspace admin required, a room-agent key is denied; own-credential participants are refused; bad/wrong-room id → uniform 404 ### Cloud Import (External Storage Sync) Cloud Import connects external cloud storage providers to workspace storage. Sync files from Google Drive, Dropbox, Box, or OneDrive for Business — and, on opt-in read-write sources, push local edits/adds/deletes back to the provider (two-way sync). Requires `cloud_import` feature enabled on the workspace. Identities are **per-user**: each member connects their own account (owned by them, up to 4 active per member per workspace); sensitive identity fields are masked for non-owners. **Feature Toggle (workspace admin):** - `POST /current/workspace/{workspace_id}/cloud-import/enable/` - Enable cloud import - `POST /current/workspace/{workspace_id}/cloud-import/disable/` - Disable cloud import **Available Providers:** - `GET /current/imports/workspace/{workspace_id}/providers/` - List providers available on workspace's plan **Provider Identities:** - `GET /current/imports/workspace/{workspace_id}/identities/` - List provider identities - `POST /current/imports/workspace/{workspace_id}/identities/provision/` - Provision your own identity. **Dropbox and OneDrive for Business** return 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. **Google Drive and Box** provision in the background and return `status: "provisioning"`; poll the identity until it reports `"active"`, then read its `identity_email` to know which address to share your folder with. The permission floor differs by provider: `onedrive_business` requires **Member**, the other three accept any member with view access. Which providers a workspace may connect at all is plan-dependent — 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/imports/workspace/{workspace_id}/identities/{identity_id}/` - Identity details - `POST /current/imports/workspace/{workspace_id}/identities/{identity_id}/revoke/` - Revoke identity (owner or admin) - `POST /current/imports/oauth/{provider}/complete/` - Finish a browser OAuth connect (`provider` is `dropbox` or `onedrive`). 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`, `credential_store_failed`, `activation_failed`, or — OneDrive only — `insufficient_scope`, which also carries an `admin_consent_url`). There is deliberately no public callback endpoint to call instead. **Drive Catalog (OneDrive for Business only):** A OneDrive for Business identity is one connected Microsoft work or school account, and that account can usually reach several document libraries — its own OneDrive, plus whatever SharePoint it has access to — with no meaningful default among them. So which library an import uses is chosen **per source** and must be picked from the identity's own catalog. - `GET /current/imports/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 keep every removal power). 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/imports/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/imports/workspace/{workspace_id}/sources/` - List import sources - `POST /current/imports/workspace/{workspace_id}/sources/discover/` - Discover remote folders (the member who owns the identity; a workspace admin may also do this for Box, Dropbox and Google Drive, but **not** for OneDrive, whose discovery browses that member's own Microsoft content). **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. - `POST /current/imports/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. 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. **Source Details:** - `GET /current/imports/details/{source_id}/` - Source details (adds `provider_name`, `owner_user_id`, `is_owner`, `conflict_count`) - `POST /current/imports/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/imports/details/{source_id}/delete/` - Soft-delete source (owner or admin) - `POST /current/imports/details/{source_id}/disconnect/` - Disconnect (keep/delete files; owner or admin) - `POST /current/imports/details/{source_id}/refresh/` - Trigger immediate sync (any member) **Import Jobs:** - `GET /current/imports/details/{source_id}/jobs/` - List sync jobs - `GET /current/imports/details/{source_id}/jobs/{job_id}/` - Job details with progress - `POST /current/imports/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)`. - `GET /current/imports/details/{source_id}/writebacks/` - List write-back jobs (any member) - `POST /current/imports/details/{source_id}/writebacks/push/{node_id}/` - Force-push one imported file (owner or admin) - `GET /current/imports/details/{source_id}/writebacks/{writeback_id}/` - Write-back details (any member) - `POST /current/imports/details/{source_id}/writebacks/{writeback_id}/retry/` - Retry a failed job (owner or admin) - `POST /current/imports/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) - `POST /current/imports/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 is plan-dependent). **Dropbox and OneDrive for Business** connect a real per-user account through a browser OAuth consent, so each identity carries that account's own address; Google Drive and Box use centrally held service credentials and are provisioned in the background. Source statuses: pending, discovering, syncing, synced, error, paused, disconnect_pending, disconnecting, disconnected. 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`; `remote_path` omitted) with a legacy `import_metadata` block kept for compatibility. Sources with >1000 files use async disconnect. 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 is removed from the workspace **or from the org**, their provider identities are revoked and each of their sources flips to `read_only` and `disconnect_pending`: inbound sync and outbound write-back both stop. Content already imported is kept, and remains in the workspace as ordinary storage. 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 (re-share the remote folder to the import agent with write access), 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 in a `response` wrapper 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` ## 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` (Streamable HTTP) or `https://mcp.fast.io/sse` (legacy SSE) - 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 Basic Auth or OAuth), an API key, or a 2FA-upgraded JWT. ### Method 1: Basic Auth to JWT 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`** — pass to mint a session-bound JWT that can be invalidated server-side via `POST /current/user/auth/sign-out/`. Recommended for browser sessions where users expect a logout button to terminate access. JWTs minted without `revocable` (the default) are stateless and `sign-out` has no effect on them — but ALL login tokens (with or without `revocable`) can still be killed via `POST /current/user/auth/invalidate-all/`, which terminates every session for the user (logins, AI-chat). See [Session Termination](#session-termination) below. ### 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. Legacy keys without scopes retain full access. **Scope formats:** OAuth authorization requests accept named scope strings (`user`, `org`, `workspace`, `all_orgs`, `all_workspaces`, `all_shares`). API responses return scopes as arrays of `entity_type:entity_id:access_mode` strings (e.g., `["org:12345:rw"]`). See the [OAuth 2.0 reference](https://api.fast.io/current/llms/oauth/) for full scope format details. ### Method 3: OAuth 2.0 PKCE For desktop/mobile apps and MCP-connected agents. No password passes through the agent. 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, Basic Auth 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. --- ## 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) 2. `GET /current/user/auth/` with Basic Auth to get JWT 3. Verify email: - `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. `solo_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 Growth). ### 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: `POST /current/org/{org_id}/members/join/` or `POST /current/workspace/{workspace_id}/members/join/` 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 `POST /current/oauth/authorize/` with `code_challenge`, `code_challenge_method=S256`, `client_id`, `redirect_uri`, `response_type=code` 2. User opens the returned URL in browser, signs in, approves access 3. Browser displays authorization code -- user copies it to 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`, `updated`, `invite`, `expires`, `locked`, `suspended`, `closed` (last three visible to self/managers only) | | `full` | standard + `country_code`, `phone_country`, `phone_number`, `2factor`, `notify`, `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). 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. | | `first_name` | string | No | Must pass name validation | User's given/first name. | | `last_name` | string | No | Must pass name validation | 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=SecureP@ss123" \ -d "tos_agree=true" \ -d "first_name=Jane" \ -d "last_name=Doe" \ -d "agent=true" ``` **Success Response (200 OK):** ```json { "result": true } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `result` | boolean | `true` on success | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "An invalid email was supplied." | Email format invalid | | `1605 (Invalid Input)` | 406 | "The email domain is invalid or cannot receive email." | Email domain validation failed | | `1605 (Invalid Input)` | 406 | "An invalid password was supplied." | Password does not meet requirements | | `1605 (Invalid Input)` | 406 | "An invalid `tos_agree` value was create." | TOS value not a valid boolean string | | `1605 (Invalid Input)` | 406 | "You declined to accept the terms of service." | TOS set to `"false"` | | `1605 (Invalid Input)` | 406 | "An invalid first name was supplied to create." | First name fails validation | | `1605 (Invalid Input)` | 406 | "An invalid last name was supplied to create." | Last name fails validation | | `1605 (Invalid Input)` | 406 | "An invalid phone country code was supplied." | Invalid phone country code | | `1605 (Invalid Input)` | 406 | "An invalid phone number was supplied." | Invalid phone number | | `1605 (Invalid Input)` | 406 | "An invalid phone number or country code was supplied." | Full phone number validation failed | | `1680 (Access Denied)` | 401 | "Your attempt to create an account was not accepted." | Risk/fraud check failed | | `1654 (Internal Error)` | 500 | "We were unable to create your user account..." | Internal processing failure | **Notes:** - **Account enumeration is intentionally not possible.** Signing up with an email that already has an account does NOT return an "already in use" error — it returns the SAME success response as a brand-new signup and sends the existing account an email so the owner can sign in / reset their password. A caller therefore cannot use signup to tell whether an email is registered. (No duplicate account is created.) - 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). Authentication via `GET /current/user/auth/` works as soon as the account exists. --- ## Session Termination ### POST /current/user/auth/sign-out/ Invalidate every revocable JWT issued to the calling user. Bumps the user's session counter; tokens minted with `revocable=true` that carry a stale counter will be rejected on their next request. **Auth:** Required (JWT or API key — but the effect only applies to *revocable* JWTs). **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:** - Only affects JWTs minted with `revocable=true` on `GET /current/user/auth/`. Tokens minted without that opt-in (API keys, OAuth/PKCE access tokens, agent tokens, default `auth/` calls) are unaffected and live their full TTL. - Affects every revocable token for the user across all browsers and devices — there is no per-device sign-out. To revoke a specific OAuth session, use the OAuth session endpoints instead. - The cookie/storage on the client is not cleared by this call. The client is responsible for clearing 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 AI-chat tokens and 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 — a strict superset of sign-out. Bumps both the user's `session_version` (kills `revocable` browser tokens) AND `global_session_version` (kills all account-session tokens: interactive logins regardless of the `revocable` opt-in, and Ripley AI-chat tokens). Any token carrying a now-stale counter is rejected on its next request. Use this for "sign me out of all devices", password changes, or a suspected account compromise — i.e. account-security actions, as opposed to a per-browser logout button (use sign-out for that). **Auth:** Required (JWT or API key for the user). **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`, plus Ripley AI-chat tokens. 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, 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). - Like sign-out, it does NOT clear client-side cookies or storage — 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. On an account that already has a password you MUST also send `current_password`. 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 | New password. If the account already has a password you MUST also send `current_password`; an SSO-only account (no password) may set an initial password without it. | | `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). Omit when setting an initial password on an SSO-only account. Must be sent in the POST body, never the query string. | | `first_name` | string | No | Must pass name validation | Updated given/first name. | | `last_name` | string | No | Must pass name validation | 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 } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "An invalid email was supplied to update." | Invalid email format | | `1605 (Invalid Input)` | 406 | "The email domain is invalid or cannot receive email." | Invalid email domain | | `1660 (Conflict)` | 409 | "There email you specified is not available." | Email already in use | | `1663 (Update Failed)` | 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 | | `1670 (Restricted)` | 406 | "You must disable 2-Factor before updating your phone." | 2FA enabled when trying to change phone | | `1605 (Invalid Input)` | 406 | "An invalid password was supplied to update." | Invalid password | | `1700 (Forbidden)` | 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` (error.code `10759`) | | `1605 (Invalid Input)` | 406 | "An invalid first name was supplied to update." | Invalid first name | | `1605 (Invalid Input)` | 406 | "An invalid last name was supplied to update." | Invalid last name | | `1605 (Invalid Input)` | 406 | "Owner-defined properties must be valid JSON." | Invalid JSON in `owner_defined` | **Notes:** - **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. An SSO-only account (no password) may set an initial password without `current_password`; use the `password_set` field on `GET /current/user/details/` to tell which case applies. - 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`. - 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 | |------------|-------------|---------|-------| | `1653 (User Not Found)` | 404 | "User not found to close." | User object invalid | | `1605 (Invalid Input)` | 406 | "An invalid email was supplied to close account." | Invalid email format | | `1605 (Invalid Input)` | 406 | "An incorrect email was supplied to close account." | Email does not match user's email | | `1605 (Invalid Input)` | 406 | "Cannot close user account that owns active organizations..." | User owns active organizations | **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. --- ### POST /current/user/email/ **Deprecated.** This endpoint previously reported whether an email was already registered, which let anyone enumerate Fast.io 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 the same success as a new signup. **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 | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 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 | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "You provided an invalid email to check." | Invalid email format | | `1654 (Internal Error)` | 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 | |------------|-------------|---------|-------| | `1650 (Authentication Invalid)` | 401 | "Your credentials were not supplied or invalid." | User not authenticated | | `1658 (Not Acceptable)` | 406 | "Your email address is already verified." | Email already verified | | `1660 (Conflict)` | 409 | "Your credentials do not match the email you provided." | Email mismatch with authenticated user | | `1658 (Not Acceptable)` | 406 | "You provided an invalid or expired token to validate email." | Invalid or expired code | | `1680 (Access Denied)` | 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 | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "There is no pending email change to confirm." | No pending change exists for this account | | `1680 (Access Denied)` | 401 | "The confirmation link is invalid or has expired." | Token invalid, expired, or already used | | `1680 (Access Denied)` | 401 | "This confirmation link does not belong to the signed-in account." | Token belongs to a different account than the one signed in | | `1660 (Conflict)` | 409 | "That email address is no longer available." | The pending email was claimed by another account before confirmation | | `1663 (Update Failed)` | 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 | |------------|-------------|---------|-------| | `1680 (Access Denied)` | 401 | "An invalid code was provided, cannot reset password." | Invalid code format | | `1680 (Access Denied)` | 401 | "Provided code was not found or expired, cannot reset password." | Code not found or wrong type | | `1680 (Access Denied)` | 401 | "Provided code has expired, get a new code and try again." | Code expired | | `1660 (Conflict)` | 409 | "Provided code belongs to another user account and cannot be used." | Code/user mismatch | | `1683 (Resource Missing)` | 404 | "The provided code belongs to an invalid user." | User not found for code | | `1660 (Conflict)` | 409 | "The provided passwords don't match." | `password1` and `password2` differ | | `1658 (Not Acceptable)` | 406 | "Both password fields must be provided and match." | Missing password fields | | `1654 (Internal Error)` | 500 | "The provided password could not be processed..." | Encryption failure | **Notes:** - The reset code is consumed (deleted) after successful password change. - A new JWT is created after the password is set. --- ### 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 | |------------|-------------|---------|-------| | `1680 (Access Denied)` | 401 | "An invalid code was provided, cannot reset password." | Invalid code format | | `1680 (Access Denied)` | 401 | "Provided code was not found or expired, cannot reset password." | Code not found | | `1680 (Access Denied)` | 401 | "Provided code has expired, get a new code and try again." | Code expired | | `1660 (Conflict)` | 409 | "Provided code belongs to another user account..." | Code/user mismatch | | `1677 (Locked)` | 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 | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "You provided an invalid phone number to check." | Invalid format | | `1605 (Invalid Input)` | 406 | "An invalid phone country code was supplied." | Invalid country code | | `1605 (Invalid Input)` | 406 | "An invalid phone number was supplied." | Invalid phone number | | `1605 (Invalid Input)` | 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 | |------------|-------------|---------|-------| | `1653 (User Not Found)` | 404 | "Unable to fetch the user details." | User not found | | `1654 (Internal Error)` | 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 session-bound and can be invalidated server-side via `POST /current/user/auth/sign-out/`. 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. | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "An invalid provider name was supplied." | Invalid provider name format | | `1605 (Invalid Input)` | 406 | "An unknown provider name was supplied." | Provider not in allowed list | | `1605 (Invalid Input)` | 406 | "Cookies must be enabled and passed to this API." | Missing state cookie | | `1680 (Access Denied)` | 401 | "Permission was not granted by the provider." | OAuth error returned from provider | | `1673 (SSO Auth Error)` | 401 | "Invalid or missing input in a required field was received." | Missing code or state | **Notes:** - Supported providers: `google`, `microsoft`. (Requesting any other provider — including `apple` — is rejected with the `1605` "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. --- ### 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 | |------------|-------------|---------|-------| | `1653 (User Not Found)` | 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", "locked": false, "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.locked` | boolean | Whether the account is locked. | | `user.profile_pic` | string | Profile photo URL. | **Self-Only Fields** (included only when viewing your own profile): | Field | Type | Description | |-------|------|-------------| | `2factor` | boolean | Whether 2FA is enabled. | | `closed` | boolean | Whether the account is closed. | | `country_code` | string | Country of residence. | | `created` | string | Registration date. | | `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. | | `suspended` | boolean | Suspension status. | | `tos_agree` | string | ToS agreement date. | | `updated` | string | Last profile update time. | | `valid_email` | boolean | Email verified status. | | `valid_phone` | boolean | Phone verified status. | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1653 (User Not Found)` | 404 | "Unable to fetch the user details." | User not found | --- ### GET /current/user/me/autosync/{state}/ Enable or disable profile photo auto-synchronization from SSO providers. **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 | |------------|-------------|---------|-------| | `1664 (Datastore Error)` | 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 free organization creation 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. New organizations select a paid plan (Starter, Business, or Growth) 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 free trial of a paid plan (vs. having to buy immediately). Use them to render the plan-selection cards ("Start N-day free trial" vs. "Buy now") before any org exists. When `free_trial_eligible` is `false`, a `no_trial_reason` string and/or a `trial_available_at` timestamp may also be returned explaining why and when a trial next becomes available. **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 | Number of existing free organizations owned by the user. | | `cooldown_remaining` | integer | Seconds remaining before next creation is allowed. | | `max_free_orgs` | integer | Maximum number of free organizations allowed. | | `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 free trial of a paid plan. | | `trial_days` | integer | Length of the free 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. | | `trial_available_at` | string | Canonical `Y-m-d H:i:s UTC` timestamp of when the user next becomes trial-eligible. Present only when `free_trial_eligible` is `false` and a future date applies. | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1653 (User Not Found)` | 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` | array | No | Optional metadata. Must be a valid array if provided. | **DELETE: Delete Asset** No request body required. **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1651 (Invalid Request Type)` | 405 | "Only user may modify." | Non-owner attempting to modify | | `1604 (File Missing)` | 400 | "Asset upload missing" | No file in POST request | | `1605 (Invalid Input)` | 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": "1234567890123456789", "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). | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1654 (Internal Error)` | 500 | "Internal error initializing app installations." | Backend unavailable | | `1609 (Not Found)` | 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": "1234567890123456789", "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 | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | Various | `app_id` blank/missing, or a field exceeds its max length, or `metadata` is not valid JSON | | `1654 (Internal Error)` | 500 | "Internal error initializing app installations." | Backend unavailable | | `1609 (Not Found)` | 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": "1234567890123456789", "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 | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | Various | `app_id` blank/missing or too long | | `1609 (Not Found)` | 404 | "No installation record found for this app." | No installation exists for this `app_id` | | `1660 (Conflict)` | 409 | "This app is already uninstalled." | Installation already in the uninstalled state | | `1663 (Update Failed)` | 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": "1234567890123456789", "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 | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | Various | `app_id` blank/missing or a field too long | | `1609 (Not Found)` | 404 | "No installation record found for this app." | No installation exists for this `app_id` | | `1660 (Conflict)` | 409 | "Cannot heartbeat an uninstalled app." | Installation is in the uninstalled state | | `1663 (Update Failed)` | 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) **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": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6abcd", "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` because this is the invitee's own authenticated view. | | `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 | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "An invalid invitation ID was supplied." | Invalid ID format | | `1605 (Invalid Input)` | 406 | "Invitation not found." | Invitation does not exist | | `1654 (Internal Error)` | 500 | "Failed to load the invitation profile or its owner." | Profile or owner load failure | **Notes:** - `invitation_key` is included because this is the invitee's own 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 | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "An invalid invitation ID was supplied." | Malformed ID | | `1605 (Invalid Input)` | 406 | "This invitation can no longer be accepted." | Already accepted/declined, or expired | | `1609 (Not Found)` | 404 | "Invitation not found." | No such invitation | | `1680 (Access Denied)` | 401 | "You are not authorized to act on this invitation." | The invitation is not addressed to the authenticated user | **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 | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "An invalid invitation ID was supplied." | Malformed ID | | `1605 (Invalid Input)` | 406 | "This invitation can no longer be declined." | Already accepted/declined | | `1609 (Not Found)` | 404 | "Invitation not found." | No such invitation | | `1680 (Access Denied)` | 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 } ``` **Notes:** - If email is validated, all pending invitations matching that email are accepted. - If email is not validated, use `invitation_key` to identify invitations. --- ### 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 | No | Optional invitation key for users without validated email. | **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": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6abcd", "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. | **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 ### GET /current/user/auth/ Authenticate via HTTP Basic Auth. Returns JWT token. **Auth:** HTTP Basic Auth (`email:password`) **Query Parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `expires` | integer | No | Server default | Custom JWT expiration time in seconds. | **Request Example:** ```bash curl -X GET "https://api.fast.io/current/user/auth/" \ -u "jane.doe@example.com:SecureP@ss123" ``` **Success Response (200 OK):** ```json { "result": true, "expires_in": 86400, "auth_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "2factor": 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). Otherwise has `user` scope (full access). | | `2factor` | boolean | `true` if 2FA is enabled. Token has limited scope until 2FA verification is completed. | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1651 (Invalid Request Type)` | 405 | "The expires time specified is invalid." | Invalid `expires` parameter | | `1650 (Authentication Invalid)` | 401 | "Your credentials were not supplied or invalid." | Missing Basic Auth header | | `1650 (Authentication Invalid)` | 401 | "Username is not valid." | Invalid email format | | `1650 (Authentication Invalid)` | 401 | "Password is not valid." | Invalid password format | | `1650 (Authentication Invalid)` | 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 | | `1650 (Authentication Invalid)` | 401 | "Your account is suspended..." | Account suspended (only after a correct password) | | `1650 (Authentication Invalid)` | 401 | "Your account is locked..." | Account locked | | `1650 (Authentication Invalid)` | 401 | "Your account is suspended due to abuse." | Account flagged for abuse | | `1650 (Authentication Invalid)` | 401 | "Your account is closed by you." | Account closed | **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. - Account enumeration is intentionally not possible: an unknown email, an SSO-only account (no password), and a wrong password all return the identical `1650`/401/"Your credentials supplied are invalid." response with matched timing. SSO-only accounts must sign in via their provider instead. --- ### 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. Only validates that the JWT is structurally valid and not expired. --- ### 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:12345:rw", "org:67890:rw"], "scopes_detail": [], "is_agent": true, "agent_name": "My MCP Agent", "full_access": false } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `auth_type` | string | Token type: `"jwt_v2"` (scoped JWT), `"jwt_v1"` (legacy JWT), `"api_key"` (unscoped API key), or `"api_key_scoped"` (API key with scopes). | | `scopes` | array | Array of scope strings in `entity_type:entity_id:access_mode` format. Empty for v1 JWTs and unscoped API keys. Populated for scoped API keys. | | `scopes_detail` | array | Hydrated scope details with entity information. Empty when scopes are empty. | | `is_agent` | boolean | Whether the token represents an agent. | | `agent_name` | string or null | Agent display name. `null` if not set or not an agent. | | `full_access` | boolean | Whether the token has unrestricted access. `true` for v1 JWTs and unscoped API keys. `false` for scoped API keys. | --- ### 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"]`). Omit or `null` for full access. | | `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" ``` **Success Response (200 OK):** ```json { "result": true, "api_key": "abcdefghij1234567890abcdefghij12" } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `api_key` | string | The newly created API key. **Only shown once** -- store it securely. | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1650 (Authentication Invalid)` | 401 | "Your credentials were not supplied or invalid." | Missing or invalid JWT | | `1650 (Authentication Invalid)` | 401 | "The scope of your credentials are not sufficient." | JWT scope not `user` or `admin` | | `1667 (Max Limit)` | 429 | "You are at the maximum number of API keys, {max}." | Maximum key limit reached | | `1605 (Invalid Input)` | 406 | "You provided an invalid Memo." | Invalid memo format | **Notes:** - 2FA verification is required if 2FA is enabled. - The full key value is only returned at creation time. Subsequent reads return masked versions. --- #### GET /current/user/auth/key/{key_id}/ Get details of an API key (key value is masked). **Auth:** Required (JWT, scope: `user` or `admin`) **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/key_12345/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true, "api_key": { "id": "key_12345", "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" } } ``` **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 full access. | | `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. | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "You provided an invalid Token to get details of." | Invalid key ID format | | `1609 (Not Found)` | 404 | Key not found | Key does not exist | --- #### POST /current/user/auth/key/{key_id}/ Update an existing API key's memo, scopes, agent_name, and/or expires. **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 (restore full access). | | `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/key_12345/" \ -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": "key_12345", "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 } } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1609 (Not Found)` | 404 | Key not found | Key does not exist or belongs to another user | | `1605 (Invalid Input)` | 406 | Various | Invalid scopes, agent_name, or expires format | **Notes:** - Only the fields you send are updated; omitted fields remain unchanged. - Send empty string or `"null"` to clear a nullable field. - 2FA verification is required if 2FA is enabled. - The update path is taken only when `{key_id}` is a well-formed key identifier. If `{key_id}` is not a valid key-ID, the request is NOT rejected — it falls through to the create path (see `POST /current/user/auth/key/`) and mints a brand-new key. Always confirm the `{key_id}` you send is valid before treating a call as an update. --- #### DELETE /current/user/auth/key/{key_id}/ Delete an API key. **Auth:** Required (JWT, scope: `user` or `admin`) **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/key_12345/" \ -H "Authorization: Bearer {jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "You provided an invalid Token to Delete." | Invalid key ID format | | `1609 (Not Found)` | 404 | "You provided a Token that was not found." | Key not found or belongs to another user | | `1610 (Delete Failed)` | 500 | "There was an error deleting the API Key." | Internal deletion failure | **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) **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": "key_12345", "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" }, { "id": "key_67890", "api_key": "****************************cd34", "memo": "Backup Script", "created": "2024-02-20 14:00:00 UTC", "scopes": null, "agent_name": null, "expires": 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 full access. | | `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. | --- ### Two-Factor Authentication (2FA) #### GET /current/user/auth/2factor/ Get current 2FA status. **Auth:** Required (JWT, scope: `user` or `admin`) **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). | --- #### POST /current/user/auth/2factor/{channel}/ Enable 2FA on the account. **Auth:** Required (JWT, scope: `user` or `admin`) **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 | |------------|-------------|---------|-------| | `1669 (Already Exists)` | 409 | "2Factor already added, please remove first." | 2FA already enabled | | `1605 (Invalid Input)` | 406 | "An invalid channel was supplied." | Invalid channel name | | `1658 (Not Acceptable)` | 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 (JWT) **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 } ``` **Verification Failed (406 Not Accepted):** ```json { "result": false } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "An invalid token was supplied to validate." | Invalid token format | | `1658 (Not Acceptable)` | 406 | "2Factor is not enabled." | 2FA not configured | **Notes:** - If 2FA is already in the `enabled` state, returns success without modification. - This is the final step of the 2FA setup flow. --- #### 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 Example:** ```bash curl -X POST "https://api.fast.io/current/user/auth/2factor/auth/123456/" \ -H "Authorization: Bearer {twofactor_jwt_token}" ``` **Success Response (200 OK):** ```json { "result": true, "expires_in": 86400, "auth_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` **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 | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "An invalid token was supplied to authenticate." | Invalid token format | | `1658 (Not Acceptable)` | 406 | "2Factor is not enabled on this account." | 2FA not enabled | | `1658 (Not Acceptable)` | 406 | "The supplied token failed to authenticate." | Wrong 2FA code | | `1650 (Authentication Invalid)` | 401 | "Internal Error." | JWT creation failure | --- #### DELETE /current/user/auth/2factor/{token}/ Disable (remove) 2FA from the account. **Auth:** Required (JWT, scope: `user` or `admin`) **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 | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "An invalid token was supplied, valid token required to remove 2Factor." | Invalid token format | | `1658 (Not Acceptable)` | 406 | "The supplied token failed to authenticate." | Token verification failed | | `1654 (Internal Error)` | 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`, or `admin` JWT scope). **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 | |------------|-------------|---------|-------| | `1650 (Authentication Invalid)` | 401 | "Your credentials were not supplied or invalid." | Invalid JWT | | `1650 (Authentication Invalid)` | 401 | "The scope of your credentials are not sufficient." | Wrong JWT scope | | `1658 (Not Acceptable)` | 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 ``` 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/{code}/ - 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 ``` --- ## 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 | |------------|-------------|---------|-------| | `1650 (Authentication Invalid)` | 401 | "Authentication required" | Missing or invalid JWT token | | `1605 (Invalid Input)` | 406 | "This value should not be blank." | Missing or empty `search` parameter | | `1654 (Internal Error)` | 500 | "Internal error" | Search backend unavailable | **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`, or `type_mismatch`. 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 | Code | Description | HTTP Status | |------|-------------|-------------| | 1600 | Internal Error | 500 Internal Server Error | | 1605 | Invalid Input | 406 Not Acceptable | | 1658 | Not Acceptable | 406 Not Acceptable | | 1607 | 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 | | 1671 | Rate Limited | 429 Too Many Requests | | 1680 | Access Denied | 401 Unauthorized | | 1670 | Restricted | 406 Not Acceptable | | 1677 | Locked | 423 Locked | | 1673 | SSO Auth Error | 401 Unauthorized | ## 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 1671 (Rate Limited). 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 (Basic Auth) | 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 - 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 - Metadata discovery (RFC 8414, RFC 9728) for automated server/resource configuration **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. **OPTIONS preflight note.** The RFC envelope above applies to GET / POST / PUT / DELETE responses from these endpoints. The two discovery endpoints (`/.well-known/oauth-authorization-server/` and `/.well-known/oauth-protected-resource/`) answer `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. 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 | | 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"], "token_endpoint_auth_methods_supported": ["none"], "code_challenge_methods_supported": ["S256"], "resource_indicators_supported": true, "scopes_supported": ["user", "org", "workspace", "all_orgs", "all_workspaces", "all_shares", "all_sign_envelopes"], "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 | | `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) | | `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/`) | | `service_documentation` | string | URL to service documentation | **Error Responses:** | Scenario | HTTP Status | Error | |----------|-------------|-------| | Wrong HTTP method | 405 | `1651 (Invalid Request Type)` | --- ### 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 { "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 | |-------|------|-------------| | `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 characters | 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"]` | -- | Requested grant types | | `response_types` | JSON array | No | `["code"]` | -- | Requested response types | ```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://api.fast.io/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 | Granted grant types | | `response_types` | array | Granted response types | | `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 characters | | `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 | 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 | | `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_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 - `grant_types` including `authorization_code` - `response_types` including `code` - `token_endpoint_auth_method` set to `none` 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/?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 | Yes | -- | Must be `"code"` | 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 | | `resource` | string | No | -- | Must be a valid resource URL (e.g., `https://mcp.fast.io/mcp`) | RFC 8707 resource indicator | | `agent_name` | string | No | -- | Max 128 characters | Display name of the requesting agent | | `response_format` | string | No | -- | `"json"` | Set to `"json"` for JSON response instead of 302 redirect | **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 exactly one of these named values (or omit `scope` for the default `user`). A `scope` value outside this set is not a valid access request. **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.) **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:12345:rw"]`). The token endpoint returns `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=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 ``` **Response (response_format=json -- 200 OK):** ```json { "result": true, "auth_request_id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "client_name": "My App", "scope": "user" } ``` **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 granted scope | | `agent_name` | string | Agent display name (present if `agent_name` was provided) | **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 | | 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 | -- | JSON array of entity IDs or scope strings | Entity IDs for scoped access (e.g., `"[12345, 67890]"`) or explicit scope strings (e.g., `'["org:12345:rw"]'`) | | `access_mode` | string | No | `"rw"` | `"r"` (read-only) or `"rw"` (read-write) | Access level for scoped tokens | | `agent_name` | string | No | -- | Max 128 characters | Override the agent display name from the GET request | ```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=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" ``` **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 | | `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 or not found | `1609 (Not Found)` | 404 | | Internal processing error | `1654 (Internal Error)` | 500 | | Invalid `access_mode` | `1605 (Invalid Input)` | 406 | | Scope validation failed | `1605 (Invalid Input)` | 406 | --- #### 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=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" ``` **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": "rw" } ``` `resource` is the RFC 8707 resource indicator the client supplied at initiate (or `null`), and `access_mode` is the requested access mode (`"r"` or `"rw"`, or `null` when the client did not supply one). Both are returned server-authoritatively from the stored authorization request so consent screens can render them without trusting URL parameters. **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"` or `"button"` (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`) | **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, signs in (supports SSO), 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"`) ### 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": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...", "scope": "org", "scopes": "[\"org:12345:rw\",\"org:67890: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 token for obtaining new access tokens. Store securely. Returned unchanged on refresh (no rotation). | | `scope` | string | Granted scope type | | `scopes` | string | JSON-encoded array of scope strings in `entity_type:entity_id:access_mode` format (v2.0 tokens only, absent for `user` scope without explicit scopes) | | `agent_name` | string | Agent display name (v2.0 only, present when set during authorization) | ##### 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=dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...&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). **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 authorization code | `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=dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4..." ``` **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:12345:rw"], "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" } ], "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` | array | Granted scope strings in `entity_type:entity_id:access_mode` format. Empty array for full-access (`user` scope) sessions. | | `sessions[].agent_name` | string or null | Agent display name if set during authorization, otherwise `null` | | `sessions[].device_name` | string | Human-readable device description | | `sessions[].device_type` | string | Device category: `desktop`, `mobile`, `tablet`, or `unknown` | | `sessions[].ip_address` | string | IP address from the most recent refresh | | `sessions[].last_used` | string | Last token refresh timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `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`) | | `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:12345:rw"], "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" } } ``` **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` and/or `agent_name` 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 | At least one of `device_name` or `agent_name` must be provided. ```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:12345:rw"], "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" } } ``` **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 | | Neither parameter provided | `1605 (Invalid Input)` | 406 | | `device_name` exceeds 128 chars | `1605 (Invalid Input)` | 406 | | `agent_name` exceeds 128 chars | `1605 (Invalid Input)` | 406 | | 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:12345:rw", "org:67890:rw"], "scopes_detail": [ { "entity_type": "org", "entity_id": "12345", "access_mode": "rw", "name": "Acme Corp", "domain": "acme" }, { "entity_type": "org", "entity_id": "67890", "access_mode": "rw", "name": "Beta Inc", "domain": "beta" } ], "is_agent": true, "agent_name": "My MCP Agent", "full_access": false } ``` **Response (200 OK -- API-key auth with scopes and expiration):** ```json { "result": true, "auth_type": "api_key_scoped", "scopes": ["org:12345:rw"], "scopes_detail": [ { "entity_type": "org", "entity_id": "12345", "access_mode": "rw", "name": "Acme Corp", "domain": "acme" } ], "is_agent": true, "agent_name": "CI Pipeline", "full_access": 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), `"jwt_v1"` (legacy JWT), `"api_key"` (unscoped API key), or `"api_key_scoped"` (API key whose stored token record has a non-empty scopes claim) | | `scopes` | array | Scope strings in `entity_type:entity_id:access_mode` format. Empty for v1.0 JWT and API key. | | `scopes_detail` | array | Hydrated scope objects with entity names and metadata. Empty for v1.0 JWT and API key. | | `is_agent` | boolean | Whether the token represents an agent | | `agent_name` | string or null | Agent display name if set, otherwise `null` | | `full_access` | boolean | `true` for v1.0 JWT, API keys, and v2.0 tokens with `user:*:rw` scope | | `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. | **Scope Detail Fields:** Each entry in `scopes_detail` contains entity-specific fields: | Field | Type | Present For | Description | |-------|------|-------------|-------------| | `entity_type` | string | All | `user`, `org`, `workspace`, or `share` | | `entity_id` | string | All | Numeric ID or `*` for wildcard | | `access_mode` | string | All | `r` (read) or `rw` (read/write) | | `label` | string | Full access / wildcard | Human-readable label (e.g., "Full Access", "All Organizations") | | `name` | string | Org, Workspace | Entity display name | | `domain` | string | Org | Organization subdomain | | `folder_name` | string | Workspace | Workspace URL slug | | `org_id` | integer | Workspace, Share | Parent organization ID | | `org_name` | string | Workspace, Share | Parent organization name | | `org_domain` | string | Workspace, Share | Parent organization subdomain | | `title` | string | Share | Share display title | | `share_type` | string | Share | `send`, `receive`, or `exchange` | | `workspace_id` | integer | Share | Parent workspace ID | | `workspace_name` | string | 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 -> 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 displayed on screen for 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": "eyJ...", "scope": "user" } 7. CLIENT -> API: Use access token for requests GET /current/user/me/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=eyJ... &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=eyJ... ``` --- ## 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": 1605, "text": "Error message", "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/ # 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_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 | `null` | | `background_color` | string (JSON) | -- | -- | JSON-encoded color object | `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. --- ## 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`, `user_status`, `member`, `closed`, `locked` (member-only), `suspended` (member-only), `created`, `updated`, `parent`, `capabilities`, `accent_color`, `background_mode`, `background`, `use_background`, `background_color`, `homepage`, `subscriber` (member-only), `subscriber_cancel` (admin-only), `subscriber_trial_until` (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`), `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`) that list-view subscription chips render. 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 currently disabled by default, so this is normally `false`). Read it to decide whether to surface signing UI rather than inferring availability from a denied request. --- ## 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 JSON. | | `background_color` | string (JSON) | No | Background color as JSON. | | `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 Growth). 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. New orgs begin a 14-day free trial of a paid plan. A user may start only one free trial every 60 days across all orgs they own; within that window a new org subscribes with immediate payment instead of a trial. **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) — free 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 is within the per-user 60-day trial cooldown, no free trial is offered — the org still subscribes but bills immediately: ```json { "result": true, "org": { "...": "..." }, "has_free_trial": false, "requires_payment": true, "is_agent": false, "no_trial_reason": "A free trial was started recently. A new free trial is available in 43 days.", "trial_available_at": "2026-08-05 12:00:00 UTC" } ``` `requires_payment` is always `true` for new orgs (the legacy free tier is closed). 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` | string/null | Brand color | | `org.closed` | boolean | Whether org is closed | | `org.suspended` | boolean | Whether org is suspended | | `has_free_trial` | boolean | Whether a free trial is available at checkout. `true` when the owner is eligible; `false` when within the per-user 60-day cooldown (one free trial every 60 days). | | `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 free trial is unavailable (only when `has_free_trial` is `false`) | | `trial_available_at` | string | UTC timestamp (`Y-m-d H:i:s UTC`) when the user's next free trial becomes available (only present when blocked by the cooldown) | **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 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 | | `1680 (Access Denied)` | 401 | GEO/risk restriction message | Request blocked by geo/risk check | --- ### 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": "#0066CC", "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` | string/null | Brand color | | `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., `"solo_monthly"`, `"business_v2_monthly"`, `"growth_monthly"`). Member+ only. | | `org.subscriber` | boolean | Whether the org has an active subscription (includes credit availability for free-tier orgs). 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.subscriber_trial_credits` | integer/null | Credits remaining in the current billing period. Admin+ only. | **Error responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1680 (Access Denied)` | 401 | "You have not been granted access to this Org." | Insufficient permission | | `1688 (Subscription Required)` | 402 | "The organization does not have an active subscription." | Org has no active subscription or free-tier credits exhausted | | `1696 (Credit Limit Exceeded)` | 402 | "You have exceeded your credit limit." | Free-tier credit limit exceeded (includes usage details) | --- ### 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": "#0066CC" } } ``` **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` | string/null | Brand color | --- ### 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). Send `"null"` to clear. | | `description` | string | Description. Send `"null"` or `""` to clear. | | `industry` | string | Industry type from predefined list. | | `accent_color` | string (JSON) | Brand accent color as JSON. Send `"null"` to clear. | | `background_color` | string (JSON) | Background color as JSON. 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_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 | | `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. **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 | | `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, "assets": [ { "name": "logo", "mime_types": ["image/png", "image/jpeg"], "max_size": 5242880 } ] } ``` --- ### 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": { "url": "https://assets.fast.io/org/logo.png", "mime_type": "image/png", "size": 45678 } } } ``` --- ### 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 array) | No | Additional metadata for the asset. | **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 | | `1605 (Invalid Input)` | 406 | "metadata invalid" | Metadata is not a valid array | --- ### 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. 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 **Request parameters (for adding an existing user by ID):** | Name | Type | Required | Description | |------|------|----------|-------------| | `permissions` | string | Yes | Permission level: `"member"`, `"admin"`. Cannot add as `"owner"`. | | `expires` | string (datetime) | No | Membership expiration date. | | `notify` | string | No | Notification preference. | | `force_notification` | boolean | No | Force send a notification email. | **Request parameters (for inviting by email):** | Name | Type | Required | Description | |------|------|----------|-------------| | `permissions` | string | Yes | Permission level for the invitation: `"member"`, `"admin"`. | | `message` | string | No | Custom invitation message. | | `expires` | string (datetime) | No | Invitation expiration. | **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": "eA1B2C3D4E5F6G7H8J9K0L1M2N3O4", "invitee_email": "jane@example.com", "entity_type": "org", "state": "pending", "created": "2024-01-15 10:30:00 UTC" } } ``` **Error responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Invalid permission specified." | Invalid permission value | | `1692 (Cannot Add As Owner)` | 500 | "Adding a member as an owner is not allowed" | Tried to add as owner (use transfer_ownership) | | `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" }, { "id": "1234567890123456780", "account_type": "agent", "email_address": "bot@example.com", "first_name": "Service", "last_name": "Bot", "permissions": "admin" } ], "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"` | | `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, "member": { "id": "9876543210987654321", "account_type": "human", "email_address": "jane@example.com", "first_name": "Jane", "last_name": "Smith", "permissions": "admin", "invite": "accepted", "notify": "Email me", "expires": null } } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `member.id` | string | 19-digit numeric user ID | | `member.account_type` | string | `"human"` or `"agent"` | | `member.email_address` | string | User's email | | `member.first_name` | string | First name | | `member.last_name` | string | Last name | | `member.permissions` | string | Role: `"owner"`, `"admin"`, `"member"` | | `member.invite` | string | Invitation status | | `member.notify` | string | Notification preference | | `member.expires` | string/null | Membership expiration (`YYYY-MM-DD HH:MM:SS UTC`) or null for no expiry | **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. **Request parameters (all optional):** | Name | Type | Description | |------|------|-------------| | `permissions` | string | New permission level (`"member"`, `"admin"`) | | `expires` | string (datetime) | Membership expiration date | | `notify` | string | Notification preference | **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 | --- ### Transfer Org Ownership ``` POST /current/org/{org_id}/member/{user_id}/transfer_ownership/ ``` Auth required. Owner only. Transfers ownership of the org to the specified member. The current owner is demoted to admin. **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 } ``` **Error responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "You cannot transfer ownership to yourself." | Target is the current user | | `1605 (Invalid Input)` | 406 | "The membership you specified does not exist." | User is not an org member | | `1605 (Invalid Input)` | 406 | "Member is already an owner." | Target is already the owner | | `1663 (Update Failed)` | 500 | "Failed to update owner of the org." | Internal error | --- ### 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 and the user's email domain must match. User is added as a Member. **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 | | `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 | --- ### 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": "eA1B2C3D4E5F6G7H8J9K0L1M2N3O4", "inviter": "John Doe", "invitee_email": "jane@example.com", "entity_type": "org", "state": "pending", "created": "2024-01-15 10:30:00 UTC", "expires": "2024-02-15 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[].invitee_email` | string | Email address of the invitee | | `invitations[].entity_type` | string | Always `"org"` for org invitations | | `invitations[].state` | string | Invitation state: `"pending"`, `"accepted"`, `"declined"` | | `invitations[].created` | string | Creation timestamp | | `invitations[].expires` | string/null | Expiration timestamp | **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. **Request parameters (all optional):** | Name | Type | Description | |------|------|-------------| | `state` | string | New invitation state | | `permissions` | string | Updated permission level | | `expires` | string (datetime) | Updated expiration date | **curl example:** ```bash curl -X POST "https://api.fast.io/current/org/1234567890123456789/members/invitation/eA1B2C3D4E5F6G7H8J9K0L1M2N3O4/" \ -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 | "Invitation not found." | Invitation does not exist | | `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 | | `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. **curl example:** ```bash curl -X DELETE "https://api.fast.io/current/org/1234567890123456789/members/invitation/eA1B2C3D4E5F6G7H8J9K0L1M2N3O4/" \ -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 | --- ## Ownership Transfer / Claim (Agent-Owned Orgs) When an **agent account** creates and owns an org, this flow lets a **human user** take over ownership. The agent (owner of an org on the agent plan) mints a short-lived transfer **token**, shares it with the human, and the human claims the org with that token. On a successful claim the human becomes the new **owner** of the org and every workspace under it, and the agent that created the org is **demoted to admin** (on the org and on each workspace). The org is also moved off the agent plan onto the transfer target plan. This is distinct from **Transfer Org Ownership** (above), which reassigns ownership between two existing members of the same org. The token flow crosses the agent→human boundary and does not require the human to already be a member. **Token lifecycle:** tokens are `pending` when created, become `claimed` once a human claims them, `expired` after their expiry (default 72 hours from creation), or `deleted` if revoked. Only a `pending`, non-expired token is claimable. An org may have at most 5 active (pending) tokens at a time. The raw token string is a 64-character secret returned by the create, list, and delete endpoints (so the agent can retrieve and share it). The **public details** endpoint deliberately omits the raw token string. ### Create Transfer Token ``` POST /current/org/{org_id}/transfer/token/create/ ``` Auth required. **Owner only**, and the caller must be an **agent account** whose org is on the **agent plan**. Mints a new pending transfer token. **curl example:** ```bash curl -X POST "https://api.fast.io/current/org/1234567890123456789/transfer/token/create/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "transfer_token": { "id": "eA1B2C3D4E5F6G7H8J9K0L1M2N3O4", "token": "a1b2c3d4e5f6...{64-char secret}", "org_id": 1234567890123456789, "state": "pending", "expires": "2026-07-10 16:37:29 UTC", "created": "2026-07-07 16:37:29 UTC" } } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `transfer_token.id` | string | Token identifier (opaque). Use this to revoke the token. | | `transfer_token.token` | string | The 64-character secret. Share this with the human who will claim the org. | | `transfer_token.org_id` | integer | Numeric org ID the token transfers | | `transfer_token.state` | string | `"pending"`, `"claimed"`, `"expired"`, or `"deleted"` | | `transfer_token.expires` | string | Expiration timestamp (`Y-m-d H:i:s UTC`) | | `transfer_token.created` | string | Creation timestamp (`Y-m-d H:i:s UTC`) | **Error responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1680 (Access Denied)` | 401 | "Only agent accounts can create org transfer tokens." | Caller is not an agent account | | `1680 (Access Denied)` | 401 | "Transfer tokens can only be created for organizations on the agent plan." | Org is not on the agent plan | | `1658 (Not Acceptable)` | 406 | "Maximum of 5 active transfer tokens per organization. Delete existing tokens first." | Active token limit reached | | `1654 (Internal Error)` | 500 | "Failed to create transfer token." | Token generation or storage failed | --- ### List Transfer Tokens ``` GET /current/org/{org_id}/transfer/token/list/ ``` Auth required. **Owner only**, and the caller must be an **agent account**. Lists the org's active (pending) transfer tokens so an agent can track outstanding handoff requests. **curl example:** ```bash curl -X GET "https://api.fast.io/current/org/1234567890123456789/transfer/token/list/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "results": 1, "transfer_tokens": [ { "id": "eA1B2C3D4E5F6G7H8J9K0L1M2N3O4", "token": "a1b2c3d4e5f6...{64-char secret}", "org_id": 1234567890123456789, "state": "pending", "expires": "2026-07-10 16:37:29 UTC", "created": "2026-07-07 16:37:29 UTC" } ] } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `results` | integer | Number of active tokens returned | | `transfer_tokens` | array | Array of token objects (same shape as Create Transfer Token) | **Error responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1680 (Access Denied)` | 401 | "Only agent accounts can list org transfer tokens." | Caller is not an agent account | | `1654 (Internal Error)` | 500 | "Unable to retrieve transfer tokens." | Token lookup failed | --- ### Revoke Transfer Token ``` DELETE /current/org/{org_id}/transfer/token/{token_id}/ ``` Auth required. **Owner only**, and the caller must be an **agent account**. Soft-deletes a pending transfer token so it can no longer be claimed. `{token_id}` is the token's opaque `id` (not the raw secret). The token must belong to the org in the path. **curl example:** ```bash curl -X DELETE "https://api.fast.io/current/org/1234567890123456789/transfer/token/eA1B2C3D4E5F6G7H8J9K0L1M2N3O4/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "transfer_token": { "id": "eA1B2C3D4E5F6G7H8J9K0L1M2N3O4", "token": "a1b2c3d4e5f6...{64-char secret}", "org_id": 1234567890123456789, "state": "deleted", "expires": "2026-07-10 16:37:29 UTC", "created": "2026-07-07 16:37:29 UTC" } } ``` **Error responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1680 (Access Denied)` | 401 | "Only agent accounts can delete org transfer tokens." | Caller is not an agent account | | `1680 (Access Denied)` | 401 | "Transfer token does not belong to this organization." | Token's org does not match the path org | | `1609 (Not Found)` | 404 | "Transfer token not found." | No token with that ID | | `1658 (Not Acceptable)` | 406 | "Token cannot be deleted. It may have already been claimed, expired, or deleted." | Token is not in a pending state | | `1654 (Internal Error)` | 500 | "Failed to delete transfer token." | Persisting the state change failed | --- ### Get Public Token Details ``` GET /current/org/transfer/claim/public/details/?token={token} ``` **No authentication required.** IP-rate-limited. Returns the token state plus the target org and agent-creator details so a human can review a handoff before claiming it. The raw token string is **not** echoed back. **Query parameters:** | Name | Type | Required | Description | |------|------|----------|-------------| | `token` | string | Yes | The 64-character transfer token secret. | **curl example:** ```bash curl -X GET "https://api.fast.io/current/org/transfer/claim/public/details/?token=a1b2c3d4e5f6...{64-char secret}" ``` **Response (200 OK):** ```json { "result": true, "transfer_token": { "id": "eA1B2C3D4E5F6G7H8J9K0L1M2N3O4", "state": "pending", "is_claimable": true, "expires": "2026-07-10 16:37:29 UTC", "created": "2026-07-07 16:37:29 UTC" }, "org": { "id": "1234567890123456789", "domain": "acme-corp", "name": "Acme Corporation", "description": "Leading provider of innovation", "logo": "https://assets.fast.io/org/logo.png", "accent_color": "#0066CC", "plan": "agent" }, "created_by": { "id": "1234567890123456780", "account_type": "agent", "first_name": "Service", "last_name": "Bot", "email_address": "bot@example.com", "profile_pic": null } } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `transfer_token.id` | string | Token identifier (opaque) | | `transfer_token.state` | string | `"pending"`, `"claimed"`, `"expired"`, or `"deleted"` | | `transfer_token.is_claimable` | boolean | Whether the token can currently be claimed (pending and not expired) | | `transfer_token.expires` | string | Expiration timestamp (`Y-m-d H:i:s UTC`) | | `transfer_token.created` | string | Creation timestamp (`Y-m-d H:i:s UTC`) | | `org` | object | Public org details (identity, branding, plan). Omitted if the org can no longer be loaded. | | `created_by` | object | Public profile of the agent that created the token. Omitted if the creator can no longer be loaded. | **Error responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | Token validation message | `token` missing, blank, or not the expected length | | `1609 (Not Found)` | 404 | "Transfer token not found or invalid." | No token matches the supplied secret | --- ### Claim Org (Human User) ``` POST /current/org/transfer/claim/ ``` Auth required. The caller must be a **human user** (agent accounts cannot claim). Consumes a pending transfer token and takes ownership of the agent-owned org. On success: the human becomes **owner** of the org and of every workspace under it; the agent that created the org is **demoted to admin** on the org and on each workspace; the org is moved off the agent plan onto its transfer target plan; and the token is marked `claimed`. **Request parameters:** | Name | Type | Required | Description | |------|------|----------|-------------| | `token` | string | Yes | The transfer token secret (accepted as a POST body field or a query parameter). | **curl example:** ```bash curl -X POST "https://api.fast.io/current/org/transfer/claim/" \ -H "Authorization: Bearer {jwt_token}" \ -d "token=a1b2c3d4e5f6...{64-char secret}" ``` **Response (200 OK):** ```json { "result": true, "org": { "id": "1234567890123456789", "domain": "acme-corp", "plan": "solo_monthly" }, "previous_owner": { "id": "1234567890123456780", "account_type": "agent" }, "workspaces_transferred": 3 } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `org.id` | string | 19-digit numeric org ID now owned by the caller | | `org.domain` | string | Org URL-safe subdomain | | `org.plan` | string | The plan the org was moved onto after transfer | | `previous_owner.id` | string | 19-digit numeric ID of the agent that previously owned the org (now an admin) | | `previous_owner.account_type` | string | Always `"agent"` | | `workspaces_transferred` | integer | Number of workspaces re-owned to the caller | **Error responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1680 (Access Denied)` | 401 | "Agent accounts cannot claim organizations. Only human users can claim ownership." | Caller is an agent account | | `1609 (Not Found)` | 404 | "Transfer token not found or invalid." | No token matches the supplied secret | | `1609 (Not Found)` | 404 | "The organization associated with this token was not found." | The org could not be loaded | | `1658 (Not Acceptable)` | 406 | "This transfer token has expired." | Token is past its expiry | | `1658 (Not Acceptable)` | 406 | "This transfer token has already been used or is no longer available." | Token is not pending | | `1658 (Not Acceptable)` | 406 | "This organization is no longer eligible for transfer." | Org is no longer on the agent plan | | `1654 (Internal Error)` | 500 | "Failed to complete organization transfer." | Ownership or plan change could not be persisted | --- ## 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. **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": "#0066CC", "closed": false, "suspended": false, "subscriber": true, "user_status": "joined", "member": true } ] } ``` **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` | string/null | Brand color | | `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`). **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": "#FF6600", "closed": false, "suspended": false, "subscriber": true, "user_status": "available", "member": 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). **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": "#0066CC", "closed": false, "suspended": false, "user_status": "joined" } ] } ``` **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": "#FF6600", "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, "technology": { "title": "Technology", "description": "Software, hardware, and IT services" }, "healthcare": { "title": "Healthcare", "description": "Medical, pharmaceutical, and health services" }, "finance": { "title": "Finance", "description": "Banking, investment, and financial services" }, "education": { "title": "Education", "description": "Schools, universities, and training providers" } } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `{key}` | string | Machine-readable industry identifier (use this value in create/update requests) | | `{key}.title` | string | Human-readable display name | | `{key}.description` | string | Brief description of the industry category | --- ## Billing ### Create or Update Subscription ``` POST /current/org/{org_id}/billing/ ``` Auth required. Admin or above. Creates or updates the org's billing subscription. **Request parameters:** | Name | Type | Required | Description | |------|------|----------|-------------| | `billing_plan` | string | No | Target plan ID (must be a valid, currently-offered paid plan, e.g., `"solo_monthly"`, `"business_v2_monthly"`, `"growth_monthly"`). Each plan also has an annual variant (e.g., `"business_v2_annual"`). Plan IDs that are not currently offered are rejected here. | **curl example:** ```bash curl -X POST "https://api.fast.io/current/org/1234567890123456789/billing/" \ -H "Authorization: Bearer {jwt_token}" \ -d "billing_plan=business_v2_monthly" ``` **Response (201 Created) -- new subscription:** ```json { "result": true, "subscription": { "...": "..." }, "customer": { "...": "..." }, "setup_intent": { "id": "{setup_id}", "client_secret": "{setup_id}_secret", "status": "requires_payment_method" }, "is_active": false, "is_trial_eligible": true, "public_key": "{public_key}" } ``` **Response (202 Accepted) -- subscription updated.** **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 `payment_recovery` object (see *Get Subscription Details* above for its shape); confirm its `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. **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 | "Cannot create subscription for a free plan." | Tried to subscribe to free plan | | `1658 (Not Acceptable)` | 406 | "An error occurred updating your subscription..." | Subscription update failed | | `1658 (Not Acceptable)` | 406 | "An error occurred creating the payment intent..." | Intent creation failed | --- ### 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. **curl example:** ```bash curl -X DELETE "https://api.fast.io/current/org/1234567890123456789/billing/" \ -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): ```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`. - `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 | --- ### 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, "subscription": { "...": "..." }, "customer": { "...": "..." }, "setup_intent": { "...": "..." }, "payment_intent": { "...": "..." }, "payment_recovery": { "...": "..." }, "is_active": true, "is_trial_eligible": false, "is_cancelled": false, "current_plan": "business_v2_monthly", "previous_plan": null, "public_key": "{public_key}" } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `subscription` | object | Payment provider subscription details | | `customer` | object | Payment provider customer details | | `setup_intent` | object/null | Active setup intent if exists | | `payment_intent` | object/null | Active payment intent if exists | | `payment_recovery` | object | Present (non-empty) when an existing subscription's invoice is unpaid — status `incomplete`, `past_due`, or `unpaid` (e.g. a declined first payment). 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}`. The client completes payment by confirming that PaymentIntent's `client_secret` with a (new) card. Empty when there is nothing to recover. | | `is_active` | boolean | Whether subscription is currently active | | `is_trial_eligible` | boolean | Whether a free trial is available for this org. `false` once the org has ever subscribed, or when the owner is within the per-user 60-day trial cooldown (one free trial every 60 days). | | `is_cancelled` | boolean | Whether subscription is cancelled | | `current_plan` | string/null | Current plan ID | | `previous_plan` | string/null | Previous plan ID (if changed) | | `public_key` | string | Payment provider publishable key | --- ### 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": true, "free_org_mode": true, "org_id": "1234567890123456789", "plan": "free", "over_limit": false, "usage": { "credits_used": 1200, "credit_limit": 10000, "credits_remaining": 8800, "usage_percentage": 12.0 }, "period": { "start": "2025-01-15 10:00:00 UTC", "end": "2025-02-14 10:00:00 UTC", "days_total": 30, "days_elapsed": 10, "days_remaining": 20 }, "renewal": { "interval_days": 30, "next_renewal": "2025-02-14 10:00:00 UTC" }, "trial": null } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `credit_limits_enabled` | boolean | Whether the plan enforces credit limits | | `free_org_mode` | boolean | `true` for free orgs (reductive model) | | `over_limit` | boolean | Whether the org has exceeded its credit limit | | `usage.credits_used` | integer | Credits consumed in the current period | | `usage.credit_limit` | integer | 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 | Trial info if applicable | **Credit costs:** storage (100/GB), bandwidth (212/GB), AI tokens (1/100 tokens), document ingestion (10/page), video ingestion (5/sec), image ingestion (5/image), file conversions (25/each). --- ### 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": [ { "id": "1234567890123456789", "account_type": "human", "email_address": "user@example.com", "parents": { "9876543210987654321": { "permission": "member", "date_joined": "2024-01-15 10:30:00 UTC" } } } ] } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `billable_members` | array | Array of billable member objects | | `billable_members[].id` | string | 19-digit user ID | | `billable_members[].account_type` | string | `"human"` or `"agent"` | | `billable_members[].email_address` | string | User's email | | `billable_members[].parents` | object | Map of workspace IDs to membership details | --- ### 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 (e.g., `"storage_bytes"`, `"bandwidth_bytes"`, `"ai_tokens"`) | | `start_time` | string (datetime) | No | 30 days ago | Start of time range | | `end_time` | string (datetime) | No | Now | End of time range | | `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. **curl example:** ```bash curl -X GET "https://api.fast.io/current/org/1234567890123456789/billing/usage/meters/list/?meter=storage_bytes&start_time=2024-01-01+00:00:00&end_time=2024-01-31+23:59:59" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "usage": { "meter": "storage_bytes", "total": 1073741824, "cost": 0.50, "credits": 500, "start_time": "2024-01-01 00:00:00 UTC", "end_time": "2024-01-31 23:59:59 UTC", "interval_hours": 24, "workspace_id": null, "share_id": null, "data_points": [ { "start_time": "2024-01-01 00:00:00 UTC", "end_time": "2024-01-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 (auto-calculated, max 30 points) | | `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 Growth, each offered in a monthly and an annual interval (e.g., `solo_monthly` / `solo_annual`, `business_v2_monthly` / `business_v2_annual`, `growth_monthly` / `growth_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):** ```json { "result": true, "results": 3, "defaults": { "pro": "solo_monthly", "business": "business_v2_monthly", "growth": "growth_monthly" }, "plans": [ { "id": "solo_monthly", "name": "Starter", "category": "pro", "amount": 2900 }, { "id": "business_v2_monthly", "name": "Business", "category": "business", "amount": 9900 }, { "id": "growth_monthly", "name": "Growth", "category": "growth", "amount": 29900 } ] } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `results` | integer | Number of available plans | | `defaults` | object | Default plan IDs per category (`pro`, `business`, `growth`) | | `plans` | array | Array of plan detail objects | | `plans[].id` | string | Plan identifier (use in subscription requests) | | `plans[].name` | string | Display name | | `plans[].category` | string | Plan category: `"pro"` (Starter), `"business"`, `"growth"` | | `plans[].amount` | integer | Price in cents (e.g., 2900 = $29.00) | --- ### 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 and workspace creation limits. **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'` (default), `'Admin or above'`, `'Only Org Owners'`. | | `perm_member_manage` | string | Yes | Who can manage workspace members. Values: `'Member or above'` (default), `'Admin or above'`. | | `intelligence` | string | Yes (non-agents) | Enable AI features (`"true"`/`"false"`). Defaults to `"true"` for agent accounts. | | `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" \ -d "intelligence=true" ``` **Response (200 OK):** ```json { "result": true, "workspace": { "id": "1234567890123456780", "folder_name": "project-alpha" } } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `workspace.id` | string | 19-digit numeric workspace ID | | `workspace.folder_name` | string | URL-safe folder name | **Error responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1685 (Feature Limit)` | 412 | "Workspace creation is not available on your current plan." | Feature disabled | | `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 | **Access levels:** | Role | Access | Notes | |------|--------|-------| | Owner | Full access | Sees all workspaces | | Admin | Full access | Sees all workspaces | | 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" } ] } ``` **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 Import | Method | Path | Description | |--------|------|-------------| | POST | `/current/workspace/{workspace_id}/cloud-import/enable/` | Enable cloud import | | POST | `/current/workspace/{workspace_id}/cloud-import/disable/` | Disable cloud import | | GET | `/current/imports/workspace/{workspace_id}/identities/` | List provider identities | | POST | `/current/imports/workspace/{workspace_id}/identities/provision/` | Provision a provider identity | | POST | `/current/imports/oauth/{provider}/complete/` | Finish a browser OAuth connect (Dropbox, OneDrive) | | GET | `/current/imports/workspace/{workspace_id}/identities/{identity_id}/` | Get identity details | | POST | `/current/imports/workspace/{workspace_id}/identities/{identity_id}/revoke/` | Revoke a provider identity | | GET | `/current/imports/workspace/{workspace_id}/identities/{identity_id}/drives/` | List reachable drives (OneDrive for Business) | | POST | `/current/imports/workspace/{workspace_id}/identities/{identity_id}/drives/refresh/` | Refresh the drive catalog | ### Discovery | Method | Path | Description | |--------|------|-------------| | GET | `/current/workspaces/all/` | List all accessible workspaces | | GET | `/current/workspaces/available/` | List joinable workspaces | | GET | `/current/workspaces/check/name/{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` | | `standard` | terse + `description`, `workspace_level`, `closed`, `archived`, `locked` (admin-only), `storage`, `created`, `updated`, `logo`, `accent_color`, `intelligence`, `capabilities` | | `full` | standard + `cloud_import`, `comments`, `chat`, `search`, `assets`, remaining branding, `perm_*` blocks, `platform`, `suspended`, `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` feature toggle 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` | Must match 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 | **`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 | --- ## Intelligence Setting The `intelligence` boolean on a workspace 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 API rejects the create/update 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. - Set at creation: `POST /current/org/{org_id}/create/workspace/` with `intelligence=true|false` - Update later: `POST /current/workspace/{id}/update/` with `intelligence=true|false` - **Can be enabled and disabled within time restrictions.** Disabling intelligence destroys indexed embeddings (the vector index is flushed). Re-enabling intelligence incurs re-indexing costs as AI credits are consumed to re-index all files. - **Agent-account default:** On plans that include `ai_agent`, new workspaces created by agent accounts default `intelligence=true` if the parameter is omitted. On a plan without `ai_agent` (such as a legacy Agent organization), the default is `false` because the indexing pipeline has no consumer. See the [AI reference](https://api.fast.io/current/llms/ai/) for the full plan matrix. --- ## 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 | 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 | Yes (auto-set for agents on plans with `ai_agent`) | `"true"` or `"false"` | Enable AI indexing. Defaults to `"true"` for agent accounts **only on plans that include the `ai_agent` feature**; otherwise the default is `"false"`. Setting `"true"` requires both `content_ai` and `ai_agent`. | | `description` | string | No | 10-1000 chars | Workspace description | | `accent_color` | string (JSON) | No | Valid JSON | Accent color styling | | `background_color1` | string (JSON) | No | Valid JSON | Background color 1 styling | | `background_color2` | string (JSON) | No | Valid JSON | 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" \ -d "intelligence=true" ``` **Response (200 OK):** ```json { "result": true, "workspace": { "id": "1234567890123456789", "folder_name": "engineering" } } ``` **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 | **Access Levels:** | Role | Access | |------|--------| | Org Owner | Can create workspaces | | Org Admin | Can create workspaces | | Org Member | Can create workspaces | **Error Responses:** | 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 | | `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 | | `1663 (Update Failed)` | 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, intelligence 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": "#0066CC", "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 | |-------|------|-------------| | `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` | string or null | Brand accent color | | `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.user_status` | string | Current user's membership status (`"joined"`, `"invited"`, `"available"`) | | `workspace.org_domain` | string | Parent organization domain | **Access Levels:** | Role | Fields Returned | |------|-----------------| | Owner | All fields including intelligence, storage, platform details | | Admin | All fields including intelligence, storage, platform details | | Member | Core workspace fields | | View | Basic workspace information | **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 | Regex `^[\p{L}\p{N}-]+$`, unique | URL-safe identifier | | `name` | string | 2-100 chars. Send `"null"` to clear. | 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 | | `accent_color` | string (JSON) | Valid JSON. Send `"null"` to clear. | Accent color | | `background_color1` | string (JSON) | Valid JSON. Send `"null"` to clear. | Background color 1 | | `background_color2` | string (JSON) | Valid JSON. 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 | "Permission denied" | 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 | "Intelligence setting can only be changed twice per minute and five times per hour..." | Intelligence toggle rate-limited by the per-window throttle | | `1605 (Invalid Input)` | 406 | "Intelligence requires a plan with agentic AI support." | Plan missing `content_ai` or `ai_agent` (cannot set `intelligence=true`) | | `1605 (Invalid Input)` | 406 | "An invalid configuration was supplied..." | Metadata validation failure | | `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 | "Permission denied" | 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 | "Permission denied" | 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 | "Permission denied" | 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, "assets": [ { "name": "logo", "type": "image", "max_size": 5242880, "accepted_formats": ["image/png", "image/jpeg", "image/svg+xml"] } ] } ``` --- ### 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": { "url": "https://assets.fast.io/1234567890123456789/logo.png", "size": 102400, "content_type": "image/png" } } } ``` --- ### 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 array) | No | Optional metadata for the asset | **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, "asset": { "name": "logo", "url": "https://assets.fast.io/1234567890123456789/logo.png" } } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1691 (File Missing)` | 412 | "Asset upload missing" | No file in request | | `1605 (Invalid Input)` | 406 | "metadata invalid" | Invalid metadata format | --- ### 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 workspace member. 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. **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"`, or `"guest"`. Cannot be `"owner"`. | | `notifications` | string | No | Notification preference | | `expires` | string | No | Membership expiration (`YYYY-MM-DD HH:MM:SS UTC`) | | `force_notification` | boolean | No | Force notification email to existing user | **Request Parameters (inviting by email):** | Name | Type | Required | Description | |------|------|----------|-------------| | `permissions` | string | No | `"admin"`, `"member"`, or `"guest"`. Cannot be `"owner"`. | | `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": "1234567890123456789", "inviter": "John Doe", "invitee_email": "newuser@example.com", "entity_type": "workspace", "state": "pending", "created": "2025-01-15 10:30:00 UTC", "expires": "2025-02-15 10:30:00 UTC" } } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1692 (Cannot Add As Owner)` | 500 | "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 | "Insufficient org permissions to invite to this workspace." | Lacks org-level permission | | `1680 (Access Denied)` | 401 | "Insufficient permissions" | Below workspace `perm_member_manage` level | **Notes:** - Adding a user to a workspace may also auto-add them to the parent organization if the workspace is configured for automatic org membership. --- ### 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 | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "Cannot remove the owner" | Attempted to remove workspace owner | | `1680 (Access Denied)` | 401 | "Insufficient permissions" | Below required permission level | **Notes:** - Removing a member cascades removal into all shares within the workspace. --- ### 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. **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", "invite": "accepted", "notify": "Email me", "expires": null }, { "id": "9876543210987654321", "account_type": "agent", "email_address": "bot@example.com", "first_name": "Sync", "last_name": "Bot", "permissions": "member", "status": "active", "invite": "accepted", "notify": "Notify me in app", "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": "5566778899001122334", "created": "2025-01-15 10:30:00 UTC", "expires": "2025-02-15 10:30:00 UTC" }, "notify": "Notify me in app", "expires": null } ] } ``` **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` | string or object | `"accepted"` for active members; object with `id`, `created`, `expires` for pending members | | `users[].notify` | string | Notification preference | | `users[].expires` | string or null | Membership expiration or `null` for permanent | --- ### 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 | --- ### 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, "member": { "id": "9876543210987654321", "account_type": "human", "email_address": "user@example.com", "first_name": "Jane", "last_name": "Smith", "permissions": "member", "invite": "accepted", "notify": "Notify me in app", "expires": null } } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `member.id` | string | 19-digit user profile ID | | `member.account_type` | string | `"human"` or `"agent"` | | `member.email_address` | string | Email address | | `member.first_name` | string | First name | | `member.last_name` | string | Last name | | `member.permissions` | string | Permission level name | | `member.invite` | string | Invitation status | | `member.notify` | string | Notification preference | | `member.expires` | string or null | Membership expiration or `null` | **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`. **Request Parameters (all optional):** | Name | Type | Description | |------|------|-------------| | `permissions` | string | New role: `"admin"`, `"member"`, `"guest"` | | `notifications` | string | Notification preference | | `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 | "Insufficient permissions" | Below required permission level | **Notes:** - Users cannot escalate permissions beyond their own level. --- ### Transfer Workspace Ownership ``` POST /current/workspace/{workspace_id}/member/{member_id}/transfer_ownership/ ``` Transfers ownership to another member. Current owner is demoted to admin. **Auth:** JWT required. Owner 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 } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "You cannot transfer ownership to yourself." | Target is self | | `1605 (Invalid Input)` | 406 | "The membership you specified does not exist." | Target is not a workspace member | | `1605 (Invalid Input)` | 406 | "Member is already an owner." | Target is already owner | | `1605 (Invalid Input)` | 406 | "The new owner must be a member of the parent organization." | Target not in parent org | | `1663 (Update Failed)` | 500 | "Failed to update owner of the workspace." | Internal failure | | `1680 (Access Denied)` | 401 | "Insufficient permissions" | Not the workspace owner | --- ### 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 | |------|------|----------|-------------| | `notifications` | 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 | **Notes:** - Self-joining members are assigned the `member` role. Only notification preferences are accepted. - Self-joined memberships do not expire. --- ### 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 | **Notes:** - The system validates the inviter still has sufficient permissions at acceptance time. - Accepting may also auto-add the user to the parent organization. --- ### 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": "5566778899001122334", "created": "2025-01-15 10:30:00 UTC", "expires": "2025-02-15 10:30:00 UTC" }, "notify": "Notify me in app", "expires": null } ``` **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": "1234567890123456789", "inviter": "Alice Johnson", "invitee_email": "newuser@example.com", "invitee_uid": null, "accepted_uid": null, "entity_type": "workspace", "workspace": { "id": "9876543210987654321", "name": "Project Alpha" }, "state": "pending", "created": "2025-01-15 10:30:00 UTC", "updated": "2025-01-15 10:30:00 UTC", "expires": "2025-02-15 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[].invitee_email` | string | Email the invitation was sent to | | `invitations[].invitee_uid` | string or null | User ID of invitee (if they exist in the system) | | `invitations[].accepted_uid` | string or null | User ID of acceptor (if accepted) | | `invitations[].entity_type` | string | Always `"workspace"` | | `invitations[].workspace` | object | Workspace details (`id`, `name`) | | `invitations[].state` | string | `"pending"`, `"accepted"`, `"declined"`, `"expired"`, `"revoked"` | | `invitations[].created` | string | Creation timestamp | | `invitations[].updated` | string | Last update timestamp | | `invitations[].expires` | string or null | Expiration timestamp | --- ### 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"`, `"expired"`, `"revoked"` | **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 numeric ID or the invitee's email address. **Auth:** JWT required. Permission depends on `perm_member_manage`. **Request Parameters (all optional):** | Name | Type | Description | |------|------|-------------| | `state` | string | New state: `"pending"`, `"accepted"`, `"declined"`, `"expired"`, `"revoked"` | | `permissions` | string | Updated permission level for membership | | `expires` | string | Updated membership expiration (`YYYY-MM-DD HH:MM:SS UTC`) | **curl Example:** ```bash # Revoke by ID curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/members/invitation/1111111111111111111/" \ -H "Authorization: Bearer {jwt_token}" \ -d "state=revoked" # 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 | "Invitation not found." | No matching invitation | | `1605 (Invalid Input)` | 406 | "An invalid state was supplied." | Unrecognized state | | `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 numeric ID or the invitee's email. **Auth:** JWT required. Permission depends on `perm_member_manage`. **curl Example:** ```bash curl -X DELETE "https://api.fast.io/current/workspace/1234567890123456789/members/invitation/1111111111111111111/" \ -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 | "Invitation not found." | No matching invitation | | `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. Admin or Owner required. 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. | | `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. | | `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) | Valid JSON | Accent color | | `background_color1` | string (JSON) | Valid JSON | Background color 1 | | `background_color2` | string (JSON) | Valid JSON | 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 | |------------|-------------|---------|-------| | `1685 (Feature Limit)` | 412 | "The organization has reached its share creation limit..." | Plan share limit exceeded | | `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 | --- ### 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 | **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 } ] } ``` --- ### Import Share into Workspace ``` POST /current/workspace/{workspace_id}/import/share/{share_id}/ ``` Transfers a user-owned share into workspace ownership. **Auth:** JWT required. Must be workspace member AND share owner. **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 | | `1685 (Feature Limit)` | 412 | "The workspace has reached its share limit..." | Plan limit exceeded | **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** node to share (must be a file, not a folder or note) | | `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`. | 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=f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf" \ -d "title=Quarterly Presentation" \ -d "access_option=anyone_with_link" ``` **Response (200 OK):** ```json { "result": true, "fileshare": { "fileshare": "1234567890123456789", "title": "Quarterly Presentation", "access_option": "anyone_with_link", "has_password": false, "bound_node_id": "f3jm5zqzfxpxdr2dx8z5bvnb3rpjf", "creator_uid": "9876543210987654321", "created": "2026-04-27 16:37:29 UTC", "updated": "2026-04-27 16:37:29 UTC", "expires": null } } ``` **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) | ### 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", "title": "Quarterly Presentation", "access_option": "anyone_with_link", "has_password": false, "bound_node_id": "f3jm5zqzfxpxdr2dx8z5bvnb3rpjf", "creator_uid": "9876543210987654321", "created": "2026-04-27 16:37:29 UTC", "updated": "2026-04-27 16:37:29 UTC", "expires": null, "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" } ] } ] } ``` ### 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`, 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`. | 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) request body (form-encoded):** 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. **Granting by email:** - If the email belongs to an **existing account**, it is granted directly — the response carries the resolved `user`, and the grant appears in the list as `state: active`. - If the email has **no account yet**, a pending **invitation** 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 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 | --- ## Workspace Discovery ### List All Workspaces ``` GET /current/workspaces/all/ ``` Lists all workspaces the user has joined or can access across all organizations. **Auth:** JWT required. **curl Example:** ```bash curl -X GET "https://api.fast.io/current/workspaces/all/" \ -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": "#0066CC", "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` | string or null | Brand accent color | | `[].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"`, `"invited"`, or `"available"` | | `[].org_domain` | string | Parent organization domain | **Notes:** - Spans all organizations the user belongs to. - Workspaces from orgs without active subscriptions are filtered out. --- ### 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, but only includes un-joined workspaces. --- ### Check Workspace Name ``` GET /current/workspaces/check/name/{name}/ ``` Checks if a workspace folder name is already in use. Useful for real-time form validation. **Auth:** JWT required. Org membership (Member or above) required. 2FA required. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{name}` | string | Yes | The folder name to check | **curl Example:** ```bash curl -X GET "https://api.fast.io/current/workspaces/check/name/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 | | `1658 (Not Acceptable)` | 406 | "The supplied workspace folder name is already in use." | Name taken | | `1680 (Access Denied)` | 401 | "Permission denied" | 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. **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 | All workspaces | | Org Admin | All workspaces | | Org Member | Workspaces matching `perm_join` permission | | External User | Only workspaces where they are a direct member | --- ## Cloud Import ### Enable Cloud Import ``` POST /current/workspace/{workspace_id}/cloud-import/enable/ ``` Enables cloud import 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 import 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 | "Permission denied" | 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 Import ``` POST /current/workspace/{workspace_id}/cloud-import/disable/ ``` Disables cloud import 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 import 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 | "Permission denied" | 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 | --- ### Provision Provider Identity ``` POST /current/imports/workspace/{workspace_id}/identities/provision/ ``` Creates a service account, or starts a browser OAuth connection, for the specified cloud provider — then returns immediately either way. The four providers split into two families, and which family you are in decides what happens next. **Google Drive and Box — provisioned in the background.** The identity comes back with `status: "provisioning"`. Poll `GET .../identities/{identity_id}/` (or the list endpoint) until `status` is `"active"` (success) or `"error"` (failure). The `identity_email` field is `"provisioning-pending"` while provisioning is in progress, and the `instructions` returned at provision time say so rather than naming an address that does not exist yet. Re-fetch the identity after it activates to get the real `identity_email`; the sharing instruction text is a provision-time field and is not repeated on the identity response. **When you share the remote folder with that address, grant a level that can WRITE if the source will be `read_write`** — for Box that is `Editor`, not `Viewer`. Nothing verifies the granted level 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, activates and syncs normally and then fails the source's first write-back permanently. **Dropbox and OneDrive for Business — connected by the user, in a browser.** Nothing is provisioned in the background for these. 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. There is no address to share a folder with, because the connected account IS the access: `identity_email` holds a placeholder (`dropbox-oauth-pending` / `onedrive-oauth-pending`) until the connect finishes, and is then the real account address. Each user connects their own account, so one workspace can hold several independent connections to the same provider. 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. **Auth:** JWT required. The identity is owned by the caller, and the floor depends on the provider: **OneDrive for Business requires Member**, because the resulting delegated grant is only ever useful for creating a source, which itself requires Member. Google Drive, Box and Dropbox accept any workspace member with view access. **Request body (form-encoded):** | Field | Type | Required | Description | |-------|------|----------|-------------| | `provider` | string | Yes | `google_drive`, `box`, `onedrive_business`, or `dropbox`. **Which of these a workspace may actually connect is plan-dependent, and not every provider is offered on every plan** — 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" }, "instructions": "This connection is still being set up. Once it reports as active, read the connection to get the address to share your folder with." } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 400 | "Unsupported provider" | Invalid provider name | | `1680 (Access Denied)` | 401 | "This action requires workspace member permissions" | OneDrive for Business requested below Member | | `1654 (Internal Error)` | 500 | "Maximum of 4 provider identities per user per workspace" | Limit reached | --- ### Complete a Browser OAuth Connect ``` POST /current/imports/oauth/{provider}/complete/ ``` Finishes a connection started by `provision`. `{provider}` is `dropbox` or `onedrive`. Google Drive and Box never 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. Both providers work exactly this way; there is deliberately **no public callback endpoint** for either, and the previous Dropbox one (`GET /current/imports/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 | | `credential_store_failed` | The credential could not be stored | | `activation_failed` | The identity could not be activated | | `insufficient_scope` | OneDrive only — consent omitted a permission the importer cannot work without. The response also carries an `admin_consent_url` a tenant administrator can open | 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)` | 400 | "Invalid JSON in request body" | Body is not valid JSON | | `1605 (Invalid Input)` | 400 | "Missing or invalid connection state" | No `state` field | | `1605 (Invalid Input)` | 400 | "This connection link has expired or was already used. Start the connection again." | Unknown, replayed, expired or tampered `state` | | `1605 (Invalid Input)` | 400 | "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 | | `1610 (Internal Error)` | 500 | "This connection is no longer waiting to be completed." | The identity is no longer `provisioning` | 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/imports/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. **Auth:** JWT required. Admin or Owner required. **Response (200 OK):** ```json { "result": true, "identity": { "id": "abc123...", "profile_id": "1234567890123456789", "provider": "google_drive", "identity_email": "import-sa@project.iam.gserviceaccount.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 | |------------|-------------|---------|-------| | `1607 (Duplicate Entry)` | 400 | "Identity revocation already in progress" | Revoke already running | | `1654 (Internal Error)` | 500 | "Identity not found" | Unknown identity ID | --- ### Identity Statuses | Status | Description | |--------|-------------| | `provisioning` | Google Drive and Box: creation is in progress; poll until `active` or `error`. Dropbox and OneDrive: the user has not finished the browser consent yet, and no amount of polling will advance it on its own | | `active` | Identity is ready; `identity_email` is the real address, or for Dropbox and OneDrive the connected account's address. Sharing instructions are returned by the provision response, not by this endpoint | | `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/imports/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 for Business identity is one connected Microsoft work or school account, and that account can usually reach several libraries with no meaningful default among them. 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** — for OneDrive there is no workspace-admin override, because this catalog enumerates one person's own Microsoft content. Admins keep every removal power (revoke the identity, disconnect or delete a source); what they cannot do is browse it. The other three providers' catalogs are a workspace-level service account or a shared folder, so those stay owner-or-admin. **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)` | 400 | " 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 in another workspace | | `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/imports/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/param/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)` | 400 | " 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 in another workspace | | `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) | | `1610 (Internal Error)` | 500 | "A drive refresh is already in progress for this identity" | A refresh is in flight | | `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/imports/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. **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 OneDrive for Business, discovery is owner-only as well — it browses that person's own Microsoft content. Admins are refused here on purpose, and keep every removal power: revoking an identity, and disconnecting or deleting a source, remain owner-or-admin. | Error Code | HTTP Status | Reason | Cause | |------------|-------------|--------|-------| | `1605 (Invalid Input)` | 400 | `drive_required` | OneDrive source without a `drive_id` | | `1605 (Invalid Input)` | 400 | `drive_unknown` | `drive_id` is not in this identity's catalog | | `1605 (Invalid Input)` | 400 | `drive_not_supported` | `drive_id` sent for a provider that does not use one | | `1605 (Invalid Input)` | 400 | `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 | 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. Only the **length** is checked when the call is accepted. A path the provider cannot resolve is reported by the discovery **job**, not by this endpoint: 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. `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. | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 400 | "remote_path is too long" | `remote_path` is longer than 2048 characters | --- ### Choosing Where an Import Lands `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. 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. 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/imports/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-import 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. 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)` | 400 | `destination_unknown` | The id is malformed, or names a node that is missing, trashed, or in another workspace | | `1605 (Invalid Input)` | 400 | `destination_unknown` | The node is a file, not a folder ("The chosen destination must be a folder, not a file") | | `1605 (Invalid Input)` | 400 | `destination_unknown` | The value is not a string (an array or object was sent) | | `1605 (Invalid Input)` | 400 | `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)` | 400 | `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*. > 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., `f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf`). Use as-is in API calls. - **Node ID prefixes:** `f` = file, `d` = folder, `n` = note - **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.) --- ## 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`, `mimetype`, `size`, `modified`, recursive `nodes` for folders when the endpoint returns children, `summary` reduced to `{title}` 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` | | `standard` | terse + `version`, `created`, `restricted`, `dmca`, `locked`, `mimecategory`, `origin` reduced to `{creator, type}`, `ai` reduced to `{state}`, `metadata` reduced to `{title, short}` (user-authored overrides), `deleted`, `deleted_from`, `is_imported`, link-only `target_type`/`target_id` | | `full` | standard + `summary.short`/`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`, `lock_info`, `import_metadata`, full `previews` state map | 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 plus type, 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 | File size in bytes | | `hash` | string | file | Content hash of the file | | `hash_algo` | string | file | Hash algorithm (e.g., `"md5"`) | | `mimetype` | string | file | MIME type (e.g., `"application/pdf"`) | | `mimecategory` | string | file | MIME category (e.g., `"document"`, `"image"`) | | `version` | string | file, note | Current version identifier (e.g., `"v1"`) | | `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 | | `virus` | object | file | Virus scan status: `{"status": "scanned", "infected": false}` | | `file_attributes` | object | file | 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 | file | AI-generated summary: `{"title": "...", "short": "...", "long": "..."}` | | `metadata` | object/null | file | User-defined custom title and description overrides | | `previews` | object | file | Preview generation state per type (e.g., `{"thumbnail": {"state": "ready"}}`) | | `ai` | object | file | AI processing state: `{"state": "...", "attach": true/false}` | | `origin` | object | file | Origin info: `{"type": "upload", "creator": "{user_id}"}` | ### 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`, `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. ### AI States | Value | Description | |-------|-------------| | `disabled` | AI processing is disabled for this file | | `pending` | Queued for AI processing | | `inprogress` | 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` | Must be `100`, `250`, or `500`; other values rejected | | `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": "d1abc-defgh-ijklm-nopqr-stuvw-xyz1", "type": "folder", "name": "Documents", "parent": "root", "created": "2025-01-01 00:00:00 UTC", "modified": "2025-01-20 14:45:00 UTC" }, { "id": "f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf", "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) | --- ## 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, or note). **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/f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "format": "single", "node": { "id": "f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf", "type": "file", "name": "document.pdf", "parent": "d1abc-defgh-ijklm-nopqr-stuvw-xyz1", "size": 5242880, "hash": "d41d8cd98f00b204e9800998ecf8427e", "hash_algo": "md5", "mimetype": "application/pdf", "mimecategory": "document", "version": "v1", "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, "virus": { "status": "scanned", "infected": false }, "file_attributes": { "width": null, "height": null, "duration": null }, "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 }, "origin": { "type": "upload", "creator": "9876543210987654321" } } } ``` **Response fields:** See Node Object Schema above for complete field reference. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `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/abc123,def456,ghi789/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (HTTP 200):** ```json { "result": true, "format": "multi", "nodes": [ { "id": "abc123", "type": "file", "name": "report.pdf" }, { "id": "def456", "type": "folder", "name": "Documents" } ], "errors": [ { "node_id": "ghi789", "code": 133123, "message": "No such file or folder exists" } ] } ``` Every details response carries a top-level `format` field — `"single"` (single-id form, paired with a `node` object) or `"multi"` (bulk form, paired with `nodes` + `errors` arrays). Clients can branch on `format` instead of inferring shape from key presence. | 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 (errors array is populated) | --- ## 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. **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": "f4kn6-arybw-qxes3-ey9z6-cwoc4-sglh", "type": "file", "name": "document.pdf", "parent": "root", "size": 5242880, "hash": "d41d8cd98f00b204e9800998ecf8427e", "hash_algo": "md5", "mimetype": "application/pdf", "mimecategory": "document", "version": "v1", "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 | **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) | --- ## 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": "d2bcd-efghi-jklmn-opqrs-tuvwx-yz12", "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 intelligence 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": "n5mno-pqrst-uvwxy-z1234-abcde-fghi", "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" } } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | Filename must end with `.md` | | `1605 (Invalid Input)` | 406 | Duplicate name in parent folder | | `1609 (Not Found)` | 404 | Parent folder not found | --- ## 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. | At least one of `name` or `content` is required. **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/n5mno-pqrst-uvwxy-z1234-abcde-fghi/updatenote/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'content=# Updated Notes\n\nRevised content here.' ``` **Response:** ```json { "result": true, "note": { "id": "n5mno-pqrst-uvwxy-z1234-abcde-fghi", "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`, no version is created, and the current state is returned under `error.params.current` so the client can rebase its edit: ```json { "error": { "code": 166226, "text": "The note was modified since the supplied version", "params": { "current": { "version_id": "v9abc-defgh-ijklm-nopqr-stuvw-xyz0", "hash": "d41d8cd98f00b204e9800998ecf8427e" } } } } ``` If the current state cannot be resolved at the time of the conflict, the affected field(s) under `current` may be omitted. **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 | | `1660 (Conflict)` | 409 | `if_version_id` did not match the note's current version | | `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/n5mno-pqrst-uvwxy-z1234-abcde-fghi/readnote/" \ -H "Authorization: Bearer {jwt_token}" # Share curl -X GET "https://api.fast.io/current/share/1234567890123456789/storage/n5mno-pqrst-uvwxy-z1234-abcde-fghi/readnote/" \ -H "Authorization: Bearer {jwt_token}" # Share with download token (no JWT needed) curl -X GET "https://api.fast.io/current/share/1234567890123456789/storage/n5mno-pqrst-uvwxy-z1234-abcde-fghi/readnote/?token={download_token}" ``` **Response:** ```json { "result": true, "content": "# Meeting Notes\n\nDiscussed project timeline.", "note": { "id": "n5mno-pqrst-uvwxy-z1234-abcde-fghi", "type": "note", "name": "meeting-notes.md", "parent": "root", "mimetype": "text/markdown", "version": "v1", "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) | **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) | | `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, title, or short description, use `updatenote` instead — supplying `from`, `metadata_title`, or `metadata_short` on a note (or omitting `name`) is rejected. **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 | At least one field should be provided. **curl example (rename):** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf/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/f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf/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": "f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf", "type": "file", "name": "new-document-name.pdf", "parent": "root", "size": 5242880, "version": "v2", "modified": "2025-01-22 11:00:00 UTC" } } ``` **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, title, or description changes | | `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 | 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) | **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). --- ## 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/f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf/move/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'parent=d1abc-defgh-ijklm-nopqr-stuvw-xyz1' ``` **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/f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf/copy/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'parent=d1abc-defgh-ijklm-nopqr-stuvw-xyz1' ``` **Response:** ```json { "result": true, "node": { "id": "f6qrs-tuvwx-yz123-abcde-fghij-klmn", "type": "file", "name": "document.pdf", "parent": "d1abc-defgh-ijklm-nopqr-stuvw-xyz1" }, "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 | | `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. --- ## 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/f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf/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/f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf/transfer/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'instance=9876543210987654321' \ -d 'parent=root' \ -d 'mode=move' ``` **Response (copy):** ```json { "result": true, "node": { "id": "f7stu-vwxyz-12345-abcde-fghij-klmp", "type": "file", "name": "document.pdf", "parent": "root" }, "job": null } ``` **Response (move):** ```json { "result": true, "node": { "id": "f7stu-vwxyz-12345-abcde-fghij-klmp", "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 | **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": true` on success. - 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. --- ## 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/f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf/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) | **Notes:** - Deleting a folder moves it and all children to trash recursively. - Share delete permissions may be restricted to files the user created. --- ## 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/f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf/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) | --- ## 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/f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf/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) | --- ## 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/f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf/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": "v8abc-defgh-ijklm-nopqr-stuvw-xyz9", "type": "file", "current_version": true, "nodeId": "f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf", "name": "document.pdf", "parent": "d1abc-defgh-ijklm-nopqr-stuvw-xyz1", "created": "2025-01-20 14:45:00 UTC", "size": 5242880, "hash": "abc123def456789...", "hash_algo": "sha256", "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" } }, { "id": "v9bcd-efghi-jklmn-opqrs-tuvwx-yz01", "type": "file", "current_version": false, "nodeId": "f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf", "name": "document.pdf", "parent": "d1abc-defgh-ijklm-nopqr-stuvw-xyz1", "created": "2025-01-15 10:30:00 UTC", "size": 4194304, "hash": "def456abc789012...", "hash_algo": "sha256", "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" } } ] } } ``` **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) | | `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 | **`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 | Folder versions carry only the basic fields (`id`, `type`, `current_version`, `nodeId`, `name`, `parent`, `created`) 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/f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf/restore-version/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'version_id=v9bcd-efghi-jklmn-opqrs-tuvwx-yz01' ``` **Response:** ```json { "result": true, "new_version": { "id": "v0cde-fghij-klmno-pqrst-uvwxy-z123", "type": "file", "current_version": true, "nodeId": "f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf", "name": "original-name.pdf", "parent": "d1abc-defgh-ijklm-nopqr-stuvw-xyz1", "created": "2025-01-25 09:00:00 UTC", "size": 4194304, "hash": "def456abc789012...", "hash_algo": "sha256", "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" } }, "node": { "id": "f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf", "type": "file", "name": "original-name.pdf", "version": "v3" } } ``` `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. --- ## 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/f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf/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/f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf/read/?token={download_token}" \ -o output.pdf # Download specific version curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf/read/?version_id=v9bcd-efghi-jklmn-opqrs-tuvwx-yz01" \ -H "Authorization: Bearer {jwt_token}" \ -o output_v1.pdf # Byte-range request (streaming) curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf/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`. **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) | --- ## 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 | **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf/requestread/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` **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 | | `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 required.** Permission: View (workspace), download permission (share). **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 | **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/root/zip/" \ -H "Authorization: Bearer {jwt_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. --- ## 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": 3, "items": [ { "id": "f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf", "name": "report.pdf", "type": "file", "parent": "d1abc-defgh-ijklm-nopqr-stuvw-xyz1", "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": 100 } } ``` **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. | | `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 — restrict semantic search to these files | | `folders_scope` | string | No | - | Comma-separated folder node ids — restrict semantic search to these folders | | `details` | string | No | - | `"true"` to include the full node resource for each result (default limit drops to 10) | | `limit` / `offset` | integer | No | - | Offset pagination | | `output` | string | No | `full` | Verbosity: `terse`, `standard`, or `full` (default). Trims `content_snippet` to a byte budget when intelligence 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 intelligence on.** The two are the file's AI-generated **summary** (matched by keyword) and the file's **semantic** (meaning-based) index. Instance intelligence 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, intelligence disabled):** ```json { "result": true, "files": { "f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf": { "name": "Q4 Report.pdf", "parent_id": "d1abc-defgh-ijklm-nopqr-stuvw-xyz1", "type": "file" } } } ``` **Response (hybrid, intelligence enabled):** ```json { "result": true, "files": { "f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf": { "name": "Q4 Report.pdf", "parent_id": "d1abc-defgh-ijklm-nopqr-stuvw-xyz1", "type": "file", "relevance_score": 0.93, "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 } } }, "search_metadata": { "intelligence_enabled": true, "semantic_available": true, "scoped": false } } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `files` | object | Map of node OpaqueIds to file info | | `files.{id}.name` | string | File name | | `files.{id}.parent_id` | string | Parent folder node ID | | `files.{id}.type` | string | Node type | | `files.{id}.relevance_score` | float | Relevance score (0.0–1.0). Hybrid mode only. | | `files.{id}.content_snippet` | string\|null | Matching text from the semantic chunk; `null` for keyword-only matches (a `null` snippet means there is no matching excerpt — read the file itself for content). Trimmed per `output`. | | `files.{id}.match_source` | string | `keyword`, `semantic`, or `both`. Always `keyword` when intelligence 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. | | `files.{id}.page` | object\|null | `{start_page, end_page}` for paginated documents. Hybrid mode only. | | `search_metadata` | object | `{intelligence_enabled, semantic_available, scoped}`, plus the capability keys below when `search_in` was supplied. See *Knowing whether content search is possible*. | **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`, and `match_source: "keyword"` — even on a workspace or share with AI features enabled. The hybrid-only fields (`relevance_score`, `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. ### 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. | | `scoped` | bool | Whether the request was narrowed by `files_scope` / `folders_scope`. | | `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. | 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. **Verbosity (`output`):** `content_snippet` is the only field affected by `output=`. All other fields are returned unchanged at every level. | `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. | 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. Default `output=full` preserves prior behavior for callers that omit the parameter. **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 `?` | | `1609 (Not Found)` | 404 | Search not available for workspace folder shares (share only) | | `1680 (Access Denied)` | 401 | No search permission (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. --- ## Metadata Search (Workspace Only) ``` GET /current/workspace/{workspace_id}/metadata/search/ ``` Keyword search across the metadata key-value pairs stored on workspace files (template fields plus custom fields). Returns matching nodes ordered by relevance. Trashed nodes are filtered out automatically. The endpoint is workspace-scoped — results never cross workspace boundaries regardless of caller input. **Auth required.** Permission: View (workspace member). **Query parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `q` | string | Yes | - | Keyword query. Whitespace-trimmed; an empty value is rejected. | | `template_id` | string | No | - | Restrict matches to nodes that have at least one metadata value contributed by this template. Custom (template-independent) fields are not included when this filter is set. | | `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": "f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf", "score": 4.215, "template_ids": ["mt_abc123def456"], "node": { "id": "f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf", "type": "file", "name": "Invoice-2026-0042.pdf", "parent": "d1abc-defgh-ijklm-nopqr-stuvw-xyz1", "size": 524288, "mimetype": "application/pdf", "mimecategory": "document", "version": "v1", "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 | | `results[].score` | number | Relevance score for the match | | `results[].template_ids` | array of string | Templates contributing to the matching values for this node. Custom (template-independent) values do not appear here. | | `results[].node` | object | Standard node resource (same shape as storage list/details) | | `pagination.total` | int | Total number of matching nodes | | `pagination.has_more` | bool | `true` when more results exist past the current window | **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | `q` missing/blank, `template_id` malformed, 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. - Both lexical match and prefix/wildcard match are supported (e.g., `inv*` matches `invoice`). - Indexing is event-driven — newly written or updated metadata typically becomes searchable within a few seconds. - Binary metadata values are not indexed; only textual values (including JSON leaves) participate in matching. - 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 by filename, this one searches by metadata field values. --- ## 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`. | **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. 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 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": "f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf", "name": "Q4 Report.pdf", "parent_id": "d1abc-defgh-ijklm-nopqr-stuvw-xyz1", "type": "file", "relevance_score": 0.93, "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": "f7kp2-aaaaa-bbbbb-ccccc-ddddd-eeee", "name": "Invoice-2026-0042.pdf", "parent_id": "d1abc-defgh-ijklm-nopqr-stuvw-xyz1", "type": "file", "relevance_score": 4.215, "template_ids": ["mt_abc123def456"], "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": "c9xy1-zzzzz-yyyyy-xxxxx-wwwww-vvvv", "entity": "f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf", "author_profile_id": "1234567890123456789", "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, ordered by relevance (`relevance_score` descending). 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: ```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 (when workspace/share intelligence is enabled) the hybrid-match fields `content_snippet` (the matching text, `null` for keyword-only matches), `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`. - **`metadata`** (workspace only) — `node_id`, `name`, `parent_id`, `type`, and `template_ids` (the templates contributing the matched values). - **`comments`** — `comment_id`, `entity` (the commented-on node/container), `author_profile_id`, `snippet` (the matching comment text), `reference_type` (the anchor type when the comment is anchored to a position in a file, else `null`), `created`. **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); share only | | `1680 (Access Denied)` | 401 | No search permission | **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 the same format as POST. 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": "qs_abc123def456", "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 template + custom metadata fields (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, "effective_capability": "download", "file": { "id": "f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf", "type": "file", "name": "presentation.pdf", "parent": "a1b2c-3d4e5-6f7g8-9h0i1-2j3k4-lmno", "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", "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. 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/` returns the bound file's **template + custom metadata** key-value fields — the same `template_metadata` / `custom_metadata` shape as the workspace metadata endpoint, so a viewer sees that one file's metadata exactly as it appears in the workspace info panel. View capability; the node is the bound file (never a caller-supplied node id). ```json { "result": true, "object_id": "f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf", "template_id": "tmpl4-q2w3e-r5t6y-u7i8o-p9a0s-dfgh", "node_id": { "id": "f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf", "type": "file", "name": "presentation.pdf", "parent": "a1b2c-3d4e5-6f7g8-9h0i1-2j3k4-lmno", "mimetype": "application/pdf" }, "template_metadata": [ { "key": "department", "description": "Owning department", "type": "string", "value": "Finance", "is_auto": false, "updated": "2026-01-28 12:30:00 UTC" } ], "custom_metadata": [ { "key": "reviewed_by", "description": null, "type": "string", "value": "jdoe", "is_auto": false, "updated": "2026-01-28 12:31:00 UTC" } ], "autoextractable": true } ``` When the bound file carries no metadata (or the owning organization has no metadata feature), `template_metadata` and `custom_metadata` are empty arrays and `template_id` is `null`. 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, or the bound file 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/n5mno-pqrst-uvwxy-z1234-abcde-fghi/" \ -H "Authorization: Bearer {jwt_token}" \ -H "x-ve-password: {link_password_if_set}" ``` **Response:** ```json { "result": true, "expires_in": 3600, "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 | Missing/invalid File Share id or note id, or the bound node is not a note | | `1609 (Not Found)` | 404 | No such File Share, or the requested id is not the File Share's bound note | | `1680 (Access Denied)` | 401 | Sign-in required (anonymous caller cannot 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 | 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/n5mno-pqrst-uvwxy-z1234-abcde-fghi/" \ -H "Authorization: Bearer {realtime_note_token}" ``` **Response:** ```json { "result": true, "content": "# Meeting Notes\n\nDiscussed project timeline.", "note": { "id": "n5mno-pqrst-uvwxy-z1234-abcde-fghi", "type": "note", "name": "meeting-notes.md", "parent": "root", "mimetype": "text/markdown", "version": "v1", "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 | | `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) | | `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`. **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/n5mno-pqrst-uvwxy-z1234-abcde-fghi/" \ -H "Authorization: Bearer {realtime_note_edit_token}" \ -d 'content=# Updated Notes\n\nRevised content here.' ``` **Response:** ```json { "result": true, "note": { "id": "n5mno-pqrst-uvwxy-z1234-abcde-fghi", "type": "note", "name": "meeting-notes.md", "parent": "root", "mimetype": "text/markdown", "version": "v2", "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, `name` not ending in `.md`, 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 | | `1660 (Conflict)` | 409 | `if_version_id` did not match the note's current version (no change made) | | `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 varies) | | `client_info` | string | No | JSON object | Client metadata: `device_name` (max 255), `client_version` (max 50) | **curl example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf/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": "f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf" } ``` **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/f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf/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 } ``` **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | No lock exists on this node | | `1609 (Not Found)` | 404 | Lock has expired | | `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 | Insufficient permission to heartbeat this lock (share only) | | `1693 (Temporarily Unavailable)` | 503 | Lock service momentarily unavailable; retry after a brief delay | **Notes:** - Send heartbeats well before the lock expires (e.g., at 50% of lock duration). - Heartbeats can recreate an expired lock if the token and user match. ### 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) | **Response (locked):** ```json { "result": true, "locked": true, "locked_at": "2025-01-28 10:00:00 UTC", "expires_at": "2025-01-28 10:05:00 UTC", "node_id": "f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf", "time_remaining": 245, "locker_uid": "1234567890123456789" } ``` **Response (unlocked):** ```json { "result": true, "locked": false } ``` --- ## 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 view permission (share). **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/f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf/preview/thumbnail/preauthorize/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "downloadToken": "eyJhbGciOiJIUzI1NiJ9...", "path": "/current/workspace/1234567890123456789/storage/f3jm5-zqzfx.../preview/thumbnail/read/eyJhbGci.../file/preview.png", "primaryFilename": "preview.png" } ``` **Response fields:** | Field | Type | Description | |-------|------|-------------| | `downloadToken` | string | JWT token for preview access | | `path` | string | Full API path 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 | | `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 | --- ## 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/f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf/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 | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Unknown transformation name | | `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: JWT or download token.** ### Image Transform Parameters Pass as query parameters on transform read endpoints: | Parameter | Type | Values | |-----------------|--------|-----------------------------------------------| | `output-format` | string | `png`, `jpg` | | `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: `IconSmall`, `IconMedium`, `Preview` | **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf/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.** **Response:** ```json { "result": true, "token": "eyJhbGciOiJIUzI1NiJ9..." } ``` --- ## Download Tokens Pattern The `requestread` endpoint generates temporary, auth-free download tokens for files, previews, and transforms. **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. In medium security mode, file previews are available for guests but direct downloads are restricted. Owners and admins can still download normally. --- ## Metadata Saved Views Each user can persist per-template column order, column visibility, and sort preferences. Saved views are **private to the user** — two users can independently customize the same template. Identity is the tuple `(workspace, user, template)`; there is at most one saved view per user per template per workspace. ``` GET /current/workspace/{workspace_id}/metadata/view/?template_id={template_id} POST /current/workspace/{workspace_id}/metadata/view/ DELETE /current/workspace/{workspace_id}/metadata/view/?template_id={template_id} GET /current/workspace/{workspace_id}/metadata/views/ POST /current/workspace/{workspace_id}/metadata/view/{template_id}/export/ ``` **Auth:** Bearer token required. Workspace member. Metadata billing feature required. **GET view** returns `1609 (Not Found)` when the caller has no saved view for the given template. **POST view** body: `application/x-www-form-urlencoded` with `template_id` (string), `config` (JSON-encoded string containing the view config), and an optional `name` (string, max 30 chars — a human label for the view; preserved on upsert if omitted). Do NOT send as `application/json` — the endpoint only accepts form-encoded bodies and returns `406 (This field is missing)` for JSON bodies. The view `id` is server-owned (never client-supplied). Upserts the caller's view — an existing row is replaced in place (id and `created` timestamp preserved; `updated` refreshed). Returns the stored view. **DELETE view** removes only the caller's own view; other users' views are untouched. `1609 (Not Found)` when absent. **GET views** lists every saved view the caller owns in this workspace (one per template the caller has customized). **Export view** queues an asynchronous TSV export of the caller's saved view to workspace storage. The `template_id` is a path segment (before `/export/`), not a body or query field. A saved view must already exist for the template (otherwise `1609 (Not Found)`). See [Export Saved View](#export-saved-view) below for the body, dedup behavior, and response. ### Config Schema ```json { "version": 1, "columns": [ { "field": "title", "visible": true, "width": 180 }, { "field": "status", "visible": true, "width": 120 } ], "sort": { "field": "updated", "dir": "desc" }, "filters": [ { "field": "status", "operator": "=", "value_type": "string", "value": "active" }, { "field": "priority", "operator": ">=", "value_type": "int", "value": 3 } ] } ``` - `version` -- int, required, must equal `1`. - `columns` -- required list; **array order IS the display order**. Each entry needs a non-empty string `field`; `visible` (bool) and `width` (positive int) are optional. Unknown column keys are rejected. - `sort` -- required object. `field` is a non-empty string; `dir` is `asc` or `desc`. Unknown sort keys are rejected. - `filters` -- required list (may be empty). AND-chained; insertion order preserved round-trip. Max 5 entries per view. Each entry is `{field, operator, value_type, value}` with exactly those keys -- unknown per-entry keys are rejected. The per-entry `field` matches the naming used by `columns[].field` and `sort.field`. - `operator` -- one of `=`, `!=`, `<`, `<=`, `>`, `>=`. - `value_type` -- one of `string`, `int`, `float`, `bool`. `json` is rejected. - `value` -- non-null scalar matching `value_type`. `int`/`float` accept the stringy form (`"42"`) via numeric validation. For `<`/`<=`/`>`/`>=` with `value_type: string`, comparison is lexical; numeric types use numeric comparison. **Stateless `filters` query parameter shape.** The template nodes listing endpoint accepts a stateless `filters` query parameter whose per-entry identifier is still `key` (not `field`); that pre-existing query-layer contract is unchanged. Saved View configs use `field` for internal consistency with `columns[].field` and `sort.field`. When the server auto-applies a saved view on the nodes endpoint, it handles the translation internally. Use `field` when writing a Saved View; use `key` only when passing a stateless `filters` query parameter directly on the nodes endpoint. **Value coercion.** Values follow standard scalar coercion -- strings like `"true"`, `"1"`, `"yes"`, `"on"` (and their falsy counterparts) are accepted when `value_type` is `bool`; numeric-form strings like `"42"` or `"1.5"` are accepted when `value_type` is `int` or `float`. Sending the typed value directly (e.g. `3` instead of `"3"`) is preferred and round-trips unchanged. Validation failures return `1605 (Invalid Input)`. ### Response Shape ```json { "result": true, "id": "{view_id}", "user_id": "{user_id}", "template_id": "{template_id}", "name": "{name}", "config": { "version": 1, "columns": [ ... ], "sort": { ... }, "filters": [] }, "created": "2026-04-23 11:02:00 UTC", "updated": "2026-04-23 11:02:00 UTC" } ``` The list endpoint wraps rows under `items[]` with a `count` field. ### Export Saved View ``` POST /current/workspace/{workspace_id}/metadata/view/{template_id}/export/ ``` Exports the caller's saved view for the given template to a TSV file written into workspace storage. `template_id` is a **path segment** (before `/export/`), not a body or query field. The export runs **asynchronously**: the call queues a job and returns immediately; poll the destination folder for the finished file. **Precondition:** a saved view must already exist for that template, otherwise `1609 (Not Found)`. **Body** (`application/x-www-form-urlencoded`, both fields optional): - `parent_node_id` -- string, max 64 chars. Destination folder OpaqueId; omit to write to the workspace root. - `filename` -- string, max 200 chars. **Dedup.** If an export for the same `(workspace, user, template, parent_node_id)` is already in flight, the call returns `{ "status": "duplicate", "filename": "...", "message": "..." }` with no `job_id` instead of queuing a second job. **Queued response:** ```json { "result": true, "job_id": "{job_id}", "status": "queued", "filename": "{filename}", "message": "Export queued" } ``` Exports are capped at roughly 50,000 rows. ### Auto-Apply on Template Nodes Listing `GET /current/workspace/{workspace_id}/metadata/templates/{template_id}/nodes` inspects the caller's saved view for the given template. - **Sort:** if the request does not include an explicit `sort_field` and the caller's saved view carries a `sort.field`, the server applies `sort.field` + `sort.dir` to the listing automatically. Passing `sort_field` in the query always wins. - **Filters:** if the request does not include a `filters` query parameter and the caller's saved view carries a non-empty `filters` array, the server applies those filters to the listing automatically. Passing `filters` explicitly (including an empty string or empty array) always wins and bypasses the saved view. No server-side merge is performed. - **Partial override.** `sort_field` and `filters` fall back independently. A request that supplies `sort_field` but omits `filters` uses the caller's sort with the saved view's filters (and vice versa). Callers that want a stateless listing with neither the saved sort nor the saved filters must supply both explicitly. Column order and visibility are **not** applied server-side — clients use the saved view's `columns` array to decide which columns to render. ### Error Responses | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | Missing or malformed parameters, or config failed validation (unknown keys, bad types, bad operator / `value_type`, or more than 5 filters) | | `1609 (Not Found)` | 404 | Caller has no saved view for this template (GET / DELETE / export) | | `1664 (Datastore Error)` | 500 | Failed to read or persist the view | | `1680 (Access Denied)` | 401 | Not a workspace member or metadata feature not enabled | --- ## 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) - 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) - 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 | Not allowed | | `exchange` | Bidirectional | Both parties can upload and download | Yes | Not allowed | **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 (Send type only). | **Restrictions:** - Receive and Exchange shares cannot use `'Anyone with the link'` unless `anonymous_uploads_enabled` is true (requires premium plan). Without anonymous uploads, file uploads require user identity. - 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.** ### 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 AI 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 intelligence. | **Optional parameters:** | Parameter | Type | Required | Default | Constraints | Description | |----------------------|---------|----------|-----------------|----------------------------------|-----------------------------------------------------------------------------| | `share_type` | string | No | `send` | `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`. | | `name` | string | No | Auto-generated | - | Share display / URL name (`custom_url`). 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 Send type + `'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": "aB3cD4eF5g", "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 | 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)`| "Receive and Exchange shares cannot have 'Anyone' access option..." | Public access on receive/exchange | | 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 | | 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": "exchange", "custom_name": "q4-reports", "storage_mode": "independent", "share_category": "portal", "closed": false, "archived": false, "share_level": "owner", "share_root_id": "node_abc123def456", "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_only", "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 access level: `owner`, `guest`, `public`, `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 | | `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 | | 403 | 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": "exchange", "storage_mode": "independent", "share_category": "portal" }, "owner": { "id": "9988776655443322110", "display_name": "Jane Smith", "avatar": "https://..." }, "nodes": [ { "id": "aB3cD4eF5g", "name": "report.pdf", "type": "file", "size": 1048576, "parent": "root" } ], "users": [ { "id": "1122334455667788990", "display_name": "John Doe", "role": "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 | | `nodes` | array | Top-level files and folders in the share root | | `users` | array | Share members (if user has member visibility permission) | | `comments` | array | Comments on the share (filtered by visibility permissions) | | `org` | object\|null| Organization resource (only for workspace-owned shares) | ### Error Responses | HTTP Status | Code | Message | Cause | |-------------|-------------------|-------------------------------------------------|------------------------------------| | 403 | 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 | |------------------------|---------|-----------------------------------|---------------------------------------------------------------------------| | `name` | string | Not blank | Share display name | | `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 | | `access_options` | string | See Access Options | Who can access the share | | `invite` | string | `owners`, `guests` | Who can manage invitations | | `password` | string | 4-128 chars, `"null"`/`""` to clear | Password. Only with Send type + `'Anyone with the link'`. | | `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&share_type=exchange" ``` ### 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'`. - **Intelligence**: 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**: Updating the share name automatically renames any associated workspace share link node. ### Error Responses | HTTP Status | Code | Message | Cause | |-------------|--------------------------|-----------------------------------------------------------------------------|------------------------------------------------| | 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)`| "Password can only be set for shares with 'Anyone' access option." | Password on non-public share | | 406 | `1605 (Invalid Input)`| "Intelligence cannot be enabled on workspace folder shares..." | Intelligence enable on a `workspace_folder` share | | 406 | `1605 (Invalid Input)`| "Intelligence requires a plan with agentic AI support." | Plan missing `content_ai` or `ai_agent` (cannot set `intelligence=true`) | | 412 | `1685 (Feature Limit)` | "Intelligence setting can only be changed twice per minute and five times per hour. Please wait and try again." | Intelligence toggle throttled (per-share limit) | | 406 | `1605 (Invalid Input)`| "Workspace folder shares cannot have an expiration date..." | Expiration on workspace_folder share | | 406 | `1605 (Invalid Input)`| "Receive and Exchange shares cannot have 'Anyone' access option..." | Public access on receive/exchange | | 403 | 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 | | 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 | | 403 | 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 | | 403 | 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 **Send** 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 | | 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. --- ## 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 | Token lifetime in seconds (86400 = 24 hours) | #### 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 | | 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 guest auth call creates a new ephemeral user account scoped to the share. - 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/init/`, chunk upload, `POST /current/upload/complete/`) with `Authorization: Bearer {auth_token}`. 4. **Add to share:** `POST /current/share/{share_id}/storage/addfile/` with the upload key, 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. Owner/admin only.** ### Upload/Set Asset ``` POST /current/share/{share_id}/assets/{asset_id}/ ``` Upload an asset (multipart/form-data). **Auth required. Owner/admin only.** | Field | Type | Required | Description | |------------|------|----------|----------------------------------| | `file` | file | Yes | The asset file to upload | | `metadata` | JSON | No | Optional metadata for the asset | #### 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. Owner/admin 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. **Auth required. Owner/admin only.** #### Request Body (Adding Existing User) | Field | Type | Required | Default | Description | |---------------------|--------|----------|----------------------|--------------------------------------------------------------------------------| | `permissions` | string | No | `member` | `owner`, `admin`, `member`, `guest`, `view`. Cannot be `owner` (use transfer). | | `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. | | `force_notification`| boolean| No | - | Resend notification email (60-second cooldown after initial add). | #### Request Body (Inviting by Email) | Field | Type | Required | Default | Description | |----------------------|--------|----------|----------|--------------------------------------------| | `permissions` | string | No | `member` | Permission level for the invitation | | `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", "message": "Join our shared files!"}' ``` #### Response (Invitation Created) ```json { "result": true, "invitation": { "id": "5566778899001122334", "inviter": "Jane Smith", "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" } } ``` #### Error Responses | HTTP Status | Code | Message | Cause | |-------------|--------------------------|--------------------------------------------------------------------------------|--------------------------------| | 500 | `1692 (Cannot Add As Owner)`| "Adding a member as an owner is not allowed" | Tried to add as owner | | 406 | `1605 (Invalid Input)`| "You cannot create a membership for the share owner." | Target is share owner | | 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 | ### Remove Member ``` DELETE /current/share/{share_id}/members/{user_id}/ ``` Remove a member. **Auth required. Owner/admin only.** Cannot remove the last owner. ### 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", "invite": null, "notify": "Notify me in app", "expires": null } } ``` ### Update Member ``` POST /current/share/{share_id}/member/{user_id}/update/ ``` Update permissions, notification settings, or expiration. **Auth required. Owner/admin only.** | Field | Type | Required | Description | |-----------------|--------|----------|---------------------------------------------------------------| | `permissions` | string | No | `admin`, `member`, `guest`, `view`. Cannot set to `owner`. | | `notify_options`| string | No | Notification preference | | `expires` | string | No | Membership expiration datetime. `null`/`""` to clear. | ### Transfer Ownership ``` POST /current/share/{share_id}/member/{user_id}/transfer_ownership/ ``` Transfer share ownership to another member. **Auth required. Owner only.** Current owner is demoted to admin. ### 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", "invite": null, "notify": "Notify me in app", "expires": null }, { "id": "5566778899001122334", "account_type": "human", "email_address": "invited@example.com", "first_name": "invited@example.com", "last_name": "", "permissions": "guest", "status": "pending", "invite": { "id": "5566778899001122334", "created": "2025-01-15 10:30:00 UTC", "expires": "2025-02-15 10:30:00 UTC" }, "notify": "Notify me in app", "expires": null } ] } ``` ### 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`, `created`, `expires`). - `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 | Self-joined members receive `member` permission with no expiration. Only `notify_options` is respected from input. ### 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.** ### Update Invitation ``` POST /current/share/{share_id}/members/invitation/{invitation_id}/ ``` Update an invitation. `{invitation_id}` can be a numeric ID or an email address. **Auth required.** | Field | Type | Required | Description | |-----------------|--------|----------|------------------------------------------------------------------| | `state` | string | No | `pending`, `accepted`, `declined` | | `permissions` | string | No | Updated permission level: `admin`, `member`, `guest`, `view` | | `notify_options`| string | No | Updated notification preference | | `expires` | string | No | Updated membership expiration datetime | ### Delete Invitation ``` DELETE /current/share/{share_id}/members/invitation/{invitation_id}/ ``` Revoke an invitation. `{invitation_id}` can be a numeric ID or an email address. **Auth required.** --- ## 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. #### Response Fields Each share in the `shares` array includes the same fields as the details endpoint, plus: | Field | Type | Description | |-----------------------------------|-------------|------------------------------------------------------| | `shares[].user_status` | string | User's membership status (e.g., `joined`, `invited`) | | `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/ ``` Generate an Open Graph image for the share. **Auth required.** ### 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. 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": "aBcDeFgHiJ", "node": { "id": "1234567890123456789", "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 public viewer metadata and the bound file's public node metadata (name / size / type) — never the workspace path or owning-profile ids. 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": "a2bk7yqe9z3w8h5n6m4xp1c0tv7rs", "title": "Quarterly Presentation", "access_option": "anyone_with_link", "has_password": false, "effective_capability": "download", "can_manage": true, "workspace_id": "9876543210987654321", "creator_uid": "1122334455667788990", "file": { "id": "f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf", "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. ### 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, or the bound file content is no longer available | --- ## 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 - `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) **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 stored. 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 any HTTP/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 `stored` 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 (the final chunk only) | | Chunk ordering | 1-based (first chunk is `order=1`) | | Supported hash algorithms | `md5`, `sha1`, `sha256`, `sha384` | | `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 40 GB) | | Max concurrent sessions | Plan-dependent (Starter and Business 5,000; Growth 10,000) | | Long-poll max wait | 590 seconds | | Stream upload | Exactly one stream upload allowed per stream-mode session | --- ## 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 for the auto-add path (when `instance_id` + `folder_id` were supplied) and the no-target path. File IS accessible for download and preview in those cases. In the async-storage path it is intermediate -- the system continues to `store -> storing -> stored`. | Stop polling if terminal for your path; otherwise keep polling (or use `addfile` if not auto-adding) | | `store` | Storage import queued | Keep polling | | `storing` | Being imported to storage | Keep polling | | `stored` | Fully complete -- file assembled and in storage (async-storage path) | Done. `new_file_id` available. | | `assembly_failed` | Assembly failed | Handle error | | `store_failed` | Storage import failed | Handle error | **Terminal states:** `complete` (auto-add path), `stored` (async path), `assembly_failed`, `store_failed`. Stop polling when you reach one of these. Note: `complete` is terminal when the upload was created with `instance_id` + `folder_id` (auto-add) or with no target at all; otherwise it is intermediate. ### State machine branches A session takes one of three branches based on how it was created: - **Container / auto-add path** -- request supplies `instance_id` + `folder_id`. Path: `uploading -> storing -> complete`. Terminal: `complete`. File is accessible for download and preview. - **Async-storage path** -- a target is supplied but finalization is routed to the async assembly worker. Path: `uploading -> assemble -> assembling -> store -> storing -> stored`. Terminal: `stored`. May also terminate at `assembly_failed` or `store_failed`. - **No-target path** -- no `instance_id` provided. Path: `uploading -> complete`. 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. --- ## 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 | 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"`, or `"sha384"` | | `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 "chunk=@notes.txt" ``` **Response (201 Created):** ```json { "result": true, "id": "u1abc-defgh-ijklm-nopqr-stuvw-xyz1", "creator": "my-web-client", "new_file_id": "f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf" } ``` **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 | OpaqueId of the created storage node. Only present for single-call uploads with an upload target (`instance_id`). | When `instance_id` and `folder_id` are provided, the file is automatically added to storage. No separate polling or `addfile` step is needed. **Response (without `instance_id`, 201 Created):** ```json { "result": true, "id": "u1abc-defgh-ijklm-nopqr-stuvw-xyz1" } ``` Without a target, only the upload session `id` is returned. Use `addfile` to place the file in storage after the upload completes. **Error responses:** | 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 | "Invalid share or workspace instance ID." | | `1658 (Not Acceptable)` | 406 | "This file type is not allowed for upload on your plan." | | `1685 (Feature Limit)` | 412 | "The file size exceeds 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." | | `1658 (Not Acceptable)` | 406 | "We were unable to create the upload session..." | --- ## 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 | Yes | `"create"` for new file, `"update"` for file replacement | | `instance_id` | string | Required if action=create | 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 | SHA-256 hex hash of the full file for integrity verification. Must be provided with `hash_algo`. | | `hash_algo` | string | No | Hash algorithm: `"md5"`, `"sha1"`, `"sha256"`, or `"sha384"` | | `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": "u1abc-defgh-ijklm-nopqr-stuvw-xyz1" } ``` ### 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"`, or `"sha384"` | **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/u1abc-defgh-ijklm-nopqr-stuvw-xyz1/chunk/?order=1&size=5242880&hash_algo=sha256&hash=abc123def456..." \ -H "Authorization: Bearer {jwt_token}" \ -F "chunk=@chunk_001.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." | | `1605 (Invalid Input)` | 406 | "No `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 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..." | | `1654 (Internal Error)` | 500 | "The chunk failed to be stored..." | ### 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 for validation. Can be provided/updated at completion time. Must be provided with `hash_algo`. | | `hash_algo` | string | No | Hash algorithm: `"md5"`, `"sha1"`, `"sha256"`, or `"sha384"` | 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/u1abc-defgh-ijklm-nopqr-stuvw-xyz1/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..." | | `1678 (Enqueue Failed)` | 500 | "Your request was valid but could not be processed." | ### 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). Server holds the connection while the upload is still processing (assembling or storing) and returns as soon as it reaches a terminal status: `complete`/`stored` 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/u1abc-defgh-ijklm-nopqr-stuvw-xyz1/details/?wait=60" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "session": { "id": "u1abc-defgh-ijklm-nopqr-stuvw-xyz1", "filename": "annual-report.pdf", "size": 52428800, "status": "stored", "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "hash_algo": "sha256", "created": "2025-01-20 10:30:00 UTC", "updated": "2025-01-20 10:35:00 UTC", "chunks": { "1": 5242880, "2": 5242880, "3": 5242880, "4": 5242880, "5": 5242880 } } } ``` **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) | | `session.hash_algo` | string | Hash algorithm (if provided) | | `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 `stored` 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 | Upload-target descriptor, present when the session was created with a target. For a create it carries `{action:"create", instance_id, folder_id, relative_path, relative_id}`; for an update `{action:"update", instance_id, file_id}`. | | `session.org` | string | Organization ID used for billing-limit resolution, when one 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 for your branch — `complete` (auto-add or no-target paths), `stored` (async-storage path), `assembly_failed`, or `store_failed`. See "State machine branches" above for which terminal applies to your session. ### 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 | |---|---|---|---| | `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 'from={"type":"upload","upload":{"id":"u1abc-defgh-ijklm-nopqr-stuvw-xyz1"}}' ``` ### 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": "u1abc-defgh-ijklm-nopqr-stuvw-xyz1"} ``` **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/u1abc-defgh-ijklm-nopqr-stuvw-xyz1/chunk/?order=1&size=5242880" \ -H "Authorization: Bearer {jwt_token}" -F "chunk=@chunk1.bin" & curl -X POST "https://api.fast.io/current/upload/u1abc-defgh-ijklm-nopqr-stuvw-xyz1/chunk/?order=2&size=5242880" \ -H "Authorization: Bearer {jwt_token}" -F "chunk=@chunk2.bin" & curl -X POST "https://api.fast.io/current/upload/u1abc-defgh-ijklm-nopqr-stuvw-xyz1/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/u1abc-defgh-ijklm-nopqr-stuvw-xyz1/complete/" \ -H "Authorization: Bearer {jwt_token}" ``` **4. Poll until stored:** ```bash curl -X GET "https://api.fast.io/current/upload/u1abc-defgh-ijklm-nopqr-stuvw-xyz1/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/u1abc-defgh-ijklm-nopqr-stuvw-xyz1/details/?wait=60" \ -H "Authorization: Bearer {jwt_token}" ``` Response: `{"result": true, "session": {"status": "stored", "new_file_id": "f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf", ...}}` -- done **5. Clean up session:** ```bash curl -X DELETE "https://api.fast.io/current/upload/u1abc-defgh-ijklm-nopqr-stuvw-xyz1" \ -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) | | `file_id` | string | Conditional | File to update (required if action=update) | | `hash` | string | No | Expected whole-file hash | | `hash_algo` | string | No | Hash algorithm | | `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. | Parameter | Type | Required | Description | |---|---|---|---| | `hash` | string | No | Whole-file hash for validation | | `hash_algo` | string | No | Hash algorithm | **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` -- the session auto-finalizes. The session's `size` is updated to actual bytes received. ### 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 -- no assembly pipeline is needed. - 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. 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}" \ -d "name=report-v2.pdf" \ -d "size=1024" \ -d "action=update" \ -d "instance_id=1234567890123456789" \ -d "file_id=f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf" \ -F "chunk=@report-v2.pdf" ``` The `instance_id` for an update target may be a **workspace**, a **share**, or a **File Share** (see below). --- ## 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}`, 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}" \ -d "name=report-v2.pdf" \ -d "size=1024" \ -d "action=update" \ -d "instance_id=1234567890123456789" \ -d "file_id=f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf" \ -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). ### Optional Compare-and-Swap (`if_version_id`) To avoid clobbering a concurrent edit, supply an optional `if_version_id` precondition. The replace is applied **only if** the bound file's current version equals that id. | 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. | `if_version_id` is honored **only** on the File Share update path. Sending it for any other upload target returns **406** (`1605 (Invalid Input)`) rather than silently ignoring it. ### Version Conflict Surfacing When `if_version_id` does not match the current version, the session ends terminally: `status` becomes `assembly_failed` and `session.status_message` carries `CONFLICT_VERSION_MISMATCH:{current_version_id}`. 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. ```json { "result": true, "session": { "id": "uABC123def456", "status": "assembly_failed", "status_message": "CONFLICT_VERSION_MISMATCH:v9xQ2-abc12-de345-fg678-hi901-jk234" } } ``` --- ## 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` | | 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"`, or `"sha384"` for optional integrity validation. | | `hash` | string | No | Hex digest of the uploaded bytes. | 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/`. | | `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 unless every file 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. **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. | | `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. - `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. - Internal storage / finalization failure for the single entry. ### 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. --- ## Web Upload (URL Import) Import files from any HTTP/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. Only HTTP/HTTPS supported. Max 2048 characters. | | `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 | Bitfield options (default: 0). See options table. | | `creator` | string | No | Client identifier string (1-150 chars, alphanumeric and hyphens only) | **Options bitfield:** | Bit | Value | Description | |---|---|---| | 0 | `0x1` | Overwrite if a file with the same name exists at the destination | | 1 | `0x2` | Skip virus scanning (admin only) | | 2 | `0x4` | Attempt to preserve source file metadata | **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": "aBcDeFgHiJkLmNoPqRsT123456", "user_id": "1234567890123456789", "profile_id": "1234567890123456789", "profile_type": "workspace", "source_url": "https://example.com/files/document.pdf", "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 | The source URL | | `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 | "Invalid URL. Only HTTP and HTTPS URLs are supported." | | `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": "aBcDeFgHiJkLmNoPqRsT123456", "user_id": "1234567890123456789", "profile_id": "1234567890123456789", "profile_type": "workspace", "source_url": "https://example.com/files/document.pdf", "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/aBcDeFgHiJkLmNoPqRsT123456/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "web_upload": { "id": "aBcDeFgHiJkLmNoPqRsT123456", "user_id": "1234567890123456789", "profile_id": "1234567890123456789", "profile_type": "workspace", "source_url": "https://example.com/files/document.pdf", "file_name": "document.pdf", "status": "complete", "bytes_downloaded": 52428800, "expected_size": 52428800, "upload_session_id": "xYzAbCdEfGhIjKlMnOpQ123456", "async_job_id": "aBcDeFgHiJkLmNoPqRsT789012", "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." | ### 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=aBcDeFgHiJkLmNoPqRsT123456" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "canceled": true, "id": "aBcDeFgHiJkLmNoPqRsT123456" } ``` **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 40 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, "results": 2, "sessions": [ { "id": "aBcDeFgHiJkLmNoPqRsT123456", "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 | ### 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/aBcDeFgHiJkLmNoPqRsT123456" \ -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." | | `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 | - | 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": 104857600, "size": 42949672960, "chunks": 500, "sessions": 7500, "sessions_size_max": 214748364800 } } ``` **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 (free-tier) 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 `"free"` | Override the billing plan to check restrictions for | **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=solo" ``` **Response (200 OK):** ```json { "result": true, "restricted_extensions": [".exe", ".apk", ".jar", ".php"], "archive_extensions": [".7z", ".zip", ".rar", ".tar.gz", ".bz2"], "enforcement_enabled": true, "plan": "free", "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 | | `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"] } ``` ### 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/aBcDeFgHiJkLmNoPqRsT123456/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/aBcDeFgHiJkLmNoPqRsT123456/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/aBcDeFgHiJkLmNoPqRsT123456/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} ``` ### 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 -> 202 POST /current/upload/{id}/complete/ # Trigger assembly -> 202 GET /current/upload/{id}/details/?wait=60 # Poll until stored -> 200: {session: {status, new_file_id}} DELETE /current/upload/{id} # Clean up session -> 200 ``` ### Manual add to storage (if no instance_id): ``` POST /current/workspace/{id}/storage/{folder}/addfile/ form: 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. - **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 **canonical** path family. The older `/ai/chat/` paths remain a **legacy alias**: every agent endpoint still resolves through the `/ai/chat/` anchor to the exact same handler, so an older integration calling `/ai/chat/...` continues to work unchanged. New integrations should use `/ai/agent/...`. If you migrate an existing `/ai/chat/...` integration, swap only the URL family — the request parameters, response shapes, and behavior are identical between the two anchors because they reach the same code. --- ## 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 - **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, streaming existing responses, and notes. 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, intelligence, RAG) | | Business | on | on | Full AI (chat, intelligence, RAG) | | Growth | on | on | Full AI (chat, intelligence, RAG) | New organizations choose a paid plan (Starter, Business, or Growth), all of which include `content_ai` and `ai_agent` for full AI. **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 create/update: `1605 (Invalid Input)` with a message indicating intelligence requires a plan that supports agentic AI 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 Growth). --- ## Intelligence Setting The `intelligence` boolean on a workspace or portal share 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). On plans that include `ai_agent`, new workspaces created by agent accounts default to `true`. On a plan without `ai_agent`, `intelligence` defaults to `false` 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:** Intelligence is only available on portal shares (independent storage). Workspace folder shares (`storage_mode=workspace_folder`) cannot have intelligence enabled — their files are indexed through the parent workspace instead. The API will return an error if you attempt to enable intelligence on a shared folder share. **Enable at creation:** ``` POST /current/org/{org_id}/create/workspace/ intelligence=true ``` **Update later:** ``` POST /current/workspace/{workspace_id}/update/ intelligence=true ``` **Note:** Intelligence can be enabled and disabled within time restrictions. Disabling intelligence destroys indexed embeddings (the vector index is flushed). Re-enabling intelligence 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`, `subjects`, or `uploads` 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 (have a ready preview or summary). ### 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). - Up to **100 file/folder references** total across all three arrays. ### 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 / 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 an intelligent workspace progress through AI processing states: | State | Meaning | |---|---| | `disabled` | Intelligence 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 intelligence 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 intelligence 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 an intelligent workspace, 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. (May be omitted only when `content_parts` carries the message text.) | | `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": "aBcDeFgHiJkLmNoPqR", "creator": { "type": "user", "id": "1234567890123456789" }, "scope": { "type": "workspace", "id": "1234567890123456789" }, "name": "New Chat", "status": "active", "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": "xYzAbCdEfGhIjKlMnO", "thread_id": "aBcDeFgHiJkLmNoPqR", "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 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 | | `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 | --- ### List chats ``` GET /current/workspace/{workspace_id}/ai/agent/list/ ``` Returns all chats created by the current user in the workspace. Sorted by most recently modified first. **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 deleted chats: `GET .../ai/agent/list/deleted` **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": "aBcDeFgHiJkLmNoPqR", "creator": { "type": "user", "id": "1234567890123456789" }, "scope": { "type": "workspace", "id": "1234567890123456789" }, "name": "Quarterly report analysis", "status": "active", "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": "xYzAbCdEfGhIjKlMnO", "thread_id": "aBcDeFgHiJkLmNoPqR", "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 | | `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 chat details with full message history. **Auth:** Bearer token required. `content_ai` plan feature required. **Request example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/ai/agent/aBcDeFgHiJkLmNoPqR/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": "aBcDeFgHiJkLmNoPqR", "creator": { "type": "user", "id": "1234567890123456789" }, "scope": { "type": "workspace", "id": "1234567890123456789" }, "name": "Quarterly report analysis", "status": "active", "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": 3, "continuable": true }, "turns": { "count": 1, "items": [ { "turn_id": "xYzAbCdEfGhIjKlMnO", "thread_id": "aBcDeFgHiJkLmNoPqR", "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. | 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/aBcDeFgHiJkLmNoPqR/update/" \ -H "Authorization: Bearer {jwt_token}" \ -d "name=Updated Chat Name" ``` **Response (200 OK):** ```json { "result": true } ``` **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1658 (Not Acceptable)` | 406 | Chat not found or locked | | `1605 (Invalid Input)` | 406 | Invalid name value | | `1664 (Datastore Error)` | 500 | Update failed | --- ### 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/aBcDeFgHiJkLmNoPqR/" \ -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 locked | | `1654 (Internal Error)` | 500 | Chat in non-deletable state or internal error | Deleted chats can be listed via `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. (May be omitted only when `content_parts` carries the message text.) | | `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/aBcDeFgHiJkLmNoPqR/message/" \ -H "Authorization: Bearer {jwt_token}" \ -d "question=How does that compare to Q2?" ``` **Response (200 OK):** ```json { "result": true, "turn_id": "mNoPqRsTuVwXyZaBcD", "thread_id": "aBcDeFgHiJkLmNoPqR", "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 | --- ### 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. - **Partial billing.** Tokens consumed up to the cancel point are charged (no refund), matching the behavior of streaming AI providers. - **SSE cancel signal.** When the SSE read endpoint detects a cancellation it emits a single `event: cancelled` (empty data) followed by `event: done` and then closes the stream. SSE clients should listen for the dedicated `cancelled` event (e.g. `eventSource.addEventListener('cancelled', …)`) rather than scanning data payloads — the read endpoint does not forward any raw `[CANCELLED]` text on the wire. - **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 | --- ### List messages in a chat ``` GET /current/workspace/{workspace_id}/ai/agent/{chat_id}/messages/list/ ``` Returns all messages in chronological order (oldest first). **Auth:** Bearer token required. `content_ai` plan feature required. **Request example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/ai/agent/aBcDeFgHiJkLmNoPqR/messages/list/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "messages": { "count": 1, "items": [ { "turn_id": "xYzAbCdEfGhIjKlMnO", "thread_id": "aBcDeFgHiJkLmNoPqR", "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`). --- ### 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/aBcDeFgHiJkLmNoPqR/message/xYzAbCdEfGhIjKlMnO/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 the answer text, `references`, `citations` (see Citation Format below), 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 — for a `needs_input` turn — a `clarification` 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` (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": "xYzAbCdEfGhIjKlMnO", "thread_id": "aBcDeFgHiJkLmNoPqR", "seq": 1, "status": "complete", "query": { "text": "Summarize the quarterly report" }, "error": null, "cost": { "credits": 5, "tokens": 500 }, "result": { "answer": "The quarterly report shows revenue growth of 15%...", "citations": [ { "hash": "a1b2c3d4", "nodeId": "f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf", "versionId": "v1abc-defgh-ijklm", "entries": [ { "page": 3, "snippet": "Revenue increased by 15% year over year...", "timestamp": null } ] } ], "truncated": false, "thought_transcript": "", "commentary_transcript": "", "thought_events": [], "commentary_events": [] }, "actions": [ { "seq": 1, "label": "Create File", "state": "done", "affected_refs": ["aBcDeFgHiJkLmNoPqR"], "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/aBcDeFgHiJkLmNoPqR/message/xYzAbCdEfGhIjKlMnO/read/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Accept: text/event-stream" ``` **SSE stream format:** ``` event: data data: {"item": "The quarterly report "} event: data data: {"item": "shows revenue growth of "} event: data data: {"item": "15% year over year."} event: analysis_data data: {"type": "analysis_chunk", ...} event: table_data data: {"type": "table_data", ...} event: done ``` **SSE event types:** | Event Type | Description | |---|---| | `data` | Text chunks of the AI response. Payload: `{"item": "..."}`. Concatenate all `data` events for the full text. | | `event` | Status update or event notification | | `analysis_data` | Structured analysis data, citations, and references to source files | | `commentary` | Interim narration the AI emits while working. Payload: `{"text": "...", "parts": [...]}` — `text` is the flattened narration, `parts` the ordered content parts (text and inline object references). Expect one per work step on multi-step responses. | | `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". | | `table_data` | Tabular data extracted or generated by the AI | | `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` | Stream complete. No more events will be sent. | **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. 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/aBcDeFgHiJkLmNoPqR/publish/" \ -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 locked | | `1660 (Conflict)` | 409 | Chat is already public | | `1664 (Datastore Error)` | 500 | Update failed | --- ### 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 opaque IDs. Min 1, max 25. | **Request example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/ai/share/" \ -H "Authorization: Bearer {jwt_token}" \ --data-urlencode 'files=["aBcDeFgHiJkLmN", "oPqRsTuVwXyZ12"]' ``` 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": "## Files\n\n### quarterly-report.pdf\n[Download](https://api.fast.io/...)\nSize: 2.5 MB\n\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 | --- ### 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`). 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": 2, "items": [ { "id": "txn_abc123", "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`, `failed`, `cancelled`, `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 below). | | `items[].tokens` | integer | Token credits consumed | | `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`) | --- ## 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: `data` (text chunks), `commentary` (interim narration), `status` (turn-progress hints, before the agent's first frame), `analysis_data`, `table_data`, `needs_input` (terminal: a single clarifying question instead of a full answer — present it and send the user's reply as a new message), `done`. **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}?chat={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. --- ## 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 PNG image data (not JSON). **Auth:** Conditional. Public shares (Anyone / RegisteredUsers) need no authentication and get a custom AI-generated image. Private shares require Bearer auth **and** the **`ai_autoog`** plan feature; without valid permission or the feature, the endpoint falls back to the default private OG image (still HTTP 200 image data). | Behavior | Description | |---|---| | Public share | Custom AI-generated image based on share content | | Private share | Custom image when the caller has permission and the `ai_autoog` feature; otherwise 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 | --- #### 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. 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 | **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": "document" } ``` | Field | Type | Description | |---|---|---| | `title` | string | AI-generated title | | `description` | string | AI-generated description | | `display_type` | string | AI-suggested display type | **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1680 (Access Denied)` | 401 | Insufficient share permissions | | `1654 (Internal Error)` | 500 | Share update or generation failure | --- ### Share Chat Endpoints Share chat endpoints mirror workspace chat endpoints. All response formats and schemas are identical to the workspace versions documented above. The key differences are: - 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. (May be omitted only when `content_parts` carries the message text.) | | `name` | string | No | Auto-generated | Chat name. A default is used if omitted. | | `kind` | string | No | `user` | Share-context creation only produces `user` chats. | | `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": "aBcDeFgHiJkLmNoPqR", "scope": { "type": "share", "id": "1234567890123456789" }, "status": "active", "kind": "user", "...": "..." }, "turn": { "turn_id": "xYzAbCdEfGhIjKlMnO", "thread_id": "aBcDeFgHiJkLmNoPqR", "seq": 1, "status": "pending", "...": "..." } } ``` **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1605 (Invalid Input)` | 406 | Invalid `kind` or `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 | --- #### 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/aBcDeFgHiJkLmNoPqR/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. `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/aBcDeFgHiJkLmNoPqR/update/" \ -H "Authorization: Bearer {jwt_token}" \ -d "name=Updated Chat Name" ``` **Response (200 OK):** ```json { "result": true } ``` **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1658 (Not Acceptable)` | 406 | Chat not found or locked | | `1605 (Invalid Input)` | 406 | Invalid name value | | `1664 (Datastore Error)` | 500 | Update failed | --- #### Delete a chat (Share) ``` DELETE /current/share/{share_id}/ai/agent/{chat_id}/ ``` **Auth:** Bearer token required. Share `view` permission and chat permission. `content_ai` plan feature required. **Request example:** ```bash curl -X DELETE "https://api.fast.io/current/share/1234567890123456789/ai/agent/aBcDeFgHiJkLmNoPqR/" \ -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 locked | | `1654 (Internal Error)` | 500 | Chat in non-deletable state or internal error | Deleted chats can be listed via `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. (May be omitted only when `content_parts` carries the message text.) | | `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/share/1234567890123456789/ai/agent/aBcDeFgHiJkLmNoPqR/message/" \ -H "Authorization: Bearer {jwt_token}" \ -d "question=How does that compare to Q2?" ``` **Response (200 OK):** ```json { "result": true, "turn_id": "mNoPqRsTuVwXyZaBcD", "thread_id": "aBcDeFgHiJkLmNoPqR", "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 | --- #### 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, partial billing, SSE `cancelled` event, 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 | --- #### 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/aBcDeFgHiJkLmNoPqR/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/aBcDeFgHiJkLmNoPqR/message/xYzAbCdEfGhIjKlMnO/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** Same format as workspace message details. 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/aBcDeFgHiJkLmNoPqR/message/xYzAbCdEfGhIjKlMnO/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/aBcDeFgHiJkLmNoPqR/publish/" \ -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 locked | | `1660 (Conflict)` | 409 | Chat is already public | | `1664 (Datastore Error)` | 500 | Update failed | --- #### 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 download 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 opaque IDs. 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=["aBcDeFgHiJkLmN", "oPqRsTuVwXyZ12"]' ``` 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": "## Files\n\n### quarterly-report.pdf\n[Download](https://api.fast.io/...)\nSize: 2.5 MB\n\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 | | `1680 (Access Denied)` | 401 | Insufficient download permissions | --- ### 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) | | `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/aBcDeFgHiJkLmNoPqRsTuVwXyZ?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 | | `1654 (Internal Error)` | 500 | Unable to retrieve or read file | All invalid/expired token errors return 404 to prevent token enumeration. --- ## Semantic Search > **Deprecated:** The `/ai/search/` endpoint is deprecated. Use `GET /current/workspace/{id}/storage/search/` (or `/share/{id}/storage/search/`) instead. The unified storage search endpoint automatically performs semantic search when workspace intelligence is enabled. Pass the `search` parameter for your query, and optionally `files_scope` and `folders_scope` to narrow results. Semantic results include `relevance_score`, `content_snippet`, `match_source`, `media_segment`, `mimetype`, and `search_metadata` fields. The legacy endpoints below continue to work but will be removed in a future release. ### Workspace Semantic Search (Deprecated) ``` GET /current/workspace/{workspace_id}/ai/search/ ``` **Auth:** Bearer token required. Workspace `view` permission. `content_ai` plan feature required. ### Share Semantic Search (Deprecated) ``` GET /current/share/{share_id}/ai/search/ ``` Identical to workspace semantic search but scoped to a share. **Parameters:** | Parameter | Type | Required | Default | Description | |---|---|---|---|---| | `question` | string | Yes | -- | Search query, 2-1,000 characters. (The parameter is named `question`, not `query_text`.) | | `files_scope` | string | No | All indexed files | Comma-separated `nodeId:versionId` pairs (max 100) | | `folders_scope` | string | No | All indexed files | Comma-separated `nodeId:depth` pairs (max 100, depth 1-10) | | `limit` | integer | No | 20 | Results per page, 1-500 | | `offset` | integer | No | 0 | Pagination offset | This deprecated endpoint has **no** `details` parameter — node enrichment via `details=true` is a feature of `/storage/search` (below), not this endpoint. It also returns `1605 (Invalid Input)` / 406 when workspace intelligence is not enabled. **Preferred approach — use `/storage/search` instead:** ```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, "response_code": 200, "results": [ { "content": "The quarterly revenue showed a 15% increase...", "score": 0.95, "node": { "id": "f3jm5-zqzfx-pxdr2-dx8z5-bvnb3-rpjf", "type": "file", "name": "quarterly-report.pdf", "parent": "root", "size": 1048576, "mimetype": "application/pdf", "ai": { "state": "indexed", "attach": true, "summary": true } } } ], "pagination": { "total": 25, "limit": 10, "offset": 0, "has_more": true } } ``` | Field | Type | Description | |---|---|---| | `results[].content` | string | Matched text snippet from the indexed document | | `results[].score` | float | Relevance score (0.0-1.0, higher is more relevant) | | `results[].node` | object/null | Full node resource, or `null` if the file was deleted | | `results[].file_details` | object | Raw metadata when `node` is null (node_id, version_id, name, mimetype) | | `pagination.total` | integer | Total number of results | | `pagination.has_more` | boolean | Whether more results are available | **Hybrid response fields (intelligence enabled):** When workspace intelligence is enabled, each file entry in the `/storage/search` response includes additional semantic fields: | Field | Type | Description | |---|---|---| | `relevance_score` | float | Semantic relevance score (0.0-1.0) | | `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/` (`terse` ~200 bytes, `standard` ~600 bytes, `full` untrimmed; truncated values end with `…`). | | `match_source` | string | Source of the match: `keyword`, `semantic`, or `both` | | `mimetype` | string | File MIME type (e.g., `application/pdf`, `audio/mpeg`). Present for semantic matches. | | `media_segment` | object | Only for audio/video matches when intelligence 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`, plus `content_search_available` and `reason` when `search_in` was supplied. See *Search* in the Storage reference. | **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 intelligence 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 | Intelligence not enabled | | `1605 (Invalid Input)` | 406 | Malformed `files_scope` or `folders_scope` | --- ## 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 | | `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 | 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`, `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`, `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` / `result.references`), fetched from **Get message details**. They reference specific locations in files that informed the AI response. | Field | Type | Description | |---|---|---| | `hash` | string | File content hash (used for grouping) | | `nodeId` | string | Storage node opaque ID | | `versionId` | string | File version opaque ID | | `entries` | array | Citation locations within the file | | `entries[].page` | integer | Page number in the document | | `entries[].snippet` | string/null | Relevant text excerpt | | `entries[].timestamp` | float/null | Timestamp for audio/video files (seconds) | --- ## 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} ``` 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:{fileId}` | File added, updated, or removed | | `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. --- ## Metadata Templates Structured metadata for workspace files. Templates define schemas (fields, types, constraints) and have many-to-many relationships with files -- a template can be applied to multiple files, and files can have metadata from multiple templates. Templates are managed at the workspace level. The metadata feature is available on **all plan tiers**, with three distinct caps that scale by plan: | Plan | Max Templates | Max Files per Template | Max Fields (Columns) per Template | |---|---|---|---| | Starter | 2 | 1000 | 10 | | Business | 10 | 1000 | 50 | | Growth | 10 | 1000 | 50 | Listing, details, and preview endpoints return `plan_node_limit`, `is_truncated`, and an unfiltered count alongside the visible (capped) count, so consumers can render upsell messaging. Manual `/nodes/add` rejects requests that would exceed the per-template file cap. Auto-match silently truncates its candidate set to the remaining slots and incurs no LLM cost when the template is already at its cap. On downgrade, overflow rows are preserved but hidden; under truncation, the visible window is ordered by mapping creation order (oldest-mapped first), so the same files remain visible across plan changes and re-upgrade restores full visibility. The field cap bounds the number of fields (columns) declared in a template's schema. It is enforced at every commit point that mutates the field set: create, update, and copy. Exceeding the cap returns `1605 (Invalid Input)` with a message describing the attempted field count and the cap. The `/details` and `/list` endpoints surface the current declared field count as `field_count` and the per-plan ceiling as `plan_field_limit` (`-1` when unlimited or unresolved). Each template field also carries an `autoextract` boolean (defaults to `true`). Fields with `autoextract=false` are excluded from the default auto-extraction scope, which is useful for purely manual fields. Every template must have at least one field with `autoextract=true`; templates that opt every field out are rejected at create/update/copy. A full-row extract request that would yield an empty effective scope short-circuits with success and does not enqueue a job; an explicit `fields` array on the extract endpoint overrides the flag and is always honored. Per-node autoextract eligibility is surfaced as an `autoextractable` boolean on both the metadata details endpoint (`GET /workspace/{id}/storage/{node_id}/metadata/details/`) and the template nodes listing (`GET /workspace/{id}/metadata/templates/{tid}/nodes`). It is `true` when the node is a non-trashed file with a completed AI summary — the same signal the extraction pipeline uses — and clients can use it to gate "extract now" affordances without making an extra probe. Files are linked to templates either manually (add/remove endpoints) or automatically via AI-based matching. When intelligence is enabled, metadata is **automatically extracted during file ingestion** for documents, spreadsheets, images (PNG, JPEG, WebP), and code files. --- ## Compact Responses (`output=`) Every metadata endpoint that returns object-metadata, template, saved-view, or eligible-node records accepts an optional `output` query parameter that selects the shape of each record 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. **Object metadata** (`GET /workspace/{id}/storage/{node}/metadata/details/`): | Level | Fields returned on each metadata record (cumulative) | |-------|-----------------------------------------------------| | `terse` | `object_id`, `template_id`, `node_id` (narrowed to `{id, name, type}`), `metadata` (the full key/value payload) | | `standard` | terse + `instance_id`, `node_id` widened to `{id, name, type, parent, mimetype}`, `autoextractable` | | `full` | everything | The key/value 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. **Templates** (`GET /workspace/{id}/metadata/templates/list/`, `GET /workspace/{id}/metadata/templates/{tid}/details/`): | Level | Fields returned (cumulative) | |-------|------------------------------| | `terse` | `id`, `name`, `description`, `enabled`, `priority` | | `standard` | terse + `instanceId`, `orgId`, `locked`, `deleted`, `updated`, `created`, `fields`, `node_count`, `field_count`, `node_count_capped`, `plan_node_limit`, `is_truncated`, `plan_field_limit` | | `full` | everything | `deleted` is included in `standard` so trash/restore workflows can surface the soft-delete timestamp without promoting to `full`. **Saved views** (`GET /workspace/{id}/metadata/views/`): | Level | Fields returned (cumulative) | |-------|------------------------------| | `terse` | `name` | | `standard` | terse + `node_id`, `filters`, `order_by`, `order_desc`, `created`, `updated` | | `full` | everything | **Eligible nodes** (`GET /workspace/{id}/metadata/eligible/`): | Level | Fields returned (cumulative) | |-------|------------------------------| | `terse` | `node_id`, `name`, `mimetype` | | `standard` | terse + `size`, `summary_title`, `summary_short`, `updated`, `templates` | | `full` | everything | 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 a template ``` POST /current/workspace/{workspace_id}/metadata/templates/ ``` Create a new metadata template defining fields, types, and constraints. **Auth:** Bearer token required. Workspace member. Metadata billing feature required. **Parameters:** | Parameter | Type | Required | Description | |---|---|---|---| | `name` | string | Yes | Template name (1-100 characters) | | `description` | string | Yes | Template description (max 255 characters) | | `fields` | string (JSON) | Yes | JSON-encoded array of field definitions | There is **no** `category` parameter — the create endpoint accepts only `name`, `description`, and `fields`. **Field definition structure:** | Property | Type | Description | |---|---|---| | `name` | string | Field identifier (alphanumeric + underscore) | | `description` | string | Human-readable description | | `type` | string | `string`, `int`, `float`, `bool`, `json`, `url`, or `datetime` | | `min` | number | Minimum value/length constraint | | `max` | number | Maximum value/length constraint | | `default` | mixed | Default value | | `fixed_list` | array | Allowed values for dropdown-style fields | | `can_be_null` | boolean | Whether the field allows null values | **Request example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/metadata/templates/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'name=Invoice+Template' \ -d 'description=Metadata+schema+for+invoices' \ -d 'fields=[{"name":"invoice_number","description":"Invoice number","type":"string","min":1,"max":50,"can_be_null":false},{"name":"amount","description":"Total amount","type":"float","min":0,"can_be_null":false}]' ``` **Response (200 OK):** ```json { "result": true, "template": { "id": "mt_aBcDeFgHiJkLmN", "instanceId": "1234567890123456789", "orgId": "9876543210987654321", "name": "Invoice Template", "description": "Metadata schema for invoices", "locked": false, "priority": null, "enabled": true, "deleted": null, "updated": "2025-01-20 10:30:00 UTC", "created": "2025-01-20 10:30:00 UTC", "fields": [ { "name": "invoice_number", "description": "Invoice number", "type": "string", "min": 1, "max": 50, "fixed_list": [], "can_be_null": false } ] } } ``` Newly created templates are active immediately — `enabled` is `true` for every template in current responses (there is no per-workspace enable/disable toggle). --- ### Delete a template ``` DELETE /current/workspace/{workspace_id}/metadata/templates/{template_id}/ ``` Soft-delete a metadata template. The delete is idempotent: deleting a template that no longer exists returns success. A template owned by a different workspace or organization (including system templates) is not visible to this endpoint and returns 404. **Auth:** Bearer token required. Workspace member. Metadata billing feature required. **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1609 (Not Found)` | 404 | Template belongs to a different workspace or organization | | `1664 (Datastore Error)` | 500 | Deletion failed | --- ### List templates ``` GET /current/workspace/{workspace_id}/metadata/templates/list/ ``` **Auth:** Bearer token required. Workspace member. Metadata billing feature required. Optional path filter (append to URL): `all` (default, both system and custom), `custom` (non-system only), `system` (system only), `enabled`, or `disabled`. **Request example:** ```bash # List all templates curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/metadata/templates/list/" \ -H "Authorization: Bearer {jwt_token}" # List only enabled templates curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/metadata/templates/list/enabled/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "count": 2, "items": [ { "id": "mt_aBcDeFgHiJkLmN", "instanceId": "1234567890123456789", "orgId": "9876543210987654321", "name": "Contract Template", "description": "Standard contract metadata fields", "locked": true, "priority": 3, "enabled": true, "deleted": null, "updated": "2025-01-15 08:00:00 UTC", "created": "2025-01-01 00:00:00 UTC", "fields": [ { "name": "contract_type", "description": "Type of contract", "type": "string", "fixed_list": ["NDA", "MSA", "SOW"], "can_be_null": false } ] } ] } ``` --- ### Get template details ``` GET /current/workspace/{workspace_id}/metadata/templates/{template_id}/details/ ``` **Auth:** Bearer token required. Workspace member. Metadata billing feature required. Returns the full template with fields and workspace-level instance settings. --- ### Update template definition ``` POST /current/workspace/{workspace_id}/metadata/templates/{template_id}/update/ ``` Update a template's field definitions. All body fields are optional -- only provided fields are updated. **Auth:** Bearer token required. Workspace member. Metadata billing feature required. | Parameter | Type | Required | Description | |---|---|---|---| | `name` | string | No | Updated template name (1-100 characters) | | `description` | string | No | Updated description (1-255 characters) | | `fields` | string (JSON) | No | Updated JSON-encoded array of field definitions | There is **no** `category` parameter. Append `/create/` to the path to copy the template instead of updating in place. **Response:** Returns the updated template object. When the `fields` array changes in a way that invalidates stored values, the response additionally includes a `schema_update` object. Auto-triggered partial re-extraction runs **only** for: (a) new field names, and (b) type changes on existing names. Soft edits (description, min/max, nullable, fixed_list, regex) bump the schema hash but do not auto-trigger extraction — call the extract-all endpoint manually if you want those applied. ```json { "result": true, "template": { "...": "..." }, "schema_update": { "previous_hash": 1234567890, "current_hash": 2345678901, "extraction_auto_enqueued": true, "added_fields": ["currency"], "type_changed_fields": ["total_amount"] } } ``` The `schema_update` object is absent when only `name` or `description` change. --- ### Eligible nodes ``` GET /current/workspace/{workspace_id}/metadata/eligible/ ``` Paginated list of files and notes eligible for metadata extraction (nodes that have both an AI summary and a preview ready). Folders and links are excluded. **Auth:** Bearer token required. Workspace member. Metadata billing feature required. | Parameter | Type | Required | Description | |---|---|---|---| | `limit` | integer | No | Number of items to return (1-500, default: 100) | | `offset` | integer | No | Number of items to skip (default: 0) | **Response:** Standard paginated response with eligible nodes (files and notes). --- ### Add nodes to template ``` POST /current/workspace/{workspace_id}/metadata/templates/{template_id}/nodes/add/ ``` Manually add nodes to a template. Both files and notes can be added; folders and links are rejected. Creates the many-to-many mapping between the template and the specified nodes. **Auth:** Bearer token required. Workspace member. Metadata billing feature required. | Parameter | Type | Required | Description | |---|---|---|---| | `node_ids` | string (JSON) | Yes | JSON-encoded array of node IDs to add to the template | The number of files mapped to a single template is capped per plan tier. When the request would push the template above its cap, the endpoint returns `1605 (Invalid Input)` with a message describing the cap, the attempted count, and the remaining slots. Concurrent calls cannot collectively exceed the cap. --- ### Remove nodes from template ``` POST /current/workspace/{workspace_id}/metadata/templates/{template_id}/nodes/remove/ ``` Remove nodes (files or notes) from a template. Removes the many-to-many mapping between the template and the specified nodes. **Auth:** Bearer token required. Workspace member. Metadata billing feature required. | Parameter | Type | Required | Description | |---|---|---|---| | `node_ids` | string (JSON) | Yes | JSON-encoded array of node IDs to remove from the template | --- ### List nodes in template ``` GET /current/workspace/{workspace_id}/metadata/templates/{template_id}/nodes/ ``` List nodes (files and notes) currently mapped to a template. The visible window is clamped to the workspace plan's per-template cap. When the template is truncated under the cap, rows are ordered by mapping creation order (oldest first) so the visible set is stable across plan changes. The default ordering when no `sort_field` is supplied is subject to change in upcoming releases; callers that depend on a specific ordering should pass `sort_field` explicitly. **Auth:** Bearer token required. Workspace member. Metadata billing feature required. | Parameter | Type | Required | Description | |---|---|---|---| | `limit` | integer | No | Number of items to return (1-500, default: 100) | | `offset` | integer | No | Number of items to skip (default: 0) | | `sort_field` | string | No | Optional template field name to sort by | | `sort_dir` | string | No | `asc` or `desc` (only with `sort_field`) | **Response (additional fields):** | Field | Type | Description | |---|---|---| | `total_count` | integer | Visible row count under the per-plan cap | | `total_count_unfiltered` | integer | True row count in storage (ignores cap) | | `plan_node_limit` | integer | Per-template node cap for the workspace's plan; `-1` indicates unlimited / unresolved | | `is_truncated` | boolean | `true` when overflow rows exist beyond the cap | Pagination is clamped to the visible window: oversized `limit` values are silently shortened, and offsets past the visible window return an empty page. Existing pagination fields (`items`, `count`, `next_cursor`, `has_more`) are unchanged. --- ### Preview template match ``` POST /current/workspace/{workspace_id}/metadata/templates/preview-match/ ``` Synchronously preview which files would match a proposed metadata template before creating it. Accepts a template name and description, scans a sample of eligible files using AI classification, and returns the matched files with details. No template is created -- this is a read-only preview operation. **Auth:** Bearer token required. Workspace member. Metadata billing feature required. | Parameter | Type | Required | Description | |---|---|---|---| | `name` | string | Yes | Proposed template name (1-255 characters) | | `description` | string | Yes | Proposed template description (1-2000 characters) | **Response (200 OK):** ```json { "result": true, "matched_files": [ { "node_id": "aBcDeFgHiJkLmN", "name": "invoice_2025.pdf", "mimetype": "application/pdf", "summary_title": "Invoice from Acme Corp", "summary_short": "Invoice INV-2025-001 for $1,500.00" } ], "total_eligible": 45, "total_scanned": 45, "total_matched": 1, "plan_node_limit": 10, "would_truncate_at": 1 } ``` | Field | Type | Description | |---|---|---| | `matched_files` | array | Files that matched the proposed template | | `matched_files[].node_id` | string | File node opaque ID | | `matched_files[].name` | string | File name | | `matched_files[].mimetype` | string | MIME type | | `matched_files[].summary_title` | string | AI-generated summary title | | `matched_files[].summary_short` | string | AI-generated short summary | | `total_eligible` | integer | Size of the eligible-file sample considered by this preview. When the workspace has more eligible files than fit in the sample window, this value is the sample ceiling (plus one, to signal "more available") rather than the true workspace-wide count | | `total_scanned` | integer | Files actually scanned in this preview. May be smaller than `total_eligible` when the sample is bounded | | `total_matched` | integer | Files that matched | | `plan_node_limit` | integer | Per-template node cap for the workspace's plan; `-1` indicates unlimited / unresolved | | `would_truncate_at` | integer | Number of matches that would be persisted by a subsequent auto-match, given the current plan cap and the sampled matches. Equal to `min(total_matched, plan_node_limit)` when the cap applies, otherwise equal to `total_matched`. Because the preview only scans a sample, this is an estimate -- a subsequent auto-match that scans the full workspace may match a different count. Frontends can show "X of Y matched -- upgrade for more" when this value is less than `total_matched`. | Requires available AI credits. Only files with completed AI summaries and previews are eligible. --- ### Suggest template fields ``` POST /current/workspace/{workspace_id}/metadata/templates/suggest-fields/ ``` Synchronously suggest a set of custom metadata fields for a proposed template, using AI. Accepts a small sample of eligible file node IDs (typically drawn from a prior preview-match call), a template description, and optional user intent text. Returns 1-10 suggested field definitions shaped to be used directly as the `fields` parameter of the create-template endpoint. No template is created -- this is a read-only suggestion operation. Sits between preview-match and create-template in a template-creation flow. **Auth:** Bearer token required. Workspace member. Metadata billing feature required. Rate limited; concurrent calls per user+workspace return 409 Conflict. | Parameter | Type | Required | Description | |---|---|---|---| | `node_ids` | string (JSON) | Yes | JSON-encoded array of file node IDs to sample (1-25 items) | | `description` | string | Yes | Template description (1-2000 characters) | | `user_context` | string | No | Optional user intent string (max 64 characters, letters/numbers/spaces only) | **Response (200 OK):** ```json { "result": true, "suggested_fields": [ { "name": "Location", "type": "string", "description": "Geographic location where the content was created", "max": 384, "can_be_null": true, "example_value": "New York, NY" }, { "name": "Year", "type": "int", "description": "Year the document was created or published", "can_be_null": true, "example_value": 2024 }, { "name": "Priority", "type": "string", "description": "Priority level of the task", "max": 384, "can_be_null": false, "fixed_list": ["low", "medium", "high"], "example_value": "medium" } ], "file_sample_count": 25 } ``` | Field | Type | Description | |---|---|---| | `suggested_fields` | array | 1-10 suggested field definitions, directly compatible with create-template `fields` | | `suggested_fields[].name` | string | Field display name | | `suggested_fields[].type` | string | One of `string`, `int`, `float`, `bool`, `datetime`, `url` | | `suggested_fields[].description` | string | Human-readable field description | | `suggested_fields[].max` | number | Maximum length/value constraint (when applicable) | | `suggested_fields[].can_be_null` | bool | Whether the field may be null | | `suggested_fields[].fixed_list` | array | Allowed values for bounded categorical fields (status, priority, category). Present only when the field is enumerable. | | `suggested_fields[].example_value` | mixed | Display-only hint showing what the extraction AI would likely return for this field, grounded in one of the sampled files. Type matches the field's `type`: string for `string`/`url` (HTML-stripped, max 256 chars), integer for `int`, number for `float`, boolean for `bool`, ISO 8601 string for `datetime`. May be omitted when no clean example is available. | | `file_sample_count` | integer | Number of files actually used to generate suggestions | Requires available AI credits. The `json` field type is not returned by this endpoint. `example_value` is a preview hint only and is not part of the create-template field schema; clients should strip it before posting to create-template (passing it through is harmless as unknown keys are ignored). --- ### Auto-match files to template ``` POST /current/workspace/{workspace_id}/metadata/templates/{template_id}/auto-match/ ``` Enqueues an async job that uses AI to match eligible files in the workspace to the template based on file content and template field definitions. The endpoint returns immediately with a job descriptor; results are written to the template asynchronously and progress can be polled via the jobs status endpoint under the `template_match` key. **Auth:** Bearer token required. Workspace member. Metadata billing feature required. **Optional body parameters:** | Parameter | Type | Description | |---|---|---| | `batch_size` | integer | Optional batch size override; clamped server-side to the supported min/max range. Omit to use the default. | **Response (200 OK) -- new job enqueued:** ```json { "result": true, "job_id": "aj_aBcDeFgHiJkLmN", "status": "queued", "message": "Template matching job enqueued" } ``` **Response (200 OK) -- duplicate job already running:** ```json { "result": true, "status": "duplicate", "message": "A template matching job is already in progress for this template" } ``` | Field | Type | Description | |---|---|---| | `job_id` | string | Async job ID. Present when `status` is `queued`; absent when `status` is `duplicate`. Use the jobs status endpoint to poll progress under the `template_match` key. | | `status` | string | `queued` for a newly enqueued job, or `duplicate` when an in-flight job for the same template was detected and re-used. | | `message` | string | Human-readable status message. | The matched set is silently capped at the workspace's per-template node cap. Eligible files are truncated pre-batch (so no LLM credit is spent on candidates that cannot be persisted) and re-checked immediately before persistence to handle interleaved manual adds. When the template is already at its cap, the job completes successfully with no LLM usage. --- ### Batch extract metadata (template-level) ``` POST /current/workspace/{workspace_id}/metadata/templates/{template_id}/extract-all/ ``` Batch-extract metadata for all files mapped to a template. Async -- returns a `job_id` for tracking. **Auth:** Bearer token required. Workspace member. Metadata billing feature required. **Request body (optional):** | Field | Type | Description | |-------|------|-------------| | `fields` | array of strings | Partial extraction -- restrict the job to the named template fields. Each name must exist in the template schema. When omitted, all fields are extracted. | | `force` | boolean | When `true`, re-extract every mapped node even if it already has KV data for this template (used by the "re-extract" flow in the UI). Default `false` skips nodes whose values are already present. | **Response (200 OK):** ```json { "result": true, "job_id": "aj_aBcDeFgHiJkLmN", "template_id": "mt_oPqRsTuVwXyZ12" } ``` Maximum 1,000 files processed per job. --- ## Template Assignments (folder-level) Templates can be assigned to individual **folder** nodes (or workspace-level), independently of the per-file node map. These endpoints manage those folder assignments. ### Assign a template to a folder ``` POST /current/workspace/{workspace_id}/metadata/template/assign/ ``` Assign a metadata template to a folder node, or workspace-level when `node_id` is omitted. When a `node_id` is supplied, the node is added to the template's node map under the same per-plan node cap that `/nodes/add` enforces. **Auth:** Bearer token required. Workspace **admin** permission. Metadata billing feature required. | Parameter | Type | Required | Description | |---|---|---|---| | `template_id` | string | Yes | Opaque template id to assign. Must belong to this workspace. | | `node_id` | string | No | Folder node opaque id. Omit (or send empty) for a workspace-level assignment. | **Response (200 OK):** ```json { "result": true, "node_id": "aBcDeFgHiJkLmN", "template_id": "9876543210987654321" } ``` `node_id` echoes what you sent (`null` for a workspace-level assignment). If the node is already mapped to the template the call succeeds as a no-op. **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1605 (Invalid Input)` | 406 | Missing/invalid `template_id` or `node_id`, or adding the node would exceed the per-plan template node cap | | `1609 (Not Found)` | 404 | Template not found, or does not belong to this workspace | | `1658 (Not Acceptable)` | 406 | Another assign for this template is in progress — retry shortly | | `1664 (Datastore Error)` | 500 | Cap check or assignment failed | --- ### List template assignments ``` GET /current/workspace/{workspace_id}/metadata/template/assignments/ ``` List the workspace's (non-deleted) templates as assignment records. **Auth:** Bearer token required. Workspace member. Metadata billing feature required. **Response (200 OK):** ```json { "result": true, "count": 1, "items": [ { "template_id": "mt_aBcDeFgHiJkLmN", "name": "Invoice Template", "created": "2026-07-07 16:00:00 UTC", "updated": "2026-07-07 16:30:00 UTC" } ] } ``` | Field | Type | Description | |---|---|---| | `count` | integer | Number of assignment records returned | | `items[].template_id` | string | Opaque template id | | `items[].name` | string | Template name | | `items[].created` | string | Creation timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `items[].updated` | string | Last update timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1664 (Datastore Error)` | 500 | Failed to list template assignments | --- ### Resolve the effective template for a node ``` GET /current/workspace/{workspace_id}/metadata/template/resolve/{node_id}/ ``` Resolve the metadata template mapped to a storage node by walking the node map. Returns the first mapped template, or `null` when the node has no mapping. **Auth:** Bearer token required. Workspace member. Metadata billing feature required. `{node_id}` is a path segment (a storage node opaque id). **Response (200 OK):** ```json { "result": true, "template": { "id": "mt_aBcDeFgHiJkLmN", "name": "Invoice Template", "fields": [] } } ``` When the node has no mapped template the response is `{ "result": true, "template": null }`. The `template` object, when present, is the full template resource (see Template Resource Schema). --- ### Unassign a template from a folder ``` DELETE /current/workspace/{workspace_id}/metadata/template/unassign/ ``` Remove a template assignment from a folder node. Because this is a `DELETE`, pass the parameters as **query string** (a DELETE body is not read). **Auth:** Bearer token required. Workspace **admin** permission. Metadata billing feature required. | Parameter | Type | Required | Description | |---|---|---|---| | `node_id` | string | No | Folder node opaque id (query param). Omit for the workspace-level no-op. | | `template_id` | string | Conditional | Required when `node_id` is supplied. | **Response (200 OK):** ```json { "result": true } ``` **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1605 (Invalid Input)` | 406 | Invalid `node_id`, or missing/invalid `template_id` when a `node_id` is supplied | | `1664 (Datastore Error)` | 500 | Failed to remove the assignment | --- ## Node Metadata Metadata stored on individual files. Split into **template metadata** (conforming to mapped templates) and **custom metadata** (user-defined fields). --- ### Get file metadata ``` GET /current/workspace/{workspace_id}/storage/{node_id}/metadata/details/ ``` Returns all metadata for a file. Response contains `template_metadata` and `custom_metadata` separately. **Auth:** Bearer token required. Workspace member. Metadata billing feature required. **Request example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/aBcDeFgHiJkLmN/metadata/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "instance_id": "1234567890123456789", "object_id": "aBcDeFgHiJkLmN", "template_id": "mt_oPqRsTuVwXyZ12", "node_id": { "id": "aBcDeFgHiJkLmN", "name": "invoice_2025.pdf", "type": "file", "size": 245760 }, "metadata": [ { "key": "invoice_number", "description": "Invoice number", "type": "string", "value": "INV-2025-001", "is_auto": false, "updated": "2025-01-20 10:30:00 UTC" }, { "key": "amount", "description": "Invoice amount", "type": "float", "value": 1500.00, "is_auto": true, "updated": "2025-01-20 10:30:00 UTC" } ] } ``` | Field | Type | Description | |---|---|---| | `template_id` | string/null | Associated template ID, or null | | `node_id` | object | Storage node resource with file details | | `metadata[].key` | string | Metadata field key | | `metadata[].type` | string | Value type (`string`, `int`, `float`, `bool`, `json`, `url`, `datetime`) | | `metadata[].value` | mixed | The metadata value | | `metadata[].is_auto` | boolean | Whether the value was auto-generated by AI extraction | | `metadata[].updated` | string | Last update timestamp | #### 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 `400`. 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/abc123,def456,ghi789/metadata/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (HTTP 200):** ```json { "result": true, "format": "multi", "objects": [ { "instance_id": "1234567890123456789", "object_id": "abc123", "template_id": "mt_oPqRsTuVwXyZ12", "node_id": { "id": "abc123", "name": "invoice_2025.pdf", "type": "file" }, "template_metadata": [ { "key": "invoice_number", "description": "Invoice number", "type": "string", "value": "INV-2025-001", "is_auto": false, "updated": "2025-01-20 10:30:00 UTC" } ], "custom_metadata": [], "autoextractable": true }, { "instance_id": "1234567890123456789", "object_id": "def456", "template_id": "mt_oPqRsTuVwXyZ12", "node_id": { "id": "def456", "name": "invoice_2026.pdf", "type": "file" }, "template_metadata": [ { "key": "invoice_number", "description": "Invoice number", "type": "string", "value": "INV-2026-014", "is_auto": true, "updated": "2026-02-04 09:12:00 UTC" } ], "custom_metadata": [], "autoextractable": true } ], "templates": { "mt_oPqRsTuVwXyZ12": { "template_id": "mt_oPqRsTuVwXyZ12", "name": "Invoice", "fields": [ { "name": "invoice_number", "type": "string", "description": "Invoice number" } ] } }, "errors": [ { "node_id": "ghi789", "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 | --- ### Update file metadata ``` POST /current/workspace/{workspace_id}/storage/{node_id}/metadata/update/{template_id}/ ``` Set or update metadata key-value pairs on a file. **Auth:** Bearer token required. Workspace member. Metadata billing feature required. | Parameter | Type | Required | Description | |---|---|---|---| | `key_values` | string (JSON) | Yes | JSON-encoded object of key-value pairs. Keys must match template field names. | **Request example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/aBcDeFgHiJkLmN/metadata/update/mt_oPqRsTuVwXyZ12/" \ -H "Authorization: Bearer {jwt_token}" \ -d 'key_values={"invoice_number":"INV-2025-001","amount":1500.00}' ``` Existing metadata for the same keys is overwritten. Keys not included in the request are left unchanged. --- ### 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. If omitted, all keys may be removed. | 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 against a template. **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 are stored with `is_auto: true`. **Auth:** Bearer token required. Workspace member. Metadata billing feature required. | Parameter | Type | Required | Description | |---|---|---|---| | `template_id` | string | No | The template ID to extract against. Defaults to the first template mapped to the file. | | `fields` | string (JSON) | No | JSON-encoded array of field names to restrict extraction scope. Each name must exist on the template. Omit to extract the full schema. | **Response (HTTP 202 Accepted):** ```json { "result": true, "job_id": "{job_id}", "template_id": "{template_id}", "node_id": "{node_id}", "fields": ["amount", "due_date"], "status": "queued", "status_uri": "/workspace/{workspace_id}/jobs/status" } ``` `status` is `queued` for a fresh job, or `in_progress` when the request is collapsed into an already-running job (see Idempotent retries below). **Idempotent retries:** submitting the same `(node, template, fields)` combination while a job is already in flight returns the existing `job_id` instead of enqueueing a duplicate. Retries within a 30-minute window are safe. **Polling flow:** ``` POST /current/workspace/{id}/storage/{node}/metadata/extract/ template_id={template_id} -> HTTP 202 { "job_id": "{job_id}", "status": "queued", "status_uri": "/workspace/{id}/jobs/status" } GET /current/workspace/{id}/jobs/status/ -> jobs.metadata_extract[] includes { "kind": "single", "active": true, "node_id": "{node_id}", "template_id": "{template_id}", "job_id": "{job_id}", "status": "in_progress", "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 `template_metadata` ``` **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1605 (Invalid Input)` | 406 | Node is root, `fields` references an unknown field, or payload is malformed | | `1609 (Not Found)` | 404 | Template not found | | `1609 (Not Found)` | 404 | Node not found | | `1664 (Datastore Error)` | 500 | Failed to enqueue extraction job | | `1696 (Credits Exhausted)` | 402 | No AI credits remaining | --- ### 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** against the workspace's template. This is the folder-level counterpart to the single-file `/metadata/extract/` (one file) and the template-level `/metadata/templates/{template_id}/extract-all/` (all files mapped to one template). **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. **Response (200 OK):** ```json { "result": true, "job_id": "aj_aBcDeFgHiJkLmN", "template_id": "mt_oPqRsTuVwXyZ12" } ``` The job runs asynchronously against the workspace's active template — poll `GET /current/workspace/{workspace_id}/jobs/status/` (the `jobs.metadata_extract[]` array) for progress. **Error responses:** | Error Code | HTTP Status | Cause | |---|---|---| | `1605 (Invalid Input)` | 406 | Missing/invalid folder id, or the node is not a folder | | `1609 (Not Found)` | 404 | Folder not found, or no template is configured for the workspace | | `1664 (Datastore Error)` | 500 | Failed to list templates or enqueue the job | --- ### List metadata by template ``` GET /current/workspace/{workspace_id}/storage/{node_id}/metadata/list/{template_id}/ ``` List all files with metadata for a specific template. Supports filtering and sorting. **Auth:** Bearer token required. Workspace member. Metadata billing feature required. | Parameter | Type | Required | Description | |---|---|---|---| | `filters` | string (JSON) | No | JSON-encoded filter criteria | | `order_by` | string | No | Field key name to sort by | | `order_desc` | string | No | `"true"` or `"false"` for descending sort | --- ### List templates in use ``` GET /current/workspace/{workspace_id}/storage/{node_id}/metadata/templates/ ``` List templates that have metadata set on files, with object counts. **Auth:** Bearer token required. Workspace member. Metadata billing feature required. --- ### Metadata versions ``` GET /current/workspace/{workspace_id}/storage/{node_id}/metadata/versions/ ``` List metadata version snapshots for a file. Useful for tracking changes to extracted metadata over time. --- ### Metadata Saved Views Each user can persist per-template column order, column visibility, and sort preferences. Saved views are **private to the user** -- two users can independently customize the same template. Identity is the tuple `(workspace, user, template)`; there is at most one saved view per user per template per workspace. Full endpoint reference is in the Storage Operations docs. ``` GET /current/workspace/{workspace_id}/metadata/view/?template_id={template_id} POST /current/workspace/{workspace_id}/metadata/view/ DELETE /current/workspace/{workspace_id}/metadata/view/?template_id={template_id} GET /current/workspace/{workspace_id}/metadata/views/ POST /current/workspace/{workspace_id}/metadata/view/{template_id}/export/ ``` **Auth:** Bearer token required. Workspace member. Metadata billing feature required. `GET .../metadata/view/?template_id={template_id}` returns the caller's saved view for that template (`404` / `1609 (Not Found)` when none exists); `DELETE .../metadata/view/?template_id={template_id}` removes it (`1609 (Not Found)` when none exists); `GET .../metadata/views/` lists the caller's saved views in the workspace, newest-first. A saved view's `id` is server-owned and never client-supplied. POST body: `application/x-www-form-urlencoded` with `template_id` (string), `config` (JSON-encoded string containing the view config), and an optional `name` (string, max 30 chars — a human label for the view, preserved on upsert if omitted). Do NOT send as `application/json` — the endpoint only accepts form-encoded bodies and returns `406 (This field is missing)` for JSON bodies. The `config` schema (the JSON serialized into the form field) is `{ version: 1, columns: [{field, visible?, width?}], sort: {field, dir}, filters: [{field, operator, value_type, value}] }`; column array order IS the display order; `dir` is `asc` or `desc`; filters are AND-chained, max 5 per view, with `operator` in `= != < <= > >=` and `value_type` in `string | int | float | bool` (`json` rejected). Each saved-view filter entry uses `field` for its template-field identifier (matching `columns[].field` and `sort.field`); the nodes endpoint's stateless `filters` query parameter keeps its pre-existing `key` form, and the server handles the translation when auto-applying a saved view. Unknown keys are rejected with `1605 (Invalid Input)`. See the Storage Operations docs for the full filter contract. `GET /workspace/{workspace_id}/metadata/templates/{template_id}/nodes` auto-applies `config.sort` **and** `config.filters` from the caller's saved view when the corresponding `sort_field` / `filters` query parameters are not passed explicitly. An explicit query parameter (even empty) always wins; no server-side merge. **Export a saved view:** `POST .../metadata/view/{template_id}/export/` asynchronously writes the caller's saved view for that template to a TSV file in storage. `{template_id}` is a URI path segment (not a body or query field). A saved view must already exist for the template, otherwise `1609 (Not Found)`. Optional form body: `parent_node_id` (string, max 64 — destination folder; omit to write to the workspace root) and `filename` (string, max 200). On success the response is `{ job_id, status: "queued", filename, message }` and the export runs asynchronously — poll the destination folder for the file (row cap ~50,000). If an export for the same `(workspace, user, template, parent_node_id)` is already in flight, the response is `{ status: "duplicate", filename, message }` with no `job_id`. --- ## 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. ### 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"] } ] } } ``` **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 per-template batch job or a per-node single-file job. | | `jobs.metadata_extract[].kind` | string | `"batch"` for per-template batch extraction, `"single"` for per-node single-file extraction | | `jobs.metadata_extract[].active` | boolean | Whether extraction is currently running | | `jobs.metadata_extract[].template_id` | string | The template identifier | | `jobs.metadata_extract[].node_id` | string/null | Node identifier for `kind: "single"` entries; `null` for `kind: "batch"` | | `jobs.metadata_extract[].job_id` | string/null | Async job identifier (useful for correlating with the job descriptor returned by the single-file extract endpoint) | | `jobs.metadata_extract[].status` | string | Batch: `queued`, `starting`, `walking`, `extracting`, or completed states. Single: `queued`, `in_progress`, `completed`, or `errored`. | | `jobs.metadata_extract[].error_message` | string/null | Human-readable error message on terminal failure, or `null` | | `jobs.metadata_extract[].fields_scope` | array/null | Template field names being extracted, or `null` for full-schema extraction | Other fields in each `metadata_extract` entry match the intelligence 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. **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. --- ## Template Resource Schema | Field | Type | Description | |---|---|---| | `id` | string | Opaque template identifier | | `instanceId` | string | Workspace ID | | `orgId` | string/null | Organization ID | | `name` | string | Template name | | `description` | string | Template description | | `locked` | boolean | Whether the template is locked from editing | | `priority` | integer/null | Priority level 1-5, or null | | `enabled` | boolean | Whether enabled for the workspace | | `deleted` | string/null | Soft-delete timestamp, or null | | `updated` | string | Last updated timestamp | | `created` | string | Creation timestamp | | `fields` | array | Array of field definition objects | | `node_count` | integer | Number of files mapped to the template | | `field_count` | integer | Number of fields declared in the template schema | The template resource does **not** include a `category` field. ### Supported Field Types | Type | Description | |---|---| | `string` | Text values (max 10,000 characters) | | `int` | Integer numbers | | `float` | Decimal numbers | | `bool` | Boolean true/false | | `json` | Complex JSON structures | | `url` | Validated URLs | | `datetime` | Date and time values | --- ## 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: data, commentary, status, analysis_data, table_data, done ``` ### 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/ template_id={template_id} -> HTTP 202 { job_id, status: "queued" | "in_progress" } 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/ ``` ### Add nodes to a template (files and notes): ``` POST /current/workspace/{id}/metadata/templates/{template_id}/nodes/add/ node_ids=["nodeId1","nodeId2"] ``` ### AI auto-match files to a template: ``` POST /current/workspace/{id}/metadata/templates/{template_id}/auto-match/ ``` ### Batch extract metadata for all files in a template: ``` POST /current/workspace/{id}/metadata/templates/{template_id}/extract-all/ -> returns job_id (rate limited) ``` ### Check status of all async jobs (intelligence + metadata extraction): ``` GET /current/workspace/{id}/jobs/status/ GET /current/share/{id}/jobs/status/ -> returns jobs.intelligence and jobs.metadata_extract[] ``` ### Semantic search (use `/storage/search` instead — `/ai/search` is deprecated): ``` 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. ### 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: data, commentary, status, analysis_data, table_data, done ``` ### 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.postJson('/current//', { ...body... })`). Omit (or any other value) 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.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.postJson('/current//', { ...body... })`) rather than naming REST endpoints by URL. 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.** Where any Fastio API returns date/time values, they use the canonical format `'Y-m-d H:i:s UTC'` (e.g. `2026-06-16 14:30:00 UTC`). This endpoint's responses do not themselves contain timestamps. - **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`, `status`, and data fields). --- ## Events Search Search and filter the event log. Events capture every action in the system -- file operations, membership changes, comments, AI activity, billing, workflow, and more. --- ### `GET /current/events/search/` Search and filter events with comprehensive filtering options. Uses offset-based pagination. **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`, `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 be > `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 | | `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=storage&limit=50" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "events": [ { "event_id": "abc123xyz789def456ghi789jkl01", "created": "2025-01-20 10:30:45 UTC", "acknowledged": false, "event": "workspace_storage_file_added", "category": "workspace", "sub_category": "storage", "object_id": "def456ghi789jkl012mno345pqr67", "calling_user": "9876543210987654321", "calling_user_name": "Jane Smith", "org_id": "1111111111111111111", "workspace_id": "1234567890123456789", "filename": "quarterly_report.pdf", "file_size": 2485760 } ] } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `result` | boolean | `true` on success | | `events` | array | Array of event objects | | `events[].event_id` | string | Unique event identifier (alphanumeric OpaqueId) | | `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 triggering user | | `events[].calling_user_name` | string | Display name of the triggering user | | `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 | Target user ID for user-specific events (if applicable) | Additional event-specific fields (e.g., `filename`, `file_size`, `member_name`) vary by event type. **Error Responses:** | 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 | Invalid datetime format for `created-min` or `created-max` | | `1680 (Access Denied)` | 401 | Token scope does not include the requested profile, or the audit-log gate is not satisfied (see Notes) | | `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) | **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.** Requesting `visibility=external_audit_log` requires (1) an `org_id`, `workspace_id`, or `share_id` filter — a `user_id`-only audit-log query is rejected — and (2) admin permission on that profile. Failing either returns `1680 (Access Denied)`. --- ### `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 identical to `GET /current/events/search/`. **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": { "storage": 30, "members": 20 } } }, "events": [ ... ] } ``` **Response Fields (additional to events search):** | Field | Type | Description | |-------|------|-------------| | `summary` | object or null | AI-generated summary, or `null` if no events or generation failed | | `summary.text` | string | Natural language summary with `@[type:ID:name]` mention pills | | `summary.metrics.total_events` | integer | Total events summarized | | `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:abc123xyz789def456ghi789jkl01:report.pdf]` | | Folder | `@[folder:FOLDER_ID:foldername]` | `@[folder:def456ghi789jkl012mno345pqr67: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. **Notes:** - Requires a billable organization for AI token billing (resolved from profile context). - AI token consumption is billed to the associated organization. - The same audit-log gate as `/events/search/` applies: `visibility=external_audit_log` requires an `org_id`/`workspace_id`/`share_id` context filter AND admin permission on that profile, otherwise `1680 (Access Denied)`. --- ### `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/abc123xyz789def456ghi789jkl01/details/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "event": { "event_id": "abc123xyz789def456ghi789jkl01", "created": "2025-01-20 10:30:45 UTC", "acknowledged": false, "event": "workspace_storage_file_added", "category": "workspace", "sub_category": "storage", "object_id": "def456ghi789jkl012mno345pqr67", "calling_user": "9876543210987654321", "calling_user_name": "Jane Smith", "org_id": "1111111111111111111", "workspace_id": "1234567890123456789", "filename": "quarterly_report.pdf", "file_size": 2485760 } } ``` **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 | |-----------|--------| | User is the `calling_user` | Granted | | User is the `event_user` (target) | Granted | | Event has `targeted` permission and user is neither target nor caller | Denied | | Event is `internal` visibility | Always denied | | 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 (targeted event) | | `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/abc123xyz789def456ghi789jkl01/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 (targeted event) | | `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. --- ## 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`, `org_id`, `workspace_id`, `user_id`, `share_id`, `acknowledged`, `template` (containing `description` + `params`), `severity`, `visibility`, `notification` | | `full` | standard + `required_params`, `requires_parent_node`, `permission` | 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 | | Share | `share` | Share operations | | AI | `ai` | AI/ML operations | | Invitation | `invitation` | Invitation events | | Email | `email` | Email-related events | | Billing | `billing` | Billing and subscription events | | Metadata | `metadata` | Metadata operations | | Apps | `apps` | Application/integration events | | Node | `node` | Node-level events (individual file/folder objects) | | Server | `server` | Server/system-level events | | Import | `import` | Import operations | --- ## Event Subcategories | Subcategory | API Value | Description | |-------------|-----------|-------------| | Storage | `storage` | File and folder operations | | Comments | `comments` | Comment activity | | Members | `members` | Membership changes | | Lifecycle | `lifecycle` | Create, update, delete, archive events | | Settings | `settings` | Configuration changes | | Security | `security` | Security-related events | | Authentication | `authentication` | Login and auth events | | 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` | Ownership transfer events | | 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 | | 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 | | `targeted` | Only the target user or the calling user can view | 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 (see the Audit-log note under `/events/search/`). --- ## Event Names Reference ### Workspace Storage - `workspace_storage_file_added` -- File uploaded - `workspace_storage_file_deleted` -- File trashed - `workspace_storage_file_moved` -- File moved - `workspace_storage_file_copied` -- File copied - `workspace_storage_file_updated` -- File metadata updated - `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_moved` -- Folder moved - `workspace_storage_download_token_created` -- Download token issued - `workspace_storage_zip_downloaded` -- ZIP download completed - `workspace_storage_link_added` -- Link added ### Share Storage - `share_storage_file_added` -- File uploaded - `share_storage_file_deleted` -- File trashed - `share_storage_file_moved` -- File moved - `share_storage_file_copied` -- File copied - `share_storage_file_updated` -- File metadata updated - `share_storage_file_restored` -- File restored - `share_storage_folder_created` -- Folder created - `share_storage_folder_deleted` -- Folder trashed - `share_storage_folder_moved` -- Folder moved - `share_storage_download_token_created` -- Download token issued - `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 ### 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 ### 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 created in workspace ### Metadata - `metadata_kv_update` / `metadata_kv_delete` / `metadata_kv_extract` - `metadata_template_update` / `metadata_template_delete` / `metadata_template_select` - `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` ### Organization - `org_created` / `org_updated` / `org_closed` ### 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=abc123xyz789def456ghi789jkl01&limit=100 ``` --- ## 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 value = enabled | If present, 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 | Map of activity field names to micro-precision UTC timestamps | | `lastactivity` | string | Most recent timestamp; pass as `lastactivity` in next poll | **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 | `PERM_VIEW` on the org | | Workspace | `PERM_VIEW` on the workspace | | Share | Share view permissions (`canViewShareDetails`) | | 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/`. --- ### 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 | | `member:{userId}` | Membership 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 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": 86400, "auth_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `result` | boolean | `true` on success | | `expires_in` | integer | Effective token lifetime in seconds (86400 = 24 hours for user/org/workspace; 1800 = 30 minutes for share and file-share) | | `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 | `PERM_VIEW` on the org | | Workspace | `PERM_VIEW` on the workspace | | Share | `canViewShareDetails` | | 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 86400 (24 hours). **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. **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 | --- ### 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. #### `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.123456 UTC", "timestamp": "1711123456.1234", "event": "workspace_storage_file_added", "category": "workspace", "sub_category": "storage", "object_id": "abc123def456ghi789jkl012mno34", "calling_user": "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.ffffff UTC`, e.g. `"2026-03-22 14:30:45.123456 UTC"`), `timestamp` (event trigger time as microtime float string, e.g. `"1711123456.1234"`), `event` (event name), `category`, `sub_category`, `object_id` (affected object OpaqueId), `calling_user` (actor's 19-digit ID), `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 `PERM_VIEW` | | Share | `canViewShareDetails` + 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": "n5mno-pqrst-uvwxy-z1234-abcde-fghi", "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 with `result`, `status`, and data fields). 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 | > **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 | Workspace, share, or File Share profile 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\|null | 19-digit numeric ID of the comment author. `null` for externally-authored comments (e.g. a File Share recipient) — in that case the `external_author_*` fields below identify the author | | `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 | | `external_author_email` | string\|null | Email of an external author (File Share recipient), when the comment was not authored by a member; 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}`) | | `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. Only included on the get-details endpoint or when explicitly requesting deleted comments | 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", "parent_id": null, "body": "This looks great!", "profile_type": "workspace", "profile_id": "1234567890123456789", "reference": null, "mentions": [], "reactions": {"👍": 2, "❤️": 1}, "user_reaction": "👍", "version": 1, "can_edit": true, "can_delete": true, "can_reply": true, "created": "2025-01-15 10:30:00 UTC", "updated": "2025-01-15 10:30:00 UTC" } ], "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:** | 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", "parent_id": null, "body": "Great work on this document!", "profile_type": "workspace", "profile_id": "1234567890123456789", "reference": null, "reactions": {}, "user_reaction": null, "version": 1, "can_edit": true, "can_delete": true, "can_reply": true, "created": "2025-01-15 10:30:00 UTC", "updated": "2025-01-15 10:30:00 UTC" } } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `comment` | object | The created or updated comment object (full schema above) | **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 | | `1605 (Invalid Input)` | 406 | "Parent comment belongs to different entity" | Parent is on a different entity | | `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 | 19-digit numeric | File Share 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/1234567890123456789/n1o2p3q4r5s6t7u8v9w0x1/" \ -H "Authorization: Bearer {jwt_token}" ``` **Request Example -- create:** ```bash curl -X POST "https://api.fast.io/current/comments/fileshare/1234567890123456789/n1o2p3q4r5s6t7u8v9w0x1/" \ -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`, and — for an externally-authored comment — the `external_author_*` fields identify the author while `user_id` is null. **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`) supplied in the request are ignored — they are written only by the server. - For a **task comment**, access additionally requires task access (Workflow feature enabled, not a share guest, task not trashed) — the same gating as the task's comment thread. --- ## 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", "parent_id": null, "body": "This looks great!", "profile_type": "workspace", "profile_id": "1234567890123456789", "reference": null, "reactions": {}, "user_reaction": null, "version": 1, "can_edit": true, "can_delete": true, "can_reply": true, "created": "2025-01-15 10:30:00 UTC", "updated": "2025-01-15 10:30:00 UTC", "deleted": null } } ``` **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", "parent_id": null, "body": "This looks great!", "profile_type": "workspace", "profile_id": "1234567890123456789", "reference": null, "reactions": {}, "user_reaction": null, "version": 1, "can_edit": false, "can_delete": false, "can_reply": false, "created": "2025-01-15 10:30:00 UTC", "updated": "2025-01-16 08:00:00 UTC", "deleted": "2025-01-16 08:00:00 UTC" } } ``` **Access Levels:** | Role | Access | |------|--------| | Comment Author | Can delete own comments | | Entity Owner | Can delete any comment on their entity | | 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 or entity owner | | `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. **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 or entity owner | | `"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 | | `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 | | `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. --- ## 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": "n1o2p3q4r5s6t7u8v9w0x1y2z3a4b5", "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": "n1o2p3q4r5s6t7u8v9w0x1y2z3a4b5"}' ``` **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": ["n1o2p3q4r5s6t7u8v9w0x1y2z3a4b5", "9876543210987654321"]}' ``` **Response:** ```json { "result": true, "comment_id": "abc123opaqueid", "attached": 1, "attachments": [ { "target_id": "n1o2p3q4r5s6t7u8v9w0x1y2z3a4b5", "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 | | `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": "n1o2p3q4r5s6t7u8v9w0x1y2z3a4b5"}' ``` **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. The same rule governs Coordination Room message bodies, which share this mention parsing, and it applies to the `@[file:…]` mention form used there as well as to `@[user:…]`. **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` | | `"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) | | `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}"`. **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 gated by a default-off feature flag and is currently dormant; when enabled, the org resource exposes `capabilities.signing` (boolean) to indicate 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. - **Provider webhook surface (public)** — provider-issued HMAC signature header. No Fastio session; the verifier is per-provider. Response format: JSON (standard envelope with `result` and data fields). Errors use the standard envelope with `error.code`, `error.text`, and (on 400) `error.params[]`. Permission model: Workspace view for read endpoints; workspace admin for mutating endpoints (`/send`, `/void`, `/retry`, document downloads on a signed envelope, audit download). 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 receives a notification with a signing link of the form `/sign_envelopes/signer/{token}/view`. 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 | ### Provider Webhook Surface (Public — HMAC Auth) Inbound webhook receiver for provider events. The connection is identified by an opaque short id in the URL and is authenticated via the provider's own HMAC signature header (verified per-provider, never via a Fastio session token). | Method | Path | Description | |--------|------|-------------| | POST | `/current/webhooks/sign_envelopes/{provider}/{connection_short_id}/` | Receive a provider webhook event (signature-verified, replay-deduped, enqueued for the async normalizer) | `{provider}` is the slug of the connected provider implementation; the platform ships a Documenso connector for inbound provider integration. ### 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 gated by a default-off feature flag and is currently dormant; these endpoints are available only when the flag is enabled. 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 (PERM_MEMBER) | | GET | `/current/workspace/{workspace_id}/sign_templates/list/` | List non-deleted templates in the workspace (PERM_VIEW; offset-paginated) | | GET | `/current/workspace/{workspace_id}/sign_templates/{template_id}/details/` | Fetch a template with full snapshot (PERM_VIEW) | | POST | `/current/workspace/{workspace_id}/sign_templates/{template_id}/update/` | In-place update under optimistic CAS — `expected_version` required (PERM_MEMBER) | | POST | `/current/workspace/{workspace_id}/sign_templates/{template_id}/delete/` | Soft-delete a template — tombstoned, not purged; advisory referrer scan returned (PERM_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 (PERM_MEMBER) | ### Availability Signing is gated by a default-off feature flag and is currently dormant. When the flag is disabled, the signing endpoints are not available; enabling it is required before envelopes can be created or sent. --- ## 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 its upstream signing connector (internal PAdES engine or an external provider). - 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 detail / create response when present. ### Lifecycle ``` draft -> sent 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` (1-based for sequential routing; 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 are `signature`, `initial`, `date`, `text`, and `checkbox`. 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 `validation` object on a `signature` / `initial` / `checkbox` 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 the envelope's `event_seq`, an `event_type` (see Activity Events below), the actor's identifier, and a `content_hash` that links to the immediately preceding row's hash. The chain is signed by a per-workspace HMAC audit key. 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 `