# 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.2 > Last updated: 2026-07-07 > 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 | | **Workflow** | https://api.fast.io/current/llms/workflow/ | Task lists & tasks; workflow feature toggle | | **Workflow Orchestration** | https://api.fast.io/current/llms/workflows/ | Durable workflow runtime, templates, triggers, obligations, signed audit export, dual-control redaction, inbound/outbound webhooks, pools, realtime channel (WebSocket invalidation nudges), workflow-native content review (approval surface), human-gate `task` / `todo` steps (blocking/non-blocking, with task reassignment), mid-run modifications (propose/apply changes to a running workflow), agent step commentary trace, agent step activity feed, natural-language edge gates (fail-closed) & structured agent verdicts (`verdict_schema` branching), storage side-effect steps (`share.create` / `share.delete` / `file.copy` / `file.delete`, run-provenance-guarded so a delete only ever touches what the run created) | | **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 — and `signing.envelope` step `sign_template_id` integration) | | **Dashboard** | https://api.fast.io/current/llms/dashboard/ | Per-workspace actionable card feed (obligations, mentions, file activity, signatures) with optional AI overlay (urgency, summaries, synthesis), dismiss/snooze, and Ripley Agent seed handoff | | **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** | Describe a workflow in plain English and an agent (Ripley or your own, over MCP) runs it on your files — event/schedule triggers, approvals, tasks, and native e-signature, charged a flat credit per completed step | | **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), workflow steps (5/completed step). **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 + Workflow Run List) Two list endpoints support cursor-based (keyset) pagination. Storage listing always uses the cursor path. The workflow run list uses it **opt-in** — activate by passing any of `?page_size=`, `?cursor=`, or `?bucket=`; a bare request or `?limit=`/`?offset=` returns the legacy offset shape `{ total, limit, offset, has_more }` instead. 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` **Workflow run list** (`GET /current/workspace/{id}/workflows/`): - `page_size`: 1–100 (default: 50) - `cursor`: opaque string from previous response; discard and restart whenever any filter changes - `bucket`: `in_flight` | `completed` | `paused` | `failed` — filter by execution status - Response (cursor path): `pagination.has_more`, `pagination.next_cursor`, `pagination.page_size`; first page also returns best-effort `counts: { in_flight, completed, paused, failed }` (omitted on cursor pages) ## 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 (tasks, imports) additionally support an endpoint-specific `?format=md` parameter that renders domain-shaped markdown (checkbox lists, 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. → 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 - `GET /current/workspace/{id}/metadata/search/` — Search nodes by metadata field values (workspace-only) - `GET /current/workspace/{id}/search/` — Unified search: files, metadata, comments & workflow items in one call, grouped into per-type buckets with independent pagination - `GET /current/share/{id}/search/` — Unified search across a share (files, comments & workflow 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. Tasks have a dedicated private comment thread (see Workflow / Task Management). - `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 ### Workflow (Task Management) → [Workflow reference](https://api.fast.io/current/llms/workflow/) Workflow features enable structured task management within workspaces and shares. All workflow endpoints require the `workflow` feature to be enabled on the target workspace or share. On shares, task access requires Member-level access or higher — guests and public guests are denied. Toggle endpoints (enable/disable) remain Admin-only. Tasks can carry **attachments** (references to files, notes, workflows, sign-envelopes, shares, or other tasks; up to 100 per task) and a private **comment thread** (see the Workflow reference for full shapes). **Task Lists & Tasks:** - `GET /current/workspace/{workspace_id}/tasks/` - List task lists - `GET /current/share/{share_id}/tasks/` - List task lists - `POST /current/workspace/{workspace_id}/tasks/create/` - Create task list - `POST /current/share/{share_id}/tasks/create/` - Create task list - `GET /current/tasks/{list_id}/details/` - Task list details - `POST /current/tasks/{list_id}/update/` - Update task list - `POST /current/tasks/{list_id}/delete/` - Delete task list - `POST /current/tasks/{list_id}/restore/` - Restore a trashed task list - `GET /current/tasks/{list_id}/items/` - List tasks - `POST /current/tasks/{list_id}/items/create/` - Create task - `GET /current/tasks/{list_id}/items/{task_id}/` - Task details - `POST /current/tasks/{list_id}/items/{task_id}/update/` - Update task - `POST /current/tasks/{list_id}/items/{task_id}/delete/` - Delete task - `POST /current/tasks/{list_id}/items/{task_id}/restore/` - Restore a trashed task (restore its list first if the list is trashed) - `POST /current/tasks/{list_id}/items/{task_id}/status/` - Change status - `POST /current/tasks/{list_id}/items/{task_id}/assign/` - Assign task - `POST /current/tasks/{list_id}/items/{task_id}/move/` - Move task to another list - `GET /current/tasks/{list_id}/items/{task_id}/attachments/` - List task attachments - `POST /current/tasks/{list_id}/items/{task_id}/attachments/` - Attach objects to a task (single or bulk; cap 100) - `POST /current/tasks/{list_id}/items/{task_id}/attachments/detach/` - Detach an object from a task - `GET /current/tasks/{list_id}/items/{task_id}/comments/` - List a task's comments - `POST /current/tasks/{list_id}/items/{task_id}/comments/` - Post a comment on a task - `POST /current/tasks/{list_id}/items/bulk-status/` - Bulk status change - `POST /current/tasks/{list_id}/items/reorder/` - Bulk reorder tasks - `POST /current/workspace/{workspace_id}/tasks/reorder/` - Bulk reorder task lists - `POST /current/share/{share_id}/tasks/reorder/` - Bulk reorder task lists **Workflow Toggle:** - `POST /current/workspace/{workspace_id}/workflow/enable/` - Enable workflow - `POST /current/workspace/{workspace_id}/workflow/disable/` - Disable workflow - `POST /current/share/{share_id}/workflow/enable/` - Enable workflow - `POST /current/share/{share_id}/workflow/disable/` - Disable workflow **Workflow Content Review (Orchestration):** - `POST /current/workflow-review/send-for-review/` - Send a workflow asset out for review (opens a review surface) - `POST /current/workflow-review/surface/{surface_id}/reviewer/add-member/` - Add a workspace member as a reviewer; `POST /current/workflow-review/surface/{surface_id}/reviewer/add-external/` - Add an external (email) reviewer; `POST /current/workflow-review/surface/{surface_id}/reviewer/{reviewer_id}/remove/` - Remove a reviewer - `POST /current/workflow-review/surface/{surface_id}/asset/{asset_id}/bind-node/` - Bind a storage node to a review asset - `GET /current/workflow-review/preview/read/` - Read a review asset preview Workflow and import GET endpoints support `format=md` for Markdown output. Most list endpoints support `limit` and `offset` pagination; the workflow run list (`GET /current/workspace/{id}/workflows/`) supports opt-in cursor pagination (see *Keyset Pagination* above; bare and `?limit=`/`?offset=` requests return the legacy offset shape). All workflow endpoints (except toggle) require the `workflow` feature enabled on the target profile. Share workflow access requires Member-level or higher. ### 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 is a universal feature — available to every organization on every plan, with no plan gating or feature flag. 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`. A workflow step of type `wait_for_signing` parks on the envelope and resumes when it reaches a terminal state. ### Dashboard → [Dashboard reference](https://api.fast.io/current/llms/dashboard/) Per-workspace, per-member feed of ranked, paginated actionable cards. Cards are drawn from workflow obligations (approvals, tasks, reviews, confirmations), @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) ### 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. Requires `cloud_import` feature enabled on the workspace. **Feature Toggle:** - `POST /current/workspace/{workspace_id}/cloud-import/enable/` - Enable cloud import (admin) - `POST /current/workspace/{workspace_id}/cloud-import/disable/` - Disable cloud import (admin) **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 identity (admin) - `GET /current/imports/workspace/{workspace_id}/identities/{identity_id}/` - Identity details - `POST /current/imports/workspace/{workspace_id}/identities/{identity_id}/revoke/` - Revoke identity (admin) **Import Sources:** - `GET /current/imports/workspace/{workspace_id}/sources/` - List import sources - `POST /current/imports/workspace/{workspace_id}/sources/discover/` - Discover remote folders (admin) - `POST /current/imports/workspace/{workspace_id}/sources/create/` - Create import source (admin) **Source Details:** - `GET /current/imports/details/{source_id}/` - Source details - `POST /current/imports/details/{source_id}/update/` - Update settings (admin) - `POST /current/imports/details/{source_id}/delete/` - Soft-delete source (admin) - `POST /current/imports/details/{source_id}/disconnect/` - Disconnect (keep/delete files, admin) - `POST /current/imports/details/{source_id}/refresh/` - Trigger immediate sync **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 (admin) **Write-back (Read-Write Sources):** - `GET /current/imports/details/{source_id}/writebacks/` - List write-back jobs - `POST /current/imports/details/{source_id}/writebacks/push/{node_id}/` - Force push file (admin) - `GET /current/imports/details/{source_id}/writebacks/{writeback_id}/` - Write-back details - `POST /current/imports/details/{source_id}/writebacks/{writeback_id}/retry/` - Retry failed (admin) - `POST /current/imports/details/{source_id}/writebacks/{writeback_id}/resolve/` - Resolve conflict (admin) - `POST /current/imports/details/{source_id}/writebacks/{writeback_id}/cancel/` - Cancel write-back (admin) Providers: `google_drive`, `dropbox`, `box`, `onedrive_business`. Source statuses: pending, discovering, syncing, synced, error, paused, disconnected, disconnecting. Sources with >1000 files use async disconnect. Import GET endpoints support `format=md`. ## 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, workflow-agent). 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-80 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 and workflow-agent 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, Ripley AI-chat tokens, and workflow-agent 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 and workflow-agent 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 (used by custom domains). 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)` | 400 | "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)` | 400 | "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. An `OPTIONS` preflight request (issued by browsers ahead of cross-origin calls) emits the platform's standard envelope, not the RFC-compliant shape — the preflight body comes from the same router branch every other endpoint uses. This is a low-impact, preflight-only difference: browsers do not read OPTIONS response bodies, and the headers (CORS allow-origin / methods / headers) are correct in both cases. Non-browser clients should not issue `OPTIONS` against these endpoints; they have no introspection schema to return. --- ## 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_workflows", "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 | 400 | `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_workflows", "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 | 400 | `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_workflows` | Wildcard access to all workflows the user can reach | | `all_sign_envelopes` | Wildcard access to all sign envelopes the user can reach | **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" } ``` **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`, `share`, or `workflow` | | `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, Workflow | Parent organization ID | | `org_name` | string | Workspace, Share, Workflow | Parent organization name | | `org_domain` | string | Workspace, Share, Workflow | Parent organization subdomain | | `title` | string | Share | Share display title | | `share_type` | string | Share | `send`, `receive`, or `exchange` | | `workspace_id` | integer | Share, Workflow | Parent workspace ID | | `workspace_name` | string | Share, Workflow | 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)` | 400 | --- ## 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 | 80 | `^[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 billing plan grants the e-signature surface (all paid plans grant it). 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-80 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-80 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 | ### Workflow | Method | Path | Description | |--------|------|-------------| | POST | `/current/workspace/{workspace_id}/workflow/enable/` | Enable workflow features | | POST | `/current/workspace/{workspace_id}/workflow/disable/` | Disable workflow features | | GET | `/current/workspace/{workspace_id}/workflow_inbound_key/` | Read the workspace inbound trigger key state (`{enabled, key_version}`; key bytes never readable). Workspace admin | | POST | `/current/workspace/{workspace_id}/workflow_inbound_key/` | Enable / disable the workspace inbound trigger key (`enabled=true\|false`) — the prerequisite for inbound-webhook triggers and `rotate_inbound_key` (see the [Workflow reference](https://api.fast.io/current/llms/workflows/)). Workspace admin | ### Task Lists (Workflow) | Method | Path | Description | |--------|------|-------------| | GET | `/current/workspace/{workspace_id}/tasks/` | List task lists | | POST | `/current/workspace/{workspace_id}/tasks/create/` | Create a task list | | POST | `/current/workspace/{workspace_id}/tasks/reorder/` | Reorder task lists | ### 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`, `workflow`, `capabilities` | | `full` | standard + `cloud_import`, `comments`, `chat`, `search`, `assets`, `workflow_permissions`, 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`/`workflow` feature toggles and the plan-gate `capabilities` bundle so list rows can differentiate "enabled but plan-locked" from "usable." Use `full` (or omit the parameter) for the workspace settings screen, branding editors, and any workflow that reads permission matrices or remaining feature blocks (`comments`, `chat`, `search`, `assets`, `workflow_permissions`). 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) | 10-100 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. --- ## Summary Enrichment Setting Workflow obligations carry a deterministic, no-AI `display_summary`. A workspace can additionally opt in to **AI summary enrichment**, which augments an obligation's display with an LLM-derived structured-extraction object (`nl_enrichment`) when an extraction schema is configured on the workflow. The enrichment is **off by default** and is governed by two workspace settings: - **`nl_summaries_enabled`** (boolean, default `true`) — the master switch. When `false`, no AI enrichment runs and obligations expose only the deterministic `display_summary`. - **`nl_summaries_daily_cap`** (integer, default `100`, range `0`–`100000`) — a per-workspace ceiling on the number of AI enrichment operations per day. `0` disables enrichment without flipping the master switch. Both settings are **Admin-or-Owner only**. They are returned on `GET /current/workspace/{id}/details/` and set on `POST /current/workspace/{id}/update/`. The enrichment itself is surfaced on the obligation read surface as a separate top-level `nl_enrichment` object — see the [Workflow reference](https://api.fast.io/current/llms/workflows/) for its shape and trust class. --- ## 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`. | | `workflow` | string | No (auto-defaults on entitled plans) | `"true"` or `"false"` | Enable the workspace workflow feature gate (Tasks + workflow orchestration) at create time. Requires a plan carrying the workflow feature. **When omitted, defaults to `"true"` on plans that include the workflow feature** (and `"false"` otherwise) — new workspaces on entitled plans have workflow enabled out of the box. Can also be toggled later via the `/workflow/enable` and `/workflow/disable` endpoints. | | `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 | **Implicit defaults on create:** A new workspace comes with `workflow_approval_native_enabled` = `"extended"` by default (in-place approval + external reviewers available out of the box). This is **not** a create parameter — you cannot set it on this endpoint. To change the tier after creation, use the update endpoint (`POST /current/workspace/{workspace_id}/update/`); see the Update Workspace section. **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", "nl_summaries_enabled": true, "nl_summaries_daily_cap": 100 } } ``` **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 | | `workspace.nl_summaries_enabled` | boolean | Whether AI summary enrichment of workflow obligations is enabled (default `true`). See *Summary Enrichment Setting* | | `workspace.nl_summaries_daily_cap` | integer | Per-day ceiling on AI enrichment operations (default `100`) | **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 | | `workflow` | string | `"true"` or `"false"`. Requires a plan carrying the workflow feature. | Toggle the workspace workflow feature gate (Tasks + workflow orchestration). Equivalent to the `/workflow/enable` and `/workflow/disable` endpoints. | | `nl_summaries_enabled` | string | `"true"` or `"false"`. Enables AI summary enrichment of workflow obligations (default `true`). See *Summary Enrichment Setting*. | AI obligation-summary enrichment toggle | | `nl_summaries_daily_cap` | integer | `0`–`100000` (default `100`). Per-day ceiling on AI enrichment operations; `0` disables enrichment without flipping the master switch. | AI enrichment daily cap | | `workflow_approval_native_enabled` | string | `"disabled"`, `"mvs"`, or `"extended"`. **Newly created workspaces default to `"extended"`** (in-place approval + external reviewers available out of the box). Send `"null"` or `""` to clear (unset resolves to `disabled`). | Native workflow-review rollout tier — gates the review-surface endpoints (see the [Workflow reference](https://api.fast.io/current/llms/workflows/)) | | `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 allow teams to pre-assign workflow items before the invitee signs 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 } ``` **Workflow assignments:** Pending members can be assigned to tasks before they create an account. Their user ID is valid and works with all assignment endpoints. **Account claim:** When the invited user signs up or accepts the invitation with an existing account, their status transitions from `"pending"` to `"active"`. All existing workflow assignments (tasks) are preserved and transfer 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 and unassigns any workflow items associated with them. **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 | | 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 | 10-100 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 | --- ## Workflow ### Enable Workflow ``` POST /current/workspace/{workspace_id}/workflow/enable/ ``` Enables the workflow feature gate for a workspace — required for the Tasks feature (task lists and tasks) and for workflow orchestration. If already enabled, returns success with a message indicating the current state. **Auth:** JWT required. Admin or Owner required. Requires the workflow 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/workflow/enable/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "message": "Workflow features enabled", "workflow": 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 workflow requires an upgraded plan." | Workflow not included in billing plan | | `1610 (Internal Error)` | 500 | "Failed to enable workflow features" | Internal failure | --- ### Disable Workflow ``` POST /current/workspace/{workspace_id}/workflow/disable/ ``` Disables workflow 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 workflow 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/workflow/disable/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "message": "Workflow features disabled", "workflow": 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 workflow requires an upgraded plan." | Workflow not included in billing plan | | `1610 (Internal Error)` | 500 | "Failed to disable workflow features" | Internal failure | --- ## Task Lists Task lists are a workflow feature. Workflow must be enabled on the workspace before using these endpoints. All task list endpoints support `Accept: text/markdown` for markdown-formatted output. ### List Task Lists ``` GET /current/workspace/{workspace_id}/tasks/ ``` Returns all task lists for the workspace. Supports pagination and markdown output. **Auth:** JWT required. View or above. Requires the workflow billing feature. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` | string | Yes | 19-digit numeric workspace ID or `folder_name` | **Query Parameters:** | Name | Type | Default | Description | |------|------|---------|-------------| | `limit` | integer | 100 | Items per page | | `offset` | integer | 0 | Items to skip | **Headers:** | Header | Value | Description | |--------|-------|-------------| | `Accept` | `text/markdown` | (Optional) Return markdown-formatted output instead of JSON | **curl Example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/tasks/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response (200 OK):** ```json { "result": true, "task_lists": [ { "id": "abc123opaque", "profile_id": "1234567890123456789", "name": "Sprint Backlog", "description": "Current sprint items", "created_by": "9876543210987654321", "properties": {}, "sort_order": 0, "created": "2026-01-15 10:30:00 UTC", "updated": "2026-01-20 14:45:00 UTC", "deleted": null } ], "pagination": { "limit": 100, "offset": 0, "total": 1 } } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `task_lists` | array | Array of task list objects | | `task_lists[].id` | string | Opaque task list ID | | `task_lists[].profile_id` | string | 19-digit workspace profile ID | | `task_lists[].name` | string | Task list name | | `task_lists[].description` | string or null | Task list description | | `task_lists[].created_by` | string | 19-digit user ID of the creator | | `task_lists[].properties` | object | Custom properties | | `task_lists[].sort_order` | integer | Sort order position | | `task_lists[].created` | string | Creation timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `task_lists[].updated` | string | Last update timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `task_lists[].deleted` | string or null | Deletion timestamp (`YYYY-MM-DD HH:MM:SS UTC`), or null | | `pagination` | object | Pagination metadata | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1695 (Upgrade Required)` | 402 | "Access to workflow requires an upgraded plan." | Workflow not included in billing plan | | `1680 (Access Denied)` | 401 | "Workflow is not enabled" | Workflow not enabled on workspace | | `1610 (Internal Error)` | 500 | "Failed to retrieve task lists" | Internal failure | --- ### Create Task List ``` POST /current/workspace/{workspace_id}/tasks/create/ ``` Creates a new task list in the workspace. Request body is JSON. **Auth:** JWT required. View or above. Requires the workflow billing feature. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` | string | Yes | 19-digit numeric workspace ID or `folder_name` | **Request Body (JSON):** | Name | Type | Required | Description | |------|------|----------|-------------| | `name` | string | Yes | Task list name | | `description` | string | No | Task list description | | `properties` | object | No | Custom properties | | `sort_order` | integer | No | Sort order position (default: 0) | **curl Example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/tasks/create/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"name": "Sprint Backlog", "description": "Current sprint items"}' ``` **Response (200 OK):** ```json { "result": true, "task_list": { "id": "abc123opaque", "profile_id": "1234567890123456789", "name": "Sprint Backlog", "description": "Current sprint items", "created_by": "9876543210987654321", "properties": {}, "sort_order": 0, "created": "2026-01-15 10:30:00 UTC", "updated": "2026-01-15 10:30:00 UTC", "deleted": null } } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1695 (Upgrade Required)` | 402 | "Access to workflow requires an upgraded plan." | Workflow not included in billing plan | | `1680 (Access Denied)` | 401 | "Workflow is not enabled" | Workflow not enabled on workspace | | `1605 (Invalid Input)` | 406 | "Invalid task list name" | Name validation failed | | `1605 (Invalid Input)` | 406 | "Invalid description" | Description validation failed | | `1605 (Invalid Input)` | 406 | "Invalid JSON in request body" | Malformed JSON | | `1610 (Internal Error)` | 500 | "Failed to create task list" | Internal failure | --- ### Reorder Task Lists ``` POST /current/workspace/{workspace_id}/tasks/reorder/ ``` Bulk reorder task lists within a workspace. All referenced task list IDs must belong to this workspace. Request body is JSON. **Auth:** JWT required. View or above. Requires the workflow billing feature. **Path Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `{workspace_id}` | string | Yes | 19-digit numeric workspace ID or `folder_name` | **Request Body (JSON):** | Name | Type | Required | Description | |------|------|----------|-------------| | `order` | array | Yes | Array of `{id, sort_order}` objects | | `order[].id` | string | Yes | Opaque task list ID | | `order[].sort_order` | integer | Yes | New sort order position | **curl Example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/tasks/reorder/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"order": [{"id": "abc123", "sort_order": 0}, {"id": "def456", "sort_order": 1}]}' ``` **Response (200 OK):** ```json { "result": true, "reordered": 2, "profile_id": "1234567890123456789" } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1695 (Upgrade Required)` | 402 | "Access to workflow requires an upgraded plan." | Workflow not included in billing plan | | `1680 (Access Denied)` | 401 | "Workflow is not enabled" | Workflow not enabled on workspace | | `1605 (Invalid Input)` | 406 | "order must be a non-empty array..." | Missing or empty order array | | `1605 (Invalid Input)` | 406 | "Each order entry must have id and sort_order fields" | Malformed order entry | | `1605 (Invalid Input)` | 406 | "Invalid task list ID format" | Invalid opaque ID | | `1605 (Invalid Input)` | 406 | "Task list ID does not belong to this workspace" | ID not owned by workspace | | `1610 (Internal Error)` | 500 | "Failed to reorder task lists" | Internal failure | --- ## Workflow-Native Tasks, Todos & Approvals Beyond standalone task lists, in-workflow obligations are produced by workflow steps when a workflow runs: - A workflow `todo` STEP creates a non-blocking checklist item inside a workflow run. - A workflow `approval` STEP is a blocking human-gate step, and the native multi-reviewer review/approval surface (`review-*`, under `Workflow\Review`) is the workflow-native approval mechanism — use it to create a review, record a decision, add reviewers, or send a document for review. These workflow-native surfaces are documented in the [Workflow reference](https://api.fast.io/current/llms/workflows/). The Tasks feature (task lists & tasks, above) remains fully supported. > 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 - **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` (including EXIF), 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 | Media metadata: `width`, `height`, `duration` (where applicable) | | `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}"}` | ### 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 | | `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 | | `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 | 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 | 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 | -- | 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 or note. **Auth required.** Permission: View (workspace), file view 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 | **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:** ```json { "result": true, "versions": [ { "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": { "pdf": { "state": "ready" } }, "virus": { "status": "scanned", "infected": false }, "ai": { "state": "indexed", "attach": true }, "file_attributes": {}, "summary": { "title": "Quarterly Report", "short": "Q4 financial summary", "long": "..." }, "origin": { "type": "upload", "creator": "9876543210987654321" } }, { "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": { "pdf": { "state": "ready" } }, "virus": { "status": "scanned", "infected": false }, "ai": { "state": "indexed", "attach": true }, "file_attributes": {}, "summary": null, "origin": { "type": "upload", "creator": "9876543210987654321" } } ] } ``` **Response fields (per version):** | 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"` | | `created` | string | Version creation timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `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 generation state per type (file/note versions) | | `virus` | object | Virus scan status (file/note versions) | | `ai` | object | AI processing state (file/note versions) | | `file_attributes` | object | Media/EXIF attributes, when applicable (file/note versions) | | `summary` | object/null | AI-generated summary, or `null` when none (file/note versions) | | `origin` | object | Origin/provenance info for the version | 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:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1609 (Not Found)` | 404 | Node not found | | `1605 (Invalid Input)` | 406 | Unsupported node type | | `1665 (Object Init Failed)` | 500 | Node data is corrupted | | `1680 (Access Denied)` | 401 | No permission to view file versions (share only) | | `1680 (Access Denied)` | 401 | No permission to view versions of files you did not create (share, creator-only restriction) | **Notes:** - Share permissions may restrict version listing to files the user created (creator-only restrictions). --- ## 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": { "pdf": { "state": "ready" } }, "virus": { "status": "scanned", "infected": false }, "ai": { "state": "indexed", "attach": true }, "file_attributes": {}, "summary": null, "origin": { "type": "upload", "creator": "9876543210987654321" } }, "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 keyword. Uses keyword and semantic search. **Auth required.** Permission: View (workspace), search + file view permissions (share). **Query parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|----------------------------| | `search` | string | Yes | Search query string | | `output` | string | No | Verbosity: `terse`, `standard`, or `full` (default). Trims `content_snippet` to a byte budget when intelligence is enabled. See *Verbosity* below. | **curl example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/search/?search=quarterly+report" \ -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. Trimmed per `output`. Hybrid mode only. | | `files.{id}.match_source` | string | `keyword`, `semantic`, or `both`. Hybrid mode only. | | `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}`. Hybrid mode only. | **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 | |------------|-------------|-------------| | `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. --- ## 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 four buckets: `files`, `metadata`, `comments`, and `workflows`. 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. | | `workflows_offset` | int | No | 0 | Result offset for the `workflows` bucket. | | `workflows_limit` | int | No | 25 | Page size for the `workflows` bucket. | 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" }, "workflows": { "items": [ { "entity_type": "task", "entity_id": "t4aa0-bbbbb-ccccc-ddddd-eeeee-ffff", "relevance_score": 1.904, "title": "Review Q4 report", "description": "Confirm the revenue figures before sign-off …", "status": "open", "updated": "2026-04-21 12:30:00 UTC" } ], "offset": 0, "limit": 25, "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). | **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`. - **`workflows`** — `entity_type` (`task` / `review_decision`), `entity_id`, and per-type body fields: `title` + `description` + `status` for tasks; `comment_text` + `action` + `status` (+ `workflow_id` / `workspace_id`) for review decisions. **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. - **workflows** — workflow items (tasks and review decisions) the caller is allowed to observe. On a share, only tasks the caller can view are returned; review decisions have no share surface. **Error responses:** | Error Code | HTTP Status | Description | |------------|-------------|-------------| | `1605 (Invalid Input)` | 406 | `search` missing/blank/too long, or a bucket request beyond the supported result window | | `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 and workflow items 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 / workflow surfaces — 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. ### 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`, `hls_stream`, 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 | 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 - 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`, `workflow` | | `full` | standard + `capabilities`, permission blocks, activity tracking, `comments`, `event_flow`, `filesystem`, `invite`, `member_visibility`, `multiplayer`, `chat`, `search`, `assets`, `workflow_permissions`, `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` | Not blank | 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 | 10-100 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. | | `workflow` | string | No | `false` | Boolean string | Enable workflow features on the new share. Setting `"true"` requires the `workflow` plan feature; plans without it are rejected. | ### 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 | 10-100 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 allow teams to pre-assign workflow items before the invitee signs 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. **Workflow assignments:** On a share, guests (including still-pending invitees) have no workflow access — they cannot be assigned to an `approval`, `task`, or `todo` step, and share guests cannot use the Tasks API. Workflow approval reviewers must be workspace members; external (non-member) reviewers require the workspace's native-review tier to be `extended`. Newly created workspaces default to the `extended` tier, so external reviewers are available out of the box. A workspace that predates that default, or whose tier was explicitly set to `mvs` or cleared, supports external reviewers only after a workspace admin sets the `extended` tier via the workspace update endpoint. **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 and unassigns any workflow items associated with them. **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 Workflow Shares support the Tasks feature (task lists & tasks) when the workflow feature gate is enabled. Workflow requires the `workflow` billing plan feature. In-workflow obligations (the workflow `todo` STEP, the blocking `approval` STEP, and the native multi-reviewer review/approval surface) are produced when a workflow runs — see the [Workflow reference](https://api.fast.io/current/llms/workflows/). ### Enable Workflow ``` POST /current/share/{share_id}/workflow/enable/ ``` Enable workflow features on a share. **Auth required. Admin only.** Idempotent -- returns success if already enabled. #### Response **Status:** `200 OK` ```json { "result": true, "message": "Workflow features enabled", "workflow": true } ``` ### Disable Workflow ``` POST /current/share/{share_id}/workflow/disable/ ``` Disable workflow features on a share. **Auth required. Admin only.** Idempotent -- returns success if already disabled. #### Response **Status:** `200 OK` ```json { "result": true, "message": "Workflow features disabled", "workflow": false } ``` ### List Task Lists ``` GET /current/share/{share_id}/tasks/ ``` List all task lists for a share. **Auth required. Workflow view permission required.** Supports offset-based pagination (`limit`/`offset`). Supports `format=markdown` query parameter for markdown output. #### Response **Status:** `200 OK` ```json { "result": true, "task_lists": [ { "id": "aBcDeFgHiJ", "profile_id": "1234567890123456789", "name": "Sprint 1", "description": "First sprint tasks", "created_by": "9876543210987654321", "properties": {}, "sort_order": 0, "created": "2026-01-15 10:30:00 UTC", "updated": "2026-01-15 10:30:00 UTC", "deleted": null } ], "pagination": { "limit": 50, "offset": 0, "total": 1 } } ``` ### Create Task List ``` POST /current/share/{share_id}/tasks/create/ ``` Create a new task list in a share. **Auth required. Workflow modify permission required.** Request body is JSON. #### Request Body | Field | Type | Required | Description | |---------------|---------|----------|--------------------------------------| | `name` | string | Yes | Task list name | | `description` | string | No | Task list description | | `properties` | object | No | Custom properties (default `{}`) | | `sort_order` | integer | No | Sort position (default `0`) | #### Request Example ```bash curl -X POST "https://api.fast.io/current/share/1234567890123456789/tasks/create/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"name": "Sprint 1", "description": "First sprint tasks"}' ``` #### Response **Status:** `200 OK` ```json { "result": true, "task_list": { "id": "aBcDeFgHiJ", "profile_id": "1234567890123456789", "name": "Sprint 1", "description": "First sprint tasks", "created_by": "9876543210987654321", "properties": {}, "sort_order": 0, "created": "2026-01-15 10:30:00 UTC", "updated": "2026-01-15 10:30:00 UTC", "deleted": null } } ``` #### Error Responses | HTTP Status | Code | Message | Cause | |-------------|--------------------------|----------------------------|---------------------------| | 406 | `1605 (Invalid Input)`| "Invalid task list name" | Name fails validation | | 406 | `1605 (Invalid Input)`| "Invalid description" | Description fails validation | | 406 | `1605 (Invalid Input)`| "Invalid JSON in request body" | Malformed JSON body | ### Reorder Task Lists ``` POST /current/share/{share_id}/tasks/reorder/ ``` Bulk reorder task lists within a share. **Auth required. Workflow modify permission required.** Request body is JSON. Prevents concurrent reordering. #### Request Body | Field | Type | Required | Description | |---------|-------|----------|---------------------------------------------------------| | `order` | array | Yes | Non-empty array of `{id, sort_order}` objects | Each entry in `order`: | Field | Type | Required | Description | |--------------|---------|----------|----------------------------| | `id` | string | Yes | Task list opaque ID | | `sort_order` | integer | Yes | New sort position | #### Request Example ```bash curl -X POST "https://api.fast.io/current/share/1234567890123456789/tasks/reorder/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"order": [{"id": "aBcDeFgHiJ", "sort_order": 0}, {"id": "kLmNoPqRsT", "sort_order": 1}]}' ``` #### Response **Status:** `200 OK` ```json { "result": true, "reordered": 2, "profile_id": "1234567890123456789" } ``` #### Error Responses | HTTP Status | Code | Message | Cause | |-------------|--------------------------|----------------------------------------------------------|----------------------------------------| | 406 | `1605 (Invalid Input)`| "order must be a non-empty array of {id, sort_order}..." | Missing or empty order array | | 406 | `1605 (Invalid Input)`| "Each order entry must have id and sort_order fields..." | Malformed entry | | 406 | `1605 (Invalid Input)`| "Task list ID ... does not belong to this share" | ID not owned by this share | ### Workflow-Native Todos & Approvals In-workflow obligations are produced by workflow steps when a workflow runs, not by standalone share endpoints: - A workflow `todo` STEP creates a non-blocking checklist item inside a workflow run. - A workflow `approval` STEP (lock-based) or `approval_in_place` STEP (in-place) is a blocking human-gate step, and the native multi-reviewer review/approval surface (the `workflow` tool's `review-*` actions) is the workflow-native approval — use it to create a review, record a decision, add reviewers, or send a document for review. See the [Workflow reference](https://api.fast.io/current/llms/workflows/) for these surfaces. ### Workflow Error Responses (Shared) The workflow task-list and enable/disable endpoints share these common errors: | HTTP Status | Code | Message | Cause | |-------------|--------------------------|--------------------------------------------------------------|-------------------------------------| | 403 | 144499 | "You do not have permissions to access this share." | Lacks admin permission (enable/disable) | | 403 | - | "Workflow features are only available to share members..." | Lacks workflow view/modify permission | | 412 | `1685 (Feature Limit)` | Workflow feature not available | Billing plan does not include workflow | --- ## 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: `binary`, `thumbnail`, `image`, `hls_stream`, `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`, `hls_stream`). 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 | --- ## Requesting Sign-off on a Share's Files Human sign-off on a share's files is done through **workflow review**, not a standalone share endpoint. Two paths produce a reviewer roster, version-pinned assets, a per-reviewer per-asset decision matrix, and a resolution policy: - **The lock-based `approval` step** — pulls the reviewed files into workspace storage and write-locks them while the review is pending, releasing them on resolution. - **The in-place `approval_in_place` step (and its "Request approval" quick path)** — reviews the files **where they already live** in the share, portal, or file share, with no move/copy/lock; the approved version is version-stamped and content-hashed for durable audit and later changes surface as *drift*. The quick path (`POST /current/workflow-review/quick-approval/`) provisions a private file share for the chosen reviewers and starts a single-step in-place approval run in one call (single file, members-only roster in v1; for multi-file or external reviewers, author an `approval_in_place` step in a workflow). See the [Workflow reference](https://api.fast.io/current/llms/workflows/) (the *Workflow Review* section) for the review surface, the `approval_in_place` step type, and the quick path. --- ## 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` | 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). Names longer than 100 chars are auto-truncated server-side, preserving the extension; conflict resolution switches to RENAME when truncation occurs. | | `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). 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). Names longer than 100 chars are auto-truncated server-side, preserving the extension; conflict resolution switches to RENAME when truncation occurs. | | `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). 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 and returns immediately when the status changes. | The server detects status changes efficiently during long-poll. Maximum wait is 590 seconds. **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). Names longer than 100 chars are auto-truncated server-side, preserving the extension. | | `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). Names longer than 100 chars are auto-truncated server-side, preserving the extension; conflict resolution switches to RENAME when truncation occurs. | | `relative_path` | string | No | Optional per-entry sub-path applied under `folder_id`. 1-8192 UTF-8 characters, trailing slash required, no leading slash, no `..` segments. 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). Names longer than 100 chars are auto-truncated server-side, preserving the extension; RENAME conflict resolution prevents distinct sources from overwriting each other when they collapse to the same truncated prefix. | | `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) | | `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 **200 MB** 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 / 200 MB / 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 / 200 MB / 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 / 200 MB / 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 / 200 MB / 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 / 200 MB / 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), 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 }`. 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 } ] } ], "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:** - 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 / 200 MB / 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 / 200 MB / 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 / 200 MB / 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 / 200 MB / 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 | **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`, `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. | **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 | | Domain | `domain` | Custom domain events | | Apps | `apps` | Application/integration events | | Node | `node` | Node-level events (individual file/folder objects) | | Server | `server` | Server/system-level events | | Import | `import` | Import operations | | Workflow | `workflow` | Workflow events (task lists, tasks, and workflow orchestration lifecycle) | | Workflow Chain | `workflow_chain` | Workflow audit-chain events | --- ## 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 | | Workflow | `workflow` | Workflow events | | Workflow Chain | `workflow_chain` | Workflow audit-chain events | --- ## 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` ### Workflow - `task_list_created` / `task_list_updated` / `task_list_deleted` - `task_created` / `task_updated` / `task_deleted` - `task_status_changed` / `task_assigned` / `task_completed` - `task_moved` — fires when a task is moved between lists - `tasks_reordered` — fires when tasks are reordered within a list - `task_lists_reordered` — fires when task lists are reordered --- ## 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 ``` **Workflow activity in a workspace:** ``` GET /current/events/search/?workspace_id={id}&category=workflow ``` --- ## 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, workflow, 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, workflow, 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, workflow, 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. | | Workflow | Workflow OBSERVE standing — workspace view (or a workflow-scoped `viewer`/`participant`/`admin` grant) for `workspace`-visibility workflows; workspace member-or-above (or a workflow-scoped `participant`/`admin` grant — a `viewer` grant alone is NOT sufficient at these visibilities) for `private`/`participants_only` workflows | | 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, Workflow, 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 workflow's OBSERVE standing can change (a grant is revoked, the workflow's visibility tightens, or the caller is removed from the parent workspace), 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. See the Workflow Orchestration reference (`https://api.fast.io/current/llms/workflows/`) for the workflow-specific realtime contract. **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 / workflow) 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. #### Workflow Channel — invalidation-nudge only Workflow profiles use the same `wss://` endpoint and frame shape, but the workflow runtime events are administrator-level, so the workflow channel ONLY emits `activity` messages — never enriched `event` messages. The frame's `activity` array carries identifiers like `details` (workflow lifecycle changed) or `steps:{step_occurrence_id}` (a step occurrence transitioned); the client refetches `GET /workflows/{workflow_id}/state/` (or the targeted step endpoint) as authoritative state. WebSocket delivery on the workflow channel is best-effort and not durable — for a transactional, gap-free record of workflow state changes use the audit-event log (`GET /workflows/{workflow_id}/audit/events/`). The full contract — connection flow, the activity-field set, reconnect behavior, mid-session authorization changes — lives in the Workflow Orchestration reference at `https://api.fast.io/current/llms/workflows/`. ### 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. **Workflow profiles do not currently support the activity-poll fallback.** The `/current/activity/poll/{profile_id}/` endpoint only services User / Organization / Workspace / Share / File Share / Sign Envelope profiles. For workflow channels, on reconnect refetch `GET /workflows/{workflow_id}/state/` (and any scoped step / obligation endpoint the UI was tracking) — that surface is authoritative and is what the realtime channel's nudges tell the client to re-read. For a transactional, gap-free record of workflow state changes, use the audit-event log (`GET /workflows/{workflow_id}/audit/events/`). --- ## 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 (works for every comment surface, incl. task comments) | | 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 | | POST | `/current/comments/{comment_id}/link/` | Link a comment to a workflow entity | | POST | `/current/comments/{comment_id}/unlink/` | Unlink a comment from a workflow entity | | GET | `/current/comments/linked/{entity_type}/{entity_id}/` | Get all comments linked to a workflow entity | | 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 | | GET | `/current/tasks/{list_id}/items/{task_id}/comments/` | List a task's comment thread | | POST | `/current/tasks/{list_id}/items/{task_id}/comments/` | Post a comment on a task | > **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. **Task comments** (see Task Comments below) are > private to their task and are excluded from comment search. --- ## 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 or an external workflow reviewer) — 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 | | `linked_entity_type` | string\|null | If linked to a workflow entity, one of `"task"` or `"workflow_review"`; otherwise null | | `linked_entity_id` | string\|null | Opaque ID of the linked workflow entity, or null | | `external_author_email` | string\|null | Email of an external author (File Share recipient or external workflow reviewer), 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 characters (raw); display text excluding mentions max 500 characters | 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 | | `linked_entity_type` | string | No | `"task"` | Link the new comment to a task at create time. `"workflow_review"` is **not** accepted here — create workflow-review comments through the workflow-review surface. Requires `linked_entity_id` | | `linked_entity_id` | string | Required if `linked_entity_type` is set | Valid opaque ID | The task to link to. Gated by the **Workflow** feature and task access (share guests denied); a missing, soft-deleted, cross-tenant, or inaccessible task returns `404` (existence is masked) | | `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 | | `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 | | `1605 (Invalid Input)` | 406 | "linked_entity_type must be one of: task, workflow_review" | Invalid `linked_entity_type` | | `1605 (Invalid Input)` | 406 | "Workflow-review comments must be created via the workflow-review API surface" | `linked_entity_type=workflow_review` is not accepted on this endpoint | | `1605 (Invalid Input)` | 406 | "linked_entity_id is required when linked_entity_type is provided" | Missing `linked_entity_id` | | `1605 (Invalid Input)` | 406 | "Invalid linked_entity_id format" | Malformed `linked_entity_id` | | `1609 (Not Found)` | 404 | "Linked entity not found" | Task to link is missing, soft-deleted, cross-tenant, or inaccessible | | `1610 (Internal Error)` | 500 | "Failed to save comment" | Internal error | **Notes:** - **Content filtering:** Bodies are sanitized (HTML stripped) and filtered for profanity, spam, and PII. Spam is blocked; other detected categories result in the body being modified before storage. - **Mentions:** Mentioned users are parsed and validated against entity membership before the comment is saved. - **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, File Share, and **task comments**. (The entity-scoped update above cannot address a task comment, whose entity is the task itself — use this endpoint.) **Auth:** Required (JWT). Rate limited. **Content-Type:** `application/json` **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `body` | string | Yes | 1-8192 characters (raw); display text excluding mentions max 500 characters | 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, threading, and task/workflow links 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. --- ## Link Comment to Workflow Entity ### `POST /current/comments/{comment_id}/link/` Link a comment to a workflow entity such as a task or a workflow review surface. This creates an association between the comment and the target entity, enabling reverse lookups and workflow integration. **Auth:** Required (JWT). Rate limited. **Content-Type:** `application/json` **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `entity_type` | string | Yes | `"task"` or `"workflow_review"` | The type of workflow entity to link to | | `entity_id` | string | Yes | Valid alphanumeric opaque ID | The ID of the workflow entity to link to | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/comments/abc123opaqueid/link/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{ "entity_type": "task", "entity_id": "task789opaqueid" }' ``` **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", "linked_entity_type": "task", "linked_entity_id": "task789opaqueid", "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 updated comment object with the link applied | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1680 (Access Denied)` | 401 | "Authentication required to link comments" | User not authenticated | | `1605 (Invalid Input)` | 406 | "Comment ID is required" | Missing comment ID in path | | `1605 (Invalid Input)` | 406 | "Invalid comment ID format" | Malformed comment opaque ID | | `1605 (Invalid Input)` | 406 | "Invalid JSON in request body" | Malformed JSON | | `1605 (Invalid Input)` | 406 | "entity_type is required" | Missing or non-string entity_type | | `1605 (Invalid Input)` | 406 | "entity_type must be one of: task, workflow_review" | Invalid entity type | | `1605 (Invalid Input)` | 406 | "entity_id is required" | Missing or non-string entity_id | | `1605 (Invalid Input)` | 406 | "Invalid entity_id format" | Malformed entity opaque ID | | `1609 (Not Found)` | 404 | "Comment not found" | Comment does not exist | | `1680 (Access Denied)` | 401 | "You do not have permission to link this comment" | User lacks access to the comment's entity | | `1609 (Not Found)` | 404 | "Target entity not found" | The workflow entity does not exist, is soft-deleted, or is not accessible to you (existence is masked) | | `1680 (Access Denied)` | 401 | "Workflow features are not enabled for this workspace/share", "Workflow features are not available on your current plan", "Insufficient permissions to access this resource", or "Workflow features are only available to share members and admins" | Linking to a `task` requires the Workflow feature enabled plus task access (share guests denied) | | `1605 (Invalid Input)` | 406 | "Invalid link parameters" | Link validation failed | | `1610 (Internal Error)` | 500 | "Failed to link comment" | Internal error | **Notes:** - Linking to a `task` is gated by the **Workflow** feature: the owning workspace or share must have Workflow enabled (and the plan must allow it), and the caller must have task access (workspace/share member or admin — **share guests are denied**). A missing, soft-deleted, or inaccessible task returns `404` (existence is masked). `workflow_review` links are created by the workflow-review surface. --- ## Unlink Comment from Workflow Entity ### `POST /current/comments/{comment_id}/unlink/` Remove the link between a comment and its associated workflow entity. After unlinking, the comment still exists but is no longer associated with any workflow entity. **Auth:** Required (JWT). Rate limited. **Content-Type:** `application/json` **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `entity_id` | string | No | Valid alphanumeric opaque ID | The ID of the linked entity (used for cache invalidation; recommended but not required) | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/comments/abc123opaqueid/unlink/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{ "entity_id": "task789opaqueid" }' ``` **Request Example -- Without entity_id:** ```bash curl -X POST "https://api.fast.io/current/comments/abc123opaqueid/unlink/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{}' ``` **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" } } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `comment` | object | The updated comment object with the link removed | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1680 (Access Denied)` | 401 | "Authentication required to unlink comments" | User not authenticated | | `1605 (Invalid Input)` | 406 | "Comment ID is required" | Missing comment ID in path | | `1605 (Invalid Input)` | 406 | "Invalid comment ID format" | Malformed comment opaque ID | | `1609 (Not Found)` | 404 | "Comment not found" | Comment does not exist | | `1680 (Access Denied)` | 401 | "You do not have permission to unlink this comment" | User lacks access to the comment's entity | | `1605 (Invalid Input)` | 406 | "Comment is not linked to any entity" | Comment has no existing link | | `1610 (Internal Error)` | 500 | "Failed to unlink comment" | Internal error | --- ## Get Linked Comments ### `GET /current/comments/linked/{entity_type}/{entity_id}/` Reverse lookup: retrieve all comments linked to a specific **task**. Only `entity_type=task` resolves through this endpoint — a `workflow_review` entity always returns `404` here (workflow-review comments are read on the workflow-review surface, not via this reverse lookup). **Auth:** Required (JWT). Rate limited. Requires the **Workflow** feature enabled on the owning workspace or share, plus access to the task (workspace/share member or admin; share **guests are denied**). A missing or soft-deleted task returns `404`. **Path Parameters:** | Parameter | Type | Format | Description | |-----------|------|--------|-------------| | `{entity_type}` | string | `"task"` or `"workflow_review"` | The type of workflow entity. **Note:** only `"task"` resolves through this endpoint (see below); `"workflow_review"` reverse lookups are served by the workflow-review surface, not here | | `{entity_id}` | string | Alphanumeric opaque ID | The ID of the workflow entity | **Query Parameters:** | Parameter | Type | Required | Default | Constraints | Description | |-----------|------|----------|---------|-------------|-------------| | `limit` | integer | No | `50` | Min: 1, Max: 200 | Number of comments to return | | `offset` | integer | No | `0` | Min: 0 | Number of comments to skip | **Request Example:** ```bash curl -X GET "https://api.fast.io/current/comments/linked/task/task789opaqueid/?limit=25&offset=0" \ -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", "linked_entity_type": "task", "linked_entity_id": "task789opaqueid", "reference": null, "reactions": {"👍": 2}, "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, "entity_type": "task", "entity_id": "task789opaqueid", "limit": 25, "offset": 0 } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `comments` | array | Array of comment objects linked to the entity | | `count` | int | Number of comments in this response | | `entity_type` | string | The entity type that was queried | | `entity_id` | string | The entity ID that was queried | | `limit` | int | Limit used in the query | | `offset` | int | Offset used in the query | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "entity_type is required in URL path" | Missing entity type | | `1605 (Invalid Input)` | 406 | "entity_type must be one of: task, workflow_review" | Invalid entity type | | `1605 (Invalid Input)` | 406 | "entity_id is required in URL path" | Missing entity ID | | `1605 (Invalid Input)` | 406 | "Invalid entity_id format" | Malformed opaque ID | | `1609 (Not Found)` | 404 | "The linked entity was not found" | Task does not exist, is soft-deleted, or `entity_type=workflow_review` (not served here) | | `1680 (Access Denied)` | 401 | "Workflow features are not enabled for this workspace/share" or "Workflow features are not available on your current plan" | Workflow feature not enabled/plan-gated | | `1680 (Access Denied)` | 401 | "Insufficient permissions to access this resource" or "Workflow features are only available to share members and admins" | Caller lacks task access (share guest denied) | | `1610 (Internal Error)` | 500 | "Failed to retrieve linked comments" | Internal error | --- ## Comment Attachments Attach arbitrary platform objects to a comment to give it context — a file or folder, a workflow, a sign envelope, a share, a File Share, a workspace, or a task. 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). - Attachments are **not supported on workflow-review comments** (a `406` is returned), and 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/workflow/envelope/File Share, or an opaque ID for a file/folder node or task) | | `target_type` | string | Coarse object type: `"node"`, `"workflow"`, `"envelope"`, `"share"`, `"workspace"`, `"fileshare"`, or `"task"` | | `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 | | `1605 (Invalid Input)` | 406 | "Attachments are not supported on workflow-review comments" | Comment is part of a workflow review | | `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. --- ## Task Comments Tasks have their own dedicated comment thread, surfaced under the task path rather than the generic comment paths. A task comment is a normal comment object (same schema, threading, mentions, reactions, and reference anchoring as above) — only the list/create surface differs. | Method | Path | Description | |--------|------|-------------| | GET | `/current/tasks/{list_id}/items/{task_id}/comments/` | List the task's comment thread (paginated; `limit` min 2, max 200; `offset`) | | POST | `/current/tasks/{list_id}/items/{task_id}/comments/` | Post a comment on the task (`body`, optional `parent_id` for a reply, optional `reference`, mentions parsed from `body`) | **Privacy.** Task comments are private to their task: they are **not** returned by the general comment listing (`GET /current/comments/{entity_type}/{parent_id}/`) and **not** included in comment search. They surface only through the task comment path above. A task comment's text is, however, searchable as part of its **task** — a workspace/share search that matches a task comment returns the parent task. **Reactions, edit, and delete** of a task comment use the existing comment-by-id endpoints in this reference — `POST /current/comments/{comment_id}/reactions/`, `DELETE /current/comments/{comment_id}/reactions/`, `POST /current/comments/{comment_id}/update/` (edit), and `DELETE /current/comments/{comment_id}/delete/`. **The task create endpoint never edits.** A POST to the task comments path whose body includes a `comment_id` field is rejected with `406 Invalid Input` (it does not create a duplicate); trailing path segments after `…/comments/` return `404`. Edits go through `POST /current/comments/{comment_id}/update/` only. **Access.** The task's `workflow` feature must be enabled on the owning workspace or share. On shares, member or admin access is required — guests are blocked. A soft-deleted task returns `404`. Mentions are validated against the task's workspace/share membership. The full request/response shapes for the task comment list and create endpoints are documented in the [Workflow (Task Management) reference](https://api.fast.io/current/llms/workflow/) under *Task Comments*. --- ## 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. - Display text (excluding all mention markup) is separately limited to 500 characters. - 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. ``` --- ## 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 undergo automatic filtering: - **HTML sanitization:** All HTML tags are stripped. - **Spam detection:** Comments identified as spam are **blocked** entirely (406 error). - **Profanity/PII filtering:** Detected content is modified in-place before storage; the returned `body` reflects the post-filter content. > Part of the Fastio API Reference. Overview: https://api.fast.io/current/llms/ > See also: Workflow Orchestration (v3.0) -> https://api.fast.io/current/llms/workflows/ # Workflow (Task Management) 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). Feature gate: All workflow endpoints (except toggle) require the `workflow` feature to be enabled on the target workspace or share. Share permissions: On shares, tasks require member or admin access -- guests are blocked. Public guests remain blocked from everything. Toggle endpoints (enable/disable) remain Admin-only. Markdown output: Workflow GET endpoints support `format=md` query parameter for Markdown output (AI agent-friendly). --- ## Endpoint Summary ### Feature Toggle | Method | Path | Description | |--------|------|-------------| | POST | `/current/workspace/{workspace_id}/workflow/enable/` | Enable workflow on a workspace | | POST | `/current/workspace/{workspace_id}/workflow/disable/` | Disable workflow on a workspace | | POST | `/current/share/{share_id}/workflow/enable/` | Enable workflow on a share | | POST | `/current/share/{share_id}/workflow/disable/` | Disable workflow on a share | ### Task Lists & Tasks | Method | Path | Description | |--------|------|-------------| | GET | `/current/workspace/{workspace_id}/tasks/` | List task lists in a workspace | | GET | `/current/share/{share_id}/tasks/` | List task lists in a share | | POST | `/current/workspace/{workspace_id}/tasks/create/` | Create a task list in a workspace | | POST | `/current/share/{share_id}/tasks/create/` | Create a task list in a share | | GET | `/current/tasks/{list_id}/details/` | Get task list details (includes tasks) | | POST | `/current/tasks/{list_id}/update/` | Update a task list | | POST | `/current/tasks/{list_id}/delete/` | Soft-delete a task list | | POST | `/current/tasks/{list_id}/restore/` | Restore a trashed task list | | GET | `/current/tasks/{list_id}/items/` | List tasks in a task list | | POST | `/current/tasks/{list_id}/items/create/` | Create a task | | GET | `/current/tasks/{list_id}/items/{task_id}/` | Get task details | | POST | `/current/tasks/{list_id}/items/{task_id}/update/` | Update a task | | POST | `/current/tasks/{list_id}/items/{task_id}/delete/` | Soft-delete a task | | POST | `/current/tasks/{list_id}/items/{task_id}/restore/` | Restore a trashed task | | POST | `/current/tasks/{list_id}/items/{task_id}/status/` | Change task status | | POST | `/current/tasks/{list_id}/items/{task_id}/assign/` | Assign/unassign a task | | POST | `/current/tasks/{list_id}/items/{task_id}/move/` | Move task to another list | | GET | `/current/tasks/{list_id}/items/{task_id}/attachments/` | List a task's attachments | | POST | `/current/tasks/{list_id}/items/{task_id}/attachments/` | Attach one or many objects to a task | | POST | `/current/tasks/{list_id}/items/{task_id}/attachments/detach/` | Detach an object from a task | | GET | `/current/tasks/{list_id}/items/{task_id}/comments/` | List a task's comments | | POST | `/current/tasks/{list_id}/items/{task_id}/comments/` | Post a comment on a task | | POST | `/current/tasks/{list_id}/items/bulk-status/` | Bulk update task statuses | | POST | `/current/tasks/{list_id}/items/reorder/` | Bulk reorder tasks in a task list | | POST | `/current/workspace/{workspace_id}/tasks/reorder/` | Bulk reorder task lists in a workspace | | POST | `/current/share/{share_id}/tasks/reorder/` | Bulk reorder task lists in a share | | GET | `/current/workspace/{workspace_id}/tasks/list/{filter}/` | Filtered task list for workspace (personal view) | | GET | `/current/share/{share_id}/tasks/list/{filter}/` | Filtered task list for share (group view, members/admins only) | | GET | `/current/workspace/{workspace_id}/tasks/summary/` | Task count summary for workspace | | GET | `/current/share/{share_id}/tasks/summary/` | Task count summary for share (members/admins only) | ### Workflow Agent Templates (v3.5+, admin-only) Workspace-scoped persona templates that pair an agent-step instruction prompt with a tool allowlist. v3.5 ships storage + admin CRUD; the agent runtime consumes these in a later release. All write methods require workspace admin permission; the list/read methods require workspace view. | Method | Path | Description | |--------|------|-------------| | POST | `/current/workspace/{workspace_id}/workflow_agent_templates/` | Create an agent template | | GET | `/current/workspace/{workspace_id}/workflow_agent_templates/` | List agent templates in the workspace | | GET | `/current/workspace/{workspace_id}/workflow_agent_templates/{template_id}/` | Read one agent template | | PATCH | `/current/workspace/{workspace_id}/workflow_agent_templates/{template_id}/` | Update mutable fields (name, instruction_prompt, tool_allowlist) | | DELETE | `/current/workspace/{workspace_id}/workflow_agent_templates/{template_id}/` | Hard-delete an agent template | Mutable fields via `PATCH`: `name` (max 128 chars), `instruction_prompt` (max 8192 chars), `tool_allowlist` (JSON array of tool id strings). Immutable: `id`, `workspace_id`, `created_at`, `created_by_user_id`. --- ## Common Path Parameters | Parameter | Type | Format | Description | |-----------|------|--------|-------------| | `{workspace_id}` | string | 19-digit numeric | Workspace profile ID | | `{share_id}` | string | 19-digit numeric | Share profile ID | | `{list_id}` | string | opaque alphanumeric string | Task list ID | | `{task_id}` | string | opaque alphanumeric string | Task ID | | `{filter}` | string | `assigned`, `created`, `status` | Task filtered-list filter | --- ## Common Error Responses These errors apply to all workflow endpoints (except toggle): | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1650 (Authentication Invalid)` | 401 | "Authentication required" | Missing or invalid JWT token | | `1680 (Access Denied)` | 401 | "Workflow is not enabled" | Workflow feature not enabled on entity | | `1680 (Access Denied)` | 401 | "Insufficient permissions" | User lacks required access level | | `1651 (Invalid Request Type)` | 400 | "Invalid request" | Wrong HTTP method | | `1671 (Rate Limited)` | 429 | "Rate limit exceeded" | Too many requests | --- ## Feature Toggle Enable or disable workflow on a workspace or share. Requires admin-level access. These endpoints do **not** require workflow to already be enabled. ### `POST /current/workspace/{workspace_id}/workflow/enable/` Enable workflow features on a workspace. If workflow is already enabled, the endpoint is a **no-op success** — it returns `result: true` with the message "Workflow features are already enabled" (not an error). **Auth:** Required (JWT). Admin or owner. **Request Example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/workflow/enable/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "message": "Workflow features enabled", "workflow": true } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `message` | string | Confirmation message | | `workflow` | bool | `true` = enabled, `false` = disabled | **Access Levels:** | Role | Access | |------|--------| | Owner / Admin | Can enable/disable | | Member / Guest | Denied | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1650 (Authentication Invalid)` | 401 | "Authentication required" | Missing or invalid token | | `1609 (Not Found)` | 404 | "Workspace not found" | Workspace does not exist | | `1680 (Access Denied)` | 401 | "Insufficient permissions" | User is not owner or admin | | `1610 (Internal Error)` | 500 | "Failed to enable workflow features" | Internal error | > Enabling an already-enabled workspace is **not** an error — it returns `result: true` with "Workflow features are already enabled". ### `POST /current/workspace/{workspace_id}/workflow/disable/` Disable workflow on a workspace. Existing data is preserved but inaccessible until re-enabled. If workflow is already disabled, the endpoint is a **no-op success** — it returns `result: true` with the message "Workflow features are already disabled" (not an error). **Request Example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/workflow/disable/" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "message": "Workflow features disabled", "workflow": false } ``` **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1650 (Authentication Invalid)` | 401 | "Authentication required" | Missing or invalid token | | `1609 (Not Found)` | 404 | "Workspace not found" | Workspace does not exist | | `1680 (Access Denied)` | 401 | "Insufficient permissions" | User is not owner or admin | | `1610 (Internal Error)` | 500 | "Failed to disable workflow features" | Internal error | > Disabling an already-disabled workspace is **not** an error — it returns `result: true` with "Workflow features are already disabled". ### Share Toggle Endpoints `POST /current/share/{share_id}/workflow/enable/` and `POST /current/share/{share_id}/workflow/disable/` behave identically to the workspace variants above, using `{share_id}` instead. **Notes:** - Disabling workflow does **not** delete any data. All entities are preserved and become accessible again when re-enabled. --- ## Task Lists & Tasks Task lists are containers for tasks, scoped to a workspace or share. Tasks support statuses, priorities, assignees, dependencies, and file/folder node links. ### Task Status Values | Value | Description | |-------|-------------| | `"pending"` | Not started | | `"in_progress"` | Actively being worked on | | `"complete"` | Finished | | `"blocked"` | Blocked by a dependency or issue | ### Status Transitions Any valid status may transition to any other valid status. The only rejected change is a **no-op** — setting a task to the status it already has — which returns `Task is already in status: {status}` (HTTP 406). There is no fixed transition matrix: e.g. `pending` -> `complete` and `complete` -> `blocked` are both accepted. **Recommended lifecycle (not enforced):** for consistent reporting, teams typically move tasks `pending` -> `in_progress` -> `complete`, using `blocked` when work is stalled and returning to `pending`/`in_progress` to resume. This is a convention only; the API does not restrict transitions to it. ### Task Priority (integer) | Value | Meaning | |-------|---------| | `0` | None (default) | | `1` | Low | | `2` | Medium | | `3` | High | | `4` | Critical | ### Task List Object | Field | Type | Description | |-------|------|-------------| | `id` | string | opaque alphanumeric ID (treat as opaque; do not assume fixed length) | | `profile_id` | string | Workspace or share profile ID (19-digit numeric) | | `name` | string | Task list name | | `description` | string\|null | Task list description | | `created_by` | string | User profile ID of the creator | | `properties` | object | Metadata properties (JSON) | | `sort_order` | int | Display sort order | | `created` | string | Timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `updated` | string | Timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `deleted` | string\|null | Deletion timestamp (`YYYY-MM-DD HH:MM:SS UTC`), or null | ### Task Object | Field | Type | Description | |-------|------|-------------| | `id` | string | opaque alphanumeric ID (treat as opaque; do not assume fixed length) | | `task_list_id` | string | Parent task list ID | | `profile_id` | string | Workspace or share profile ID (19-digit numeric) | | `created_by` | string | User profile ID of the task creator | | `title` | string | Task title | | `description` | string\|null | Task description | | `status` | string | `"pending"`, `"in_progress"`, `"complete"`, `"blocked"` | | `assignee_id` | string\|null | Assigned user's profile ID, or null | | `priority` | int 0 (none) through 4 (critical) | | `sort_order` | int | Display sort order | | `dependencies` | array\\|null | Task IDs this task depends on | | `node_id` | string\|null | Associated file/folder node ID | | `properties` | object | Metadata properties (JSON) | | `attachment_count` | int | Number of objects attached to the task (single-task detail and task list rows) | | `attachments` | array | Hydrated attachment rows (see Task Attachments). **Detail endpoint only** | | `comment_count` | int | Number of comments on the task (single-task detail and task list rows) | | `created` | string | Timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `updated` | string | Timestamp (`YYYY-MM-DD HH:MM:SS UTC`) | | `deleted` | string\|null | Deletion timestamp (`YYYY-MM-DD HH:MM:SS UTC`), or null | The live `attachment_count` and `comment_count` fields are returned by the single-task detail endpoint **and** on every task row of the task list endpoints (`GET /current/tasks/{list_id}/details/`, `GET /current/tasks/{list_id}/items/`). The hydrated `attachments` array is returned **only** by the single-task detail endpoint. --- ### List Task Lists #### `GET /current/workspace/{workspace_id}/tasks/` #### `GET /current/share/{share_id}/tasks/` List all task lists in a workspace or share. > **Backwards compatibility:** The previous routes (`/current/tasks/workspace/{workspace_id}/` and `/current/tasks/share/{share_id}/`) continue to work via automatic redirect. **Auth:** Required (JWT). Rate limited. **Query Parameters:** | Parameter | Type | Required | Default | Constraints | Description | |-----------|------|----------|---------|-------------|-------------| | `limit` | int | No | `100` | Max 500 | Number of task lists to return | | `offset` | int | No | `0` | Min 0 | Number to skip | | `format` | string | No | -- | `"md"` | Markdown output | **Request Example:** ```bash curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/tasks/?limit=50" \ -H "Authorization: Bearer {jwt_token}" ``` **Response:** ```json { "result": true, "task_lists": [ { "id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5", "profile_id": "1234567890123456789", "name": "Sprint 12 Tasks", "description": "Tasks for the current sprint", "created_by": "9876543210987654321", "properties": {}, "sort_order": 0, "created": "2025-06-01 09:00:00 UTC", "updated": "2025-06-10 14:30:00 UTC", "deleted": null } ], "pagination": { "limit": 50, "offset": 0, "total": 1 } } ``` --- ### Create Task List #### `POST /current/workspace/{workspace_id}/tasks/create/` #### `POST /current/share/{share_id}/tasks/create/` Create a new task list. > **Backwards compatibility:** The previous routes (`/current/tasks/workspace/{workspace_id}/create/` and `/current/tasks/share/{share_id}/create/`) continue to work via automatic redirect. **Auth:** Required (JWT). Rate limited. **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `name` | string | Yes | 1-255 characters | Task list name | | `description` | string | No | Max 65535 characters | Task list description | | `properties` | object | No | -- | Metadata properties | | `sort_order` | int | No | Default 0 | Display sort order | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/tasks/create/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "name=Sprint%2012%20Tasks&description=Tasks%20for%20the%20current%20sprint" ``` **Response:** ```json { "result": true, "task_list": { "id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5", "profile_id": "1234567890123456789", "name": "Sprint 12 Tasks", "description": "Tasks for the current sprint", "created_by": "9876543210987654321", "properties": {}, "sort_order": 0, "created": "2025-06-01 09:00:00 UTC", "updated": "2025-06-01 09:00:00 UTC", "deleted": null } } ``` --- ### Get Task List Details #### `GET /current/tasks/{list_id}/details/` Get task list details including all tasks in the list. **Auth:** Required (JWT). Rate limited. **Query Parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `format` | string | No | -- | `"md"` for Markdown output | **Response:** ```json { "result": true, "task_list": { "id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5", "profile_id": "1234567890123456789", "name": "Sprint 12 Tasks", "description": "Tasks for the current sprint", "created_by": "9876543210987654321", "properties": {}, "sort_order": 0, "created": "2025-06-01 09:00:00 UTC", "updated": "2025-06-10 14:30:00 UTC", "deleted": null }, "tasks": [ { "id": "x1y2z3a4b5c6d7e8f9g0h1i2j3k4l5", "task_list_id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5", "profile_id": "1234567890123456789", "title": "Implement login flow", "description": "Build the OAuth2 login flow", "status": "in_progress", "assignee_id": "9876543210987654321", "priority": 3, "sort_order": 0, "dependencies": null, "node_id": null, "properties": {}, "attachment_count": 1, "comment_count": 2, "created": "2025-06-01 09:30:00 UTC", "updated": "2025-06-10 14:30:00 UTC", "deleted": null } ] } ``` --- ### Update Task List #### `POST /current/tasks/{list_id}/update/` Update a task list. Only provided fields are changed. **Auth:** Required (JWT). Rate limited. **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `name` | string | No | 1-255 characters | Updated name | | `description` | string\|null | No | Max 65535 chars; `null` to clear | Updated description | | `properties` | object | No | -- | Updated properties | | `sort_order` | int | No | -- | Updated sort order | --- ### Delete Task List #### `POST /current/tasks/{list_id}/delete/` Soft-delete a task list. **Auth:** Required (JWT). Rate limited. --- ### Restore Task List #### `POST /current/tasks/{list_id}/restore/` Restore a soft-deleted (trashed) task list, clearing its trashed state. Restoring a list also makes its tasks searchable again. Restoring an already-live list returns an "is not deleted" validation error (HTTP 406). **Auth:** Required (JWT). Rate limited. **Response:** ```json { "result": true, "task_list": { "id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5", "profile_id": "1234567890123456789", "name": "Sprint 12 Tasks", "description": null, "created_by": "9876543210987654321", "properties": {}, "sort_order": 0, "created": "2026-06-13 09:30:00 UTC", "updated": "2026-06-13 16:37:29 UTC", "deleted": null } } ``` --- ### List Tasks #### `GET /current/tasks/{list_id}/items/` List all tasks in a task list. Each task row includes the live `attachment_count` and `comment_count` fields (see Task Object). **Auth:** Required (JWT). Rate limited. **Query Parameters:** | Parameter | Type | Required | Default | Constraints | Description | |-----------|------|----------|---------|-------------|-------------| | `limit` | int | No | `100` | Max 500 | Number of tasks to return | | `offset` | int | No | `0` | Min 0 | Number to skip | | `status` | string | No | -- | `"pending"`, `"in_progress"`, `"complete"`, `"blocked"` | Return only tasks with this status (server-side filter applied before pagination) | | `format` | string | No | -- | `"md"` | Markdown output | --- ### Create Task #### `POST /current/tasks/{list_id}/items/create/` Create a new task in a task list. **Auth:** Required (JWT). Rate limited. **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `title` | string | Yes | 1-512 characters | Task title | | `description` | string | No | Max 65535 characters | Task description | | `priority` | int | No | 0-4 (default 0) | Priority: 0=none, 1=low, 2=medium, 3=high, 4=critical | | `assignee_id` | string | No | 19-digit numeric profile ID | User to assign | | `node_id` | string | No | Alphanumeric opaque ID | File/folder node to link | | `dependencies` | array\ | No | Array of task IDs | Tasks this task depends on | | `properties` | object | No | -- | Metadata properties | | `sort_order` | int | No | Default 0 | Display sort order | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/tasks/a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5/items/create/" \ -H "Authorization: Bearer {jwt_token}" \ -d "title=Implement%20login%20flow&priority=3&assignee_id=9876543210987654321" ``` **Response:** ```json { "result": true, "task": { "id": "x1y2z3a4b5c6d7e8f9g0h1i2j3k4l5", "task_list_id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5", "profile_id": "1234567890123456789", "title": "Implement login flow", "description": null, "status": "pending", "assignee_id": "9876543210987654321", "priority": 3, "sort_order": 0, "dependencies": null, "node_id": null, "properties": {}, "created": "2025-06-01 09:30:00 UTC", "updated": "2025-06-01 09:30:00 UTC", "deleted": null } } ``` --- ### Get Task Details #### `GET /current/tasks/{list_id}/items/{task_id}/` Get full details of a single task. This is the only endpoint that returns the hydrated `attachments` array; the live `attachment_count` and `comment_count` fields (computed fresh on each read) are returned here **and** on task list rows. **Auth:** Required (JWT). Rate limited. **Query Parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `format` | string | No | -- | `"md"` for Markdown output | **Response:** ```json { "result": true, "task": { "id": "x1y2z3a4b5c6d7e8f9g0h1i2j3k4l5", "task_list_id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5", "profile_id": "1234567890123456789", "title": "Implement login flow", "description": "Build the OAuth2 login flow", "status": "in_progress", "assignee_id": "9876543210987654321", "priority": 3, "sort_order": 0, "dependencies": null, "node_id": null, "created_by": "9876543210987654321", "properties": {}, "attachment_count": 1, "attachments": [ { "target_id": "n1o2p3q4r5s6t7u8v9w0x1y2z3a4b5", "target_type": "node", "kind": "node", "display_name": "spec.pdf", "available": true } ], "comment_count": 2, "created": "2026-06-09 09:30:00 UTC", "updated": "2026-06-09 14:30:00 UTC", "deleted": null } } ``` --- ### Update Task #### `POST /current/tasks/{list_id}/items/{task_id}/update/` Update a task. Only provided fields are changed. **Auth:** Required (JWT). Rate limited. **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `title` | string | No | 1-512 characters | Updated title | | `description` | string\|null | No | Max 65535 chars; `null` to clear | Updated description | | `priority` | int | No | 0-4 | Updated priority | | `assignee_id` | string\|null | No | 19-digit numeric; `null` to unassign | Updated assignee | | `node_id` | string\|null | No | Opaque ID; `null` to clear | Updated node link | | `dependencies` | array\ | No | Array of task IDs | Updated dependencies | | `properties` | object | No | -- | Updated properties | | `sort_order` | int | No | -- | Updated sort order | --- ### Delete Task #### `POST /current/tasks/{list_id}/items/{task_id}/delete/` Soft-delete a task. **Auth:** Required (JWT). Rate limited. --- ### Restore Task #### `POST /current/tasks/{list_id}/items/{task_id}/restore/` Restore a soft-deleted (trashed) task, clearing its trashed state. A restored task (and its comments) becomes searchable again. Restoring an already-live task returns an "is not deleted" validation error (HTTP 406). A task whose **task list is itself trashed cannot be restored** — restore the task list first. Attempting to restore a task under a trashed list returns an input-validation error (HTTP 406) with a message instructing you to restore the task list first. **Auth:** Required (JWT). Rate limited. **Response:** ```json { "result": true, "task": { "id": "x1y2z3a4b5c6d7e8f9g0h1i2j3k4l5", "task_list_id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5", "profile_id": "1234567890123456789", "title": "Implement login flow", "description": "Build the OAuth2 login flow", "status": "in_progress", "assignee_id": "9876543210987654321", "priority": 3, "sort_order": 0, "dependencies": null, "node_id": null, "created_by": "9876543210987654321", "properties": {}, "created": "2026-06-13 09:30:00 UTC", "updated": "2026-06-13 16:37:29 UTC", "deleted": null } } ``` --- ### Change Task Status #### `POST /current/tasks/{list_id}/items/{task_id}/status/` Change a task's status. Any status may change to any other status; the only rejected change is a no-op (setting a task to the status it already has). See Status Transitions above. **Auth:** Required (JWT). Rate limited. **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `status` | string | Yes | `"pending"`, `"in_progress"`, `"complete"`, `"blocked"` | New status | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/tasks/a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5/items/x1y2z3a4b5c6d7e8f9g0h1i2j3k4l5/status/" \ -H "Authorization: Bearer {jwt_token}" \ -d "status=in_progress" ``` **Response:** ```json { "result": true, "task": { "id": "x1y2z3a4b5c6d7e8f9g0h1i2j3k4l5", "status": "in_progress", "...": "..." }, "old_status": "pending", "new_status": "in_progress" } ``` --- ### Assign Task #### `POST /current/tasks/{list_id}/items/{task_id}/assign/` Assign or unassign a task. **Auth:** Required (JWT). Rate limited. **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `assignee_id` | string\|null | Yes | 19-digit numeric profile ID, or `null` | User to assign, or `null` to unassign | **Notes:** - The `assignee_id` can refer to a pending member (an invited user who has not yet signed up). Pending members have a valid user ID and can be assigned to tasks before they create their account. When the user claims their account, the assignment is preserved. --- ### Move Task #### `POST /current/tasks/{list_id}/items/{task_id}/move/` Move a task from one task list to another within the same profile. **Auth:** Required (JWT). Rate limited. **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `target_task_list_id` | string | Yes | opaque alphanumeric ID | Destination task list ID | | `sort_order` | int | No | Default 0 | Sort order in the target list | **Constraints:** Target list must exist, not be deleted, and belong to the same profile. Source and target list cannot be the same. **Request Example:** ```bash curl -X POST "https://api.fast.io/current/tasks/a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5/items/x1y2z3a4b5c6d7e8f9g0h1i2j3k4l5/move/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"target_task_list_id": "p1q2r3s4t5u6v7w8x9y0z1a2b3c4d5"}' ``` **Response:** Updated task object with `old_task_list_id` and `new_task_list_id`. --- ### Bulk Update Task Status #### `POST /current/tasks/{list_id}/items/bulk-status/` Update the status of multiple tasks at once. **Auth:** Required (JWT). Rate limited. **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `tasks` | array | Yes | Non-empty array | Array of `{"task_id": string, "status": string}` objects | **Request Example:** ```bash curl -X POST "https://api.fast.io/current/tasks/a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5/items/bulk-status/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"tasks": [{"task_id": "x1y2z3...", "status": "complete"}, {"task_id": "p1q2r3...", "status": "in_progress"}]}' ``` Each entry is processed independently: a failing entry (unknown task, no-op status, a task not in this list, etc.) is recorded in `errors` and skipped, while the rest still apply. The request only fails outright if `tasks` is missing or empty. **Response:** ```json { "result": true, "updated": [ {"task_id": "x1y2z3...", "old_status": "pending", "new_status": "complete"}, {"task_id": "p1q2r3...", "old_status": "pending", "new_status": "in_progress"} ], "errors": [], "summary": { "total": 2, "succeeded": 2, "failed": 0 } } ``` An entry that fails appears in `errors` instead of `updated`, e.g.: ```json { "result": true, "updated": [ {"task_id": "x1y2z3...", "old_status": "pending", "new_status": "complete"} ], "errors": [ {"index": 1, "task_id": "p1q2r3...", "error": "Task is already in status: in_progress"} ], "summary": { "total": 2, "succeeded": 1, "failed": 1 } } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `updated` | array | Successful updates, each `{"task_id", "old_status", "new_status"}` | | `errors` | array | Failed entries, each `{"index", "task_id", "error"}` (`index` is the entry's position in the request `tasks` array; `task_id` is omitted when the entry itself was malformed) | | `summary` | object | `{"total", "succeeded", "failed"}` counts | --- ### Reorder Tasks #### `POST /current/tasks/{list_id}/items/reorder/` Bulk reorder tasks within a task list. Updates `sort_order` for each specified task atomically. **Auth:** Required (JWT). Rate limited. **Request Body (JSON):** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `order` | array | Yes | Non-empty array | Array of `{"id": string, "sort_order": int}` objects | Each entry in the `order` array specifies a task ID and its new sort order. All task IDs must belong to the specified task list. **Request Example:** ```bash curl -X POST "https://api.fast.io/current/tasks/a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5/items/reorder/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"order": [{"id": "x1y2z3...", "sort_order": 0}, {"id": "p1q2r3...", "sort_order": 1}]}' ``` **Response:** ```json { "result": true, "reordered": 2, "list_id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5" } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `reordered` | int | Number of tasks reordered | | `list_id` | string | The task list ID | **Error Responses:** | Error Code | HTTP Status | Message | Cause | |------------|-------------|---------|-------| | `1605 (Invalid Input)` | 406 | "order must be a non-empty array..." | Missing or invalid order array | | `1605 (Invalid Input)` | 406 | "Task ID ... does not belong to this task list" | Task not in specified list | | `1610 (Internal Error)` | 500 | "Concurrent operation conflict..." | Concurrent reorder conflict | --- ### Reorder Task Lists #### `POST /current/workspace/{workspace_id}/tasks/reorder/` #### `POST /current/share/{share_id}/tasks/reorder/` Bulk reorder task lists within a workspace or share. Updates `sort_order` for each specified task list atomically. > **Backwards compatibility:** The previous routes (`/current/tasks/workspace/{workspace_id}/reorder/` and `/current/tasks/share/{share_id}/reorder/`) continue to work via automatic redirect. **Auth:** Required (JWT). Rate limited. **Request Body (JSON):** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `order` | array | Yes | Non-empty array | Array of `{"id": string, "sort_order": int}` objects | Each entry in the `order` array specifies a task list ID and its new sort order. All task list IDs must belong to the specified workspace or share. **Request Example:** ```bash curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/tasks/reorder/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"order": [{"id": "a1b2c3...", "sort_order": 0}, {"id": "d4e5f6...", "sort_order": 1}]}' ``` **Response:** ```json { "result": true, "reordered": 2, "profile_id": "1234567890123456789" } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `reordered` | int | Number of task lists reordered | | `profile_id` | string | The workspace or share profile ID | --- ## Task Attachments Attach arbitrary platform objects to a task to give it context — a file or folder, a note, a workflow, a sign-envelope, a share, a File Share, or another task. An attachment is a lightweight reference: it records what is attached, not a copy of it. - A task can hold up to **100** attachments. - Attaching is **idempotent** — re-attaching an object that is already attached is a no-op and does not count against the cap. - Attaching does **not** verify that the target exists or is accessible; display names are resolved (and access-gated) on read. - All attachment endpoints require the `workflow` feature on the owning workspace or share. On shares, member or admin access is required — guests are blocked. A soft-deleted task 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/workflow/envelope/File Share, or an opaque ID for a file/folder node or task) | | `target_type` | string | Coarse object type: `"node"`, `"workflow"`, `"envelope"`, `"share"`, `"workspace"`, `"fileshare"`, or `"task"` | | `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 | --- ### List Task Attachments #### `GET /current/tasks/{list_id}/items/{task_id}/attachments/` List all objects attached to a task, hydrated and access-gated to the caller. **Auth:** Required (JWT). Rate limited. **Response:** ```json { "result": true, "task_id": "x1y2z3a4b5c6d7e8f9g0h1i2j3k4l5", "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 } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `task_id` | string | The task the attachments belong to | | `attachments` | array | Hydrated attachment objects (see Attachment Object) | | `count` | int | Number of attachments returned | --- ### Attach Objects to a Task #### `POST /current/tasks/{list_id}/items/{task_id}/attachments/` Attach one object (`target_id`) or many (`target_ids`) to a task. The operation is idempotent (already-attached objects are skipped) and the new objects are committed atomically (a partial-batch failure attaches nothing). The full, updated hydrated attachment list is returned. **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 | 1-100 items | Multiple objects to attach | The `target_id` (or each entry in `target_ids`) is the ID of any file/folder node, note, workflow, sign-envelope, share, File Share, workspace, or task. A request whose new attachments would push the task over the 100-attachment cap is rejected before anything is written. **Request Example -- single:** ```bash curl -X POST "https://api.fast.io/current/tasks/a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5/items/x1y2z3a4b5c6d7e8f9g0h1i2j3k4l5/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/tasks/a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5/items/x1y2z3a4b5c6d7e8f9g0h1i2j3k4l5/attachments/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"target_ids": ["n1o2p3q4r5s6t7u8v9w0x1y2z3a4b5", "9876543210987654321"]}' ``` **Response:** ```json { "result": true, "task_id": "x1y2z3a4b5c6d7e8f9g0h1i2j3k4l5", "attached": 1, "attachments": [ { "target_id": "n1o2p3q4r5s6t7u8v9w0x1y2z3a4b5", "target_type": "node", "kind": "node", "display_name": "spec.pdf", "available": true } ], "count": 1 } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `task_id` | string | The task 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 | "target_ids exceeds the maximum of 100" | Batch larger than the cap | | `1605 (Invalid Input)` | 406 | "Invalid attachment target" | A target ID could not be decoded | | `1605 (Invalid Input)` | 406 | "Attachment limit reached (maximum 100 per task)" | The new attachments would exceed the cap | | `1609 (Not Found)` | 404 | "Task not found" | Task does not exist or is soft-deleted | --- ### Detach an Object from a Task #### `POST /current/tasks/{list_id}/items/{task_id}/attachments/detach/` Remove an attachment by its `target_id`. Detaching is idempotent — detaching an object that is not attached returns success with `removed: false`. The updated hydrated attachment list is returned. **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/tasks/a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5/items/x1y2z3a4b5c6d7e8f9g0h1i2j3k4l5/attachments/detach/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"target_id": "n1o2p3q4r5s6t7u8v9w0x1y2z3a4b5"}' ``` **Response:** ```json { "result": true, "task_id": "x1y2z3a4b5c6d7e8f9g0h1i2j3k4l5", "removed": true, "attachments": [], "count": 0 } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `task_id` | string | The task 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 | --- ## Task Comments Collaborate on a task with a dedicated comment thread. Task comments support the full comment feature set — threaded replies, @-mentions, emoji reactions, edit/delete, and edited/created timestamps. The comment shape and threading/mention/reference semantics match the [Comments reference](https://api.fast.io/current/llms/comments/); only the list/create surface is task-specific. - Task comments require the `workflow` feature on the owning workspace or share. On shares, member or admin access is required — guests are blocked. A soft-deleted task returns `404`. - Task comments are **private to the task**: they are not returned by the general comment listing (`GET /current/comments/{entity_type}/{parent_id}/`) and do not appear in the global comment search. - A task comment's text **is** searchable as part of its task, however: a workspace/share search that matches the text of a task comment returns the parent **task** (the newest comments on a task are folded into the task's searchable content). Restoring a trashed task or task list makes its tasks searchable again. - **Reactions, edit, and delete** of a task comment use the existing comment-by-id endpoints (`POST /current/comments/{comment_id}/reactions/`, `POST /current/comments/{comment_id}/update/` to edit, `DELETE /current/comments/{comment_id}/delete/`). See the Comments reference. - The single-task detail endpoint and task list rows return a live `comment_count`. - **Realtime routing:** comment events (`comment_created`, `comment_updated`, `comment_deleted`, `comment_reaction`, mention/reply notifications) for a task comment carry `linked_entity_type: "task"` and `linked_entity_id` (the task id) in their payload, so clients can route them to the right task thread. The full comment object schema, threading rules, mention markup, reaction behavior, and reference anchoring are documented in the [Comments reference](https://api.fast.io/current/llms/comments/). --- ### List Task Comments #### `GET /current/tasks/{list_id}/items/{task_id}/comments/` List the task's comment thread, paginated. **Auth:** Required (JWT). Rate limited. **Query Parameters:** | Parameter | Type | Required | Default | Constraints | Description | |-----------|------|----------|---------|-------------|-------------| | `limit` | int | No | `50` | Min 2, Max 200 | Number of comments to return | | `offset` | int | No | `0` | Min 0 | Number to skip | **Response:** ```json { "result": true, "task_id": "x1y2z3a4b5c6d7e8f9g0h1i2j3k4l5", "comments": [ { "id": "cmt1opaqueid", "entity": "x1y2z3a4b5c6d7e8f9g0h1i2j3k4l5", "user_id": "9876543210987654321", "parent_id": null, "body": "Started on this — first draft is in spec.pdf.", "profile_type": "workspace", "profile_id": "1234567890123456789", "linked_entity_type": "task", "linked_entity_id": "x1y2z3a4b5c6d7e8f9g0h1i2j3k4l5", "reference": null, "mentions": [], "reactions": {"👍": 1}, "user_reaction": null, "version": 1, "can_edit": false, "can_delete": false, "can_reply": true, "created": "2026-06-09 10:00:00 UTC", "updated": "2026-06-09 10:00:00 UTC" } ], "count": 1, "limit": 50, "offset": 0 } ``` **Response Fields:** | Field | Type | Description | |-------|------|-------------| | `task_id` | string | The task the comments belong to | | `comments` | array | Comment objects (full schema in the Comments reference) | | `count` | int | Number of comments returned | | `limit` | int | Limit used in the query | | `offset` | int | Offset used in the query | --- ### Post a Task Comment #### `POST /current/tasks/{list_id}/items/{task_id}/comments/` Post a comment on the task, or reply to an existing task comment. **Auth:** Required (JWT). Rate limited. **Content-Type:** `application/json` **Request Body:** | Field | Type | Required | Constraints | Description | |-------|------|----------|-------------|-------------| | `body` | string | Yes | 1-8192 characters (raw); display text excluding mentions max 500 characters | Comment text (may include mention markup) | | `parent_id` | string | No | Valid comment opaque ID | Parent comment to reply to (single-level threading). The parent must already belong to **this** task's thread | | `reference` | object | No | See the Comments reference | Anchoring reference to a position in a file | | `properties` | object | No | -- | Arbitrary key-value metadata to attach | | `target_id` | string | No | Valid object ID | A single object to attach to the new comment inline | | `target_ids` | array\ | No | Array of valid object IDs | Multiple objects to attach to the new comment inline | Mentions are parsed from the `body` markup and validated against the task's workspace/share membership; client-supplied mention lists are ignored. **Inline attachments:** supply `target_id` (single) or `target_ids` (array) to attach objects to the comment as it is created — the comment and its attachments persist atomically (a bad target rejects the whole create; nothing is written). Targets are the same object kinds as task attachments (file/folder node, note, workflow, sign-envelope, share, File Share, workspace, or task). The deduped set is capped per comment; exceeding it, or supplying a malformed/undecodable target, returns `Invalid Input`. When present, the response echoes the hydrated `attachments` and an `attachment_count`. This endpoint is **create-only**: a body containing a `comment_id` field is rejected with `406 Invalid Input` (it never edits and never creates a duplicate), and trailing path segments after `…/comments/` return `404`. To edit a task comment use `POST /current/comments/{comment_id}/update/`. **Request Example:** ```bash curl -X POST "https://api.fast.io/current/tasks/a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5/items/x1y2z3a4b5c6d7e8f9g0h1i2j3k4l5/comments/" \ -H "Authorization: Bearer {jwt_token}" \ -H "Content-Type: application/json" \ -d '{"body": "Looks good — @[user:1234567890123456789:Jane Smith] can you take the review?"}' ``` **Response:** `201 Created` with the created comment object: ```json { "result": true, "comment": { "id": "cmt2opaqueid", "entity": "x1y2z3a4b5c6d7e8f9g0h1i2j3k4l5", "user_id": "9876543210987654321", "parent_id": null, "body": "Looks good — @[user:1234567890123456789:Jane Smith] can you take the review?", "profile_type": "workspace", "profile_id": "1234567890123456789", "linked_entity_type": "task", "linked_entity_id": "x1y2z3a4b5c6d7e8f9g0h1i2j3k4l5", "reference": null, "mentions": ["1234567890123456789"], "reactions": {}, "user_reaction": null, "version": 1, "can_edit": true, "can_delete": true, "can_reply": true, "created": "2026-07-07 10:05:00 UTC", "updated": "2026-07-07 10:05:00 UTC" }, "attachments": [], "attachment_count": 0 } ``` When inline attachments are supplied, `attachments` holds the hydrated (access-gated) attachment objects — same shape as task attachments (`target_id`, `target_type`, `kind`, `display_name`, `available`) — and `attachment_count` is their number: ```json { "result": true, "comment": { "id": "cmt2opaqueid", "...": "..." }, "attachments": [ { "target_id": "n1o2p3q4r5s6t7u8v9w0x1y2z3a4b5", "target_type": "node", "kind": "node", "display_name": "spec.pdf", "available": true } ], "attachment_count": 1 } ``` **Response Fields (top level):** | Field | Type | Description | |-------|------|-------------| | `comment` | object | The created comment (full schema in the Comments reference) | | `attachments` | array | Hydrated, access-gated inline attachments (empty when none supplied) | | `attachment_count` | int | Number of inline attachments created with the comment | **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 text (excluding mentions) must not exceed 500 characters..." | Display text too long | | `1605 (Invalid Input)` | 406 | "Your comment appears to contain spam content and cannot be posted" | Spam detected | | `1605 (Invalid Input)` | 406 | "This endpoint only creates comments..." | Body contained a `comment_id` field (use `POST /current/comments/{comment_id}/update/` to edit) | | `1609 (Not Found)` | 404 | "Parent comment not found" | `parent_id` is not a comment in this task's thread | | `1609 (Not Found)` | 404 | "Task not found" | Task does not exist or is soft-deleted | --- ## Profile-Scoped Filtered Lists & Summaries Tasks support profile-scoped filtered list and summary endpoints. These replace the need for global cross-profile queries in most use cases. - **Workspace context** = personal view (filtered to the current user) - **Share context** = group view for owners and members - **Pagination:** All endpoints support `?limit=` (default 50, max 100) and `?offset=` (default 0) ### Share Guest Access On shares, tasks require member or admin access -- guests are blocked. | Operation | Owner/Admin/Member | Share Guest | Public Guest | |-----------|-------------------|-------------|-------------| | View/create/resolve tasks | Yes | No | No | --- ### Task Filtered Lists #### `GET /current/workspace/{workspace_id}/tasks/list/{filter}/` #### `GET /current/share/{share_id}/tasks/list/{filter}/` Filters: `assigned` (assigned to user, optional `?status=`), `created` (created by user, optional `?status=`), `status` (requires `?status=` query param). Share endpoint shows group view. Members and admins only -- guests are blocked. #### `GET /current/workspace/{workspace_id}/tasks/summary/` #### `GET /current/share/{share_id}/tasks/summary/` Returns total count, counts by status, tasks assigned to current user, and tasks created by current user. Members and admins only on share. --- ## Workflow Events Task operations emit events on all state changes. Filter with `GET /current/events/search/?workspace_id={id}&category=workflow`. **Task events:** `task_list_created`, `task_list_updated`, `task_list_deleted`, `task_list_restored`, `task_created`, `task_updated`, `task_deleted`, `task_restored`, `task_status_changed`, `task_assigned`, `task_moved`, `task_completed`, `tasks_reordered`, `task_lists_reordered`, `task_attachment_added`, `task_attachment_removed` All workflow events use event category `workflow` and subcategory `workflow`. > Part of the Fastio API Reference. Overview: https://api.fast.io/current/llms/ > See also: Tasks (task lists & tasks) -> https://api.fast.io/current/llms/workflow/ # Workflow Orchestration API (v3.2) Base URL: `https://api.fast.io/current/` Auth: All endpoints require `Authorization: Bearer {jwt_token}` (JWT, OAuth, or API key). Inbound HMAC receivers are the only exception -- they accept HMAC-signed requests exclusively (no bearer/OAuth/API key). 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 the workflow profile read and grant list; observe (participant-level grant for `private`/`participants_only` visibility, otherwise workspace view) for runtime-read endpoints (state, audit events, step occurrences, step lists); workspace admin for mutating endpoints. Obligation resolve requires assignee/claimer/admin standing. Redaction requires two distinct workspace admins. Reading an agent step's commentary trace requires observe standing AND member-or-above level (a share-guest is excluded). Mid-run modification endpoints require the `workflow_mid_run_edit` plan capability; the reads (list / detail) require member-or-above (a share-guest is excluded — they carry change payloads, the assignee diff, and proposer/applier ids), and propose / apply / cancel require workflow ADMIN. ## What This Is Workflow Orchestration is the durable runtime that executes multi-step processes scoped to a workspace. Each workflow is a workspace-child profile with lifecycle state (`draft` / `active` / `paused` / `completed` / `cancelled` / `archived`), an optional credit budget, and an associated runtime row that tracks in-flight execution. Workflows emit a tamper-evident, HMAC-signed audit chain on every state change; the chain can be exported as a signed bundle and verified offline. Workflow steps can include AI agent invocations (optionally returning schema-validated verdicts), human obligations (claim/resolve), inbound webhook waits, outbound webhook deliveries, and arbitrary fan-out / loop / gate constructs (including fail-closed natural-language edge gates). External callers integrate through three surfaces: REST (for owner-side operations), inbound HMAC webhooks (for external systems delivering events), and outbound HMAC webhooks (for the platform pushing events to receivers). A fourth, optional surface is the client-facing realtime channel — a WebSocket that pushes invalidation nudges for runtime activity so UIs do not need to poll `GET /workflows/{workflow_id}/state/`; see the "Realtime Channel (WebSocket)" section under Concepts. Distinct from the Tasks feature at `/current/llms/workflow/` -- that surface ships task lists and tasks; this surface ships a durable orchestration runtime, audit chain, and webhook integration. Human gates inside a workflow run are expressed as workflow steps (the `task` / `todo` / `confirmation` / `approval` steps) and the native review/approval surface (see *Workflow Review*), not as standalone primitives. --- ## Endpoint Summary > **Searching workflow items:** To search workflow items by keyword — tasks, > to-dos, work logs, approvals, and review decisions — use the unified search > endpoint. `GET /current/workspace/{workspace_id}/search/` (and > `/current/share/{share_id}/search/`) returns a `workflows` bucket alongside > files and other types, each with its own pagination. See *Unified Search* in > the Storage Operations reference. Results are permission-filtered to the items > the caller is allowed to observe (on a share, only approvals the caller is a > party to are returned). ### Workflow Profile + Runtime | Method | Path | Description | |--------|------|-------------| | POST | `/current/workspace/{workspace_id}/workflows/` | Create a workflow | | GET | `/current/workspace/{workspace_id}/workflows/` | List workflows in a workspace (cursor-paginated; `?page_size=N` (1–100, default 50), `?cursor=` for subsequent pages; `?bucket=in_flight\|completed\|paused\|failed` filters by execution status; first page returns `counts: { in_flight, completed, paused, failed }`; results ordered newest-created first; `?include=run_summary,run_meta` adds per-item enrichment; all other filters `?template_id=`, `?archived=`, `?created_by=me`, `?participant=me` compose; `?limit=`/`?offset=`/`?state=` accepted but deprecated) | | GET | `/current/workflows/{workflow_id}/` | Get a workflow | | PATCH | `/current/workflows/{workflow_id}/` | Update mutable fields / transition lifecycle | | DELETE | `/current/workflows/{workflow_id}/` | Soft-archive (`?hard=true` for owner-only hard delete) | | POST | `/current/workflows/{workflow_id}/transfer/` | Transfer to another workspace (same org only) | | POST | `/current/workflows/{workflow_id}/grants/` | Grant a user a workflow-scoped role (`viewer`/`participant`/`admin`) | | GET | `/current/workflows/{workflow_id}/grants/` | List the workflow's grants | | DELETE | `/current/workflows/{workflow_id}/grants/{user_id}/` | Revoke a user's grant (soft revoke) | | POST | `/current/workflows/{workflow_id}/instantiate/` | Instantiate the workflow runtime (idempotent on `idempotency_key`; optional `step_seeds` to pre-seed `wait_for_files` steps at run start). Rejected `422` when the workflow's published definition has no runnable steps (an idempotent replay still returns the existing run) | | GET | `/current/workflows/{workflow_id}/state/` | Runtime state snapshot (active steps, recent steps, credit budget) | | POST | `/current/workflows/{workflow_id}/pause/` | Pause an active workflow | | POST | `/current/workflows/{workflow_id}/resume/` | Resume a paused workflow | | POST | `/current/workflows/{workflow_id}/cancel/` | Cancel (cascades to sync sub-children) | | GET | `/current/workflows/{workflow_id}/steps/{step_occurrence_id}/` | Get a step occurrence | | POST | `/current/workflows/{workflow_id}/steps/{step_occurrence_id}/advance/` | Dispatch handler | | POST | `/current/workflows/{workflow_id}/steps/{step_occurrence_id}/cancel/` | Cancel a single step occurrence (CAS-guarded) | | POST | `/current/workflows/{workflow_id}/steps/{step_occurrence_id}/files/` | Provide existing file node ids to a WAITING `wait_for_files` step (in place; no move/copy). Re-evaluates the step | | POST | `/current/workflows/{workflow_id}/steps/{step_occurrence_id}/complete/` | Explicitly advance a manual-completion `wait_for_files` step once the required file count has been provided (no body; returns the updated step occurrence) | | POST | `/current/workflows/{workflow_id}/steps/{step_occurrence_id}/output/` | Submit a step's output envelope | | GET | `/current/workflows/{workflow_id}/steps/{step_id}/occurrences/` | List occurrences for a step definition | | GET | `/current/workflows/{workflow_id}/steps/{step_occurrence_id}/agent_trace/` | Read an AI-agent step occurrence's commentary trace (member-or-above observers) | | GET | `/current/workflows/{workflow_id}/steps/{step_occurrence_id}/agent_activity/` | Read an AI-agent step occurrence's action feed (member-or-above observers) | | GET | `/current/workflows/{workflow_id}/steps/{step_occurrence_id}/agent_result/` | Read an AI-agent step occurrence's final answer + references/citations/products (member-or-above observers) | | POST | `/current/workflows/{workflow_id}/rotate_inbound_key/` | Rotate per-workflow inbound HMAC key (returns new version int only) | ### Workflow Templates (immutable revisions) | Method | Path | Description | |--------|------|-------------| | POST | `/current/workspace/{workspace_id}/workflow_templates/` | Create a template revision (validated end-to-end; 422 on failure with `validation_report`) | | GET | `/current/workspace/{workspace_id}/workflow_templates/` | List template revisions for a workspace | | GET | `/current/workflow_templates/{template_id}/` | Get a single revision (`?include_body=true` to inline `template_body`) | | PATCH | `/current/workflow_templates/{template_id}/` | Always returns 422 (templates are immutable; POST a new revision) | | POST | `/current/workflow_templates/{template_id}/publish/` | Lifecycle transition to `published` (legal only from `validated`; 422 otherwise) | | POST | `/current/workflow_templates/{template_id}/withdraw/` | Lifecycle transition to `withdrawn` (legal only from `published`; 422 otherwise) | | POST | `/current/workflow_templates/{template_id}/deprecate/` | Lifecycle transition to `deprecated` (legal only from `published`; 422 otherwise) | ### Workflow Triggers (workspace-scoped fire-on-event) | Method | Path | Description | |--------|------|-------------| | POST | `/current/workspace/{workspace_id}/workflow_triggers/` | Create a trigger | | GET | `/current/workspace/{workspace_id}/workflow_triggers/` | List triggers (`?enabled_filter=true|false|all`). Each trigger includes `target_template_name` — the fired template's human-readable name (`null` when unresolved) — resolved in one batched lookup alongside `target_template_id` | | GET | `/current/workflow_triggers/{trigger_id}/` | Get a trigger. The response includes `target_template_name` (the fired template's human-readable name, `null` when unresolved) alongside `target_template_id` | | PATCH | `/current/workflow_triggers/{trigger_id}/` | Update mutable fields (toggle / rate cap / concurrency cap / event match scope); params accepted in the body (form-urlencoded or JSON) or query string. `event_match` replaces the trigger's `event_match.event_filter.scope` (the storage-event watch scope) in place — `scope.id` values are always returned as JSON strings for precision safety (same kind required; new scope takes effect on the next matching event; in-flight runs unaffected). `param_mapping` is create-only and `kind` is immutable; `target_template_id` is changed via the dedicated retarget endpoint (below), not PATCH | | POST | `/current/workflow_triggers/{trigger_id}/retarget/` | Re-point the trigger at a different target template (`target_template_id` in the body). Fail-closed: the new template must be in the workspace, publishable, and have runnable steps, and the trigger's `param_mapping` must still supply every run param the new template newly requires (an incompatible mapping returns 422). Clears any version pin (the trigger falls back to the new template's latest published revision). Workspace admin | | DELETE | `/current/workflow_triggers/{trigger_id}/` | Soft-delete (sets `enabled=false`); `?hard=true` permanently deletes the trigger (soft-delete-first required) | | POST | `/current/workflow_triggers/{trigger_id}/fire/` | Manually fire (integration testing / replay) | | POST | `/current/workflow_triggers/{trigger_id}/dry_run/` | Dry-run (backtest) a saved event trigger over a historical window — reports matched vs would-fire counts and sample matches without firing. Params: `window_days` (≤90), `sample_limit`, `apply_guards` | | POST | `/current/workspace/{workspace_id}/workflow_triggers/dry_run/` | Dry-run (backtest) an unsaved event-trigger draft (inline `event_match`/`param_mapping`/`target_template_id`) over a historical window — same report; nothing saved or fired | | POST | `/current/workflow_triggers/{trigger_id}/rotate_inbound_key/` | Rotate workspace inbound trigger key (the key must be enabled on the workspace first). Success response: `{"rotation": {trigger_id, workspace_id, tier: "workspace", new_key_version, grace_window_ends}}` | | GET | `/current/workspace/{workspace_id}/workflow_inbound_key/` | Read the workspace inbound trigger key state — `{"workflow_inbound_key": {enabled, key_version}}`; key bytes are never readable. Workspace admin | | POST | `/current/workspace/{workspace_id}/workflow_inbound_key/` | Enable / disable the workspace inbound trigger key (`enabled=true\|false`). Enabling is the prerequisite for `rotate_inbound_key` and for workspace inbound-webhook receivers; the version counter is monotonic across disable → re-enable. Workspace admin | ### Obligations (human "waiting on me" rows) | Method | Path | Description | |--------|------|-------------| | GET | `/current/obligations/` | List obligations for a workflow (required `workflow_id` query filter is the authz anchor; optional `status`, `assigned_user_id`; offset-paginated). Visibility matches the per-obligation read rule: a workspace admin sees every obligation; a workspace member sees obligations assigned to or claimed by them plus every role-addressed obligation; a non-member sees only obligations assigned to or claimed by them — `pagination.total` counts the caller-visible rows | | GET | `/current/obligations/{obligation_id}/` | Get an obligation | | POST | `/current/obligations/{obligation_id}/claim/` | Atomically claim a role-addressed obligation | | POST | `/current/obligations/{obligation_id}/release/` | Release a claimed obligation back into the pool (claimer-only) | | POST | `/current/obligations/{obligation_id}/resolve/` | Resolve with an optional `resolution_payload` (JSON object, ≤32 KB), recorded durably and surfaced on the resolving step's output; completes a blocking `task` or `confirmation` step | | POST | `/current/workflows/{workflow_id}/steps/{step_occurrence_id}/reassign/` | Reassign a waiting `task` step to a new assignee (`new_assignee_user_id`). Workflow ADMIN or the current assignee | | GET | `/current/me/inbox/` | Cross-workspace top-K inbox (not cached) | | GET | `/current/me/inbox/workspace/{workspace_id}/` | Workspace-scoped inbox (cached) | | GET | `/current/me/inbox/workspace/{workspace_id}/pool/{pool_key}/` | Pool-scoped inbox (cached) | Every obligation response (the single-obligation GET and all three inbox endpoints) carries a display surface alongside the existing flat `summary_text`: a `display_summary` object with `structured_status` (`valid` / `absent` / `parse_failed` / `validation_failed` / `stale`). The `what_it_asks` (a deterministic one-line description), `source` (`deterministic`), and `trust_class` (`system_trusted`) fields are present if and only if `structured_status` is `valid`; `absent` means there is no structured summary for that obligation, and any other value indicates the structured summary could not be produced. The structured fields are computed deterministically with no AI call; `summary_text` remains as the fallback whenever `structured_status` is not `valid`. The single-obligation GET and the inbox endpoints additionally return `available_actions` (a list of the actions the caller may take now -- a subset of `claim` / `release` / `resolve`, derived from the obligation status, claim state, and the caller's standing) and `suggested_next` (the single most relevant action, or `null`). When the caller can observe the workflow runtime, a short `what_changed` line summarizing the workflow's most recent activity is also included; it is omitted when the caller lacks runtime-observe access. The top-level `/current/obligations/` list returns the caller-neutral fields only (`display_summary`, `summary_text`) -- not `available_actions` / `what_changed`, which are caller-specific. **Terminal-context cleanup.** Obligations are automatically superseded (status `superseded`, `available_actions: []`) when their context can no longer be acted on: pending **decision** obligations (an approval vote or a review-surface reviewer decision) close as soon as their step occurrence reaches any terminal state — including normal completion, where the surface resolved without needing that vote — and **cancelling, archiving, or hard-deleting a workflow closes every open obligation it minted** (including standing `todo` checklist items, which otherwise survive a workflow's normal completion). A periodic reconciliation sweep also retires rows stranded open by earlier releases — covering cancelled workflows as well as archived/deleted ones — so inbox consumers should treat `superseded` as "no longer actionable", not as an error, and need no client-side filtering for completed-step, cancelled-workflow, or archived-workflow rows. Every obligation response also carries a separate top-level `nl_enrichment` object (sibling to `display_summary`) describing the optional AI structured-extraction enrichment. It always carries `enrichment_status` (`absent` / `pending` / `valid` / `stale` / `error`); the validated extraction (`fields`) plus provenance (`source: "llm"`, `enriched_at`, `schema_hash`) and `trust_class: "agent_constrained"` are present only when `enrichment_status` is `valid`. The enrichment is AI-derived and read-only, so its `trust_class` is always `agent_constrained` — never `system_trusted`. It is populated only when the workflow has an *extraction schema* configured AND the workspace has enabled AI summary enrichment (`nl_summaries_enabled`, see the [Workspaces reference](https://api.fast.io/current/llms/workspaces/)); otherwise `enrichment_status` is `absent`. Consumers should prefer the deterministic `display_summary` / `summary_text` for the canonical line and treat `nl_enrichment.fields` as supplemental, attacker-influenceable data to be rendered inert and badged. ### Extraction Schema (per-workflow structured-extraction field set) | Method | Path | Description | |--------|------|-------------| | GET | `/current/workflows/{workflow_id}/extraction_schema/` | Get the workflow's current extraction schema (the field set its structured extraction runs against). Returns `{"extraction_schema": null}` when none is configured. Workflow VIEW access | | POST / PUT | `/current/workflows/{workflow_id}/extraction_schema/` | Replace the field set with a new append-only schema. Workflow ADMIN access | | POST | `/current/workflows/{workflow_id}/extraction_schema/derive/` | Auto-derive: an LLM proposes a field set from a sample of the workflow's files; the proposal is returned, not persisted. Workflow ADMIN access | ### Mid-Run Modifications (propose / apply changes to a running workflow) Available when the workspace plan includes the `workflow_mid_run_edit` capability (enabled on every current plan). Every endpoint in this family returns `403 (Access Denied)` when the plan does not include it. | Method | Path | Description | |--------|------|-------------| | POST | `/current/workflows/{workflow_id}/modifications/` | Propose a modification (skip / reassign / patch step occurrences) against a running workflow; auto-pauses the run. Workflow ADMIN | | GET | `/current/workflows/{workflow_id}/modifications/` | List the workflow's proposals (optional `status` filter). Member-or-above (share-guest excluded) | | GET | `/current/workflows/{workflow_id}/modifications/{modification_id}/` | Get a proposal's detail (changes + before/after diff). Member-or-above (share-guest excluded) | | POST | `/current/workflows/{workflow_id}/modifications/{modification_id}/apply/` | Apply a proposal's changes, then finalize and resume the run. Workflow ADMIN | | POST | `/current/workflows/{workflow_id}/modifications/{modification_id}/cancel/` | Cancel a proposal and resume the run unchanged. Workflow ADMIN | ### Audit (event log, signed export, dual-control redaction) | Method | Path | Description | |--------|------|-------------| | GET | `/current/workflows/{workflow_id}/audit/events/` | Paginated audit event log (`?include_payload=true` to inline payload) | | POST | `/current/workflows/{workflow_id}/audit/export/` | Start an asynchronous signed export job | | GET | `/current/audit/export_jobs/` | List export jobs for a workspace (required `workspace_id` query filter; workspace-admin; offset-paginated) | | GET | `/current/audit/export_jobs/{job_id}/` | Get export job status (manifest signature, chunk count) | | GET | `/current/audit/export_jobs/{job_id}/bundle/{chunk_id}/` | Stream manifest (`{chunk_id}=manifest`) or a chunk file (`{chunk_id}=0..N-1`) | | POST | `/current/workspace/{workspace_id}/audit/redaction/` | Initiate (`mode=request`) or confirm (`mode=confirm`) a redaction | | GET | `/current/workspace/{workspace_id}/audit/redaction/{redaction_id}/` | Get a committed redaction batch summary | | GET | `/current/workspace/{workspace_id}/audit/signing/` | Read the workspace audit-signing state (`{enabled, key_version}`). Workspace admin | | POST | `/current/workspace/{workspace_id}/audit/signing/` | One-way opt-in that mints the v1 audit-signing key, enabling signed audit exports for all future workflows (`enabled=true`; idempotent; no disable). Workspace admin | ### Inbound Webhooks (HMAC-only) | Method | Path | Description | |--------|------|-------------| | POST | `/current/inbound/workspace/{workspace_id}/trigger/{trigger_id}/` | Instantiate a workflow from a workspace trigger | | POST | `/current/inbound/workflow/{workflow_id}/step/{url_token}/` | Advance a specific step occurrence | ### Outbound Webhook Subscriptions | Method | Path | Description | |--------|------|-------------| | POST | `/current/workspace/{workspace_id}/outbound_webhook_subscriptions/` | Create a subscription (one-time view of HMAC secret) | | GET | `/current/workspace/{workspace_id}/outbound_webhook_subscriptions/` | List subscriptions | | GET | `/current/outbound_webhook_subscriptions/{subscription_id}/` | Get a subscription (secret not returned) | | PATCH | `/current/outbound_webhook_subscriptions/{subscription_id}/` | Update (toggle / description / rate cap / family allowlist) | | DELETE | `/current/outbound_webhook_subscriptions/{subscription_id}/` | Hard-delete the subscription | | POST | `/current/outbound_webhook_subscriptions/{subscription_id}/rotate_secret/` | Rotate HMAC secret (one-time response with new bytes) | ### Workflow Pools (concurrency caps) | Method | Path | Description | |--------|------|-------------| | POST | `/current/workspace/{workspace_id}/workflow_pools/` | Create a pool | | GET | `/current/workspace/{workspace_id}/workflow_pools/` | List pools (each row carries best-effort `active_concurrent`) | | GET | `/current/workspace/{workspace_id}/workflow_pools/{pool_key}/` | Get a single pool (carries best-effort `active_concurrent`) | | DELETE | `/current/workspace/{workspace_id}/workflow_pools/{pool_key}/` | Delete a pool (requires zero running and zero queued workflows) | ### External Subjects (cross-workflow correlation) | Method | Path | Description | |--------|------|-------------| | GET | `/current/workspace/{workspace_id}/external_subjects/{subject_id}/workflows/` | List workflows indexed by an integrator correlation handle | --- ## Concepts ### Workflow Profile A workflow is a workspace-child profile -- the same hierarchy primitive as a folder or share. It carries lifecycle state, ownership, and provenance. The `state` column is the lifecycle. The runtime row (a separate resource keyed by `workflow_id`) carries in-flight execution: `status`, `last_event_seq`, `active_step_occurrence_count`, `revision_number`. Pause/resume operate on the profile state; cancel cascades through both profile and runtime. Provenance is exposed via `origin_kind` (`manual` / `template` / `trigger` / `dynamic_child` / `inbound_webhook`) and, for template-origin runs, a stable top-level `template_id` -- a base32 OpaqueId string mirroring `origin_ref.template_id` (`null` for non-template runs, always present). The list read `GET /current/workspace/{workspace_id}/workflows/?template_id=` narrows the result to a single template's runs by exact-string match (manual runs excluded). The same list uses cursor (keyset) pagination: `?page_size=N` (1–100, default 50) controls page size; `?cursor=` (the `pagination.next_cursor` from a prior response, passed back URL-encoded and otherwise verbatim -- the cursor is opaque; never parse or construct it) advances to the next page. Results are ordered newest-created first, stable across pages. When cursor pagination is active (any of `?page_size=`/`?cursor=`/`?bucket=` is present), the `pagination` object is `{ has_more, next_cursor, page_size }`, and the first page (no `?cursor=`) additionally returns best-effort `counts: { in_flight, completed, paused, failed }` -- the tally of runs in each execution-status bucket; cursor pages omit `counts`. A page may contain fewer than `page_size` items even when `has_more` is `true`, so rely on `has_more` / `next_cursor` to decide whether to continue -- never on a page being full. A request that passes none of those parameters (and the deprecated `?limit=`/`?offset=`) instead returns the legacy offset pagination `{ total, limit, offset, has_more }`. **Bucket filter:** `?bucket=in_flight|completed|paused|failed` narrows the list to a single execution status (`in_flight` = running or queued; `completed` = completed; `paused` = manually paused; `failed` = failed or cancelled). Each run is in at most one bucket; runs still pending -- draft or not yet started -- are in no bucket, while a run that reached a terminal lifecycle state (completed or cancelled) without ever producing a runtime row is still tallied (in `completed` / `failed` respectively). The four `counts` therefore do not necessarily sum to the total run count. The same list also supports `?state=` (a single lifecycle state, **deprecated** — prefer `?bucket=`), `?archived=true|false|all`, `?created_by=me` (alias `?mine=true`; runs you manually started -- automation runs have no human creator and are excluded), and `?participant=me` (runs where you hold an actionable obligation). All filters, including `?bucket=`, compose (AND together). **Cursor-reset rule:** a cursor is bound to the workspace and the full active filter set; discard it and restart from the first page whenever any filter or bucket changes — reusing a stale cursor after a filter change returns an input-invalid error. The legacy `?limit=` and `?offset=` parameters are still accepted but deprecated in favor of `?page_size=` and `?cursor=`. When `POST /current/workspace/{workspace_id}/workflows/` is called with a `template_id`, the new workflow is created with a bound, runnable definition copied from that template, so it is immediately instantiable. The request is rejected when the `template_id` is missing/invalid or refers to a template that is not in this workspace (invalid input, HTTP 406), and with `1698 (Unprocessable Entity)` HTTP 422 when the referenced template is withdrawn/deprecated, its underlying workflow has been deleted/archived, or it has no published definition to copy. ### Templates and Definition Revisions A template revision is an immutable JSON document describing steps, gates, and edges. POSTing a new revision validates the body against the platform's trust matrix, DAG, gate-independence, and DSL rules; on failure the response carries a `validation_report` array. Validated revisions are signed with a content-hash (`template_signature_hash`, hex SHA-256 of the canonical snapshot bytes). Each workflow can be instantiated against a published revision. Every revision carries a `lifecycle_state` -- one of `validated`, `published`, `withdrawn`, `deprecated`. A new revision starts `validated`. The lifecycle endpoints (`publish`, `withdraw`, `deprecate`) persist a state transition along the state machine `validated` -> `published` -> `withdrawn`, with `published` -> `deprecated`; an illegal transition is rejected with 422. Revisions of the same template chain via `parent_revision_id` (the immediate predecessor; `null` on a chain head). Template reads additionally carry a stable **`lineage_root_id`** -- the hyphenated OpaqueId of the chain's root revision, identical across every revision of the same template -- so clients can group a template's revision history without matching on the `name` string. Template rows also report `has_triggers`: `true` when the template body declares embedded triggers **or** at least one workspace trigger row targets that revision (`target_template_id`). **Inline one-off workflows.** `POST /current/workspace/{workspace_id}/workflows/` accepts an optional `definition` param -- a JSON template body passed as a **string**, with the exact same authoring contract as the template-create endpoint (step keys must be valid CRC OpaqueId strings; hyphenated display forms are accepted and normalized; the body runs through the same trust-matrix validation pipeline). `definition` is **mutually exclusive** with `template_id`, and the reserved provenance keys `_workflow_usage` / `_system_gallery` are rejected if present in the body (the template-create endpoint enforces the same rejection). When `definition` is supplied the server creates the workflow profile **and** validates → snapshots → publishes → binds the definition in one call, and the response additionally carries the created `workflow_template` resource (the no-`definition` response shape is unchanged). The run is still started separately via `POST /workflows/{workflow_id}/instantiate/`. On any failure **after** profile creation (a validation, materialize, or persist error -- no committed revision) the just-created workflow is rolled back and the mapped error is returned (422 with a `validation_report` for validation failures); a publish/bind failure that lands after a committed revision keeps the workflow and returns recovery params (publish manually via `POST /workflow_templates/{template_id}/publish/`, or bind via the next `instantiate` call). The `idempotency_key` on this endpoint is provenance-only -- it is **not** replay-safe (a retried create with the same key creates a new workflow), unlike the gallery `from_system` path. `GET /current/workspace/{workspace_id}/workflow_templates/` accepts an optional `usage` filter -- `library` (returns only templates without the one-off marker), `one_off` (only one-off templates), or `all` (default, unchanged behavior). Template rows now carry a top-level `usage` field (`"library"` or `"one_off"`); revisions created by an inline one-off workflow create are stamped with the server-owned `_workflow_usage: "one_off"` provenance marker in their snapshot, which is what `usage` classifies on. ### Step Occurrences Each step in a workflow can fire multiple times -- once per iteration in a loop, again on retry, and so on. The step definition id (`step_id`) is stable; each invocation gets a unique `step_occurrence_id`. Step occurrences carry a lowercase `state` -- one of `pending`, `in_progress`, `waiting`, `blocked`, `completed`, `failed`, `skipped`, `cancelled` (the last four terminal) -- `iteration_path` (loop coordinates), an output envelope, and a display-ready `step_name` / `step_type` resolved from the workflow definition. Mutations are CAS-guarded; conflicting writes return 409 and the caller should re-read and retry. A `failed` step occurrence additionally carries a structured `failure_reason` object with `code` and `message` fields describing why the step failed. A `skipped` occurrence carries the same `failure_reason` object when the skip was decided by a natural-language gate (`code` is `nl_gate_denied` or `nl_gate_unevaluable` -- see *Natural-Language Gates & Verdicts*); a `skipped` occurrence pruned by an ordinary false DSL guard carries no `failure_reason`. The field is absent on every other state and absent on a `failed` or `skipped` occurrence for which no reason was recorded. (`failure_reason` on `skipped` occurrences is an additive change -- clients that previously handled it only on `failed` occurrences are unaffected.) Every step occurrence carries a read-only `trust_class` field describing how much the platform trusts the step's output: `system_trusted` (manual / approval / review outputs and other system-derived values), `agent_constrained` (AI-agent-produced output, which is attacker-influenceable and must be rendered inert and badged accordingly), or `template_bound` (the strictest class — also the fail-closed value when the class cannot be determined). `trust_class` is display metadata for the UI to badge; it is never accepted as an input. A `provenance` field is reserved for richer agent-result attribution and is currently always `null`. The step `output` object contains only handler output -- platform-internal metadata is not exposed. An `agent` step occurrence additionally carries an `agent_turn_ref` field: an object `{thread_id, turn_id}` identifying the AI-agent conversation turn the step minted, or `null`. The field is present and non-null only on `agent` step occurrences (every other step type returns `null`); it is the handle the agent commentary-trace reader resolves (`GET /current/workflows/{workflow_id}/steps/{step_occurrence_id}/agent_trace/`). The ids are opaque tokens — never parse them. A human-in-the-loop step occurrence additionally carries an inline actor block so a run view can render who is responsible without a second fetch. A `task`, `todo`, or `confirmation` occurrence carries an `assignee` object `{user_id, name}` — the assigned (or, for `confirmation`, the recipient) user, with `user_id` as a string and `name` as the resolved display name or `null`. An `approval` occurrence instead carries a `reviewers` array — the bound review surface's reviewer roster, each entry with `id`, `surface_id`, `kind` (`member` or `external`), `member_user_id`, `external_email`, `external_name`, `required`, `overall_signer`, `notification_opt_out_per_decision`, `invited_at`, `claimed_at`, and `removed_at`. The `reviewers` block is **members-only**: it (and the reviewer email / name / member id it carries) is returned only to a caller who is a member of the workflow's workspace — a viewer with observe-only access does not receive it. Both blocks are additive: they appear only on the step types above and are omitted otherwise (and `reviewers` is omitted before the review surface is created), so a client that did not previously read them is unaffected. These blocks appear on the step-occurrence reads (`GET /current/workflows/{workflow_id}/state/` -> `active_steps[]` / `recent_steps[]`, and `GET /current/workflows/{workflow_id}/steps/{step_occurrence_id}/`). A template may declare a step of type `subworkflow` whose handler instantiates one or more child workflows and aggregates their outputs into the parent step. Each child is a real workflow runtime row with its own audit chain; the parent step's `output` reflects the aggregated result once every child reaches a terminal state. Cancellation cascades through synchronous children as it does for the rest of the runtime. (The per-workflow agent credit cap that previously partitioned across children at fan-out is disabled pending rework — there is no cap budgeting across subworkflow children in this release.) Wait-style steps (`wait_for_files`, `inbound_event`, `wait_for_signing`) resume in two ways: the runtime registers a real-time event subscription that wakes the step as soon as the awaited event lands, and a periodic timeout poller is the fallback. All three families have the realtime wake path wired end-to-end as of v3.2 — `wait_for_files` was wired in v3.1, and `inbound_event` + `wait_for_signing` were wired in v3.2 by the inbound payload consumer (realtime + periodic-sweep backstop). Plan around realtime completion latency, with the timeout poller as the worst-case fallback. When a `wait_for_signing` step starts, an obligation is written so the configured in-workspace signing tracker is visible in the assignee's inbox. A `wait_for_files` step supports four file-provision channels that feed the same completion check and may be combined freely: 1. **Folder watch (`watch_scope`)** — opt-in automatic matching for new files landing in a location. Include `"watch_scope": {"kind": "folder", "id": ""}` in the step definition to monitor a specific folder (fully recursive), or `"watch_scope": {"kind": "workspace"}` to monitor the workspace root's direct children. **When `watch_scope` is absent or empty, the step operates in ad-hoc mode: it watches no folder and is satisfied only by files explicitly provided via `step_seeds` or the during-run submit endpoint.** V1 limitation: `kind: "workspace"` only matches files added directly at the workspace root level — files inside subfolders are not matched; use `kind: "folder"` to monitor a subfolder fully. `kind: "share"` is not supported in v1. 2. **Trigger auto-seed** — when a run is started by a `workspace_storage_file_added` event trigger, the file that fired the trigger is automatically counted toward the run's first (entry) `wait_for_files` step without any manual submission. 3. **Run-start `step_seeds`** — `POST /current/workflows/{workflow_id}/instantiate/` accepts an optional `step_seeds` body parameter: a JSON object string mapping **reminted runtime step ids** of the running workflow to arrays of storage-node OpaqueId strings (`{ "": ["", ...] }`), limited to a bounded number of steps and ids. The referenced files are seeded in place at run start. **`step_seeds` keys MUST be the workflow's published definition-revision (reminted) step ids — NOT the gallery template's fixed step ids.** For a gallery-instantiated workflow, obtain the reminted step ids from `system_gallery.step_id_map` in the `from_system` response (maps gallery-fixed→reminted; preferred), or from `GET /workflows/{workflow_id}/state/`; keying by the gallery template's fixed step id results in a silently-dropped seed (not an error). This run-start `step_seeds` key is the workflow's **definition (reminted) step id** — a different id from the **`step_occurrence_id`** used to address the during-run submit endpoint (item 4 below), which is the per-run occurrence id from `/state/`. 4. **During-run submit** — `POST /current/workflows/{workflow_id}/steps/{step_occurrence_id}/files/` pushes additional existing file node ids (body `{ "node_ids": [...] }`) into a `waiting` step at any point during the run. Requires workflow admin. Returns 409 if the step is not `waiting` or is not a `wait_for_files` step; returns 422 if submitting would exceed the per-step submitted-id limit. An ad-hoc `wait_for_files` step (one with no `watch_scope`) may additionally declare `"manual_completion": true` in its step config. In **manual mode** the step accumulates submitted files and holds `waiting` even after the required count is reached — the operator adds files one at a time, then explicitly signals "done" by calling `POST /current/workflows/{workflow_id}/steps/{step_occurrence_id}/complete/` (no body required; workflow admin). The `expected_count` becomes a minimum gate. The minimum is checked by the runtime when the step is dispatched (not from a cached value), and `/complete/` responds from the fresh result: 200 once the step transitions out of `waiting` (completed on match, or `timed_out` — a manual wait past its `timeout_seconds` times out rather than completing on a late Continue), 422 until at least `expected_count` matching files have been provided, and a retryable 409 when the continue was recorded but has not yet applied (common right after a `files/` submit — simply retry). Submitting files to a manual-mode step never auto-completes it; it stays `waiting` even above the minimum until `/complete/` is called. Manual completion is AD-HOC ONLY: `manual_completion: true` is REJECTED at template validation when a `watch_scope` is set — a folder-watch step always auto-advances on count and cannot be combined with manual completion. The step's output carries three additional fields on every emission while in manual mode: `manual_completion` (boolean, always `true` while in manual mode), `required_count` (the minimum), and `matched_count` (the current tally); a client uses these to show progress and gate the "Continue" control. The `/complete/` endpoint returns the updated step occurrence; it also returns 409 when the step is not a manual ad-hoc `wait_for_files` in the `waiting` state. A `waiting` `inbound_event` occurrence in the state read (`GET /current/workflows/{workflow_id}/state/` → `active_steps[]`) additionally carries `inbound_step_url` — the full, environment-correct receiver URL (`https://api./current/inbound/workflow/{workflow_id}/step/{url_token}/`) the external system must POST to in order to advance that step. The field appears only on `waiting` `inbound_event` occurrences. It is addressing information only — posts against it must carry a valid per-workflow-inbound-key HMAC signature, so displaying the URL grants nothing by itself. Two step types put a person in the loop directly. A **`task`** step is a *blocking* human gate: the occurrence enters `waiting` when the run reaches it and stays there until the assignee completes it, then the run advances. A **`todo`** step is *non-blocking*: the occurrence completes immediately and the run continues, while the assignee keeps a standing checklist item. Both declare a single literal-user assignee via `role_slots` (`{"assignee": [{"kind": "user", "id": ""}]}`; the user must be a workspace member) and write an obligation into that user's inbox. A `task` step takes optional `timeout_seconds` + `on_timeout` (`fail` / `complete_timed_out` / `mark_overdue_and_wait`), plus optional `due_at` (display/SLA only) and `priority` (`0`–`4`); a resolution that lands before the deadline always beats the timeout. **Complete** either step type by resolving its obligation (`POST /current/obligations/{obligation_id}/resolve/`); for a blocking `task` the step then completes and the `resolution_payload` you submitted is surfaced on the step output. **Reassign** a waiting task to a different member without losing the run's place with `POST /current/workflows/{workflow_id}/steps/{step_occurrence_id}/reassign/` (body `new_assignee_user_id`); the caller must be a workflow admin or the task's current assignee, the request is rejected with 409 while a modification proposal is open, and after a successful reassign the previous assignee's pending resolve returns 409. Lifecycle activity entries `task.assigned` / `task.completed` / `task.overdue` / `task.reassigned` are written to the workflow's activity chain. A workflow-linked `task` step additionally exposes a **board-completion path**: marking the task `complete` via `POST /current/tasks/{list_id}/items/{task_id}/status/` (or the bulk-status endpoint) automatically resolves the blocking obligation and advances the suspended workflow step — in addition to the direct `POST /current/obligations/{obligation_id}/resolve/` path. The advance is gated: only the task's **assignee**, a user who has **claimed** the obligation, or a workspace **admin** triggers the workflow advance; other users may still mark the task complete on the board, but that does not advance the workflow. Re-completing an already-complete task is idempotent with respect to the workflow (the obligation resolve is a no-op if already resolved). When the obligation resolve hits a transient fault, `POST .../status/` returns HTTP 500 (`APP_INTERNAL_ERROR`) — the caller should retry the status call; re-completing a task whose obligation is already resolved is safe. **System-managed task and list properties.** The following properties on workflow-linked tasks and task lists are set exclusively by the workflow runtime and are **reserved** — values supplied by a client on task or list create/update are silently ignored: `obligation_id` (links a task to its workflow obligation), `step_occurrence_id` (links a task to its step occurrence), and `workflow_system_list` (marks the task list as system-managed for a workflow run). Do not attempt to set these fields directly; read them to correlate a task with its workflow context. A third human-in-the-loop step type — **`confirmation`** — is a *blocking* confirmation gate: the run pauses (the occurrence enters `waiting`) when it reaches the step and stays there until the named recipient explicitly confirms, then the run advances. Use it when a person must sign off before the run proceeds — for example confirming an AI-derived value or a consequential action before it executes. The step config declares a single literal-user recipient via `role_slots` (the same `{"assignee": [{"kind": "user", "id": ""}]}` shape as `task` / `todo`; the user must be a workspace member), a required `reason` string describing what is being confirmed (surfaced on the recipient's inbox obligation), and an optional boolean `nl_derived` flag (default `false`) marking the confirmation as originating from AI/natural-language processing, which changes how the inbox obligation is categorized. A `confirmation` step has no timeout — it waits indefinitely until it is resolved or the workflow is cancelled, and it cannot be reassigned. **Complete** it by resolving the obligation written to the recipient's inbox (`POST /current/obligations/{obligation_id}/resolve/`); the step then completes with `confirmed: true`, `confirmed_by_user_id`, and any `resolution_payload` you submitted surfaced on the step output (the payload is human input — downstream consequential steps cannot consume it without explicit laundering, the same rule as `task` output). A lifecycle activity entry `confirmation.completed` is written to the workflow's activity chain on completion. A separate step type — `signing.envelope` — drives native (in-platform) electronic signature flows. Where `wait_for_signing` posts to an external signing provider over HTTPS, `signing.envelope` creates a sign envelope inside the platform itself, sends it to the configured recipients, and suspends until the envelope reaches a terminal state. The step input shape is `documents` (list of storage node ids from a prior step output), `recipients` (list of `{email, name, phone_e164, role, routing_order, auth_method}`), `fields` (per-recipient signature field placements with normalized 0..1 coordinates), `expires_in_days` (defaults to 14, capped at 365), `policy` (optional, e.g. `{otp_required, auth_method}`), and `timeout_seconds` (suspend timeout; defaults to 24h, capped at 90d). On the envelope reaching `completed`, the step output carries the signed PDF storage node ids per document, with `_trust_propagation_class` `system_trusted` so downstream consequential steps can consume the signed bytes directly. Terminal-but-failed states (`declined`, `voided`, `expired`, `failed`) surface as step failures with the failure tag in the step's `failure_reason`. The wait-timeout poller is the active resume path while the realtime wake hook from envelope completion to the step is on the roadmap. Example step config (inline): ```json { "type": "signing.envelope", "documents": { "trust_class": "step_output_bound", "step_id": "render_contract", "output_path": "signed_pdf_node_ids" }, "recipients": { "trust_class": "template_bound", "literal": [ {"email": "signer@example.com", "name": "Pat Signer", "role": "signer", "routing_order": 0, "auth_method": "email_otp"} ] }, "fields": { "trust_class": "template_bound", "literal": [ {"recipient_email": "signer@example.com", "document_index": 0, "page": 1, "x_norm": 0.10, "y_norm": 0.80, "w_norm": 0.20, "h_norm": 0.05, "field_type": "signature", "required": true} ] }, "expires_in_days": 14, "policy": {"otp_required": true, "auth_method": "email_otp"}, "timeout_seconds": 86400 } ``` **Template-driven variant.** A `signing.envelope` step may instead reference a workspace signing template by id (`sa…` OpaqueId). The template captures the recipient slots, document slots, field placements, and policy; the step config supplies only the concrete bindings for each slot. The three template config keys — `sign_template_id`, `doc_source`, and `recipient_bindings` — are **not patchable mid-run** (they are on the step's PatchPolicy denylist). ```json { "type": "signing.envelope", "sign_template_id": "sa...", "doc_source": "pinned", "recipient_bindings": { "signer_1": { "email": "{{inputs.consultant_email}}", "display_name": "{{inputs.consultant_name}}" }, "cc_1": { "email": "hr@example.com", "display_name": "HR Team" } }, "timeout_seconds": 604800 } ``` | Template config field | Type | Notes | |-----------------------|------|-------| | `sign_template_id` | string | The `sa…` OpaqueId of the template to use. | | `doc_source` | string | `"pinned"` — use the documents pinned in the template's document slots. `"step_output"` is not yet supported and causes a hard failure. | | `recipient_bindings` | object | A **map** keyed by `slot_key` string. Each value carries `email`, optional `display_name`, optional `auth_method`. A list/array is rejected with `recipient_bindings_invalid`. All slots must be bound (or the template slot must have a `default_email`). | At dispatch the runtime resolves the template, applies `recipient_bindings`, resolves the document slots (from `source_node_id` / `source_version_id` in the template's document slots when `doc_source=pinned`), runs a geometry check, and then sends the envelope. **Geometry check at dispatch is a hard gate:** if the resolved documents' dimensions do not match the field geometry stored in the template, the draft envelope is voided and the step fails with `ERROR_TEMPLATE_GEOMETRY_MISMATCH` — no partial envelope is left in place. (The standalone `/instantiate/` endpoint uses a soft advisory instead: it returns the draft with `geometry_mismatch: true` and lets the caller decide.) The step output audit block carries `sign_template_id`, `sign_template_version`, and `sign_template_snapshot_hash` for provenance tracing. A step type — **`webhook.fire`** — makes a single outbound HTTPS POST as part of the flow. This is **distinct** from the outbound status-webhook subscriptions (see *Outbound Delivery Protocol*): a `webhook.fire` step is authored into the workflow, the author controls the body and headers, and it does **not** retry on its own. The step is *blocking* — the run waits for the HTTP response (or timeout) before advancing. Author config: `url` (required, template-bound literal; must start with `https://`), `body` (required, template-bound literal string or JSON value; encoded body capped at 1 MiB), `method` (optional — `POST` (default), `PUT`, or `PATCH`), `headers` (optional, template-bound literal string→string map; names must be safe header tokens — hop-by-hop / `host` / `content-length` and any `x-webhook-*` name are reserved and cannot be overridden), `secret_ref` (optional HMAC-SHA256 key — when set the platform signs the body and sends `X-Webhook-Signature: `, otherwise no signature header is sent), and `timeout_seconds` (optional, default 30, capped at 120). The platform stamps headers the author cannot override: `Content-Type: application/json`, `X-Webhook-Step-Id`, `X-Webhook-Workflow-Id`, `User-Agent: Fast.io-Workflow-Webhook/1.0`, and (when `secret_ref` is set) `X-Webhook-Signature`; a non-POST `method` is conveyed via an `X-Webhook-Method` header. The step completes on a `2xx` and fails on `4xx` / `5xx` / timeout / transport error; there is no built-in retry — to retry, route the failed edge through a `wait` step back into the step. Note the two webhook mechanisms send different `User-Agent` values: the status-webhook subscriptions send `Fast.io-Webhook/1.0`; the `webhook.fire` step sends `Fast.io-Workflow-Webhook/1.0`. A step type — **`transform`** — is a pure, in-process data transform: no I/O, deterministic, completes in one pass (blocking but immediate). Its shape is `{"type": "transform", "inputs": {"": { }}, "config": {"handler": "", "handler_config": { }}}`. `config.handler` (required) is one of `field_extract`, `policy_check`, `aggregate`, `template_fill`, `format_convert`; `config.handler_config` (optional) carries handler-specific options; `inputs` are named bindings the handler consumes (literal, prior step output, policy, or event-sourced values). The handlers: `field_extract` pulls one primitive leaf from a nested value at a dotted `path`; `policy_check` compares a workspace policy value to a literal and returns a boolean; `aggregate` runs `count` / `sum` / `min` / `max` / `any` / `all` / `none` over a collection with an optional predicate; `template_fill` substitutes `{arg}` placeholders in a template string; `format_convert` converts a value to `json`, `csv`, or `plain`. The step completes with a computed `value` and fails on invalid config / inputs. A family of four step types — **`share.create`**, **`share.delete`**, **`file.copy`**, and **`file.delete`** — perform real storage side effects as part of the flow. All four are consequential sinks (their inbound gating must satisfy the gate-independence and NL co-gate rules above), and all four are non-blocking (the step performs its side effect and the run advances). Each carries a binding/provenance rule, and the two delete steps are provenance-guarded — a delete step can only ever act on a resource **this run created**, proven against an unforgeable run-provenance stamp rather than by trusting the bound id. - **`share.create`** — provisions a share. Required: `share_mode`, one of `file_share` / `portal` / `workspace_folder`; `file_share` mode additionally requires `bound_node_id` (the file/note node to expose) and `workspace_folder` mode additionally requires `folder_node_id` (the folder node to expose). Optional, every value a `template_bound` literal: `share_type` (`send` / `receive` / `exchange`), `access_option`, `name`, `title`, `description`, `expiry_seconds`, `comments_enabled`. The step writes an unforgeable run-provenance stamp onto the share it creates, so a later `share.delete` in the same run can prove ownership of the target. - **`share.delete`** — soft-deletes a share. Required: `share_target`, which must be `step_output_bound` to an upstream `share.create` step's output in the same run — never a literal or template-pinned share id. It refuses any share the run did not create (provenance-checked). - **`file.copy`** — copies a workspace file/note. Required: `source_node`, a `template_bound` literal or a `step_output_bound` reference whose upstream output is `system_trusted`. Optional: `destination_folder`, which is `template_bound` **only** (an agent cannot redirect the destination at runtime) and defaults to the workspace storage root. In v1 the source and destination must live in the same workspace storage instance. The step writes a run-provenance stamp onto the new copy, so a later `file.delete` in the same run can target it. - **`file.delete`** — soft-deletes a workspace file/note. Required: `target_node`, accepted at save time by shape (a `template_bound` / literal id or a `step_output_bound` reference). At run time it refuses any file the run did not create via its own `file.copy` (provenance-checked) — a valid-looking `target_node` is not necessarily a deletable one. File and note nodes only; folders are refused. ### Natural-Language Gates & Verdicts Two complementary surfaces let a workflow route on AI judgment while keeping every consequential action deterministically gated: **natural-language edge guards** (the platform evaluates a plain-language predicate over upstream step outputs and allows or denies the edge) and **structured agent verdicts** (an `agent` step returns a schema-validated object that DSL guards branch on deterministically). #### Natural-Language Edge Guards An edge's `guards[]` may carry a natural-language guard (`kind: 2`) alongside ordinary DSL guards (`kind: 1`): ```json { "kind": 2, "dsl_expression": "The flagged content contains real personal data rather than placeholder or test data.", "dsl_version": 1, "evidence_bindings": [], "max_deferral_seconds": 3600 } ``` - `dsl_expression` -- REQUIRED. For a `kind: 2` guard this field carries the natural-language predicate prompt (not a DSL expression). A blank or whitespace-only prompt is rejected at template save. - `evidence_bindings` -- REQUIRED. A JSON list of step ids whose completed outputs join the evidence the predicate is evaluated against. The edge's source-step output is always included automatically, so `[]` is a legal explicit declaration ("judge on the gate's own source output"). Every entry must reference a step that exists and is strictly upstream of the edge's `from_step_id`; duplicate, unknown, `_`-prefixed, and over-cap entries are rejected at save (at most 16 bindings per guard). On a fan-in source step, a declared binding whose step has not completed yet resolves as *unresolved* at evaluation time: the evaluation still runs and is told which declared bindings were absent. Authors who need a binding present every time should bind only steps every path passes through. - `max_deferral_seconds` -- OPTIONAL positive integer bounding how long the platform may defer the evaluation before the gate fails closed. Defaults to, and is clamped to, the platform ceiling of 4 hours; the most-restrictive value across an edge's NL guards wins. **Evaluation semantics.** Guards on an edge evaluate DSL-first: the natural-language predicate is consulted only after every DSL guard on the same edge has passed. The evaluation itself resolves in one of two ways: - **Repeat evaluations short-circuit.** The same predicate over unchanged evidence reuses the prior decision without a new AI call, and the edge decision applies inline. - **Deferred evaluation.** Otherwise the edge's target step occurrence is created in the `blocked` state while the evaluation is scheduled, and the rest of the run proceeds (a parked gate blocks only its own branch). When the verdict lands the gate resumes in near-realtime; a periodic reconciliation sweep is the fallback. Plan around realtime resume latency with the periodic sweep as the worst-case fallback -- the same posture as wait-style steps. **Fail-closed contract.** A natural-language gate whose verdict cannot be obtained **denies the edge**. Whenever the platform cannot evaluate the predicate -- the deferral deadline lapses, evaluation capacity is exhausted past the deadline, or evaluation fails terminally -- the blocked target occurrence is `skipped` with `failure_reason.code` `nl_gate_unevaluable`. A gate the evaluator denies skips the target with `failure_reason.code` `nl_gate_denied`. In both cases the denial prunes that branch exactly like a false DSL guard -- nothing downstream of the denied edge fans out, and the rest of the run is unaffected. There is no fail-open path: an unevaluable gate never lets the edge through. **Evidence handling & retention.** Excerpts of the gate's source-step output and the declared `evidence_bindings` outputs are embedded into the evaluation prompt sent to the AI evaluator, and that prompt is retained in the platform's AI transaction records under the same retention as other platform AI features -- do not route secrets or credentials through the outputs of steps feeding a natural-language gate. The embedded evidence block is truncated at 64 KiB. Evidence is framed to the evaluator as untrusted data: instructions embedded in step outputs are not followed. Evaluation lifecycle events land on the workflow's audit chain (`nl_eval.admitted` / `nl_eval.rejected` / `nl_eval.decided` / `nl_eval.max_deferral_exceeded`) carrying content hashes only -- never prompt or evidence bytes. Evaluations spend AI credits attributed to the workspace. #### Structured Agent Verdicts An `agent` step's free output is prose -- not something a guard can branch on deterministically. The step config may therefore declare a structured-verdict pair: ```json { "type": "agent", "prompt": "Check the document for PII. Answer via the verdict schema.", "verdict_schema": [ {"name": "pii_found", "type": "bool", "can_be_null": false}, {"name": "severity", "type": "string"} ], "verdict_required": true } ``` - `verdict_schema` -- OPTIONAL. A non-empty list of field definitions using the same field vocabulary as the workflow extraction schema (`name`, `type` of `string` / `int` / `float` / `bool` / `datetime` / `url` / `json`, `description`, `can_be_null`, `fixed_list`, `min` / `max`, `regex`, `default`; no nesting). The field count is capped; a map shorthand (`{"pii_found": "bool"}`) is rejected. - `verdict_required` -- OPTIONAL bool. Defaults to `true` when a schema is present. Declaring it without a `verdict_schema` is rejected at template save. The platform appends a deterministic schema-instruction block (field names, types, nullability, bounds) to the step's prompt, so the prompt does not need to enumerate the fields in prose. After the agent answers, the platform extracts and validates the answer against the declared schema with no second AI call: - **Success** -- the validated object lands at `output.verdict` (alongside `verdict_extracted: true`). Only declared fields land: injected extra keys are dropped and non-conforming values are rejected, so prompt-injected prose cannot corrupt the verdict shape. - **Extraction miss with `verdict_required`** -- the step fails with `failure_reason.code` `verdict_extraction_failed`. The failure is terminal (no automatic retry -- a retry would re-run the agent and re-spend credits). - **Extraction miss without `verdict_required`** -- the step completes with `verdict: null` and `verdict_extracted: false`. Schema validation is a **shape guarantee, not a trust upgrade**: the verdict, like all agent output, remains `agent_constrained` -- guards may read it to route, but it can never be bound directly into a trusted sink (email recipients/body, webhook URLs, share invitees, and the like). Steps without a `verdict_schema` are unchanged -- no verdict keys appear in their output. **Branching on a verdict.** DSL guards read verdict fields through the standard output path -- no special syntax: ``` edge pii_check -> reject_path guard: steps.pii_check.output.verdict.pii_found == true edge pii_check -> proceed_path guard: steps.pii_check.output.verdict.pii_found == false ``` #### Verdict-Driven Outcome Patterns The three canonical outcomes a verdict (or natural-language gate) drives are existing step types composed under the standard trust rules: - **Assign a task.** Gate an edge into a `task` (blocking -- the run pauses until the assignee resolves) or `todo` (non-blocking checklist item) step. A single DSL guard on the verdict field suffices. The assignee is always a literal `role_slots` user declared in the template -- a dynamic assignee sourced from a verdict or any step output is rejected at template save (a principal selected by AI-influenced data would be a privilege primitive). Task/todo display text is the step's literal name -- agent prose cannot be injected into the reviewer-facing obligation text. - **Start another workflow.** Gate an edge into a `subworkflow` step (child runtime row, partitioned credit budget, cancellation cascade). `subworkflow` is a consequential sink: its inbound gating must satisfy the gate rules below. - **Send a notification email.** Gate an edge into an `email.send` step. Recipients, sender, and template id (`to` / `cc` / `bcc` / `from` / `template`) must be template-bound literals. `subject`, `body`, and `template_data` may instead bind step output -- but only from a `system_trusted` upstream. An `agent` step's output is `agent_constrained`, so a raw agent-output binding into any `email.send` argument is rejected at template save; to put dynamic content into a `body`, launder it through a sanitizing `field_extract` + `template_fill` transform chain (which yields `system_trusted`) and bind that. (`webhook.fire` arguments remain template-bound literals only -- no step-output binding.) #### Authoring Rules (Rejected at Template Save) Template validation rejects, with a `validation_report`, any of: - A natural-language guard with a blank predicate, a missing or malformed `evidence_bindings` declaration, or a non-positive `max_deferral_seconds`. - An `evidence_bindings` entry that is unknown, duplicated, `_`-prefixed, not strictly upstream of the edge's source step, or over the per-guard cap. - A consequential step (`email.send`, `webhook.fire`, `share.create`, `share.delete`, `file.copy`, `file.delete`, `chain`, `subworkflow`, `terminal`) gated **only** by natural-language guards. - An edge into a consequential step that carries a natural-language guard without a deterministic co-gate on that same edge (a DSL guard, or a self-gating / wait-like source step) -- an NL-only edge cannot be a side door around a deterministically gated sink. - Gate-independence shortfalls: natural-language guards never count toward gate independence, since an NL verdict can only narrow an edge that deterministic sources already authorize. One DSL guard plus any number of NL guards fails the independence requirement exactly like one DSL guard alone. - A `verdict_schema` that is not a valid field-definition list (unknown type, duplicate names, over-cap field count, map shorthand), or a `verdict_required` declared without a schema. - Agent output -- verdict fields included -- bound directly into a trusted sink argument, or a task/todo assignee sourced from any step output. ### Trigger Kinds | Kind | Description | |------|-------------| | `manual` | Fired explicitly via the `instantiate` endpoint | | `schedule` | Cron-style scheduled firing (subject to rate / concurrency caps) | | `event` | Fired by a workspace event matching the trigger's `event_match` filter | | `inbound_webhook` | Fired by a signed inbound webhook hitting the trigger's path | | `ai_driven` | Fired by an AI agent flow (when integrated) | The kind is the top-level `type` field of the trigger's `event_match` JSON document, and trigger reads echo it as `trigger_kind`. On create, a declared `type` must be one of the kinds above (anything else is rejected as invalid input); an omitted `type` is derived from the document's structure — an `event_filter` object implies `event`, a `cron` string implies `schedule` — and the derived value is persisted, so stored triggers always carry an explicit, routable kind. A document that neither declares nor implies a kind is rejected. (Triggers created before this normalization that lack a `type` read back with the same derived `trigger_kind`, but they should be recreated — the runtime dispatcher routes on the stored `type`.) Create applies two more guards so a trigger can never be saved in a state where it would silently never fire. An `event`-kind `event_match` whose `event_filter.event` names something that is not a recognized platform event is rejected as invalid input (a typo'd or made-up event name cannot be saved). And the trigger's target template is checked at create: a `target_template_id` whose template revision is withdrawn / deprecated, or whose underlying workflow has been deleted, is rejected (so a trigger cannot point at a retired template). Separately, deleting or archiving a template's workflow disables every trigger in the workspace that targets it. A trigger has a target template + version, an event match expression, a parameter mapping (extracts inputs from the event), an optional rate limit (`rate_limit_per_hour`), a concurrency cap (`concurrency_cap`), a dedup scope (`trigger_local` / `template_per_workspace` / `event_source_per_workspace`), a dedup window, and an idempotency key template. Trigger fire denials return 409 (or 403 for `permission_denied`) with one of `trigger_disabled` / `dedup_hit` / `rate_limited` / `concurrency_cap_hit` / `ancestry_guard` / `oscillation_detected` / `permission_denied` in the `reason` field (carried inside the error envelope's `params` object — `response.error.params.reason`). An `inbound_webhook`-kind trigger row additionally serializes `inbound_url` — the full, environment-correct receiver URL on the public API origin (`https://api./current/inbound/workspace/{workspace_id}/trigger/{trigger_id}/`). It is addressing information only: POSTs against it must still carry a valid workspace-inbound-key HMAC signature, so displaying the URL grants nothing by itself. The field is present only on `inbound_webhook` rows. ### Storage-Event Scoping (`scope` / `file_pattern` / `user_id`) An `event`-kind trigger that subscribes to a storage event (`workspace_storage_file_added`, the file `*_updated` / `*_moved` / `*_copied` variants, `workspace_storage_folder_created`, and the `share_storage_*` mirrors) can narrow the match two additional ways inside `event_filter` — `scope` and `file_pattern`: ```json { "type": "event", "event_filter": { "event": "workspace_storage_file_added", "scope": { "kind": "folder", "id": "{storage_node_opaque_id}" }, "file_pattern": "*.pdf" } } ``` A third narrowing key, `user_id`, applies to the mention events instead (see below). `scope` / `file_pattern` and `user_id` are never combined on one filter — they target different event families. - **`scope`** — `{"kind": ..., "id": ...}` restricts the match by location: - `workspace` (the default when `scope` is omitted) — the whole workspace. An optional `id`, if present, must equal the trigger's own workspace id. - `share` — `id` is **required** and must be a share id (returned as a JSON string); matches only that share's storage events. - `folder` — `id` is **required** and must be a storage-node opaque id; matches files whose at-upload ancestor chain (or immediate parent) contains that folder. - `portal` is **not** supported and is rejected at trigger save. - **`file_pattern`** — a glob over the file's basename only: `*` and `?` wildcards, matched case-insensitively, at most 128 chars, no `/` (other glob metacharacters such as `[ ] { }` are rejected). Use `scope.kind="folder"` for directory scoping, not a path glob. - **`user_id`** — an integer user id, valid **only** on the three mention events (`comment_mentioned`, `comment_file_mentioned`, `comment_folder_mentioned`); matches the mentioned user. It is a mention-event narrowing key, not a storage-event one — naming `user_id` alongside a storage event (or any non-mention event) is rejected at save. `scope` and `file_pattern` are accepted only alongside a subscribed storage event; `user_id` only alongside a mention event. Validation is **fail-closed at save**: any unknown `event_filter` key — including the old, never-enforced `folder_path` — is rejected, as is an out-of-place `scope` / `file_pattern` / `user_id` or an unsupported `scope.kind`. **Frozen-at-emit semantics.** A file's folder ancestry and filename are captured when the storage event is emitted, not when the trigger evaluates. A file moved or renamed after upload still matches its at-upload state; moving a folder does not retro-fire for files already inside it. Storage events persisted **before** this feature shipped carry no captured ancestry/name and therefore never match a folder `scope` or a `file_pattern` (including in dry-run backtest windows) — the constraint fails closed rather than matching everything. ### Comment Events as Workflow Triggers An `event` trigger can subscribe to comment activity. The platform routes the comment event family (`comment.created`, `comment.mentioned`, and the file/folder variants) through the standard event pipeline; a trigger that filters on `category=comment` (and optionally a specific `event_type`) fires when a matching comment lands. A workflow is a Profile in its own right, so a comment body that mentions the workflow with the standard `@[profile:{workflow_id}]` mention syntax is matched by an event filter on the workflow id. Workspaces also carry an optional **verb→template alias map** at the public field `workflow_trigger_aliases` on the workspace resource (PATCH on the workspace endpoint). It is a JSON object whose keys are short verbs (e.g. `redact`, `summarize`) and whose values name a template to instantiate. A comment body that begins with an alias verb (typically as `@redact` or similar) resolves through this map; non-matching comments do not fire. The map is workspace-scoped. **Run-as model.** A comment-fired workflow runs under a creator-pinned principal — at trigger setup time the configuring admin chooses the effective `runs_as_user_id`; at fire time the commenter's permissions are independently checked against the workspace and the trigger's `permission_required` gate. The instantiated runtime row carries the resolved fire parameters in `run_params_json`. A commenter without the required workspace permission does not fire the workflow. **Trust handling.** Comment body text is attacker-influenceable. The trust lattice keeps the body classified as `agent_constrained` — it can be passed into a step input slot annotated `agent_constrained`, but the platform refuses to bind comment-derived text into a consequential argument slot annotated `template_bound`. The same rule applies to every external-event-derived value. ### Audit Chain Every state change emits an audit envelope: ```json { "event_id": "{opaque_id}", "event_seq": 1, "event_type": "workflow.created", "workflow_id": "{19_digit_id}", "occurred_at": "2026-05-14 13:00:00 UTC", "committed_at": "2026-05-14 13:00:00 UTC", "prior_content_hash": null, "payload_schema_version": "v1", "actor": {"kind": "user", "id": "{user_id}"}, "subject": {"kind": "workflow", "id": "{workflow_id}"}, "ancestry_path": [], "content_hash": "{hex_sha256}", "signature_version": 1, "envelope_format_version": 2 } ``` `event_seq` is a monotonic per-workflow sequence. `content_hash` is SHA-256 of the canonical envelope bytes; `prior_content_hash` is the hash of the immediately preceding event (null for `event_seq=1`). The full chain is signed by a per-workspace HMAC audit key. `envelope_format_version` is the canonical envelope format the row was committed under: `1` is the legacy workflow-only envelope; `2` is the current generic-subject envelope (any chain-able profile type). Older rows written before format versioning landed return `null` and are accepted under format-1 semantics. The HMAC signature bytes are never returned by the event-log endpoint -- use the signed-export endpoint to obtain verifiable signed material. **Document events.** When a native-review step's surface mints its document roster, the platform appends one `file.attached` event per document to the workflow's chain (the first member of the `file.uploaded` / `file.attached` / `file.delivered` family). The payload carries `node_id`, `version_id`, and `step_occurrence_id` (hyphenated display ids, matching the occurrence ids the state read returns), plus `file_name`, `mime_type`, and `size_bytes` when known; the event `subject` is the review surface. A client can derive a per-step file list for a run by filtering the audit log for the `file.*` family (`GET /workflows/{workflow_id}/audit/events/?include_payload=true` -- the payload is omitted without the flag). These payloads are visible to anyone with runtime OBSERVE standing on the workflow -- including direct workflow grantees (`viewer` / `participant`) who are not workspace members -- consistent with step outputs (e.g. a `wait_for_files` step's `matched_files`), which expose the same document metadata to the same audience. The document bytes remain gated by storage permissions. `payload.definition_revision_id` is **polymorphic across emit sites** and should not be interpreted literally. The string value is one of three shapes depending on which call site emitted the row: a runtime DefinitionRevision OpaqueId (step-transitioned and step-failed events), a `target_template_id` (bare or `:vN`-versioned literal; trigger fire / AI-driven selection events), or a workflow profile id (NL-evaluation and inbound-receiver events). To disambiguate, consumers should read the additive `payload.subject_kind` discriminator — values are `"definition_revision"`, `"template_id_vN"`, or `"workflow_profile"`. Older rows emitted before the discriminator was introduced do not carry `subject_kind`; downstream tooling that interprets `definition_revision_id` literally MUST be reviewed against the polymorphism. ### Audit Signing Enablement (one-way opt-in) Signed audit export requires the workflow's parent workspace to have audit signing turned on. A workflow pins its parent workspace's active signing key **version** at creation time, and that pin is immutable for the life of the workflow — so signing must be enabled **before** you create the workflows you intend to export. A workflow created while signing was off stays unsigned and cannot be exported; enabling signing later does not retroactively sign it. - `GET /current/workspace/{workspace_id}/audit/signing/` — read the state: `{"audit_signing": {"enabled": , "key_version": }}`. `key_version` reads `0` when signing has never been enabled. The key bytes are never readable — the workspace key is derived server-side and only ever used to sign/verify audit material. Workspace admin; the workspace must have the workflow feature enabled. - `POST /current/workspace/{workspace_id}/audit/signing/` — opt the workspace into audit signing. Body: `enabled=true` (**required and explicit — there is no default**, because enabling is one-way). This mints the v1 key version and is **idempotent**: re-enabling an already-enabled workspace returns its current active version. There is **no disable** — turning signing off mid-flight would orphan unverifiable audit rows, so `enabled=false` is rejected with `1605 (Invalid Input)` (HTTP 406), as is a missing/other value. Workspace admin. ```bash curl -X POST "https://api.fast.io/current/workspace/{workspace_id}/audit/signing/" \ -H "Authorization: Bearer {api_key}" \ -d 'enabled=true' # -> {"result":true,"audit_signing":{"enabled":true,"key_version":1}} ``` The enable also verifies the active key actually derives before returning success; if the underlying signing substrate is temporarily unavailable the call returns `500` and the enable can be safely retried (idempotent). Requires workspace admin (`1680`/401 otherwise) and the workspace's workflow feature. ### Signed Audit Export Export jobs are asynchronous. Posting to the export endpoint enqueues a job, returns a `job_id`, and emits chunks as the job walks the workflow's chain. When `status=completed`, the export job has a `manifest_signature` (hex HMAC-SHA256 over the JCS-canonicalized manifest) and a fixed `total_chunks`. Download via `GET /current/audit/export_jobs/{job_id}/bundle/{chunk_id}/` with `{chunk_id}=manifest` or an integer in `[0, total_chunks)`. The bundle is independently verifiable using the protocol below. The manifest carries `definition_revision_range` describing which template revisions are referenced by the events in the bundle. The value is one of three shapes: `null` (the workflow has no template-bound events in scope — e.g. a fully manual run), `{revision_id: "{opaque}"}` (every event references exactly one template revision), or `{start_revision_id: "{opaque}", end_revision_id: "{opaque}"}` (the bundle spans more than one revision; ordered by first appearance). The manifest also carries `signing_key_version` (workspace audit-signing key version pinned at bundle-sign time) and optionally `audit_master_version` (platform audit-master version under which that workspace key was derived). When `audit_master_version` is present, the bundle survives a platform audit-master rotation between sign and verify: the verifier re-derives the historical key bytes using BOTH version pointers. When `audit_master_version` is absent, the bundle is from before this guarantee landed and the verifier falls back to the workspace's current active key — practically this only affects pre-release dev/test bundles. #### Verifier Recipe 1. **Manifest signature.** Recompute HMAC-SHA256 over JCS-canonicalized manifest JSON; compare with `manifest_signature` using `hash_equals`. The workspace HMAC key is re-derived from `signing_key_version` (and `audit_master_version` if present) — when present, both version pointers MUST be supplied to the key-derivation primitive so the bundle remains verifiable across platform-side audit-master rotations. Reject on mismatch. 2. **Chunk integrity.** Each chunk hash is in the manifest; recompute and compare per chunk. 3. **Content-hash chain.** Walk events in `event_seq` order; recompute each `content_hash`; verify `prior_content_hash` matches the immediately preceding event. First event has `prior_content_hash: null`. 4. **Overlay pin.** Manifest captures `overlay_manifest_hash_at_start`; check overlay rows reference the pinned manifest hash. Concurrent redactions during export are detected here. 5. **Completeness proof.** When `includes_completeness_proof=true`, verify chunks cover every sequence in `[event_seq_start, event_seq_end]` with no gaps. Reject the bundle on any failure. Do not partially trust. ### Redaction (Dual Control) Tier-3-NDE redaction is dual-controlled: a workspace admin requests, a second workspace admin confirms. **Phase 1 -- request:** ```bash curl -X POST "https://api.fast.io/current/workspace/{workspace_id}/audit/redaction/" \ -H "Authorization: Bearer {api_key}" \ -d 'mode=request&target_event_id={event_id}&target_workflow_id={workflow_id}&redaction_paths=["payload.pii.email"]&reason=GDPR%20art%2017' ``` Returns an `action_id` (32-char hex) and `expires_in_seconds` (900 = 15 min). **Phase 2 -- confirm (different admin):** ```bash curl -X POST "https://api.fast.io/current/workspace/{workspace_id}/audit/redaction/" \ -H "Authorization: Bearer {api_key}" \ -d 'mode=confirm&action_id={action_id}&confirmer_user_id={confirmer_user_id}' ``` - The confirmer must differ from the requester. A 403 is returned on same-user. - `confirmer_user_id` must match the authenticated caller. - The `action_id` expires after 15 minutes and is single-use. - Off-scope `redaction_paths` reject the whole request -- no partial accept. - Legal hold blocks redaction; the gate is re-checked at confirm. - Substitution values are never returned by any read endpoint -- only the path keys. A successful confirm commits one or more overlay rows and emits a `workflow.redaction_committed` audit event. ### Inbound Webhook HMAC Protocol Inbound endpoints accept HMAC-SHA256 signed requests only. No bearer/OAuth/API key fallback. HTTPS is mandatory. **Headers (required on every request):** | Header | Description | |--------|-------------| | `X-Workflow-Signature` | Lowercase hex HMAC-SHA256 of the canonical signing string | | `X-Workflow-Timestamp` | Unix epoch seconds at signing time (decimal) | | `X-Workflow-Nonce` | Single-use nonce, at least 16 characters | **Canonical signing string:** ``` {METHOD}\n{path}\n{sorted_query}\n{sha256(body)_hex}\n{timestamp}\n{nonce} ``` - `METHOD` is uppercase (always `POST` today). - `path` is the URL path -- no scheme, host, or query. - `sorted_query` is RFC-3986 encoded `key=value` pairs joined by `&`, sorted by key then by value within duplicate keys. Empty if no query. - `sha256(body)_hex` is lowercase hex SHA-256 of the raw body bytes. - `timestamp` matches `X-Workflow-Timestamp`; `nonce` matches `X-Workflow-Nonce`. **Replay window:** - Timestamp must be within 5 minutes (300 seconds) of server time in either direction. - Nonce is stored server-side for 10 minutes; replays inside the window are rejected. - The nonce window is strictly greater than the timestamp window so replayers cannot wait for nonce expiry while the timestamp is still valid. Failures return 401. Validation is constant-time. **Per-step URL token:** Computed as the first 16 lowercase hex characters of `HMAC-SHA256(workflow_inbound_key, "step:" || step_occurrence_id || ":" || workflow_id)`. Each step occurrence has a unique unguessable token; rotating the workflow inbound key invalidates every step URL at once. **Key rotation:** Both rotation endpoints return only the new key **version integer**. The HMAC secret bytes are never returned over the wire. Operators retrieve the new signing material out-of-band. The previous version remains accepted until `grace_window_ends` (short). ### Outbound Delivery Protocol Subscriptions sign outbound deliveries with a per-subscription HMAC secret. The signature is the lowercase hex HMAC-SHA256 of the **raw JSON body bytes** keyed by the subscription secret -- there is no canonical string for outbound deliveries; the signature is over the body alone. Headers on outbound deliveries: | Header | Value | |--------|-------| | `Content-Type` | `application/json` | | `X-Webhook-Signature` | Lowercase hex HMAC-SHA256 of the raw request body | | `X-Webhook-Idempotency-Key` | `:` -- use this to dedupe retries | | `X-Webhook-Subscription-Id` | The subscription's id | | `X-Webhook-Event-Type` | The event type (mirrors `event_type` in the body) | | `X-Webhook-Event-Id` | The event id (mirrors `event_id` in the body) | | `X-Webhook-Attempt` | Delivery attempt number, starting at `1` | | `X-Webhook-Depth` | Loop-guard depth; currently always `1` | | `User-Agent` | `Fast.io-Webhook/1.0` | **Secret lifecycle:** Secrets are returned **one time** -- on subscription create and on `rotate_secret`. Reads omit the secret. If lost, rotate. Rotation immediately revokes the previous version. The version transition is audit-logged; the secret bytes are not. **Receiver checklist:** 1. Read the raw request body bytes. 2. Compute `hex(HMAC_SHA256(body, subscription_secret))`. 3. Constant-time compare against `X-Webhook-Signature`; reject on mismatch. 4. Dedupe on `X-Webhook-Idempotency-Key` -- ignore an attempt you have already processed. **Retry, timeout, and dead-lettering:** - **Timeout:** 30 seconds per attempt. - **Attempts:** up to 6, with backoff between attempts of 1 minute -> 5 minutes -> 30 minutes -> 2 hours -> 24 hours (~26.6h total elapsed). After the 6th failed attempt the event is dead-lettered (DLQ) and a workspace-admin alert fires. - **Status-code handling:** - `2xx` -- success, acknowledged, no retry. - `4xx` -- permanent failure; dead-lettered immediately (no retries -- your endpoint rejected the request and that will not change on replay). - `5xx`, timeout, or DNS failure -- retried up to the attempt budget, then DLQ. - `3xx` -- treated as a failed delivery; redirects are **not** followed. - **Max body:** outbound bodies are capped at 1 MiB. **SSRF policy:** `target_url` must be HTTPS. The target host is screened for SSRF at create time and the resolved IPs are re-checked at delivery time; private / reserved / loopback / link-local addresses are rejected. **Subscription delivery controls:** A subscription accepts an optional `description` (human-readable label), a `rate_limit_per_hour` (per-subscription hourly delivery cap; `0`, the default, applies no per-subscription cap; must be a whole number -- a fractional value is rejected with 406), and a `family_allowlist` (JSON array of CDN-family suffixes -- `workers.dev`, `pages.dev`, `vercel.app`, `netlify.app`, `web.app`). At delivery time, a delivery that exceeds the hourly cap is throttled and not retried, and a delivery whose `target_url` host is not under an allowlisted CDN family is screened out. A non-empty `family_allowlist` restricts deliveries to hosts under the listed CDN families only -- so an ordinary non-CDN destination host is screened out as well; an empty `family_allowlist` applies no family restriction. ### Pools (Concurrency) Each workflow can be assigned a `pool_key` at instantiation. The platform tracks in-flight workflows per pool and applies the `max_concurrent` cap to new instantiations. Pools are workspace-scoped; the `pool_key` namespace is unique within a workspace. `pool_source` is one of `tag` / `template_id` / `freeform`. `pool_admission_policy` (one of `reject` / `queue`, default `reject`) decides what happens when an instantiation hits a full pool: `reject` fails it fast with `422`; `queue` parks the workflow at the `queued` status and starts it automatically (oldest first) once a slot frees up. Pools cannot be deleted while they have running or queued workflows. ### External Subjects `external_subject_id` is the integrator's correlation handle -- a Salesforce opportunity id, a Jira ticket key, a customer document id, anything. Opaque to the platform, indexed for fast retrieval. When set at instantiation, the `(workspace_id, external_subject_id)` tuple indexes the workflow; the lookup endpoint returns every workflow that touched the same handle, ordered by `created_at DESC`. ### Extraction Schema and Obligation Enrichment A workflow can declare an **extraction schema** -- a set of field definitions describing what structured data to pull out of the workflow's files. It is the workflow analog of a metadata template's fields. The schema is **append-only**: each write (`POST` / `PUT /workflows/{workflow_id}/extraction_schema/`) creates a new version, and the "current" schema is the most recent one. Reading the schema requires workflow VIEW access; replacing it (or deriving one) requires workflow ADMIN. Each field carries a `name`, a `type` (one of `string`, `int`, `float`, `bool`, `datetime`, `url`, `json` — the same seven types as the structured-verdict schema; see *Structured Agent Verdicts*), and optional `description`, `min` / `max` (numeric bounds or string length), `can_be_null` (default `true`; `false` makes the field required), and `fixed_list` (a bounded allowed set — there is no separate `enum` type). The field count is capped per plan; an over-cap write is rejected. A malformed field set is rejected with an input-invalid error. `POST /extraction_schema/derive/` analyzes a sample of the workflow's files (`node_ids`) with an LLM and **proposes** a domain-tailored field set, returned for the caller to review and edit. It does **not** persist a schema — the caller commits one with a subsequent `POST /extraction_schema/`. Deriving spends AI credits and bills the workflow's owning organization. When a schema is configured and the parent workspace has enabled AI summary enrichment (`nl_summaries_enabled`), the platform extracts the declared fields from each obligation's files and surfaces the validated result on the obligation's `nl_enrichment` object. Replacing the schema with a write that **adds** a field or **changes** a field's type re-opens the enrichment slot on the affected obligations so a fresh extraction can re-land; soft edits (description, bounds, `fixed_list`, nullability) do not trigger a re-extraction. Because the enrichment is LLM-derived, its `trust_class` is always `agent_constrained`. ### Mid-Run Modifications A running workflow can be adjusted in flight without cancelling and re-instantiating it. The flow is a two-step **propose then apply** model so a change is reviewed before it touches the live run, and it is gated behind the `workflow_mid_run_edit` plan capability (enabled on every current plan — see *Plan Capabilities* below). A proposal carries one or more **operations**, each targeting a specific step occurrence: - `skip` — mark a step occurrence skipped so it never runs (valid while the occurrence is `pending`, `waiting`, or `blocked`). - `reassign` — change the assigned user of a human-gated step occurrence such as an approval or signing step (valid while `pending` or `waiting`; the new assignee must be a member of the workflow's workspace). - `patch` — override a conservative allowlist of a step occurrence's configuration values (valid while `pending` or `waiting`). The allowlist is intentionally narrow — only plain scalar knobs such as a wait/webhook `timeout_seconds`; consequential routing, recipient, credential, and binding fields are never patchable. Lifecycle: 1. **Propose** (`POST /modifications/`). The platform validates every operation against the current runtime, computes a before/after diff, opens the proposal, and **automatically pauses the workflow** so no targeted step advances while the proposal is open. At most **one** proposal may be open per workflow at a time — a second propose while one is open returns `409 (Conflict)`. A proposal carries at most 50 operations. Each proposal is created with an expiry; an `expires_in_seconds` body field bounds it. The maximum is **7 days (604800 seconds)**: a larger value is clamped to it, and an absent or non-positive value defaults to it. A proposal that is neither applied nor cancelled before it expires is reaped and the run resumes. 2. **Review** (`GET /modifications/` and `GET /modifications/{modification_id}/`). The list and detail reads return each proposal's status, source, proposer, and — on the detail view — the per-change rows and the computed before/after diff. Both reads require **member-or-above** standing on the workflow (a share-guest is excluded, since the responses carry change payloads, the assignee diff, and proposer/applier ids). 3. **Apply** (`POST /modifications/{modification_id}/apply/`) or **Cancel** (`POST /modifications/{modification_id}/cancel/`). Apply commits the changes to the live runtime, finalizes the proposal, and resumes the run; cancel discards the proposal and resumes the run unchanged. Both require workflow ADMIN. An apply must resolve **every** pending change of the proposal: pass an empty `apply_change_ids` to apply all of them, or list ids covering all currently-pending changes — a partial selection that would leave a pending change unapplied is rejected (already-terminal changes may be omitted, and every id you do list must belong to this proposal). A `skip` that removes a **human gate** (skipping an approval or signing step) is a consequential action: the apply request must carry `confirm_removes_human_gate=true`, otherwise it is rejected with `403 (Access Denied)` and a message instructing the caller to resubmit with the confirmation. This is an explicit guard above the ADMIN authority floor. Each applied change emits a `workflow.modified_mid_run` event (one per applied change) and fans out to any outbound webhook subscription that opted into that event type. This is a platform **event** (the same delivery path as other workflow lifecycle events), not an entry on the workflow's signed, HMAC-chained audit log — it does **not** appear in `GET /workflows/{workflow_id}/audit/events/` and is **not** part of the signed export bundle; subscribe to the outbound webhook (or consume the event) to observe it. Applying also pushes realtime invalidation nudges on the workflow's WebSocket channel (see *Realtime Channel*), and once the apply resumes the run a final bare `runtime` nudge fires so a client refetches the settled (resumed) state — important for a patch/reassign-only apply that has no step fan-out. These notifications are **best-effort**: they fire after the change has committed, and a delivery failure is logged but does not roll back the applied change — do not treat them as guaranteed-once delivery. A proposal records its **source** — `human` (the default) or `agent`. An agent-sourced proposal is one an AI agent proposed on the caller's behalf; it additionally requires the workspace's AI-agent capability, and the agent's conversation turn is verified server-side to belong to the workflow's workspace before the proposal is accepted. The proposer of record is always the authenticated human user, never the agent — an agent can only **propose**; **applying is always a human action** (see *Agent Capabilities* in the Agents reference). ### Agent Commentary Trace An `agent` step occurrence's narration transcript can be read back through `GET /current/workflows/{workflow_id}/steps/{step_occurrence_id}/agent_trace/`. The response is `{"agent_trace": {"commentary", "available", "step_occurrence_id", "workflow_id"}}`, where `commentary` is the agent's interim narration transcript (populated when the agent narrates its work, e.g. around tool calls). `available` is `true` only when `commentary` is non-empty. The reader returns the commentary **only** — never the step's answer, references, or citations. Access requires workflow OBSERVE standing **and** member-or-above on the workflow — its **effective** level, which folds the workspace baseline together with any direct workflow grant, so a non-workspace-member holding a direct workflow member grant also qualifies. A share-guest who can otherwise observe the runtime cannot read the commentary. Reading the trace does not require the mid-run-edit capability and does not widen access to the underlying AI conversation. The endpoint returns `404 (Not Found)` for a non-agent occurrence, an occurrence that belongs to another workflow, or one whose commentary cannot be resolved to this workflow. ### Agent Activity Feed An `agent` step occurrence's action feed — the list of actions the agent took while running — can be read back through `GET /current/workflows/{workflow_id}/steps/{step_occurrence_id}/agent_activity/`. The response is `{"agent_activity": {"actions", "available", "step_occurrence_id", "workflow_id"}}`, where `actions` is an ordered list (ascending by `seq`) of action cards. Each card has the shape `{"seq", "label", "state", "affected_refs", "started_at", "ended_at"}`: `seq` is the execution order; `label` is the human-readable action name; `state` is one of `running`, `done`, `failed`, `cancelled`; `affected_refs` is a list of opaque reference strings the action touched; and `started_at` / `ended_at` are canonical UTC timestamps (`YYYY-MM-DD HH:MM:SS UTC`, empty string when unknown). `available` is `false` (and `actions` empty) when the occurrence stored no readable action feed. The reader returns the action feed **only** — never the step's answer, references, citations, commentary transcript, or any action's internal diagnostic detail. The feed has the same response shape whether the step is running or finished: **while a step runs** it returns the actions the agent has emitted so far (empty until the agent emits its first action), so poll the endpoint to follow a step's progress; **after the step completes** it returns the durable action list. Access requires the same standing as the commentary trace: workflow OBSERVE **and** member-or-above on the workflow (its **effective** level, folding the workspace baseline with any direct workflow grant). A share-guest who can otherwise observe the runtime cannot read the action feed. Reading the feed does not require the mid-run-edit capability and does not widen access to the underlying AI conversation. The endpoint returns `404 (Not Found)` for a non-agent occurrence, an occurrence that belongs to another workflow, or one whose activity cannot be resolved to this workflow. ### Agent Final Answer An `agent` step occurrence's final output can be read back through `GET /current/workflows/{workflow_id}/steps/{step_occurrence_id}/agent_result/`. The response is `{"agent_result": {"answer_markdown", "answer", "references", "citations", "products", "available", "step_occurrence_id", "workflow_id"}}`: `answer_markdown` is the answer prose as markdown (text content only — no inline reference-pill tokens in v1); `answer` is the ordered answer content (text parts and reference pills) for clients that compose inline references; `references` carries out-of-line reference details for the answer pills; `citations` carries source citations backing the answer; and `products` lists objects the turn created or produced. `available` is `false` (and the lists empty, `answer_markdown` empty) until the step completes with a result — unlike the action feed, there is no live in-flight view; a running step (or one whose turn ref is gone) returns `available: false` at 200. Poll until `available` flips `true`. The reader returns the final answer **only** — never the step's commentary transcript or action feed. Access requires the same standing as the commentary trace and action feed: workflow OBSERVE **and** member-or-above on the workflow (its **effective** level, folding the workspace baseline with any direct workflow grant). A share-guest who can otherwise observe the runtime cannot read the final answer. Reading the result does not require the mid-run-edit capability and does not widen access to the underlying AI conversation. The endpoint returns `404 (Not Found)` for a non-agent occurrence, an occurrence that belongs to another workflow, or one whose result cannot be resolved to this workflow. `answer_markdown`, `answer`, and `products` are untrusted, agent-authored content — render `answer_markdown` as untrusted markdown with no expectation of server-side sanitization. `references` and `citations` resolve within the workflow's own workspace. Composing the answer into a note or forwarding it to a new agent is done client-side via existing endpoints. ### Plan Capabilities Some workflow surfaces are gated by the workspace's plan capabilities: - `workflow_mid_run_edit` — enables the *Mid-Run Modifications* family (propose / list / detail / apply / cancel). Enabled on every current plan; every modification endpoint returns `403 (Access Denied)` when the capability is absent. An agent-sourced proposal additionally requires the AI-agent capability. Read the effective value before rendering mid-run-edit affordances: workspace details (`GET /current/workspace/{workspace_id}/details/`) surface it as `capabilities.workflow_mid_run_edit` (boolean), resolved by the same gate the modification endpoints enforce — a client that reads `true` will not then hit the 403. The `capabilities` bundle also carries `workflow_approval_native_tier` (`disabled` | `mvs` | `extended` — the raw native-review rollout tier, alongside the existing derived boolean `workflow_approval_extended`) and `workflow_approval_in_place` (boolean — whether the in-place `approval_in_place` step type and the "Request approval" quick path are available; `true` on the `mvs` and `extended` tiers, `false` on `disabled` — see *Workflow Review*). New workspaces default to `extended`, so these read as `extended` / `true` / `true` out of the box. ### Realtime Channel (WebSocket) The workflow profile exposes a client-facing realtime WebSocket channel so a step-centric UI can be notified of runtime activity instead of polling `GET /workflows/{workflow_id}/state/`. The channel is part of the same WebSocket realtime infrastructure documented in the Events reference (`https://api.fast.io/current/llms/events/`); this section covers only the workflow-specific surface — the connection flow, the activity field set the channel emits, and the reconnect / catch-up story. The channel uses an **invalidation-nudge model**, not a rich event stream. Each frame carries a small `activity` field identifier — the identifier IS the nudge, telling the client which slice of workflow state to refetch. The frame never carries step output, audit-event payloads, or any AI-derived content. Authoritative state lives behind `GET /workflows/{workflow_id}/state/` (and the scoped step / obligation / runtime endpoints); the client refetches whatever endpoint corresponds to the activity field. WebSocket delivery is best-effort and is not durable — on reconnect or a missed frame, refetch state. #### Connect Flow 1. **Mint a token.** `GET /current/websocket/auth/{workflow_id}` — the caller MUST be authorized to observe the workflow's runtime (`observe` standing). For `workspace`-visibility workflows, workspace view is sufficient and a workflow-scoped grant of `viewer` / `participant` / `admin` also satisfies the gate. For `private` / `participants_only` visibilities, the caller needs at least member-level standing: either workspace member-or-above, or a workflow-scoped `participant` / `admin` grant (a `viewer` grant alone is NOT sufficient at these visibilities). A caller with only workspace view (and no participant-or-above standing) on a `private` workflow is denied with `1605 (Invalid Input)`. 2. **Token TTL.** The workflow JWT lifetime is **shorter** than other profile types (the response's `expires_in` carries the effective seconds). Clients refresh the token before it expires by hitting the auth endpoint again. 3. **Connect.** Open a `wss://{host}/api/websocket/?{auth_token}` connection — the JWT is the entire query string (no `token=` key, no other query parameters). This matches the same generic WebSocket endpoint documented in the Events reference. The subscription is implicit — the token's bound `workflow_id` selects the channel. 4. **Receive frames.** Each push is a JSON `activity` frame whose `activity` array contains one of the workflow activity-field identifiers below. #### Workflow Activity Fields | Field identifier | Meaning | Refetch endpoint | |---|---|---| | `details` | Workflow lifecycle changed (`created`, `completed`, `failed`, `cancelled`) | `GET /workflows/{workflow_id}/` or `/workflows/{workflow_id}/state/` | | `steps:{step_occurrence_id}` | A step occurrence transitioned or failed — the prefixed key carries the specific occurrence id for a targeted refresh | `GET /workflows/{workflow_id}/steps/{step_occurrence_id}/` | | `runtime` | The workflow's runtime view should be refetched — currently emitted when a mid-run modification is applied (an apply can skip / reassign / patch occurrences, fan out new occurrences, or shift the spine). Other runtime status nudges (e.g. pause / resume / queued -> running) may begin emitting in a later release | `GET /workflows/{workflow_id}/state/` | | `obligations` | **Reserved.** Obligation lifecycle nudges (`created` / `claimed` / `resolved` / `superseded`) are not emitted in this release; obligation changes are picked up on the next `/state/` refetch or via the inbox endpoints | `GET /obligations/?workflow_id={workflow_id}` (when populated) | Applying a mid-run modification emits **both** a targeted `steps:{step_occurrence_id}` frame (for the modified occurrence) and a bare `runtime` frame, one pair per applied change — refetch the step occurrence and the runtime state in response. The `obligations` activity field is part of the contract and may begin emitting in a later release without a breaking change for clients that already refetch on every received field. #### Frame Shape ```json { "response": "activity", "activity": ["details"] } ``` ```json { "response": "activity", "activity": ["steps:1234567890abcdef0123"] } ``` The frame is intentionally minimal. The workflow channel does NOT emit enriched `event` frames — workflow runtime events are administrator-level, so the only delivery is the bare `activity` identifier. Trust-class badging and the internal-meta-key scrub described under "Step Occurrences" apply to the `GET /workflows/{workflow_id}/state/` (and step occurrence) payloads the client refetches; the WebSocket frame has nothing to badge or scrub because it carries no output content. #### Workspace Dual-Publish Alongside the per-workflow `WORKFLOW-{workflow_id}` channel, every workflow lifecycle / step / review-surface activity is **also** dual-published to the workflow's parent workspace channel `WS-{workspace_id}` under a single `workflows` activity field, carrying the workflow id as the activity object — so a workspace-level dashboard (e.g. a runs list, or an in-place "under review" badge) can live-refresh the one affected row from a single workspace subscription instead of opening a channel per workflow or polling. This mirrors how signing dual-publishes envelope activity under `sign_envelopes` (see the Signing reference, `https://api.fast.io/current/llms/signing/`). The workspace-channel nudge is a **bare existence/invalidation signal only** — it reveals that a workflow had activity, never the step or review-surface detail (that detail stays on the `WORKFLOW-{id}` channel behind the OBSERVE gate, while the workspace channel is reachable with workspace view). React to it by refetching the affected workflow row (e.g. `GET /workflows/{workflow_id}/state/`, or the workspace's active-review list). A workflow that has no resolvable parent workspace publishes to its `WORKFLOW-{id}` channel only — there is no workspace dual-publish in that case, and no share-channel fan-out (a workflow is always workspace-parented). #### Reconnect & Catch-Up WebSocket delivery is **not durable**. If the connection drops or a frame is missed: 1. Reconnect (mint a fresh token if `expires_in` has elapsed). 2. Refetch `GET /workflows/{workflow_id}/state/` for the authoritative runtime snapshot, plus any specific step occurrence the UI was tracking. 3. Resume listening for `activity` frames. There is no replay buffer and no sequence number; the channel exists to reduce polling latency, not to be a transactional event feed. For a transactional, gap-free record of workflow state changes, use the audit-event log (`GET /workflows/{workflow_id}/audit/events/`) — that surface is durable and signed. #### Mid-Session Authorization Changes Workflow standing can change while a connection is open — a grant can be revoked, a workflow's visibility can tighten, or the caller can be removed from the parent workspace. The realtime channel handles this with two mechanisms: - **Token expiry.** When the JWT expires, the daemon closes the connection with a `denied` frame whose `details` indicate `JWT expired`. Mint a new token and reconnect; the OBSERVE gate is re-applied at mint time, so a now-unauthorized caller is rejected at that step. - **Explicit revocation.** When a workflow grant is revoked or the workspace membership is removed via the public API, the daemon closes the affected user's open sessions for that workflow promptly. This covers the most common revocation paths. Some less-common authorization changes are not yet wired to publish an explicit revoke signal in this release: a workflow visibility flip (e.g. `workspace` -> `private`), a workspace role downgrade that drops the caller below the OBSERVE threshold without removing membership, org-level removal cascades, and a workflow grant's `expires_at` lapsing while a token minted just before is still valid (the workflow JWT lifetime is bounded — typically a small number of minutes — so the worst-case window is one TTL after the grant lapses). For those, the connection remains open until the next token expiry — clients refresh tokens periodically, and the mint-time OBSERVE gate denies the refresh. The realtime channel is a courtesy push layer; authoritative state and authorization continue to be enforced at the REST endpoints the client refetches. #### Rate & Connection Limits The token-mint endpoint and the daemon both enforce limits to protect the realtime infrastructure. Specific per-endpoint numbers are not published (per the global rate-limiting policy); on a workflow-auth limit hit the mint endpoint returns `1605 (Invalid Input)` (the same shape as the other WebSocket-auth arms, which surface throttling through the generic input-invalid error rather than the platform's canonical `1671`), and the daemon closes the connection with a `limited` frame. Standard rate-limit response headers (`x-ve-limit-avail`, `x-ve-limit-max`, `x-ve-limit-expires`) apply to the token-mint endpoint as documented in the main API reference. --- ## Workflow Lifecycle ``` draft -> active | cancelled active -> paused | completed | cancelled paused -> active | completed | cancelled archived -> (terminal except via unarchive flow) deleted -> (terminal) ``` `completed`, `cancelled`, `archived`, and `deleted` are terminal. PATCHing `state` outside this DAG is rejected with `1605 (Invalid Input)`, which maps to HTTP 406 (see *Error Responses*). ## Step Occurrence Lifecycle ``` pending initial in_progress, waiting, blocked active (runtime-managed) completed, failed, skipped, cancelled terminal ``` An occurrence starts at `pending`, is advanced through the active states by the runtime, and finishes in a terminal state; terminal states reject further transitions. The exact transition graph is runtime-managed, not a fixed table. Step mutations are CAS-guarded -- a 409 means the row was modified concurrently or the lock could not be acquired; re-read and retry. --- ## Example Workflows ### Instantiating a workflow ```bash curl -X POST "https://api.fast.io/current/workflows/{workflow_id}/instantiate/" \ -H "Authorization: Bearer {api_key}" \ -H "Content-Type: application/x-www-form-urlencoded" \ --data 'idempotency_key=my-job-2026-05-14-001&trigger_payload={"customer":"acme"}&external_subject_id=sub_abc123' ``` `external_subject_id` is optional (1..255 chars). When supplied, the workflow is indexed under the `(workspace_id, external_subject_id)` tuple and is retrievable via the external-subjects lookup endpoint. Response: ```json { "result": true, "workflow_runtime": { "workflow_id": "60001234567890123456", "status": "running", "origin": "manual", "runs_as_user_id": "11223344556677889900", "caller_user_id": "11223344556677889900", "idempotency_key": "my-job-2026-05-14-001", "ancestry_path": [], "last_event_seq": 0, "active_step_occurrence_count": 0, "body_iteration_count": 0, "revision_number": 1, "run_params_json": {"customer": "acme"}, "updated_at": "2026-05-14 13:05:00 UTC", "lifecycle_created_emitted_at": "2026-05-14 13:05:00 UTC" } } ``` `idempotency_key` is required. Replays with the same key return the existing runtime row (409 if a runtime exists with a different key). `run_params_json` carries the resolved input bindings used by step handlers. ### Polling runtime state ```bash curl -X GET "https://api.fast.io/current/workflows/{workflow_id}/state/" \ -H "Authorization: Bearer {api_key}" ``` Response: ```json { "result": true, "template_id": "wfabc-12345-defgh-67890-ijklm-nopqrs", "workflow": { "id": "60001234567890123456", "state": "active", "updated_at": "2026-05-14 13:10:00 UTC" }, "run_summary": { "run_name": "Q3 Creative Review", "status": "running", "current_steps": [ { "step_id": "stabc-12345-defgh-67890-ijklm-nopqrs", "step_name": "Review Submission", "step_type": "agent", "state": "in_progress" } ], "active_step_count": 2, "step_progress": { "completed": 6, "active": 2, "n": 8, "total": 9, "basis": "static_graph" }, "age": { "started_at": "2026-05-14 13:00:00 UTC", "age_seconds": 600 } }, "runtime": { "status": "running", "last_event_seq": 17, "active_step_occurrence_count": 2, "updated_at": "2026-05-14 13:10:00 UTC" }, "active_steps": [ { "id": "wxabc-12345-defgh-67890-ijklm-nopqrs", "step_id": "stabc-12345-defgh-67890-ijklm-nopqrs", "step_name": "Review Submission", "step_type": "agent", "state": "in_progress", "started_at": "2026-05-14 13:09:00 UTC" } ], "recent_steps": [], "pending_steps": [ { "step_id": "styzz-12345-defgh-67890-ijklm-nopqrs", "step_name": "Final Sign-off", "step_type": "approval", "state": "pending" } ], "progress": { "completed_occurrences": 6, "active_occurrences": 2, "known_total_occurrences": 9, "progress_basis": "static_graph" }, "credit_budget": { "cap": null, "spent_so_far": 13380, "remaining": null } } ``` `credit_budget` reports the run's agent credit usage. `spent_so_far` is the authoritative agent spend for the run, expressed in CREDITS -- use this field to display agent spend. `cap` and `remaining` are always `null`: the per-workflow agent credit cap is disabled pending rework, so there is no cap to enforce or remaining budget to compute. Do not rely on a cap being enforced. (The workflow object's `agent_credits_spent_so_far` is a legacy field, effectively always `0`; prefer `credit_budget.spent_so_far`.) `template_id` (top level) is the run's source-template id -- a base32 OpaqueId string set only for a run created from a template via `POST /workspace/{id}/workflows/` with `template_id`; `null` for every other run, including manual, inline-`definition`, gallery `from_system`, trigger-fired, and dynamic subworkflow-child runs (a gallery or trigger run reports `origin_kind: "template"` yet `template_id: null`, since those paths don't stamp `origin_ref.template_id`). It mirrors `workflow.template_id` and the workspace workflows-list per-item `template_id`, so a client that opens a run directly (without a prior list fetch to seed provenance) can resolve the run's template lineage from this one response. `runtime` is `null` if no instantiation has occurred. `active_steps` lists step occurrences whose state is non-terminal at snapshot time. `pending_steps` lists the definition steps the run has NOT yet materialized an occurrence for -- display-ready entries (`step_id`, `step_name`, `step_type`, `state: "pending"`) in definition order -- so a client can render the complete plan (completed + active + pending) without fetching the template. A step appears here only while it has zero occurrences; a conditional branch that may never run still appears (this is a plan view, not a prediction), and for a draft / never-instantiated workflow the full plan is pending. `run_summary` is a compact digest of the run intended for list rows and headers, so a UI can render a run without stitching the other blocks together. It carries the run name, lifecycle `status` (`queued` / `running` / `completed` / `failed` / `cancelled`, or `not_started` when the workflow was never instantiated; a never-run workflow whose profile reached a terminal lifecycle state instead reports that terminal status — `cancelled` / `completed` / `archived`), the `current_steps` (a capped display slice of the non-terminal occurrences, each with its human `step_name`), the authoritative `active_step_count` (which may exceed the slice length), the coarse `step_progress` (`n` = `completed + active` occurrences so far; `total` is the denominator only for a fully static graph and is `null` for loops / dynamic fan-out / gated graphs; any count the service could not obtain is `null`, never fabricated), and the run `age` (`started_at` is when the run began executing, with both `age` fields `null` until then). The identical `run_summary` shape is available per item on the workspace workflows list by passing `?include=run_summary`. Independently, every workflows-list item carries an always-present `has_run` boolean (`true` iff a runtime row exists for the workflow) so a client can route a clicked workflow to its run view rather than the template editor without requesting `?include=run_summary`. The workspace workflows list also accepts `?include=run_meta` for a per-item `run_meta` provenance block (independent of `run_summary`; pass `?include=run_summary,run_meta` for both). It carries `origin` (the fine-grained runtime origin -- `manual` / `schedule` / `event_triggered` / `webhook` / `ai_driven` / `cascade`, distinct from the profile-level `origin_kind`), `trigger_id` (the firing trigger, or `null`), `last_activity_at` (the most recent runtime-row update -- this is NOT a completion timestamp; counter and active-step writes bump it), `created_by` (`{ user_id, name }` for runs a human manually started, or `null` for automation runs), and -- for trigger-fired runs -- `trigger_kind` and `target_template_id` (both `null` otherwise). Like `run_summary`, `run_meta` is enriched only for the returned page and only for runs the caller can observe. Each step occurrence carries a display-ready `step_name` and `step_type` resolved from the workflow definition (no client-side lookup needed). `step_name` falls back to the raw `step_id` and `step_type` is `null` when the bound definition cannot be resolved. `progress` reports coarse progress with an explicit basis. `completed_occurrences` and `active_occurrences` are live counts. `known_total_occurrences` is a true denominator -- the workflow's graph-node count -- and is set only when `progress_basis` is `static_graph` (the definition is a simple linear graph: no loops, no dynamic fan-out, no conditional edges, and no step reached by more than one path, so each node runs exactly once). When the definition contains a loop body, a dynamic fan-out step, a conditional edge, or a step that multiple branches converge on, the occurrence total is not knowable ahead of time: `progress_basis` is `occurrences_only` and `known_total_occurrences` is `null`. A count value may also be `null` if the service could not compute it; treat any `null` field as "not available". Never compute a percentage when `known_total_occurrences` is `null` -- render the running counts instead. ### Submitting a step output ```bash curl -X POST "https://api.fast.io/current/workflows/{workflow_id}/steps/{step_occurrence_id}/output/" \ -H "Authorization: Bearer {api_key}" \ -d 'output={"decision":"approved","reviewer":"33445566778899001122"}' ``` Response: ```json { "result": true, "step_occurrence": { "id": "wxabc-12345-defgh-67890-ijklm-nopqrs", "state": "completed", "output": {"decision": "approved", "reviewer": "33445566778899001122"}, "trust_class": "system_trusted", "provenance": null, "ended_at": "2026-05-14 13:35:00 UTC" } } ``` The registered step handler is the single dispatch path that persists the output and decides the next state. ### Proposing and applying a mid-run modification ```bash # Propose skipping one step occurrence on a running workflow. # This auto-pauses the run and returns the proposal + its before/after diff. curl -X POST "https://api.fast.io/current/workflows/{workflow_id}/modifications/" \ -H "Authorization: Bearer {api_key}" \ -d 'ops=[{"op":"skip","target_step_occurrence_id":"wxabc-12345-defgh-67890-ijklm-nopqrs"}]' \ -d 'reason=Customer withdrew the review request' # Apply the proposal (empty apply_change_ids = all of its changes), then resume. # If any change removes a human gate (an approval/signing step), add # confirm_removes_human_gate=true. curl -X POST "https://api.fast.io/current/workflows/{workflow_id}/modifications/{modification_id}/apply/" \ -H "Authorization: Bearer {api_key}" \ -d 'confirm_removes_human_gate=true' # Or discard the proposal and resume the run unchanged. curl -X POST "https://api.fast.io/current/workflows/{workflow_id}/modifications/{modification_id}/cancel/" \ -H "Authorization: Bearer {api_key}" ``` The propose response carries the opened proposal with its `status`, `source`, per-change rows, and the computed `diff`. The apply response carries an `outcome` block (`overall_status`, `applied_count`, `failed_count`, `deferred_count`, and per-change results) alongside the finalized proposal. Each applied change emits a `workflow.modified_mid_run` event (delivered to outbound webhook subscribers; not an entry on the signed audit log) and a realtime nudge on the workflow channel; the run resuming after the apply emits one more `runtime` nudge. ### Claiming, releasing, and resolving an obligation ```bash # Claim a role-addressed obligation (atomic; 409 if another user claims first) curl -X POST "https://api.fast.io/current/obligations/{obligation_id}/claim/" \ -H "Authorization: Bearer {api_key}" # Release a claimed obligation back into the pool (claimer-only; the # inverse of claim — claimed -> open, or claimed -> partially_claimed # for a multi-claim obligation; 409 if the caller is not the claimer) curl -X POST "https://api.fast.io/current/obligations/{obligation_id}/release/" \ -H "Authorization: Bearer {api_key}" # Resolve with optional payload (attached to the audit envelope, not the obligation row) curl -X POST "https://api.fast.io/current/obligations/{obligation_id}/resolve/" \ -H "Authorization: Bearer {api_key}" \ -d 'resolution_payload={"decision":"approved","comment":"Looks good"}' ``` Resolve response: ```json { "result": true, "obligation": { "id": "100200300", "status": "resolved", "resolved_at": "2026-05-14 11:00:00 UTC" } } ``` ### Initiating a redaction (request + confirm) ```bash # Phase 1 -- requester admin curl -X POST "https://api.fast.io/current/workspace/{workspace_id}/audit/redaction/" \ -H "Authorization: Bearer {requester_api_key}" \ -d 'mode=request&target_event_id={event_id}&target_workflow_id={workflow_id}&redaction_paths=["payload.pii.email"]&reason=GDPR%20art%2017' # -> HTTP 202: {"result":true,"action_id":"{32_hex}","expires_in_seconds":900,"status":"pending_dual_confirm"} # Phase 2 -- different admin curl -X POST "https://api.fast.io/current/workspace/{workspace_id}/audit/redaction/" \ -H "Authorization: Bearer {confirmer_api_key}" \ -d 'mode=confirm&action_id={action_id}&confirmer_user_id={confirmer_user_id}' # -> HTTP 202: {"result":true,"redaction_id":"{opaque}","overlay_count":N,"status":"committed_stub","carry_forward":false,"committed_at":"2026-05-14 16:00:00 UTC"} ``` ### Verifying an audit export bundle ```bash # 1. Start the export job curl -X POST "https://api.fast.io/current/workflows/{workflow_id}/audit/export/" \ -H "Authorization: Bearer {api_key}" \ -d "scope=full&include_overlays=true&redaction_pin_strategy=job_start" # -> {"result":true,"job_id":"{job_id}","status":"queued","created_at":"2026-05-14 15:00:00 UTC"} # 2. Poll until status=completed curl -X GET "https://api.fast.io/current/audit/export_jobs/{job_id}/" \ -H "Authorization: Bearer {api_key}" # When ready: total_chunks, manifest_signature populated. # 3. Download manifest + each chunk curl -X GET "https://api.fast.io/current/audit/export_jobs/{job_id}/bundle/manifest/" \ -H "Authorization: Bearer {api_key}" \ --output manifest.json curl -X GET "https://api.fast.io/current/audit/export_jobs/{job_id}/bundle/0/" \ -H "Authorization: Bearer {api_key}" \ --output chunk_0000.jsonl # 4. Run the verifier recipe above (manifest signature -> chunk integrity -> chain -> overlay pin -> completeness). ``` The manifest carries one chunk hash per chunk file; the chain carries per-event `content_hash` and `prior_content_hash`. Reject the bundle on any verification failure. ### Signing an inbound webhook request ```python # Pseudocode import hashlib, hmac, time, os, urllib.parse method = "POST" path = "/current/inbound/workspace/{workspace_id}/trigger/{trigger_id}/" query = "" # sorted, RFC-3986 encoded key=value pairs joined by & body = b'{"event":{"type":"file.uploaded","id":"{file_id}"}}' timestamp = str(int(time.time())) nonce = os.urandom(16).hex() body_hash = hashlib.sha256(body).hexdigest() canonical = f"{method}\n{path}\n{query}\n{body_hash}\n{timestamp}\n{nonce}" signature = hmac.new(workflow_inbound_key_bytes, canonical.encode("utf-8"), hashlib.sha256).hexdigest() headers = { "Content-Type": "application/json", "X-Workflow-Signature": signature, "X-Workflow-Timestamp": timestamp, "X-Workflow-Nonce": nonce, } ``` ### Creating an outbound subscription ```bash curl -X POST "https://api.fast.io/current/workspace/{workspace_id}/outbound_webhook_subscriptions/" \ -H "Authorization: Bearer {api_key}" \ -d 'target_url=https%3A%2F%2Fexample.com%2Fhooks%2Ffast&event_type_subscriptions=["workflow.created","workflow.completed"]' ``` Response (one-time secret view -- store immediately): ```json { "result": true, "outbound_webhook_subscription": { "id": "sbabc-12345-defgh-67890-ijklm-nopqrs", "workspace_id": "12345678901234567890", "target_url": "https://example.com/hooks/fast", "event_type_subscriptions": ["workflow.created", "workflow.completed"], "enabled": true, "secret": "{32_byte_hex_secret}", "secret_version": 1, "created_at": "2026-05-14 17:00:00 UTC" } } ``` Subsequent reads omit `secret`. If lost, rotate via `POST /current/outbound_webhook_subscriptions/{subscription_id}/rotate_secret/`. ### Outbound delivery body shape ```json { "event_id": "evabc12345defgh67890ijklmn", "event_type": "workflow.completed", "workspace_id": "12345678901234567890", "occurred_at": "2026-05-14 17:30:00 UTC", "data": { } } ``` - `event_id` -- opaque event identifier (also in the `X-Webhook-Event-Id` header). - `event_type` -- one of the delivered event types (see *Subscribable event types* below). - `workspace_id` -- 19-digit owning workspace id (string). - `occurred_at` -- `Y-m-d H:i:s UTC`. - `data` -- event-specific payload object (may be `{}`). The per-delivery dedupe key lives in the `X-Webhook-Idempotency-Key` header and the subscription id in `X-Webhook-Subscription-Id` -- both are headers, not body fields. ### Subscribable event types The subscription endpoint **accepts** 17 event-type strings in `event_type_subscriptions` without error, but only **6** are emitted today. Subscribe only to a delivered type if you need it to arrive. **Delivered today (subscribe to these):** | Event type | Fires when | |------------|------------| | `workflow.created` | A workflow run is created | | `workflow.completed` | A run reaches a terminal completed state. When the run ends because an `approval` step resolved `rejected`, the event payload carries `terminal_reason: "approval_rejected"` to distinguish it from a successful completion | | `workflow.modified_mid_run` | An in-flight run is edited (mid-run modification applied) | | `step.transitioned` | A step changes state | | `step.failed` | A step fails | | `inbound_webhook.received` | An inbound webhook is received by a running workflow | **Reserved -- accepted by the API but NOT yet emitted:** these validate (no error on subscribe) but do not currently deliver; do not rely on them yet: `workflow.activated`, `workflow.paused`, `workflow.resumed`, `workflow.cancelled`, `workflow.archived`, `workflow.deleted`, `step.timed_out`, `obligation.created`, `obligation.claimed`, `obligation.resolved`, `obligation.superseded`. --- ## Error Responses Common error codes for this category (see the [overview reference](https://api.fast.io/current/llms/) for the full code table): | Error Code | HTTP Status | Cause | |------------|-------------|-------| | `1650 (Authentication Invalid)` | 401 | Missing or invalid token (or HMAC signature on inbound paths) | | `1605 (Invalid Input)` | 406 | Field validation failure | | `1698 (Unprocessable Entity)` | 422 | Template body validation failure (carries `validation_report`) | | `1680 (Access Denied)` | 401 | Caller lacks the required workspace permission, or redaction roles mismatch | | `1609 (Not Found)` | 404 | Resource not found | | `1660 (Conflict)` | 409 | Trigger fire denied / state transition conflict / CAS guard mismatch / lock contention | | `1693 (Temporarily Unavailable)` | 503 | Bundle not yet finalized; transient enqueue failure | | `1671 (Rate Limited)` | 429 | Rate limit exceeded | Trigger-fire denial responses (409; 403 for `permission_denied`) carry a `reason` field with one of `trigger_disabled`, `dedup_hit`, `rate_limited`, `concurrency_cap_hit`, `ancestry_guard`, `oscillation_detected`, `permission_denied` — inside the error envelope's `params` object (`response.error.params.reason`). A transient gate failure returns 503 (retryable) with no `reason`. Pool-delete 422 responses carry the current `active_concurrent` count. Mid-run modification endpoints add a few specific cases: a `403 (Access Denied)` when the workspace plan lacks the `workflow_mid_run_edit` capability, or when an apply skips a human gate without `confirm_removes_human_gate=true`; a `409 (Conflict)` when a second proposal is opened on a workflow that already has one open. An `agent_trace/`, `agent_activity/`, or `agent_result/` read returns `404 (Not Found)` for a non-agent occurrence or one that does not resolve to this workflow. A trigger hard-delete (`DELETE /current/workflow_triggers/{trigger_id}/?hard=true`) enforces a three-layer safety protocol before the row is removed. (1) The trigger must already be soft-deleted -- a `?hard=true` against a still-enabled trigger returns 409 with a message instructing the caller to soft-delete first. (2) The trigger must have zero in-flight workflow runs (both running workflows it started and fires it has accepted that have not yet started their run); 409 is returned otherwise. (3) The platform must be able to verify quietness -- when an internal availability issue prevents the pending-fire count from being read, the request returns 500 rather than silently proceeding; the soft-delete is unaffected and the retry is safe. Soft-delete (omit `?hard`) is always allowed and is the recommended sequence: soft-delete, wait for fires to drain, then hard-delete. A fire that lands AFTER a successful hard-delete is terminally acknowledged and surfaces in workspace audit as a rejection breadcrumb. Standard rate limits apply. See the overview for the global rate-limit header schema (`x-ve-limit-avail`, `x-ve-limit-max`, `x-ve-limit-expires`) and error code 1671. --- ## Identifier Formats Workflow API responses carry three identifier kinds. Every identifier is returned as a JSON string -- never a number -- and every one is an opaque token: never parse it or assume internal structure. | Identifier kind | Shape | Fields | |-----------------|-------|--------| | Profile id | 19-digit numeric string (e.g. `4011234567890123456`) | `workflow_id`, `workspace_id`, `org_id`, `owner_user_id`, `runs_as_user_id`, `caller_user_id`, `assigned_user_id`, `claimed_by_user_id`, `granted_by`, `exporter_user_id`, and every other `*_user_id` field | | OpaqueId | base32 alphanumeric string in canonical hyphenated form (e.g. `nwxab-12345-defgh-67890-ijklm-nopq`) | `template_id` and template/definition revision ids, `trigger_id`, `subscription_id`, `pool_id`, `job_id` (audit export), `redaction_id`, `step_occurrence_id`, `step_id`, `definition_revision_id`, audit `event_id` | | Sequence id | plain numeric string -- a short integer sequence value, NOT a 19-digit Profile id (e.g. `100200300`) | `obligation_id` (the obligation's own `id`) | Profile ids identify workspace-child profiles (a workflow is itself a profile) and the users / workspaces / orgs they relate to. OpaqueId strings identify non-profile workflow resources -- templates, triggers, subscriptions, pools, export jobs, redaction batches, and runtime rows. The obligation `id` is the one exception: it is a plain numeric sequence id, shorter than a Profile id and emitted as a numeric string. A field whose name ends in `_user_id` is always a Profile id; a resource's own `id` follows the format of that resource's kind. --- ## Notes - All timestamps are `'Y-m-d H:i:s UTC'` (e.g. `2026-05-14 13:09:00 UTC`). - `idempotency_key` on `instantiate` is mandatory and is the basis for replay-safe instantiation; the same key returns the existing runtime row. - Cross-organization workflow transfer is rejected; transfers are allowed only between workspaces in the same organization. - Workflow profile features (templates, triggers, pools, obligations, audit) require workspace-level enablement; check the workspace's feature flags before issuing workflow-creating calls. - Workflow grants are an additive authorization source: a grant of role `viewer`/`participant`/`admin` widens a user's effective access to one workflow on top of the parent-workspace permissions; it never removes access. Access to a workflow (and to every workflow operation -- the read endpoints, and the mutating/lifecycle endpoints cancel/pause/resume/transfer/instantiate/step operations/inbound-key rotation) is the higher of the caller's parent-workspace permission and any live workflow grant; a `viewer`/`participant`/`admin` grant satisfies the read endpoints and an `admin` grant additionally satisfies the mutating endpoints. - A user holds at most one live grant per workflow. Re-granting a user who has an active grant returns 409 (revoke it first); if the user's existing grant has expired it is functionally dead and the new grant replaces it. Revocation is a soft operation -- a revoked grant is retained but confers no access; an expired grant (`expires_at` in the past) likewise confers nothing. All three grant verbs require the caller to be able to administer the workflow. `GET /workflows/{workflow_id}/` includes a `grants` array when the caller can administer the workflow. - The `GET /workflows/{workflow_id}/grants/` list endpoint supports cursor pagination. Accepts `limit` (default 100, max 500) and `cursor` (the value of `pagination.next_cursor` from a prior response) query params; returns `pagination: { has_more, next_cursor }` alongside `grants`. Iterate by sending `next_cursor` back as `cursor` until `has_more` is `false`. The list is ordered by grantee `user_id` ascending (monotonic + stable across pages) and contains only LIVE grants — revoked grants live in the audit log, not in the list response; expired grants are filtered as functionally dead. - Workflow-grant cleanup is cascaded on membership removals. Removing a user from a workspace (directly, or transitively via org-level removal) revokes their grant on every workflow owned by that workspace. Phantom-merge (an invited phantom user accepting via an existing real user) re-parents the phantom's workflow grants onto the real user; for workflow grants the phantom's `expires` timestamp and invite metadata (grant_id, granted_by, granted_at, role) are preserved when the real user has no prior membership. Deleting a workspace does NOT cascade-delete its child workflows -- the workflows orphan harmlessly (the parent is gone, the workflow is unreachable through every read path) and are lazily reclaimed. - Template revisions are immutable -- PATCH always returns 422. Create a new revision via POST. - Obligation release is the claimer-only inverse of claim: `claimed` -> `open`, or `claimed` -> `partially_claimed` for a multi-claim obligation, clearing the claimer field. Only the user who currently holds the claim may release; any other caller receives 409. - The runtime row carries the resolved fire parameters (the values bound to each template input slot at instantiation) under `run_params_json`. The shape is opaque to the platform; downstream step handlers consume it. - An `email.send` step accepts an optional `from` override field. The override is restricted to a sender address under the platform's default sending domain (`fast.io`). A per-workspace verified-sender-domain registry is planned for a later release; until then, attempts to set `from` to an arbitrary domain are rejected at step execution time. - A `webhook.fire` step POSTs (or PUTs/PATCHes) to an external HTTPS endpoint and is, like `email.send`, a consequential sink — its inbound gating must satisfy the gate-independence and NL co-gate rules above. Its arguments are authored as top-level envelopes on the step spec (`url`, `body`, optional `headers`), with an optional scalar `method` (one of `POST` / `PUT` / `PATCH`, default `POST`). The same envelope discipline as `email.send` applies: in this release every argument must be a **template-bound literal** — binding step output to any `webhook.fire` argument (directly or laundered) is rejected at template save. Literal shapes are validated at save: `url` must be a non-empty `https://` string (the SSRF screen stays a runtime check), `body` is a string or any JSON-encodable value up to 1 MiB, and `headers` is a string→string map whose names pass a safe-name allowlist (the `Host` / `Connection` / `Transfer-Encoding` family and the `X-Webhook-*` platform prefix are banned) and whose values are control-char-free (the save-time CRLF-injection screen). `url` and `body` are required. - Inbound webhook payloads for `inbound_event` and `wait_for_signing` steps are consumed by the realtime inbound-payload pipeline that landed in v3.2: after durable receipt-side persistence, a best-effort async realtime-dispatch enqueue handles the common path, and a 5-minute periodic sweep is the backstop that drains payloads the realtime path missed (e.g. async-job worker crash before terminal ACK). The wait-timeout poller remains the worst-case fallback when both the realtime and sweep paths drop a payload. Plan around realtime completion latency. --- ## Workflow Review (v3.5b) A native content-approval surface attached to a workflow's `approval` steps. Available when the workspace has the native-review rollout flag enabled. Endpoints 404 when the flag is off. The flag is the workspace setting `workflow_approval_native_enabled` (`disabled` | `mvs` member-only | `extended` adds external reviewers), set by workspace admins via `POST /current/workspace/{workspace_id}/update/` — see the Workspaces reference. **Newly created workspaces default this tier to `extended`** (in-place approval + external reviewers available out of the box); this is not a create parameter — to change it, use the workspace update endpoint (`POST /current/workspace/{workspace_id}/update/`). Deploying a system gallery template that contains a native `approval` step (or any `approval_in_place` step) (via `POST /workspace/{id}/workflow_templates/from_system/`) or instantiating a workflow whose definition contains a native `approval` step (or any `approval_in_place` step) auto-enables the `mvs` tier on the workspace if the flag is not already set (this only matters for workspaces that predate the `extended` default or were explicitly reset; auto-provisioning never downgrades a higher tier). A workspace explicitly set to `disabled` is left as-is — a native `approval` step in that workspace will FAIL (step state: `failed`, reason: `native_review_tier_not_provisioned`) rather than silently auto-passing. Trigger-fired runs, subworkflow-child runs, and template-clone runs do NOT auto-enable the tier; if a workspace has never had the tier enabled and a trigger fires a workflow containing a native approval step, that gate will fail with the same `native_review_tier_not_provisioned` reason until a workspace admin enables the tier (or deploys / instantiates once to trigger auto-provisioning). On the `mvs` tier a review surface covers a SINGLE asset. When an `approval` step's reviewed-asset set resolves to more than one file (for example a multi-file collection step feeding the review), the `mvs` surface reviews only the first file. Reviewing every file of a multi-file set is an `extended`-tier capability. Greenfield workspaces default to `extended`, so multi-file review is available out of the box; the deliberate-opt-in restriction applies only to a workspace whose tier was explicitly set to `mvs` or reset/cleared — the runtime auto-provision hook only ever grants `mvs`, so such a workspace must be upgraded to `extended` deliberately. | Method | Path | Purpose | |--------|------|---------| | POST | `/current/workflow-review/surface/create/` | Get-or-create a review surface for a `step_occurrence_id` (idempotent) | | POST | `/current/workflow-review/send-for-review/` | Member shortcut that mints a review surface for a file (or, on the `extended` tier, a mixed member/external roster) | | GET | `/current/workflow-review/surface/{surface_id}/` | Fetch surface + assets + reviewers + per-asset decision matrix | | GET | `/current/workflow-review/surface/{surface_id}/asset/{asset_id}/` | Fetch a single asset + its current round + that round's decisions | | POST | `/current/workflow-review/surface/{surface_id}/asset/{asset_id}/decision/` | Record a decision (`approve` / `reject` / `request_changes`) | | POST | `/current/workflow-review/surface/{surface_id}/asset/{asset_id}/bind-node/` | Bind a new file version as the current review version (request-changes re-upload round) | | POST | `/current/workflow-review/surface/{surface_id}/admin-resolve/` | Workspace admin force-resolves a stuck surface (`approved` / `rejected` / `cancelled`) | | POST | `/current/workflow-review/surface/{surface_id}/reviewer/add-member/` | Workspace admin adds an existing workspace member as a reviewer to an open surface | | POST | `/current/workflow-review/surface/{surface_id}/reviewer/add-external/` | Workspace admin adds one external (by-email) reviewer to an open surface (`extended` tier) | | POST | `/current/workflow-review/surface/{surface_id}/reviewer/{reviewer_id}/remove/` | Workspace admin removes a reviewer (soft-remove; decisions preserved) | | POST | `/current/workflow-review/surface/{surface_id}/reviewer/{reviewer_id}/notification-opt-out/` | Reviewer self-service toggle for per-decision notifications (`opt_out` body bool; explicit set, idempotent) | | GET | `/current/workflow-review/asset/{asset_id}/comments/` | List review-scoped comments for the asset's surface | | POST | `/current/workflow-review/asset/{asset_id}/comments/` | Create a review-scoped comment on the asset | | POST | `/current/workflow-review/asset/{asset_id}/comments/{comment_id}/update/` | Edit a review-scoped comment body (author edits own; workspace admin any) | | POST | `/current/workflow-review/asset/{asset_id}/comments/{comment_id}/delete/` | Delete a review-scoped comment (author soft-delete; workspace admin hard-delete) | | GET | `/current/workflow-review/preview/read/` | Stream the pinned-version preview bytes for a review asset | | GET | `/current/workflow-review/preview/states/` | Per-preview-type readiness map for a review asset's pinned version | | POST | `/current/workflow-review/link-token/{link_token_id}/revoke/` | Workspace admin revokes a legacy external-reviewer link-token | | GET | `/current/workflow-review/workspace/{workspace_id}/active/` | List the ACTIVE (`arming` / `open`) review surfaces in a workspace, each with its asset rows | | GET | `/current/share/{share_id}/review/active/` | List the ACTIVE in-place review surfaces targeting a share, each with its asset rows (badge hydration) | | POST | `/current/workflow-review/quick-approval/` | "Request approval" quick path: provision a file share and a single-step in-place approval run for one file (members or, on the `extended` tier, external (by-email) reviewers) | Permission: workspace member. The decision endpoint additionally requires the caller to be on the surface's reviewer roster. The admin-resolve endpoint requires workspace admin (PERM_ADMIN) on the surface's parent workspace and bypasses the roster — it is the supported operator path for force-resolving a stuck approval surface. The notification-opt-out endpoint accepts the reviewer's own member session (self-service), a claimed external reviewer's authenticated session (self-service for their own roster row), or a workspace admin; it sets whether that reviewer receives a notification each time another reviewer records a decision, and the response echoes the resulting `notification_opt_out_per_decision` state alongside the reviewer row. Review-resolved and lock-override notifications are never suppressed by the opt-out. The workspace active list is a hydration read: one call on workspace load returns every in-flight review with its asset `node_id`s so clients can badge files as under review without per-file fetches. The response is `{ reviews: [ { surface, assets } ], pagination: { limit, offset, total, has_more } }` — it intentionally omits each surface's reviewer roster and decision matrix (fetch the by-id surface read for those). Accepts `limit` (default 25, max 100) and `offset` query params, ordered oldest-created first (stable id tiebreak). Unlike the other review endpoints this read is not gated on the native-review rollout flag: in-flight reviews keep blocking file writes even if the feature is later disabled, so the list always reports them; for a workspace member it returns `result: true` with an empty `reviews` list when nothing is under review, never an error. Response shapes (the by-id surface read returns all five blocks; the active list returns `surface` + `assets` per review): - `surface` — `{ id, step_occurrence_id, workflow_id, workspace_id, policy_mode, policy_quorum_n, status, resolved_outcome, resolved_at, created, updated, revision_diff }`. `revision_diff` (object or `null`) is described below. - `revision_diff` — present on the by-id surface read; an AI-generated "what changed since the last revision" summary for the LATEST request-changes round (a reviewer asked for changes and the author re-uploaded a new file version). Shape: `{ summary, structured, trust_class, generated_at }` — `summary` (string, the reviewer-facing change description), `structured` (bool, whether a structured schema was applied), `trust_class` (always `agent_constrained`), `generated_at` (`Y-m-d H:i:s UTC`). It is **display-only** and **agent-constrained**: render it for the reviewer alongside (never folded into) the human-authored note, badge it as AI-derived, and never treat it as a decision input — it never drives policy, routing, or terminal outcomes. `null` when no request-changes round has produced a diff yet (or the feature is off for the step). - `assets[]` — `{ id, surface_id, node_id, current_version_id, current_round_id, iteration_count, status, name, mime_type, size, created, updated }`. `name` + `mime_type` + `size` (bytes) describe the review-PINNED version (captured when the review starts and re-captured on each re-upload round) — not the file's live name; they may be `null` when capture was unavailable. `status`: `pending|approved|rejected|request_changes|stale_version`. - `reviewers[]` — `{ id, surface_id, kind, member_user_id, external_email, external_name, required, overall_signer, notification_opt_out_per_decision, invited_at, claimed_at, removed_at }`. `kind`: `member|external`. Member display names are not embedded — resolve `member_user_id` against the workspace member list; `external_name`/`external_email` apply to external reviewers only. `notification_opt_out_per_decision` (bool) is the reviewer's per-decision notification opt-out (toggled via the notification-opt-out endpoint above). The roster is paginated via `reviewers_pagination { limit, offset, total, has_more }` (same `limit`/`offset` query params, default 25, max 100). - `decisions` — an object keyed by **asset `id`** (not `node_id`), each value an ARRAY of decision rows: `{ id, surface_id, asset_id, reviewer_id, round_id, pinned_version_id, action, status, comment_text, decided_at, decided_via, audit_event_id }`. `action`: `approve|reject|request_changes`. `status`: `recorded|stale_version|superseded`. `decided_via`: the authentication channel used — new external-reviewer decisions use `external_member` (authenticated claimed-account plane); the value `external_token` is a legacy value from the retired JWT link-token decision path and is no longer written for new decisions. A reviewer who has not decided simply has no row (no pending placeholders). All rounds' decisions are returned ordered `decided_at` ascending; the current round is `assets[].current_round_id`, and prior-round rows carry terminal statuses (`superseded`/`stale_version`). - `trust_class` — `observe_full|observe_own|none`. The decision matrix is filtered per caller: workspace admins and the surface creator see all decisions (`observe_full`); a rostered reviewer sees only their own rows (`observe_own`). The field tells the client which view it received. **Decision request.** Body params: `action` (`approve` / `reject` / `request_changes`), `version_id_pinned` (the asset's `current_version_id`), and `comment_text` (string). `comment_text` is **required and must be non-empty** for `reject` and `request_changes` — a reason must accompany a rejection or change-request; omitting it or passing a blank value returns HTTP 422 with error name `ERR_REASON_REQUIRED`. `comment_text` is optional for `approve`. **Decision response.** On success the endpoint returns: ```json { "result": true, "decision": { "...decision row..." }, "resolution_state": "pending", "current_round_id": "", "asset_status": "pending|approved|rejected|request_changes" } ``` `current_round_id` is the asset's active round at response time. `asset_status` is the asset's status immediately after the decision is recorded: a `request_changes` decision flips it to `request_changes` synchronously; `approve` and `reject` leave it `pending` until the asynchronous policy evaluation resolves the surface, so `resolution_state` stays `pending` in those cases. Decisions CAS-check the asset's `current_version_id` against the body's `version_id_pinned` (409 `ERR_VERSION_MISMATCH` on mismatch) and CAS-dedupe on `(asset_id, reviewer_id, round_id)` (409 `ERR_DECISION_CAS_CONFLICT` on duplicate — a reviewer gets exactly one decision per round; it is immutable within the round). The `ERR_DECISION_CAS_CONFLICT` response carries a structured `error.params` body: `{ "code": "ERR_DECISION_CAS_CONFLICT", "reason": "reviewer_already_decided_this_round", "current_round_id": "" }`. On a terminal policy outcome the platform re-enters the workflow runtime so the step transitions through the standard audit + write-along substrate. **Machine-readable error discriminators.** Three symbolic decision failures also carry a stable `error.params.code` token so a client can switch on it instead of parsing `error.text`: a self-approval attempt (the workflow initiator deciding on their own review) returns `{ "code": "ERR_SELF_APPROVAL_DENIED" }`; a missing reason on `reject` / `request_changes` returns `{ "code": "ERR_REASON_REQUIRED", "actions_requiring_reason": ["reject", "request_changes"] }`; and an unrecognized `action` returns `{ "code": "ERR_INVALID_ACTION", "valid_actions": ["approve", "reject", "request_changes"] }`. The same `ERR_SELF_APPROVAL_DENIED` `params.code` is attached on the adjacent review endpoints that reject a self-approval (adding the initiator as a reviewer of their own review, or an admin force-approving their own review). The `ERR_` token also stays in `error.text` unchanged. **Rejection lifecycle.** When an `approval` step resolves `rejected` and the template has not authored an explicit rejected-outcome edge, the run does not advance past the approval as if approved — it stops. The run ends with status `completed` (a rejection is a completed approval, not a failure), and the `workflow.completed` event carries a `terminal_reason: "approval_rejected"` signal distinguishing it from a successful completion. Templates may author an explicit rejected-outcome edge to branch the run instead of stopping. **In-place approval (`approval_in_place` step).** There are two approval step types that share the review surface above, differing only in what happens to the reviewed files. A standard `approval` step pulls the files into workspace storage and write-locks them while the review is pending (`enforcement_mode: ingest_locked`). An `approval_in_place` step instead requests sign-off on files **where they already live** in a share, portal, or file share (`enforcement_mode: in_place_stamped`): the files are **not moved, copied, or locked**, the approved version is version-stamped and content-hashed for durable audit, and any later change to the live file is surfaced as **drift**. The surface and asset reads carry an `effective_status` overlay — `version_drift` when the live version no longer matches the version pinned for review, or `asset_unavailable` when the file can no longer be resolved — and each decision read carries `pinned_content_hash` + `pinned_hash_algo` (the durable audit proof of what was approved; `null` on legacy lock-based decisions). Every surface read (the by-id GET, the workspace-active list, the share-active list, and the create response) carries `enforcement_mode` (the tier discriminator) plus, for an in-place review, the targeting anchors `share_id`, `storage_instance_id`, `target_mode`, and `target_fingerprint` (all `null` on a lock-based surface). The mode is fixed when the review opens and never changes for the life of the review. In-place targets one of `single_file`, `fileset`, or `whole_share`. In-place is gated by the `workflow_approval_in_place` capability (emitted in the workspace resource's `capabilities` array), which is `true` on the `mvs` and `extended` native-review tiers and `false` on `disabled` — the same tier source as the `approval` step. On the `mvs` tier an in-place review covers a single file with the `single` policy; external reviewers, multi-file / whole-target review, and non-`single` policies require the `extended` tier. Because newly created workspaces default to the `extended` tier, a greenfield workspace has the full in-place surface (external reviewers + multi-file / whole-target review) available out of the box unless the tier was changed. External (by-email) reviewers added to an `approval` / `approval_in_place` step (by the workflow editor or via the reviewer-add endpoints) are provisioned through an account-claim invite: an unregistered email receives a phantom-user placeholder + a join-link email so they can create an account, and a registered email simply finds the review in their inbox. The reviewer then decides as an authenticated reviewer once their email is verified (the verified email is matched to the reviewer roster) — there is no anonymous review link to issue or expire, and being added as an external reviewer grants no workspace, workflow, or file access beyond the review itself. **The "Request approval" quick path.** `POST /current/workflow-review/quick-approval/` is a one-call orchestration of in-place approval for a single file: it provisions a private file share for the chosen reviewers and starts a single-step `approval_in_place` run targeting that file. Auth: workspace **member** (the request is anchored to `workspace_id`); the workspace must have the workflow feature and a native-review tier of `mvs` or `extended` (a `disabled` tier fails closed with `404` — the quick path does **not** enable the tier; a workspace admin must enable native review first). Body: `workspace_id`, `node_id` (the file), a reviewer roster, and a required `idempotency_key`; optional `policy_mode` (default `single`), `policy_quorum_n` (for `quorum`), and `approval_timeout_seconds` (60–7,776,000; a timed-out gate rejects). The roster accepts `reviewer_user_ids` (member shorthand) or a `reviewers[]` array with entries of `{kind: "member", member_user_id}` or, on the `extended` tier, `{kind: "external", email, name}`. External reviewers require the caller to be a workspace admin (PERM_ADMIN); each external email must not match an active workspace member (a fail-closed guard that also blocks the caller from self-approving via an external slot). External reviewers are invited onto the provisioned private file share via the pending-user / invitation system — a registered email receives a direct file-access grant; an unregistered email receives a phantom invite and a join-link email so they can claim an account. External reviewers decide via the authenticated-external access path after claiming an account and verifying their email; the quick path does **not** issue a review JWT link-token. The `mvs` tier quick path is members-only; a non-`single` policy mode or any external reviewer requires `extended`. The caller is excluded from the roster (no self-approval). Response: `{ workflow_id, fileshare_id, surface_id, run_status, poll }` — the run is 1:1 with the workflow so `workflow_id` is the run identity, and the review surface is minted lazily so `surface_id` is `null` at response time (poll the run state at `poll` for the surface and reviewer status). A retry over the same file converges on the existing live review with `409` carrying `existing_surface_id`, `existing_workflow_id`, and `existing_fileshare_id` rather than minting a duplicate. For multi-file targets or whole-share review, author an `approval_in_place` step in a workflow instead. The quick path is REST-only — there is no dedicated `workflow` MCP tool action for it (MCP agents call the REST path). **Send for review (member shortcut).** `POST /current/workflow-review/send-for-review/` mints a review surface for an already-instantiated workflow's `approval` step in one call (distinct from the `quick-approval` path, which also *provisions* the file share and starts the run). Auth: workspace **member** at write level (`PERM_MEMBER` — minting a review surface and issuing invites is a write operation); the request escalates to workspace **admin** (`PERM_ADMIN`) when the roster includes any external reviewer, and external reviewers additionally require the `extended` tier. Body: `workspace_id` (required, numeric — the anchor), `workflow_id` (required, numeric), `step_occurrence_id` (required, the approval step occurrence OpaqueId), a reviewer roster (`reviewer_user_ids[]` member shorthand **OR** a `reviewers[]` array of `{kind:"member", member_user_id}` / `{kind:"external", email, name}` — the two shapes are mutually exclusive; sending both returns `ERR_AMBIGUOUS_ROSTER_SHAPE`), the assets to review (`assets[]` of `{node_id, version_id}`, or the shorthand `reviewed_node_ids[]` + a single `version_id`), optional `policy_mode` (default `single`; a non-`single` mode requires `extended`), `policy_quorum_n`, `deadline_at`, `message`, and `external_invite_notes`. On a non-`extended` tier the roster and assets are single-asset only. The surface is minted synchronously — the returned `surface_id` is real and immediately fetchable. Response: `{ workflow_runtime_id, surface_id, surface, assets[], reviewers[], invites_provisioned }`; each external reviewer row carries `invite_provisioned` and `email_sent`. Errors: `1605 (Invalid Input)→406` on malformed ids; `1698 (Unprocessable Entity)→422` for roster/tier/policy violations (`ERR_EXTENDED_TIER_DISABLED`, `ERR_ROSTER_TOO_LARGE`, `ERR_TOO_MANY_EXTERNALS_PER_BATCH`, `ERR_EXTERNAL_EMAIL_MATCHES_MEMBER`, `ERR_EXTERNAL_EMAIL_ALIASED`, single-asset-tier violation, `ERR_OVERALL_SIGNOFF_REQUIRES_ONE_SIGNER`, `ERR_QUORUM_EXCEEDS_ROSTER`); `1680 (Access Denied)→401` when a non-admin requests external reviewers; `1609 (Not Found)→404` for an unknown node/version; and `404` when the native-review flag is off. **Reviewer roster management.** These endpoints modify an already-open surface's roster; all require workspace **admin** (`PERM_ADMIN` — admins bypass the reviewer roster) and the native-review flag enabled. - `POST /current/workflow-review/surface/{surface_id}/reviewer/add-member/` — add an existing workspace member. Body: `member_user_id` (required, a 19-digit workspace-member id), optional `required` (bool, default `true`). No invite or email is minted (members authenticate with their own session); a review obligation is fanned into their inbox. Re-adding a previously removed member re-activates the original row in place. Response: `{ reviewer, reactivated }`. Errors: `1660 (Conflict)→409` `ERR_SURFACE_RESOLVED` (terminal surface) / `ERR_MEMBER_ALREADY_REVIEWER`; `1698→422` `ERR_REVIEWER_NOT_WORKSPACE_MEMBER` / `ERR_ROSTER_TOO_LARGE` / `ERR_SELF_APPROVAL_DENIED` (the workflow initiator can never be added); `1680→401` non-admin; `1609→404` unknown surface. - `POST /current/workflow-review/surface/{surface_id}/reviewer/add-external/` — add one external (by-email) reviewer. **`extended` tier only.** Body: `email` (required), optional `name`, optional `invite_notes`. Mints the reviewer row and provisions a claim-only invite (an unregistered email gets a phantom placeholder + a join-link email; a registered email is a no-op + a best-effort notification) — no anonymous review link is issued. Response: `{ reviewer, invite_provisioned: true, email_sent, reactivated }` (`email_sent` is `true` / `false` / `null`). Errors: `1698→422` `ERR_EXTENDED_TIER_DISABLED` / `ERR_ROSTER_TOO_LARGE` / `ERR_EXTERNAL_EMAIL_MATCHES_MEMBER` / `ERR_EXTERNAL_EMAIL_ALIASED` / `ERR_SELF_APPROVAL_DENIED`; `1660→409` `ERR_SURFACE_RESOLVED` / `ERR_EMAIL_ALREADY_INVITED`; `1680→401` non-admin; `1609→404` unknown surface. - `POST /current/workflow-review/surface/{surface_id}/reviewer/{reviewer_id}/remove/` — soft-remove a reviewer (`removed_at` stamped). Prior decisions are preserved for audit, pending obligations are revoked, and any live link-tokens the reviewer held are swept. Response: `{ reviewer, obligations_revoked, decisions_preserved }`. Errors: `1609→404` `ERR_REVIEWER_NOT_FOUND`; `1660→409` `ERR_ALREADY_REMOVED` (idempotent double-call); `1698→422` `ERR_OVERALL_SIGNER_CANNOT_BE_REMOVED` (transfer the overall-signer flag first); `1680→401` non-admin. **Bind a revision (request-changes re-upload round).** `POST /current/workflow-review/surface/{surface_id}/asset/{asset_id}/bind-node/` advances the asset's review round to a new file version — the deterministic counterpart to the async re-upload subscriber, for a client to call right after it uploads the revised content. Auth: workspace **member** (`PERM_VIEW`); the revise-from-existing-file path additionally requires workspace **write** (`PERM_MEMBER`). Body: `node_id` (required — MUST equal the asset's existing node id; re-pointing the review at a *different* file is not supported and returns `1605→406`), optional `version_id` (pin a version of that node; defaults to its current version), optional `source_node_id` + `source_version_id` (revise-from-existing-file — copy another workspace file's content in as a new version of the file under review; rejected for in-place surfaces). Response: `{ surface_id, asset_id, node_id, version_id, iteration, current_round_id, status, asset }`. A revision can only be bound **after a reviewer requested changes** on the current round. Errors: `1605→406` on invalid ids or a different-node bind; `1609→404` `ERR_ASSET_NOT_FOUND` / source-file not found; `1660 (Conflict)→409` `ERR_SURFACE_TERMINAL` / `ERR_ASSET_TERMINAL` / `ERR_NO_OPEN_REQUEST_CHANGES` / `ERR_MAX_REVISION_ROUNDS` / `ERR_NOTHING_TO_REVISE` / an in-place copy-revise conflict; `1698→422` when the source is not a file or cannot be applied as a revision; `1680→401` when a copy-revise lacks write access. **Review comment threads.** Review comments are keyed by the **asset id** (a surface has exactly one asset on the `mvs` tier, so this is effectively per-surface). Auth: a workspace member who is on the surface's reviewer roster OR a workspace admin; a claimed external reviewer (authenticated, with a verified email matching an active external roster row) participates via the authenticated-external plane. Review comments never appear in the generic workspace / share / file comment lists. - `GET /current/workflow-review/asset/{asset_id}/comments/` — list the surface's comments. Query `limit` (default 50, clamped) / `offset`. Response: `{ comments[], pagination: { limit, offset, total } }`. - `POST /current/workflow-review/asset/{asset_id}/comments/` — create a comment. Body: `body` (required, non-empty, length-capped), optional `anchor` (a reference object validated against the asset's pinned version). Attachments are not supported — a `target_id` / `target_ids` payload is rejected `1605→406`. Returns **HTTP 201** with `{ comment }`. - `POST /current/workflow-review/asset/{asset_id}/comments/{comment_id}/update/` — edit a comment body (`body` required). The author edits their own; a workspace admin may edit any (an external reviewer may edit only their own). Response: `{ comment }`. - `POST /current/workflow-review/asset/{asset_id}/comments/{comment_id}/delete/` — delete a comment: the author soft-deletes, a workspace admin hard-deletes for moderation (an external reviewer soft-deletes only their own). Response: `{ "deleted": true }`. Body-validation failures return `1698 (Unprocessable Entity)→422` (`ERR_COMMENT_BODY_INVALID` / `ERR_COMMENT_REFERENCE_INVALID`); an unknown asset or comment returns `1609 (Not Found)→404` (`ERR_ASSET_NOT_FOUND` / `ERR_COMMENT_NOT_FOUND`); a caller not authorized to edit/delete a comment returns `1680 (Access Denied)→401`. **Pinned-version preview.** So a reviewer always renders the exact version they were invited to review (never a later upload), previews resolve the asset's **pinned** `current_version_id`. Auth on both endpoints: a workspace member on the reviewer roster or a workspace admin, or a claimed external reviewer via the authenticated-external plane; both require query params `step_occurrence_id` (the approval step occurrence) and `node_id`. - `GET /current/workflow-review/preview/read/` — stream the pinned-version preview bytes. Additional query `preview_type` (one of `thumbnail` / `hlsstream` / `pdf` / `mp4` / `image` / `audio` / `spreadsheet` / `bin`). Supports HTTP range requests; delivered bytes are metered to the workspace's billing entity. Errors: `1605→406` on missing/invalid params; `1698→422` `ERR_UNSUPPORTED_PREVIEW_TYPE`; `1609→404` `ERR_ASSET_NOT_FOUND`; `410 (Gone)` `ERR_PREVIEW_NOT_AVAILABLE` when the render is not available; `1680→401` when the caller is not on the roster. - `GET /current/workflow-review/preview/states/` — the per-preview-type readiness map, so a client knows which `preview_type` to request. Response: `{ states: [ { preview_type, ready, size_bytes?, reason? } ], pinned_version_id }`; when `ready` is `false`, `reason` is one of `unknown` / `not_possible` / `not_generated` / `error` / `still_rendering`. **Revoke an external link-token.** `POST /current/workflow-review/link-token/{link_token_id}/revoke/` — workspace **admin** revokes a legacy external-reviewer link-token, immediately blocking that token's access to the surface. (New external reviewers decide via the authenticated-external plane, not a JWT link-token; this endpoint remains to invalidate any still-live token.) Optional body `reason` (1–60 chars; defaults to `admin`). Response: `{ link_token: { id, surface_id, reviewer_id, revoked_at, revoke_reason } }`. Errors: `1609→404` unknown token; `1660→409` `ERR_ALREADY_REVOKED` (idempotent double-call); `1698→422` `ERR_INVALID_REASON`; `1680→401` non-admin. **Share-scoped active reviews.** `GET /current/share/{share_id}/review/active/` is the share counterpart of the workspace active list: it returns the ACTIVE (`arming` / `open`) **in-place** review surfaces targeting a share, each with its asset rows, so storage rows can paint "Under Review" badges (join on each asset's `node_id`). Auth: share Admin / Member / ShareGuest with review-view permission — this read is **not** gated on the native-review rollout flag (in-flight reviews keep blocking file writes even if the feature is later disabled). Query `limit` (share-review default, capped) / `offset`. Response: `{ surfaces: [ { kind: "workflow_in_place", surface, assets[] } ], pagination: { limit, offset, has_more } }`; each in-place asset carries the read-time `effective_status` + `live_version_id` drift overlay. Only in-place surfaces targeting a share appear here — ingest-locked (standard `approval`) surfaces do not. All timestamps use the standard `Y-m-d H:i:s UTC` format. --- ## System Template Gallery A built-in catalog of ready-made workflow templates shipped with the platform. Any authenticated user can browse the gallery and read a template's full definition; a workspace admin instantiates one into a workspace, where it becomes a normal template revision (see *Templates and Definition Revisions*) on a new or existing workflow. Gallery templates are versioned platform content — they are not stored per workspace and cannot be edited in place; instantiation snapshots the definition, so later gallery updates never affect workflows already created from it. | Method | Path | Description | |--------|------|-------------| | GET | `/current/workflow_templates/system/` | List the gallery (metadata only). Any authenticated user; no workspace scope or plan gate. The catalog is bounded, so the whole list is returned without pagination | | GET | `/current/workflow_templates/system/{handle}/` | One gallery template — metadata plus the full definition body including the `setup` block. Any authenticated user. 404 for an unknown handle | | POST | `/current/workspace/{workspace_id}/workflow_templates/from_system/` | Instantiate a gallery template into this workspace as a new template revision. Workspace admin; requires the workspace's workflow feature | ### Listing and reading the gallery ```bash curl -X GET "https://api.fast.io/current/workflow_templates/system/" \ -H "Authorization: Bearer {api_key}" ``` ```json { "result": true, "gallery_templates": [ { "handle": "system:gallery:contract-review", "name": "Contract Review", "description": "Wait for a contract upload, run an AI risk summary, route it to a legal review task, and close with a business-owner approval.", "category": "legal", "version": 1, "updated_at": "2026-06-11 00:00:00 UTC", "sort_order": 10, "icon_key": "contract", "status": "active", "required_features": ["agent"], "step_count": 4, "step_preview": [ {"type": "wait_for_files"}, {"type": "agent"}, {"type": "task"}, {"type": "approval"} ] } ] } ``` `status` is `active` or `deprecated` — deprecated entries stay listed (existing instantiations keep their provenance) but can no longer be instantiated (422). `required_features` lists plan capabilities the template needs at instantiation time: `agent` means the template contains an AI-agent step and the workspace's plan must include the AI-agent capability (403 otherwise). `version` is the catalog version of the shipped content — any change to a template's shipped definition bumps it. `step_preview` is an ordered array of `{"type": ""}` objects (one per step, in authored order) for rendering a compact composition preview alongside `step_count`; it carries no step ids or names and is a **display aid only — not an execution-order guarantee** (fanout / loop constructs may execute steps in a different order). The detail read returns the same metadata plus `definition` — the exact authored template body (`steps` keyed by step id, `edges`, `dsl_version`) together with the gallery-only `setup` block describing the inputs to collect before instantiating. The step ids in the shipped definition are fixed ids, but fresh ids are minted server-side at instantiation, so two workspaces instantiating the same gallery template never share step ids. ```bash curl -X GET "https://api.fast.io/current/workflow_templates/system/system:gallery:contract-review/" \ -H "Authorization: Bearer {api_key}" ``` ```json { "result": true, "gallery_template": { "handle": "system:gallery:contract-review", "name": "Contract Review", "category": "legal", "version": 1, "updated_at": "2026-06-11 00:00:00 UTC", "status": "active", "required_features": ["agent"], "step_count": 4, "definition": { "handle": "system:gallery:contract-review", "name": "Contract Review", "dsl_version": 1, "steps": { "{step_id}": {"type": "wait_for_files", "name": "Wait for contract", "expected_count": 1, "timeout_seconds": 604800} }, "edges": [ {"from_step_id": "{step_id}", "to_step_id": "{step_id}"} ], "setup": { "version": 1, "inputs": [ { "id": "legal_reviewer", "label": "Legal reviewer", "description": "Workspace member who performs the legal review task.", "type": "workspace_user", "required": true, "default_to_creator": true, "applies_to": [ {"target": "role_slot_user", "step_id": "{step_id}", "slot": "assignee"} ] } ] } } } } ``` ### Setup inputs Gallery templates ship with role slots and tunable values unfilled; the `setup` block declares the inputs that fill them at instantiation time. Each input has an `id`, a display `label` / `description`, a `type`, and one or more `applies_to` targets describing where the resolved value lands in the materialized template: - `workspace_user` — a single workspace member (a 19-digit user id). Targets a human step's assignee slot or an approval step's reviewer roster. - `workspace_user_list` — a list of workspace members (capped at 100 entries). Targets an approval step's reviewer roster. - `integer` — a numeric configuration value (optional `min` / `max` bounds), e.g. an upload-wait timeout. - `text` — a free-text configuration value (optional `max_length`; a server-side length cap applies even when the template declares none). Resolution: an explicitly supplied value wins; otherwise, an input with `default_to_creator: true` (user types only) resolves to the instantiating user; otherwise a `required` input is reported missing — all missing or invalid input ids are returned at once in a 422 `setup_report`. Every resolved user must be an accepted member of the workspace (a pending invitee does not qualify). Every gallery template covers each of its human steps (task / todo / confirmation / approval) with a `default_to_creator` input, so a zero-input instantiation always succeeds with the creator filling every role — useful for trying a template out before wiring in real assignees. Gallery approval steps gate the run as a whole rather than binding specific documents; a workspace admin resolves them through the review surface admin-resolve endpoint (see *Workflow Review*). ### Instantiating (`from_system`) ```bash curl -X POST "https://api.fast.io/current/workspace/{workspace_id}/workflow_templates/from_system/" \ -H "Authorization: Bearer {api_key}" \ -d 'handle=system:gallery:contract-review' \ -d 'inputs={"legal_reviewer":"{user_id}","business_approver":"{user_id}"}' \ -d 'expected_version=1' \ -d 'idempotency_key=gallery-contract-2026-06-11-001' ``` Parameters: | Param | Description | |-------|-------------| | `handle` | Required. The gallery template handle | | `workflow_id` | Attach the new revision to this existing workflow (must belong to this workspace). Default: create a new workflow | | `create_workflow` | `true` / `false`. Mutually exclusive with `workflow_id` (`create_workflow=false` requires a `workflow_id`); omitting both creates a new workflow | | `name` / `description` | Overrides for the created template revision (and the created workflow); default to the catalog entry's values | | `inputs` | JSON object string mapping setup input ids to values (see *Setup inputs*) | | `expected_version` | Integer compare-and-set against the catalog version: if the gallery entry was updated since the caller read it, the request fails 409 with `expected_version` / `catalog_version` in the error params — re-read the entry and retry | | `idempotency_key` | ≤128 chars. Enables replay-safe retries on both target modes (see below) | | `publish` | Default `true`: the revision is published and bound to the workflow so the next run executes it. `false` leaves the revision `validated` and unbound (publish later via `POST /workflow_templates/{template_id}/publish/`; binding then happens on the next `POST /workflows/{workflow_id}/instantiate/`) | | `reason` | Revision reason string (defaults to a generated "Created from system template …" line) | Response: ```json { "result": true, "workflow_template": { "id": "nwxab-12345-defgh-67890-ijklm-nopq", "lifecycle_state": "published", "created_at": "2026-06-11 15:30:00 UTC" }, "workflow": { "id": "60001234567890123456", "state": "draft" }, "system_gallery": { "handle": "system:gallery:contract-review", "version": 1, "source_hash": "{hex_sha256}", "idempotency_key": "gallery-contract-2026-06-11-001", "lifecycle_state": "published", "bound": true, "replayed": false, "workflow_created": true, "step_id_map": { "{gallery_fixed_step_id}": "{reminted_runtime_step_id}" } } } ``` The `system_gallery` block reports what this call did: `lifecycle_state` is the returned revision's state, `bound` is whether the workflow's bound revision pointer references the returned revision **at response time** — the pointer is re-read just before responding, and concurrent instantiations converge it on the workflow's current published revision, so a `publish=true` call that raced a newer instantiation can truthfully report `false` (`publish=false` always leaves the revision unbound), `replayed` is whether the response was served from a prior instantiation via the idempotency key, and `workflow_created` is whether a new workflow was created. The created revision's snapshot embeds a `_system_gallery` provenance block (handle, catalog `version`, `source_hash`, the idempotency key) — visible in the template body read — so a template's gallery origin survives independently of later catalog changes. `step_id_map` (present on every fresh instantiation; absent on a replay of a template created before this field was added) maps each gallery template's **fixed step id** to the **reminted runtime step id** the platform assigned in this instantiation. Use these reminted ids when keying `step_seeds` on `POST /workflows/{workflow_id}/instantiate/` — the gallery's fixed step ids are not valid keys and a `step_seeds` entry keyed by a fixed id is silently dropped. If `step_id_map` is absent (an older replay), read the reminted step ids from `GET /workflows/{workflow_id}/state/` instead. ### Idempotency and recovery - **Replay scope.** Idempotent replay works on **both target modes**. With `workflow_id` (the attach path), the platform scans the target workflow's recent revisions for a prior instantiation stamped with the same key; without one (the default-create path), it scans the workspace's recent revisions, so a retried create returns the workflow + revision the original call created instead of creating a second pair. Each scan is bounded best-effort — the 50 most recent revisions of its scope, not a durable ledger. If the workflow a keyed create originally created has since been deleted, the retry proceeds as a fresh create rather than failing. - **Dedupe identity.** The key dedupes on scope (the target workflow, or the whole workspace on the create path) + handle + publish flag only. Reusing a key in the same scope with a different `handle`, or with a different `publish` value, is a 409 conflict; reusing it with different `inputs` / `name` / `description` replays the ORIGINAL revision unchanged. - **Replay healing.** A replay completes a half-finished instantiation: a revision left `validated` by a failed publish leg is published, and a published-but-unbound revision is re-bound — but never by stealing the pointer from a newer published revision (in that case the replayed revision stays published-but-unbound and the response's `bound` is `false`). The response's `lifecycle_state`, `bound` (re-read at response time), and `replayed: true` report exactly what was returned. A replay also survives the catalog entry being removed, deprecated, or version-bumped since the original call — the stored revision is the source of truth. - **Recovery on publish/bind failure.** If the publish or bind leg fails AFTER the revision was persisted, the error params carry `workflow_id`, `workflow_template_id`, and `lifecycle_state` (plus `bound: false` on a bind failure). WITH an idempotency key, simply retry the request — the replay path (workspace-scoped on the create path; resending with the returned `workflow_id` as an attach request also works) finds the stored revision and completes the publish and/or repairs the bind. WITHOUT a key, do NOT blind-retry (that would create a duplicate revision): publish a `validated` revision manually via `POST /workflow_templates/{template_id}/publish/`, or bind a published-but-unbound revision via the next `POST /workflows/{workflow_id}/instantiate/` call. If the platform cannot verify a keyed request's idempotency (a transient read failure during the scan), it fails closed with a 503 — retry shortly. ### Catalog The v1 catalog comprises the 24 templates below (the list endpoint returns the currently published set, in the deterministic order shown). Handles marked † include an AI-agent step (`required_features: ["agent"]`) and require the AI-agent plan capability to instantiate. | Handle | Name | Category | What it does | |--------|------|----------|--------------| | `system:gallery:contract-review` † | Contract Review | legal | Wait for a contract upload → AI risk summary → legal review approval → business-owner approval (a rejection at either review routes to a record-and-notify step) | | `system:gallery:brand-asset-review` † | Brand Asset Review | marketing | Collect brand assets → AI completeness review → auto-return incomplete sets → creative review approval → brand-lead approval | | `system:gallery:policy-change-approval` † | Policy Change Approval | legal | Wait for a policy draft → AI change summary → legal approval → publish-ready confirmation | | `system:gallery:pr-media-sign-off` † | PR Media Sign-off | marketing | Wait for press materials → AI tone/risk check → PR review approval → comms-lead sign-off | | `system:gallery:new-hire-submissions` † | New Hire Submissions | hr | Collect new-hire documents → AI completeness review → auto-return incomplete paperwork → HR confirmation → payroll/IT follow-ups | | `system:gallery:new-hire-checklist` | New Hire Checklist | hr | HR kickoff task → IT provisioning + payroll setup handoffs → manager-ready confirmation | | `system:gallery:headcount-requisition` † | Headcount Requisition Approval | hr | Wait for a job description → AI completeness review → auto-return incomplete requisitions → VP approval → finance approval | | `system:gallery:change-order-process` † | Change Order Process | business_ops | Wait for a change request → AI impact summary → project-manager review approval → sponsor approval | | `system:gallery:quarterly-business-review` † | Quarterly Business Review | business_ops | Section-updates handoff → wait for the QBR deck → AI completeness review → auto-return incomplete deck → operations review → executive sign-off | | `system:gallery:sales-contract-review` † | Sales Contract Review | sales | Wait for an order form → AI terms summary → sales-owner review approval → final deal approval | | `system:gallery:customer-onboarding` † | New Customer Onboarding | sales | Collect the onboarding packet → AI completeness review → auto-return incomplete packets → CSM review → implementation checklist → kickoff-ready confirmation | | `system:gallery:budget-review-approval` † | Budget Review & Approval | finance | Wait for a budget packet → AI variance summary → finance review approval → budget-owner approval | | `system:gallery:vendor-document-intake` † | Vendor Document Intake | business_ops | Wait for a vendor document packet → AI document review → auto-return incomplete packets → intake review → compliance approval → vendor-record-complete confirmation | | `system:gallery:campaign-asset-approval` † | Campaign Asset Approval | marketing | Collect campaign assets → AI completeness review → auto-return incomplete sets → creative + marketing-lead approvals → launch-cleared confirmation | | `system:gallery:invoice-approval` † | Invoice Approval | finance | Wait for an invoice → AI completeness review → auto-return incomplete invoices → accounts-payable review task → budget-owner approval → cleared-for-payment confirmation | | `system:gallery:expense-report-approval` † | Expense Report Approval | finance | Wait for an expense report → AI completeness review → auto-return reports with missing receipts → finance review → manager approval | | `system:gallery:vendor-onboarding` † | Vendor Onboarding | business_ops | Wait for a vendor onboarding packet → AI completeness review → auto-return incomplete packets → procurement review task → vendor-manager approval → approved-to-transact confirmation | | `system:gallery:employee-offboarding-checklist` | Employee Offboarding Checklist | hr | HR offboarding task → access-revocation + asset-return handoffs → offboarding-complete confirmation | | `system:gallery:design-review-handoff` † | Design Review Handoff | marketing | Wait for design files → AI deliverables review → auto-return incomplete handoffs → design review task → art-director approval → production handoff checklist | | `system:gallery:document-translation-review` † | Document Translation Review | business_ops | Wait for a translated document → AI completeness review → auto-return incomplete translations → linguist review task → localization-lead approval | | `system:gallery:proposal-submission-review` † | Proposal Submission Review | business_ops | Wait for a proposal → AI completeness review → auto-return incomplete proposals → reviewer + bid-manager approvals → cleared-to-submit confirmation | | `system:gallery:incident-postmortem-review` † | Incident Postmortem Review | business_ops | Wait for a postmortem → AI completeness review → auto-return incomplete postmortems → engineering review → incident-lead approval → follow-up actions checklist | | `system:gallery:customer-renewal-approval` † | Customer Renewal Approval | sales | Wait for a renewal packet → AI completeness review → auto-return incomplete packets → account review task → sales-manager approval → cleared-to-close confirmation | | `system:gallery:board-deck-sign-off` | Board Deck Sign-off | business_ops | Board-deck prep checklist → wait for the deck → finance review approval → CEO approval | ### Versioning Each catalog entry carries an integer `version` and an `updated_at` timestamp; any change to a template's shipped content bumps both. Instantiation embeds the catalog version and a content hash in the created revision's provenance, and the revision's snapshot is immutable — so a later gallery update changes only FUTURE instantiations, never existing workflows or revisions. Use `expected_version` to pin a read-then-instantiate flow against a concurrent catalog update (409 on mismatch), and the keyed replay path to retry safely across catalog changes. > Part of the Fastio API Reference. Overview: https://api.fast.io/current/llms/ > See also: Workflow Orchestration -> https://api.fast.io/current/llms/workflows/ (a workflow step can wait on a SignEnvelope's completion; a signing.envelope step can instantiate a template at dispatch) # 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). Every endpoint additionally gates on the org's billing-plan signing capability (see Availability); the org resource exposes the resolved value as `capabilities.signing` (boolean). - **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 workflows, comments, and notification subscribers can react. A workflow step of type `wait_for_signing` can be parked on a SignEnvelope and resumes when the envelope reaches a terminal state. --- ## 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 universally available (every org, every plan, no feature flag), so these endpoints never reject a call for a missing signing entitlement. 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 a universal feature — available to every organization on every plan, with no feature flag or plan-level gating. The signing endpoints never reject a call for a missing signing entitlement. --- ## Concepts ### SignEnvelope Profile A SignEnvelope is a Profile — the same hierarchy primitive used by Workspaces, Shares, and Workflows. 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 (workflow trust matrix, 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`. This matches the workflow trust matrix's classification system — handler outputs that pass through a SignEnvelope are platform-derived and downstream consumers (workflow runtime, audit pipelines, AI agents) can rely on the value without independent verification. Like the workflow's `trust_class` field, `_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 `