Storage Operations File and folder operations, locking, previews, and transforms

Base URL: https://api.fast.io/current/ Auth: Bearer JWT

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

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.

LevelFields returned on each node (cumulative)
terseid, type, name, parent, version, file/note-only mimetype and size, modified, recursive nodes for folders when the endpoint returns children, file/note-only summary reduced to {title} only, file/note-only previews reduced to a per-type {ready: bool} map (keys preserved: thumbnail, image, pdf, mp4, hlsstream, audio, spreadsheet), folder-only is_share_root/share_id, file/note-only metadata_facts reduced to a comma-separated fields name list
standardterse + created, restricted, dmca, locked, file/note-only mimecategory, origin reduced to {creator, type}, file/note-only ai reduced to {state}, file/note-only metadata reduced to {title, short} (user-authored overrides), deleted and deleted_from (present ONLY while the node is in the trash — on a live node they are absent at every tier, including full, so their absence never means "not deleted" for a node you have not checked), is_imported, link-only target_type/target_id, metadata_facts reduced to field, abbreviated value, and value_truncated
fullstandard + summary.short/summary.long, note-only summary.category, virus, full ai object, full file_attributes (embedded EXIF / media metadata, returned only to callers permitted to download the file — see Node Object Schema), all remaining origin.* fields, hash, hash_algo, lock_info, import_metadata, full previews state map, full metadata_facts items (complete fact records)

A tier is a CEILING, not a guarantee — read every row above as "at most these fields". A key is present only when the node actually has it, so the same tier returns a different key set for a file, a folder, and a link. File/note-only: mimetype, size, summary, previews, metadata_facts, mimecategory, ai, metadata, hash, hash_algo, file_attributes. Folder-only: is_share_root, share_id, recursive nodes. Link-only: target_type, target_id. Trashed-only: deleted, deleted_from. The gap is large — at full, a file returns 27 keys and a folder 17 — so treat a missing key as "not applicable to this node type", never as a null value or an error, and never infer a node's state from a key's absence.

Use terse for list rows, tree rendering, pickers, breadcrumb navigation, and drag-and-drop targets — it carries modified (so list rows can render the date column and "sort by modified" without a follow-up fetch), a per-type previews readiness map (so the thumbnail selector can pick the best available source), and the summary title. Use standard for most detail views, file-browser main lists, and any UI that shows AI-processing state, lock/restricted chips, DMCA chips, import-provider chips, or trash state — it adds the ai.state that drives the "summarizing…" spinner, timestamps, origin creator 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 for the full contract.

Node Object Schema

All endpoints that return node data use this format. Fields vary by node type.

FieldTypePresent OnDescription
idstringallOpaqueId of the node
namestringallFile, folder, or note name
typestringall"file", "folder", "note", or "link"
parentstringallParent folder OpaqueId or "root"
sizeintegerfileFile size in bytes
hashstringfileContent hash of the file
hash_algostringfileHash algorithm (e.g., "md5")
mimetypestringfileMIME type (e.g., "application/pdf")
mimecategorystringfileMIME category (e.g., "document", "image")
versionstringfile, noteCurrent version identifier (e.g., "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5")
createdstringallCreation timestamp (YYYY-MM-DD HH:MM:SS UTC)
modifiedstringallLast-modified timestamp (YYYY-MM-DD HH:MM:SS UTC)
restrictedbooleanallWhether the file has been restricted
dmcabooleanallWhether the file has a DMCA flag
lockedbooleanallWhether the node has an active lock
lock_infoobject/nullallLock details when locked; null otherwise. Shape: {"locker_uid": "...", "locked_at": "...", "expires_at": "...", "locker": {"display_name": "...", "agent_name": "...", "agent_name_source": "..."}}. locker.display_name is the holder’s name, or null when it cannot be resolved. locker.agent_name names the agent that took the lock on that account’s behalf, when one did, and locker.agent_name_source says where that name came from; both are null when a person took the lock directly. agent_name is self-declared, not verified — display it beside the holder, never rely on it to identify or authorize anyone. It belongs to the CREDENTIAL, not the lock — it is read from the JWT claim or API-key label set when you signed in or minted the key, there is no per-lock parameter for it, and every lock that credential takes carries the same label. Identity requires MEMBER level or above — any caller below member, including share guests and public-link recipients and not only outsiders, receives lock_info: null while locked stays truthful, so they learn the file is held without learning by whom
virusobjectfileVirus scan status: {"status": "scanned", "infected": false}
file_attributesobjectfileMetadata read out of the file itself — media_metadata and/or exif_metadata when present. Returned only to callers permitted to download the file; see Embedded File Metadata below
summaryobjectfileAI-generated summary: {"title": "...", "short": "...", "long": "..."}
metadataobject/nullfileUser-defined custom title and description overrides
metadata_factsobjectfile, noteExtracted metadata facts recorded for the node — count, total, is_truncated, and a payload shaped by the output level (items on full and standard, a comma-separated fields name list on terse). Returned only to WORKSPACE members; never present in a share context; see Extracted Metadata Facts below
previewsobjectfilePreview generation state per type (e.g., {"thumbnail": {"state": "ready"}})
aiobjectfileAI processing state: {"state": "...", "attach": true/false}
originobjectfileOrigin info: {"type": "upload", "creator": "{user_id}"}

Embedded File Metadata (file_attributes)

file_attributes carries metadata read out of the file's own bytes: exif_metadata (camera and lens details, device make and model, authoring software, author and copyright text) and media_metadata (container, codec, and per-stream details). Because that content comes from inside the file, it is returned only to callers who are permitted to download the file.

Extracted Metadata Facts (metadata_facts)

metadata_facts carries the extracted metadata facts recorded for a file: the field-and-value pairs the extraction pipeline produced against the workspace's field vocabulary. It is returned on file and note nodes at all three ?output= levels, in a different shape at each level, and every shape carries count — how many facts this payload carries, not how many the node holds — total — how many the node holds, counted before any cap — and is_truncated. Folder and link nodes never carry it, and the lightweight recent-files listing does not include it.

If you send no output parameter you get full — on node listings and on node details alike. There is one global default and no endpoint overrides it, so the two cannot diverge: a node arriving from a folder listing carries the same complete fact records as the same node fetched directly. The lean and projected shapes below are reachable only by asking for them explicitly.

full — up to 100 facts as complete records: field, value, declared_type, stored_type, source (one of ai, user, exif, mediainfo, validated_server), confidence (low, medium, high, certain, or null), rationale (a string or null), and updated (YYYY-MM-DD HH:MM:SS UTC). A fact is identified by its field name — no id of any kind is returned.

"metadata_facts": {
  "count": 2,
  "total": 2,
  "is_truncated": false,
  "items": [
    {
      "field": "invoice_number",
      "value": "INV-1042",
      "declared_type": "string",
      "stored_type": "string",
      "source": "ai",
      "confidence": "high",
      "rationale": "Read from the header block on page 1",
      "updated": "2026-08-20 14:02:11 UTC"
    },
    {
      "field": "amount_due",
      "value": 4820.5,
      "declared_type": "float",
      "stored_type": "float",
      "source": "ai",
      "confidence": "certain",
      "rationale": null,
      "updated": "2026-08-20 14:02:11 UTC"
    }
  ]
}

standard — up to 8 facts carrying field, an abbreviated value, and a value_truncated boolean, with no provenance. The 8 are chosen in a fixed priority order — typed values first (numbers, dates, booleans), then identifier fields (names ending _number, _id, _code, _reference), then everything else, alphabetical by field name within each group — so a monetary or date fact is never cut in favour of an address or contact string. value_truncated is always present at this level. A string value longer than 64 characters is cut to 64 characters with appended and value_truncated is true; a complete string, number, boolean or null value is returned unchanged with value_truncated: false. A json-typed list value is returned as a real array (no longer rendered as a JSON string): the array is whole, with value_truncated: false, when its JSON encoding is 64 characters or shorter — counted on the value’s own characters, with unicode and / counted unescaped rather than on an escaped-for-transmission byte form — and otherwise holds the longest leading run of elements whose encoding fits that same budget (at least one element) with value_truncated: true — elements present are exact, except that when the first element alone does not fit, it is cut so the one-element array’s own encoding fits the 64-character budget — the kept text comes out shorter than 64 characters (for example 59 plus for a plain string) — cutting the string itself for a string element or the element’s JSON encoding for any other type, whatever the element’s own type (a number, boolean, null, nested object or array all reduce to that same cut string). A json-typed object value is returned as the structure itself when its encoding is 64 characters or shorter by the same measure, and otherwise as a cut JSON preview string with value_truncated: true. Use full when you need the exact value or its type. Never paste a value_truncated: true value into a metadata_filters equality predicate — it cannot match; call full (or the node facts endpoint) for the complete value. The same key name reappears elsewhere under a different rule: results[].matched_fields[].value_truncated under Metadata Search below cuts to a window taken around the matched text, with no ellipsis appended — do not apply one “strip the ellipsis” handler to both fields. Because each tier caps independently, count is not comparable across tiers for the same node: a node holding 9 facts can report "count": 9, "is_truncated": false at terse (under its 20-name cap) and "count": 8, "is_truncated": true at standard (capped at 8) for the identical set of facts — both are correct, and is_truncated signals that more facts exist beyond what was returned. total IS comparable across tiers, because it counts what the node holds rather than what the tier emitted: the same node reports "total": 9 at both levels. Compare totals across tiers, never counts.

"metadata_facts": {
  "count": 8,
  "total": 14,
  "is_truncated": true,
  "items": [
    {"field": "abstract", "value": "This master services agreement between Northwind and Con…", "value_truncated": true},
    {"field": "amount_due", "value": 4820.5, "value_truncated": false}
  ]
}

terse — up to 20 field names and no values at all, as one comma-separated string under a fields key (fields, not items), in the same priority order as standard (typed values, then identifiers, then the rest; alphabetical within each group). Splitting that string on , is safe: a field name may contain only letters, digits, spaces, underscores and hyphens (Unicode-aware, so accented and non-Latin names are fine), so a comma can never appear inside a name. That charset is enforced wherever a name enters the vocabulary, including names an AI proposes during extraction. Which characters are allowed is a different question from which names are the SAME name: field names are compared case-, accent- and width-insensitively, so Category, category and catégory are ONE field, not three — the first spelling written owns the name and a later write in another spelling resolves to it.

"metadata_facts": {
  "count": 5,
  "total": 5,
  "is_truncated": false,
  "fields": "abstract, amount_due, contract_type, currency, invoice_number"
}

AI States

ValueDescription
disabledAI processing is disabled for this file
pendingQueued for AI processing
inprogressAI processing is running
readyAI processing complete
failedAI processing failed
indexedFile 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

ParameterTypeDefaultDescription
sort_bystringnameOne of: name, updated, created, type
sort_dirstringascOne of: asc, desc
page_sizeint100One of: 100, 250, 500 (snapped to nearest)
cursorstringOpaque cursor string from previous response

Response Pagination Fields

FieldTypeDescription
pagination.has_morebooleanWhether more pages exist
pagination.next_cursorstring/nullCursor for the next page; null if last page
pagination.page_sizeintegerEffective page size used

Notes:

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.

Auth required. Permission: View (workspace), Guest+ (share). Share list on public shares may not require JWT.

Path Parameters

ParameterTypeRequiredDescription
{workspace_id} or {share_id}stringYes19-digit profile ID
{parent_id}stringYesFolder OpaqueId, "root", or "trash"

Query parameters: See Keyset Pagination section above.

curl Example

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

{
  "result": true,
  "nodes": {
    "count": 2,
    "items": [
      {
        "id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5",
        "type": "folder",
        "name": "Documents",
        "parent": "root",
        "created": "2025-01-01 00:00:00 UTC",
        "modified": "2025-01-20 14:45:00 UTC"
      },
      {
        "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4",
        "type": "file",
        "name": "photo.jpg",
        "parent": "root",
        "size": 2048000,
        "mimetype": "image/jpeg",
        "created": "2025-01-10 09:00:00 UTC",
        "modified": "2025-01-10 09:00:00 UTC"
      }
    ]
  },
  "pagination": {
    "has_more": true,
    "next_cursor": "eyJwIjoiMmFiYzEyMy4uLiIsInMi...",
    "page_size": 100
  }
}

Response Fields

FieldTypeDescription
nodes.countintegerNumber of nodes in this page
nodes.itemsarrayArray of node objects for current page
pagination.has_morebooleantrue if more pages exist
pagination.next_cursorstring/nullCursor for next page
pagination.page_sizeintegerActual page size used

Error Responses

Error CodeHTTP StatusDescription
1609 (Not Found)404Folder not found
1605 (Invalid Input)406Node is not a folder
1605 (Invalid Input)406Invalid pagination cursor (tampered or mismatched)

Workspace Inventory

GET /current/workspace/{workspace_id}/storage/inventory/
GET /current/share/{share_id}/storage/inventory/

Enumerate every live node in a workspace or share as one flat, paged list covering the whole tree.

Enumerate every live node in a workspace or share — files, folders, notes and links — as one flat, paged list covering the whole tree. There is no folder scope and no recursion to drive: one walk returns everything. Trashed nodes are excluded.

This is the intended way to enumerate every live file. Listing the tree with /storage/{parent_id}/list/ costs one call per folder, and the number of folders cannot be known in advance. An inventory row is deliberately lightweight, so enumerating a whole workspace is much cheaper per file than listing folders — reach for this endpoint instead of walking the tree whenever you want “everything in this workspace”.

Rows are ordered by node id ascending and walked with a keyset cursor. Ordering by id is what makes the walk stable: renaming or editing a file between pages does not move it, so it is neither skipped nor served twice.

The cursor is the id of the last row you kept — not an opaque token. The comparison is strictly greater than, so that row is not repeated, and it is independent of type and page_size, both of which may change between pages. This is deliberate: a client that renders a page and truncates it stopped somewhere the server does not know, and an opaque cursor would make it skip every row it dropped.

Rows are terse by design. There is no output= parameter on this endpoint and it is not accepted. A caller that wants a file's full record already has its id — read it with /storage/{node_id}/details/.

Auth required. Permission: View (workspace), Guest+ (share). Share inventory on public shares may not require JWT.

Path Parameters

ParameterTypeRequiredDescription
{workspace_id} or {share_id}stringYes19-digit profile ID

Query Parameters

ParameterTypeRequiredDefaultDescription
page_sizeintegerNo100Must be 100, 250, or 500. The default is sized so a page renders whole inside an AI agent's context window; use 250 or 500 for non-agent clients.
cursorstringNoThe id of the last row you kept, hyphenated or raw. Absent means the first page. Strictly greater-than, and independent of type and page_size. A value that is not a node id is refused with 406.
typestringNoRestrict the walk to one node type: file, folder, link, or note
includestringNoComma-separated extra field groups. Only path is recognised. An unrecognised token is refused with 406, never silently ignored.

curl Example

curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/inventory/?type=file" \
  -H "Authorization: Bearer {jwt_token}"

Response

{
  "result": true,
  "nodes": {
    "count": 2,
    "items": [
      {
        "id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5",
        "type": "folder",
        "name": "Documents",
        "parent_id": "root",
        "size": null,
        "mimetype": null,
        "updated": "2025-01-20 14:45:00 UTC",
        "version": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5",
        "summary": null,
        "facts_total": null
      },
      {
        "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4",
        "type": "file",
        "name": "Q4 Report.pdf",
        "parent_id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5",
        "size": 2048000,
        "mimetype": "application/pdf",
        "updated": "2025-01-10 09:00:00 UTC",
        "version": "3gzfc-3x7gt-4xsnm-qw52d-sjzcx-cuxa",
        "summary": { "title": "Q4 revenue and headcount summary" },
        "facts_total": 12
      }
    ]
  },
  "pagination": {
    "has_more": true,
    "next_cursor": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4",
    "page_size": 100
  }
}

The envelope uses the same key names as List Folder Contents (nodes.count, nodes.items, pagination.*), so a client that already pages folder listings reuses its parser unchanged.

Response Fields

FieldTypeDescription
nodes.countintegerNumber of rows in this page
nodes.itemsarrayThe inventory rows for this page
nodes.items[].idstringNode OpaqueId
nodes.items[].typestring"file", "folder", "link", or "note"
nodes.items[].namestringNode name
nodes.items[].parent_idstringParent folder OpaqueId, or "root" for a node directly in the storage root
nodes.items[].sizeinteger/nullSize in bytes for files and notes; null for folders and links
nodes.items[].mimetypestring/nullMIME type for files and notes; null otherwise
nodes.items[].updatedstringLast-modified time, YYYY-MM-DD HH:MM:SS UTC
nodes.items[].versionstringCurrent version OpaqueId
nodes.items[].summaryobject/null{"title": "..."} when the file has an AI summary, otherwise null. Title only — the short and long forms stay on Node Details.
nodes.items[].facts_totalinteger/null, or absentHow many extracted metadata fields the file has. Emitted only for callers who may read metadata facts (workspace members) — absent for everyone else, and never emitted at all by the share twin. When present: an integer count, or null when the file has never had metadata extracted. Folders and links are always null when the key is present; notes follow the same rule as files. See the note below — an absent key means unknown, never zero.
pagination.has_morebooleantrue if more pages exist
pagination.next_cursorstring/nullCursor for the next page; null when there are no more pages
pagination.page_sizeintegerEffective page size used

facts_total — absent, null and a number mean three different things. The field is gated on permission to read metadata facts, and facts never cross into a share, so it is emitted only for callers who may read them (workspace members) and the share twin never emits it at all. Read the three states separately:

include=path — where each node lives. Add include=path and every row gains three more fields: path (string/null), ancestors (array of {id, name}), and path_complete (boolean). They have exactly the same meaning and shape as the fields of the same names on a storage-search hit — read the Folder paths subsection under Search for the full contract, including why path_complete is the field the other two are read through. include is the only way to get them here; an include token other than path is refused with 1605 (Invalid Input) / 406 rather than ignored, so a typo can never look like “the field is unavailable”.

Share twin. Inventory is available on independent-storage shares only. A share backed by a workspace folder refuses it with 1609 (Not Found) / 404 and the message “Inventory is not available for Shared Folders” — the same restriction the share storage search twin has. Enumerate such a share's contents through the backing workspace instead.

Error Responses

Error CodeHTTP StatusDescription
1605 (Invalid Input)406cursor is not a node id
1605 (Invalid Input)406Unsupported include value
1609 (Not Found)404Inventory is not available for Shared Folders (share only, workspace-folder shares)

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

ParameterTypeRequiredDescription
{workspace_id} or {share_id}stringYes19-digit profile ID
{node_id}stringYesNode OpaqueId

curl Example

curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/details/" \
  -H "Authorization: Bearer {jwt_token}"

Response

