Storage Operations File and folder operations, locking, previews, and transforms
Storage endpoints are available on both workspaces and shares. The API patterns are identical — replace workspace/{workspace_id} with share/{share_id} in any path below unless noted as workspace-only or share-only.
All endpoints require JWT authentication unless otherwise noted. Include the header Authorization: Bearer {jwt_token} with every request.
Conventions
- Root folder: Use the literal string
"root"as the path parameter (e.g.,/storage/root/list/) - Trash folder: Use
"trash"to list trashed items (e.g.,/storage/trash/list/) - Node IDs: OpaqueIds — 29-character alphanumeric strings displayed with hyphens (e.g.,
2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4). Use as-is in API calls. - Node ID prefixes:
f= file,d= folder,n= note - Name length: File, folder, and note names are 1–255 characters. Every endpoint that creates or renames a node by name enforces this range and rejects a longer name with
1605 (Invalid Input). The count is characters, not bytes, so an accented, CJK, or emoji character each counts as one. - Node types in responses:
"file","folder","note","link"(lowercase strings) - Parent field: Nodes at the storage root have
"parent": "root"; nested nodes show the parent's OpaqueId - Delete vs purge:
DELETE .../storage/{node_id}/delete/moves to trash.DELETE .../storage/trash/delete/empties the entire trash.DELETE .../storage/{node_id}/purge/permanently deletes a single trashed item. - Workspace folder shares: Shares that reference a workspace folder have their
rootmapped to the designated folder. All operations are scoped to that subtree. - Compact responses: Every storage endpoint that returns nodes (list, details, search, metadata, trash, quickshares) accepts an optional
?output=<token>query parameter with three detail levels:terse,standard, orfull. See the "Compact Responses" section below for the full contract, field lists, and the HTTP 406 rule for multi-level combinations. (QuickShare creation is deprecated — use the durable File Share instead.) - Reading the error tables: the four-digit
16xx/17xxvalues below are HTTP-status classes, noterror.code. Theerror.codea client actually receives is assigned per endpoint, so use the HTTP status as the gate and a documentederror.code— five or six digits, plus the9661-9669family — only as a refinement. A16xxvalue identifies the status class — useful for telling which kind of failure occurred — but comparing one againsterror.codewill never match. Five- and six-digit codes (and the9661-9669family) are realerror.codevalues. If you widen a check from a specific code to a status, widen what you assert with it — a status covers failures the narrower code did not, so a message written for that one code becomes a confident falsehood on the rest.
Compact Responses (output=)
Every storage endpoint that returns node objects — folder listings, node details, search hits, metadata endpoints, trash listings, and quickshares — accepts an optional output query parameter that selects the shape of each node in the response. A single detail-level token may be combined with modifier tokens; specifying two detail levels (e.g. ?output=terse,standard) returns HTTP 406. When output= is omitted, responses are full and byte-for-byte unchanged.
| Level | Fields returned on each node (cumulative) |
|---|---|
terse | id, type, name, parent, version, file/note-only mimetype and size, modified, recursive nodes for folders when the endpoint returns children, file/note-only summary reduced to {title} only, file/note-only previews reduced to a per-type {ready: bool} map (keys preserved: thumbnail, image, pdf, mp4, hlsstream, audio, spreadsheet), folder-only is_share_root/share_id, file/note-only metadata_facts reduced to a comma-separated fields name list |
standard | terse + created, restricted, dmca, locked, file/note-only mimecategory, 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 |
full | standard + summary.short/summary.long, note-only summary.category, virus, full ai object, full file_attributes (embedded EXIF / media metadata, returned only to callers permitted to download the file — see Node Object Schema), all remaining origin.* fields, hash, hash_algo, lock_info, import_metadata, full previews state map, 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.
| Field | Type | Present On | Description |
|---|---|---|---|
id | string | all | OpaqueId of the node |
name | string | all | File, folder, or note name |
type | string | all | "file", "folder", "note", or "link" |
parent | string | all | Parent folder OpaqueId or "root" |
size | integer | file | File size in bytes |
hash | string | file | Content hash of the file |
hash_algo | string | file | Hash algorithm (e.g., "md5") |
mimetype | string | file | MIME type (e.g., "application/pdf") |
mimecategory | string | file | MIME category (e.g., "document", "image") |
version | string | file, note | Current version identifier (e.g., "3u6cr-vxmyl-4y2pr-5jboz-afoke-k4s5") |
created | string | all | Creation timestamp (YYYY-MM-DD HH:MM:SS UTC) |
modified | string | all | Last-modified timestamp (YYYY-MM-DD HH:MM:SS UTC) |
restricted | boolean | all | Whether the file has been restricted |
dmca | boolean | all | Whether the file has a DMCA flag |
locked | boolean | all | Whether the node has an active lock |
lock_info | object/null | all | Lock details when locked; null otherwise. 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 |
virus | object | file | Virus scan status: {"status": "scanned", "infected": false} |
file_attributes | object | file | Metadata read out of the file itself — media_metadata and/or exif_metadata when present. Returned only to callers permitted to download the file; see Embedded File Metadata below |
summary | object | file | AI-generated summary: {"title": "...", "short": "...", "long": "..."} |
metadata | object/null | file | User-defined custom title and description overrides |
metadata_facts | object | file, note | Extracted metadata facts recorded for the node — count, total, is_truncated, and a payload shaped by the output level (items on full and standard, a comma-separated fields name list on terse). Returned only to WORKSPACE members; never present in a share context; see Extracted Metadata Facts below |
previews | object | file | Preview generation state per type (e.g., {"thumbnail": {"state": "ready"}}) |
ai | object | file | AI processing state: {"state": "...", "attach": true/false} |
origin | object | file | Origin info: {"type": "upload", "creator": "{user_id}"} |
Embedded File Metadata (file_attributes)
file_attributes carries metadata read out of the file's own bytes: exif_metadata (camera and lens details, device make and model, authoring software, author and copyright text) and media_metadata (container, codec, and per-stream details). Because that content comes from inside the file, it is returned only to callers who are permitted to download the file.
- When the caller may view a file but not download it, the
exif_metadataandmedia_metadatakeys are omitted, sofile_attributescomes back as an empty object{}. It is nevernull, no error is raised, and no other field changes. - Everything else reported about a file —
size,hash,hash_algo,mimetype,mimecategory,created,modified,origin,previews,virus,ai, and the AIsummary— is derived by the platform rather than read from the file, and is returned regardless of download rights. - Workspace responses are unaffected. Workspace members always have download rights, so workspace endpoints return
file_attributesexactly as before. - On a share, the gate follows the share's file download permission: a share whose downloads are turned off (and receive-share guests, who are upload-only) get
{}. Members and administrators always download, so they always receive it. - On a File Share link, the gate follows the link's
effective_capability:viewalone gets{};downloadandeditreceive the metadata.
Extracted Metadata Facts (metadata_facts)
metadata_facts carries the extracted metadata facts recorded for a file: the field-and-value pairs the extraction pipeline produced against the workspace's field vocabulary. It is returned on file and note nodes at all three ?output= levels, in a different shape at each level, and every shape carries count — how many facts this payload carries, not how many the node holds — total — how many the node holds, counted before any cap — and is_truncated. Folder and link nodes never carry it, and the lightweight recent-files listing does not include it.
If you send no output parameter you get full — on node listings and on node details alike. There is one global default and no endpoint overrides it, so the two cannot diverge: a node arriving from a folder listing carries the same complete fact records as the same node fetched directly. The lean and projected shapes below are reachable only by asking for them explicitly.
full — up to 100 facts as complete records: field, value, declared_type, stored_type, source (one of ai, user, exif, mediainfo, validated_server), confidence (low, medium, high, certain, or null), rationale (a string or null), 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"
}
is_truncatedis present at every level and istruewhen the node holds more facts than were returned. To read the complete set, call the node facts endpoint —GET /current/workspace/{workspace_id}/storage/{node_id}/metadata/facts/— which is not paginated and returns every fact on the node in one call.totalis present at every level and is how many facts the node holds, counted before any cap.countis what this payload carries;totalis what there was to carry;totalis never less thancount. Read the pair as “8 shown of 14” — that is what tells you whether re-reading the node at?output=fullis worth a second call, and whentotalequalscountthere is nothing more to fetch. The node facts endpoint's own wrapper does not carry it, and does not need to: that read is uncapped, so itscountalready is the total.- An absent
metadata_factsdoes not mean the file has no metadata. The key is omitted entirely — nevernull— when the caller is not entitled to extracted metadata, or when the facts could not be read for that request. A file that genuinely holds none returns"count": 0, "total": 0with an emptyitems(or an emptyfieldsstring) instead. - Extracted metadata is a WORKSPACE-ONLY surface, because both the values and the field names are the owning team's own data. Workspace members receive it. It is never returned in a share context, to any share role — not to share members, not to share administrators, not to share guests or public-link guests — and File Share link recipients never do. A workspace-folder share mirrors workspace nodes, and those nodes are covered by the same rule.
AI States
| Value | Description |
|---|---|
disabled | AI processing is disabled for this file |
pending | Queued for AI processing |
inprogress | AI processing is running |
ready | AI processing complete |
failed | AI processing failed |
indexed | File has been indexed for search and RAG |
Keyset Pagination (Storage List)
Storage listing endpoints (list and recent) use cursor-based pagination, not offset-based.
Request Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| sort_by | string | name | One of: name, updated, created, type |
| sort_dir | string | asc | One of: asc, desc |
| page_size | int | 100 | One of: 100, 250, 500 (snapped to nearest) |
| cursor | string | — | Opaque cursor string from previous response |
Response Pagination Fields
| Field | Type | Description |
|---|---|---|
pagination.has_more | boolean | Whether more pages exist |
pagination.next_cursor | string/null | Cursor for the next page; null if last page |
pagination.page_size | integer | Effective page size used |
Notes:
- Cursors are HMAC-signed; tampered cursors are rejected with an error.
- When using a cursor, the page size from the cursor takes precedence over the request parameter.
- Results for the first page may be slightly delayed.
- The
recentendpoint ignoressort_byandsort_dir(always sorted byupdateddescending).
List Folder Contents
/current/workspace/{workspace_id}/storage/{parent_id}/list/
/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
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} or {share_id} | string | Yes | 19-digit profile ID |
| {parent_id} | string | Yes | Folder OpaqueId, "root", or "trash" |
Query parameters: See Keyset Pagination section above.
curl Example
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
| Field | Type | Description |
|---|---|---|
nodes.count | integer | Number of nodes in this page |
nodes.items | array | Array of node objects for current page |
pagination.has_more | boolean | true if more pages exist |
pagination.next_cursor | string/null | Cursor for next page |
pagination.page_size | integer | Actual page size used |
Error Responses
| Error Code | HTTP Status | Description |
|---|---|---|
1609 (Not Found) | 404 | Folder not found |
1605 (Invalid Input) | 406 | Node is not a folder |
1605 (Invalid Input) | 406 | Invalid pagination cursor (tampered or mismatched) |
Workspace Inventory
/current/workspace/{workspace_id}/storage/inventory/
/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
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} or {share_id} | string | Yes | 19-digit profile ID |
Query Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| page_size | integer | No | 100 | Must be 100, 250, or 500. The default is sized so a page renders whole inside an AI agent's context window; use 250 or 500 for non-agent clients. |
| cursor | string | No | — | The id of the last row you kept, hyphenated or raw. Absent means the first page. Strictly greater-than, and independent of type and page_size. A value that is not a node id is refused with 406. |
| type | string | No | — | Restrict the walk to one node type: file, folder, link, or note |
| include | string | No | — | Comma-separated extra field groups. Only path is recognised. An unrecognised token is refused with 406, never silently ignored. |
curl Example
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
| Field | Type | Description |
|---|---|---|
nodes.count | integer | Number of rows in this page |
nodes.items | array | The inventory rows for this page |
nodes.items[].id | string | Node OpaqueId |
nodes.items[].type | string | "file", "folder", "link", or "note" |
nodes.items[].name | string | Node name |
nodes.items[].parent_id | string | Parent folder OpaqueId, or "root" for a node directly in the storage root |
nodes.items[].size | integer/null | Size in bytes for files and notes; null for folders and links |
nodes.items[].mimetype | string/null | MIME type for files and notes; null otherwise |
nodes.items[].updated | string | Last-modified time, YYYY-MM-DD HH:MM:SS UTC |
nodes.items[].version | string | Current version OpaqueId |
nodes.items[].summary | object/null | {"title": "..."} when the file has an AI summary, otherwise null. Title only — the short and long forms stay on Node Details. |
nodes.items[].facts_total | integer/null, or absent | How many extracted metadata fields the file has. Emitted only for callers who may read metadata facts (workspace members) — absent for everyone else, and never emitted at all by the share twin. When present: an integer count, or null when the file has never had metadata extracted. Folders and links are always null when the key is present; notes follow the same rule as files. See the note below — an absent key means unknown, never zero. |
pagination.has_more | boolean | true if more pages exist |
pagination.next_cursor | string/null | Cursor for the next page; null when there are no more pages |
pagination.page_size | integer | Effective page size used |
facts_total — absent, null and a number mean three different things. The field is gated on permission to read metadata facts, and facts never cross into a share, so it is emitted only for callers who may read them (workspace members) and the share twin never emits it at all. Read the three states separately:
- Absent — unknown, or not permitted for this caller. The key is also absent when the count is not available for this response. Treat it as unknown: never as
0, and never as “this file has no metadata”. null— the key is present and the file has never had metadata extracted. Folders and links are alwaysnullwhen the key is present; notes follow the same rule as files.- A number — that many extracted metadata fields.
include=path — where each node lives. Add include=path and every row gains three more fields: path (string/null), ancestors (array of {id, name}), and path_complete (boolean). They have exactly the same meaning and shape as the fields of the same names on a storage-search hit — read 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 Code | HTTP Status | Description |
|---|---|---|
1605 (Invalid Input) | 406 | cursor is not a node id |
1605 (Invalid Input) | 406 | Unsupported include value |
1609 (Not Found) | 404 | Inventory is not available for Shared Folders (share only, workspace-folder shares) |
Node Details
/current/workspace/{workspace_id}/storage/{node_id}/details/
/current/share/{share_id}/storage/{node_id}/details/
Get full details for a single node (file, folder, or note).
Auth required. Permission: View (workspace), View (share).
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} or {share_id} | string | Yes | 19-digit profile ID |
| {node_id} | string | Yes | Node OpaqueId |
curl Example
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 Code | HTTP Status | Description |
|---|---|---|
1609 (Not Found) | 404 | Node not found |
1680 (Access Denied) | 401 | No permission to view details (share only) |
Bulk Form
The details endpoint accepts a comma-separated list of node ids in place of a single {node_id}:
GET /current/workspace/{workspace_id}/storage/{id1},{id2},{id3}/details/
Up to 25 ids per call. Duplicate ids are silently deduplicated. Empty segments (e.g. trailing comma) return 406. The bulk form is currently only available for the workspace path.
The bulk response shape differs from the single-id form. Successfully resolved nodes appear as an array under nodes; per-id failures appear in a parallel errors array. HTTP status is 200 when at least one node resolves and 404 when every requested id errored.
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 Code | HTTP Status | Description |
|---|---|---|
1605 (Invalid Input) | 406 | Empty segment between commas |
1605 (Invalid Input) | 406 | More than 25 unique ids in one request |
1609 (Not Found) | 404 | Every requested id errored (errors array is populated) |
Node Content
/current/workspace/{workspace_id}/storage/{node_id}/content/
/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
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} or {share_id} | string | Yes | 19-digit profile ID |
| {node_id} | string | Yes | Node OpaqueId. Both spellings are accepted — with or without hyphens. Ids in the response are always hyphenated. |
Query Parameters
All optional. At most one window selector (page, chunk_from/chunk_to, q); with none, the response starts at the beginning of the file.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| q | string | No | — | Relevance mode. Rank this file's own chunks by keyword match and return the best ones, each with full text and a numeric score. 1–512 characters. Cannot be combined with page, chunk_from/chunk_to, or cursor. |
| page | integer | No | — | Return the chunks whose [start_page, end_page] range overlaps this page. 1-based. |
| chunk_from | integer | No | — | Start of an inclusive position range. Minimum 0. Positions at or beyond 10000 cannot be addressed this way — continue into a large file with cursor instead. |
| chunk_to | integer | No | — | End of that inclusive position range. Requires chunk_from and must be greater than or equal to it, and is subject to the same 10000 ceiling. |
| cursor | string | No | — | Continue after this chunk within the selected window. Opaque; pass back the next_cursor from the MOST RECENT response verbatim, never a value you built yourself and never one you stored from an earlier walk. Not valid with q. When used together with chunk_from/chunk_to, the cursor must have come from a walk of that same range: a token whose position falls outside the range is refused as an invalid window. |
| limit | integer | No | 5 (3 with q) | Chunks per response. 1–20. |
| max_bytes | integer | No | 32768 | UTF-8 byte budget over the text in one response, applied in the ordered modes only. 1024–262144. |
| output | string | No | full | terse returns the chunk map with no text; standard and full include it. Composable with the markdown modifier like every other storage endpoint. |
curl Example
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
| Field | Type | Description |
|---|---|---|
node_id | string | The node, hyphenated. |
name, mimetype | string | Taken from the node itself, never from the text index, so a stale index entry can never change what the file is reported to be. |
indexed | boolean | Whether this version has any extracted text at all. Describes the WHOLE file, not the window you asked for. |
complete | boolean | Whether the file's text extraction finished. When false, only part of the text has been written so far — the chunks you get are real, and more may appear later. |
indexed_version_id | string/null | The file version the chunks were indexed from. null when indexed is false. |
page_addressable | boolean | true only when EVERY chunk of the file carries a page range. When false, a page selector returns an empty chunks list — switch to chunk_from/chunk_to. |
num_pages | integer/null | Page count of the converted document; null when the format has no pages. |
total_chunks | integer | Chunks in the whole file, not in this response. A window that matches nothing on an indexed file still returns indexed: true with the real total_chunks and an empty chunks list. |
chunks | array | The matching chunks, ordered by position (by score descending in relevance mode). |
chunks[].position | integer | The chunk's address. Its 0-based ordinal place in the file's read order. Always present and always exact. This is what chunk_from and chunk_to select on, and the value to record if you want to come back to a chunk later; cursor continues a walk from an opaque token instead. |
chunks[].sequence | integer/null | The ordering coordinate assigned when the file's text was extracted. It increases through the file but is not necessarily contiguous, and is null for text extracted before it was assigned. Published for correlation and debugging — address a chunk by position, not by this. |
chunks[].chunk_index | integer/null | Legacy field, kept for compatibility and now nullable: newer text may not carry one. Read position as the address instead. |
chunks[].start_page, chunks[].end_page | integer/null | Inclusive 1-based page range the chunk covers; both null on a file with no pages. |
chunks[].chars | integer | Character length of the full chunk text — present under output=terse too, so a caller can budget before asking for the text. |
chunks[].score | number/null | The keyword relevance score in q mode; null in every ordered mode. |
chunks[].text | string | The chunk's text. Absent under output=terse. |
next_cursor | string/null | Opaque continuation token for the last chunk actually emitted, to pass back as cursor. Treat it as an opaque string — never construct, parse or store one, and always send back the token from the most recent response; a token held over from an earlier release is refused as an invalid window. It carries the file version it was issued against, so a walk can never straddle two versions. null when the window is exhausted — that, not a short page, is how you know you are done. |
truncated | boolean | true when whole chunks were withheld by max_bytes. |
Reading a whole file: call with no selector, then keep re-calling with cursor set to the previous next_cursor until next_cursor is null, concatenating text. Chunks are contiguous and in order; joining them with a newline between chunks reproduces the extracted text apart from the whitespace the splitter dropped at each boundary.
Ordered modes and the byte budget. In the ordered modes (no selector, page, chunk_from/chunk_to) the response stops BEFORE the chunk that would push it past max_bytes; text is never cut inside a chunk, and at least one chunk is always returned even when that one chunk is larger than the budget. truncated: true means matched chunks were withheld — not that a chunk's text was shortened — and next_cursor is the last chunk actually sent, so continuing from it loses nothing.
Relevance mode (q). q ranks this one file's chunks by keyword match (the terms are matched independently, so a natural-language question still returns the passage carrying the most of its words first). It returns the top limit chunks — default 3 — sorted by score descending, each with its FULL text: max_bytes is deliberately not applied, so a hit is never silently dropped. Use it to locate and quote a clause without walking the file; use the ordered modes to read the file through.
output=terse returns the same envelope and the same chunk list with text omitted — a chunk map of positions, page ranges and chars. The byte budget is not spent on a response that carries no text, so a terse page is never shortened.
A File With No Extracted Text
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 Code | HTTP Status | Description |
|---|---|---|
1609 (Not Found) | 404 | Node not found, or the node is in the trash |
1605 (Invalid Input) | 406 | Node is a folder or a link — only files and notes carry extracted text |
1605 (Invalid Input) | 406 | Window parameters conflict or are out of range: more than one selector, q with cursor, chunk_to without chunk_from, chunk_from greater than chunk_to, a chunk_from/chunk_to position at or beyond 10000, a cursor that was not taken verbatim from the most recent response, a cursor used with a chunk_from/chunk_to range it does not fall inside, or a value outside the documented bounds |
1680 (Access Denied) | 401 | Share caller has no download permission, or the file is virus-flagged (share path) |
1652 (Resource Not Found) | 404 | The file's content is no longer available |
1654 (Internal Error) | 500 | Content temporarily unavailable — the text could not be read. Retry; never treat this as “the file has no text” |
Multi-File Content Search
/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
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} | string | Yes | 19-digit profile ID |
Query Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| nodes | string | Yes | — | Comma-separated node ids, 1–10 of them. Both spellings are accepted — with or without hyphens. Blank segments are ignored (a,,b names two files) and a repeated id is de-duplicated silently. |
| q | string | Yes | — | The query the files are scored against. 1–512 characters. Required here — this route publishes no other way to select text. |
| limit | integer | No | 3 | Chunks returned per file. 1–20. |
| max_bytes | integer | No | 32768 | UTF-8 byte budget over the emitted text, spent per file — same name, default and bounds as the single-file route's max_bytes, but ten files at the cap means ten separate budgets, not one shared pot: the response ceiling is nodes × max_bytes, roughly 320 KiB of text at ten files and the default. At the maximum max_bytes, the ceiling is 10 × 262144 ≈ 2.5 MiB of text — a deliberate opt-in, equal to the single-file route's per-request bound times the node cap. 1024–262144. Text is never cut inside a chunk — a file's emission stops before the chunk that would overflow its budget, and at least one chunk is always returned. |
| output | string | No | full | terse returns the chunk map with 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
| Field | Type | Description |
|---|---|---|
q, limit | string, integer | Echoed back as applied, so a caller can see the default limit that was used. |
nodes | object | Keyed by hyphenated node id, in the order the ids were named. An object even when empty — a caller keying into it never has to handle a list. |
nodes.{id}.name, nodes.{id}.mimetype | string | Taken from the file itself, never from the text index. |
nodes.{id}.indexed | boolean | Whether this version has any extracted text at all — describes the whole file, not the query window. |
nodes.{id}.indexed_version_id | string/null | The file version the returned chunks were indexed from. null when indexed is false. |
nodes.{id}.page_addressable | boolean | true only when EVERY chunk of the file carries a page range — the same meaning as on the single-file route. |
nodes.{id}.chunks | array | That file's 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_chunks | integer | Chunks in the WHOLE file, not in this response. A file that is indexed but matched nothing comes back with an empty chunks list and a non-zero total_chunks; a file with no extracted text at all comes back with total_chunks: 0. |
nodes.{id}.num_pages | integer/null | Page count of the converted document; null when the format has no pages. |
nodes.{id}.truncated | boolean | true when that file's chunks were cut short by max_bytes — not by limit. A file whose text matched in more places than limit still returns truncated: false; a chunks length equal to limit means more matches may exist — raise limit to see them. |
missing | array | One entry per named id the read could not answer for: {id, reason}. One unreadable id never costs you the others. |
missing[].reason | string | not_found (no such file in this workspace, or its content is gone), trashed (the file is in the bin), or not_text (a folder or a link, which carries no extracted text). |
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 Code | HTTP Status | Description |
|---|---|---|
1605 (Invalid Input) | 406 | nodes names no ids, names more than 10, or contains a malformed id; q is missing, empty or longer than 512 characters; limit is outside 1–20; max_bytes is outside 1024–262144 |
1654 (Internal Error) | 500 | Content temporarily unavailable — the text could not be read. Retry; never treat this as “these files have no matching text” |
1654 (Internal Error) | 500 | A named node could not be retrieved (a backend read failure at the node or physical-record lookup) — never reported as missing/not_found; the whole request fails instead. |
Add File from Upload
/current/workspace/{workspace_id}/storage/{parent_id}/addfile/
/current/share/{share_id}/storage/{parent_id}/addfile/
Add a previously uploaded file to storage.
Auth required. Permission: Guest (workspace), file creation permission (share).
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} or {share_id} | string | Yes | 19-digit profile ID |
| {parent_id} | string | Yes | Parent folder OpaqueId or "root" |
Request Body (form-encoded)
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Yes | Filename for the new file. 1–255 characters (counted as characters, not bytes). |
| from | string | Yes | JSON-encoded source object (see below) |
from format:
Both workspace and share variants accept the same JSON shape. Two source types are supported: upload (a completed upload session) and hash (deduplicate against an existing object by content hash).
{"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 Code | HTTP Status | Description |
|---|---|---|
1605 (Invalid Input) | 406 | Upload session not found or not associated with your account |
1605 (Invalid Input) | 406 | Upload is not complete |
1609 (Not Found) | 404 | Parent folder not found |
1605 (Invalid Input) | 406 | Parent node is not a folder |
1609 (Not Found) | 404 | Parent folder is in trash |
1605 (Invalid Input) | 406 | Name conflict (only when using FAIL strategy) |
1693 (Temporarily Unavailable) | 503 | Chunk manifest not yet durable — retry after a brief delay |
Notes:
- The upload session must be in COMPLETE status before adding the file.
- Virus scanning occurs during upload assembly, not at this stage.
- Conflict resolution: If a file with the same name exists, the default behavior is to replace (overwrite) the existing file, creating a version for rollback. Folder or type-mismatch conflicts fall back to renaming.
Add Link (Workspace Only)
/current/workspace/{workspace_id}/storage/{parent_id}/addlink/
Add a share link node to workspace storage. Link nodes represent references to shares within the workspace tree.
Auth required. Permission: Guest.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} | string | Yes | 19-digit workspace profile ID |
| {parent_id} | string | Yes | Parent folder OpaqueId or "root" |
Request Body (form-encoded)
| Parameter | Type | Required | Description |
|---|---|---|---|
| link_target_type | string | Yes | Must be "share" |
| share | string | Yes | Share identifier to link |
curl Example
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 Code | HTTP Status | Description |
|---|---|---|
1683 (Resource Missing) | 404 | Share not found or not accessible |
1605 (Invalid Input) | 406 | Share does not belong to this workspace |
1605 (Invalid Input) | 406 | A link to this share already exists (only one per share) |
1605 (Invalid Input) | 406 | Share has no title or custom URL to name the link node with |
1610 (General Error) | 500 | The link node was created but the share's back-link could not be saved; the node is removed again and the request fails — retry |
The link node is named after the share's title, falling back to its custom URL when no title is set.
Create Folder
/current/workspace/{workspace_id}/storage/{parent_id}/createfolder/
/current/share/{share_id}/storage/{parent_id}/createfolder/
Create a new folder.
This endpoint is idempotent by default: if a folder with the same name already exists in the parent, the existing folder is returned (HTTP 200) instead of creating a duplicate. Pass force=true to bypass this and always create a new folder (auto-renamed on a name collision, e.g. Documents (2)).
Auth required. Permission: Guest (workspace), folder creation permission (share).
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} or {share_id} | string | Yes | 19-digit profile ID |
| {parent_id} | string | Yes | Parent folder OpaqueId or "root" |
Request Body (form-encoded)
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Yes | Folder name. 1–255 characters (counted as characters, not bytes). |
| force | boolean | No | When true, always create a new folder even if one with this name already exists (auto-renamed on collision). Defaults to false (idempotent — returns the existing folder). |
curl Example
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 Code | HTTP Status | Description |
|---|---|---|
1605 (Invalid Input) | 406 | A non-folder item already uses this name in the parent folder |
1609 (Not Found) | 404 | Parent folder not found |
1605 (Invalid Input) | 406 | Parent node is not a folder |
1609 (Not Found) | 404 | Parent folder is in trash |
1680 (Access Denied) | 401 | No folder creation permission (share only) |
Create Note (Workspace Only)
/current/workspace/{workspace_id}/storage/{parent_id}/createnote/
Create a markdown note. Notes are auto-indexed for AI when workspace intelligence is enabled.
Auth required. Permission: Guest.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} | string | Yes | 19-digit workspace profile ID |
| {parent_id} | string | Yes | Parent folder OpaqueId or "root" |
Request Body (form-encoded)
| Parameter | Type | Required | Constraints | Description |
|---|---|---|---|---|
| name | string | Yes | 1–255 characters (counted as characters, not bytes); must end in .md | Note name |
| content | string | Yes | Max 100 KB | Markdown content |
curl Example
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 Code | HTTP Status | Description |
|---|---|---|
1605 (Invalid Input) | 406 | Filename must end with .md |
1605 (Invalid Input) | 406 | Duplicate name in parent folder |
1609 (Not Found) | 404 | Parent folder not found |
Update Note (Workspace Only)
/current/workspace/{workspace_id}/storage/{node_id}/updatenote/
Update an existing note's name and/or content. Updating content creates a new version.
Auth required. Permission: Guest.
This workspace endpoint also accepts a realtime-note bearer token (minted by GET /current/realtime/note-auth/{profile_id}/{note_id}) in place of a user JWT, for the collaborative-editing backend to save on the user's behalf. The token is bound to a specific note and workspace and is rejected (403 Forbidden) if it does not match the requested note or workspace, or if it carries only view permission (a view token cannot update). The share note endpoints do not accept this token.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} | string | Yes | 19-digit workspace profile ID |
| {node_id} | string | Yes | Note OpaqueId |
Request Body (form-encoded)
| Parameter | Type | Required | Constraints | Description |
|---|---|---|---|---|
| name | string | No | 1–255 characters (counted as characters, not bytes); must end in .md | New note name |
| content | string | No | Max 100 KB, non-blank | New markdown content (an empty or whitespace-only value is rejected) |
| if_version_id | string | No | Version OpaqueId | Compare-and-swap precondition. When supplied, the update only proceeds if the note's current version matches this value; otherwise the request is rejected with 409 Conflict and no change is made. Use the version returned by a prior read or update 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 Code | HTTP Status | Description |
|---|---|---|
1609 (Not Found) | 404 | Note not found |
1605 (Invalid Input) | 406 | Node is not a note |
1609 (Not Found) | 404 | Note is in trash |
1605 (Invalid Input) | 406 | No content or name provided |
1605 (Invalid Input) | 406 | Duplicate name in parent folder |
113958 | 409 | if_version_id did not match the note's current version. error.params[] carries the conflict entry — see Conflict Response above. (1660 is not the value of error.code.) |
1700 (Forbidden) | 403 | realtime-note token does not match the requested note or workspace |
1700 (Forbidden) | 403 | realtime-note token lacks edit permission (a view token cannot update) |
Read Note
/current/workspace/{workspace_id}/storage/{node_id}/readnote/
/current/share/{share_id}/storage/{node_id}/readnote/
Read a note's content as JSON. Unlike the binary /read/ endpoint, this returns the sanitized markdown content as a string within the JSON response along with the full note resource.
Auth required. Permission: View (workspace), download permission or download token (share).
The workspace endpoint also accepts a realtime-note bearer token (minted by GET /current/realtime/note-auth/{profile_id}/{note_id}) in place of a user JWT, for the collaborative-editing backend to read on the user's behalf. The token is bound to a specific note and workspace and is rejected (403 Forbidden) if it does not match the requested note or workspace. The share note endpoint does not accept this token.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} or {share_id} | string | Yes | 19-digit profile ID |
| {node_id} | string | Yes | Note OpaqueId |
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| version_id | string | No | Specific version OpaqueId to read |
| token | string | No | Download token (share only — bypasses JWT auth) |
curl Example
# 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
| Field | Type | Description |
|---|---|---|
content | string | Sanitized markdown content of the note |
note | object | Full note node object (same shape as other node responses) |
Error Responses
| Error Code | HTTP Status | Description |
|---|---|---|
1605 (Invalid Input) | 406 | Invalid node ID |
1609 (Not Found) | 404 | Note not found |
1605 (Invalid Input) | 406 | Node is not a note |
1609 (Not Found) | 404 | Note is in trash |
1609 (Not Found) | 404 | Version not found |
1605 (Invalid Input) | 406 | Version does not belong to this note |
1609 (Not Found) | 404 | Version data no longer available |
1680 (Access Denied) | 401 | No permission to read notes (share only) |
1680 (Access Denied) | 401 | No permission to read notes you did not create (share, creator-only restriction) |
1700 (Forbidden) | 403 | realtime-note token does not match the requested note or workspace |
1700 (Forbidden) | 403 | realtime-note token lacks read permission |
Update Node
/current/workspace/{workspace_id}/storage/{node_id}/update/
/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
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} or {share_id} | string | Yes | 19-digit profile ID |
| {node_id} | string | Yes | Node OpaqueId |
Request Body (form-encoded)
| Parameter | Type | Required | Constraints | Description |
|---|---|---|---|---|
| name | string | No | 1–255 characters (counted as characters, not bytes) | New node name |
| from | string | No | JSON-encoded | New file content source (same format as addfile) |
| metadata_title | string | No | Max 50 chars | Custom title override |
| metadata_short | string | No | Max 2048 chars | Custom short description override |
| if_version_id | string | No | Version OpaqueId | Compare-and-swap precondition — the update proceeds only if the node's current version still matches this value; otherwise the request is rejected with 409 Conflict and no change is made. Applies to a content replace via either from.type=upload or from.type=hash, and to a rename via name. It is refused with 406 on a metadata-only update, which creates no new version (see below). Omit it and behavior is unchanged — last write wins. A present-but-empty value (if_version_id=) is rejected as invalid input, not treated as omitted. |
At least one field should be provided. if_version_id does not itself count toward that requirement — supplying only if_version_id (with no name, from, metadata_title, or metadata_short) still returns “No update parameters were specified”.
curl Example (rename)
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 Code | HTTP Status | Description |
|---|---|---|
1609 (Not Found) | 404 | Node not found |
1605 (Invalid Input) | 406 | Notes can only be renamed here — supply only name; use updatenote for content, title, or description changes |
1605 (Invalid Input) | 406 | No update parameters were specified |
1605 (Invalid Input) | 406 | Cannot update a folder or link with file data |
1605 (Invalid Input) | 406 | Name conflict — a file or folder with that name already exists in this location |
1693 (Temporarily Unavailable) | 503 | Chunk manifest not yet durable when replacing content — retry after a brief delay |
1609 (Not Found) | 404 | Node is in trash |
1680 (Access Denied) | 401 | No modify permission (share only) |
1680 (Access Denied) | 401 | No permission to modify files you did not create (share, creator-only restriction) |
158175 / 180212 | 409 | if_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 / 132163 | 406 | if_version_id supplied together with a new name on a link node (share / workspace) — see Link Rename Refusal above. |
Notes:
- At least one of
name,from,metadata_title, ormetadata_shortmust be provided. - Replacing content creates a new version.
- Renaming a link node propagates the rename to the linked share.
- Custom metadata overrides AI-generated summary values for display.
- Share permissions may restrict modification to files the user created (creator-only restrictions).
if_version_idguards a content replace with compare-and-swap; see Conflict Response above. It cannot be combined with a newnameon a link node — see Link Rename Refusal above.
Move Node
/current/workspace/{workspace_id}/storage/{node_id}/move/
/current/share/{share_id}/storage/{node_id}/move/
Move a node to a different folder within the same storage instance.
Auth required. Permission: Guest (workspace), file modification permission (share).
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} or {share_id} | string | Yes | 19-digit profile ID |
| {node_id} | string | Yes | Node OpaqueId to move |
Request Body (form-encoded)
| Parameter | Type | Required | Description |
|---|---|---|---|
| parent | string | Yes | Destination folder OpaqueId or "root" |
curl Example
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 Code | HTTP Status | Description |
|---|---|---|
1609 (Not Found) | 404 | Source or destination node not found |
1609 (Not Found) | 404 | Source or destination is in trash |
1605 (Invalid Input) | 406 | Cannot move a folder into itself or its subfolders |
1680 (Access Denied) | 401 | No move permission (share only) |
1680 (Access Denied) | 401 | No permission to move a folder containing files you cannot modify (recursive move requires modify-all, share only) |
Notes:
- Guests who can view or modify only their own files may copy or move individual files they created; copying or moving a whole folder requires permission to view (copy) or modify (move) all files in the share.
- Conflict resolution: If a file with the same name exists in the destination, the existing file is replaced (moved to trash for rollback). Folder or type-mismatch conflicts fall back to renaming.
Copy Node
/current/workspace/{workspace_id}/storage/{node_id}/copy/
/current/share/{share_id}/storage/{node_id}/copy/
Copy a node to another folder within the same storage instance. Folder copies are recursive.
Auth required. Permission: Guest (workspace), file/folder creation permission (share).
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} or {share_id} | string | Yes | 19-digit profile ID |
| {node_id} | string | Yes | Node OpaqueId to copy |
Request Body (form-encoded)
| Parameter | Type | Required | Description |
|---|---|---|---|
| parent | string | Yes | Destination folder OpaqueId or "root" |
curl Example
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 Code | HTTP Status | Description |
|---|---|---|
1609 (Not Found) | 404 | Source or destination not found |
1605 (Invalid Input) | 406 | Destination is not a folder |
1609 (Not Found) | 404 | Source or destination is in trash |
1605 (Invalid Input) | 406 | Cannot copy a folder into itself or a descendant — the destination parent is the source folder, or a folder beneath it |
1680 (Access Denied) | 401 | No permission to view or copy files you did not create (share, creator-only restriction) |
1680 (Access Denied) | 401 | No permission to copy a folder containing files you cannot view (recursive copy requires view-all, share only) |
Notes:
- Creates a deep copy for folders (all children are copied recursively).
- Guests who can view only their own files may copy or move individual files they created; copying or moving a whole folder requires permission to view (copy) or modify (move) all files in the share.
- The copied node gets a new OpaqueId.
- Conflict resolution: If a file with the same name exists in the destination, the existing file is replaced (moved to trash for rollback). Folder or type-mismatch conflicts fall back to renaming.
- The copy normally inherits the original’s metadata. Copying a file or a note carries its extracted and hand-entered metadata values onto the copy, with their sources and confidence intact — the copy holds the same content, so the same values are true of it, and it is not re-analyzed at your expense. Inheritance happens after the copy itself is saved, and it is best-effort: if it cannot complete, or if the original’s stored content changed while the copy was being made — so the values would no longer describe the copy’s own content — the copy is still created successfully, just with no metadata, and is treated as new content from then on. Nothing is retried in the background, so a copy that arrives without metadata keeps none until it is analyzed or filled in again. See Transfer Node below for metadata behavior when copying to a different storage instance.
Transfer Node
/current/workspace/{workspace_id}/storage/{node_id}/transfer/
/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
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} or {share_id} | string | Yes | Source profile ID |
| {node_id} | string | Yes | Node OpaqueId to transfer, or "root" for all |
Request Body (form-encoded)
| Parameter | Type | Required | Description |
|---|---|---|---|
| instance | string | Yes | 19-digit destination workspace or share profile ID |
| parent | string | Yes | Destination parent folder OpaqueId or "root" |
| mode | string | No | copy (default) or move. When move, the source node is trashed after copying. Cannot use mode=move when {node_id} is "root". |
curl Example (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 Code | HTTP Status | Description |
|---|---|---|
1609 (Not Found) | 404 | Source node not found or outside scope |
1609 (Not Found) | 404 | Source or destination instance (workspace/share) not found |
1680 (Access Denied) | 401 | No modify permission on the source node (share only) |
1680 (Access Denied) | 401 | No permission to modify source files you did not create (share, creator-only restriction) |
1680 (Access Denied) | 401 | No permission to transfer a whole folder or root (recursive copy requires view-all, move requires modify-all, share only) |
1680 (Access Denied) | 401 | No write access to destination -- the caller lacks file-creation permission on the destination share (e.g. a send-type share where guests cannot create) |
1680 (Access Denied) | 401 | Destination is a password-protected public share and a valid share password was not supplied |
1680 (Access Denied) | 401 | Writing into a personal (user-owned) share requires an identified caller with creation permission (an unauthenticated caller is denied) |
1680 (Access Denied) | 401 | The request token's scope does not cover the destination share (a scoped token must include the destination share, or its parent workspace/org) |
1605 (Invalid Input) | 406 | Name conflict at destination, or unsupported/not-allowed transfer |
1605 (Invalid Input) | 406 | mode=move cannot be used with "root" as the source node |
1605 (Invalid Input) | 406 | Cannot move/copy a folder into itself or a descendant — the destination parent is the source folder, or a folder beneath it, in the same storage instance |
Notes:
- Folder transfers are recursive.
- The user must have write access to both source and destination. Writing into a destination share requires file-creation permission on that share, honoring its type and access settings; a password-protected public destination share also requires a valid share password.
- When
mode=move, the source node is trashed in the source storage instance after the copy completes. The response includes"source_trashed": trueon success. - Guests who can view or modify only their own files may copy or move individual files they created; copying or moving a whole folder requires permission to view (copy) or modify (move) all files in the share.
- Metadata does not carry over to the destination. Copying or moving a node to a different storage instance carries no metadata: field definitions belong to a workspace, so a value moved across would be attached to the wrong field. The transfer itself succeeds either way.
Delete Node (Move to Trash)
/current/workspace/{workspace_id}/storage/{node_id}/delete/
/current/share/{share_id}/storage/{node_id}/delete/
Move a node to trash. Pass "trash" as the {node_id} to empty the entire trash bin.
Auth required. Permission: Guest (workspace), file modification permission (share). Emptying trash on shares requires admin permission.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} or {share_id} | string | Yes | 19-digit profile ID |
| {node_id} | string | Yes | Node OpaqueId, or "trash" to empty the entire trash |
curl 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 Code | HTTP Status | Description |
|---|---|---|
1609 (Not Found) | 404 | Node not found |
1609 (Not Found) | 404 | Node already in trash |
1680 (Access Denied) | 401 | No delete permission (share only) |
1680 (Access Denied) | 401 | No permission to empty trash (share, non-admin) |
1693 (Temporarily Unavailable) | 503 | Emptying the trash only: another trash operation on this workspace or share is in progress; nothing was changed — retry the same request after a brief delay |
Notes:
- Deleting a folder moves it and all children to trash recursively.
- Share delete permissions may be restricted to files the user created.
- Trashed files still count toward storage usage. Moving a node to trash does not reduce the storage your org is billed for — the bytes are still stored, and they are still yours to restore. Only a permanent delete releases them.
- Emptying the trash returns as soon as the trash is empty, but what it held is not removed straight away: those files are retained for 30 days as a recovery window, and they keep counting toward your storage usage for the whole of that window. Emptying the trash therefore does NOT reduce storage usage immediately — the bytes are released only once the window has passed. The call is still not undoable from the API; the window is a safeguard against an accidental empty, not a second trash.
- A storage limit measured over a billing period does not clear when you free space. The storage meter records the highest usage seen during the period, so an org that went over its limit keeps returning
402on writes until the period resets or more credit is added. Emptying the trash is not a remedy for it: the emptied files keep counting for their 30-day retention window, and even once they are released the period's high-water mark stands.
Purge Node (Permanent Delete)
/current/workspace/{workspace_id}/storage/{node_id}/purge/
/current/share/{share_id}/storage/{node_id}/purge/
Permanently delete a node that is already in trash. Irreversible.
Auth required. Permission: Member (workspace), admin (share).
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} or {share_id} | string | Yes | 19-digit profile ID |
| {node_id} | string | Yes | OpaqueId of the trashed node |
curl Example
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 Code | HTTP Status | Description |
|---|---|---|
1609 (Not Found) | 404 | Node not found |
1605 (Invalid Input) | 406 | Node is not in trash |
1680 (Access Denied) | 401 | Insufficient permission (share, non-admin) |
1693 (Temporarily Unavailable) | 503 | Another trash operation on this workspace or share is in progress; nothing was changed — retry the same request after a brief delay |
Restore from Trash
/current/workspace/{workspace_id}/storage/{node_id}/restore/
/current/share/{share_id}/storage/{node_id}/restore/
Restore a trashed node to its original location.
Auth required. Permission: Guest (workspace), file modification + admin (share).
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} or {share_id} | string | Yes | 19-digit profile ID |
| {node_id} | string | Yes | OpaqueId of the trashed node |
curl Example
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 Code | HTTP Status | Description |
|---|---|---|
1609 (Not Found) | 404 | Node not found |
1605 (Invalid Input) | 406 | Node is not in trash |
1605 (Invalid Input) | 406 | Node is inside a trashed folder (restore the parent folder instead) |
1680 (Access Denied) | 401 | No restore permission (share only) |
1693 (Temporarily Unavailable) | 503 | Another trash operation on this workspace or share is in progress; nothing was changed — retry the same request after a brief delay |
List Versions
/current/workspace/{workspace_id}/storage/{node_id}/versions/
/current/share/{share_id}/storage/{node_id}/versions/
List all versions of a file, note, or folder node.
Workspace route: auth required, View permission on the workspace.
Share route: auth is optional — an anonymous caller may read a public-link share (the share password, when set, must still be satisfied). Access is decided entirely by the share's own permissions.filesystem.file_view policy; there is no workspace-level requirement.
file_view: "all"— every version of every node in the share is listable.file_view: "owned"— only versions of nodes the caller created are listable; every other node is rejected. An anonymous caller has no creator identity, so all nodes are rejected.file_view: "none"— the endpoint is rejected outright.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} or {share_id} | string | Yes | 19-digit profile ID |
| {node_id} | string | Yes | Node OpaqueId |
curl Example
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
| Field | Type | Description |
|---|---|---|
versions.count | integer | Number of entries in versions.items |
versions.items | array | The version entries, described below |
Per Version Entry
| Field | Type | Description |
|---|---|---|
id | string | Version OpaqueId |
type | string | Node type: "file", "folder", or "note" |
current_version | boolean | true if this is the node's current (live) version |
nodeId | string | OpaqueId of the node this version belongs to |
name | string | Node name at this version |
parent | string | Parent folder OpaqueId, or "root" / "trash" |
created | string | Version creation timestamp (YYYY-MM-DD HH:MM:SS UTC) |
deleted | string | Present only on a version created by trashing (YYYY-MM-DD HH:MM:SS UTC) |
deleted_from | string/null | OpaqueId of the folder the node was trashed from; present alongside deleted |
size | integer | File size in bytes (file/note versions) |
hash | string | Content hash for this version (file/note versions) |
hash_algo | string | Hash algorithm, e.g. "sha256" (file/note versions) |
mimetype | string | MIME type (file/note versions) |
mimecategory | string | MIME category (file/note versions) |
previews | object | Preview state keyed by preview type, each { "state": "..." } (file/note versions) |
virus | object | Virus scan result: status ("scanned", "unscanned", "unknown"), plus infected and/or reason (file/note versions) |
ai | object | AI processing state: state ("disabled", "pending", "in_progress", "ready", "indexed", "failed"), attach (boolean), summary (boolean) (file/note versions) |
file_attributes | object | Metadata read out of the file itself: media_metadata and/or exif_metadata when available, otherwise empty (file/note versions). Returned only to callers permitted to download the file — a view-only caller gets {} (see Embedded File Metadata) |
summary | object/null | AI summary { title, short, long }, or null when none (file/note versions) |
origin | object | Provenance for this version — see below |
replaces | object | Which version this version replaced — see below |
author | object | Who created this version — see below |
origin Object
| Field | Type | Description |
|---|---|---|
origin.type | string | Identifier type of the actor that produced the version (e.g. "User"), or "unknown" for versions that predate origin tracking |
origin.creator | string | Creator profile ID, "anonymous" for an anonymous public-link contributor, or "unknown" |
origin.operations | array | Operations that produced this version: any of "create", "rename", "move", "modify", "restore" |
origin.created | string/null | When the origin record was written (YYYY-MM-DD HH:MM:SS UTC), or null for versions that predate origin tracking |
replaces Object
| Field | Type | Description |
|---|---|---|
replaces.status | string | "known", "none", or "unknown" |
replaces.version_id | string/null | The id of the version this one replaced; non-null only when status is "known" |
known—version_idis the version this version replaced.none— this version replaced nothing; it is the version the file was created with.unknown— it cannot be determined;version_idisnull. Clients must branch onstatusand must never treat a nullversion_idas "this was the first version" —noneandunknownare different answers.- Replacement lineage is derived from the order of the versions that still exist, not stored. Older version history is thinned over time, so for versions older than roughly half a day the preceding surviving version is not necessarily the one that was replaced — those report
unknownrather than naming a version that might be wrong. The current version'sreplacesis always resolvable, at any age. unknownis also returned when the returned list is not the file's complete history — a full page of results (older versions exist beyond it), or a listing that omits versions the caller may not access (the File Share version listing omits versions whose content is unavailable).
author Object
| Field | Type | Description |
|---|---|---|
author.status | string | "known" or "unknown" |
author.user_id | string/null | Profile id credited with creating this version; "anonymous" for an anonymous public-link contributor; null when unknown |
author.actor_type | string/null | What kind of profile user_id is — usually "User", but not always (see below); null when unknown |
author.agent_name | string/null | Name of the agent that acted on that account's behalf, when one did |
author.agent_name_source | string/null | Where that name came from; null when no agent acted |
user_idis a PROFILE id and it is not always a person. Checkactor_typebefore resolving it against a user lookup: some versions are attributed to a workspace rather than to a user, and looking one of those up as a user will find nothing.author.user_id/author.actor_typecarry the same pair asorigin.creator/origin.type.- The account is who the version is attributed to; the agent name only qualifies it — there is always an account behind an agent.
agent_nameis self-declared, not verified. Display it; never rely on it to identify or authorize anyone.statusisunknownin two different situations thatuser_idalone cannot tell apart. Most history does carry an author, sounknownis not the common case — but it is a value you will genuinely receive, and it never fills in later:- Authorship was never recorded — versions created before authorship tracking existed. These also return an empty
origin.operationsand anullorigin.created, which is how you distinguish this case. - Nothing was acting as an account — versions created by background processing rather than by a request: a synced file that changed at the connected cloud provider, a folder copy large enough to run in the background, or an upload assembled without an associated account.
- Authorship was never recorded — versions created before authorship tracking existed. These also return an empty
Folder versions carry only the basic fields (id, type, current_version, nodeId, name, parent, created, replaces, author) plus origin; the file-specific fields above are omitted. A version that was created by trashing carries additional deleted / deleted_from fields.
Error Responses
The Error Code column shows the application error class followed by the per-call-site value returned in error.code.
| Error Code | HTTP Status | Description |
|---|---|---|
1609 (Not Found) — 193615 / 124906 | 404 | Node not found (share / workspace) |
1609 (Not Found) — 173577 | 404 | Node exists but falls outside the share's folder scope (share only) |
1609 (Not Found) — 148373 | 404 | The workspace folder backing the share was deleted (share only) |
1605 (Invalid Input) — 194361 / 149251 | 406 | Unsupported node type, for example a link (share / workspace) |
1665 (Object Init Failed) — 167497 / 112869 | 500 | Node data is corrupted (share / workspace) |
1664 (Datastore Error) — 186805 / 137723 | 500 | Version lookup failed (share / workspace) |
1680 (Access Denied) — 144499 | 401 | Share file_view is none (share only) |
1680 (Access Denied) — 134467 | 401 | No file-view access to this node (share only) |
1680 (Access Denied) — 120944 | 401 | Share file_view is owned and the caller did not create this node (share only) |
Notes:
- Share permissions may restrict version listing to files the user created (
file_view: "owned"). - The share route needs no workspace permission and no authenticated user; a public-link share is readable anonymously.
Restore Version
/current/workspace/{workspace_id}/storage/{node_id}/restore-version/
/current/share/{share_id}/storage/{node_id}/restore-version/
Restore a file to a previous version. Creates a new version pointing to the historical version's content. Both filename and content are restored.
Auth required. Permission: Guest (workspace), file modification permission (share).
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} or {share_id} | string | Yes | 19-digit profile ID |
| {node_id} | string | Yes | File OpaqueId |
Request Body (form-encoded)
| Parameter | Type | Required | Description |
|---|---|---|---|
| version_id | string | Yes | OpaqueId of the version to restore |
curl Example
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 Code | HTTP Status | Description |
|---|---|---|
1609 (Not Found) | 404 | Node not found |
1605 (Invalid Input) | 406 | Can only restore file versions (not folders) |
1605 (Invalid Input) | 406 | Cannot restore version of trashed file |
1609 (Not Found) | 404 | Version not found |
1605 (Invalid Input) | 406 | Version does not belong to this file |
1609 (Not Found) | 404 | Version data no longer available |
1680 (Access Denied) | 401 | No permission to restore versions (share only) |
Notes:
- Original versions are preserved; restoring creates a new current version whose content and name match the selected historical version.
- Both filename and content are restored to the historical version's state.
new_version.replacesis always"unknown"on this endpoint — this response returns a single version with no surrounding history to derive lineage from. Call List Versions for lineage.
Download File (Read)
/current/workspace/{workspace_id}/storage/{node_id}/read/
/current/share/{share_id}/storage/{node_id}/read/
Download file content as binary. For notes, returns raw markdown. Supports byte-range requests for partial downloads and video streaming.
Auth: JWT or download token. Permission: View (workspace), download permission (share). With a valid token query parameter, no JWT is required.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} or {share_id} | string | Yes | 19-digit profile ID |
| {node_id} | string | Yes | File or note OpaqueId |
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| token | string | No | Download token from requestread (bypasses JWT auth) |
| version_id | string | No | Specific version OpaqueId to download |
curl Examples
# Download with JWT auth
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/read/" \
-H "Authorization: Bearer {jwt_token}" \
-o output.pdf
# Download with token (no JWT needed)
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/read/?token={download_token}" \
-o output.pdf
# Download specific version
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/read/?version_id=34wse-ehjvl-zefmd-2dfeh-hhw66-t4xe" \
-H "Authorization: Bearer {jwt_token}" \
-o output_v1.pdf
# Byte-range request (streaming)
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4/read/" \
-H "Authorization: Bearer {jwt_token}" \
-H "Range: bytes=0-1023"
Response: Binary file content streamed directly.
- Status
200 OKfor full file,206 Partial Contentfor range requests. - Headers:
Content-Type,Content-Length,Content-Disposition,Accept-Ranges: bytes. - On a
206,Content-Lengthis the length of the returned slice; the total size is the value after the slash inContent-Range: bytes {first}-{last}/{total}. - A syntactically valid range that cannot be satisfied — a first byte at or past the end of the file — returns
416 Range Not SatisfiablewithContent-Range: */{total}. An unparseableRangeheader is ignored and the full file is returned with200. - Only
GETis accepted;HEADreturns405. To learn a file's size without downloading it, sendRange: bytes=0-0and read the total fromContent-Range.
Error Responses
| Error Code | HTTP Status | Description |
|---|---|---|
1609 (Not Found) | 404 | File not found |
1605 (Invalid Input) | 406 | Can only read file or note (not folder) |
1609 (Not Found) | 404 | File is in trash |
1609 (Not Found) | 404 | Version not found |
1605 (Invalid Input) | 406 | Version does not belong to this file |
1609 (Not Found) | 404 | Version data no longer available |
1680 (Access Denied) | 401 | File flagged as virus-infected (share only) |
Request Download Token
/current/workspace/{workspace_id}/storage/{node_id}/requestread/
/current/share/{share_id}/storage/{node_id}/requestread/
Generate a temporary auth-free download token.
Auth required. Permission: View (workspace), download permission (share).
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} or {share_id} | string | Yes | 19-digit profile ID |
| {node_id} | string | Yes | File or note OpaqueId |
curl Example
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
| Parameter | Type | Required | Description |
|---|---|---|---|
| version_id | string | No | Pin the token to one specific version. Omit it and the token is bound to the node only — the read call can still name its own version_id, and the bytes returned are whatever that read resolves (current, or the version it names). |
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 Code | HTTP Status | Description |
|---|---|---|
1609 (Not Found) | 404 | File not found |
1605 (Invalid Input) | 406 | Can only read file or note |
1609 (Not Found) | 404 | File is in trash |
182855 (workspace) / 125883 (share) | 404 | Version not found |
141154 (workspace) / 105838 (share) | 406 | version_id does not belong to this file (rejected at mint time, not at fetch time) |
164665 (workspace) / 191654 (share) | 404 | Version data no longer available |
129880 (workspace) / 190664 (share) | 5xx | Unable to verify version (backend failure, safe to retry) |
1680 (Access Denied) | 401 | No download permission (share only) |
Download Folder as ZIP
/current/workspace/{workspace_id}/storage/{folder_id}/zip/
/current/share/{share_id}/storage/{folder_id}/zip/
Download an entire folder as a streaming ZIP archive.
Auth required. Permission: View (workspace), download permission (share).
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} or {share_id} | string | Yes | 19-digit profile ID |
| {folder_id} | string | Yes | Folder OpaqueId, or "root" for entire storage |
curl Example
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/storage/root/zip/" \
-H "Authorization: Bearer {jwt_token}" \
-o workspace.zip
Response: Binary ZIP archive streamed directly with Content-Type and Content-Disposition headers.
Notes:
- Uses ZIP64 format — supports archives up to 50GB (plan-dependent limits may be lower).
- Compatible with all modern extraction tools (WinRAR, 7-Zip, macOS Archive Utility, Windows Explorer).
- The archive is streamed; it is not buffered in memory.
- Files are stored without compression for immediate streaming.
- Maximum 10,000 files per archive.
- Rate limited more aggressively than other endpoints due to resource cost.
Recent Files
/current/workspace/{workspace_id}/storage/recent/
/current/share/{share_id}/storage/recent/
List recently modified nodes across all folders, sorted by updated descending. Unlike list which is scoped to a single folder, this endpoint returns nodes from the entire storage tree.
Auth required. Permission: View (workspace), Guest+ (share). Public shares may allow password-only access.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| page_size | int | 100 | One of: 100, 250, 500 (snapped to nearest) |
| cursor | string | — | Opaque cursor string from previous response |
| type | string | — | Filter by node type: file, folder, link, note |
curl Example
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
| Field | Type | Description |
|---|---|---|
nodes.count | integer | Number of nodes in this page |
nodes.items | array | Array of node resources |
pagination.has_more | boolean | Whether more pages exist |
pagination.next_cursor | string/null | Cursor for the next page |
pagination.page_size | integer | Effective page size used |
Error Responses
| Error Code | HTTP Status | Description |
|---|---|---|
1605 (Invalid Input) | 406 | Invalid pagination cursor |
1680 (Access Denied) | 401 | Insufficient permissions to view files (share only) |
1609 (Not Found) | 404 | Orphaned workspace folder share |
Notes:
- Sort order is always
updated DESCand is not configurable. - Uses cursor-based (keyset) pagination, same as the
listendpoint. - For workspace folder shares, results are post-filtered to the share's subtree.
Search
/current/workspace/{workspace_id}/storage/search/
/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
| Parameter | Type | Required | Description |
|---|---|---|---|
| search | string | Yes | Search query string. Under a precise name_match this string is the pattern. |
| search_in | string | No | Which side of the file to match: filename, content, or both (default). See Search Modes below. |
| name_match | string | No | How the filename is matched: auto (default), exact, prefix, contains, or glob. Ignored when search_in=content. |
| case_sensitive | string | No | true / false / 1 / 0. Default false. Applies to the precise name_match values; ignored under auto. |
| files_scope | string | No | Comma-separated nodeId:versionId pairs, query string only. Narrows the meaning-based (semantic) leg ONLY — filename and summary matches are not restricted by it. Takes nodes of type: "file" or type: "note"; a folder or link is refused. See Scoping to Files or Folders below. |
| folders_scope | string | No | Comma-separated nodeId:depth pairs, query string only. Narrows the meaning-based (semantic) leg ONLY, same as files_scope. Takes nodes of type: "folder" only; a file, note or link is refused. The :depth is required; 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. |
| filters | string (JSON) | No | Workspace routes only. A JSON array of metadata predicate objects. Narrows the search to files whose extracted metadata satisfies every predicate, before the query runs. Cannot currently be combined with folders_scope. See Filtering by Metadata below. |
| output | string | No | Verbosity: 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
| Value | Matches |
|---|---|
filename | The 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. |
content | The AI's understanding of the file — its AI-generated summary plus meaning-based (semantic) matches on what the file is about. |
both | (default) Filename and content together, blended and ranked — the behavior that shipped before these parameters existed. |
content is not a text scan of the file. Fastio does not index the literal bytes of your documents, so search_in=content is not 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.
| Value | Matches | Example |
|---|---|---|
auto | (default) Layered relevance — phrase, prefix, fuzzy, stemmed, and substring tiers, with near-exact filename matches promoted to the top. Unchanged behavior. | quarterly report finds Q4 Quarterly Report (final).pdf |
exact | The whole filename equals the query. Also matches the extensionless base, so you can type the name without knowing the extension. | Q4 Report matches Q4 Report.pdf and Q4 Report |
prefix | The filename starts with the query, taken literally. | Invoice- matches Invoice-2026-0042.pdf |
contains | The filename contains the query as a literal substring, anywhere. | 2026 matches Invoice-2026-0042.pdf |
glob | Shell-style pattern over the whole filename: * matches any run of characters (including none), ? matches exactly one. | Quarterly*.pdf matches Quarterly Report.pdf |
glob 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
| Field | Type | Description |
|---|---|---|
files | object | Map of node OpaqueIds to file info. A matching node appears exactly once, whichever part of the search found it. Keys are the unformatted OpaqueId — the 29-character form with no hyphens — so compare them hyphen-insensitively if you hold ids in the grouped display form. |
files.{id}.name | string | File name |
files.{id}.parent_id | string|null | Parent folder node ID, as it is right now. The server reads the file’s own record on every request and reports the folder it finds there, so this is never the stale value a lagging search index may hold, and details=true is not needed for it. A hit that matched only by meaning (match_source: "semantic") arrives from the content engine without one and is filled the same way. It is empty ("" or null) only when that lookup failed, and path_complete is false on the same row whenever it did. |
files.{id}.path | string|null | Where the file lives — the names of the folders from the workspace or share root down to the folder holding this hit, joined with /, with no leading or trailing slash ("Finance/Invoices/AR"). The file’s own name is not in it; that is name. "" means the hit sits directly in the root. null whenever path_complete is false — read that flag, not this field, to tell “at the root” from “not known”. Present on every result row, in both hybrid and keyword-only mode and at every output tier. Display value, not an address: folder names are returned exactly as stored and the / separator is not escaped, so a folder whose own name contains / is indistinguishable from a level boundary. Use ancestors or parent_id to address a folder. |
files.{id}.ancestors | array | The same folders as path, in the same order, as {id, name} objects — id being the folder’s OpaqueId in the formatted (hyphenated) form the rest of the API takes, so each entry is directly usable with /storage/{parent_id}/list/. Whenever path_complete is true, path is exactly these names joined with /, so the two are one answer in two spellings; [] then means the hit is at the root. When path_complete is false this is a best-effort suffix: the folders NEAREST the file, with an unknown number of levels missing from the top — never a prefix, and never padded or guessed. It is [] when nothing could be resolved at all. |
files.{id}.path_complete | boolean | Whether the walk from the file up to the root actually reached it. true means path and ancestors are the complete answer. false means the walk stopped early — a folder could not be read, the chain was longer than the server walks, or the response needed more folder lookups than one request will make — in which case path is null and ancestors holds only the partial suffix above. Every hit is resolved from the file’s CURRENT record, never from the parent_id the row carried — a search index lags the tree, so a file moved a moment ago still names its old folder there, and a path walked from it would be wrong while claiming to be complete. That recovery is ATTEMPTED on every row, so a match_source: "semantic" hit resolves the same as any other, with or without details=true; when the record itself cannot be read, the row reports path_complete: false like any other unresolved one. parent_id is corrected to the live folder when the two disagree AND the walk reached the root, so a corrected value always matches the path beside it. On a row whose walk stopped short, parent_id is left exactly as the index had it rather than replaced with a folder the platform could not place. A false here is never a statement about where the file is; a file at the root reports path_complete: true with path: "". |
files.{id}.type | string | Node type |
files.{id}.relevance_score | float | A rank-fusion score combining the two retrieval legs — the name/text search and the content search. Each leg contributes by the file's position in that leg, a file found by both legs collects both contributions, and the result is normalised within this result set so the maximum relevance_score in the set is exactly 1.0 and every hit is above 0.0. That is the maximum, not necessarily the first result — ordering is tier-first (see Result Ordering), so a promoted hit with a lower score can come back above it. Its scale is therefore re-derived for every query, and 1.0 means "the best of these results", not "a good result". Use it to order results — do not threshold it, and do not compare it across queries. Hybrid mode only. |
files.{id}.raw_score | float|null | The un-rescaled retrieval score, on the scale named by score_source (on a metadata row that is the keyword scale — see below). The merge does not divide, clamp or round it against the other hits that came back with it. That is a statement about rescaling, not a promise the number is constant: re-running retrieval can still return a different value, because a keyword score is BM25 and moves with the index statistics. Not bounded to 0.0–1.0. null when score_source is filename. Hybrid mode only. |
files.{id}.score_source | string | An attribution — and only three of its four values name a scale. keyword (the keyword engine's BM25 score — unbounded above, and dependent on the index contents and the query terms), semantic (the content engine's similarity score for the passage that ranked the file — the same passage that produced content_snippet, page, and media_segment) and filename (the row was placed by a name match, not by a measured score, so raw_score is null) each name the scale raw_score is on. On a file both legs found, the leg reported is the one that ranked it higher; on an equal position the keyword leg is reported, because its score is always a real measurement while a content score is optional and can come back as 0.0. metadata is not a fourth scale: it says the row was promoted because the query matched its extracted metadata, and its raw_score is still populated and remains on the keyword scale — compare it with other keyword rows, not with semantic ones. A promoted hit ranked higher by the content engine reports semantic rather than metadata; read metadata_match for the promotion itself. There is no both — which legs matched is a separate question, answered by match_source. Decided per hit, so one response can carry all four: group by score_source before comparing any two raw_score values. Hybrid mode only. |
files.{id}.content_snippet | string|null | Matching text from the highest-scoring passage. On a semantic or both hit that is the passage the content search ranked the file by. A keyword-only hit now carries one too: the search goes back over the file's indexed text and quotes the best-matching passage, for the top few results of the page only and only where text_indexed is true — so a filename or metadata match is no longer returned without the text behind it. Still null on a 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_source | string | Which legs of the hybrid search matched this file: keyword (filename/text index only), semantic (content embedding only), or both (matched by BOTH legs — not merely by several semantic passages). Always keyword when intelligence is not enabled. |
files.{id}.media_segment | object|null | {start_seconds, end_seconds} for audio/video matches. Hybrid mode only. |
files.{id}.mimetype | string|null | File MIME type. Hybrid mode only. Populated on a semantic-or-both hit as before; a keyword-only hit now carries it too, filled from the file itself — except at ?output=terse (that tier skips the per-hit file read that supplies it). |
files.{id}.page | object|null | {start_page, end_page} for paginated documents. Hybrid mode only. A keyword-only hit carries one whenever the search quoted a passage for it — see content_snippet. |
files.{id}.text_indexed | boolean|null | Whether the file has any indexed text at all — the question a null content_snippet cannot answer on its own. false means there is nothing indexed to quote, so reading the file is the only way to get at its content. true means the file's current version has indexed text (or, where the current version cannot be determined for the row, any still-active indexed version) — but not that this query matched it: a true beside a null snippet says the search did not return a passage for this query, not that the file has none. null means this response could not determine coverage — never that the file is unindexed. Present on every result row, in both hybrid and keyword-only mode. |
files.{id}.summary_short | string|null | The stored short AI summary of the whole file. null when the file has no summary, and on a share search null whenever the caller's share role may not view summaries. Always null at ?output=terse — the verbosity tier drops it, the same way it trims a node's summary — and always null on the keyword-only response. |
files.{id}.best_chunk | object|null | The highest-scoring real passage of the file, as distinct from a whole-file summary: {text, page, media_segment, score, result_type, position, sequence, indexed_version_id, 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_match | boolean | true when the query also matched the file's extracted metadata — entity-style values such as a counterparty, a customer, or a document title. false otherwise. It commonly co-occurs with a filename or summary match; it does not mean the match happened instead of those. Read match_source for which retrieval legs matched, and Result Ordering below for why a row sits where it does — a true here makes the row eligible for tier 3 unless a stronger filename tier (exact or prefix name match) already applies. |
files.{id}.metadata_match_field | string|null | Reserved — currently always null. It will name the metadata field that carried the match once per-field matching exists. Do not branch on it today. |
search_metadata | object ⚠ 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_filter | object | {applied, matched, truncated, scope_incomplete, coverage}. Present only when a filters value reached the server and ran. Its absence on a request you believe was filtered means the filter was dropped in transit — see Filtering by Metadata. |
Passages vs Summaries (content_snippet vs best_chunk). content_snippet reports whichever piece of evidence ranked the file highest — and that can be the file's whole-document summary. A summary is not located anywhere in the file, so page is null on such a hit and there is nothing to jump to. best_chunk always reports a real passage, with the locator that belongs to it — page for a document passage, media_segment for a transcript passage, and the other one null — which is what you want when you are opening the file at the right place, quoting it, or citing a page. When the top-ranked evidence is already a passage, best_chunk.text carries the same text as content_snippet — as in the example above, which is at the default full tier where both stay populated and same_as_snippet is false. At ?output=terse and ?output=standard, once both are trimmed to the tier's byte budget, the server drops the duplicate instead: best_chunk.text is null and best_chunk.same_as_snippet is true, and the caller reads content_snippet for the text while still using best_chunk's locator fields.
Passages on a filename or metadata match. A keyword hit is found by the file's name and its extracted metadata, neither of which is document text, so such a row used to come back with nothing to quote even where the file's text was fully indexed. It is now quoted the same way any other row is: the search takes the best-matching passage of that file's indexed text and fills content_snippet, page and best_chunk from it. Three limits are worth knowing. It applies to the top few results of the page, not all of them, so a null on a lower-ranked keyword row is a budget, not a verdict. It applies only where text_indexed is true. And best_chunk.score on such a row is a text-match score inside that one file — do not compare it with a semantic passage's score, or with the row's own raw_score.
Reading around the passage. best_chunk.position is the address the storage content/ endpoint takes: send it back as chunk_from (and chunk_to a little higher) on GET /current/{workspace|share}/{id}/storage/{node_id}/content/ to read the matching passage in full together with the text on either side of it, without downloading the file. Clamp both ends — chunk_from at 0, chunk_to at 9999. Then compare best_chunk.indexed_version_id with the indexed_version_id that response returns: if they differ the file was re-indexed between the two calls, the position is stale, and the search should be re-run rather than the text quoted.
raw_score and score_source are on /storage/search/ only — the unified /search/ route does not return them.
Result shape under search_in=filename. A filename search has no content leg at all, so it returns the keyword-only item shape shown above — name, parent_id, type, content_snippet: null, match_source: "keyword", plus summary_short: null, best_chunk: null, metadata_match and metadata_match_field: null — even on a workspace or share with AI features enabled. The hybrid-only fields (relevance_score, raw_score, score_source, mimetype, media_segment, page) are absent, because there is no content match to score or locate. If your client requires relevance_score, use both (the default) rather than filename. This shape is not new — it is exactly what every response looks like when AI features are off. The unified endpoints are unaffected: their files bucket keeps its usual item shape in every mode.
Folder paths
Every search hit says where it lives, so you do not have to list folders one at a time to find out. Three fields, one answer:
path— the ancestor folder names from the workspace or share root down to the folder holding the file, joined with/, no leading or trailing slash:"Finance/Invoices/AR". The file’s own name is not in it.""means the root.ancestors— the same folders as{id, name}objects in the same order, withidin the formatted form/storage/{parent_id}/list/takes. Use this when you want to navigate rather than display.path_complete— whether the walk reached the root. Read this first.
When path_complete is true, path is exactly the ancestors names joined with /, and [] / "" together mean the file is at the root.
When it is false, path is null and ancestors carries only a best-effort suffix — the folders nearest the file, with an unknown number of levels missing from the top, never padded and never guessed. It goes false when a folder could not be read and past an internal depth limit. It is never a claim about where the file is.
A hit that came back with an empty parent_id — which is every hit the meaning side found on its own — still gets a path: the server reads the file’s own record to recover its folder. You do not need details=true for that, and the two retrieval legs answer alike.
Paths are built when the response is assembled, so a folder rename shows up on the next call with no re-indexing delay. Through a share the root is the shared folder, so a path never names anything outside it.
path is a display value, not an address. Folder names are returned exactly as stored and the / separator is not escaped, so a folder whose own name contains / is indistinguishable from a level boundary. Address folders with ancestors[].id or parent_id.
Result Ordering
Results are ordered tier first, then by relevance_score descending within a tier. Two rows tie on the score readily — a file at position r of the name/text leg and another at position r of the content leg fuse to the same value — so the comparator has two further steps: a row whose raw_score is on the keyword scale (score_source keyword or metadata) sorts above one on the semantic scale, matching the rule that decides attribution on an equal position, and node_id ascending ends the comparison so the same result set always paginates the same way.
The tiers, highest first:
| Tier | What lands in it |
|---|---|
| 1 | Exact filename match |
| 2 | Filename prefix match |
| 3 | Metadata-entity match (metadata_match: true) |
| 4 | Everything else |
Rows in the fourth group are ordered by relevance_score descending.
⚠ A promoted hit can appear above a hit with a higher relevance_score. This is the one thing most likely to surprise an integrator, and it is the ordering working as designed rather than a scoring bug: the tier is applied first, and relevance_score only breaks ties inside a tier. Do not re-sort results by relevance_score client-side — that throws the promotion away. To see why a row sits where it does, read score_source and metadata_match.
A promotion changes the ORDER, never the score. All three promoting tiers work the same way: they place the row above the untiered results and leave relevance_score exactly as retrieval produced it. A promoted row is therefore identifiable by its position and by score_source / metadata_match — never by a special score value. The one field a promotion does change is raw_score on a name match (tiers 1 and 2): a name equality is not a measurement, so those rows report score_source: "filename" with raw_score: null. A metadata-promoted row (tier 3) keeps its raw_score, and that number is on the keyword scale only where score_source is metadata — that value is reported when the keyword leg ranked the file higher. A metadata-promoted row the content engine ranked higher reports score_source: "semantic" and carries the provider score.
Knowing Whether Content Search Is Possible
A content search on an instance that cannot match content returns HTTP 200 with an empty result set, not an error. An empty list therefore has two very different meanings, and search_metadata is how you tell them apart:
"search_metadata": {
"intelligence_enabled": false,
"semantic_available": false,
"scoped": false,
"content_search_available": false,
"reason": "intelligence_disabled"
}
| Field | Type | Description |
|---|---|---|
intelligence_enabled | bool | Whether AI features are enabled on this workspace or share. |
semantic_available | bool | Whether the meaning-based channel actually served this request. This is the per-request outcome — read it, not content_search_available, to know what you just got. |
scoped | bool | Whether the meaning-based leg was actually narrowed by files_scope / folders_scope. It reports the narrowing that was applied, not the parameter you sent — sending a scope on a request where that leg does not run (AI features off, search_in=filename, or the leg failing) gives scoped: false, because nothing in those results was narrowed by it. |
scope_incomplete | true | Present, and always true, when the scope that was applied is narrower than the one you asked for: a folders_scope tree ran past the reference limit, so part of it was left out. Absent otherwise, and never present alongside scoped: false. A short answer would otherwise be indistinguishable from a complete one — you cannot count a folder's subtree before naming it. |
scope_requested | int | How many scope entries you sent, counting files_scope and folders_scope together. Present only alongside scoped: true. |
scope_resolved | int | How many of those entries the scope resolved to. Present only alongside scoped: true. A gap against scope_requested means part of what you named could not be resolved. ⚠ It measures scope resolution, not final coverage — sending filters 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_available | bool | Whether either content channel — the semantic index or summary access — is open here at all. A statement of capability, not a prediction of results. Present only when search_in was supplied. |
reason | string | Why content search is unavailable. Present only when content_search_available is false. Treat unrecognized values as opaque. |
reason | Meaning | What to do |
|---|---|---|
intelligence_disabled | AI features are off for this share, so meaning-based matching cannot run for anyone. | Retry with search_in=filename, or tell the user AI features must be enabled on the share. |
summary_permission_denied | AI features are on, but neither channel is open: the meaning-based channel did not serve this request, and this share's permissions do not let you search file summaries. | Retry with search_in=filename. This one is specific to you — another member of the same share may be permitted. |
content_not_indexed | Generic fallback for any other cause. | Retry with search_in=filename. |
Required client behavior: when content_search_available is false, do not report “no files found.” Either retry the same query with search_in=filename or tell the user that content search is unavailable here. This is the difference between recovering and confidently reporting a wrong answer.
What the flag does and does not tell you. content_search_available answers “can content search work here at all?” — not “will this query return results?” It is true whenever either channel is open to you, so a true flag over an empty list is an ordinary “nothing matched,” not a malfunction. For the per-request outcome, read semantic_available: it reports whether the meaning-based channel actually served this request.
It is reachable as false only on share routes, and only on a share where neither channel is open. A workspace member may always search file summaries, so on /workspace/{id}/storage/search/ the flag is always true and reason never appears — including when AI features are off for that workspace, because the summary channel is still open there. There is no workspace recovery path to code for.
When the block is emitted. search_metadata is returned whenever the response is a hybrid one (AI features enabled), exactly as before. It is additionally returned on the keyword-only path when you explicitly supply search_in. The two capability keys (content_search_available, reason) appear only when you explicitly supply search_in. Supplying only name_match and/or case_sensitive does not change the response shape at all.
⚠ 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.
| Operator | Value | Meaning |
|---|---|---|
= != < <= > >= | required | Compare the field against value. != is the complement of =. Ordered comparison is not legal on boolean or JSON fields. |
in | required (non-empty list) | The field's value is one of the list. Counts as one clause. |
exists / not_exists | omitted | The field is present / absent on the file. |
confidence_gte | required (int 0–3) | The extracted value's confidence band is at least this level. The levels are 0 = low, 1 = medium, 2 = high, 3 = certain — so 2 means “high or better”. Legal on every field type, because it tests how the value was obtained rather than the value itself. Two traps below. |
confidence_gte — read these before using it. The operator takes the
integer, while a fact's confidence comes back as the band name, so the mapping is
0 = low, 1 = medium, 2 = high,
3 = certain. Send the integer (a decimal string such as "2" is also
accepted); a band name such as "high" is rejected, as is any non-integer, including
2.0.
confidence_gte: 3excludes every AI-extracted value.certainis reserved for deterministic sources (exif,mediainfo,validated_server); AI-extracted facts are capped athighon write. So3means “deterministic sources only”, not “the most confident AI results”.confidence_gte: 0is not “no minimum”. A value entered by a person has no extraction confidence (confidenceisnull), and anullconfidence satisfies no level — including0. So0matches extracted values only and drops every hand-entered value. To match a field regardless of how it was obtained, useexistsinstead.
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
}
}
}
| Field | Type | Description |
|---|---|---|
applied | bool | Always true when the block is present. The block's presence is the signal — see below. |
matched | int | How many files satisfied the filter. This is the candidate count before the search narrowed it further and before any cap was applied to the meaning-based leg, so it is normally larger than the number of files returned. When truncated is true, treat it as a floor rather than an exact count. |
truncated | bool | true when the candidate set was clipped because it exceeded a cap. Deterministic — see below. |
scope_incomplete | bool | true when a transient fault dropped candidates that genuinely match. Retryable — see below. |
coverage | object | How much of the searched scope the filter could even see: {scope, available, files_in_scope, files_without_metadata}. Always present when metadata_filter is, and populated for a whole-workspace filter as well as an explicit files_scope — see Coverage below. |
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.
| Field | Type | Description |
|---|---|---|
scope | string | files when you narrowed the search with an explicit files_scope; workspace when the filter ran across the whole workspace. |
available | bool | Whether the two counts are real numbers. This is the only field to branch on. |
files_in_scope | int/null | How many files the search covered. null when available is false. |
files_without_metadata | int/null | How many of those hold no extracted metadata, and so could not match any clause. null when available is false. |
scope: "files",available: true— you named the files, so both counts are exact.scope: "workspace",available: true— a whole-workspace filtered search reports coverage too.files_in_scopecounts the live files in the workspace — files and notes, which are exactly the things metadata is extracted from. Folders, links and anything in the trash are not counted, because no clause could match them.files_without_metadatais that number minus the files that currently carry any metadata. The covered share —files_in_scopeminusfiles_without_metadata— counts only files that are both live and currently carrying metadata. A file that has been deleted is never counted as covered, even when metadata was extracted from it before it was deleted.scope: "workspace",available: false, both countsnull— the numbers could not be established for this request. Either the workspace is larger than the ceiling the two counts are read under (5,000), so the count stopped short of a total and a partial one would understate the uncovered share; or a count could not be read at all; or the two counts came back as a pair that cannot be true (see below); or a zero failed the final check described below. The search itself still answers normally — only this advisory block goes unfilled.
The ceiling is there because establishing the covered count means checking every file that carries metadata against live storage, so the work grows with how many of them there are; the limit is set from what that costs. It bounds the counting only — it never limits the search itself.
⚠ That ceiling counts metadata RECORDS, not live files. A file’s
metadata record outlives the file — deleting a file leaves it behind — so a workspace
with a long history of uploads and deletions can be permanently past the ceiling while holding very
few files today, and will report available: false on every filtered search. (Explicitly
clearing a file’s metadata does remove it from the count; deleting the file does not.) Nothing
about the workspace’s current size tells you whether coverage will be available for it.
⚠ 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:
metadata_filterpresent → the filter ran on the server, andmatchedis trustworthy.metadata_filterabsent → the filter did not reach the server. The results in your hands are unfiltered and must not be treated as filtered.
What the block does not tell you. applied certifies that the predicate executed — nothing more. It is not a statement that the result set is exactly what you asked for. truncated and scope_incomplete are the fields that qualify the result set, and you must read them separately.
matched describes the PREDICATE, not the PAYLOAD. It counts the files the filter selected; it does not count the files this response contains. The two answer different questions and are routinely different numbers — matched is measured before the search narrows further and before any cap, so it is normally the larger of the two. Do not derive a result count from it, and do not treat matched being non-zero as a promise that files is non-empty.
Two things cause the key to be absent on a request you believe carried filters: an intermediate client, proxy, or SDK that strips query parameters it does not recognise (at least one client library does), or a deployment that does not yet accept filters. You do not need to tell them apart — the correct action is the same either way: do not treat the results as filtered.
Check for the key before trusting a filtered result. It is the only way to tell a filter that ran from one that never arrived: both come back 200 with a plausible-looking list of files.
truncated and scope_incomplete are different conditions — do not collapse them into “results may be incomplete.” One is worth retrying and the other never is:
truncated: true— deterministic. Retrying changes nothing. The candidate set was larger than a cap (the filter's own match ceiling, or the ceiling on how many files the meaning-based leg will accept) and was clipped. Re-running the identical request returns the identical answer. The fix is to narrow: add a clause, or make an existing one more selective.scope_incomplete: true— transient. Retrying is worth it. A temporary fault dropped candidates that genuinely match your filter, so the answer is short for a reason that has nothing to do with your query. Re-run the same request — it may return more. Do not report the short list as complete.
They are independent booleans and both can be true at once. truncated says you asked for too much; scope_incomplete says we lost some of it.
A filtered search only reaches files that have extracted metadata. Predicates are evaluated against the metadata Fastio has already extracted for a file, so a file with no extracted metadata is not a candidate for any clause — including not_exists, which means “has metadata, but none for this field,” not “has no metadata at all.” Extraction is asynchronous, so a file added moments ago may not be filterable yet. To see what has been extracted for a file, read its metadata_facts on the node object. Read an empty filtered result as nothing that has been extracted matches this filter, not no such files exist.
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.
output | content_snippet and best_chunk.text shape | summary_short |
|---|---|---|
terse | First ~200 bytes of the matching chunk, UTF-8 safe. Truncated values end with …. | Always null — the tier drops it, the same way it trims a node's summary. |
standard | First ~600 bytes (roughly one paragraph). Same … suffix when truncated. | Returned in full. |
full (default) | Full matching chunk, untrimmed. | Returned in full. |
Snippets shorter than the budget are returned unchanged (no padding, no …). Null/empty snippets are returned unchanged at every level. The byte budget is inclusive of the trailing … so the wire payload never exceeds the cap. best_chunk.text follows the same budget and the same … rule as content_snippet; at terse and standard, if the trimmed best_chunk.text then comes out byte-identical to the trimmed content_snippet, text is replaced with null and best_chunk.same_as_snippet is true (otherwise text is present and same_as_snippet is false) — at full, text is never suppressed and same_as_snippet is always false. The rest of best_chunk (page, media_segment, score, result_type, position, sequence, indexed_version_id) is returned unchanged at every level. Default output=full preserves the prior best_chunk.text value and never deduplicates it; the same_as_snippet key is the one additive change at that level.
A snippet is an excerpt, not the passage. When you need the surrounding text in full — to quote a clause, or to read what the match sits in — do not widen output and hope: call the file's Node Content endpoint with ?q= (the same query terms) and it returns that file's best-matching chunks with their complete text, page ranges, and no snippet budget at all.
Error Responses
| Error Code | HTTP Status | Description |
|---|---|---|
1605 (Invalid Input) | 406 | search_in or name_match is not one of the listed values; case_sensitive is not true/false/1/0; or the pattern is empty / longer than 256 characters / a glob of nothing but * and ? |
1605 (Invalid Input) | 406 | filters is not a JSON array of clause objects, or a clause is missing a field / operator (workspace only) |
1605 (Invalid Input) | 406 | The filter could not be applied: more than 5 clauses, a field the workspace vocabulary does not have, or an operator/value that does not suit the field's type (workspace only) |
1605 (Invalid Input) | 406 | filters was combined with folders_scope — not currently supported together (workspace only) |
1605 (Invalid Input) | 406 | A files_scope / folders_scope entry is wrong: not a nodeId:versionId / nodeId:depth pair at all (a bare 0 included), 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) | 406 | folders_scope named the root or trash folder alias. It takes folder node ids only — send the folder's own node id, or omit the scope entirely to search everything |
1609 (Not Found) | 404 | Search not available for workspace folder shares (share only) |
1680 (Access Denied) | 401 | No search permission (share only) |
Notes:
- Share search is not available for workspace-backed shares (shared folders).
- Search results are filtered by the user's file view permissions.
- Omitting
search_in,name_match, andcase_sensitivereproduces the exact query, ranking, and response keys this endpoint returned before they existed. search_in=filenameskips the content lookup entirely rather than running it and discarding the result, so it is also the fastest mode.- The pattern rules are checked whenever a precise
name_matchis asked for, even in combination withsearch_in=contentwhere the filename is not matched at all — a request that would be silently ignored is rejected instead. - A single search examines at most 1,000 matching files. A deliberately broad pattern (
*,*a*) can reach that ceiling and return a truncated view, so prefer the narrowest pattern that answers the question. - The precise
name_matchvalues (exact,prefix,contains,glob) depend on filename indexing that is rolled out per environment;autoworks everywhere. If a precise match returns nothing where you expect a hit, retry withname_match=autobefore concluding the file is absent. - Omitting
filtersreproduces the exact response this endpoint returned before it existed — nometadata_filterkey is added. - Sending
filtersas an empty array ([]) is treated as no filter at all: the search runs unfiltered and nometadata_filterblock is returned. filtersis accepted on the workspace route only, and the share route ignores it rather than refusing it — the request succeeds and returns the unfiltered results, with nometadata_filterblock. A406would tell you the parameter did not apply; a200does not, so the absent block is the only signal and you have to look for it. Same formetadata_filter: workspace-only.- Always read
metadata_filterbefore presenting a filtered result. A missing block means the results are unfiltered, and atruncatedorscope_incompleteflag means they are partial for two very different reasons.
Metadata Search (Workspace Only)
/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
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| q | string | Yes | — | Keyword query. Whitespace-trimmed; an empty value is rejected. |
| template_id | string | No | — | Retired — supplying it is now an error (406). It used to restrict matches to nodes carrying a value contributed by that template; the searched corpus no longer records a template association, so the filter cannot be honoured. It is REFUSED rather than ignored, because silently accepting a narrowing filter and then returning every match is a widening a caller cannot detect. Remove the parameter. |
| limit | int | No | 100 | Maximum number of results (1-100). |
| offset | int | No | 0 | Number of results to skip. Requests beyond the supported result window return an input error. |
curl Example
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
| Field | Type | Description |
|---|---|---|
results | array | Matching nodes, ordered by relevance score (highest first) |
results[].node_id | string | OpaqueId of the matching node |
results[].score | number | Relevance score for the match |
results[].template_ids | array of string | Always empty. Templates are retired and the searched corpus records no template association. The key is kept so existing clients keep parsing, but it will not be populated again. |
results[].matched_fields | array of object | The metadata field(s) whose value matched this query, in the order they are stored on the file. May be empty — 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[].field | string | The field name, exactly as it is spelled on the file. Case and accents are preserved verbatim — do not fold or normalise it before matching it against your own field list. |
results[].matched_fields[].value | string | The matching value, as text. Long values are shortened — see value_truncated. |
results[].matched_fields[].value_truncated | bool | true when the stored value is longer than the returned text. The returned text is a window taken around the part that matched, so it always contains the match — it is not simply the beginning of the value. Values are shortened to at most 256 characters. |
results[].matched_fields_truncated | bool | true 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[].node | object | Standard node resource (same shape as storage list/details) |
pagination.total | int | Total number of matching nodes |
pagination.has_more | bool | true when more results exist past the current window |
Access. matched_fields and matched_fields_truncated are
returned only to callers holding Member permission on the workspace — the same
level every dedicated metadata read requires. The route itself is unchanged and still opens at View:
a caller below Member still searches metadata and still receives every matching file, its score, its
ranking and its full node payload. The two keys are simply not present on each
result.
Read them with a presence check on the key, not with a length check. An
absent matched_fields means the caller is not cleared to see metadata
values. An empty matched_fields means something different and unrelated:
the match could not be attributed to a specific field on that file. Do not treat the two as the same
state, and never suppress a hit for either one.
Error Responses
| Error Code | HTTP Status | Description |
|---|---|---|
1605 (Invalid Input) | 406 | q missing/blank, template_id supplied at all (the filter is retired — remove it), or a request beyond the supported result window |
1680 (Access Denied) | 401 | Caller is not a member of the workspace |
1654 (Internal Error) | 500 | Search backend transient failure |
Notes:
- Returns at most one entry per node, even when multiple metadata fields on that node matched — the fields that matched are listed in
matched_fieldson that single entry. - Matching is substring and case-insensitive —
invalready matchesinvoice, so no wildcard is needed.*and?in your query are treated literally, not as wildcards:inv*searches for a literal asterisk and will NOT matchinvoice. A multi-word query requires ALL of its words to be present, not any. - Indexing is event-driven — newly written or updated metadata typically becomes searchable within a few seconds.
- Binary metadata values are not indexed; only textual values (including JSON leaves) participate in matching.
- Each indexed document is bounded; metadata values exceeding the per-node ceiling are truncated for indexing only — source data is unaffected.
- This endpoint is independent of
/storage/search/— that endpoint searches filenames and file content, this one searches metadata field values. To search content within files selected by their metadata, use/storage/search/with itsfiltersparameter instead (see Filtering by Metadata under Search). - Reading
matched_fields. Values are truncated to at most 256 characters, and at most 10 fields are listed per result; both limits are reported rather than applied silently (value_truncatedandmatched_fields_truncated). An emptymatched_fieldsis a valid result, not an error — it means attribution was not available for this file, so the match could not be tied back to a specific field. The result itself is still correct: the file genuinely matched. Render it normally when the list is empty; never suppress the hit. An absentmatched_fieldsis a different state entirely — the caller is below Member permission; see Access above. - Field names come from the metadata on the file and are caller data. Compare them byte-for-byte against your own vocabulary; a lowercased or accent-stripped comparison will miss fields that genuinely matched.
Compound Search (Metadata Filter + Content Query)
/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
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| filters | string (JSON) | Yes | — | JSON-encoded predicate array — [{"field": "…", "operator": "…", "value": "…"}]. Must be a non-empty list of objects, each carrying a string field and operator. Same predicate vocabulary as saved metadata filters; see Filtering by Metadata under Search. |
| content_query | string | Yes | — | The content question. Max 1024 characters; blank is rejected. |
| limit | int | No | server default | Result-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
| Field | Type | Description |
|---|---|---|
scope.match_count | int | Matching files — those satisfying the filter and the content query. Always equals the number of entries in items. |
scope.match_relation | string | eq when match_count is exact, gte when it is a floor because a cap bounded the answer. |
scope.scope_used | int | How many candidate files the content stage actually searched. |
scope.scope_truncated | bool | true when more indexed candidates existed than the content stage could search. |
scope.files_not_indexed | int/null | Candidates the filter matched that had no content index entry and were therefore invisible to the content stage. null means the coverage read itself was unavailable — unknown, not zero. |
scope.limit_clamped_from | int/null | Your original limit when it exceeded the server maximum, else null. |
scope.causes | array of string | Every bound that fired, with no precedence between them. [] when nothing bounded the answer. |
causes values and what to do about each:
| Cause | Meaning | Remedy |
|---|---|---|
filter_cap | The filter matched more files than the metadata stage will carry forward | Narrow the filter |
scope_cap | More indexed candidates existed than the content stage’s budget allows | Narrow the filter — the same remedy, not an upgrade |
result_budget | The content stage filled the requested limit, so more may match beyond it | Raise limit, or make the query more specific |
coverage_unavailable | The index-coverage read degraded, so files_not_indexed is unknown | Retry if completeness matters |
Treat causes as an open set: handle the values you know and fall back for the rest. Only the count-bounding causes (filter_cap, scope_cap, result_budget) make match_relation a floor, so causes can be non-empty while match_relation is still eq — a degraded coverage read does not clip the candidate set.
Error Responses
| Error Code | HTTP Status | Description |
|---|---|---|
115280 | 406 | Intelligence is not enabled on this workspace |
119701 | 406 | filters is missing, blank, not a JSON array, or an empty array |
116139 | 406 | An element of filters is not an object |
155870 | 406 | An element of filters lacks a non-empty field or operator |
100859 | 406 | content_query missing or blank |
179646 | 406 | The predicates are not valid for the fields they name — a wrong operator for the field’s type, an unusable value, or too many clauses |
varies per call site — read error.code from the response | 401 | Caller is below Member on the workspace |
109283 | 503 | A transient storage fault left the candidate set incomplete, so the answer cannot be trusted — retry |
135817 | 503 | A transient fault in either stage — retry |
193826 | 500 | The workspace’s storage instance could not be resolved |
134988 | 500 | A permanent, non-validation backend fault |
Notes:
- A
503here is specifically not an empty result. The endpoint refuses rather than returning a partial answer that looks complete. - This is distinct from
/metadata/search/, which is a keyword search over metadata values only and never reads file content. - To filter by metadata inside a plain content search instead, use
/storage/search/with itsfiltersparameter — see Filtering by Metadata under Search.
Unified Search (Grouped by Type)
/current/workspace/{workspace_id}/search/
/current/share/{share_id}/search/
One search call across everything in a workspace or share, with results grouped by type into buckets. Instead of calling the per-type search endpoints separately, you issue a single query and get back a set of buckets — each with its own results and its own pagination. A workspace search returns up to three buckets: files, metadata, and comments. A share search returns the subset that applies to shares (typically files, plus comments when commenting is enabled on the share; metadata is workspace-only).
Each bucket is independently paginated and independently health-reported, so a transient problem affecting one bucket never blocks the others — that bucket comes back degraded with an empty result set while the rest return normally.
Auth required. Permission: View (workspace), search + file view permissions (share). All buckets are permission-filtered — see Permission Model below.
Query Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| search | string | Yes | — | Search query string (max 1024 characters; an empty value is rejected). |
| files_offset | int | No | 0 | Result offset for the files bucket. |
| files_limit | int | No | 25 | Page size for the files bucket. |
| metadata_offset | int | No | 0 | Result offset for the metadata bucket (workspace only). |
| metadata_limit | int | No | 25 | Page size for the metadata bucket (workspace only). |
| comments_offset | int | No | 0 | Result offset for the comments bucket. |
| comments_limit | int | No | 25 | Page size for the comments bucket. |
| search_in | string | No | both | files bucket only. Which side of the file to match: filename, content, or both. |
| name_match | string | No | auto | files bucket only. How the filename is matched: auto, exact, prefix, contains, or glob. Ignored when search_in=content. |
| case_sensitive | string | No | false | files bucket only. true / false / 1 / 0. Applies to the precise name_match values; ignored under auto. |
| details | string | No | — | "true" enables metadata_facts on file/note items in the files bucket, shaped by output exactly as documented under Extracted Metadata Facts; any other value is treated as absent (no error). Folders and links never carry it. |
| output | string | No | full | Verbosity: terse, standard, or full (default). Trims content_snippet on files-bucket items to a byte budget, and shapes metadata_facts when details=true. See Verbosity below. |
Search modes apply to the files bucket only. search_in, name_match, and case_sensitive behave exactly as documented under Search above — same values, same defaults, same escaping and case rules, same pattern limits, same 1605 (Invalid Input) on a bad value. They shape only the files bucket; the metadata and comments buckets are unaffected and keep matching as they always have. Omit all three and the response is byte-for-byte what it was before they existed. search_in=filename is the most useful of the three here: it turns the files bucket into a pure filename lookup while still returning metadata and comments matches from the same call.
details=true adds extracted metadata facts to the files bucket. Each file or note item whose facts were read then carries metadata_facts — the same block /storage/search/ and the node object emit, in the same three output shapes (terse, standard, full) with the same per-tier caps and the same count/total/is_truncated semantics — see Extracted Metadata Facts above. Folder and link items never carry the key, matching every other surface. Visibility follows the same rule as everywhere else metadata_facts appears: the key is omitted entirely (never emitted empty) when the caller is not entitled to extracted metadata. Because extracted metadata is a workspace-only surface, that omission is unconditional on the share twin — /share/{share_id}/search/ never returns metadata_facts regardless of details, for any share role. On the workspace route it is present for callers who are members of the owning workspace. With details absent or any string other than "true", the response is unchanged from before this parameter existed — no new key; a non-string value is rejected as invalid input, like any other declared parameter. Cost is at most one batched facts read per files-bucket page, not one read per item, so details=true is cheap here even at files_limit=25.
Verbosity (output)
content_snippet on files-bucket items is trimmed to a byte budget by the output level — the same budget /storage/search/ applies, so every search surface sizes the field the same way.
output | content_snippet shape |
|---|---|
terse | First ~200 bytes of the matching chunk, UTF-8 safe. Truncated values end with …. |
standard | First ~600 bytes (roughly one paragraph). Same … suffix when truncated. |
full (default) | Full matching chunk, untrimmed. |
The budget is inclusive of the trailing …, so the wire payload never exceeds the cap, and the … is appended only when the value was actually cut. Snippets shorter than the budget are returned unchanged (no padding, no …), and a null snippet — what a keyword-only hit carries — is returned unchanged at every level. Only the files bucket is affected: the metadata and comments buckets are identical at every level, and content_snippet is the only field trimmed here, because this route returns no best_chunk and no summary_short. output sizes the snippet whether or not you send details=true — the two parameters are independent, and details governs only whether metadata_facts is added. As on /storage/search/, when you need the surrounding text in full, do not widen output: call the file's Node Content endpoint with ?q= and read the whole chunk with no snippet budget at all.
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
| Field | Type | Description |
|---|---|---|
buckets | object | Map of bucket type → bucket object. Only applicable buckets are present (e.g. no metadata on a share). |
buckets.{type}.items | array | Result items for this bucket. The metadata and comments buckets are ordered by relevance_score descending. The files bucket is ordered promotion tier first — an exact filename match, then a filename prefix match, then everything else — and only then by relevance_score descending, then a keyword-scale row above a semantic-scale one, then node_id ending the comparison; it shares its ranking with /storage/search/, so a name match can sit above an item with a higher relevance_score here too. (The metadata-entity tier is /storage/search/-only and never applies on this route.) Item shape is type-specific — see Bucket Item Shapes below. |
buckets.{type}.offset | int | The offset applied to this bucket. |
buckets.{type}.limit | int | The page size applied to this bucket. |
buckets.{type}.total | int | Number of matching, permission-visible items. This count is computed after permission filtering — it reflects what you can actually see, not raw index hits. |
buckets.{type}.total_relation | string | eq when total is exact within the searched window, or gte when it is a lower bound (more visible matches may exist beyond the searched window). |
buckets.{type}.has_more | bool | true when more results exist past the current page. |
buckets.{type}.status | string | ok for a healthy bucket, or degraded when the backend behind that bucket was temporarily unavailable (the bucket returns an empty items array but is still present so you can tell a backend hiccup from a bucket that does not apply). |
buckets.files.search_metadata | object | Capability report for the files bucket — {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:
files—node_id,name,parent_id,type, plus (when workspace/share intelligence is enabled) the hybrid-match fieldscontent_snippet(the matching text,nullfor keyword-only matches, trimmed peroutput),match_source(keyword/semantic/both, wherebothmeans both legs matched rather than several semantic passages),media_segment({start_seconds, end_seconds}for audio/video, elsenull),page({start_page, end_page}for paginated documents, elsenull), andmimetype. Afileornoteitem additionally carriesmetadata_factswhen the request setdetails=true— see details=true adds extracted metadata facts above.metadata(workspace only) —node_id,name,parent_id,type,matched_fields+matched_fields_truncated, andtemplate_ids.matched_fieldslists the metadata field(s) whose value matched, each as{field, value, value_truncated}— this is what distinguishes a metadata hit from a filename hit in thefilesbucket, and it is identical in shape and meaning to thematched_fieldsreturned by/metadata/search/(see that endpoint for the full field table and the truncation rules). It may be empty, which is a valid result rather than an error.matched_fieldsandmatched_fields_truncatedare returned only to callers holding Member permission on the workspace, exactly as on/metadata/search/; below that level the two keys are absent from each item rather than empty, and the item’snode_id,name,parent_id,typeand ranking are unaffected. Test for the key’s presence, not the list’s length.template_idsis now ALWAYS EMPTY — templates are retired and the searched corpus records no template association. The key is kept so existing clients keep parsing; it will not be populated again.comments—comment_id,entity(the commented-on node/container),author_profile_id,snippet(the matching comment text),reference_type(the anchor type when the comment is anchored to a position in a file, elsenull),created.
Permission Model
Every bucket is permission-filtered against the same rules as that type's dedicated endpoint. Results are produced by reading each match from the source of truth and re-checking the caller's permission before any content or count is returned, so search can never reveal an item, a snippet, or even a count for something the caller is not allowed to see. In particular:
- files / metadata — filtered by the caller's file view permissions (and, on a share, the share's per-item file-view rules).
- comments — only comments the caller can see in that workspace or share. On a workspace, the comments bucket is owner-inclusive of File Shares: it also returns comments left through the workspace's own live (active, non-expired, non-revoked) File Shares (a File Share is a view of a workspace file, so the comments belong to the same node). This is one-directional — workspace members see File Share comments, but File Share recipients never see the workspace's internal comments. Share search is unaffected. In rare cases (a workspace with a very large number of File Shares, or a transient error while enumerating them) workspace comment search may temporarily cover only the workspace's own comments; the File Share comments reappear on a later retry.
Error Responses
| Error Code | HTTP Status | Description |
|---|---|---|
1605 (Invalid Input) | 406 | search missing/blank/too long, a bucket request beyond the supported result window, or an invalid search_in / name_match / case_sensitive value or pattern |
1609 (Not Found) | 404 | Search not available for workspace-backed shares (shared folders); share only |
1680 (Access Denied) | 401 | No search permission |
Notes:
- Search is not available for workspace-backed shares (shared folders) — the share endpoint returns
404in that case. - Comments become searchable shortly after they are created or updated (indexing is asynchronous — typically within a few seconds).
- A
degradedbucket is safe to retry; the rest of the response is still valid. - This unified endpoint composes the same per-type searches as
/storage/search/,/metadata/search/, and the comments surface — use it when you want a single grouped result set, or the per-type endpoints when you only need one kind of result.
QuickShare (Workspace Only)
Deprecated — use File Share. The POST (create / extend-expiry) path now returns 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.
/current/workspace/{workspace_id}/storage/{node_id}/quickshare/
/current/workspace/{workspace_id}/storage/{node_id}/quickshare/
/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
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} | string | Yes | 19-digit workspace profile ID |
| {node_id} | string | Yes | File OpaqueId |
POST — Create or Update QuickShare (deprecated → 403)
This path is deprecated and returns 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
/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 Status | Code | Cause |
|---|---|---|
| 406 | 1605 (Invalid Input) | Invalid File Share id |
| 401 | 1650 (Authentication Invalid) | A link password is required and was missing or wrong (present it via the x-ve-password header) |
| 403 | 1700 (Forbidden) | The access tier or named grant does not permit the caller |
| 404 | 1609 (Not Found) | No such File Share, or the bound file content is no longer available |
File Share Note Endpoints (Collaborative Editing)
When a File Share's bound node is a note (a markdown .md node), recipients can read — and, with an edit-capable grant, collaboratively edit — the note through the File Share link. These endpoints back the real-time collaborative editor. The flow is two-step:
- 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 eithervieworeditstanding. - Read / update the note content with that token in the
Authorization: Bearerheader.readnoteandupdatenoteare 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
/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
| Field | Type | Description |
|---|---|---|
auth_token | string | The realtime-note bearer token to present to readnote / updatenote |
expires_in | integer | Token lifetime in seconds |
Error Responses
| Error Code | HTTP Status | Description |
|---|---|---|
1605 (Invalid Input) | 406 | Missing/invalid File Share id or note id, or the bound node is not a note |
1609 (Not Found) | 404 | No such File Share, or the requested id is not the File Share's bound note |
1680 (Access Denied) | 401 | Sign-in required (anonymous caller cannot mint a realtime token) |
1700 (Forbidden) | 403 | The access tier / named grant / password does not permit the caller, or the presented access token is not scoped to this File Share |
1650 (Authentication Invalid) | 401 | Token could not be minted |
Read Note (File Share)
/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 Code | HTTP Status | Description |
|---|---|---|
1650 (Authentication Invalid) | 401 | Realtime-note token missing, invalid, or expired |
1651 (Invalid Method) | 405 | Only GET is accepted |
1605 (Invalid Input) | 406 | Invalid File Share id, note id, or version id |
1700 (Forbidden) | 403 | Token is not bound to this File Share/note, the File Share is unavailable, or the token lacks read capability |
1609 (Not Found) | 404 | Bound note no longer exists |
1680 (Access Denied) | 401 | The note (or requested version) is blocked from serving — virus-infected, DMCA-flagged, or restricted (an edit lock does not block reads) |
1654 (Internal Error) | 500 | Failed to retrieve the note |
Update Note (File Share)
/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)
| Parameter | Type | Required | Constraints | Description |
|---|---|---|---|---|
| name | string | No | 1–255 characters (counted as characters, not bytes); must end in .md | New note name |
| content | string | No | Max 100 KB, non-blank | New markdown content (empty/whitespace-only is rejected) |
| if_version_id | string | No | Version OpaqueId | Compare-and-swap precondition — the update proceeds only if the note's current version matches; otherwise 409 Conflict with no change |
At least one of name or content is required.
curl Example
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 Code | HTTP Status | Description |
|---|---|---|
1650 (Authentication Invalid) | 401 | Realtime-note token missing, invalid, or expired |
1651 (Invalid Method) | 405 | Only POST is accepted |
1605 (Invalid Input) | 406 | Invalid File Share id / note id, name not ending in .md, blank content, or malformed markdown / version id |
1700 (Forbidden) | 403 | Token is not bound to this File Share/note, the File Share is unavailable, or the token lacks edit capability (a view token cannot update) |
1609 (Not Found) | 404 | Bound note no longer exists |
113958 | 409 | if_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) | 401 | The note is blocked from writing — virus-infected, DMCA-flagged, or restricted |
1654 (Internal Error) | 500 | Failed to retrieve or update the note |
File Locking
Lock a file to prevent concurrent edits. Locks expire automatically if not renewed via heartbeat. Available on both workspace and share storage.
Acquire Lock
/current/workspace/{workspace_id}/storage/{node_id}/lock/
/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)
| Parameter | Type | Required | Constraints | Description |
|---|---|---|---|---|
| duration | integer | No | 60-3600 seconds | Lock duration (default varies) |
| client_info | string | No | JSON object | Client metadata: device_name (max 255), client_version (max 50) |
curl Example
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
| Field | Type | Description |
|---|---|---|
lock_token | string | Token required for heartbeat and release operations |
locked_at | string | Lock acquisition time (YYYY-MM-DD HH:MM:SS UTC) |
expires_at | string | Lock expiration time (YYYY-MM-DD HH:MM:SS UTC) |
node_id | string | OpaqueId of the locked node |
Error Responses
| Error Code | HTTP Status | Description |
|---|---|---|
1609 (Not Found) | 404 | Node not found or outside your workspace/share scope |
1609 (Not Found) | 404 | Cannot lock a deleted node (workspace only) |
1660 (Conflict) | 409 | Node already locked by another user |
1680 (Access Denied) | 401 | Insufficient permission to acquire a lock on this node (share only) |
1693 (Temporarily Unavailable) | 503 | Lock service momentarily unavailable; retry after a brief delay |
Heartbeat (Extend Lock)
/current/workspace/{workspace_id}/storage/{node_id}/lock/heartbeat/
/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)
| Parameter | Type | Required | Description |
|---|---|---|---|
| lock_token | string | Yes | Token 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 Code | HTTP Status | Description |
|---|---|---|
1609 (Not Found) | 404 | No lock exists on this node |
1609 (Not Found) | 404 | Lock has expired |
1609 (Not Found) | 404 | Node not found or outside your workspace/share scope |
1660 (Conflict) | 409 | Recreation race: another caller acquired the lock first; re-acquire and retry |
1680 (Access Denied) | 401 | Lock token does not match |
1680 (Access Denied) | 401 | The lock was taken over by another user. Stop editing and re-read the file — this is not an expiry, and re-acquiring would discard the fact that somebody took the file from you |
1680 (Access Denied) | 401 | Insufficient permission to heartbeat this lock (share only) |
1693 (Temporarily Unavailable) | 503 | Lock service momentarily unavailable; retry after a brief delay |
Notes:
- A heartbeat renews the lock for the same
durationit was acquired with. A lock taken for an hour is renewed for another hour, not shortened to some fixed amount. The renewal replaces the time still remaining rather than adding to it, soexpires_atalways comes back as roughly now plus that duration. - Send heartbeats well before the lock expires (e.g., at 50% of lock duration).
- Heartbeats can recreate a lock that has already expired — the
lock_tokenis what is checked. Any authorized caller presenting the correct token recreates it, and the recreated lock is then held by that caller, so treat the token as a secret. A recreated lock starts a fresh lease at the service default duration, not at the duration the original lock was acquired with — the expired lock is gone and its duration cannot be recovered, and this endpoint accepts noduration. Readexpires_atfrom the response rather than assuming, and re-acquire with an explicitdurationif you need a longer lease again.
Release Lock
/current/workspace/{workspace_id}/storage/{node_id}/lock/
/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
| Parameter | Type | Required | Description |
|---|---|---|---|
| lock_token | string | Yes | Token from acquire response |
Response
{
"result": true,
"released": true
}
Error Responses
| Error Code | HTTP Status | Description |
|---|---|---|
1609 (Not Found) | 404 | No lock exists on this node |
1609 (Not Found) | 404 | Node not found or outside your workspace/share scope |
1680 (Access Denied) | 401 | Lock token does not match |
1680 (Access Denied) | 401 | Insufficient permission to release this lock (share only) |
1693 (Temporarily Unavailable) | 503 | Lock service momentarily unavailable; retry after a brief delay |
Lock Status
/current/workspace/{workspace_id}/storage/{node_id}/lock/
/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 Code | HTTP Status | Description |
|---|---|---|
1609 (Not Found) | 404 | Node not found or outside your workspace/share scope |
1680 (Access Denied) | 401 | Insufficient permission to view lock status (share only) |
The holder’s identity requires MEMBER level or above. locked and node_id are returned to every caller who may reach the endpoint — a client that cannot see them will offer a busy node as editable. The remaining fields (locker_uid, locked_at, expires_at, locker, and time_remaining on workspaces) identify who holds the lock and when they started and stopped working, and are returned only to callers at member level or above. Below that — workspace guests, share guests, public-link recipients — those keys are absent, exactly as lock_info is null on the node object for the same callers. Read locked for occupancy and treat the identity keys as optional; do not infer “unlocked” from a missing locker_uid.
locker.agent_name names the agent that took the lock on the holder’s behalf, when one did, and locker.agent_name_source says where that name came from; both are null when a person took the lock directly. agent_name is self-declared, not verified — display it beside the holder, never rely on it to identify or authorize anyone.
🔴 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
/current/workspace/{workspace_id}/storage/{node_id}/lock/override/
/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
| Field | Type | Description |
|---|---|---|
lock_token | string | YOUR new token for the lock you now hold — required for heartbeat and release |
node_id | string | OpaqueId of the node |
locked_at | string | When you took the lock (YYYY-MM-DD HH:MM:SS UTC) |
expires_at | string | When your lock expires unless renewed (YYYY-MM-DD HH:MM:SS UTC) |
overridden | boolean | true when a live lock was actually replaced; false when the file was not locked, or when you already held it. It reports whether somebody was interrupted, which is not the same question as whether we can name them — it stays true even when previous_locker_uid is null |
previous_locker_uid | string/null | Who held the lock, or null when nobody did (or when the holder could not be identified). Always a string, never a JSON number — ids exceed JavaScript’s safe integer range. Requires MEMBER level or above, exactly as locker_uid on lock status does; below that the key is absent. Read overridden to learn whether anyone was displaced — that stays truthful for every caller |
Error Responses
| Error Code | HTTP Status | Description |
|---|---|---|
1609 (Not Found) | 404 | Node not found or outside your workspace/share scope |
1680 (Access Denied) | 401 | Insufficient permission to modify locks on this node (share only) |
1693 (Temporarily Unavailable) | 503 | Lock service momentarily unavailable; retry after a brief delay |
Notes:
- After overriding, heartbeat the returned
lock_tokenon the usual schedule — it is an ordinary lock with an ordinary expiry. - A client that has its heartbeat refused with
1680 (Access Denied)should stop editing and re-read the file: someone took the lock. That is different from1609 (Not Found)on heartbeat, which means the lock lapsed and can be re-acquired.
Previews
File previews provide rendered views of documents, images, video, and other content without downloading the original file.
Preview Types
| Value | Description |
|---|---|
thumbnail | Small thumbnail image |
image | Full-size image preview |
mp4 | MP4 video preview (transcoded) |
hlsstream | HLS video/audio stream |
audio | Audio preview (transcoded) |
pdf | PDF document preview |
spreadsheet | Spreadsheet preview |
bin | Binary preview (raw bytes for clients that render their own preview) |
Preview States
Returned in node details responses under previews.{type}.state:
| State | Description |
|---|---|
unknown | Preview status not yet determined |
not possible | File type cannot be previewed |
not generated | Preview not yet generated |
error | Preview generation failed |
in progress | Preview is being generated |
ready | Preview is available |
Preauthorize Preview
/current/workspace/{workspace_id}/storage/{node_id}/preview/{preview_type}/preauthorize/
/current/share/{share_id}/storage/{node_id}/preview/{preview_type}/preauthorize/
Get a preview download URL with an embedded token.
Auth required. Permission: View (workspace), file view permission (share).
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {preview_type} | string | Yes | One of: thumbnail, image, mp4, hlsstream, audio, pdf, spreadsheet, bin |
curl Example
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
| Field | Type | Description |
|---|---|---|
downloadToken | string | JWT token for preview access |
path | string | Full API path to read the preview file |
primaryFilename | string | Name of the primary preview file |
Error Responses
| Error Code | HTTP Status | Description |
|---|---|---|
1609 (Not Found) | 404 | File not found |
1605 (Invalid Input) | 406 | Can only preview file or note |
1609 (Not Found) | 404 | File is in trash |
1652 (Resource Not Found) | 404 | File content is no longer available |
1652 (Resource Not Found) | 404 | Preview not available |
Read Preview
/current/workspace/{workspace_id}/storage/{node_id}/preview/{preview_type}/read/
/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
/current/workspace/{workspace_id}/storage/{node_id}/preview/{preview_type}/read/{token}/file/{filename}
/current/share/{share_id}/storage/{node_id}/preview/{preview_type}/read/{download_token}/file/{filename}
Read a specific preview file using a token (from preauthorize). Token-based auth — no Authorization header needed.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {token} | string | Yes | Download token from preauthorize |
| {filename} | string | Yes | Preview filename |
Range requests are supported, with the same semantics as the file read endpoint above: 206 Partial Content with Content-Range for a satisfiable range, 416 for a range that starts at or past the end, 200 with the whole body for an unparseable Range header, and GET only (HEAD returns 405).
The download token is not single-use — it stays valid for its lifetime, so a client may issue many ranged requests against the same URL. Note that the size being ranged over is the preview artifact's, which is not the source file's size unless the preview is served as the original (as it is for a PDF preview of a PDF).
Request Preview Nonce (Share Only)
/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 Code | HTTP Status | Description |
|---|---|---|
125621 | 406 | The share’s download security is not medium |
164543 | 406 | The node is a folder — only a file or note can be previewed |
180409 | 404 | Node not found |
160750 | 404 | The node exists but is outside this share |
173838 | 404 | The node is in the trash |
Transforms
Image transforms allow on-the-fly resizing, cropping, rotating, and format conversion.
Get Transform Status
/current/workspace/{workspace_id}/storage/{node_id}/transform/{transform_name}/
/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
| State | Description |
|---|---|
rendered | Transform is ready |
rendering | Transform in progress |
unrendered | Transform not yet requested |
unable to render | Transform failed or unsupported |
Request Transform
/current/workspace/{workspace_id}/storage/{node_id}/transform/{transform_name}/request/
/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 Code | HTTP Status | Description |
|---|---|---|
1609 (Not Found) | 404 | Unknown transformation name |
1609 (Not Found) | 404 | Unable to transform (failed or unsupported) |
Read Transformed File
/current/workspace/{workspace_id}/storage/{node_id}/transform/{transform_name}/read/
/current/share/{share_id}/storage/{node_id}/transform/{transform_name}/read/
Download the transformed file. Supports byte-range requests and token auth.
Auth: JWT or download token.
Image Transform Parameters
Pass as query parameters on transform read endpoints:
| Parameter | Type | Values |
|---|---|---|
| output-format | string | png, jpg, jpeg — required |
| width | int | Target width in pixels |
| height | int | Target height in pixels |
| cropwidth | int | Crop region width |
| cropheight | int | Crop region height |
| cropx | int | Crop region X offset |
| cropy | int | Crop region Y offset |
| rotate | int | 0, 90, 180, 270 |
| size | string | Predefined: IconTiny, IconSmall, IconMedium, Preview |
output-format is required on every transform read; the rest are optional. Send
OPTIONS to a transform read URL for the accepted parameter list as the API itself
reports it.
curl Example
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
/current/workspace/{workspace_id}/storage/{node_id}/transform/{transform_name}/requestread/
/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:
GET .../storage/{node_id}/requestread/— returns{"token": "..."}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.
/current/workspace/{workspace_id}/metadata/filters//current/workspace/{workspace_id}/metadata/filters//current/workspace/{workspace_id}/metadata/filters/{filter_id}//current/workspace/{workspace_id}/metadata/filters/{filter_id}//current/workspace/{workspace_id}/metadata/filters/{filter_id}//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:
addlink— add a share link to storagecreatenote/updatenote— markdown note creation and editingreadnoteis available on both workspaces and shares (shares support token-based access)quickshare— temporary public file links (creation deprecated → use File Share)quickshares/list— list all quicksharescreate/fileshare/list/fileshares— durable single-file File Share management (replaces QuickShare)
Share-Specific Notes
- Share storage follows identical patterns to workspace storage for all common operations
- Shares support file locking (acquire, heartbeat, release, status, override)
- Shares support previews and transforms (status, request, read, requestread)
- Shares additionally support
requestpreview(one-time preview nonce) fordownload_security=mediumshares - Share permissions are granular: separate permissions for file view, download, creation, modification, and administration
- Embedded file metadata (
file_attributes.exif_metadata/file_attributes.media_metadata) is returned only to callers permitted to download the file — when downloads are not permitted the keys are omitted andfile_attributesis{}(see Embedded File Metadata) - Shares may restrict operations to files the user created (creator-only restrictions)
- Workspace folder shares map
rootto the designated folder and scope all operations to that subtree - Search is not available for workspace folder shares (files are indexed by workspace, not share)
- Public shares may allow listing and downloading without JWT authentication