{
  "result": true,
  "format": "single",
  "node": {
    "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4",
    "type": "file",
    "name": "document.pdf",
    "parent": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5",
    "size": 5242880,
    "hash": "d41d8cd98f00b204e9800998ecf8427e",
    "hash_algo": "md5",
    "mimetype": "application/pdf",
    "mimecategory": "document",
    "version": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5",
    "created": "2025-01-15 10:30:00 UTC",
    "modified": "2025-01-20 14:45:00 UTC",
    "restricted": false,
    "dmca": false,
    "locked": false,
    "lock_info": null,
    "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 CodeHTTP StatusDescription
1609 (Not Found)404Node not found
1680 (Access Denied)401No 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.

curl Example

curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/abc123,def456,ghi789/details/" \
  -H "Authorization: Bearer {jwt_token}"

Response (HTTP 200)

{
  "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.

Bulk Error Responses

Error CodeHTTP StatusDescription
1605 (Invalid Input)406Empty segment between commas
1605 (Invalid Input)406More than 25 unique ids in one request
1609 (Not Found)404Every requested id errored (errors array is populated)

Node Content

GET /current/workspace/{workspace_id}/storage/{node_id}/content/
GET /current/share/{share_id}/storage/{node_id}/content/

Return a file's or note's extracted text as ordered chunks.

This is the same text the platform already extracted and indexed for search and AI. read/ hands back raw bytes and search returns only a snippet, so this is the route that lets a caller actually read a PDF's words.

The unit is a chunk, not a page. Text is chunked for retrieval, and a chunk can span two pages or split one, so page= returns the whole chunks that OVERLAP that page — never a page-shaped blob of text. Pages are known only for formats that carry them (documents that were paginated when they were converted); spreadsheets, plain text, code, and notes have no pages and are addressed by chunk_from/chunk_to instead. Read page_addressable before sending a page. Audio and video files are not served by this route today and answer indexed: false.

A chunk's address is its position. position is the chunk's 0-based place in the file's read order, and it is what chunk_from/chunk_to select on. chunk_index is the older name for that idea and is now nullable — newer text may not carry one — so address chunks by position and treat chunk_index as legacy. sequence is a different coordinate, assigned when the file's text was extracted: it rises through the file without being contiguous, is null on text extracted before it was assigned, and is published for correlation rather than addressing. chunk_from/chunk_to reach the first 10000 positions of a file; a position at or beyond 10000 cannot be addressed with them and is refused as an invalid window — keep walking a large file with cursor, which has no such limit.

Only the file's current version has text. indexed_version_id names the version the returned chunks were indexed from. A cursor walk is not a snapshot: a cursor is stamped with the version it was issued against, so if the file is replaced mid-walk, the next call with the old cursor returns an EMPTY chunks list with next_cursor: null. indexed_version_id shows the new version once its text is indexed, and is null while it is still being processed — restart from the beginning either way, rather than splicing old text onto new.

Auth required. Permission: View (workspace). The share path requires download permission, not view — extracted text is the interior of the file, so a guest who may not fetch the bytes may not read the text either.

Path Parameters

ParameterTypeRequiredDescription
{workspace_id} or {share_id}stringYes19-digit profile ID
{node_id}stringYesNode OpaqueId. Both spellings are accepted — with or without hyphens. Ids in the response are always hyphenated.

Query Parameters

All optional. At most one window selector (page, chunk_from/chunk_to, q); with none, the response starts at the beginning of the file.

ParameterTypeRequiredDefaultDescription
qstringNoRelevance mode. Rank this file's own chunks by keyword match and return the best ones, each with full text and a numeric score. 1–512 characters. Cannot be combined with page, chunk_from/chunk_to, or cursor.
pageintegerNoReturn the chunks whose [start_page, end_page] range overlaps this page. 1-based.
chunk_fromintegerNoStart of an inclusive position range. Minimum 0. Positions at or beyond 10000 cannot be addressed this way — continue into a large file with cursor instead.
chunk_tointegerNoEnd of that inclusive position range. Requires chunk_from and must be greater than or equal to it, and is subject to the same 10000 ceiling.
cursorstringNoContinue after this chunk within the selected window. Opaque; pass back the next_cursor from the MOST RECENT response verbatim, never a value you built yourself and never one you stored from an earlier walk. Not valid with q. When used together with chunk_from/chunk_to, the cursor must have come from a walk of that same range: a token whose position falls outside the range is refused as an invalid window.
limitintegerNo5 (3 with q)Chunks per response. 120.
max_bytesintegerNo32768UTF-8 byte budget over the text in one response, applied in the ordered modes only. 1024262144.
outputstringNofullterse returns the chunk map with no text; standard and full include it. Composable with the markdown modifier like every other storage endpoint.

curl Example

curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/content/?page=2&limit=5" \
  -H "Authorization: Bearer {jwt_token}"

Response

{
  "result": true,
  "node_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4",
  "name": "Master Services Agreement.pdf",
  "mimetype": "application/pdf",
  "indexed": true,
  "complete": true,
  "indexed_version_id": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5",
  "page_addressable": true,
  "num_pages": 3,
  "total_chunks": 3,
  "chunks": [
    {"position": 0, "sequence": 0, "chunk_index": 0, "start_page": 1, "end_page": 1, "chars": 4000, "score": null, "text": "MASTER SERVICES AGREEMENT ..."},
    {"position": 1, "sequence": 12, "chunk_index": 1, "start_page": 1, "end_page": 2, "chars": 4309, "score": null, "text": "... 5. TERM AND TERMINATION ..."}
  ],
  "next_cursor": "",
  "truncated": false
}

Response Fields

FieldTypeDescription
node_idstringThe node, hyphenated.
name, mimetypestringTaken from the node itself, never from the text index, so a stale index entry can never change what the file is reported to be.
indexedbooleanWhether this version has any extracted text at all. Describes the WHOLE file, not the window you asked for.
completebooleanWhether the file's text extraction finished. When false, only part of the text has been written so far — the chunks you get are real, and more may appear later.
indexed_version_idstring/nullThe file version the chunks were indexed from. null when indexed is false.
page_addressablebooleantrue only when EVERY chunk of the file carries a page range. When false, a page selector returns an empty chunks list — switch to chunk_from/chunk_to.
num_pagesinteger/nullPage count of the converted document; null when the format has no pages.
total_chunksintegerChunks in the whole file, not in this response. A window that matches nothing on an indexed file still returns indexed: true with the real total_chunks and an empty chunks list.
chunksarrayThe matching chunks, ordered by position (by score descending in relevance mode).
chunks[].positionintegerThe chunk's address. Its 0-based ordinal place in the file's read order. Always present and always exact. This is what chunk_from and chunk_to select on, and the value to record if you want to come back to a chunk later; cursor continues a walk from an opaque token instead.
chunks[].sequenceinteger/nullThe ordering coordinate assigned when the file's text was extracted. It increases through the file but is not necessarily contiguous, and is null for text extracted before it was assigned. Published for correlation and debugging — address a chunk by position, not by this.
chunks[].chunk_indexinteger/nullLegacy field, kept for compatibility and now nullable: newer text may not carry one. Read position as the address instead.
chunks[].start_page, chunks[].end_pageinteger/nullInclusive 1-based page range the chunk covers; both null on a file with no pages.
chunks[].charsintegerCharacter length of the full chunk text — present under output=terse too, so a caller can budget before asking for the text.
chunks[].scorenumber/nullThe keyword relevance score in q mode; null in every ordered mode.
chunks[].textstringThe chunk's text. Absent under output=terse.
next_cursorstring/nullOpaque continuation token for the last chunk actually emitted, to pass back as cursor. Treat it as an opaque string — never construct, parse or store one, and always send back the token from the most recent response; a token held over from an earlier release is refused as an invalid window. It carries the file version it was issued against, so a walk can never straddle two versions. null when the window is exhausted — that, not a short page, is how you know you are done.
truncatedbooleantrue when whole chunks were withheld by max_bytes.

Reading a whole file: call with no selector, then keep re-calling with cursor set to the previous next_cursor until next_cursor is null, concatenating text. Chunks are contiguous and in order; joining them with a newline between chunks reproduces the extracted text apart from the whitespace the splitter dropped at each boundary.

Ordered modes and the byte budget. In the ordered modes (no selector, page, chunk_from/chunk_to) the response stops BEFORE the chunk that would push it past max_bytes; text is never cut inside a chunk, and at least one chunk is always returned even when that one chunk is larger than the budget. truncated: true means matched chunks were withheld — not that a chunk's text was shortened — and next_cursor is the last chunk actually sent, so continuing from it loses nothing.

Relevance mode (q). q ranks this one file's chunks by keyword match (the terms are matched independently, so a natural-language question still returns the passage carrying the most of its words first). It returns the top limit chunks — default 3 — sorted by score descending, each with its FULL text: max_bytes is deliberately not applied, so a hit is never silently dropped. Use it to locate and quote a clause without walking the file; use the ordered modes to read the file through.

output=terse returns the same envelope and the same chunk list with text omitted — a chunk map of positions, page ranges and chars. The byte budget is not spent on a response that carries no text, so a terse page is never shortened.

A File With No Extracted Text

This is a normal 200, not an error:

{
  "result": true,
  "node_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4",
  "name": "scan.jpg",
  "mimetype": "image/jpeg",
  "indexed": false,
  "complete": false,
  "indexed_version_id": null,
  "page_addressable": false,
  "num_pages": null,
  "total_chunks": 0,
  "chunks": [],
  "next_cursor": null,
  "truncated": false
}

indexed: false means this version has no text in the index — it may never have been processed, processing may still be queued, or the format may carry no extractable text. It never means the read failed: a failure to read the index is a 500, so indexed: false is always a statement about the file and never about the platform.

Error Responses

Error CodeHTTP StatusDescription
1609 (Not Found)404Node not found, or the node is in the trash
1605 (Invalid Input)406Node is a folder or a link — only files and notes carry extracted text
1605 (Invalid Input)406Window parameters conflict or are out of range: more than one selector, q with cursor, chunk_to without chunk_from, chunk_from greater than chunk_to, a chunk_from/chunk_to position at or beyond 10000, a cursor that was not taken verbatim from the most recent response, a cursor used with a chunk_from/chunk_to range it does not fall inside, or a value outside the documented bounds
1680 (Access Denied)401Share caller has no download permission, or the file is virus-flagged (share path)
1652 (Resource Not Found)404The file's content is no longer available
1654 (Internal Error)500Content temporarily unavailable — the text could not be read. Retry; never treat this as “the file has no text”
GET /current/workspace/{workspace_id}/storage/content/

Score several named files against one query and return the passages that answered it.

This is the relevance mode of the single-file route above, asked of up to ten files at once, so a caller assembling context for a prompt makes one request instead of ten. The chunk objects are identical to that route's, field for field, so the two can be mixed freely.

Note the shape of the path: the file ids travel in the nodes query parameter, not in the path, so this route sits beside search/ rather than under a {node_id}.

Each file is scored against itself. Every file is ranked only among its own chunks, and the scores are not comparable BETWEEN files — keyword relevance scores are only meaningful within one result set. Take the top chunks per file; do not merge the lists and re-sort them by score.

Auth required. Permission: View (workspace) — the same gate as the single-file route. Workspace only; there is no share form of this route.

Path Parameters

ParameterTypeRequiredDescription
{workspace_id}stringYes19-digit profile ID

Query Parameters

ParameterTypeRequiredDefaultDescription
nodesstringYesComma-separated node ids, 1–10 of them. Both spellings are accepted — with or without hyphens. Blank segments are ignored (a,,b names two files) and a repeated id is de-duplicated silently.
qstringYesThe query the files are scored against. 1–512 characters. Required here — this route publishes no other way to select text.
limitintegerNo3Chunks returned per file. 120.
max_bytesintegerNo32768UTF-8 byte budget over the emitted text, spent per file — same name, default and bounds as the single-file route's max_bytes, but ten files at the cap means ten separate budgets, not one shared pot: the response ceiling is nodes × max_bytes, roughly 320 KiB of text at ten files and the default. At the maximum max_bytes, the ceiling is 10 × 262144 ≈ 2.5 MiB of text — a deliberate opt-in, equal to the single-file route's per-request bound times the node cap. 1024262144. Text is never cut inside a chunk — a file's emission stops before the chunk that would overflow its budget, and at least one chunk is always returned.
outputstringNofullterse returns the chunk map with no text; standard and full include it.

There is no page, chunk_from/chunk_to or cursor here: those address a walk through ONE file, which the single-file route already serves. Follow a passage found here by calling that route with the position this one returned.

curl Example

curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/content/?nodes=2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4,2azz3-bexth-xvixb-cpq37-azkzx-xinq&q=retention%20policy&limit=3" \
  -H "Authorization: Bearer {jwt_token}"

Response

{
  "result": true,
  "q": "retention policy",
  "limit": 3,
  "nodes": {
    "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4": {
      "name": "Master Services Agreement.pdf",
      "mimetype": "application/pdf",
      "indexed": true,
      "indexed_version_id": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5",
      "page_addressable": true,
      "num_pages": 6,
      "total_chunks": 12,
      "chunks": [
        {"position": 4, "sequence": 1005, "chunk_index": 4, "start_page": 3, "end_page": 4, "chars": 4102, "score": 7.25, "text": "... 9. RECORD RETENTION ..."}
      ],
      "truncated": false
    }
  },
  "missing": [
    {"id": "2azz3-bexth-xvixb-cpq37-azkzx-xinq", "reason": "trashed"}
  ]
}

Response Fields

FieldTypeDescription
q, limitstring, integerEchoed back as applied, so a caller can see the default limit that was used.
nodesobjectKeyed by hyphenated node id, in the order the ids were named. An object even when empty — a caller keying into it never has to handle a list.
nodes.{id}.name, nodes.{id}.mimetypestringTaken from the file itself, never from the text index.
nodes.{id}.indexedbooleanWhether this version has any extracted text at all — describes the whole file, not the query window.
nodes.{id}.indexed_version_idstring/nullThe file version the returned chunks were indexed from. null when indexed is false.
nodes.{id}.page_addressablebooleantrue only when EVERY chunk of the file carries a page range — the same meaning as on the single-file route.
nodes.{id}.chunksarrayThat file's best-matching chunks, score descending, at most limit of them. Identical in fields and formatting to the single-file route's chunks: position, sequence, chunk_index, start_page, end_page, chars, score, text. position means the same thing and can be sent straight back to the single-file route as chunk_from.
nodes.{id}.total_chunksintegerChunks in the WHOLE file, not in this response. A file that is indexed but matched nothing comes back with an empty chunks list and a non-zero total_chunks; a file with no extracted text at all comes back with total_chunks: 0.
nodes.{id}.num_pagesinteger/nullPage count of the converted document; null when the format has no pages.
nodes.{id}.truncatedbooleantrue when that file's chunks were cut short by max_bytes — not by limit. A file whose text matched in more places than limit still returns truncated: false; a chunks length equal to limit means more matches may exist — raise limit to see them.
missingarrayOne entry per named id the read could not answer for: {id, reason}. One unreadable id never costs you the others.
missing[].reasonstringnot_found (no such file in this workspace, or its content is gone), trashed (the file is in the bin), or not_text (a folder or a link, which carries no extracted text).

complete and next_cursor are deliberately not published here — those describe a walk through ONE file, which this route does not do. Ask the single-file route when you need them.

A failure to read the text index is a 500, never an empty chunks list. An empty list always means those files hold nothing matching your query, and never that the platform could not look.

Error Responses

Error CodeHTTP StatusDescription
1605 (Invalid Input)406nodes names no ids, names more than 10, or contains a malformed id; q is missing, empty or longer than 512 characters; limit is outside 120; max_bytes is outside 1024262144
1654 (Internal Error)500Content temporarily unavailable — the text could not be read. Retry; never treat this as “these files have no matching text”
1654 (Internal Error)500A named node could not be retrieved (a backend read failure at the node or physical-record lookup) — never reported as missing/not_found; the whole request fails instead.

Add File from Upload

POST /current/workspace/{workspace_id}/storage/{parent_id}/addfile/
POST /current/share/{share_id}/storage/{parent_id}/addfile/

Add a previously uploaded file to storage.

Auth required. Permission: Guest (workspace), file creation permission (share).

Path Parameters

ParameterTypeRequiredDescription
{workspace_id} or {share_id}stringYes19-digit profile ID
{parent_id}stringYesParent folder OpaqueId or "root"

Request Body (form-encoded)

ParameterTypeRequiredDescription
namestringYesFilename for the new file. 1–255 characters (counted as characters, not bytes).
fromstringYesJSON-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).

{"type": "upload", "upload": {"id": "{upload_id}"}}
{"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

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

{
  "result": true,
  "node": {
    "id": "2emaf-exxpw-thkzj-5rlym-ocyoh-iufa",
    "type": "file",
    "name": "document.pdf",
    "parent": "root",
    "size": 5242880,
    "hash": "d41d8cd98f00b204e9800998ecf8427e",
    "hash_algo": "md5",
    "mimetype": "application/pdf",
    "mimecategory": "document",
    "version": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5",
    "created": "2025-01-15 10:30:00 UTC",
    "modified": "2025-01-15 10:30:00 UTC",
    "restricted": false,
    "dmca": false,
    "locked": false
  }
}

Error Responses

Error CodeHTTP StatusDescription
1605 (Invalid Input)406Upload session not found or not associated with your account
1605 (Invalid Input)406Upload is not complete
1609 (Not Found)404Parent folder not found
1605 (Invalid Input)406Parent node is not a folder
1609 (Not Found)404Parent folder is in trash
1605 (Invalid Input)406Name conflict (only when using FAIL strategy)
1693 (Temporarily Unavailable)503Chunk manifest not yet durable — retry after a brief delay

Notes:

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

ParameterTypeRequiredDescription
{workspace_id}stringYes19-digit workspace profile ID
{parent_id}stringYesParent folder OpaqueId or "root"

Request Body (form-encoded)

ParameterTypeRequiredDescription
link_target_typestringYesMust be "share"
sharestringYesShare identifier to link

curl Example

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

{"result": true}

Error Responses

Error CodeHTTP StatusDescription
1683 (Resource Missing)404Share not found or not accessible
1605 (Invalid Input)406Share does not belong to this workspace
1605 (Invalid Input)406A link to this share already exists (only one per share)
1605 (Invalid Input)406Share has no title or custom URL to name the link node with
1610 (General Error)500The link node was created but the share's back-link could not be saved; the node is removed again and the request fails — retry

The link node is named after the share's title, falling back to its custom URL when no title is set.

Create Folder

POST /current/workspace/{workspace_id}/storage/{parent_id}/createfolder/
POST /current/share/{share_id}/storage/{parent_id}/createfolder/

Create a new folder.

This endpoint is idempotent by default: if a folder with the same name already exists in the parent, the existing folder is returned (HTTP 200) instead of creating a duplicate. Pass force=true to bypass this and always create a new folder (auto-renamed on a name collision, e.g. Documents (2)).

Auth required. Permission: Guest (workspace), folder creation permission (share).

Path Parameters

ParameterTypeRequiredDescription
{workspace_id} or {share_id}stringYes19-digit profile ID
{parent_id}stringYesParent folder OpaqueId or "root"

Request Body (form-encoded)

ParameterTypeRequiredDescription
namestringYesFolder name. 1–255 characters (counted as characters, not bytes).
forcebooleanNoWhen 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

curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/root/createfolder/" \
  -H "Authorization: Bearer {jwt_token}" \
  -d 'name=Documents'

Response

{
  "result": true,
  "node": {
    "id": "2uvd6-rylta-qajvp-y3yr6-fzadn-e4rc",
    "type": "folder",
    "name": "Documents",
    "parent": "root",
    "created": "2025-01-15 10:30:00 UTC",
    "modified": "2025-01-15 10:30:00 UTC",
    "restricted": false,
    "dmca": false,
    "locked": false
  }
}

Error Responses

Error CodeHTTP StatusDescription
1605 (Invalid Input)406A non-folder item already uses this name in the parent folder
1609 (Not Found)404Parent folder not found
1605 (Invalid Input)406Parent node is not a folder
1609 (Not Found)404Parent folder is in trash
1680 (Access Denied)401No 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

ParameterTypeRequiredDescription
{workspace_id}stringYes19-digit workspace profile ID
{parent_id}stringYesParent folder OpaqueId or "root"

Request Body (form-encoded)

ParameterTypeRequiredConstraintsDescription
namestringYes1–255 characters (counted as characters, not bytes); must end in .mdNote name
contentstringYesMax 100 KBMarkdown content

curl Example

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

{
  "result": true,
  "note": {
    "id": "2ik5q-a43cm-uixi2-van5r-3eolo-7mue",
    "type": "note",
    "name": "meeting-notes.md",
    "parent": "root",
    "mimetype": "text/markdown",
    "created": "2025-01-15 10:30:00 UTC",
    "modified": "2025-01-15 10:30:00 UTC"
  }
}

Error Responses

Error CodeHTTP StatusDescription
1605 (Invalid Input)406Filename must end with .md
1605 (Invalid Input)406Duplicate name in parent folder
1609 (Not Found)404Parent 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

ParameterTypeRequiredDescription
{workspace_id}stringYes19-digit workspace profile ID
{node_id}stringYesNote OpaqueId

Request Body (form-encoded)

ParameterTypeRequiredConstraintsDescription
namestringNo1–255 characters (counted as characters, not bytes); must end in .mdNew note name
contentstringNoMax 100 KB, non-blankNew markdown content (an empty or whitespace-only value is rejected)
if_version_idstringNoVersion OpaqueIdCompare-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 to guard against overwriting concurrent edits. version is returned at every detail level, including ?output=terse, so a compact read is a valid base for a later conditional write.

At least one of name or content is required.

curl Example

curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ik5q-a43cm-uixi2-van5r-3eolo-7mue/updatenote/" \
  -H "Authorization: Bearer {jwt_token}" \
  -d 'content=# Updated Notes\n\nRevised content here.'

Response

{
  "result": true,
  "note": {
    "id": "2ik5q-a43cm-uixi2-van5r-3eolo-7mue",
    "type": "note",
    "name": "meeting-notes.md",
    "parent": "root",
    "mimetype": "text/markdown",
    "created": "2025-01-15 10:30:00 UTC",
    "modified": "2025-01-20 14:45:00 UTC"
  }
}

Conflict Response (if_version_id mismatch)

When if_version_id is supplied and does not match the note's current version, the request is rejected with HTTP 409 Conflict and no version is created. error.params is a list of parameter entries, matching the structured detail used elsewhere in this API:

{
  "error": {
    "code": 113958,
    "text": "The note was modified since the supplied version",
    "params": [
      {
        "name": "if_version_id",
        "kind": "conflict",
        "message": "The note was modified since the supplied version. current_version_id=3jnix-kx7lb-pf2lf-qw6jd-6jng2-hqjn",
        "code": 174450,
        "reason": "conflict_version_mismatch",
        "current_version_id": "3jnix-kx7lb-pf2lf-qw6jd-6jng2-hqjn"
      }
    ]
  }
}

How to recognise this conflict. Branch on params[].reason == "conflict_version_mismatch" — the only field naming the cause. If your client keeps only the four standard entry fields, params[].name == "if_version_id" with params[].kind == "conflict" proves a precondition on that parameter failed, not which one, so treat it as a generic conflict rather than as proof of a version mismatch. Do not branch on the HTTP status — 409 also reports several unrelated conditions — and do not branch on the numeric code, which is a diagnostic identifier and is not a stable indicator of the cause.

kind here is conflict, which extends the values used for validation failures (missing, invalid, type_mismatch, unknown_parameter). It is distinct from invalid on purpose: invalid means the supplied if_version_id was not a well-formed id, whereas conflict means it was well-formed and the version had moved.

Rebasing. Take the current version from current_version_id, re-read the note, and re-apply your edit against it. The id is also appended to message in the fixed form current_version_id=<id> for clients that surface only the message text. Do not resend the same request unchanged — the version has moved and will stay moved, so an unmodified retry cannot succeed.

If the current state cannot be resolved at the time of the conflict, current_version_id is omitted and the message carries no id suffix; the entry still carries name, kind, message, code and reason, so the conflict remains identifiable.

Error Responses

Error CodeHTTP StatusDescription
1609 (Not Found)404Note not found
1605 (Invalid Input)406Node is not a note
1609 (Not Found)404Note is in trash
1605 (Invalid Input)406No content or name provided
1605 (Invalid Input)406Duplicate name in parent folder
113958409if_version_id did not match the note's current version. error.params[] carries the conflict entry — see Conflict Response above. (1660 is not the value of error.code.)
1700 (Forbidden)403realtime-note token does not match the requested note or workspace
1700 (Forbidden)403realtime-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

ParameterTypeRequiredDescription
{workspace_id} or {share_id}stringYes19-digit profile ID
{node_id}stringYesNote OpaqueId

Query Parameters

ParameterTypeRequiredDescription
version_idstringNoSpecific version OpaqueId to read
tokenstringNoDownload token (share only — bypasses JWT auth)

curl Example

# Workspace
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ik5q-a43cm-uixi2-van5r-3eolo-7mue/readnote/" \
  -H "Authorization: Bearer {jwt_token}"

# Share
curl -X GET "https://api.fast.io/current/share/1234567890123456789/storage/2ik5q-a43cm-uixi2-van5r-3eolo-7mue/readnote/" \
  -H "Authorization: Bearer {jwt_token}"

# Share with download token (no JWT needed)
curl -X GET "https://api.fast.io/current/share/1234567890123456789/storage/2ik5q-a43cm-uixi2-van5r-3eolo-7mue/readnote/?token={download_token}"

Response

{
  "result": true,
  "content": "# Meeting Notes\n\nDiscussed project timeline.",
  "note": {
    "id": "2ik5q-a43cm-uixi2-van5r-3eolo-7mue",
    "type": "note",
    "name": "meeting-notes.md",
    "parent": "root",
    "mimetype": "text/markdown",
    "version": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5",
    "created": "2025-01-15 10:30:00 UTC",
    "modified": "2025-01-15 10:30:00 UTC"
  }
}

Response Fields

FieldTypeDescription
contentstringSanitized markdown content of the note
noteobjectFull note node object (same shape as other node responses)

Error Responses

Error CodeHTTP StatusDescription
1605 (Invalid Input)406Invalid node ID
1609 (Not Found)404Note not found
1605 (Invalid Input)406Node is not a note
1609 (Not Found)404Note is in trash
1609 (Not Found)404Version not found
1605 (Invalid Input)406Version does not belong to this note
1609 (Not Found)404Version data no longer available
1680 (Access Denied)401No permission to read notes (share only)
1680 (Access Denied)401No permission to read notes you did not create (share, creator-only restriction)
1700 (Forbidden)403realtime-note token does not match the requested note or workspace
1700 (Forbidden)403realtime-note token lacks read permission

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. An optional if_version_id parameter adds a compare-and-swap precondition on a content replace or a rename — see Conflict Response below.

Auth required. Permission: Guest (workspace), file modification permission (share).

Path Parameters

ParameterTypeRequiredDescription
{workspace_id} or {share_id}stringYes19-digit profile ID
{node_id}stringYesNode OpaqueId

Request Body (form-encoded)

ParameterTypeRequiredConstraintsDescription
namestringNo1–255 characters (counted as characters, not bytes)New node name
fromstringNoJSON-encodedNew file content source (same format as addfile)
metadata_titlestringNoMax 50 charsCustom title override
metadata_shortstringNoMax 2048 charsCustom short description override
if_version_idstringNoVersion OpaqueIdCompare-and-swap precondition — the update proceeds only if the node's current version still matches this value; otherwise the request is rejected with 409 Conflict and no change is made. Applies to a content replace via either from.type=upload or from.type=hash, and to a rename via name. It is refused with 406 on a metadata-only update, which creates no new version (see below). Omit it and behavior is unchanged — last write wins. A present-but-empty value (if_version_id=) is rejected as invalid input, not treated as omitted.

At least one field should be provided. if_version_id does not itself count toward that requirement — supplying only if_version_id (with no name, from, metadata_title, or metadata_short) still returns “No update parameters were specified”.

curl Example (rename)

curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/update/" \
  -H "Authorization: Bearer {jwt_token}" \
  -d 'name=new-document-name.pdf'

curl Example (replace content and update metadata)

curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/update/" \
  -H "Authorization: Bearer {jwt_token}" \
  -d 'from={"type":"upload","upload":{"id":"upload_opaque_id"}}' \
  -d 'metadata_title=Updated Report' \
  -d 'metadata_short=Q1 2025 revision'

Response

{
  "result": true,
  "node": {
    "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4",
    "type": "file",
    "name": "new-document-name.pdf",
    "parent": "root",
    "size": 5242880,
    "version": "3ak5n-dr47a-qnylo-kzv6c-e6bnm-3u3c",
    "modified": "2025-01-22 11:00:00 UTC"
  }
}

A precondition must have something that can invalidate it. if_version_id is refused with 406 when the request supplies neither from nor name — a metadata-only update. Custom metadata does not create a new version, so the precondition would still be satisfied for the next writer: two callers holding the same version could both succeed and the second would silently overwrite the first, while both were told a compare-and-swap protected them. Send it together with name or from, or omit it. Metadata alongside a rename or a content replace is fine — those do create a version, so the precondition is real.

Conflict Response (if_version_id mismatch)

When if_version_id is supplied and does not match the node's current version, the request is rejected with HTTP 409 Conflict and no new version is created. error.params is a list of parameter entries, the same shape documented for updatenote above:

{
  "error": {
    "code": 180212,
    "text": "The file was modified since the supplied version",
    "params": [
      {
        "name": "if_version_id",
        "kind": "conflict",
        "message": "The file was modified since the supplied version. current_version_id=3jnix-kx7lb-pf2lf-qw6jd-6jng2-hqjn",
        "code": 106523,
        "reason": "conflict_version_mismatch",
        "current_version_id": "3jnix-kx7lb-pf2lf-qw6jd-6jng2-hqjn",
        "contested": true,
        "rebase_count": 3,
        "contested_since": "2026-08-26 04:00:00 UTC"
      }
    ]
  }
}

Contention (contested, rebase_count, contested_since). A sliding ~10-minute window of how many writes this node has refused, so a losing writer can tell bad luck from a genuine fight. rebase_count counts conflicts inside the window including this one; contested is true from the second onward; contested_since is when the window opened. contested: true means ESCALATE TO A HUMAN — which is not the same as “stop writing”, and what it implies depends on what you are. An autonomous agent deciding whether to loop should stop and surface: retrying against a live editor is how one conflict becomes a storm. A stateful relay holding a person’s live editing session must NOT stop persisting — its “retry” is the mechanism by which that person’s unsaved keystrokes reach the platform, so stopping silently drops their work. Escalate by telling the human a conflict is live, and keep holding their content.

These three keys are OMITTED, never zeroed, when the signal is unavailable. Absent means unknown; it does not mean uncontested. Treat a missing block as “no information” and fall back to your normal retry policy, not as permission to retry.

Shown for the workspace endpoint; the share endpoint returns the identical shape with outer error.code 158175 and nested params[].code 101301. Branch on params[].reason == "conflict_version_mismatch" — the only field naming the cause — and not on the 409 status (which also reports unrelated conditions) or on the numeric code (assigned per call site, so it differs between the workspace and share variants of this same endpoint, and between this endpoint and updatenote). See Conflict Response and How to recognise this conflict under Update Note above for the full field-by-field rationale; it applies unchanged here.

Rebasing. Take the current version from current_version_id, re-read the node (or re-fetch its version via list/details), and re-apply your update against it. Do not resend the same request unchanged — the version has moved and will stay moved, so an unmodified retry cannot succeed.

Link Rename Refusal. Supplying if_version_id together with a new name is refused with HTTP 406 when the target node is a link, rather than attempted: renaming a link propagates the new name to the link's target before the version check runs, so a rejected write could still leave the rename applied. Rename the link without if_version_id, or supply if_version_id without a new name.

Error Responses

Error CodeHTTP StatusDescription
1609 (Not Found)404Node not found
1605 (Invalid Input)406Notes can only be renamed here — supply only name; use updatenote for content, title, or description changes
1605 (Invalid Input)406No update parameters were specified
1605 (Invalid Input)406Cannot update a folder or link with file data
1605 (Invalid Input)406Name conflict — a file or folder with that name already exists in this location
1693 (Temporarily Unavailable)503Chunk manifest not yet durable when replacing content — retry after a brief delay
1609 (Not Found)404Node is in trash
1680 (Access Denied)401No modify permission (share only)
1680 (Access Denied)401No permission to modify files you did not create (share, creator-only restriction)
158175 / 180212409if_version_id did not match the node's current version, no change made (share / workspace). error.params[] carries the conflict entry — see Conflict Response above.
182375 / 132163406if_version_id supplied together with a new name on a link node (share / workspace) — see Link Rename Refusal above.

Notes:

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

ParameterTypeRequiredDescription
{workspace_id} or {share_id}stringYes19-digit profile ID
{node_id}stringYesNode OpaqueId to move

Request Body (form-encoded)

ParameterTypeRequiredDescription
parentstringYesDestination folder OpaqueId or "root"

curl Example

curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/move/" \
  -H "Authorization: Bearer {jwt_token}" \
  -d 'parent=2qk7d-kri4y-yievb-q5hri-eq4io-hij5'

Response

{"result": true}

Error Responses

Error CodeHTTP StatusDescription
1609 (Not Found)404Source or destination node not found
1609 (Not Found)404Source or destination is in trash
1605 (Invalid Input)406Cannot move a folder into itself or its subfolders
1680 (Access Denied)401No move permission (share only)
1680 (Access Denied)401No permission to move a folder containing files you cannot modify (recursive move requires modify-all, share only)

Notes:

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

ParameterTypeRequiredDescription
{workspace_id} or {share_id}stringYes19-digit profile ID
{node_id}stringYesNode OpaqueId to copy

Request Body (form-encoded)

ParameterTypeRequiredDescription
parentstringYesDestination folder OpaqueId or "root"

curl Example

curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/copy/" \
  -H "Authorization: Bearer {jwt_token}" \
  -d 'parent=2qk7d-kri4y-yievb-q5hri-eq4io-hij5'

Response

{
  "result": true,
  "node": {
    "id": "2fbt2-66lwc-hle6y-kokf2-wahnc-z4py",
    "type": "file",
    "name": "document.pdf",
    "parent": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5"
  },
  "job": null
}

Error Responses

Error CodeHTTP StatusDescription
1609 (Not Found)404Source or destination not found
1605 (Invalid Input)406Destination is not a folder
1609 (Not Found)404Source or destination is in trash
1605 (Invalid Input)406Cannot copy a folder into itself or a descendant — the destination parent is the source folder, or a folder beneath it
1680 (Access Denied)401No permission to view or copy files you did not create (share, creator-only restriction)
1680 (Access Denied)401No permission to copy a folder containing files you cannot view (recursive copy requires view-all, share only)

Notes:

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 (copy). Use mode=move to copy the node and then trash the source.

Auth required. Permission: Guest on source + write access on destination.

Path Parameters

ParameterTypeRequiredDescription
{workspace_id} or {share_id}stringYesSource profile ID
{node_id}stringYesNode OpaqueId to transfer, or "root" for all

Request Body (form-encoded)

ParameterTypeRequiredDescription
instancestringYes19-digit destination workspace or share profile ID
parentstringYesDestination parent folder OpaqueId or "root"
modestringNocopy (default) or move. When move, the source node is trashed after copying. Cannot use mode=move when {node_id} is "root".

curl Example (copy, default)

curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/transfer/" \
  -H "Authorization: Bearer {jwt_token}" \
  -d 'instance=9876543210987654321' \
  -d 'parent=root'

curl Example (move)

curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/transfer/" \
  -H "Authorization: Bearer {jwt_token}" \
  -d 'instance=9876543210987654321' \
  -d 'parent=root' \
  -d 'mode=move'

Response (copy)

{
  "result": true,
  "node": {
    "id": "2rugc-wuylb-far5j-yxics-lla5z-rmbu",
    "type": "file",
    "name": "document.pdf",
    "parent": "root"
  },
  "job": null
}

Response (move)

{
  "result": true,
  "node": {
    "id": "2rugc-wuylb-far5j-yxics-lla5z-rmbu",
    "type": "file",
    "name": "document.pdf",
    "parent": "root"
  },
  "source_trashed": true,
  "job": null
}

Error Responses

Error CodeHTTP StatusDescription
1609 (Not Found)404Source node not found or outside scope
1609 (Not Found)404Source or destination instance (workspace/share) not found
1680 (Access Denied)401No modify permission on the source node (share only)
1680 (Access Denied)401No permission to modify source files you did not create (share, creator-only restriction)
1680 (Access Denied)401No permission to transfer a whole folder or root (recursive copy requires view-all, move requires modify-all, share only)
1680 (Access Denied)401No 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)401Destination is a password-protected public share and a valid share password was not supplied
1680 (Access Denied)401Writing into a personal (user-owned) share requires an identified caller with creation permission (an unauthenticated caller is denied)
1680 (Access Denied)401The 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)406Name conflict at destination, or unsupported/not-allowed transfer
1605 (Invalid Input)406mode=move cannot be used with "root" as the source node
1605 (Invalid Input)406Cannot move/copy a folder into itself or a descendant — the destination parent is the source folder, or a folder beneath it, in the same storage instance

Notes:

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

ParameterTypeRequiredDescription
{workspace_id} or {share_id}stringYes19-digit profile ID
{node_id}stringYesNode OpaqueId, or "trash" to empty the entire trash

curl Examples

# Delete a specific node
curl -X DELETE "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/delete/" \
  -H "Authorization: Bearer {jwt_token}"

# Empty the trash
curl -X DELETE "https://api.fast.io/current/workspace/1234567890123456789/storage/trash/delete/" \
  -H "Authorization: Bearer {jwt_token}"

Response

{
  "result": true,
  "job": null
}

Error Responses

Error CodeHTTP StatusDescription
1609 (Not Found)404Node not found
1609 (Not Found)404Node already in trash
1680 (Access Denied)401No delete permission (share only)
1680 (Access Denied)401No permission to empty trash (share, non-admin)
1693 (Temporarily Unavailable)503Emptying the trash only: another trash operation on this workspace or share is in progress; nothing was changed — retry the same request after a brief delay

Notes:

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

ParameterTypeRequiredDescription
{workspace_id} or {share_id}stringYes19-digit profile ID
{node_id}stringYesOpaqueId of the trashed node

curl Example

curl -X DELETE "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/purge/" \
  -H "Authorization: Bearer {jwt_token}"

Response

{
  "result": true,
  "job": null
}

Error Responses

Error CodeHTTP StatusDescription
1609 (Not Found)404Node not found
1605 (Invalid Input)406Node is not in trash
1680 (Access Denied)401Insufficient permission (share, non-admin)
1693 (Temporarily Unavailable)503Another trash operation on this workspace or share is in progress; nothing was changed — retry the same request after a brief delay

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

ParameterTypeRequiredDescription
{workspace_id} or {share_id}stringYes19-digit profile ID
{node_id}stringYesOpaqueId of the trashed node

curl Example

curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/restore/" \
  -H "Authorization: Bearer {jwt_token}"

Response

{
  "result": true,
  "job": null
}

Error Responses

Error CodeHTTP StatusDescription
1609 (Not Found)404Node not found
1605 (Invalid Input)406Node is not in trash
1605 (Invalid Input)406Node is inside a trashed folder (restore the parent folder instead)
1680 (Access Denied)401No restore permission (share only)
1693 (Temporarily Unavailable)503Another trash operation on this workspace or share is in progress; nothing was changed — retry the same request after a brief delay

List Versions

GET /current/workspace/{workspace_id}/storage/{node_id}/versions/
GET /current/share/{share_id}/storage/{node_id}/versions/

List all versions of a file, note, or folder node.

Workspace route: auth required, View permission on the workspace.

Share route: auth is optional — an anonymous caller may read a public-link share (the share password, when set, must still be satisfied). Access is decided entirely by the share's own permissions.filesystem.file_view policy; there is no workspace-level requirement.

Path Parameters

ParameterTypeRequiredDescription
{workspace_id} or {share_id}stringYes19-digit profile ID
{node_id}stringYesNode OpaqueId

curl Example

curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/versions/" \
  -H "Authorization: Bearer {jwt_token}"

Response

versions is an object carrying a count and an items array — not a bare array.

{
  "result": true,
  "versions": {
    "count": 2,
    "items": [
      {
        "id": "3maag-qdkzs-whhrp-5jifb-zgbni-2udf",
        "type": "file",
        "current_version": true,
        "nodeId": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4",
        "name": "document.pdf",
        "parent": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5",
        "created": "2025-01-20 14:45:00 UTC",
        "size": 5242880,
        "hash": "abc123def456789...",
        "hash_algo": "sha256",
        "mimetype": "application/pdf",
        "mimecategory": "document",
        "previews": { "thumbnail": { "state": "ready" }, "pdf": { "state": "ready" } },
        "virus": { "status": "scanned", "infected": false },
        "ai": { "state": "indexed", "attach": true, "summary": true },
        "file_attributes": {},
        "summary": { "title": "Quarterly Report", "short": "Q4 financial summary", "long": "..." },
        "origin": {
          "type": "User",
          "creator": "9876543210987654321",
          "operations": ["modify"],
          "created": "2025-01-20 14:45:00 UTC"
        },
        "replaces": { "status": "known", "version_id": "34wse-ehjvl-zefmd-2dfeh-hhw66-t4xe" },
        "author": { "status": "known", "user_id": "9876543210987654321", "actor_type": "User", "agent_name": "Claude-2", "agent_name_source": "api_key_label" }
      },
      {
        "id": "34wse-ehjvl-zefmd-2dfeh-hhw66-t4xe",
        "type": "file",
        "current_version": false,
        "nodeId": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4",
        "name": "document.pdf",
        "parent": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5",
        "created": "2025-01-15 10:30:00 UTC",
        "size": 4194304,
        "hash": "def456abc789012...",
        "hash_algo": "sha256",
        "mimetype": "application/pdf",
        "mimecategory": "document",
        "previews": { "thumbnail": { "state": "ready" }, "pdf": { "state": "ready" } },
        "virus": { "status": "scanned", "infected": false },
        "ai": { "state": "indexed", "attach": true, "summary": false },
        "file_attributes": {},
        "summary": null,
        "origin": {
          "type": "User",
          "creator": "9876543210987654321",
          "operations": ["create"],
          "created": "2025-01-15 10:30:00 UTC"
        },
        "replaces": { "status": "none", "version_id": null },
        "author": { "status": "known", "user_id": "9876543210987654321", "actor_type": "User", "agent_name": null, "agent_name_source": null }
      }
    ]
  }
}

Response Fields

FieldTypeDescription
versions.countintegerNumber of entries in versions.items
versions.itemsarrayThe version entries, described below

Per Version Entry

FieldTypeDescription
idstringVersion OpaqueId
typestringNode type: "file", "folder", or "note"
current_versionbooleantrue if this is the node's current (live) version
nodeIdstringOpaqueId of the node this version belongs to
namestringNode name at this version
parentstringParent folder OpaqueId, or "root" / "trash"
createdstringVersion creation timestamp (YYYY-MM-DD HH:MM:SS UTC)
deletedstringPresent only on a version created by trashing (YYYY-MM-DD HH:MM:SS UTC)
deleted_fromstring/nullOpaqueId of the folder the node was trashed from; present alongside deleted
sizeintegerFile size in bytes (file/note versions)
hashstringContent hash for this version (file/note versions)
hash_algostringHash algorithm, e.g. "sha256" (file/note versions)
mimetypestringMIME type (file/note versions)
mimecategorystringMIME category (file/note versions)
previewsobjectPreview state keyed by preview type, each { "state": "..." } (file/note versions)
virusobjectVirus scan result: status ("scanned", "unscanned", "unknown"), plus infected and/or reason (file/note versions)
aiobjectAI processing state: state ("disabled", "pending", "in_progress", "ready", "indexed", "failed"), attach (boolean), summary (boolean) (file/note versions)
file_attributesobjectMetadata read out of the file itself: media_metadata and/or exif_metadata when available, otherwise empty (file/note versions). Returned only to callers permitted to download the file — a view-only caller gets {} (see Embedded File Metadata)
summaryobject/nullAI summary { title, short, long }, or null when none (file/note versions)
originobjectProvenance for this version — see below
replacesobjectWhich version this version replaced — see below
authorobjectWho created this version — see below

origin Object

FieldTypeDescription
origin.typestringIdentifier type of the actor that produced the version (e.g. "User"), or "unknown" for versions that predate origin tracking
origin.creatorstringCreator profile ID, "anonymous" for an anonymous public-link contributor, or "unknown"
origin.operationsarrayOperations that produced this version: any of "create", "rename", "move", "modify", "restore"
origin.createdstring/nullWhen the origin record was written (YYYY-MM-DD HH:MM:SS UTC), or null for versions that predate origin tracking

replaces Object

FieldTypeDescription
replaces.statusstring"known", "none", or "unknown"
replaces.version_idstring/nullThe id of the version this one replaced; non-null only when status is "known"

author Object

FieldTypeDescription
author.statusstring"known" or "unknown"
author.user_idstring/nullProfile id credited with creating this version; "anonymous" for an anonymous public-link contributor; null when unknown
author.actor_typestring/nullWhat kind of profile user_id is — usually "User", but not always (see below); null when unknown
author.agent_namestring/nullName of the agent that acted on that account's behalf, when one did
author.agent_name_sourcestring/nullWhere that name came from; null when no agent acted

Folder versions carry only the basic fields (id, type, current_version, nodeId, name, parent, created, replaces, author) plus origin; the file-specific fields above are omitted. A version that was created by trashing carries additional deleted / deleted_from fields.

Error Responses

The Error Code column shows the application error class followed by the per-call-site value returned in error.code.

Error CodeHTTP StatusDescription
1609 (Not Found)193615 / 124906404Node not found (share / workspace)
1609 (Not Found)173577404Node exists but falls outside the share's folder scope (share only)
1609 (Not Found)148373404The workspace folder backing the share was deleted (share only)
1605 (Invalid Input)194361 / 149251406Unsupported node type, for example a link (share / workspace)
1665 (Object Init Failed)167497 / 112869500Node data is corrupted (share / workspace)
1664 (Datastore Error)186805 / 137723500Version lookup failed (share / workspace)
1680 (Access Denied)144499401Share file_view is none (share only)
1680 (Access Denied)134467401No file-view access to this node (share only)
1680 (Access Denied)120944401Share file_view is owned and the caller did not create this node (share only)

Notes:

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

ParameterTypeRequiredDescription
{workspace_id} or {share_id}stringYes19-digit profile ID
{node_id}stringYesFile OpaqueId

Request Body (form-encoded)

ParameterTypeRequiredDescription
version_idstringYesOpaqueId of the version to restore

curl Example

curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/restore-version/" \
  -H "Authorization: Bearer {jwt_token}" \
  -d 'version_id=34wse-ehjvl-zefmd-2dfeh-hhw66-t4xe'

Response

{
  "result": true,
  "new_version": {
    "id": "3qced-56d4r-4o7q3-w3xru-zw3m5-nutj",
    "type": "file",
    "current_version": true,
    "nodeId": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4",
    "name": "original-name.pdf",
    "parent": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5",
    "created": "2025-01-25 09:00:00 UTC",
    "size": 4194304,
    "hash": "def456abc789012...",
    "hash_algo": "sha256",
    "mimetype": "application/pdf",
    "mimecategory": "document",
    "previews": { "thumbnail": { "state": "ready" }, "pdf": { "state": "ready" } },
    "virus": { "status": "scanned", "infected": false },
    "ai": { "state": "indexed", "attach": true, "summary": false },
    "file_attributes": {},
    "summary": null,
    "origin": {
      "type": "User",
      "creator": "9876543210987654321",
      "operations": ["restore"],
      "created": "2025-01-25 09:00:00 UTC"
    },
    "replaces": { "status": "unknown", "version_id": null },
    "author": { "status": "known", "user_id": "9876543210987654321", "actor_type": "User", "agent_name": null, "agent_name_source": null }
  },
  "node": {
    "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4",
    "type": "file",
    "name": "original-name.pdf",
    "version": "3qpwy-4fuxn-o5zkk-qqqh5-w6zqd-habx"
  }
}

new_version carries the same per-version fields as a List Versions entry (it is the freshly created current version); node is the standard node resource (see Node Object Schema).

Error Responses

Error CodeHTTP StatusDescription
1609 (Not Found)404Node not found
1605 (Invalid Input)406Can only restore file versions (not folders)
1605 (Invalid Input)406Cannot restore version of trashed file
1609 (Not Found)404Version not found
1605 (Invalid Input)406Version does not belong to this file
1609 (Not Found)404Version data no longer available
1680 (Access Denied)401No permission to restore versions (share only)

Notes:

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

ParameterTypeRequiredDescription
{workspace_id} or {share_id}stringYes19-digit profile ID
{node_id}stringYesFile or note OpaqueId

Query Parameters

ParameterTypeRequiredDescription
tokenstringNoDownload token from requestread (bypasses JWT auth)
version_idstringNoSpecific version OpaqueId to download

curl Examples

# Download with JWT auth
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/read/" \
  -H "Authorization: Bearer {jwt_token}" \
  -o output.pdf

# Download with token (no JWT needed)
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/read/?token={download_token}" \
  -o output.pdf

# Download specific version
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/read/?version_id=34wse-ehjvl-zefmd-2dfeh-hhw66-t4xe" \
  -H "Authorization: Bearer {jwt_token}" \
  -o output_v1.pdf

# Byte-range request (streaming)
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/read/" \
  -H "Authorization: Bearer {jwt_token}" \
  -H "Range: bytes=0-1023"

Response: Binary file content streamed directly.

Error Responses

Error CodeHTTP StatusDescription
1609 (Not Found)404File not found
1605 (Invalid Input)406Can only read file or note (not folder)
1609 (Not Found)404File is in trash
1609 (Not Found)404Version not found
1605 (Invalid Input)406Version does not belong to this file
1609 (Not Found)404Version data no longer available
1680 (Access Denied)401File 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

ParameterTypeRequiredDescription
{workspace_id} or {share_id}stringYes19-digit profile ID
{node_id}stringYesFile or note OpaqueId

curl Example

curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/requestread/" \
  -H "Authorization: Bearer {jwt_token}"

Response

{
  "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.

Query Parameters

ParameterTypeRequiredDescription
version_idstringNoPin the token to one specific version. Omit it and the token is bound to the node only — the read call can still name its own version_id, and the bytes returned are whatever that read resolves (current, or the version it names).

Pinning a Token to a Version

Pass version_id and the issued token is bound to that exact version. The response then echoes the version it was pinned to:

{
  "result": true,
  "token": "{download_token}",
  "version_id": "3o2ex-os4uz-32bkm-icr5l-uv3jd-546r"
}

Why pin. Without it, the token authorises the node and the version_id on the read selects the bytes — the two are never compared, so a token issued while looking at one version can fetch a different one committed in between. A pinned token cannot: a read naming any other version is rejected. Use this whenever the bytes you read will become the basis of a later write, so the version you record is provably the version you received.

Pass the same version_id on the subsequent read call. A pinned token is rejected on a read that names a different version, and on a read that names none.

Compatibility. version_id is optional and additive — omit it and behaviour is exactly as before. Tokens already issued without it keep working, including on reads that name a version.

Applies to the note read too. GET /current/share/{share_id}/storage/{node_id}/readnote/ accepts the same token and the same version_id, and enforces the same pin.

Errors are raised at mint time rather than later at fetch time: an unknown version returns 404, a version belonging to a different file returns 406, and a version whose content is no longer stored returns 404. A backend failure returns a 5xx and is safe to retry — it is deliberately not reported as a missing version, which would say the resource is gone when it is not.

Error Responses

Error CodeHTTP StatusDescription
1609 (Not Found)404File not found
1605 (Invalid Input)406Can only read file or note
1609 (Not Found)404File is in trash
182855 (workspace) / 125883 (share)404Version not found
141154 (workspace) / 105838 (share)406version_id does not belong to this file (rejected at mint time, not at fetch time)
164665 (workspace) / 191654 (share)404Version data no longer available
129880 (workspace) / 190664 (share)5xxUnable to verify version (backend failure, safe to retry)
1680 (Access Denied)401No 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

ParameterTypeRequiredDescription
{workspace_id} or {share_id}stringYes19-digit profile ID
{folder_id}stringYesFolder OpaqueId, or "root" for entire storage

curl Example

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:

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

ParameterTypeDefaultDescription
page_sizeint100One of: 100, 250, 500 (snapped to nearest)
cursorstringOpaque cursor string from previous response
typestringFilter by node type: file, folder, link, note

curl Example

curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/recent/?type=file&page_size=250" \
  -H "Authorization: Bearer {jwt_token}"

Response

{
  "result": true,
  "nodes": {
    "count": 3,
    "items": [
      {
        "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4",
        "name": "report.pdf",
        "type": "file",
        "parent": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5",
        "size": 2048576,
        "modified": "2025-02-18 14:30:00 UTC",
        "created": "2025-02-17 10:00:00 UTC"
      }
    ]
  },
  "pagination": {
    "has_more": true,
    "next_cursor": "eyJsYXN0X3VwZGF0ZWQiOiIyMDI1LTAyLTE4...",
    "page_size": 100
  }
}

Response Fields

FieldTypeDescription
nodes.countintegerNumber of nodes in this page
nodes.itemsarrayArray of node resources
pagination.has_morebooleanWhether more pages exist
pagination.next_cursorstring/nullCursor for the next page
pagination.page_sizeintegerEffective page size used

Error Responses

Error CodeHTTP StatusDescription
1605 (Invalid Input)406Invalid pagination cursor
1680 (Access Denied)401Insufficient permissions to view files (share only)
1609 (Not Found)404Orphaned workspace folder share

Notes:

GET /current/workspace/{workspace_id}/storage/search/
GET /current/share/{share_id}/storage/search/

Search files by filename, by content, or by both. By default a query matches the filename and the content together and blends the two into one ranked list. When you are looking for a file, ask for the filename side explicitly with search_in=filename and pick a matching style with name_match.

Auth required. Permission: View (workspace), search + file view permissions (share).

Query Parameters

ParameterTypeRequiredDescription
searchstringYesSearch query string. Under a precise name_match this string is the pattern.
search_instringNoWhich side of the file to match: filename, content, or both (default). See Search Modes below.
name_matchstringNoHow the filename is matched: auto (default), exact, prefix, contains, or glob. Ignored when search_in=content.
case_sensitivestringNotrue / false / 1 / 0. Default false. Applies to the precise name_match values; ignored under auto.
files_scopestringNoComma-separated nodeId:versionId pairs, query string only. Narrows the meaning-based (semantic) leg ONLY — filename and summary matches are not restricted by it. Takes nodes of type: "file" or type: "note"; a folder or link is refused. See Scoping to Files or Folders below.
folders_scopestringNoComma-separated nodeId:depth pairs, query string only. Narrows the meaning-based (semantic) leg ONLY, same as files_scope. Takes nodes of type: "folder" only; a file, note or link is refused. The :depth is required; a bare node id is refused with 406. See Scoping to Files or Folders below. Folder node ids only — the root and trash folder aliases are refused; omit the scope entirely to search everything.
filtersstring (JSON)NoWorkspace routes only. A JSON array of metadata predicate objects. Narrows the search to files whose extracted metadata satisfies every predicate, before the query runs. Cannot currently be combined with folders_scope. See Filtering by Metadata below.
outputstringNoVerbosity: terse, standard, or full (default). Trims content_snippet and best_chunk.text to a byte budget, and drops summary_short, when intelligence is enabled. See Verbosity below.

search_in, name_match, and case_sensitive are all optional and all additive: omit them and you get exactly the behavior this endpoint has always had — same query, same ranking, same response keys. Values are matched case-insensitively (GLOB and glob are the same value).

curl Example

curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/search/?search=quarterly+report" \
  -H "Authorization: Bearer {jwt_token}"

Search Modes

search_in — which side of the file to match

ValueMatches
filenameThe file's name only. Predictable and pattern-driven — the find-style surface, and the one to reach for when you know what the file is called. It never runs a content lookup, so it behaves identically whether or not AI features are enabled.
contentThe AI's understanding of the file — its AI-generated summary plus meaning-based (semantic) matches on what the file is about.
both(default) Filename and content together, blended and ranked — the behavior that shipped before these parameters existed.

content is not a text scan of the file. Fastio does not index the literal bytes of your documents, so search_in=content is not a substring or full-text search — it matches the AI-generated summary of a file and the file's semantic index. Those are two channels, and intelligence gates only the meaning-based one: switching AI features off stops new semantic matching but does not un-index summaries that were already written, so a content search on a workspace with AI features off still returns hits for files summarized earlier and returns nothing when there are none. Either way the file must have finished indexing at some point (ai.state: indexed): a file uploaded a moment ago is findable by name immediately and by content only once indexing completes. A phrase that appears verbatim inside a document will not necessarily match, and a phrase that never appears in it may match — ask content questions in terms of what a document is about. If you need an exact string, that string is almost always in the filename; use search_in=filename.

name_match — how the filename is matched. Only meaningful when the filename participates in the query (search_in=filename or both); ignored under search_in=content.

ValueMatchesExample
auto(default) Layered relevance — phrase, prefix, fuzzy, stemmed, and substring tiers, with near-exact filename matches promoted to the top. Unchanged behavior.quarterly report finds Q4 Quarterly Report (final).pdf
exactThe whole filename equals the query. Also matches the extensionless base, so you can type the name without knowing the extension.Q4 Report matches Q4 Report.pdf and Q4 Report
prefixThe filename starts with the query, taken literally.Invoice- matches Invoice-2026-0042.pdf
containsThe filename contains the query as a literal substring, anywhere.2026 matches Invoice-2026-0042.pdf
globShell-style pattern over the whole filename: * matches any run of characters (including none), ? matches exactly one.Quarterly*.pdf matches Quarterly Report.pdf

glob spans spaces and hyphens. Because the pattern runs against the complete filename rather than word-by-word, Quarterly*.pdf finds Quarterly Report.pdf, report-*.xlsx finds report-2026-q1.xlsx, and Q?-2026.csv finds Q1-2026.csv. *.pdf gives you every PDF. This is the reason glob exists — it is the only mode that can match across a space or a hyphen in a filename.

Do not escape your query — escaping is handled for you. Under exact, prefix, and contains, * and ? are matched literally: name_match=contains with search=report* looks for a filename that really contains the two characters report* — it will not match report-2026.pdf. That is what makes a filename containing an asterisk searchable at all (name_match=prefix with search=weird* finds weird*name.txt). Only glob treats * and ? as wildcards. A client that pre-escapes its query (report\*) will search for the backslash.

Case sensitivity. case_sensitive defaults to false. Case-insensitive matching folds non-ASCII letters tooÄNDERUNG matches änderung.docx and RÉSUMÉ matches résumé.pdf. It folds case only and does not strip accents, so resume will not match résumé.pdf. Send the query exactly as the user typed it: do not lowercase or otherwise normalize it client-side.

Pattern rules (precise modes only). Under any name_match other than auto: the query must be non-empty after trimming, and at most 256 characters; and a glob pattern made up of nothing but * and ? is rejected because it matches every file. Violations return 1605 (Invalid Input). auto keeps the length rules it has always had — search is not capped on this endpoint.

Search Mode Examples

# Every PDF in the workspace, by name only — no content lookup at all
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/search/?search=*.pdf&search_in=filename&name_match=glob" \
  -H "Authorization: Bearer {jwt_token}"

# A filename that spans a space
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/search/?search=Quarterly*.pdf&search_in=filename&name_match=glob" \
  -H "Authorization: Bearer {jwt_token}"

# Everything whose name starts with a known prefix, case-sensitively
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/search/?search=Invoice-&search_in=filename&name_match=prefix&case_sensitive=true" \
  -H "Authorization: Bearer {jwt_token}"

# Ask what a document is about, ignoring filenames entirely
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/search/?search=revenue+guidance+for+next+year&search_in=content" \
  -H "Authorization: Bearer {jwt_token}"

Response (keyword-only, intelligence disabled)

{
  "result": true,
  "files": {
    "2ltsuq4mjacuv7pgc5ydlxnsjwee4": {
      "name": "Q4 Report.pdf",
      "parent_id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5",
      "path": "Finance/Reports",
      "ancestors": [
        { "id": "2e2f3-inlkh-p63sd-3ungd-vfyvc-gilh", "name": "Finance" },
        { "id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "name": "Reports" }
      ],
      "path_complete": true,
      "type": "file",
      "summary_short": null,
      "best_chunk": null,
      "metadata_match": false,
      "metadata_match_field": null,
      "text_indexed": true
    }
  }
}

Response (hybrid, intelligence enabled)

{
  "result": true,
  "files": {
    "2emafexxpwthkzj5rlymocyohiufa": {
      "name": "MSA - Northwind Traders.pdf",
      "parent_id": "27ifb-ytevm-pzori-ybzul-dro2u-dqcm",
      "path": "Legal/Contracts",
      "ancestors": [
        { "id": "2tyji-byjgb-3f72a-cpvhd-5jw2i-6um7", "name": "Legal" },
        { "id": "27ifb-ytevm-pzori-ybzul-dro2u-dqcm", "name": "Contracts" }
      ],
      "path_complete": true,
      "type": "file",
      "relevance_score": 0.98,
      "raw_score": 4.31,
      "score_source": "metadata",
      "content_snippet": "This Master Services Agreement is entered into by Northwind Traders …",
      "match_source": "keyword",
      "media_segment": null,
      "mimetype": "application/pdf",
      "page": { "start_page": 1, "end_page": 1 },
      "text_indexed": true,
      "summary_short": "Master services agreement with Northwind Traders, effective 2026-01-01.",
      "best_chunk": {
        "text": "This Master Services Agreement is entered into by Northwind Traders …",
        "same_as_snippet": false,
        "page": { "start_page": 1, "end_page": 1 },
        "media_segment": null,
        "score": 6.42,
        "result_type": "doc",
        "position": 0,
        "sequence": 2001,
        "indexed_version_id": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5"
      },
      "metadata_match": true,
      "metadata_match_field": null
    },
    "2ltsuq4mjacuv7pgc5ydlxnsjwee4": {
      "name": "Q4 Report.pdf",
      "parent_id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5",
      "path": "Finance/Reports",
      "ancestors": [
        { "id": "2e2f3-inlkh-p63sd-3ungd-vfyvc-gilh", "name": "Finance" },
        { "id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5", "name": "Reports" }
      ],
      "path_complete": true,
      "type": "file",
      "relevance_score": 1.0,
      "raw_score": 0.87,
      "score_source": "semantic",
      "content_snippet": "Quarterly revenue grew 18% year-over-year …",
      "match_source": "both",
      "media_segment": null,
      "mimetype": "application/pdf",
      "page": { "start_page": 3, "end_page": 3 },
      "text_indexed": true,
      "summary_short": "Q4 revenue and margin review for the North America segment.",
      "best_chunk": {
        "text": "Quarterly revenue grew 18% year-over-year …",
        "same_as_snippet": false,
        "page": { "start_page": 3, "end_page": 3 },
        "media_segment": null,
        "score": 0.87,
        "result_type": "doc",
        "position": 12,
        "sequence": 3001,
        "indexed_version_id": "3mcys-2dr56-rmgdt-nh36b-prmp7-b47q"
      },
      "metadata_match": false,
      "metadata_match_field": null
    }
  },
  "search_metadata": {
    "intelligence_enabled": true,
    "semantic_available": true,
    "scoped": false
  }
}

Response Fields

FieldTypeDescription
filesobjectMap of node OpaqueIds to file info. A matching node appears exactly once, whichever part of the search found it. Keys are the unformatted OpaqueId — the 29-character form with no hyphens — so compare them hyphen-insensitively if you hold ids in the grouped display form.
files.{id}.namestringFile name
files.{id}.parent_idstring|nullParent folder node ID, as it is right now. The server reads the file’s own record on every request and reports the folder it finds there, so this is never the stale value a lagging search index may hold, and details=true is not needed for it. A hit that matched only by meaning (match_source: "semantic") arrives from the content engine without one and is filled the same way. It is empty ("" or null) only when that lookup failed, and path_complete is false on the same row whenever it did.
files.{id}.pathstring|nullWhere the file lives — the names of the folders from the workspace or share root down to the folder holding this hit, joined with /, with no leading or trailing slash ("Finance/Invoices/AR"). The file’s own name is not in it; that is name. "" means the hit sits directly in the root. null whenever path_complete is false — read that flag, not this field, to tell “at the root” from “not known”. Present on every result row, in both hybrid and keyword-only mode and at every output tier. Display value, not an address: folder names are returned exactly as stored and the / separator is not escaped, so a folder whose own name contains / is indistinguishable from a level boundary. Use ancestors or parent_id to address a folder.
files.{id}.ancestorsarrayThe same folders as path, in the same order, as {id, name} objects — id being the folder’s OpaqueId in the formatted (hyphenated) form the rest of the API takes, so each entry is directly usable with /storage/{parent_id}/list/. Whenever path_complete is true, path is exactly these names joined with /, so the two are one answer in two spellings; [] then means the hit is at the root. When path_complete is false this is a best-effort suffix: the folders NEAREST the file, with an unknown number of levels missing from the top — never a prefix, and never padded or guessed. It is [] when nothing could be resolved at all.
files.{id}.path_completebooleanWhether the walk from the file up to the root actually reached it. true means path and ancestors are the complete answer. false means the walk stopped early — a folder could not be read, the chain was longer than the server walks, or the response needed more folder lookups than one request will make — in which case path is null and ancestors holds only the partial suffix above. Every hit is resolved from the file’s CURRENT record, never from the parent_id the row carried — a search index lags the tree, so a file moved a moment ago still names its old folder there, and a path walked from it would be wrong while claiming to be complete. That recovery is ATTEMPTED on every row, so a match_source: "semantic" hit resolves the same as any other, with or without details=true; when the record itself cannot be read, the row reports path_complete: false like any other unresolved one. parent_id is corrected to the live folder when the two disagree AND the walk reached the root, so a corrected value always matches the path beside it. On a row whose walk stopped short, parent_id is left exactly as the index had it rather than replaced with a folder the platform could not place. A false here is never a statement about where the file is; a file at the root reports path_complete: true with path: "".
files.{id}.typestringNode type
files.{id}.relevance_scorefloatA rank-fusion score combining the two retrieval legs — the name/text search and the content search. Each leg contributes by the file's position in that leg, a file found by both legs collects both contributions, and the result is normalised within this result set so the maximum relevance_score in the set is exactly 1.0 and every hit is above 0.0. That is the maximum, not necessarily the first result — ordering is tier-first (see Result Ordering), so a promoted hit with a lower score can come back above it. Its scale is therefore re-derived for every query, and 1.0 means "the best of these results", not "a good result". Use it to order results — do not threshold it, and do not compare it across queries. Hybrid mode only.
files.{id}.raw_scorefloat|nullThe un-rescaled retrieval score, on the scale named by score_source (on a metadata row that is the keyword scale — see below). The merge does not divide, clamp or round it against the other hits that came back with it. That is a statement about rescaling, not a promise the number is constant: re-running retrieval can still return a different value, because a keyword score is BM25 and moves with the index statistics. Not bounded to 0.0–1.0. null when score_source is filename. Hybrid mode only.
files.{id}.score_sourcestringAn attribution — and only three of its four values name a scale. keyword (the keyword engine's BM25 score — unbounded above, and dependent on the index contents and the query terms), semantic (the content engine's similarity score for the passage that ranked the file — the same passage that produced content_snippet, page, and media_segment) and filename (the row was placed by a name match, not by a measured score, so raw_score is null) each name the scale raw_score is on. On a file both legs found, the leg reported is the one that ranked it higher; on an equal position the keyword leg is reported, because its score is always a real measurement while a content score is optional and can come back as 0.0. metadata is not a fourth scale: it says the row was promoted because the query matched its extracted metadata, and its raw_score is still populated and remains on the keyword scale — compare it with other keyword rows, not with semantic ones. A promoted hit ranked higher by the content engine reports semantic rather than metadata; read metadata_match for the promotion itself. There is no both — which legs matched is a separate question, answered by match_source. Decided per hit, so one response can carry all four: group by score_source before comparing any two raw_score values. Hybrid mode only.
files.{id}.content_snippetstring|nullMatching text from the highest-scoring passage. On a semantic or both hit that is the passage the content search ranked the file by. A keyword-only hit now carries one too: the search goes back over the file's indexed text and quotes the best-matching passage, for the top few results of the page only and only where text_indexed is true — so a filename or metadata match is no longer returned without the text behind it. Still null on a keyword result past that cut-off, one whose text nothing matched, or one whose file has no indexed text; and at ?output=terse (that tier skips the per-hit file read the quote is scoped by). A null snippet means there is no matching excerpt in this response — read the file itself for content. Trimmed per output.
files.{id}.match_sourcestringWhich legs of the hybrid search matched this file: keyword (filename/text index only), semantic (content embedding only), or both (matched by BOTH legs — not merely by several semantic passages). Always keyword when intelligence is not enabled.
files.{id}.media_segmentobject|null{start_seconds, end_seconds} for audio/video matches. Hybrid mode only.
files.{id}.mimetypestring|nullFile MIME type. Hybrid mode only. Populated on a semantic-or-both hit as before; a keyword-only hit now carries it too, filled from the file itself — except at ?output=terse (that tier skips the per-hit file read that supplies it).
files.{id}.pageobject|null{start_page, end_page} for paginated documents. Hybrid mode only. A keyword-only hit carries one whenever the search quoted a passage for it — see content_snippet.
files.{id}.text_indexedboolean|nullWhether the file has any indexed text at all — the question a null content_snippet cannot answer on its own. false means there is nothing indexed to quote, so reading the file is the only way to get at its content. true means the file's current version has indexed text (or, where the current version cannot be determined for the row, any still-active indexed version) — but not that this query matched it: a true beside a null snippet says the search did not return a passage for this query, not that the file has none. null means this response could not determine coverage — never that the file is unindexed. Present on every result row, in both hybrid and keyword-only mode.
files.{id}.summary_shortstring|nullThe stored short AI summary of the whole file. null when the file has no summary, and on a share search null whenever the caller's share role may not view summaries. Always null at ?output=terse — the verbosity tier drops it, the same way it trims a node's summary — and always null on the keyword-only response.
files.{id}.best_chunkobject|nullThe highest-scoring real passage of the file, as distinct from a whole-file summary: {text, page, media_segment, score, result_type, position, sequence, indexed_version_id, same_as_snippet}. text is the passage text, trimmed to the same byte budget as content_snippet under output (~200 bytes at terse, ~600 at standard, untrimmed at full); at terse and standard, text is null when same_as_snippet is true (read content_snippet for the text instead). same_as_snippet (boolean) is true when, after both are trimmed to the tier's byte budget, text was byte-identical to content_snippet — the caller then reads content_snippet for the text while still using best_chunk's locator fields — and false otherwise; always false at the default full. page and media_segment are both always present, and at most one of them is ever set: page is {start_page, end_page} (1-based, inclusive) on a document passage (result_type: "doc"), media_segment is {start_seconds, end_seconds} on a transcript passage (result_type: "transcript"), and the other is null — both are null for a passage from a source with no locator. score is the passage's own un-rescaled retrieval score: on a semantic or both row it is on the content engine's scale — the same scale a semantic raw_score is on; on a keyword-backfilled row (see below) it is an in-file text-match score on a different scale. Never compare score across rows with different score_source. result_type is doc or transcript. position (integer|null) is the passage's 0-based address in the file's chunk order — the same address GET /current/{workspace|share}/{id}/storage/{node_id}/content/ takes as chunk_from / chunk_to, so it is how you read the passage and its surroundings without downloading the file. sequence (integer|null) is the underlying ordering coordinate, absent on content indexed before it existed. indexed_version_id (string|null) is the file version the address was worked out against, in the same form the content endpoint returns it: compare it with the content response's own indexed_version_id, and if they differ the file was re-indexed in between, so the position is stale and the search should be re-run. Both are null when this response could not resolve the address: they are published for document text only — an audio or video passage (result_type: "transcript") never carries one, and neither does a passage that no longer opens a chunk after a re-index or one on a page too large to resolve addresses for (only the first 100 hits of a page get one). That is not a statement that the passage cannot be read. null when no qualifying non-summary passage was returned for that file on this query — the retrieval window may simply not have returned one, so this is not a statement that the file has no passages. A keyword-only hit can carry one too: for the top few results of the page, and only where text_indexed is true, the search quotes the best-matching passage of the file's indexed text so a filename or metadata match is not returned bare. On such a hit score is that passage's text-match score within its own file — it is on neither the semantic nor the row's own raw_score scale, and like every score here it is not a threshold; result_type is always doc, media_segment always null, and position / sequence / indexed_version_id are resolved exactly as for a passage the content search returned. It stays null on a keyword hit past that cut-off, on one whose text nothing matched, and at ?output=terse. See Passages vs Summaries below.
files.{id}.metadata_matchbooleantrue when the query also matched the file's extracted metadata — entity-style values such as a counterparty, a customer, or a document title. false otherwise. It commonly co-occurs with a filename or summary match; it does not mean the match happened instead of those. Read match_source for which retrieval legs matched, and Result Ordering below for why a row sits where it does — a true here makes the row eligible for tier 3 unless a stronger filename tier (exact or prefix name match) already applies.
files.{id}.metadata_match_fieldstring|nullReserved — currently always null. It will name the metadata field that carried the match once per-field matching exists. Do not branch on it today.
search_metadataobject ⚠ Despite the name, this is not the files’ metadata — it describes THIS SEARCH (which channels were available, whether a scope applied). For extracted metadata, read the fields on each file, or metadata_filter for the outcome of a filter you sent.{intelligence_enabled, semantic_available, scoped}, plus scope_incomplete when the applied scope was cut short, plus the capability keys below when search_in was supplied.
metadata_filterobject{applied, matched, truncated, scope_incomplete, coverage}. Present only when a filters value reached the server and ran. Its absence on a request you believe was filtered means the filter was dropped in transit — see Filtering by Metadata.

Passages vs Summaries (content_snippet vs best_chunk). content_snippet reports whichever piece of evidence ranked the file highest — and that can be the file's whole-document summary. A summary is not located anywhere in the file, so page is null on such a hit and there is nothing to jump to. best_chunk always reports a real passage, with the locator that belongs to it — page for a document passage, media_segment for a transcript passage, and the other one null — which is what you want when you are opening the file at the right place, quoting it, or citing a page. When the top-ranked evidence is already a passage, best_chunk.text carries the same text as content_snippet — as in the example above, which is at the default full tier where both stay populated and same_as_snippet is false. At ?output=terse and ?output=standard, once both are trimmed to the tier's byte budget, the server drops the duplicate instead: best_chunk.text is null and best_chunk.same_as_snippet is true, and the caller reads content_snippet for the text while still using best_chunk's locator fields.

Passages on a filename or metadata match. A keyword hit is found by the file's name and its extracted metadata, neither of which is document text, so such a row used to come back with nothing to quote even where the file's text was fully indexed. It is now quoted the same way any other row is: the search takes the best-matching passage of that file's indexed text and fills content_snippet, page and best_chunk from it. Three limits are worth knowing. It applies to the top few results of the page, not all of them, so a null on a lower-ranked keyword row is a budget, not a verdict. It applies only where text_indexed is true. And best_chunk.score on such a row is a text-match score inside that one file — do not compare it with a semantic passage's score, or with the row's own raw_score.

Reading around the passage. best_chunk.position is the address the storage content/ endpoint takes: send it back as chunk_from (and chunk_to a little higher) on GET /current/{workspace|share}/{id}/storage/{node_id}/content/ to read the matching passage in full together with the text on either side of it, without downloading the file. Clamp both ends — chunk_from at 0, chunk_to at 9999. Then compare best_chunk.indexed_version_id with the indexed_version_id that response returns: if they differ the file was re-indexed between the two calls, the position is stale, and the search should be re-run rather than the text quoted.

raw_score and score_source are on /storage/search/ only — the unified /search/ route does not return them.

Result shape under search_in=filename. A filename search has no content leg at all, so it returns the keyword-only item shape shown above — name, parent_id, type, content_snippet: null, match_source: "keyword", plus summary_short: null, best_chunk: null, metadata_match and metadata_match_field: null — even on a workspace or share with AI features enabled. The hybrid-only fields (relevance_score, raw_score, score_source, mimetype, media_segment, page) are absent, because there is no content match to score or locate. If your client requires relevance_score, use both (the default) rather than filename. This shape is not new — it is exactly what every response looks like when AI features are off. The unified endpoints are unaffected: their files bucket keeps its usual item shape in every mode.

Folder paths

Every search hit says where it lives, so you do not have to list folders one at a time to find out. Three fields, one answer:

When path_complete is true, path is exactly the ancestors names joined with /, and [] / "" together mean the file is at the root.

When it is false, path is null and ancestors carries only a best-effort suffix — the folders nearest the file, with an unknown number of levels missing from the top, never padded and never guessed. It goes false when a folder could not be read and past an internal depth limit. It is never a claim about where the file is.

A hit that came back with an empty parent_id — which is every hit the meaning side found on its own — still gets a path: the server reads the file’s own record to recover its folder. You do not need details=true for that, and the two retrieval legs answer alike.

Paths are built when the response is assembled, so a folder rename shows up on the next call with no re-indexing delay. Through a share the root is the shared folder, so a path never names anything outside it.

path is a display value, not an address. Folder names are returned exactly as stored and the / separator is not escaped, so a folder whose own name contains / is indistinguishable from a level boundary. Address folders with ancestors[].id or parent_id.

Result Ordering

Results are ordered tier first, then by relevance_score descending within a tier. Two rows tie on the score readily — a file at position r of the name/text leg and another at position r of the content leg fuse to the same value — so the comparator has two further steps: a row whose raw_score is on the keyword scale (score_source keyword or metadata) sorts above one on the semantic scale, matching the rule that decides attribution on an equal position, and node_id ascending ends the comparison so the same result set always paginates the same way.

The tiers, highest first:

TierWhat lands in it
1Exact filename match
2Filename prefix match
3Metadata-entity match (metadata_match: true)
4Everything else

Rows in the fourth group are ordered by relevance_score descending.

A promoted hit can appear above a hit with a higher relevance_score. This is the one thing most likely to surprise an integrator, and it is the ordering working as designed rather than a scoring bug: the tier is applied first, and relevance_score only breaks ties inside a tier. Do not re-sort results by relevance_score client-side — that throws the promotion away. To see why a row sits where it does, read score_source and metadata_match.

A promotion changes the ORDER, never the score. All three promoting tiers work the same way: they place the row above the untiered results and leave relevance_score exactly as retrieval produced it. A promoted row is therefore identifiable by its position and by score_source / metadata_match — never by a special score value. The one field a promotion does change is raw_score on a name match (tiers 1 and 2): a name equality is not a measurement, so those rows report score_source: "filename" with raw_score: null. A metadata-promoted row (tier 3) keeps its raw_score, and that number is on the keyword scale only where score_source is metadata — that value is reported when the keyword leg ranked the file higher. A metadata-promoted row the content engine ranked higher reports score_source: "semantic" and carries the provider score.

Knowing Whether Content Search Is Possible

A content search on an instance that cannot match content returns HTTP 200 with an empty result set, not an error. An empty list therefore has two very different meanings, and search_metadata is how you tell them apart:

"search_metadata": {
  "intelligence_enabled": false,
  "semantic_available": false,
  "scoped": false,
  "content_search_available": false,
  "reason": "intelligence_disabled"
}
FieldTypeDescription
intelligence_enabledboolWhether AI features are enabled on this workspace or share.
semantic_availableboolWhether the meaning-based channel actually served this request. This is the per-request outcome — read it, not content_search_available, to know what you just got.
scopedboolWhether the meaning-based leg was actually narrowed by files_scope / folders_scope. It reports the narrowing that was applied, not the parameter you sent — sending a scope on a request where that leg does not run (AI features off, search_in=filename, or the leg failing) gives scoped: false, because nothing in those results was narrowed by it.
scope_incompletetruePresent, and always true, when the scope that was applied is narrower than the one you asked for: a folders_scope tree ran past the reference limit, so part of it was left out. Absent otherwise, and never present alongside scoped: false. A short answer would otherwise be indistinguishable from a complete one — you cannot count a folder's subtree before naming it.
scope_requestedintHow many scope entries you sent, counting files_scope and folders_scope together. Present only alongside scoped: true.
scope_resolvedintHow many of those entries the scope resolved to. Present only alongside scoped: true. A gap against scope_requested means part of what you named could not be resolved. ⚠ It measures scope resolution, not final coverage — sending filters narrows the searched set further afterwards and that is not subtracted here. ⚠ It reports counts, never a cause: an entry that did not resolve covers a node that is gone and one that merely could not be read on this request. Do not render it as “those folders no longer exist”.
content_search_availableboolWhether either content channel — the semantic index or summary access — is open here at all. A statement of capability, not a prediction of results. Present only when search_in was supplied.
reasonstringWhy content search is unavailable. Present only when content_search_available is false. Treat unrecognized values as opaque.
reasonMeaningWhat to do
intelligence_disabledAI features are off for this share, so meaning-based matching cannot run for anyone.Retry with search_in=filename, or tell the user AI features must be enabled on the share.
summary_permission_deniedAI features are on, but neither channel is open: the meaning-based channel did not serve this request, and this share's permissions do not let you search file summaries.Retry with search_in=filename. This one is specific to you — another member of the same share may be permitted.
content_not_indexedGeneric fallback for any other cause.Retry with search_in=filename.

Required client behavior: when content_search_available is false, do not report “no files found.” Either retry the same query with search_in=filename or tell the user that content search is unavailable here. This is the difference between recovering and confidently reporting a wrong answer.

What the flag does and does not tell you. content_search_available answers “can content search work here at all?”not “will this query return results?” It is true whenever either channel is open to you, so a true flag over an empty list is an ordinary “nothing matched,” not a malfunction. For the per-request outcome, read semantic_available: it reports whether the meaning-based channel actually served this request.

It is reachable as false only on share routes, and only on a share where neither channel is open. A workspace member may always search file summaries, so on /workspace/{id}/storage/search/ the flag is always true and reason never appears — including when AI features are off for that workspace, because the summary channel is still open there. There is no workspace recovery path to code for.

When the block is emitted. search_metadata is returned whenever the response is a hybrid one (AI features enabled), exactly as before. It is additionally returned on the keyword-only path when you explicitly supply search_in. The two capability keys (content_search_available, reason) appear only when you explicitly supply search_in. Supplying only name_match and/or case_sensitive does not change the response shape at all.

search_metadata sits in a DIFFERENT PLACE on the two search routes, and reading only one position is indistinguishable from “no metadata”. On this route (/storage/search/) it is top level. On the unified route (/workspace/{workspace_id}/search/) it rides inside the files bucket, at buckets.files.search_metadata — see Unified search below. Nothing in either response points at the other location, so a client that hard-codes one position reads null on the other route and will report a search as unscored, unscoped, or semantically unavailable when it was none of those.

Read the top-level key first and fall back to buckets.files.search_metadata (or branch on which route you called). Treat a missing block as “not reported”, never as “the capability is off” — and note the two are not interchangeable in content either: the unified route carries no scope parameters, so its scoped is always false and it never emits scope_incomplete / scope_requested / scope_resolved.

Filtering by Metadata

filters narrows a search to files whose extracted metadata satisfies a set of predicates, so one call answers “find payment terms in open invoices over $1,000” instead of two. It is available on the workspace route only.

On the share route filters is IGNORED, not refused. The share search does not declare the parameter, so sending it there does not fail — the request returns 200 with the unfiltered result set and no metadata_filter block. Nothing in the response says the filter was discarded except that absent block, so a client that sends filters to a share and does not check for metadata_filter will present unfiltered results as filtered. metadata_filter is workspace-only for the same reason. If you need metadata narrowing on a share, do it client-side.

The filter runs first, as a stage of its own, and produces the candidate set the search is then confined to. Both legs of the search obey it: filename/text matches outside the candidate set are dropped, and the meaning-based leg searches only the survivors. This is deliberately not a post-filter — you are searching within the filtered files, not filtering what a workspace-wide search happened to return.

A filter that matches nothing yields no results. There is no fallback to an unfiltered search. If you asked for files satisfying a predicate and none do, the answer is an empty files map with metadata_filter.matched: 0.

A folder scope cannot be combined with filters. Sending both folders and filters is refused with an input error rather than quietly answered. The filter stage does not narrow by folder, so “inside this folder, matching this filter” is not something this endpoint can answer accurately today — and answering it by ignoring the folder would search the whole workspace and hand back far more than you asked for, which is worse than saying no. Filter the whole workspace instead, or name specific files. Combining filters with files is supported: the result is the intersection of the two.

The predicate array. filters is a JSON array of clause objects, each {"field", "operator", "value"} — the same shape the saved metadata filter predicate uses. Clauses are AND-combined. A request may carry at most 5 clauses. field names a field in the workspace's metadata vocabulary (canonical name or alias); the field's type is resolved server-side, so you never declare it.

OperatorValueMeaning
= != < <= > >=requiredCompare the field against value. != is the complement of =. Ordered comparison is not legal on boolean or JSON fields.
inrequired (non-empty list)The field's value is one of the list. Counts as one clause.
exists / not_existsomittedThe field is present / absent on the file.
confidence_gterequired (int 03)The extracted value's confidence band is at least this level. The levels are 0 = low, 1 = medium, 2 = high, 3 = certain — so 2 means “high or better”. Legal on every field type, because it tests how the value was obtained rather than the value itself. Two traps below.

confidence_gte — read these before using it. The operator takes the integer, while a fact's confidence comes back as the band name, so the mapping is 0 = low, 1 = medium, 2 = high, 3 = certain. Send the integer (a decimal string such as "2" is also accepted); a band name such as "high" is rejected, as is any non-integer, including 2.0.

A value can be a bare JSON integer, and the server compares it exactly as sent. Do not round-trip such a value through a JavaScript Number — anything above Number.MAX_SAFE_INTEGER (9007199254740991) silently loses precision if you JSON.parse it into a Number and re-serialize; pass it through unmodified.

curl Example (filtered search)

curl -G "https://api.fast.io/current/workspace/1234567890123456789/storage/search/" \
  -H "Authorization: Bearer {jwt_token}" \
  --data-urlencode "search=payment terms" \
  --data-urlencode 'filters=[{"field":"status","operator":"=","value":"open"},{"field":"invoice_total","operator":">=","value":1000}]'

Response (filtered search)

{
  "result": true,
  "files": {
    "2ltsuq4mjacuv7pgc5ydlxnsjwee4": {
      "name": "Invoice-2026-0042.pdf",
      "parent_id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5",
      "type": "file",
      "relevance_score": 1.0,
      "raw_score": 0.62,
      "score_source": "semantic",
      "content_snippet": "Payment terms are net 30 from the invoice date …",
      "match_source": "semantic",
      "media_segment": null,
      "mimetype": "application/pdf",
      "page": { "start_page": 1, "end_page": 1 },
      "summary_short": "Invoice 2026-0042 for professional services, net 30.",
      "best_chunk": {
        "text": "Payment terms are net 30 from the invoice date …",
        "same_as_snippet": false,
        "page": { "start_page": 1, "end_page": 1 },
        "media_segment": null,
        "score": 0.62,
        "result_type": "doc",
        "position": 4,
        "sequence": 1000,
        "indexed_version_id": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5"
      },
      "metadata_match": false,
      "metadata_match_field": null
    }
  },
  "pagination": {
    "total": 1,
    "limit": 100,
    "offset": 0,
    "has_more": false
  },
  "search_metadata": {
    "intelligence_enabled": true,
    "semantic_available": true,
    "scoped": false
  },
  "metadata_filter": {
    "applied": true,
    "matched": 34,
    "truncated": false,
    "scope_incomplete": false,
    "coverage": {
      "scope": "files",
      "available": true,
      "files_in_scope": 40,
      "files_without_metadata": 28
    }
  }
}
FieldTypeDescription
appliedboolAlways true when the block is present. The block's presence is the signal — see below.
matchedintHow many files satisfied the filter. This is the candidate count before the search narrowed it further and before any cap was applied to the meaning-based leg, so it is normally larger than the number of files returned. When truncated is true, treat it as a floor rather than an exact count.
truncatedbooltrue when the candidate set was clipped because it exceeded a cap. Deterministic — see below.
scope_incompletebooltrue when a transient fault dropped candidates that genuinely match. Retryable — see below.
coverageobjectHow much of the searched scope the filter could even see: {scope, available, files_in_scope, files_without_metadata}. Always present when metadata_filter is, and populated for a whole-workspace filter as well as an explicit files_scope — see Coverage below.

Coverage — How Many Files the Filter Could Not Even See

Because a file with no extracted metadata is never a candidate, it is absent from a filtered result rather than unmatched, and matched cannot express that: a file the filter never got to evaluate is not a file it rejected. metadata_filter.coverage puts a number on it.

FieldTypeDescription
scopestringfiles when you narrowed the search with an explicit files_scope; workspace when the filter ran across the whole workspace.
availableboolWhether the two counts are real numbers. This is the only field to branch on.
files_in_scopeint/nullHow many files the search covered. null when available is false.
files_without_metadataint/nullHow many of those hold no extracted metadata, and so could not match any clause. null when available is false.

The ceiling is there because establishing the covered count means checking every file that carries metadata against live storage, so the work grows with how many of them there are; the limit is set from what that costs. It bounds the counting only — it never limits the search itself.

⚠ That ceiling counts metadata RECORDS, not live files. A file’s metadata record outlives the file — deleting a file leaves it behind — so a workspace with a long history of uploads and deletions can be permanently past the ceiling while holding very few files today, and will report available: false on every filtered search. (Explicitly clearing a file’s metadata does remove it from the count; deleting the file does not.) Nothing about the workspace’s current size tells you whether coverage will be available for it.

⚠ A 0 is VERIFIED against the actual files, not computed from two totals. A 0 is the one answer a caller acts on — it says the predicate saw every file, so an empty result set is a real absence rather than a coverage gap — and a difference of two totals cannot support that claim: a covered file deleted and a different file appearing in the same moment leave both totals untouched while a real file goes uncovered. So before a 0 is published, the live files in scope are listed and every one of them is checked for a metadata record. The 0 means: at the final read of this request, every live file in scope had metadata. If any live file is missing one, or the list does not match the count being published, the block answers available: false instead.

available: false means the numbers are UNKNOWN, never that they are zero. Rendering an absent count as 0 states that every file in scope has metadata, which nothing measured. Say nothing about coverage when available is false. coverage is workspace-only for the same reason metadata_filter is: the share route accepts no filters, so no predicate runs there and neither block is emitted.

⚠ A pair of counts that cannot be true is reported as unknown, never as a zero. The two whole-workspace counts are read separately, so a workspace changing underneath the request can produce a pair that is impossible — more covered files than there are files in scope. An impossible pair means one of the counts could not be trusted, so the block answers available: false with both counts null rather than publishing a coverage of 0. A zero is the one value a caller would act on by trusting the result set, which is exactly what an untrustworthy count must not invite.

files_without_metadata is not a fault count. It counts files a predicate cannot evaluate at all, and that includes files with nothing to extract — an image carrying no text — as well as files nobody has run extraction over. A non-zero value is therefore not by itself a sign that anything is wrong; only a value of 0 proves the predicate saw every file in the scope.

metadata_filter is an acknowledgement that the filter RAN, and its absence is meaningful. The block is emitted only when a filters value actually reached the server and ran. A request that sent no filter gets exactly the response it always got, with no such key. So, for a request you believe carried filters:

What the block does not tell you. applied certifies that the predicate executed — nothing more. It is not a statement that the result set is exactly what you asked for. truncated and scope_incomplete are the fields that qualify the result set, and you must read them separately.

matched describes the PREDICATE, not the PAYLOAD. It counts the files the filter selected; it does not count the files this response contains. The two answer different questions and are routinely different numbers — matched is measured before the search narrows further and before any cap, so it is normally the larger of the two. Do not derive a result count from it, and do not treat matched being non-zero as a promise that files is non-empty.

Two things cause the key to be absent on a request you believe carried filters: an intermediate client, proxy, or SDK that strips query parameters it does not recognise (at least one client library does), or a deployment that does not yet accept filters. You do not need to tell them apart — the correct action is the same either way: do not treat the results as filtered.

Check for the key before trusting a filtered result. It is the only way to tell a filter that ran from one that never arrived: both come back 200 with a plausible-looking list of files.

truncated and scope_incomplete are different conditions — do not collapse them into “results may be incomplete.” One is worth retrying and the other never is:

They are independent booleans and both can be true at once. truncated says you asked for too much; scope_incomplete says we lost some of it.

A filtered search only reaches files that have extracted metadata. Predicates are evaluated against the metadata Fastio has already extracted for a file, so a file with no extracted metadata is not a candidate for any clause — including not_exists, which means “has metadata, but none for this field,” not “has no metadata at all.” Extraction is asynchronous, so a file added moments ago may not be filterable yet. To see what has been extracted for a file, read its metadata_facts on the node object. Read an empty filtered result as nothing that has been extracted matches this filter, not no such files exist.

Combining filters with a scope. filters and files_scope work together: the search runs over the intersection — the files you named that also satisfy the filter.

filters and folders_scope cannot currently be used together, and sending both is refused with 1605 (Invalid Input). Either drop the folder scope and let the filter select across the whole workspace, or name the specific files you want with files_scope. This is a current limitation rather than a permanent rule, so handle the refusal as a condition rather than building on it as an invariant.

Note that search_metadata.scoped reports only files_scope / folders_scope. A metadata filter is reported by metadata_filter, not by scoped, so scoped: false alongside a metadata_filter block is normal.

Scoping to Files or Folders (files_scope / folders_scope)

Both narrow the meaning-based (semantic) leg only. The filename/summary leg is deliberately not restricted by them, so a hybrid response can contain files from outside the scope carrying match_source: "keyword".

For a hard boundary, filter on match_source yourself: keep the results marked "semantic" or "both", and drop the ones marked "keyword". Both of the kept values mean the file came back from the meaning-based leg — the leg the scope narrowed — so they are the results that are genuinely inside the scope.

search_in=content is not a substitute for that filter. content matches two channels, and only one of them is the meaning-based leg; the other is a keyword match against the file's AI-generated summary, which the scope does not restrict either. Wherever summary search is open to you, a scoped search_in=content request can therefore still return files from outside the scope, carrying match_source: "keyword". Summary search is always open on the workspace routes, so search_in=content never bounds the result set there; on a share it depends on that share's permissions for you specifically. Filtering on match_source is the only approach that holds on every route and for every caller.

Because the scope applies to that leg alone, it changes nothing at all when that leg does not run — AI features off on the workspace or share, search_in=filename, or the leg failing on this request. search_metadata.scoped reports that honestly: it is true only when the narrowing was really applied, so scoped: false on a request that carried a scope means the scope had no effect on what came back.

A scope that resolves to no files returns no meaning-based results — never the whole workspace or share. A reference you name can drop out during resolution (the file was trashed, or a transient fault made it unreadable), and when every one of them does, the answer is an empty meaning-based result set rather than an unscoped search. An empty scope is still a scope: the empty one.

Entries are validated, not silently dropped. A value that is not a nodeId:versionId / nodeId:depth pair at all — including a bare 0 — a pair whose versionId is not a version of the file it is paired with, an id that is not a valid node or version id, or a node of a type the parameter does not take — a folder in files_scope, a file or note in folders_scope, or a link in either — is refused with 1605 (Invalid Input) / 406, and the message names the entry that was wrong. This matters because ids render both hyphenated and unhyphenated for the same value, so a pair assembled from two different responses is easy to mismatch by accident while each half stays individually well-formed. To send no scope, omit the parameter rather than sending a placeholder value.

files_scope takes files AND notes; folders_scope takes folders; links cannot be scoped. Search results carry a type of file, folder, link or note. Notes are indexed the same way files are and are returned by meaning-based search, so files_scope accepts a note's nodeId:versionId pair exactly as it accepts a file's — if a note came back as a hit, you can scope your next query to it. A link has no stored content to index and is accepted by neither parameter. A node of the wrong type for the parameter it was named in is refused with 1605 (Invalid Input) / 406, and the message names the type the node actually is and, where the other parameter would take it, which one to use instead.

Both parameters are read from the query string only. These are GET endpoints and the scope is a query parameter; a scope sent in a request body is not read, and the search runs unscoped. Put files_scope / folders_scope in the URL.

Folder aliases are not accepted. folders_scope takes a folder's own node id. The root and trash aliases are refused with 1605 (Invalid Input) / 406, and the message names the alias you sent. To search everything, omit files_scope and folders_scope rather than scoping to the root.

A scope carries at most 100 references in total, counting every file you name plus every folder you name plus every subfolder reached by expanding a folders_scope entry to its :depth. Naming more than 100 files is refused. A folder tree that runs past the limit is not refused — you cannot count a subtree before naming it — so it is truncated instead, and the truncation is reported: the response carries search_metadata.scope_incomplete: true, meaning the search covered less than you asked for. Narrow the :depth, or name fewer folders, and retry.

Verbosity (output)

content_snippet, best_chunk.text, best_chunk.same_as_snippet and summary_short are the fields affected by output=. All other fields are returned unchanged at every level.

outputcontent_snippet and best_chunk.text shapesummary_short
terseFirst ~200 bytes of the matching chunk, UTF-8 safe. Truncated values end with .Always null — the tier drops it, the same way it trims a node's summary.
standardFirst ~600 bytes (roughly one paragraph). Same suffix when truncated.Returned in full.
full (default)Full matching chunk, untrimmed.Returned in full.

Snippets shorter than the budget are returned unchanged (no padding, no ). Null/empty snippets are returned unchanged at every level. The byte budget is inclusive of the trailing so the wire payload never exceeds the cap. best_chunk.text follows the same budget and the same rule as content_snippet; at terse and standard, if the trimmed best_chunk.text then comes out byte-identical to the trimmed content_snippet, text is replaced with null and best_chunk.same_as_snippet is true (otherwise text is present and same_as_snippet is false) — at full, text is never suppressed and same_as_snippet is always false. The rest of best_chunk (page, media_segment, score, result_type, position, sequence, indexed_version_id) is returned unchanged at every level. Default output=full preserves the prior best_chunk.text value and never deduplicates it; the same_as_snippet key is the one additive change at that level.

A snippet is an excerpt, not the passage. When you need the surrounding text in full — to quote a clause, or to read what the match sits in — do not widen output and hope: call the file's Node Content endpoint with ?q= (the same query terms) and it returns that file's best-matching chunks with their complete text, page ranges, and no snippet budget at all.

Error Responses

Error CodeHTTP StatusDescription
1605 (Invalid Input)406search_in or name_match is not one of the listed values; case_sensitive is not true/false/1/0; or the pattern is empty / longer than 256 characters / a glob of nothing but * and ?
1605 (Invalid Input)406filters is not a JSON array of clause objects, or a clause is missing a field / operator (workspace only)
1605 (Invalid Input)406The filter could not be applied: more than 5 clauses, a field the workspace vocabulary does not have, or an operator/value that does not suit the field's type (workspace only)
1605 (Invalid Input)406filters was combined with folders_scope — not currently supported together (workspace only)
1605 (Invalid Input)406A files_scope / folders_scope entry is wrong: not a nodeId:versionId / nodeId:depth pair at all (a bare 0 included), an id that is not a valid node or version id, a versionId that is not a version of that file, or a node of the wrong type — a folder in files_scope, a file or note in folders_scope, or a link in either. The message names the offending entry and the type the node actually is
1605 (Invalid Input)406folders_scope named the root or trash folder alias. It takes folder node ids only — send the folder's own node id, or omit the scope entirely to search everything
1609 (Not Found)404Search not available for workspace folder shares (share only)
1680 (Access Denied)401No search permission (share only)

Notes:

GET /current/workspace/{workspace_id}/metadata/search/

Keyword search across the metadata stored on workspace files. This now searches the current metadata corpus — it previously searched only the older key-value store, which stopped receiving new values when automatic extraction moved to the current model, so recently extracted metadata was not findable here even though it was visible on the file itself. 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.

Every result now tells you which metadata field matched. A metadata hit used to carry only the node, its name and a score — exactly what a filename hit carries — so there was no way to show a user why a file came back, and a correct match was indistinguishable from a match on the filename. Each result now includes matched_fields: the field name(s) whose value matched, each with the matching value. This is additive; every field that was there before is unchanged.

Auth required. Permission: View (workspace member).

Query Parameters

ParameterTypeRequiredDefaultDescription
qstringYesKeyword query. Whitespace-trimmed; an empty value is rejected.
template_idstringNoRetired — supplying it is now an error (406). It used to restrict matches to nodes carrying a value contributed by that template; the searched corpus no longer records a template association, so the filter cannot be honoured. It is REFUSED rather than ignored, because silently accepting a narrowing filter and then returning every match is a widening a caller cannot detect. Remove the parameter.
limitintNo100Maximum number of results (1-100).
offsetintNo0Number of results to skip. Requests beyond the supported result window return an input error.

curl Example

curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/metadata/search/?q=invoice&limit=25" \
  -H "Authorization: Bearer {jwt_token}"

Response

{
  "result": true,
  "results": [
    {
      "node_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4",
      "score": 4.215,
      "template_ids": [],
      "matched_fields": [
        { "field": "document_type", "value": "Invoice", "value_truncated": false },
        { "field": "vendor", "value": "Invoice Systems Ltd", "value_truncated": false }
      ],
      "matched_fields_truncated": false,
      "node": {
        "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4",
        "type": "file",
        "name": "Invoice-2026-0042.pdf",
        "parent": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5",
        "size": 524288,
        "mimetype": "application/pdf",
        "mimecategory": "document",
        "version": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5",
        "created": "2026-04-20 14:12:08 UTC",
        "modified": "2026-04-22 09:30:55 UTC",
        "restricted": false,
        "dmca": false,
        "locked": false
      }
    }
  ],
  "pagination": {
    "total": 1,
    "limit": 25,
    "offset": 0,
    "has_more": false
  }
}

Response Fields

FieldTypeDescription
resultsarrayMatching nodes, ordered by relevance score (highest first)
results[].node_idstringOpaqueId of the matching node
results[].scorenumberRelevance score for the match
results[].template_idsarray of stringAlways empty. Templates are retired and the searched corpus records no template association. The key is kept so existing clients keep parsing, but it will not be populated again.
results[].matched_fieldsarray of objectThe metadata field(s) whose value matched this query, in the order they are stored on the file. May be empty — an empty list is a valid result, not an error. Omitted entirely — not empty — for callers below Member permission; see Access below.
results[].matched_fields[].fieldstringThe field name, exactly as it is spelled on the file. Case and accents are preserved verbatim — do not fold or normalise it before matching it against your own field list.
results[].matched_fields[].valuestringThe matching value, as text. Long values are shortened — see value_truncated.
results[].matched_fields[].value_truncatedbooltrue when the stored value is longer than the returned text. The returned text is a window taken around the part that matched, so it always contains the match — it is not simply the beginning of the value. Values are shortened to at most 256 characters.
results[].matched_fields_truncatedbooltrue when the matched_fields list is known to be incomplete — more than 10 fields matched, or part of this file’s metadata was too large to be searched. Present it as “matched X and more”, not as the full list.
results[].nodeobjectStandard node resource (same shape as storage list/details)
pagination.totalintTotal number of matching nodes
pagination.has_morebooltrue when more results exist past the current window

Access. matched_fields and matched_fields_truncated are returned only to callers holding Member permission on the workspace — the same level every dedicated metadata read requires. The route itself is unchanged and still opens at View: a caller below Member still searches metadata and still receives every matching file, its score, its ranking and its full node payload. The two keys are simply not present on each result.

Read them with a presence check on the key, not with a length check. An absent matched_fields means the caller is not cleared to see metadata values. An empty matched_fields means something different and unrelated: the match could not be attributed to a specific field on that file. Do not treat the two as the same state, and never suppress a hit for either one.

Error Responses

Error CodeHTTP StatusDescription
1605 (Invalid Input)406q missing/blank, template_id supplied at all (the filter is retired — remove it), or a request beyond the supported result window
1680 (Access Denied)401Caller is not a member of the workspace
1654 (Internal Error)500Search backend transient failure

Notes:

POST /current/workspace/{workspace_id}/metadata/compound-search/

Two inputs, never one blended string. A structured metadata filter selects the candidate files, then a semantic content query ranks the ones whose content answers the question. Use it for “the contracts signed last quarter that mention early termination”: the quarter is a metadata predicate, the clause is a content question, and neither half alone answers it.

Workspace only — there is no share form.

Auth required. Permission: Member on the workspace. The organization’s plan must include both the metadata and content_ai features, and the workspace must have Intelligence enabled: the second stage searches the content index the Intelligence pipeline builds, so a workspace with Intelligence off is refused up front rather than handed a misleadingly empty result. There is no keyword leg to fall back on here — where /storage/search/ degrades, this endpoint refuses.

Parameters

ParameterTypeRequiredDefaultDescription
filtersstring (JSON)YesJSON-encoded predicate array — [{"field": "…", "operator": "…", "value": "…"}]. Must be a non-empty list of objects, each carrying a string field and operator. Same predicate vocabulary as saved metadata filters; see Filtering by Metadata under Search.
content_querystringYesThe content question. Max 1024 characters; blank is rejected.
limitintNoserver defaultResult-count cap, minimum 1. A value above the server maximum is clamped, not rejected, and the clamp is reported as scope.limit_clamped_from.

🔴 filters is a FORM FIELD whose value is a JSON string — not a JSON request body. This is the most common way to call this endpoint wrong. Send the request form-encoded (application/x-www-form-urlencoded or multipart/form-data). A request sent as Content-Type: application/json does not populate filters at all, and is refused exactly as though you had sent no filters — 406 with error.code 119701 — which reads as “my filter is invalid” when the real problem is how the body was encoded.

The three parameters are not symmetric about this. content_query and limit are read from either the POST body or the query string, so they survive being sent either way. filters is read from the POST body only — there is no query-string form of it, and it is the one that goes missing.

curl Example

curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/metadata/compound-search/" \
  -H "Authorization: Bearer {jwt_token}" \
  --data-urlencode 'filters=[{"field":"document_type","operator":"=","value":"contract"}]' \
  --data-urlencode "content_query=early termination clause" \
  --data-urlencode "limit=25"

Response

{
  "result": true,
  "items": [],
  "scope": {
    "match_count": 12,
    "match_relation": "eq",
    "scope_used": 100,
    "scope_truncated": false,
    "files_not_indexed": 3,
    "limit_clamped_from": null,
    "causes": []
  }
}

items holds standard node resources — the same shape as storage list/details, ranked by content relevance, honouring the request’s output= tier.

Read the scope object; it is the point of this endpoint. A short items list can mean “twelve files matched” or “far more matched and the answer was cut short”, and scope is what tells the two apart.

Response Fields

FieldTypeDescription
scope.match_countintMatching files — those satisfying the filter and the content query. Always equals the number of entries in items.
scope.match_relationstringeq when match_count is exact, gte when it is a floor because a cap bounded the answer.
scope.scope_usedintHow many candidate files the content stage actually searched.
scope.scope_truncatedbooltrue when more indexed candidates existed than the content stage could search.
scope.files_not_indexedint/nullCandidates the filter matched that had no content index entry and were therefore invisible to the content stage. null means the coverage read itself was unavailable — unknown, not zero.
scope.limit_clamped_fromint/nullYour original limit when it exceeded the server maximum, else null.
scope.causesarray of stringEvery bound that fired, with no precedence between them. [] when nothing bounded the answer.

causes values and what to do about each:

CauseMeaningRemedy
filter_capThe filter matched more files than the metadata stage will carry forwardNarrow the filter
scope_capMore indexed candidates existed than the content stage’s budget allowsNarrow the filter — the same remedy, not an upgrade
result_budgetThe content stage filled the requested limit, so more may match beyond itRaise limit, or make the query more specific
coverage_unavailableThe index-coverage read degraded, so files_not_indexed is unknownRetry if completeness matters

Treat causes as an open set: handle the values you know and fall back for the rest. Only the count-bounding causes (filter_cap, scope_cap, result_budget) make match_relation a floor, so causes can be non-empty while match_relation is still eq — a degraded coverage read does not clip the candidate set.

Error Responses

Error CodeHTTP StatusDescription
115280406Intelligence is not enabled on this workspace
119701406filters is missing, blank, not a JSON array, or an empty array
116139406An element of filters is not an object
155870406An element of filters lacks a non-empty field or operator
100859406content_query missing or blank
179646406The predicates are not valid for the fields they name — a wrong operator for the field’s type, an unusable value, or too many clauses
varies per call site — read error.code from the response401Caller is below Member on the workspace
109283503A transient storage fault left the candidate set incomplete, so the answer cannot be trusted — retry
135817503A transient fault in either stage — retry
193826500The workspace’s storage instance could not be resolved
134988500A permanent, non-validation backend fault

Notes:

GET /current/workspace/{workspace_id}/search/
GET /current/share/{share_id}/search/

One search call across everything in a workspace or share, with results grouped by type into buckets. Instead of calling the per-type search endpoints separately, you issue a single query and get back a set of buckets — each with its own results and its own pagination. A workspace search returns up to three buckets: files, metadata, and comments. A share search returns the subset that applies to shares (typically files, plus comments when commenting is enabled on the share; metadata is workspace-only).

Each bucket is independently paginated and independently health-reported, so a transient problem affecting one bucket never blocks the others — that bucket comes back degraded with an empty result set while the rest return normally.

Auth required. Permission: View (workspace), search + file view permissions (share). All buckets are permission-filtered — see Permission Model below.

Query Parameters

ParameterTypeRequiredDefaultDescription
searchstringYesSearch query string (max 1024 characters; an empty value is rejected).
files_offsetintNo0Result offset for the files bucket.
files_limitintNo25Page size for the files bucket.
metadata_offsetintNo0Result offset for the metadata bucket (workspace only).
metadata_limitintNo25Page size for the metadata bucket (workspace only).
comments_offsetintNo0Result offset for the comments bucket.
comments_limitintNo25Page size for the comments bucket.
search_instringNobothfiles bucket only. Which side of the file to match: filename, content, or both.
name_matchstringNoautofiles bucket only. How the filename is matched: auto, exact, prefix, contains, or glob. Ignored when search_in=content.
case_sensitivestringNofalsefiles bucket only. true / false / 1 / 0. Applies to the precise name_match values; ignored under auto.
detailsstringNo"true" enables metadata_facts on file/note items in the files bucket, shaped by output exactly as documented under Extracted Metadata Facts; any other value is treated as absent (no error). Folders and links never carry it.
outputstringNofullVerbosity: terse, standard, or full (default). Trims content_snippet on files-bucket items to a byte budget, and shapes metadata_facts when details=true. See Verbosity below.

Search modes apply to the files bucket only. search_in, name_match, and case_sensitive behave exactly as documented under Search above — same values, same defaults, same escaping and case rules, same pattern limits, same 1605 (Invalid Input) on a bad value. They shape only the files bucket; the metadata and comments buckets are unaffected and keep matching as they always have. Omit all three and the response is byte-for-byte what it was before they existed. search_in=filename is the most useful of the three here: it turns the files bucket into a pure filename lookup while still returning metadata and comments matches from the same call.

details=true adds extracted metadata facts to the files bucket. Each file or note item whose facts were read then carries metadata_facts — the same block /storage/search/ and the node object emit, in the same three output shapes (terse, standard, full) with the same per-tier caps and the same count/total/is_truncated semantics — see Extracted Metadata Facts above. Folder and link items never carry the key, matching every other surface. Visibility follows the same rule as everywhere else metadata_facts appears: the key is omitted entirely (never emitted empty) when the caller is not entitled to extracted metadata. Because extracted metadata is a workspace-only surface, that omission is unconditional on the share twin — /share/{share_id}/search/ never returns metadata_facts regardless of details, for any share role. On the workspace route it is present for callers who are members of the owning workspace. With details absent or any string other than "true", the response is unchanged from before this parameter existed — no new key; a non-string value is rejected as invalid input, like any other declared parameter. Cost is at most one batched facts read per files-bucket page, not one read per item, so details=true is cheap here even at files_limit=25.

Verbosity (output)

content_snippet on files-bucket items is trimmed to a byte budget by the output level — the same budget /storage/search/ applies, so every search surface sizes the field the same way.

outputcontent_snippet shape
terseFirst ~200 bytes of the matching chunk, UTF-8 safe. Truncated values end with .
standardFirst ~600 bytes (roughly one paragraph). Same suffix when truncated.
full (default)Full matching chunk, untrimmed.

The budget is inclusive of the trailing , so the wire payload never exceeds the cap, and the is appended only when the value was actually cut. Snippets shorter than the budget are returned unchanged (no padding, no ), and a null snippet — what a keyword-only hit carries — is returned unchanged at every level. Only the files bucket is affected: the metadata and comments buckets are identical at every level, and content_snippet is the only field trimmed here, because this route returns no best_chunk and no summary_short. output sizes the snippet whether or not you send details=true — the two parameters are independent, and details governs only whether metadata_facts is added. As on /storage/search/, when you need the surrounding text in full, do not widen output: call the file's Node Content endpoint with ?q= and read the whole chunk with no snippet budget at all.

The capability report rides on the bucket it describes, at buckets.files.search_metadata, and is present only when search_in was explicitly supplied. ⚠ This is the reciprocal of the /storage/search/ placement — there the same block is top level. A client that reads only one position gets null on the other route, which is indistinguishable from “no metadata”: read the top-level key first, then fall back to buckets.files.search_metadata, or branch on which route you called. Its fields and reason values are identical to the /storage/search/ block documented under Knowing Whether Content Search Is Possible, and the same client rule applies: on content_search_available: false, do not report “no files found” — retry with search_in=filename. scoped is always false here because this endpoint has no scope parameters, and content_search_available reports capability, not result likelihood — it is reachable as false only on share routes, and semantic_available is the field that tells you what this particular request got.

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

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

{
  "result": true,
  "buckets": {
    "files": {
      "items": [
        {
          "node_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4",
          "name": "Q4 Report.pdf",
          "parent_id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5",
          "type": "file",
          "relevance_score": 1.0,
          "content_snippet": "Quarterly revenue grew 18% year-over-year …",
          "match_source": "both",
          "media_segment": null,
          "page": { "start_page": 3, "end_page": 3 },
          "mimetype": "application/pdf",
          "updated": "2026-04-22 09:30:55 UTC"
        }
      ],
      "offset": 0,
      "limit": 10,
      "total": 1,
      "total_relation": "eq",
      "has_more": false,
      "status": "ok"
    },
    "metadata": {
      "items": [
        {
          "node_id": "23kgh-fgzmg-72knw-u676u-vibbh-jizb",
          "name": "Invoice-2026-0042.pdf",
          "parent_id": "2qk7d-kri4y-yievb-q5hri-eq4io-hij5",
          "type": "file",
          "relevance_score": 4.215,
          "template_ids": [],
          "matched_fields": [
            { "field": "document_type", "value": "Invoice", "value_truncated": false }
          ],
          "matched_fields_truncated": false,
          "updated": "2026-04-22 09:30:55 UTC"
        }
      ],
      "offset": 0,
      "limit": 25,
      "total": 1,
      "total_relation": "eq",
      "has_more": false,
      "status": "ok"
    },
    "comments": {
      "items": [
        {
          "comment_id": "caaq5-twdpg-axqnt-bbtt4-v2xit-unmv2",
          "entity": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4",
          "author_profile_id": "1234567890123456789",
          "snippet": "Can we double-check the Q4 totals …",
          "reference_type": null,
          "relevance_score": 2.118,
          "created": "2026-04-21 08:02:11 UTC",
          "updated": "2026-04-21 08:02:11 UTC"
        }
      ],
      "offset": 0,
      "limit": 5,
      "total": 1,
      "total_relation": "eq",
      "has_more": false,
      "status": "ok"
    },
  }
}

Response Fields

FieldTypeDescription
bucketsobjectMap of bucket type → bucket object. Only applicable buckets are present (e.g. no metadata on a share).
buckets.{type}.itemsarrayResult items for this bucket. The metadata and comments buckets are ordered by relevance_score descending. The files bucket is ordered promotion tier first — an exact filename match, then a filename prefix match, then everything else — and only then by relevance_score descending, then a keyword-scale row above a semantic-scale one, then node_id ending the comparison; it shares its ranking with /storage/search/, so a name match can sit above an item with a higher relevance_score here too. (The metadata-entity tier is /storage/search/-only and never applies on this route.) Item shape is type-specific — see Bucket Item Shapes below.
buckets.{type}.offsetintThe offset applied to this bucket.
buckets.{type}.limitintThe page size applied to this bucket.
buckets.{type}.totalintNumber 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_relationstringeq 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_morebooltrue when more results exist past the current page.
buckets.{type}.statusstringok for a healthy bucket, or degraded when the backend behind that bucket was temporarily unavailable (the bucket returns an empty items array but is still present so you can tell a backend hiccup from a bucket that does not apply).
buckets.files.search_metadataobjectCapability report for the files bucket — {intelligence_enabled, semantic_available, scoped, content_search_available, reason}. Present only when search_in was explicitly supplied.

Bucket Item Shapes

Every item carries a relevance_score (higher is more relevant) and an updated timestamp. Beyond that, fields are type-specific:

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:

Error Responses

Error CodeHTTP StatusDescription
1605 (Invalid Input)406search missing/blank/too long, a bucket request beyond the supported result window, or an invalid search_in / name_match / case_sensitive value or pattern
1609 (Not Found)404Search not available for workspace-backed shares (shared folders); share only
1680 (Access Denied)401No search permission

Notes:

QuickShare (Workspace Only)

Deprecated — use File Share. The POST (create / extend-expiry) path now returns 10756 (Quickshare Deprecated) (403) with a directed message pointing to POST /current/workspace/{workspace_id}/create/fileshare/. The durable File Share 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/
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 (POST returns 403).

Auth required. Permission: Member.

Path Parameters

ParameterTypeRequiredDescription
{workspace_id}stringYes19-digit workspace profile ID
{node_id}stringYesFile OpaqueId

POST — Create or Update QuickShare (deprecated → 403)

This path is deprecated and returns 10756 (Quickshare Deprecated) (403) for both create and extend-expiry. Use POST /current/workspace/{workspace_id}/create/fileshare/ (see File Share). 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) (404) if no quickshare exists.

DELETE — Delete QuickShare

Returns {"result": true}. Returns 1609 (Not Found) (404) 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/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/

Returns an array of all active quickshares in the workspace.

Auth required. Permission: Member.

curl Example

curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/quickshares/list/" \
  -H "Authorization: Bearer {jwt_token}"

Response

{
  "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 metadata pointer: node, template, extraction eligibility; no field values (view capability)
GET /current/fileshare/{fileshare_id}/storage/read/                                  -- download the bound file (download capability)
GET /current/fileshare/{fileshare_id}/storage/preview/{preview_type}/read/           -- preview (view capability)
GET /current/fileshare/{fileshare_id}/storage/preview/{preview_type}/read/{download_token}/file/{filename}  -- preview sub-file
GET /current/fileshare/{fileshare_id}/storage/versions/                              -- list the bound file's version history (view capability)
GET /current/fileshare/{fileshare_id}/storage/versions/{version_id}/read/            -- download a specific version (download capability)

Details Response

{
  "result": true,
  "fileshare": {
    "fileshare": "1234567890123456789",
    "title": "Quarterly Presentation",
    "access_option": "anyone_with_link",
    "has_password": false,
    "effective_capability": "download",
    "file": {
      "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4",
      "type": "file",
      "name": "presentation.pdf",
      "parent": "2ekc7-5efba-yapdo-psqmq-3ntiv-ri56",
      "version": "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5",
      "created": "2026-01-28 10:00:00 UTC",
      "modified": "2026-01-28 12:30:00 UTC",
      "size": 5242880,
      "hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
      "hash_algo": "sha256",
      "mimetype": "application/pdf",
      "mimecategory": "document",
      "previews": { "pdf": { "state": "ready" }, "thumbnail": { "state": "ready" } },
      "virus": { "status": "scanned", "infected": false },
      "summary": { "title": "Quarterly Presentation", "short": "Q4 results deck", "long": "..." },
      "metadata": { "title": null, "short": null }
    }
  }
}

The bound file object carries the same fields as a workspace file-node detail (see Node Details in this reference): a viewer of the share sees that one file's full info — versioning, previews, summary, metadata, and provenance — exactly as it appears in the workspace. The share-level effective_capability (view / download / edit) reflects the highest capability the access tier / grant / password admit for the calling viewer.

One field follows effective_capability: embedded file metadata. file_attributes.exif_metadata and file_attributes.media_metadata are read out of the file's own bytes, so they are served only to a viewer who may download it. A view-only viewer receives file_attributes as an empty object {} (keys omitted, not null, not an error) — on details, on versions, and on the nested node object the metadata endpoint returns; download and edit viewers receive the metadata. See Embedded File Metadata.

Metadata Response

GET /current/fileshare/{fileshare_id}/storage/metadata/details/ identifies the bound file's metadata — which node it is, which template governs it, and whether the file is eligible for automatic extraction. It returns no metadata field VALUES. View capability; the node is the bound file (never a caller-supplied node id).

{
  "result": true,
  "object_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4",
  "template_id": "cl4ev-5j54o-cladr-lnyuj-2lcub-gqjm",
  "node_id": { "id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4", "type": "file", "name": "presentation.pdf", "parent": "2ekc7-5efba-yapdo-psqmq-3ntiv-ri56", "mimetype": "application/pdf" },
  "autoextractable": true
}

Those five keys are the entire response — there is no sixth. template_id is null when the bound file is mapped to no template. Do not code against an instance_id here either: the workspace metadata endpoint returns one, this endpoint deliberately does not, because it names the workspace the link was cut from.

No metadata VALUE crosses a File Share link, and that is deliberate. A link admits anonymous recipients, while the values are the file’s own contents and the field names are the owning team’s private vocabulary — so this surface serves neither corpus. metadata_facts is absent (see Extracted Metadata Facts): absent here, absent on the nested node object this endpoint returns, and absent on every other File Share surface. The legacy template_metadata / custom_metadata key/value sets are absent too — those blocks have been withdrawn from every Fastio response, on this surface and in the workspace alike. This is the settled end state, not a temporary restriction; a recipient sees no metadata values, and nothing is queued to bring them back, so do not build a viewer that waits for them. A workspace member reading the same file through the workspace endpoint gets its facts — see Get file metadata in the AI & Metadata reference.

Preview types match the storage preview surface (thumbnail, image, pdf, mp4, hlsstream, etc.); multi-file previews (e.g. HLS) return a 307 Temporary Redirect to a sub-file endpoint. Version listing is read-only — there is no restore/promote on the public surface; each entry carries the same per-version fields as a workspace version listing.

Error Responses

HTTP StatusCodeCause
4061605 (Invalid Input)Invalid File Share id
4011650 (Authentication Invalid)A link password is required and was missing or wrong (present it via the x-ve-password header)
4031700 (Forbidden)The access tier or named grant does not permit the caller
4041609 (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.

curl Example

curl -X GET "https://api.fast.io/current/fileshare/1234567890123456789/realtime/note-auth/2ik5q-a43cm-uixi2-van5r-3eolo-7mue/" \
  -H "Authorization: Bearer {jwt_token}" \
  -H "x-ve-password: {link_password_if_set}"

Response

{
  "result": true,
  "expires_in": 3600,
  "auth_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

Response Fields

FieldTypeDescription
auth_tokenstringThe realtime-note bearer token to present to readnote / updatenote
expires_inintegerToken lifetime in seconds

Error Responses

Error CodeHTTP StatusDescription
1605 (Invalid Input)406Missing/invalid File Share id or note id, or the bound node is not a note
1609 (Not Found)404No such File Share, or the requested id is not the File Share's bound note
1680 (Access Denied)401Sign-in required (anonymous caller cannot mint a realtime token)
1700 (Forbidden)403The 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)401Token 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. An optional version_id query parameter reads a specific version.

curl Example

curl -X GET "https://api.fast.io/current/fileshare/1234567890123456789/storage/readnote/2ik5q-a43cm-uixi2-van5r-3eolo-7mue/" \
  -H "Authorization: Bearer {realtime_note_token}"

Error Responses

Error CodeHTTP StatusDescription
1650 (Authentication Invalid)401Realtime-note token missing, invalid, or expired
1651 (Invalid Method)405Only GET is accepted
1605 (Invalid Input)406Invalid File Share id, note id, or version id
1700 (Forbidden)403Token is not bound to this File Share/note, the File Share is unavailable, or the token lacks read capability
1609 (Not Found)404Bound note no longer exists
1680 (Access Denied)401The note (or requested version) is blocked from serving — virus-infected, DMCA-flagged, or restricted (an edit lock does not block reads)
1654 (Internal Error)500Failed 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).

Request Body (form-encoded)

ParameterTypeRequiredConstraintsDescription
namestringNo1–255 characters (counted as characters, not bytes); must end in .mdNew note name
contentstringNoMax 100 KB, non-blankNew markdown content (empty/whitespace-only is rejected)
if_version_idstringNoVersion OpaqueIdCompare-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

curl -X POST "https://api.fast.io/current/fileshare/1234567890123456789/storage/updatenote/2ik5q-a43cm-uixi2-van5r-3eolo-7mue/" \
  -H "Authorization: Bearer {realtime_note_edit_token}" \
  -d 'content=# Updated Notes\n\nRevised content here.'

Error Responses

Error CodeHTTP StatusDescription
1650 (Authentication Invalid)401Realtime-note token missing, invalid, or expired
1651 (Invalid Method)405Only POST is accepted
1605 (Invalid Input)406Invalid File Share id / note id, name not ending in .md, blank content, or malformed markdown / version id
1700 (Forbidden)403Token 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)404Bound note no longer exists
113958409if_version_id did not match the note's current version (no change made). error.params[] carries the conflict entry — identical to the workspace Conflict Response above. (1660 is not the value of error.code.)
1680 (Access Denied)401The note is blocked from writing — virus-infected, DMCA-flagged, or restricted
1654 (Internal Error)500Failed 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/

Acquire a lock on a file.

Auth required. Permission: Guest (workspace), file modification permission (share).

Request Body (form-encoded)

ParameterTypeRequiredConstraintsDescription
durationintegerNo60-3600 secondsLock duration (default varies)
client_infostringNoJSON objectClient metadata: device_name (max 255), client_version (max 50)

curl Example

curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/lock/" \
  -H "Authorization: Bearer {jwt_token}" \
  -d 'duration=300' \
  -d 'client_info={"device_name":"My Laptop","client_version":"2.1.0"}'

Response

{
  "result": true,
  "lock_token": "unique_lock_token_string",
  "locked_at": "2025-01-28 10:00:00 UTC",
  "expires_at": "2025-01-28 10:05:00 UTC",
  "node_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4"
}

Response Fields

FieldTypeDescription
lock_tokenstringToken required for heartbeat and release operations
locked_atstringLock acquisition time (YYYY-MM-DD HH:MM:SS UTC)
expires_atstringLock expiration time (YYYY-MM-DD HH:MM:SS UTC)
node_idstringOpaqueId of the locked node

Error Responses

Error CodeHTTP StatusDescription
1609 (Not Found)404Node not found or outside your workspace/share scope
1609 (Not Found)404Cannot lock a deleted node (workspace only)
1660 (Conflict)409Node already locked by another user
1680 (Access Denied)401Insufficient permission to acquire a lock on this node (share only)
1693 (Temporarily Unavailable)503Lock 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/

Extend the lock duration.

Auth required. Permission: Guest (workspace), file modification permission (share).

Request Body (form-encoded)

ParameterTypeRequiredDescription
lock_tokenstringYesToken from acquire response

curl Example

curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/lock/heartbeat/" \
  -H "Authorization: Bearer {jwt_token}" \
  -d 'lock_token=unique_lock_token_string'

Response

{
  "result": true,
  "expires_at": "2025-01-28 10:10:00 UTC",
  "time_remaining": 300
}

Error Responses

Error CodeHTTP StatusDescription
1609 (Not Found)404No lock exists on this node
1609 (Not Found)404Lock has expired
1609 (Not Found)404Node not found or outside your workspace/share scope
1660 (Conflict)409Recreation race: another caller acquired the lock first; re-acquire and retry
1680 (Access Denied)401Lock token does not match
1680 (Access Denied)401The lock was taken over by another user. Stop editing and re-read the file — this is not an expiry, and re-acquiring would discard the fact that somebody took the file from you
1680 (Access Denied)401Insufficient permission to heartbeat this lock (share only)
1693 (Temporarily Unavailable)503Lock service momentarily unavailable; retry after a brief delay

Notes:

Release Lock

DELETE /current/workspace/{workspace_id}/storage/{node_id}/lock/
DELETE /current/share/{share_id}/storage/{node_id}/lock/

Release a lock on a file.

Auth required. Permission: Guest (workspace), file modification permission (share).

Request Body/Query

ParameterTypeRequiredDescription
lock_tokenstringYesToken from acquire response

Response

{
  "result": true,
  "released": true
}

Error Responses

Error CodeHTTP StatusDescription
1609 (Not Found)404No lock exists on this node
1609 (Not Found)404Node not found or outside your workspace/share scope
1680 (Access Denied)401Lock token does not match
1680 (Access Denied)401Insufficient permission to release this lock (share only)
1693 (Temporarily Unavailable)503Lock 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/

Check the lock status of a node.

Auth required. Permission: Guest (workspace), file view permission (share).

Error Responses

Error CodeHTTP StatusDescription
1609 (Not Found)404Node not found or outside your workspace/share scope
1680 (Access Denied)401Insufficient permission to view lock status (share only)

The holder’s identity requires MEMBER level or above. locked and node_id are returned to every caller who may reach the endpoint — a client that cannot see them will offer a busy node as editable. The remaining fields (locker_uid, locked_at, expires_at, locker, and time_remaining on workspaces) identify who holds the lock and when they started and stopped working, and are returned only to callers at member level or above. Below that — workspace guests, share guests, public-link recipients — those keys are absent, exactly as lock_info is null on the node object for the same callers. Read locked for occupancy and treat the identity keys as optional; do not infer “unlocked” from a missing locker_uid.

locker.agent_name names the agent that took the lock on the holder’s behalf, when one did, and locker.agent_name_source says where that name came from; both are null when a person took the lock directly. agent_name is self-declared, not verified — display it beside the holder, never rely on it to identify or authorize anyone.

🔴 The name belongs to the CREDENTIAL, not to the lock. It is read from the credential that took the lock — a JWT claim (agent_name_source: "jwt_claim") or the API key’s own label ("api_key_label") — and it is set once, when you sign in or mint the key. There is no per-lock parameter for it, and nothing you send at acquire time can set, override, or suppress it. Two consequences worth designing around: every lock taken by one credential carries the same label, and a credential with no agent name produces null on every lock it takes, forever, until the credential itself is changed. If you want a lock to show an agent name, that decision happens at sign-in or key creation — not at the lock call.

This block carries no display_name; read the node object’s lock_info.locker when you need the holder’s name.

Response (locked, member or above)

{
  "result": true,
  "locked": true,
  "node_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4",
  "locked_at": "2025-01-28 10:00:00 UTC",
  "expires_at": "2025-01-28 10:05:00 UTC",
  "time_remaining": 245,
  "locker_uid": "1234567890123456789",
  "locker": {
    "agent_name": "Claude-2",
    "agent_name_source": "api_key_label"
  }
}

Response (locked, below member)

{
  "result": true,
  "locked": true,
  "node_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4"
}

Response (unlocked)

{
  "result": true,
  "locked": false
}

Override Lock

POST /current/workspace/{workspace_id}/storage/{node_id}/lock/override/
POST /current/share/{share_id}/storage/{node_id}/lock/override/

Take over the lock on a file from whoever holds it, without their lock_token.

Auth required. Permission: Guest (workspace), file modification permission (share) — the same bar as writing the file.

Takes the lock over from whoever holds it, without their lock_token, and gives it to you. Use it when a collaborator left a file locked and is no longer editing. Anyone who can write the file can override its lock: the lock is advisory and expires on its own, so it never granted exclusive write rights, and requiring more than write access to take it over would protect nothing.

This is a TAKEOVER, not a release — you end up holding the lock. The response carries a new lock_token that is yours: heartbeat it and release it exactly as if you had acquired it. The displaced holder’s token stops working immediately; their next heartbeat is refused with 1680 (Access Denied), which is how their client learns a person took the file rather than that their own lock lapsed. That refusal holds even after you release the lock again — for the remainder of the lifetime their own lock had left — so a slow or paused client cannot quietly pick the file back up.

Overriding does NOT let you overwrite someone else’s changes, and must not be presented to a user as “force save”. A lock and a version precondition are different protections. The override takes the lock; it does not waive if_version_id. An update sent afterwards with a stale if_version_id still fails with the same 409 conflict it would have returned before the override — see Conflict Response above. To save after an override, re-read the file, rebase onto the current version_id, and send that.

Overriding a file that is not locked also succeeds — you simply acquire it, and overridden is false. Overriding a lock you already hold is also safe: you keep your existing lock_token, nothing is displaced, and your in-flight heartbeat keeps working. A retry is therefore harmless. Every override is recorded as an event on the owning workspace or share, naming who overrode and which file, and is readable through the events API. The displaced holder is deliberately NOT named in that event: their identity requires member level, while the event itself is readable one tier below that, so it is recorded internally rather than published.

No request body.

curl Example

curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/lock/override/" \
  -H "Authorization: Bearer {jwt_token}"

Response

{
  "result": true,
  "lock_token": "unique_lock_token_string",
  "node_id": "2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4",
  "locked_at": "2025-01-28 10:03:12 UTC",
  "expires_at": "2025-01-28 10:08:12 UTC",
  "overridden": true,
  "previous_locker_uid": "1234567890123456789"
}

Response Fields

FieldTypeDescription
lock_tokenstringYOUR new token for the lock you now hold — required for heartbeat and release
node_idstringOpaqueId of the node
locked_atstringWhen you took the lock (YYYY-MM-DD HH:MM:SS UTC)
expires_atstringWhen your lock expires unless renewed (YYYY-MM-DD HH:MM:SS UTC)
overriddenbooleantrue when a live lock was actually replaced; false when the file was not locked, or when you already held it. It reports whether somebody was interrupted, which is not the same question as whether we can name them — it stays true even when previous_locker_uid is null
previous_locker_uidstring/nullWho held the lock, or null when nobody did (or when the holder could not be identified). Always a string, never a JSON number — ids exceed JavaScript’s safe integer range. Requires MEMBER level or above, exactly as locker_uid on lock status does; below that the key is absent. Read overridden to learn whether anyone was displaced — that stays truthful for every caller

Error Responses

Error CodeHTTP StatusDescription
1609 (Not Found)404Node not found or outside your workspace/share scope
1680 (Access Denied)401Insufficient permission to modify locks on this node (share only)
1693 (Temporarily Unavailable)503Lock service momentarily unavailable; retry after a brief delay

Notes:

Previews

File previews provide rendered views of documents, images, video, and other content without downloading the original file.

Preview Types

ValueDescription
thumbnailSmall thumbnail image
imageFull-size image preview
mp4MP4 video preview (transcoded)
hlsstreamHLS video/audio stream
audioAudio preview (transcoded)
pdfPDF document preview
spreadsheetSpreadsheet preview
binBinary preview (raw bytes for clients that render their own preview)

Preview States

Returned in node details responses under previews.{type}.state:

StateDescription
unknownPreview status not yet determined
not possibleFile type cannot be previewed
not generatedPreview not yet generated
errorPreview generation failed
in progressPreview is being generated
readyPreview 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

ParameterTypeRequiredDescription
{preview_type}stringYesOne of: thumbnail, image, mp4, hlsstream, audio, pdf, spreadsheet, bin

curl Example

curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/preview/thumbnail/preauthorize/" \
  -H "Authorization: Bearer {jwt_token}"

Response

{
  "result": true,
  "downloadToken": "eyJhbGciOiJIUzI1NiJ9...",
  "path": "/current/workspace/.../preview/thumbnail/read/eyJhbGci.../file/preview.png",
  "primaryFilename": "preview.png"
}

Response Fields

FieldTypeDescription
downloadTokenstringJWT token for preview access
pathstringFull API path to read the preview file
primaryFilenamestringName of the primary preview file

Error Responses

Error CodeHTTP StatusDescription
1609 (Not Found)404File not found
1605 (Invalid Input)406Can only preview file or note
1609 (Not Found)404File is in trash
1652 (Resource Not Found)404File content is no longer available
1652 (Resource Not Found)404Preview 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

ParameterTypeRequiredDescription
{token}stringYesDownload token from preauthorize
{filename}stringYesPreview filename

Range requests are supported, with the same semantics as the file read endpoint above: 206 Partial Content with Content-Range for a satisfiable range, 416 for a range that starts at or past the end, 200 with the whole body for an unparseable Range header, and GET only (HEAD returns 405).

The download token is not single-use — it stays valid for its lifetime, so a client may issue many ranged requests against the same URL. Note that the size being ranged over is the preview artifact's, which is not the source file's size unless the preview is served as the original (as it is for a PDF preview of a PDF).

Request Preview Nonce (Share Only)

GET /current/share/{share_id}/storage/{node_id}/requestpreview/

Mint a one-time nonce that lets a caller read a file it is not allowed to download. Shares with download_security=medium only — any other security level is rejected. This is how a share that withholds downloads still lets a viewer see the file: the nonce authorizes a single read and nothing else.

Auth required. File-view permission on the share.

curl Example

curl -X GET "https://api.fast.io/current/share/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/requestpreview/" \
  -H "Authorization: Bearer {jwt_token}"

Response

{
  "result": true,
  "preview_nonce": "abc123..."
}

Pass the value back as the preview_nonce parameter on GET /current/share/{share_id}/storage/{node_id}/read/. It is single-use and short-lived — 60 seconds, consumed on first use — so mint it at the moment of the read, not ahead of time, and mint a fresh one per read. It is bound to the node it was issued for and is not valid for any other file.

Error Responses

Error CodeHTTP StatusDescription
125621406The share’s download security is not medium
164543406The node is a folder — only a file or note can be previewed
180409404Node not found
160750404The node exists but is outside this share
173838404The node is in the trash

Transforms

Image transforms allow on-the-fly resizing, cropping, rotating, and format conversion.

Get Transform Status

GET /current/workspace/{workspace_id}/storage/{node_id}/transform/{transform_name}/
GET /current/share/{share_id}/storage/{node_id}/transform/{transform_name}/

Check if a transformation is available without triggering it.

Auth required. Currently the supported {transform_name} is image.

curl Example

curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/transform/image/" \
  -H "Authorization: Bearer {jwt_token}"

Response

{
  "result": true,
  "state": "rendered"
}

Transformation States

StateDescription
renderedTransform is ready
renderingTransform in progress
unrenderedTransform not yet requested
unable to renderTransform 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

{
  "result": true,
  "state": "rendered"
}

Error Responses

Error CodeHTTP StatusDescription
1609 (Not Found)404Unknown transformation name
1609 (Not Found)404Unable 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:

ParameterTypeValues
output-formatstringpng, jpg, jpegrequired
widthintTarget width in pixels
heightintTarget height in pixels
cropwidthintCrop region width
cropheightintCrop region height
cropxintCrop region X offset
cropyintCrop region Y offset
rotateint0, 90, 180, 270
sizestringPredefined: IconTiny, IconSmall, IconMedium, Preview

output-format is required on every transform read; the rest are optional. Send OPTIONS to a transform read URL for the accepted parameter list as the API itself reports it.

curl Example

curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/transform/image/read/?width=200&height=200&output-format=jpg" \
  -H "Authorization: Bearer {jwt_token}" \
  -o thumbnail.jpg

Request Transform Download Token

GET /current/workspace/{workspace_id}/storage/{node_id}/transform/{transform_name}/requestread/
GET /current/share/{share_id}/storage/{node_id}/transform/{transform_name}/requestread/

Get a temporary download token for the transformed file. Available on both workspaces and shares.

Auth required.

Response

{
  "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.

Saved metadata filters

The per-user saved-view endpoints (metadata/view/, metadata/views/) have been removed and replaced by workspace-shared saved filters: a named predicate over extracted metadata plus an optional display projection. Filters are shared across the workspace rather than private to one user, and are identified by a server-owned filter_id.

POST /current/workspace/{workspace_id}/metadata/filters/
GET /current/workspace/{workspace_id}/metadata/filters/
GET /current/workspace/{workspace_id}/metadata/filters/{filter_id}/
PUT /current/workspace/{workspace_id}/metadata/filters/{filter_id}/
DELETE /current/workspace/{workspace_id}/metadata/filters/{filter_id}/
GET /current/workspace/{workspace_id}/metadata/filters/{filter_id}/nodes/

Auth required. Workspace member. Metadata billing feature required.

A predicate is an array of {field, operator, value} clauses, AND-chained, at most 5 per filter. An empty predicate is valid and means “everything” — a filter with no clauses is a saved layout over the whole workspace. Create and update validate structure only; field existence and operator legality are checked when the filter is executed, which can return 406.

Create and update take a JSON object request body; list and execute take query-string parameters. Create and update both require name and predicate (send [] for match-all), and PUT replaces the whole filter — an omitted description or projection is cleared, so read before you write. Create, get and update answer {"result": true, "filter": …}; list answers {"result": true, "count", "items", "cursor", "has_more"} — note items, not filters; delete answers {"result": true} and is idempotent, so a 200 does not prove anything was deleted.

Full reference — the request/response contract per endpoint, predicate operators, the clause cap, projection semantics and the scope object — is in the AI & Metadata documentation under Saved metadata filters.

Workspace-Only Features

These endpoints are only available on workspaces, not shares:

Share-Specific Notes

↑ Back to top