Workspace Management Create, configure, and manage workspaces within organizations. Control members, assets, shares, and AI intelligence settings.
Workspaces are shared project spaces within an organization. They support file versioning, search, AI chat, and member management. Most workspace endpoints also accept the workspace's folder_name (e.g., my-project) in place of the numeric ID.
Endpoint Summary
Workspace CRUD
| Method | Endpoint | Description |
|---|---|---|
| POST | /current/org/{org_id}/create/workspace/ | Create a workspace |
| GET | /current/workspace/{workspace_id}/details/ | Get workspace details |
| POST | /current/workspace/{workspace_id}/update/ | Update workspace settings |
| DELETE | /current/workspace/{workspace_id}/delete/ | Delete (close) a workspace |
| POST | /current/workspace/{workspace_id}/archive/ | Archive a workspace |
| POST | /current/workspace/{workspace_id}/unarchive/ | Unarchive a workspace |
Assets
| Method | Endpoint | Description |
|---|---|---|
| GET | /current/workspace/assets/ | List available asset types |
| GET | /current/workspace/{workspace_id}/assets/ | List workspace assets |
| POST | /current/workspace/{workspace_id}/assets/{asset_name}/ | Upload/set asset |
| DELETE | /current/workspace/{workspace_id}/assets/{asset_name}/ | Delete asset |
| GET | /current/workspace/{workspace_id}/assets/{asset_name}/read/ | Download asset binary |
| HEAD | /current/workspace/{workspace_id}/assets/{asset_name}/read/ | Get asset metadata headers |
Members
| Method | Endpoint | Description |
|---|---|---|
| POST | /current/workspace/{workspace_id}/members/{email_or_user_id}/ | Add member or send invitation |
| DELETE | /current/workspace/{workspace_id}/members/{user_id}/ | Remove a member |
| GET | /current/workspace/{workspace_id}/members/list/ | List all members |
| POST | /current/workspace/{workspace_id}/members/join/ | Self-join by org membership |
| POST | /current/workspace/{workspace_id}/members/join/{invitation_key}/{action}/ | Join via invitation |
| DELETE | /current/workspace/{workspace_id}/member/ | Leave workspace (self) |
| GET | /current/workspace/{workspace_id}/member/{member_id}/details/ | Get member details |
| POST | /current/workspace/{workspace_id}/member/{member_id}/update/ | Update member role |
| POST | /current/workspace/{workspace_id}/member/{member_id}/transfer_ownership/ | Transfer ownership |
Invitations
| Method | Endpoint | Description |
|---|---|---|
| GET | /current/workspace/{workspace_id}/members/invitations/list/ | List all invitations |
| GET | /current/workspace/{workspace_id}/members/invitations/list/{state}/ | List invitations by state |
| POST | /current/workspace/{workspace_id}/members/invitation/{invitation_id}/ | Update an invitation |
| DELETE | /current/workspace/{workspace_id}/members/invitation/{invitation_id}/ | Delete an invitation |
Shares (in workspace context)
| Method | Endpoint | Description |
|---|---|---|
| POST | /current/workspace/{workspace_id}/create/share/ | Create a share |
| GET | /current/workspace/{workspace_id}/list/shares/ | List shares |
| POST | /current/workspace/{workspace_id}/import/share/{share_id}/ | Import a user-owned share |
File Shares (durable single-file links)
| Method | Endpoint | Description |
|---|---|---|
| POST | /current/workspace/{workspace_id}/create/fileshare/ | Create a File Share bound to a file node |
| GET | /current/workspace/{workspace_id}/list/fileshares/ | List the workspace's File Shares |
| POST / PATCH | /current/fileshare/{fileshare_id}/update/ | Update title / access tier / password |
| DELETE | /current/fileshare/{fileshare_id}/delete/ | Delete a File Share |
| GET / POST / DELETE | /current/fileshare/{fileshare_id}/grants/ | List, grant, or revoke per-user capabilities |
Cloud Sync
| Method | Endpoint | Description |
|---|---|---|
| POST | /current/workspace/{workspace_id}/cloud-import/enable/ | Enable cloud sync |
| POST | /current/workspace/{workspace_id}/cloud-import/disable/ | Disable cloud sync |
| GET | /current/cloudsync/workspace/{workspace_id}/providers/ | List the providers this workspace's plan may connect |
| GET | /current/cloudsync/workspace/{workspace_id}/identities/ | List provider identities |
| POST | /current/cloudsync/workspace/{workspace_id}/identities/provision/ | Provision a provider identity |
| POST | /current/cloudsync/oauth/{provider}/complete/ | Finish a browser OAuth connect (all four providers) |
| GET | /current/cloudsync/workspace/{workspace_id}/identities/{identity_id}/ | Get identity details |
| POST | /current/cloudsync/workspace/{workspace_id}/identities/{identity_id}/revoke/ | Revoke a provider identity |
| GET | /current/cloudsync/workspace/{workspace_id}/identities/{identity_id}/drives/ | List reachable drives (OneDrive for Business) |
| POST | /current/cloudsync/workspace/{workspace_id}/identities/{identity_id}/drives/refresh/ | Refresh the drive catalog |
| GET | /current/cloudsync/workspace/{workspace_id}/sources/ | List the workspace's sync sources |
| POST | /current/cloudsync/workspace/{workspace_id}/sources/create/ | Create a sync source from an identity you own |
| POST | /current/cloudsync/workspace/{workspace_id}/sources/discover/ | Browse an identity's cloud folders to pick what to sync |
| POST | /current/cloudsync/workspace/{workspace_id}/sources/estimate/ | Count the files and bytes in up to 10 folders before connecting them |
| GET | /current/cloudsync/details/{source_id}/ | Get source details |
| POST | /current/cloudsync/details/{source_id}/update/ | Update a source's settings |
| POST | /current/cloudsync/details/{source_id}/refresh/ | Trigger an immediate incremental sync |
| POST | /current/cloudsync/details/{source_id}/disconnect/ | Stop syncing a source, keeping or trashing the files it imported |
| POST | /current/cloudsync/details/{source_id}/delete/ | Delete a source and the files it imported |
| GET | /current/cloudsync/details/{source_id}/jobs/ | List a source's sync jobs |
| GET | /current/cloudsync/details/{source_id}/jobs/{job_id}/ | Get one sync job's details and progress |
| POST | /current/cloudsync/details/{source_id}/jobs/{job_id}/cancel/ | Cancel a pending sync job |
| GET | /current/cloudsync/details/{source_id}/writebacks/ | List write-back jobs |
| POST | /current/cloudsync/details/{source_id}/writebacks/push/{node_id}/ | Push one imported file back to the provider |
| GET | /current/cloudsync/details/{source_id}/writebacks/{writeback_id}/ | Get write-back job details |
| POST | /current/cloudsync/details/{source_id}/writebacks/{writeback_id}/retry/ | Retry a failed write-back |
| POST | /current/cloudsync/details/{source_id}/writebacks/{writeback_id}/resolve/ | Resolve a write-back conflict |
| POST | /current/cloudsync/details/{source_id}/writebacks/{writeback_id}/cancel/ | Cancel a pending or conflicting write-back |
Every /current/cloudsync/details/{source_id}/... endpoint above enforces a scoped credential's workspace scope, the same way the /current/cloudsync/workspace/{workspace_id}/... endpoints already do. The source id resolves to a workspace, and a credential whose scope does not cover that workspace is refused with 10560 (Access Denied) → 403, “Your token does not have sufficient scope for this Workspace.” — read endpoints (details, jobs, jobs/{job_id}, writebacks, writebacks/{id}) need read scope on the workspace, mutations (update, refresh, delete, disconnect, jobs/{job_id}/cancel, and every writebacks/... write action) need write scope.
Several of those mutations are also gated on who is acting, not only on scope. jobs/{job_id}/cancel always requires an admin-capable credential. refresh, update, delete, disconnect, writebacks/push/{node_id} and writebacks/{writeback_id}/cancel|resolve|retry require one only when the caller is acting as a workspace admin rather than as the member who connected the source — that connector/identity owner still only needs the write scope above. Admin-capable means a sign-in session, or a token holding rwa on the workspace or its org (see scope_admin_required under Transfer Workspace Ownership above). A legacy unscoped API key passes the write-scope check above but is never admin-capable, so it cannot take these actions as an admin.
Discovery
| Method | Endpoint | Description |
|---|---|---|
| GET | /current/workspaces/all/ | List all accessible workspaces (optional limit/offset pagination) |
| GET | /current/workspaces/available/ | List joinable workspaces |
| GET | /current/workspaces/check/name/{org_id}/{name}/ | Check folder name availability |
| GET | /current/org/{org_id}/list/workspaces/ | List workspaces in an org |
Compact Responses (output=)
Every endpoint that returns one or more workspace objects (details, list, discovery) accepts an optional output query parameter that selects the response shape. A single detail-level token may be combined with modifier tokens; specifying two detail levels (e.g. ?output=terse,standard) returns HTTP 406. When output= is omitted, responses are full and byte-for-byte unchanged.
| Level | Fields returned on each workspace (cumulative) |
|---|---|
terse | id, name, folder_name, org_domain, user_status (user_status is returned by GET /current/workspaces/all/ only) |
standard | terse + description, workspace_level, closed, archived, locked (admin-only), storage (admin-only), created, updated, logo, accent_color, intelligence, metadata_extraction, capabilities |
full | standard + cloud_import, cloud_sync_mode, effective_cloud_sync, external_invites, comments, chat, search, assets, perm_join, perm_member_manage, sharing_shares, sharing_file_links, platform (admin-only), suspended (admin-only), owner_defined, parents |
Use terse for workspace switchers, autocomplete, and org-scoped navigation — it carries the identifier, display name, folder slug, parent org domain, and user_status so the two-column "Joined vs. Available" workspace list can render and the join button can enable/disable without a follow-up fetch. Use standard for workspace list views and the summary area of workspace detail pages — it adds lifecycle flags (including the locked admin-only chip), storage usage, timestamps, description, the workspace's visual identity (logo, accent_color), plus the intelligence and metadata_extraction feature toggles and the plan-gate capabilities bundle so list rows can differentiate "enabled but plan-locked" from "usable." Use full (or omit the parameter) for the workspace settings screen, branding editors, and any workflow that reads permission matrices or remaining feature blocks (comments, chat, search, assets). Unknown tokens are silently ignored. Add the markdown modifier (e.g. ?output=standard,markdown) to receive the response as GitHub-flavored Markdown (Content-Type: text/markdown; charset=UTF-8) instead of JSON — see the cross-cutting ?output= reference for the full contract.
Field Constraints
| Field | Constraint |
|---|---|
folder_name | 4-80 characters matching regex ^[\p{L}\p{N}-]+$ (letters, numbers, hyphens). Must be globally unique. |
name | 2–100 characters, string |
description | 10–1000 characters, string (optional) |
title (shares) | 2–80 characters |
custom_name (shares) | 4–80 characters, URL-friendly |
Permission Values
perm_join — who can self-join from the parent org
| Value | Description |
|---|---|
'Member or above' | Any org member can join (default) |
'Admin or above' | Only org admins and owners |
'Only Org Owners' | Only org owners |
'No one can join automatically' | Nobody self-joins; direct members only |
perm_member_manage — who can manage workspace members
| Value | Description |
|---|---|
'Member or above' | Any workspace member can manage (default) |
'Admin or above' | Only workspace admins and owners |
Permission Levels (numeric hierarchy)
| Level | Numeric | Description |
|---|---|---|
| Owner | 1000 | Full control; one per workspace |
| Admin | 500 | Administrative access |
| Member | 100 | Standard member |
| Guest | 50 | Limited guest access |
| View | 20 | Read-only access |
Intelligence Setting
The intelligence boolean on a workspace controls whether uploaded files are automatically indexed for RAG (retrieval-augmented generation).
- Enable (
intelligence=true) — files are auto-indexed for semantic search, summarization, and citation. Required forchat_with_filesAI chat type. Requires both thecontent_aiandai_agentplan features. Plans that lack either feature cannot setintelligence=true; the update endpoint rejects the request with1605 (Invalid Input). - Disable (
intelligence=false) — files are stored/shared without RAG indexing. You can still attach files directly to achattype conversation for one-off analysis on plans that support chat. - On by default at creation.
POST /current/org/{org_id}/create/workspace/enables intelligence unless you sendintelligence=false; omitting the field means on, and is not an error. On a plan that lacks eithercontent_aiorai_agentthe new workspace is created with intelligence off whatever you send, because the indexing pipeline has no consumer there — creation still succeeds rather than being rejected. - Change it later:
POST /current/workspace/{id}/update/withintelligence=true|false. - Can be enabled and disabled within time restrictions. Disabling intelligence destroys indexed embeddings (the vector index is flushed). Re-enabling intelligence incurs re-indexing costs as AI credits are consumed to re-index all files.
- An Enterprise org can additionally gate this with an AI policy. Turning
intelligence=trueON can be refused (ai_policy_denied/ai_policy_workspace_not_allowed) independently of the plan check above, and even with the switch already on, background indexing and interactive use can be paused by the org. Readcapabilities.can_use_intelligenceandcapabilities.ai_policy_stateon workspace details for the effective, policy-aware answer rather than inferring it from the rawintelligenceswitch — see AI, Intelligence & MCP Access Policy in Organizations.
Automatic Metadata Extraction Setting
The metadata_extraction boolean on a workspace controls whether automatic metadata extraction runs for newly uploaded files. It is an opt-out layered under the intelligence setting and the plan: automatic extraction runs only when intelligence is on, the plan includes the metadata feature (capabilities.can_use_metadata), and metadata_extraction is not false. It can withhold extraction; it can never enable it where the intelligence setting or the plan does not allow it.
- On by default. Every workspace reports
metadata_extraction: trueunless it was explicitly switched off — including workspaces created before the setting existed.POST /current/org/{org_id}/create/workspace/acceptsmetadata_extraction=falseto create it off; omitting the field means on. - Change it later:
POST /current/workspace/{id}/update/withmetadata_extraction=true|false. No plan requirement: setting it on a plan without themetadatafeature is accepted and has no effect. Unlikeintelligenceit is not rate-limited. - Turning it off stops automatic extraction for files that reach processing from then on. The setting is read when a file is processed after upload, not at the moment the upload is accepted, so a file already being processed may still be extracted and an extraction already queued is not cancelled. Nothing is deleted: existing metadata values, fields, views and filters stay readable and editable, metadata search keeps working, and explicit per-file or per-folder extraction requests (
POST .../storage/{node_id}/metadata/extract/,.../extract-all/) still run — the switch governs only what happens on its own at upload time. - Turning it back on does not extract files uploaded while it was off; only new uploads are picked up. Use the explicit extraction endpoints to catch existing files up.
- Read it back as the boolean
metadata_extractionon the workspace object at thestandardoutput level and above (same level asintelligence). - An Enterprise org can additionally gate this with an AI policy. Turning
metadata_extraction=trueON can be refused the same way asintelligence=trueabove, and the explicit per-file/per-folder extraction endpoints and automatic on-ingest extraction alike can be paused org-wide or narrowed to specific workspaces. Readcapabilities.can_use_metadataandcapabilities.ai_policy_state.metadataon workspace details for the effective answer — see AI, Intelligence & MCP Access Policy in Organizations.
Sharing Policy
Two booleans on a workspace control whether new sharing may be created in it:
| Setting | Governs |
|---|---|
sharing_shares | Shares — Send, Receive and Exchange, including shared folders created from the workspace. |
sharing_file_links | Single-file share links. |
The organization carries a twin of each. The effective answer is the org setting AND the workspace setting, so the org is a ceiling: a workspace may switch sharing off for itself, but cannot switch it back on once the org has switched it off. Switching either level off requires the Enterprise plan; switching one back on, resubmitting it unchanged, and reading them do not.
- On by default. A workspace that has never configured these reports both as
true, including every workspace created before the settings existed. - Change them:
POST /current/workspace/{workspace_id}/update/withsharing_sharesand/orsharing_file_links. Workspace Admin or above. Send both form-encoded or as query parameters, with the string"true"or"false"— a JSON request body is not read by this endpoint. - Switching one off blocks NEW creation only. Shares and links that already exist keep working and remain editable and deletable. Nothing is revoked, expired or deleted.
- Read the effective answer, not the settings.
capabilities.can_create_share,capabilities.can_create_folder_shareandcapabilities.can_create_fileshareon the workspace already combine the calling user's role with both policy levels. The rawsharing_*booleans report this workspace's own setting only, which is not the whole answer when the org has switched sharing off. - The org half of the rule is readable on the workspace.
capabilities.org_sharing_sharesandcapabilities.org_sharing_file_linksreport the parent organization's ceiling for each switch, andcapabilities.org_controlsreports whether that organization's plan allows configuring these controls at all; all three are returned to workspace members at thefulloutput level, so a settings screen can distinguish "switched off here" from "switched off for the whole organization" without a second request. - Refusals from the create endpoints are HTTP 403 with
params.reason=policy_sharing_disabled. A refusal never leaves a partially created share behind — for a shared folder in particular, the folder is not created. - Audit. A write that changes either switch adds
policy_changes.<key> = { before, after }to theworkspace_updatedevent (beforeis the value in force, so a switch never configured reportstrue).policy_changesis absent when neither switch changed.
Collaboration Policy (External Invites)
A separate org-level setting, external_invites_workspaces, governs whether a member may bring an outside person onto a workspace — distinct from the sharing policy above, which governs creating shares at all. See Collaboration Policies in the Organizations reference for the full envelope shape, the override rules, and the org-level request/response fields.
- This workspace's own switch:
external_invites(allowed/denied, or absent to inherit the org policy) — a raw setting, readable by any member at thefulloutput level and writable only by a workspace admin, and gated by nothing itself. It can only ever tighten below the org's result, never loosen it, and there is currently no way to reset it back to "inherit" once set. - Read the effective answer:
capabilities.can_invite_externalon the workspace's details response combines the org policy, the calling user's role/override, and this workspace's own switch for the calling user. Do not recompute it client-side fromexternal_invites. - Refusals are HTTP 403 with
params.reason=external_invites_denied(the org policy) orexternal_invites_object_denied(this workspace's own switch), raised at member invitation (fresh, resend, and any broadening edit — a permission reduction is never gated) and at invitation acceptance, re-checked against the live policy at redemption time. A redemption refusal leaves the invitation pending, never failed; decline is never gated. - Inviting a member to the org itself is a different action and is never gated by this policy.
Cloud Sync Policy
Distinct from the cloud_import on/off switch (which the Cloud Sync endpoints below still use to gate the feature entirely), an Enterprise-plan org can restrict the direction cloud sync is allowed to run in, and a workspace can tighten that further for itself.
| Setting | Level | Shape | Governs |
|---|---|---|---|
| cloud_sync | Org | A policy envelope — {"admin": V, "member": V, "overrides": {"<user_id>": V}} where each V is {"enabled": bool, "mode": "read"|"read_write"} | Whether sync runs at all, and whether it may write back, per role and per named member |
| cloud_sync_mode | Workspace | "read" | "read_write" | The per-workspace direction ceiling under the org value — never a grant, and not an on/off switch (that is cloud_import) |
enabled = falsestops sync in both directions: every import source in the workspace parks atstatus: "suspended_policy".mode = "read"stops only the outbound half — inbound sync continues, and the source keeps its live status.- Effective access is always the org-then-workspace meet, resolved in that order: a member override that would widen access still loses to a workspace stored at
read. cloud_sync_modeabsent on a workspace reads asread_write(the permissive default), neverread.- The workspace value is a preference:
POST workspace/{workspace_id}/update/accepts either value from an authorised workspace admin, including wideningreadback toread_write— the runtime meet already prevents a storedread_writefrom beating an orgread. - Read the effective answer:
GET workspace/{workspace_id}/details/(fulloutput tier only) returns the rawcloud_sync_modepluseffective_cloud_sync: {enabled, mode, reason}for the calling user.reasonisnull,cloud_sync_disabled, orcloud_sync_read_only.nullis never permissive. - Every cloud-sync source object also carries
effective_access_mode/effective_access_mode_reasonbesideaccess_mode/enforced_access_mode— see When the Org or Workspace Policy Restricts Cloud Sync below. - The list response (
GET .../sources/) additionally carriespending_removal, describing a sync that stopped rather than remove an unusually large share of a connected folder — see Confirming a Large Deletion below. Not present onGET .../details/{source_id}/. - A write-back queued before a flip to
readis deferred, not failed — nowrite_back_failedevent, and it resumes if the policy widens. The hold is bounded (~5 days); a row still held at the ceiling retires as terminalfailedwithproperties.terminal_reason: "policy_hold_expired". The local edit is never touched. - Manual write-back actions (
push,retry, akeep_localresolve) refuse immediately with403andparams.reason = cloud_sync_read_only(orcloud_sync_disabled) instead of accepting and quietly deferring. - Opening a new connection (identity provision, source create, each provider's OAuth-complete) is refused with
403/cloud_sync_disabledwhenenabled = false; on OAuth-complete it is raised before the authorization code is redeemed.modenever gates a connection. Inspecting or disconnecting an existing connection is never blocked by this policy. - How a policy refusal reads. A refusal is
1700 (Forbidden)→ 403 carryingparams.reason(cloud_sync_disabledorcloud_sync_read_only) — settled until an admin changes the policy, so do not retry it. When the policy could not be read at all, the same endpoints answer1693 (Temporarily Unavailable)→ 503 "Cloud sync policy is temporarily unavailable. Please try again shortly." — transient; send the same request again shortly and never render it as a denial. Branch on the HTTP status andparams.reason, never on the numericerror.code. - A policy-parked source can still be paused.
POST cloudsync/details/{source_id}/update/withaction: pauseis accepted on asuspended_policysource (as well assynced/error), so a user can keep it held when the policy re-enables sync; it then stayspauseduntil they resume it. A source that is alreadypausedis never moved tosuspended_policy.
Both halves are wired. The org-level write (cloud_sync on POST org/{org_id}/update/) and its raw, admin-only echo on GET org/{org_id}/details/ use the same shared policy spine as Collaboration Policies and Credential Policy — see Cloud Sync Policy in the Organizations reference for the org-side shape.
Workspace CRUD
Create Workspace
/current/org/{org_id}/create/workspace/
Creates a new workspace within an organization. The authenticated user becomes the workspace owner.
Auth: JWT required. Org membership (Member or above) required.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {org_id} | string | Yes | 19-digit numeric organization ID |
Request Parameters
| Name | Type | Required | Constraints | Description |
|---|---|---|---|---|
| folder_name | string | Yes | 4-80 chars, regex ^[\p{L}\p{N}-]+$, globally unique | URL-safe identifier used in workspace URLs |
| name | string | Yes | 2–100 chars, non-blank | Display name |
| perm_join | string | Yes | See Permission Values | Who can self-join from the org |
| perm_member_manage | string | Yes | See Permission Values | Who can manage workspace members |
| intelligence | string | No | "true" or "false" | Enable AI indexing. Defaults to "true" when omitted. On a plan without both content_ai and ai_agent the workspace is created with it off regardless of what you send — the create still succeeds. |
| metadata_extraction | string | No | "true" or "false" | Automatic metadata extraction for new uploads. Defaults to "true" when omitted. Effective only while intelligence is on and the plan includes metadata; see Automatic Metadata Extraction Setting. |
| description | string | 10–1000 chars | Workspace description | |
| accent_color | string (JSON) | JSON color object {"color":"#RRGGBB","opacity":0-100} | Accent color styling | |
| background_color1 | string (JSON) | JSON color object {"color":"#RRGGBB","opacity":0-100} | Background color 1 styling | |
| background_color2 | string (JSON) | JSON color object {"color":"#RRGGBB","opacity":0-100} | Background color 2 styling |
Example
curl -X POST "https://api.fast.io/current/org/1000000000000000001/create/workspace/" \
-H "Authorization: Bearer {jwt_token}" \
-d "folder_name=engineering" \
-d "name=Engineering Team" \
-d "description=Main engineering workspace for the team" \
-d "perm_join=Member or above" \
-d "perm_member_manage=Admin or above"
Response (200 OK)
{
"result": true,
"workspace": {
"id": "1234567890123456789",
"folder_name": "engineering",
"intelligence": true
}
}
Response Fields
| Field | Type | Description |
|---|---|---|
| workspace.id | string | 19-digit numeric workspace profile ID |
| workspace.folder_name | string | The URL-safe folder name that was set |
| workspace.intelligence | boolean | Intelligence as actually applied: false when you sent intelligence=false, or when the plan lacks content_ai or ai_agent (forced off whatever you sent) |
Access Levels
| Role | Access |
|---|---|
| Org Owner | Can create workspaces |
| Org Admin | Can create workspaces |
| Org Member | Can create workspaces |
Error Responses
Reading the error tables: the four-digit
16xx/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.
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1685 (Feature Limit) | 412 | Workspace creation is not available on your current plan. | Feature disabled on billing plan |
1685 (Feature Limit) | 412 | You have reached your workspace creation limit. | Workspace count limit exceeded |
1700 (Forbidden) | 403 | Your organization does not permit you to create workspaces. | The caller is below the org's perm_workspace_create threshold and is not on its allowlist. params.reason = policy_workspace_create_denied. |
1658 (Not Acceptable) | 406 | The supplied workspace folder name is already in use. | Duplicate folder_name |
1605 (Invalid Input) | 406 | An invalid workspace folder name was supplied. | Invalid folder_name format |
1605 (Invalid Input) | 406 | An invalid configuration was supplied... | Metadata validation failure |
1654 (Internal Error) | 500 | There was an internal error processing your create request. | Internal failure |
Get Workspace Details
/current/workspace/{workspace_id}/details/
Returns full workspace details including settings, permissions, owner, intelligence state, and branding.
Auth: JWT required. Workspace membership required (View or above).
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} | string | Yes | 19-digit numeric workspace ID or folder_name |
Example
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/details/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"workspace": {
"id": "1234567890123456789",
"name": "Engineering Team",
"folder_name": "engineering",
"description": "Main engineering workspace",
"accent_color": {"color": "#0066CC", "opacity": 100},
"logo": "https://assets.fast.io/1234567890123456789/logo.png",
"closed": false,
"archived": false,
"perm_join": "Member or above",
"perm_member_manage": "Admin or above",
"created": "2023-01-15 10:30:00 UTC",
"updated": "2024-01-20 14:45:00 UTC",
"org_domain": "acme-corp"
}
}
Response Fields
| Field | Type | Description |
|---|---|---|
| workspace.id | string | 19-digit workspace profile ID |
| workspace.name | string | Display name |
| workspace.folder_name | string | URL-safe folder identifier |
| workspace.description | string or null | Workspace description |
| workspace.accent_color | object or null | Brand accent color ({color, opacity}) |
| workspace.logo | string or null | Logo asset URL |
| workspace.closed | boolean | Whether workspace is closed (soft-deleted) |
| workspace.archived | boolean | Whether workspace is archived |
| workspace.perm_join | string | Who can join |
| workspace.perm_member_manage | string | Who can manage members |
| workspace.created | string | Creation timestamp |
| workspace.updated | string | Last update timestamp |
| workspace.org_domain | string | Parent organization domain |
| workspace.sharing_shares | boolean | Whether this workspace allows new shares. true on workspaces that have never configured it. The org carries a twin of this setting and is a ceiling — see Sharing Policy below. |
| workspace.sharing_file_links | boolean | Whether this workspace allows new single-file share links. true on workspaces that have never configured it. |
| workspace.capabilities.can_create_share | boolean | Whether the calling user may create a share here right now — role and both policy levels combined. |
| workspace.capabilities.can_create_folder_share | boolean | Whether the calling user may share a folder from this workspace. Always the same answer as can_create_share; a shared folder is a share. |
| workspace.capabilities.can_create_fileshare | boolean | Whether the calling user may create a single-file share link here right now. |
| workspace.external_invites | string or null | This workspace's own external-invite switch: allowed, denied, or null to inherit the org's collaboration policy. Not the effective answer — see capabilities.can_invite_external below and Collaboration Policy above. |
| workspace.capabilities.can_invite_external | boolean | Whether the calling user may currently bring an outside person onto this workspace — org policy, role/override and this workspace's own switch combined. |
| workspace.capabilities.can_use_intelligence | boolean | Existing field, now also AND-ed with the org's ai_intelligence policy (including the ai_workspaces allowlist) — see Intelligence Setting above and AI, Intelligence & MCP Access Policy in Organizations. |
| workspace.capabilities.can_use_metadata | boolean | Existing field, now also AND-ed with the org's ai_metadata policy (including the ai_workspaces allowlist) — see Automatic Metadata Extraction Setting above and AI, Intelligence & MCP Access Policy in Organizations. |
| workspace.capabilities.can_use_ai_agent | boolean | Whether the calling user may currently use Ripley Agent chat in this workspace — plan and the org's ai_agent policy combined. |
| workspace.capabilities.ai_policy_state | object | Per-feature detail behind the three booleans above: {"agent": "allowed"|"paused_by_org", "intelligence": "allowed"|"paused_by_org"|"workspace_not_allowed", "metadata": "allowed"|"paused_by_org"|"workspace_not_allowed"} (agent is never workspace_not_allowed). Show “Paused by your organization” for a paused_by_org feature rather than rendering its switch as simply off. Present on details responses only — org workspace lists and dashboard lists do not carry it; treat an absent field as allowed there and let the server's own refusal be the backstop. |
| workspace.capabilities.org_controls | boolean | Whether the parent organization's plan allows configuring the organization and workspace security controls. Members only, full output level. Reading the controls never requires it. |
| workspace.capabilities.org_sharing_shares | boolean | The parent organization's ceiling for new shares. false means no workspace under it may create shares, whatever sharing_shares says here. Members only, full output level. |
| workspace.capabilities.org_sharing_file_links | boolean | The parent organization's ceiling for new single-file share links. Members only, full output level. |
Access Levels
| Role | Fields Returned |
|---|---|
| Owner | All fields, including the admin-only locked, storage, platform and suspended |
| Admin | All fields, including the admin-only locked, storage, platform and suspended |
| Member | All fields except the admin-only ones |
| View | All fields except the admin-only ones |
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1650 (Authentication Invalid) | 401 | Authentication required | Missing or invalid JWT |
1609 (Not Found) | 404 | Workspace not found | Invalid ID or no access |
Update Workspace
/current/workspace/{workspace_id}/update/
Updates workspace configuration. All fields are optional; only provided fields are updated.
Auth: JWT required. Admin or Owner required.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} | string | Yes | 19-digit numeric workspace ID |
Request Parameters (all optional)
| Name | Type | Constraints | Description |
|---|---|---|---|
| folder_name | string | 4-80 chars, regex ^[\p{L}\p{N}-]+$, unique | URL-safe identifier |
| name | string | 2–100 chars. Cannot be cleared. | Display name |
| description | string | 10–1000 chars. Send "null" or "" to clear. | Description |
| perm_join | string | See Permission Values | Who can self-join |
| perm_member_manage | string | See Permission Values | Who can manage members |
| intelligence | string | "true" or "false". Can be toggled. Setting to "true" requires both content_ai and ai_agent plan features. Disabling flushes embeddings; re-enabling re-indexes (costs AI credits). | AI indexing toggle |
| metadata_extraction | string | "true" or "false". Switches automatic metadata extraction for new uploads on or off. Deletes nothing, not rate-limited, no plan requirement (has no effect unless intelligence is on and the plan includes metadata). Explicit extraction requests are unaffected. | Automatic extraction toggle |
| sharing_shares | string | Send the string "true" or "false", form-encoded or as a query parameter. Enterprise plan only to tighten (switching it off); resubmitting it unchanged or switching it back on works on any plan. | Whether this workspace allows new shares. See Sharing Policy. |
| sharing_file_links | string | Send the string "true" or "false", form-encoded or as a query parameter. Enterprise plan only to tighten (switching it off); resubmitting it unchanged or switching it back on works on any plan. | Whether this workspace allows new single-file share links. |
| external_invites | string | "allowed" or "denied". Ungated (workspace admin only) — see Collaboration Policy. | This workspace's own external-invite switch. |
| cloud_sync_mode | string | "read" or "read_write"; any other value is rejected. No plan requirement, and either value is accepted from a workspace admin (including widening read back to read_write). | This workspace's cloud-sync direction ceiling under the org cloud_sync policy. See Cloud Sync Policy. |
| accent_color | string (JSON) | JSON color object {"color":"#RRGGBB","opacity":0-100}. Send "null" to clear. | Accent color |
| background_color1 | string (JSON) | JSON color object {"color":"#RRGGBB","opacity":0-100}. Send "null" to clear. | Background color 1 |
| background_color2 | string (JSON) | JSON color object {"color":"#RRGGBB","opacity":0-100}. Send "null" to clear. | Background color 2 |
| owner_defined | string (JSON) | Valid JSON. Send "null" or "" to clear. | Custom owner-defined properties |
Example
curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/update/" \
-H "Authorization: Bearer {jwt_token}" \
-d "name=Updated Workspace Name" \
-d "description=New description for the workspace" \
-d "perm_join=Admin or above"
Response (200 OK)
{
"result": true
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1680 (Access Denied) | 401 | Appropriate access is not granted to this Workspace. | User is not admin or owner |
1605 (Invalid Input) | 406 | An invalid workspace folder name was supplied. | Invalid folder_name |
1658 (Not Acceptable) | 406 | The supplied workspace folder name is already in use. | Duplicate folder_name |
1685 (Feature Limit) | 412 | Intelligence setting can only be changed twice per minute and five times per hour... | Intelligence toggle rate-limited by the per-window throttle |
278337 | 406 | Intelligence feature requires a plan upgrade. This feature is not available on your current subscription plan. | Plan missing content_ai or ai_agent (cannot set intelligence=true) |
1700 (Forbidden) | 403 | This configuration requires an Enterprise plan. | sharing_shares or sharing_file_links was switched off by an org without the entitlement. Resubmitting one unchanged, or switching one back on, is not refused. params.reason = plan_required. |
1605 (Invalid Input) | 406 | The external invite setting must be "allowed" or "denied". | Invalid external_invites value |
1605 (Invalid Input) | 406 | An invalid configuration was supplied... | Metadata validation failure |
| (generated per call site) | 403 | Your organization's AI policy does not allow enabling this here. | Turning intelligence=true or metadata_extraction=true ON while the org's AI policy denies the caller that feature for this workspace. params.reason = ai_policy_denied (params.feature:"intelligence" or "metadata") or ai_policy_workspace_not_allowed (allowlist arm, adds params.workspace_id). Turning either off is never refused by this policy. See AI, Intelligence & MCP Access Policy in Organizations. |
1663 (Update Failed) | 500 | There was an internal error processing your update request. | Internal failure |
If no fields have changed, returns
200 OKwithout making changes. JSON fields are decoded server-side; send valid JSON strings.
Delete Workspace
/current/workspace/{workspace_id}/delete/?confirm={folder_name_or_id}
Permanently close (soft-delete) a workspace. Enters a retention period before final purge.
Auth: JWT required. Owner only. 2FA required.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} | string | Yes | 19-digit numeric workspace ID |
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| confirm | string | Yes | Must match the workspace's folder_name (case-insensitive) or numeric id. Safety confirmation. |
Example
curl -X DELETE "https://api.fast.io/current/workspace/1234567890123456789/delete/?confirm=engineering" \
-H "Authorization: Bearer {jwt_token}"
Response (202 Accepted)
{
"result": true
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1680 (Access Denied) | 401 | Appropriate access is not granted to this Workspace. | User is not the workspace owner |
130670 | 406 | The confirm field is required. Pass the workspace's folder name or numeric id as the confirm query parameter. | confirm query parameter was not provided |
10563 | 406 | The confirm field provided does not match the workspace's folder name or id. | Confirmation does not match folder name or ID |
1663 (Update Failed) | 500 | There was an internal error processing your request. | Internal failure |
Archive Workspace
/current/workspace/{workspace_id}/archive/
Archives a workspace. Archived workspaces are hidden from default listings.
Auth: JWT required. Admin or Owner required. 2FA required.
Example
curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/archive/" \
-H "Authorization: Bearer {jwt_token}"
Response (202 Accepted)
{
"result": true
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1680 (Access Denied) | 401 | Appropriate access is not granted to this Workspace. | Not admin or owner |
1663 (Update Failed) | 500 | The workspace is already archived. | Already archived |
Unarchive Workspace
/current/workspace/{workspace_id}/unarchive/
Restores an archived workspace to active status.
Auth: JWT required. Admin or Owner required. 2FA required.
Example
curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/unarchive/" \
-H "Authorization: Bearer {jwt_token}"
Response (202 Accepted)
{
"result": true
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1680 (Access Denied) | 401 | Appropriate access is not granted to this Workspace. | Not admin or owner |
1663 (Update Failed) | 500 | The workspace is not archived. It cannot be unarchived. | Not currently archived |
Workspace Assets
List Available Asset Types
/current/workspace/assets/
Returns available workspace asset metadata types (e.g., logo).
Auth: JWT required.
Example
curl -X GET "https://api.fast.io/current/workspace/assets/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"names": ["logo"],
"metadata_scheme": {
"logo": {
"width": {"name": "Image Width", "description": "Image width", "required": true, "type": "int", "min": 1},
"height": {"name": "Image Height", "description": "Image height", "required": true, "type": "int", "min": 1},
"mime": {"name": "Mimetype", "description": "Image mimetype", "required": true, "type": "string"},
"megapixels": {"name": "Image Megapixels", "description": "Image megapixels", "required": true, "type": "int", "min": 0, "max": 48},
"transform": {"name": "transform", "description": "Transform image", "required": false, "type": "image_transformer"}
}
},
"file_types": {"logo": "image"}
}
names lists the asset names a workspace accepts (only logo); file_types maps each to its file kind; metadata_scheme maps each to the metadata properties validated on upload.
List Workspace Assets
/current/workspace/{workspace_id}/assets/
Returns assets currently set on the workspace.
Auth: JWT required. Owner only. 2FA required.
Example
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/assets/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"assets": {
"logo": {
"metadata": {"width": 512, "height": 512, "megapixels": 0, "mime": "image/png"}
}
}
}
Keyed by asset name; each entry carries the stored metadata. A workspace with no assets returns "assets": []. Fetch the bytes with Read Asset Binary below.
Upload/Set Workspace Asset
/current/workspace/{workspace_id}/assets/{asset_name}/
Upload or replace an asset. Sent as multipart/form-data.
Auth: JWT required. Admin or Owner required.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} | string | Yes | 19-digit numeric workspace ID |
| {asset_name} | string | Yes | Name of the asset (e.g., logo) |
Request Body (multipart/form-data)
| Field | Type | Required | Description |
|---|---|---|---|
| file | file | Yes | The asset file to upload |
| metadata | string (JSON object) | Optional metadata for the asset, sent as a JSON object string (e.g. {}). Invalid JSON or a non-object value is refused with 406. |
Example
curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/assets/logo/" \
-H "Authorization: Bearer {jwt_token}" \
-F "file=@/path/to/logo.png" \
-F "metadata={}"
Response (200 OK)
{
"result": true
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1691 (File Missing) | 412 | Asset upload missing | No file in request |
100289 | 406 | metadata must be a JSON object encoded as a string. | metadata is not valid JSON, or is not a JSON object |
Delete Workspace Asset
/current/workspace/{workspace_id}/assets/{asset_name}/
Delete a specific asset from a workspace.
Auth: JWT required. Admin or Owner required.
Example
curl -X DELETE "https://api.fast.io/current/workspace/1234567890123456789/assets/logo/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true
}
Read Asset Binary
/current/workspace/{workspace_id}/assets/{asset_name}/read/
GET returns raw binary data. HEAD returns metadata headers only (Content-Type, Content-Length).
Auth: JWT required. Any signed-in user; workspace membership is not required. 2FA required.
Example
# Download asset
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/assets/logo/read/" \
-H "Authorization: Bearer {jwt_token}" \
--output logo.png
# Get metadata headers only
curl -I "https://api.fast.io/current/workspace/1234567890123456789/assets/logo/read/" \
-H "Authorization: Bearer {jwt_token}"
Response: Raw binary content with appropriate Content-Type header (not JSON).
Workspace Members
Add or Invite a Member
/current/workspace/{workspace_id}/members/{email_or_user_id}/
Add an existing user directly by user ID, or send an invitation by email address.
Auth: JWT required. Permission depends on workspace perm_member_manage setting. You cannot add or invite someone at a role above your own — the ceiling applies to invitations as well as direct adds.
Send the parameters as form fields (application/x-www-form-urlencoded or multipart/form-data); a JSON request body is refused with a 406 error.
If you pass an email address that belongs to an account which has not verified that address, an invitation is sent to it instead of adding the account directly.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} | string | Yes | 19-digit numeric workspace ID |
| {email_or_user_id} | string | Yes | 19-digit user ID (direct add) or email address (invitation) |
Request Parameters (adding by user ID)
| Name | Type | Required | Description |
|---|---|---|---|
| permissions | string | "admin", "member", "guest", or "view"; omitted means "member" for a new member, while a current (unexpired) member keeps their role (your own role must be at least that role) and an expired membership is restored as "member". Cannot be "owner". Any other value (including "any") is refused with a 406 error rather than treated as "member". | |
| notify_options | string | Notification preference | |
| expires | string | Membership expiration (YYYY-MM-DD HH:MM:SS UTC) | |
| notification | string | Send force to force the notification email to the added user |
Request Parameters (inviting by email)
| Name | Type | Required | Description |
|---|---|---|---|
| permissions | string | "admin", "member", "guest", or "view"; omitted means "member". Cannot be "owner". Any other value (including "any") is refused with a 406 error rather than treated as "member". | |
| message | string | Custom message in invitation email | |
| invitation_expires | string | Invitation expiration (YYYY-MM-DD HH:MM:SS UTC) |
Examples
# Add existing user by ID
curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/members/9876543210987654321/" \
-H "Authorization: Bearer {jwt_token}" \
-d "permissions=member"
# Invite by email
curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/members/newuser@example.com/" \
-H "Authorization: Bearer {jwt_token}" \
-d "permissions=member" \
-d "message=Welcome to the project!"
Response — Direct Add (200 OK)
{
"result": true
}
Response — Invitation Sent (200 OK)
{
"result": true,
"invitation": {
"id": "aea3w-cuan6-edcu5-vkaex-g52gm-dacr",
"inviter": "John Doe",
"invitee_email": "newuser@example.com",
"entity_type": "workspace",
"state": "pending",
"created": "2025-01-15 10:30:00 UTC",
"expires": "2025-01-18 10:30:00 UTC"
}
}
Abbreviated: the invitation object carries every field of a List Workspace Invitations entry, plus a workspace object.
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1692 (Cannot Add As Owner) | 406 | Adding a member as an owner is not allowed | Attempted owner-level permission |
1656 (Limit Exceeded) | 413 | Varies | Workspace or org member limit reached |
1680 (Access Denied) | 401 | Appropriate access is not granted to this Workspace. | Below workspace perm_member_manage level |
1605 (Invalid Input) | 406 | Invalid permissions value. Valid values are: admin, member, guest, view. | permissions is not one of those role names |
1605 (Invalid Input) | 406 | This endpoint does not accept a JSON request body. … | The request was sent as a JSON body |
127022 | 406 | You cannot add, update, or delete a membership with a higher permission than your own. | The requested role (when omitted: member for a new member or invitation, a current (unexpired) member's role on a re-add) is above your own — for an invitation as well as a direct add |
| — | 403 | Reason-carrying refusal (params.reason) | The invitee is external and the org's collaboration policy (or this workspace's own external_invites switch) denies the inviter. reason = external_invites_denied or external_invites_object_denied. Applies to a fresh invite, a resend, and a broadening edit to an existing membership; a pure permission reduction is never gated. See Collaboration Policy above. |
Direct-add and invitation both count as "adding a member" for the collaboration policy above; an org invite (adding someone to the org itself, not to this workspace) is a separate action and is never gated by it.
Remove a Member
/current/workspace/{workspace_id}/members/{user_id}/
Removes a member from the workspace. Cannot remove the workspace owner.
Auth: JWT required. Permission depends on perm_member_manage.
Example
curl -X DELETE "https://api.fast.io/current/workspace/1234567890123456789/members/9876543210987654321/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
146727 | 401 | Unable to remove owner. Use transfer ownership API. | Attempted to remove the workspace owner |
150044 | 401 | User can not remove user with greater permissions. | The member's role is above your own |
1680 (Access Denied) | 401 | Appropriate access is not granted to this Workspace. | Below workspace perm_member_manage level |
1605 (Invalid Input) | 406 | The membership you specified does not exist. | The user is not a member |
Removing a member cascades removal into all shares within the workspace.
List Workspace Members
/current/workspace/{workspace_id}/members/list/
Lists all members with their permissions, notification preferences, and membership metadata.
Auth: JWT required. Any workspace member.
Query Parameters: limit (default 100, max 500) and offset (default 0). The response carries a pagination object (total, limit, offset, has_more).
Example
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/members/list/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"users": [
{
"id": "1234567890123456789",
"account_type": "human",
"email_address": "owner@example.com",
"first_name": "Alice",
"last_name": "Johnson",
"permissions": "owner",
"status": "active",
"notify": "Notify me in app"
},
{
"id": "9876543210987654321",
"account_type": "agent",
"email_address": "bot@example.com",
"first_name": "Sync",
"last_name": "Bot",
"permissions": "member",
"status": "active",
"expires": "2025-12-31 23:59:59 UTC"
},
{
"id": "5566778899001122334",
"account_type": "human",
"email_address": "invited@example.com",
"first_name": "invited@example.com",
"last_name": "",
"permissions": "member",
"status": "pending",
"invite": {
"id": "aea3wcuan6edcu5vkaexg52gmdacr",
"created": "2025-01-15 10:30:00",
"expires": "2025-01-18 10:30:00"
}
}
],
"pagination": {
"total": 3,
"limit": 100,
"offset": 0,
"has_more": false
}
}
Response Fields
| Field | Type | Description |
|---|---|---|
| users | array | Array of member objects |
| users[].id | string | 19-digit user profile ID |
| users[].account_type | string | "human" or "agent" |
| users[].email_address | string | Member's email address |
| users[].first_name | string | First name |
| users[].last_name | string | Last name |
| users[].permissions | string | Role: "owner", "admin", "member", "guest" |
| users[].status | string | "active" for registered users, "pending" for invited users who have not yet signed up |
| users[].invite | object | Present on pending members (absent when unset, which is normal for active members): id (the invitation ID, unhyphenated), created, expires (the invitation's acceptance deadline, or null). A snapshot taken when the invitation was sent, with timestamps as YYYY-MM-DD HH:MM:SS (UTC, no suffix) |
| users[].notify | string | Notification preference — present only on your own entry |
| users[].expires | string | Membership expiration; absent for a permanent membership |
| pagination | object | total, limit, offset, has_more |
Leave Workspace (Self)
/current/workspace/{workspace_id}/member/
Removes the authenticated user from the workspace. Owners cannot leave; they must transfer ownership first.
Auth: JWT required.
Example
curl -X DELETE "https://api.fast.io/current/workspace/1234567890123456789/member/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | You cannot leave a workspace you are the owner of, transfer ownership or close workspace. | User is the owner |
1605 (Invalid Input) | 406 | You cannot leave an workspace you are not a member of. | Not a member |
Get Member Details
/current/workspace/{workspace_id}/member/{member_id}/details/
Returns membership details for a specific user.
Auth: JWT required. Any workspace member.
Example
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/member/9876543210987654321/details/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"user": {
"id": "9876543210987654321",
"account_type": "human",
"email_address": "user@example.com",
"first_name": "Jane",
"last_name": "Smith",
"permissions": "member"
}
}
Response Fields
| Field | Type | Description |
|---|---|---|
| user.id | string | 19-digit user profile ID |
| user.account_type | string | "human" or "agent" |
| user.email_address | string | Email address |
| user.first_name | string | First name |
| user.last_name | string | Last name |
| user.permissions | string | Permission level name |
| user.invite | object | Pending-invitation snapshot, as in List Workspace Members; absent when unset |
| user.notify | string | Notification preference — present only when you read your own membership |
| user.expires | string | Membership expiration; absent for a permanent membership |
| user.member_added_at | string | When the membership was created — present only to the member themselves or a workspace admin or above |
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | The membership you specified does not exist. | User is not a member |
Update a Member
/current/workspace/{workspace_id}/member/{member_id}/update/
Updates a member's role, notification preferences, or expiration.
Auth: JWT required. Permission depends on perm_member_manage; you may always update your own membership. Either way you cannot set a role above your own.
Send the parameters as form fields; a JSON request body is refused with a 406 error.
Request Parameters (all optional)
| Name | Type | Description |
|---|---|---|
| permissions | string | New role: "admin", "member", "guest", "view". Omitted leaves the role unchanged; "owner" is ignored (use Transfer Workspace Ownership). Any other value (including "any") is refused with a 406 error. |
| notify_options | string | Notification preference; omitted leaves it unchanged |
| expires | string | Membership expiration (YYYY-MM-DD HH:MM:SS UTC) |
Example
curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/member/9876543210987654321/update/" \
-H "Authorization: Bearer {jwt_token}" \
-d "permissions=admin"
Response (200 OK)
{
"result": true
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | The membership you specified does not exist. | Target is not a member |
1680 (Access Denied) | 401 | Appropriate access is not granted to this Workspace. | Below workspace perm_member_manage level (updating someone else) |
1605 (Invalid Input) | 406 | Invalid permissions value. Valid values are: admin, member, guest, view. | permissions is not one of those role names |
1605 (Invalid Input) | 406 | This endpoint does not accept a JSON request body. … | The request was sent as a JSON body |
127022 | 406 | You cannot add, update, or delete a membership with a higher permission than your own. | The resulting role is above your own |
Transfer Workspace Ownership
/current/workspace/{workspace_id}/member/{member_id}/transfer_ownership/
POST only — GET, HEAD, and every other method return 405. Transfers ownership to another member, who must be an enabled, non-phantom member of both the workspace and its parent org. The current owner is demoted to admin. The workspace's parent org is never changed.
Auth: JWT required. Owner only.
Fixed behaviour:
- The successor is promoted before the current owner is demoted, under a per-workspace lock.
- Retry completes a partial transfer. If a previous call stopped midway (both users end up owners), calling again with the same target finishes it and returns 200.
- A concurrent call on the same workspace returns 409
transfer_in_progress. - No email is sent — this is recorded as an event only.
Example
curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/member/9876543210987654321/transfer_ownership/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"ownership": {
"profile_id": "1234567890123456789", "profile_type": "workspace",
"previous_owner": "1111111111111111111", "new_owner": "9876543210987654321",
"transferred_at": "2026-09-23 16:37:29 UTC"
}
}
Error Responses (error.params.reason)
| Reason | HTTP | Message | Cause |
|---|---|---|---|
successor_is_self | 406 | You cannot transfer ownership to yourself. | Target is self |
successor_not_member | 406 | The membership you specified does not exist. / The new owner must be a member of the parent organization. | Target is not a workspace member (or their membership was removed or has expired), or not a member of the parent org |
successor_unavailable | 406 | The new owner's account is not active, so ownership cannot be transferred to it. | Target is closed, suspended, locked, or a phantom member |
transfer_in_progress | 409 | Another ownership transfer of this workspace is in progress. Please try again shortly. | Another transfer of this workspace is running; retry shortly |
| — | 401 | Appropriate access is not granted to this Workspace. / You are no longer the owner of this workspace. | Not the workspace owner (the second text: ownership changed while the call was waiting) |
scope_admin_required | 403 | — | The credential is not admin-capable on this workspace (an API key/OAuth token without rwa) |
| — | 500 | The ownership transfer did not finish. Transfer to the same member again to complete it. | The transfer did not finish — repeat the call with the same target to complete it |
| — | 503 | Ownership transfer is temporarily unavailable. Please try again shortly. | Temporarily unavailable; retry |
Event: ownership_transferred (audit log; profile_type, from_user, to_user). The two existing membership_updated events (promotion and demotion) still fire.
Join Workspace
/current/workspace/{workspace_id}/members/join/
Self-join a workspace based on org membership. Subject to the workspace's perm_join setting.
Auth: JWT required.
Request Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| notify_options | string | Notification preference |
Example
curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/members/join/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1680 (Access Denied) | 401 | You do not have the appropriate permissions to join this workspace. | User's org role does not meet perm_join |
1656 (Limit Exceeded) | 413 | Varies | Workspace member limit reached |
1654 (Internal Error) | 500 | Unable to verify workspace member limits. Please try again later. | The member limit could not be checked; retry |
A new self-joined member gets the
memberrole; a current (unexpired) member who self-joins keeps their role; an expired membership is restored asmember. Onlynotify_optionsis read;permissionsis ignored. A self-join never sets a membership expiry.
Join Workspace via Invitation
/current/workspace/{workspace_id}/members/join/{invitation_key}/{action}/
Join or decline a workspace invitation.
Auth: JWT required.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {invitation_key} | string | Yes | Unique invitation key |
| {action} | string | "accept" (default) or "decline" |
Example
curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/members/join/abc123def456/accept/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1654 (Internal Error) | 500 | Failed to get invitation. | Invalid or expired invitation key |
1680 (Access Denied) | 401 | The inviter no longer has appropriate permissions... | Inviter lost management permissions |
1656 (Limit Exceeded) | 413 | Varies | Member limit reached |
| — | 403 | Reason-carrying refusal (params.reason) | Accept only: re-checked against the collaboration policy at redemption, since the org policy or the inviter's own standing can have changed since the invitation was sent. reason = external_invites_denied or external_invites_object_denied. The invitation stays pending, not failed. decline is never gated. See Collaboration Policy above. |
The system validates the inviter still has sufficient permissions at acceptance time.
Pending Members
When a user is invited to a workspace by email but does not yet have a Fastio account, they appear as a pending member in the member list.
How pending members appear in responses:
- The
statusfield is"pending"(vs"active"for registered users). - An
inviteobject is included with basic invitation details (id,created,expires). email_addressshows the invited email address.first_nameis set to the invited email address;last_nameis empty.
Example Member List Entry (Pending)
{
"id": "5566778899001122334",
"account_type": "human",
"email_address": "newuser@example.com",
"first_name": "newuser@example.com",
"last_name": "",
"permissions": "member",
"status": "pending",
"invite": {
"id": "aea3wcuan6edcu5vkaexg52gmdacr",
"created": "2025-01-15 10:30:00",
"expires": "2025-01-18 10:30:00"
}
}
Account claim: When the invited user signs up or accepts the invitation with an existing account, their status transitions from "pending" to "active".
Removal: To remove a pending member, delete their invitation using the invitation endpoints (see Workspace Invitations below). Deleting the invitation removes the pending member.
Notifications: Pending members do not receive in-app or email notifications until they claim their account.
Workspace Invitations
List Workspace Invitations
/current/workspace/{workspace_id}/members/invitations/list/
Returns all invitations for the workspace.
Auth: JWT required. Any workspace member.
Example
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/members/invitations/list/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"invitations": [
{
"id": "aea3w-cuan6-edcu5-vkaex-g52gm-dacr",
"inviter": "Alice Johnson",
"inviter_actor": {
"user_id": "1234567890123456789",
"kind": "human",
"agent_name": null,
"name_source": null,
"credential_type": "session",
"verified": false
},
"invitee_email": "newuser@example.com",
"invitee_uid": "5566778899001122334",
"accepted_uid": null,
"entity_type": "workspace",
"state": "pending",
"consumed": false,
"created": "2025-01-15 10:30:00 UTC",
"updated": "2025-01-15 10:30:00 UTC",
"expires": "2025-01-18 10:30:00 UTC"
}
]
}
Response Fields
| Field | Type | Description |
|---|---|---|
| invitations | array | Array of invitation objects |
| invitations[].id | string | Invitation ID |
| invitations[].inviter | string | Display name of inviting user |
| invitations[].inviter_actor | object | Who sent the invitation and whether an agent acted for them: user_id, kind (human, agent, api_key, app, system, unknown), agent_name, name_source, credential_type, verified. Any agent_name other than Fastio’s own verified agent is self-declared. Full reference: Actor Attribution in the Storage reference |
| invitations[].invitee_email | string | Email the invitation was sent to |
| invitations[].invitee_uid | string or null | User ID (19-digit string) of the invitee's pending-member placeholder; null when there is none |
| invitations[].accepted_uid | string or null | 19-digit user ID of the account that accepted, as a string; null until accepted |
| invitations[].entity_type | string | Always "workspace" |
| invitations[].state | string | "pending", "accepted", "declined" |
| invitations[].consumed | boolean | true once the invitation has been accepted |
| invitations[].created | string | Creation timestamp |
| invitations[].updated | string | Last update timestamp |
| invitations[].expires | string or null | Deadline for accepting the invitation |
List Invitations by State
/current/workspace/{workspace_id}/members/invitations/list/{state}/
Filter invitations by state.
Auth: JWT required. Any workspace member.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {state} | string | Yes | "pending", "accepted", "declined" |
Example
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/members/invitations/list/pending/" \
-H "Authorization: Bearer {jwt_token}"
Response: Same format as List Workspace Invitations, filtered by state.
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | An invalid invitation state was supplied. | Unrecognized state |
Update an Invitation
/current/workspace/{workspace_id}/members/invitation/{invitation_id}/
Update an existing invitation. The {invitation_id} can be the invitation ID or the invitee's email address. The invitation must belong to this workspace; otherwise the request fails with 406 exactly as an unknown invitation does ("Invalid invitation id or Invitation not found." for an ID).
Auth: JWT required. Permission depends on perm_member_manage.
Send the parameters as form fields; a JSON request body is refused with a 406 error.
Request Parameters (all optional)
| Name | Type | Description |
|---|---|---|
| state | string | New state: "pending", "accepted", "declined" |
| permissions | string | The role granted when the invitation is accepted: "admin", "member", "guest", "view". Cannot be "owner" or above your own role; either refusal returns an error and leaves the invitation unchanged |
| notify_options | string | Notification preference applied on acceptance |
| expires | string | New deadline for accepting the invitation (YYYY-MM-DD HH:MM:SS UTC, must be in the future). It does not set an expiry on the membership the invitation grants. An empty value or null leaves the deadline unchanged |
Examples
# Decline by ID
curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/members/invitation/aea3w-cuan6-edcu5-vkaex-g52gm-dacr/" \
-H "Authorization: Bearer {jwt_token}" \
-d "state=declined"
# Update by email
curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/members/invitation/user@example.com/" \
-H "Authorization: Bearer {jwt_token}" \
-d "permissions=admin"
Response (200 OK)
{
"result": true
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | An invalid invitation ID or email was supplied. | Malformed identifier |
1605 (Invalid Input) | 406 | Invalid invitation id or Invitation not found. | No invitation with that ID, or it belongs to a different workspace |
1605 (Invalid Input) | 406 | An invalid state was supplied. | Unrecognized state |
1605 (Invalid Input) | 406 | Invalid permissions value. Valid values are: admin, member, guest, view. | permissions is not one of those role names |
1605 (Invalid Input) | 406 | This endpoint does not accept a JSON request body. … | The request was sent as a JSON body |
1692 (Cannot Add As Owner) | 406 | Adding a member as an owner is not allowed | permissions=owner |
127022 | 406 | You cannot add, update, or delete a membership with a higher permission than your own. | The new role is above your own |
1679 (Update Failed) | 500 | Failed to update invitation. | Internal failure |
1680 (Access Denied) | 401 | Insufficient permissions | Below required permission level |
Delete an Invitation
/current/workspace/{workspace_id}/members/invitation/{invitation_id}/
Delete (revoke) an invitation. The {invitation_id} can be the invitation ID or the invitee's email. The invitation must belong to this workspace; otherwise the request fails with 406 exactly as an unknown invitation does ("Invalid invitation id or Invitation not found." for an ID).
Auth: JWT required. Permission depends on perm_member_manage.
Example
curl -X DELETE "https://api.fast.io/current/workspace/1234567890123456789/members/invitation/aea3w-cuan6-edcu5-vkaex-g52gm-dacr/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | An invalid invitation ID or email was supplied. | Malformed identifier |
1605 (Invalid Input) | 406 | Invalid invitation id or Invitation not found. | No invitation with that ID, or it belongs to a different workspace |
1666 (Delete Failed) | 500 | Failed to delete invitation. | Internal failure |
1680 (Access Denied) | 401 | Insufficient permissions | Below required permission level |
Creating Shares from Workspaces
Create Share
/current/workspace/{workspace_id}/create/share/
Create a new share within a workspace. Shares can use independent storage (isolated portal) or a workspace folder as their storage root (live folder share).
Auth: JWT required. Workspace Guest or above, subject to the Sharing Policy — read capabilities.can_create_share for the effective answer.
For full share management documentation, see Shares reference.
Required Parameters
| Name | Type | Constraints | Description |
|---|---|---|---|
| intelligence | string | "true" or "false" | Enable AI features for the share |
Optional Parameters
| Name | Type | Constraints | Description |
|---|---|---|---|
| share_type | string | "send", "receive", "exchange" | Type of share. Optional — falls back to the profile default when omitted. Portal (independent) shares are always send — a receive/exchange value is silently overridden. receive/exchange require storage_mode=workspace_folder. |
| access_options | string | See access options below | Access control setting. Optional — falls back to the profile default when omitted. |
| invite | string | "owners" or "guests" | Who can manage invitations. Optional — falls back to the profile default when omitted. |
| storage_mode | string | "independent" (default) or "workspace_folder" | Storage isolation mode |
| folder_node_id | string | Valid OpaqueId | Existing workspace folder (for workspace_folder mode) |
| create_folder | string | "true" or "false" | Create new folder (for workspace_folder mode) |
| folder_name | string | 1-255 characters | Name for new folder (defaults to "Shared Folder") |
| title | string | 2–80 chars | Display title |
| description | string | 10–500 chars | Share description |
| custom_name | string | 4–80 chars, URL-friendly | Custom URL name. Auto-generated if omitted. |
| custom_url | string | 10–100 chars | Custom URL for linking to the share. Default null; not auto-generated. |
| password | string | 4–128 chars | Password protection (Send type only, requires "Anyone with the link" access) |
| expires | string | datetime | Expiration date (portals only, not for workspace folder shares) |
| notify | string | "never", "notify_on_file_received", "notify_on_file_sent_or_received" | Notification preference |
| comments_enabled | string | "true" or "false" | Enable comments |
| download_security | string | high, medium, off | Download security level. high: downloads disabled. medium: restricted. off: unrestricted. |
| guest_chat_enabled | string | "true" or "false" | Enable guest AI chat |
| accent_color | string (JSON) | JSON color object {"color":"#RRGGBB","opacity":0-100} | Accent color |
| background_color1 | string (JSON) | JSON color object {"color":"#RRGGBB","opacity":0-100} | Background color 1 |
| background_color2 | string (JSON) | JSON color object {"color":"#RRGGBB","opacity":0-100} | Background color 2 |
| owner_defined | string (JSON) | Valid JSON | Custom properties |
Access Options (access_options)
| Value | Description |
|---|---|
'Only members of the Share or Workspace' | Most restrictive (default) |
'Members of the Share, Workspace or Org' | Includes org members |
'Anyone with a registered account' | Any authenticated user |
'Anyone with the link' | Least restrictive; allows password. Not available for Receive/Exchange types. |
Example
curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/create/share/" \
-H "Authorization: Bearer {jwt_token}" \
-d "title=Client Deliverables" \
-d "share_type=send" \
-d "intelligence=true" \
-d "access_options=Anyone with a registered account" \
-d "invite=owners"
Response (200 OK)
{
"result": true,
"share": {
"id": "9876543210987654321",
"custom_name": "abc123opaque",
"storage_mode": "independent"
}
}
Response — Workspace Folder Share
{
"result": true,
"share": {
"id": "9876543210987654321",
"custom_name": "def456opaque",
"storage_mode": "workspace_folder",
"folder_node_id": "abc123def456"
}
}
Response Fields
| Field | Type | Description |
|---|---|---|
| share.id | string | 19-digit share profile ID |
| share.custom_name | string | URL name (custom or auto-generated) |
| share.storage_mode | string | "independent" or "workspace_folder" |
| share.folder_node_id | string | (Workspace folder shares only) Folder node ID |
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
131979 | 403 | Sharing is turned off for this workspace. | The org or workspace sharing policy denies new shares. params.reason = policy_sharing_disabled. See Sharing Policy above. |
1658 (Not Acceptable) | 406 | The supplied share custom name is already in use. | Duplicate custom_name |
1605 (Invalid Input) | 406 | An invalid share custom name was supplied. | Invalid custom_name format |
1605 (Invalid Input) | 406 | Workspace folder shares cannot have an expiration date. | expires set on workspace folder share |
1605 (Invalid Input) | 406 | Receive and Exchange shares cannot have "Anyone" access option. | Invalid access/type combination |
1605 (Invalid Input) | 406 | Password can only be set for shares with "Anyone" access option. | Password on non-public share |
1658 (Not Acceptable) | 406 | This folder has already been shared. | Folder already has a share |
1660 (Conflict) | 409 | Unable to process share creation request due to concurrent operation. | Concurrent folder share creation |
1700 (Forbidden) | 403 | Creating a share with this access setting is not permitted here by policy. | The final access_options admits people outside the org ('Anyone with a registered account' or 'Anyone with the link') and the org's collaboration policy denies the acting user. params.reason = external_invites_denied. See Collaboration Policy above. |
1654 (Internal Error) | 500 | The policy that governs sharing in this workspace could not be determined. Please try again. | The collaboration policy could not be evaluated (transient — retry). |
List Shares in Workspace
/current/workspace/{workspace_id}/list/shares/
Lists all shares belonging to a workspace.
Auth: JWT required. View or above.
Query Parameters
| Name | Type | Default | Description |
|---|---|---|---|
| archived | string | "false" | "true" for archived shares, "false" for active |
| limit | integer | 100 | Page size (max 500) |
| offset | integer | 0 | Number of shares to skip |
Example
curl -X GET "https://api.fast.io/current/workspace/1234567890123456789/list/shares/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"shares": [
{
"id": "9876543210987654321",
"title": "Client Deliverables",
"share_type": "send",
"custom_name": "client-deliverables",
"archived": false,
"closed": false
}
],
"pagination": {
"total": 1,
"limit": 100,
"offset": 0,
"has_more": false
}
}
Import Share into Workspace
/current/workspace/{workspace_id}/import/share/{share_id}/
Transfers a user-owned share into workspace ownership.
Auth: JWT required. Workspace Member or above AND owner of the share.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} | string | Yes | 19-digit workspace ID |
| {share_id} | string | Yes | 19-digit share ID to import |
Example
curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/import/share/9876543210987654321/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"share": {
"id": "9876543210987654321",
"parent_type": "workspace",
"parent_workspace": "1234567890123456789"
}
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | This share is not owned by you and cannot be imported. | Share parent is not the current user |
1650 (Authentication Invalid) | 401 | You must be the owner of the share to import it to a workspace. | Not the share owner |
1605 (Invalid Input) | 406 | The share has multiple owners... | Remove other owners first |
Share must be user-owned (not already in another workspace). User must be the sole owner. Multiple owners must be removed first. Archived shares are auto-unarchived during import.
File Shares (Durable Single-File Links)
A File Share is a durable, link-shareable view of one workspace file — the successor to the deprecated QuickShare. It is durable by default (an optional expiry can be set on create/update) and has no per-link transfer cap (bandwidth is metered to the owning organization). A File Share is bound to a single file node at creation and the binding is immutable. The public read endpoints (details, download, preview, versions) are documented in the Shares and Storage references; the management endpoints below require the caller to be an authenticated member of the workspace.
Comment visibility is one-directional (workspace ⊇ File Share). Because a File Share is just a view of a workspace file, comments left by File Share recipients also surface to workspace members — read-only on the workspace file's comment thread and in workspace comment search. The reverse is never true: a File Share recipient sees only the comments made under that File Share and never the workspace's internal comments.
Create a File Share
/current/workspace/{workspace_id}/create/fileshare/
Create a File Share bound to a workspace file node. Auth required. Permission: Member of the workspace named in the path.
Request Body (form-encoded)
| Name | Type | Required | Description |
|---|---|---|---|
| node | string | Yes | OpaqueId of the file or note node to share (a folder is refused) |
| title | string | No | Display title (max 255 chars) |
| access_option | string | No | anyone_with_link, any_registered, or named_people (default: named_people) |
| password | string | No | Optional link password (1-255 chars). Body-only — never accepted from the query string. |
| expires | integer | No | Optional expiry, RELATIVE: seconds from now (> 0). Mutually exclusive with expires_at. Omitted = durable (never expires). |
| expires_at | string | No | Optional expiry, ABSOLUTE: a future datetime; a value without a timezone is interpreted as UTC. Mutually exclusive with expires. An expired File Share stops serving immediately (404) and is reaped automatically by the hourly cleanup (grants cascaded, file_share_deleted fires); the bound file is never touched. |
| comments_enabled | string | No | "true" or "false" — allow comments on the File Share (default off) |
curl Example
curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/create/fileshare/" \
-H "Authorization: Bearer {jwt_token}" \
-d "node=2ltsu-q4mja-cuv7p-gc5yd-lxnsj-wee4" \
-d "title=Quarterly Presentation" \
-d "access_option=anyone_with_link"
Response (200 OK)
{
"result": true,
"fileshare": {
"fileshare": "1234567890123456789",
"id_alt": "adheih5r326qjiqvk4wfamvt4rqeh",
"title": "Quarterly Presentation",
"access_option": "anyone_with_link",
"has_password": false,
"bound_node_id": "2ltsuq4mjacuv7pgc5ydlxnsjwee4",
"creator_uid": "9876543210987654321",
"created": "2026-04-27 16:37:29 UTC",
"updated": "2026-04-27 16:37:29 UTC",
"expires": null,
"comments_enabled": false
}
}
Error Responses
| Error Code | HTTP Status | Cause |
|---|---|---|
1605 (Invalid Input) | 406 | node missing/invalid, the node is not a file, or the password is unusable |
1680 (Access Denied) | 401 | The bound file is not publicly serveable (locked/DMCA/infected) |
1700 (Forbidden) | 403 | Org or workspace sharing policy refuses new single-file links. params.reason = policy_sharing_disabled. Read capabilities.can_create_fileshare on the workspace to avoid it. |
1700 (Forbidden) | 403 | access_option is any_registered or anyone_with_link and the org's collaboration policy denies the acting user ("Creating a file link with this access setting is not permitted here by policy."). params.reason = external_invites_denied. See Collaboration Policy above. |
1654 (Internal Error) | 500 | The collaboration policy could not be evaluated ("The policy that governs sharing this file could not be determined. Please try again."; transient — retry). |
List File Shares in Workspace
/current/workspace/{workspace_id}/list/fileshares/
Lists the workspace's File Shares (offset pagination). Scoped strictly to File Shares — never mixes with the Share list. Auth required. Permission: Member.
Each item additionally carries grant_count (the number of live named-people grants on the File Share — revoked / expired grants excluded) and grants_preview (the first few grants, in the same shape as the grants list endpoint, for rendering an avatar stack without a second call). Both are best-effort: if the grant read faults for an item, the two fields are omitted for that item (the rest of the list still returns). The full access list is the GET .../grants/ endpoint below.
Response (200 OK)
{
"result": true,
"fileshares": [
{
"fileshare": "1234567890123456789",
"id_alt": "adheih5r326qjiqvk4wfamvt4rqeh",
"title": "Quarterly Presentation",
"access_option": "anyone_with_link",
"has_password": false,
"bound_node_id": "2ltsuq4mjacuv7pgc5ydlxnsjwee4",
"creator_uid": "9876543210987654321",
"created": "2026-04-27 16:37:29 UTC",
"updated": "2026-04-27 16:37:29 UTC",
"expires": null,
"comments_enabled": false,
"grant_count": 2,
"grants_preview": [
{
"user": "9876543210987654321",
"name": "Ada Lovelace",
"email": "ada@example.com",
"capability": "edit",
"state": "active",
"created": "2026-04-27 16:37:29 UTC",
"expires": null
},
{
"user": null,
"name": null,
"email": "pending.invitee@example.com",
"capability": "view",
"state": "pending",
"created": "2026-04-27 16:40:00 UTC",
"expires": "2026-05-27 16:40:00 UTC"
}
]
}
],
"pagination": {
"total": 1,
"limit": 100,
"offset": 0,
"has_more": false
}
}
Update a File Share
/current/fileshare/{fileshare_id}/update/
/current/fileshare/{fileshare_id}/update/
Update a File Share's mutable settings. The bound file node is immutable here (a rebind is a new File Share); only title, access_option, password, comments_enabled and the expiry change. Auth required. Permission: Member of the File Share's parent workspace.
Request Body (form-encoded)
| Name | Type | Description |
|---|---|---|
| title | string | New title (max 255). Send empty / null to clear. |
| access_option | string | anyone_with_link, any_registered, or named_people |
| password | string | New link password (max 255). Body-only. Send empty to clear. |
| expires | integer | New expiry, RELATIVE: seconds from now (> 0). Mutually exclusive with expires_at. |
| expires_at | string | New expiry, ABSOLUTE: a future datetime (no timezone = UTC). Send null to clear the expiry (durable again). Mutually exclusive with expires. |
| comments_enabled | string | "true" or "false" — turn commenting on the File Share on or off |
Widening access_option (named_people → any_registered or anyone_with_link, or any_registered → anyone_with_link) is governed by the org's collaboration policy exactly like the grants endpoint below — narrowing and every other field are unaffected.
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
177116 | 403 | Widening this FileShare's access is not permitted here by policy. | params.reason = external_invites_denied. See Collaboration Policy above. |
198821 | 500 | The policy that governs sharing this file could not be determined. Please try again. | The policy could not be evaluated (transient — retry). |
Returns the updated File Share in the same shape as create.
Delete a File Share
/current/fileshare/{fileshare_id}/delete/
Delete a File Share. Revokes the link and cascades its grants. Auth required. Permission: Member of the File Share's parent workspace. Response: {"result": true}.
List / Manage Per-User Grants
/current/fileshare/{fileshare_id}/grants/
/current/fileshare/{fileshare_id}/grants/
/current/fileshare/{fileshare_id}/grants/
List or manage the named-people access list. GET lists the File Share's live grants; POST grants or raises a user's capability; DELETE revokes (idempotent). A grant raises an individual user's capability regardless of tier; the named_people tier consults this list directly. Auth required. Permission: Member of the File Share's parent workspace (all three methods).
List (GET) Response (200 OK)
Returns the File Share's live named-people grants — revoked and expired grants are excluded. There are no pagination parameters (the access list is small; the first 1000 grants are returned).
{
"result": true,
"grants": [
{
"user": "9876543210987654321",
"name": "Ada Lovelace",
"email": "ada@example.com",
"capability": "edit",
"state": "active",
"created": "2026-04-27 16:37:29 UTC",
"expires": null
},
{
"user": null,
"name": null,
"email": "pending.invitee@example.com",
"capability": "view",
"state": "pending",
"created": "2026-04-27 16:40:00 UTC",
"expires": "2026-05-27 16:40:00 UTC"
}
]
}
Grant Fields
| Field | Type | Description |
|---|---|---|
| user | string | null | The grantee's 19-digit user profile ID. null for a pending invitee (no account yet). |
| name | string | null | Display name. null when unknown (a pending invitee, or no name set). |
| string | null | The grantee's email address (the real invitation email for a pending invitee). null in the rare case a claimed account has no email on record. | |
| capability | string | view, download, or edit. |
| state | string | active (a real, claimed user) or pending (an invited address not yet claimed). |
| created | string | When the grant was created (Y-m-d H:i:s UTC). |
| expires | string | null | For an active grant, the grant's own expiry; for a pending invitee, the invitation's expiry. null when it does not expire. |
Grant / Revoke (POST / DELETE) Parameters
Where to send them differs by method. On POST, send them as a form-encoded request body (they are also accepted on the query string). On DELETE, send them as query-string parameters — a request body is not read on this DELETE, so a form-encoded DELETE body is silently ignored and the request fails as though you had supplied neither user nor email.
Supply exactly one of user or email (supplying both, or neither, is rejected as invalid input — 1605, HTTP 406).
| Name | Type | Required | Description |
|---|---|---|---|
| user | string | One of user/email | 19-digit user profile ID of the grantee (an existing account) |
| string | One of user/email | Email address of the grantee — lets you grant a person who may not have an account yet | |
| capability | string | Yes for POST | view, download, or edit |
A view grant can open the link and preview; download can also download the bytes (and historical versions); edit can additionally replace the file's content via an upload session (see the Upload reference). Re-granting the same capability, or revoking a non-existent grant, is a silent no-op. Response (POST by user, or DELETE): {"result": true}.
Collaboration policy. A grant to an external person (not a current member of the File Share's owning org, and not on one of that org's verified SSO domains) is governed by the org's external_invites_shares policy — see Collaboration Policy above. A File Share has no external-invite switch of its own; it follows the org policy only. A new grant or any broadening edit — raising the capability, extending an expiry, or widening access_option — is gated; a pure reduction is always available. This applies to POST (both the email and numeric-user arms) and to a resend that raises the capability, but never to DELETE.
Granting by Email
- If the email belongs to an existing account that has verified that address, it is granted directly — the response carries the resolved
user, and the grant appears in the list asstate: active. A new grant sends that person an email with a direct link to the File Share (granting byuserdoes the same); changing the capability of an existing grant sends nothing. - If the email has no account yet, or belongs to an account that has not verified it, a pending invitation to that address is created and an invite email is sent. The grant appears in the list as
state: pending(withuser: null) and activates automatically when that person signs up with the invited address — they then hold exactly the capability you granted. The response carries the new pendinggrantin the same shape as a grants-list row (so it folds straight into the rendered access list). - Re-granting a pending email at a different capability updates the pending invitation to the new capability.
- Granting a now-registered, verified email directly supersedes any pending invitation for that address (the pending entry is replaced by the active grant).
Revoking by Email (DELETE)
Revokes an active grant for that address, and/or cancels a still-pending invitation for it. Cancelling a pending invitation confers nothing, so it does not emit an access-revoked event. A DELETE that matches neither is an idempotent success.
Response (POST by registered email): { "result": true, "user": { "id": "9876543210987654321" } }
Response (POST by unregistered email — invitation created). The pending grant is returned in the same shape as a grants-list row (user is null for an unclaimed invitee; the internal invitation/account id is never exposed):
{
"result": true,
"grant": {
"user": null,
"name": null,
"email": "newperson@example.com",
"capability": "view",
"state": "pending",
"created": "2026-04-27 16:37:29 UTC",
"expires": "2026-05-27 16:37:29 UTC"
}
}
Error Responses
| Error Code | HTTP Status | Cause |
|---|---|---|
1605 (Invalid Input) | 406 | user is not a valid user id / the user does not exist; email is malformed or cannot receive access; both user and email (or neither) were supplied; or capability is missing on a grant |
143671 | 403 | "External invitations are not permitted here by policy." A new grant, or a broadening of an existing one, to an external person. params.reason = external_invites_denied — see Collaboration Policy above. |
184840 | 500 | The policy could not be evaluated (transient — retry). |
Workspace Discovery
List All Workspaces
/current/workspaces/all/
Lists all workspaces the user has joined or can access across all organizations. Supports optional offset-based pagination.
Auth: JWT required.
Query Parameters
| Query Parameter | Type | Required | Description |
|---|---|---|---|
| limit | integer | No | Number of results to return per page, max 500. The default of 100 applies only once limit or offset is present; with neither, every result is returned. An invalid value is rejected with HTTP 406 (1605). |
| offset | integer | No | Number of results to skip. Default 0, must be 0 or greater. |
Pagination is opt-in: it activates only when the request includes
limitand/oroffset(an explicitoffset=0counts as a request). When neither parameter is present, the response is unchanged from before — every accessible workspace, and nopaginationkey.
Example
curl -X GET "https://api.fast.io/current/workspaces/all/" \
-H "Authorization: Bearer {jwt_token}"
Example (paginated)
curl -X GET "https://api.fast.io/current/workspaces/all/?limit=50&offset=0" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"workspaces": [
{
"id": "1234567890123456789",
"name": "Engineering Team",
"folder_name": "engineering",
"description": "Main engineering workspace",
"accent_color": {"color": "#0066CC", "opacity": 100},
"logo": "https://assets.fast.io/1234567890123456789/logo.png",
"closed": false,
"archived": false,
"perm_join": "Member or above",
"perm_member_manage": "Admin or above",
"created": "2023-01-15 10:30:00 UTC",
"updated": "2024-01-20 14:45:00 UTC",
"user_status": "joined",
"org_domain": "acme-corp"
}
]
}
Response Fields
| Field | Type | Description |
|---|---|---|
| workspaces | array | Array of workspace objects |
| [].id | string | 19-digit workspace profile ID |
| [].name | string | Display name |
| [].folder_name | string | URL-safe folder identifier |
| [].description | string or null | Description |
| [].accent_color | object or null | Brand accent color ({color, opacity}) |
| [].logo | string or null | Logo asset URL |
| [].closed | boolean | Whether closed |
| [].archived | boolean | Whether archived |
| [].perm_join | string | Who can join |
| [].perm_member_manage | string | Who can manage members |
| [].created | string | Creation timestamp |
| [].updated | string | Last update timestamp |
| [].user_status | string | "joined" or "available" |
| [].org_domain | string | Parent organization domain |
| pagination | object | Present only when the request included limit and/or offset |
| pagination.total | integer | Total number of accessible workspaces after filtering, across all pages |
| pagination.limit | integer | The limit applied to this page |
| pagination.offset | integer | The offset applied to this page |
| pagination.has_more | boolean | Whether more workspaces exist beyond this page |
Spans all organizations the user belongs to. Workspaces from orgs without active subscriptions are filtered out. Pagination uses the same
limit/offsetparameters andpaginationobject as the shares listing but, unlike it, applies only when requested. An out-of-range or non-numericlimit(valid1-500), or a negative or non-numericoffset, is rejected with an invalid-input error (1605).
List Available Workspaces
/current/workspaces/available/
Lists workspaces the user can join but has not yet joined. Useful for discovery UI.
Auth: JWT required.
Example
curl -X GET "https://api.fast.io/current/workspaces/available/" \
-H "Authorization: Bearer {jwt_token}"
Response: Same structure as List All Workspaces (without user_status or pagination), but only includes un-joined workspaces.
Check Workspace Name
/current/workspaces/check/name/{org_id}/{name}/
Checks if a workspace folder name is already in use. Useful for real-time form validation.
Auth: JWT required. Membership (Member or above) of the org in the path required. 2FA required.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {org_id} | string | Yes | 19-digit ID of an org you are a member of |
| {name} | string | Yes | The folder name to check |
Example
curl -X GET "https://api.fast.io/current/workspaces/check/name/1234567890123456789/engineering/" \
-H "Authorization: Bearer {jwt_token}"
Response — Name Available (202 Accepted)
{
"result": true
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | An invalid workspace folder name was supplied. | Invalid name format |
10073 | 406 | The supplied workspace folder name is already in use. | Name taken |
1680 (Access Denied) | 401 | Appropriate access is not granted to this Org. | Not an org member |
Checks globally across all workspaces, not just the current org. Returns
202 Accepted(not200 OK) when the name is available.
List Workspaces in Org
/current/org/{org_id}/list/workspaces/
Lists workspaces within a specific organization. Paginated.
A workspace is listed when either the caller is a direct member of it, or the
caller is an org member whose org permission level satisfies that workspace's join permission
(perm_join). These are alternatives, not requirements, so the list contains both the
workspaces the caller has joined and the ones they are entitled to join. Users who are not org
members see only workspaces where they are a direct member.
Each row reports the caller's own standing in workspace_level
(owner, admin, member, guest,
viewer, none) at the standard and full output
levels; ?output=terse omits it. A row the caller can see but has not joined reports
none. Read this field to determine membership — do not infer it by subtracting
/current/workspaces/available/ from this list. When the caller's level cannot be
determined, workspace_level is omitted rather than reported as
none, so none always means an authoritative "not a member".
Auth: JWT required.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {org_id} | string | Yes | 19-digit numeric organization ID |
Query Parameters
| Name | Type | Default | Description |
|---|---|---|---|
| limit | integer | 100 | 1–500, items per page |
| offset | integer | 0 | Items to skip |
| archived | string | "false" | "true" for archived, "false" for active |
Example
curl -X GET "https://api.fast.io/current/org/1000000000000000001/list/workspaces/?limit=50&offset=0" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"workspaces": [
{
"id": "1234567890123456789",
"folder_name": "engineering",
"name": "Engineering Team",
"description": "Main project workspace"
}
],
"pagination": {
"total": 5,
"limit": 50,
"offset": 0,
"has_more": false
}
}
Access Levels
| Role | Visibility |
|---|---|
| Org Owner | Direct memberships, plus workspaces whose perm_join admits an owner |
| Org Admin | Direct memberships, plus workspaces whose perm_join admits an admin |
| Org Member | Direct memberships, plus workspaces whose perm_join admits a member |
| External User | Only workspaces where they are a direct member |
No role sees a workspace set to No one can join automatically unless they are a
direct member of it, and an admin does not see a Only Org Owners workspace unless they
are a direct member. A removed or expired org membership confers no visibility.
Cloud Sync
Enable Cloud Sync
/current/workspace/{workspace_id}/cloud-import/enable/
Enables cloud sync features for a workspace. If already enabled, returns success with a message indicating the current state.
Auth: JWT required. Admin or Owner required. Requires the cloud sync billing feature.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} | string | Yes | 19-digit numeric workspace ID or folder_name |
Example
curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/cloud-import/enable/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"message": "Cloud import features enabled",
"cloud_import": true
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1680 (Access Denied) | 401 | Appropriate access is not granted to this Workspace. | Not admin or owner |
1695 (Upgrade Required) | 402 | Access to cloud_import requires an upgraded plan. | Cloud import not included in billing plan |
1610 (Internal Error) | 500 | Failed to enable cloud import features | Internal failure |
Disable Cloud Sync
/current/workspace/{workspace_id}/cloud-import/disable/
Disables cloud sync features for a workspace. If already disabled, returns success with a message indicating the current state.
Auth: JWT required. Admin or Owner required. Requires the cloud sync billing feature.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {workspace_id} | string | Yes | 19-digit numeric workspace ID or folder_name |
Example
curl -X POST "https://api.fast.io/current/workspace/1234567890123456789/cloud-import/disable/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"message": "Cloud import features disabled",
"cloud_import": false
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1680 (Access Denied) | 401 | Appropriate access is not granted to this Workspace. | Not admin or owner |
1695 (Upgrade Required) | 402 | Access to cloud_import requires an upgraded plan. | Cloud import not included in billing plan |
1610 (Internal Error) | 500 | Failed to disable cloud import features | Internal failure |
When the Plan Stops Including Cloud Sync
A workspace whose plan no longer includes cloud sync keeps everything it has already imported. Its sources move to suspended_plan at their next scheduled check: the imported files, the import folder and the link to the remote folder are all kept, and only scheduling stops — nothing syncs in and nothing writes back while a source sits there.
Nothing has to be repaired. A source in suspended_plan resumes on its own at the next check once the workspace is back on a plan that includes cloud sync; there is no call to make and no reconnect to redo.
Read it as its own state rather than as an idle healthy source: it is reported in the source's status like any other value, and a source that has stopped syncing because of the plan looks identical to a quiet one in every other field.
When the Workspace Is Deleted
Deleting a workspace — or closing the org that owns it — does not disconnect its cloud-sync sources, because a deleted workspace can be restored for a while. Instead each source moves to suspended_workspace at its next scheduled check: the imported files, the import folder and the link to the remote folder are all kept, and only scheduling stops — nothing syncs in and nothing writes back while a source sits there. The check that found the workspace deleted is recorded once as a failed job; no further jobs run for the source while it is suspended.
If the workspace is restored, the source resumes on its own within about a day: it goes back to synced and syncs at the next opportunity, with the same files and the same connection — nothing is re-imported. There is no call to make and no reconnect to redo. If the workspace is never restored, its sources are removed together with the rest of the workspace when the deletion becomes permanent.
A source in suspended_workspace cannot be resumed or refreshed by hand (update/ with action: resume and refresh/ both answer 409); it can still be paused, or disconnected.
When the Org or Workspace Policy Restricts Cloud Sync
See Cloud Sync Policy above for the org cloud_sync envelope and the workspace cloud_sync_mode ceiling. Two outcomes, driven by the two halves of that policy:
enabled = falseparks every source in the workspace atstatus: "suspended_policy"— a new value alongsidesuspended_plan, with the same "nothing to repair" behavior: files, the graft and the remote link are all kept, and the source resumes on its own once the policy re-enables sync. The policy is resolved for the member who owns each source's connected account, so a per-member override parks only that member's sources.mode = "read"changes no source status. Inbound sync continues; only outbound write-back is affected.
Every cloud-sync source object (list, details, and the sources/create/ and details/{source_id}/update/ responses) carries effective_access_mode and effective_access_mode_reason beside access_mode / enforced_access_mode: the actual behavior for the calling principal once the policy is applied, in the source's own read_only / read_write vocabulary (not the policy's read / read_write vocabulary — the two are never conflated). It is the caller's own resolved policy met with the policy of the member who owns the source's connected account (the write-back runs under that account), then capped by the source's own access_mode — so a read_only source is never reported read_write, and a permissive caller still sees read_only when the owner's policy is read. effective_access_mode_reason is null, cloud_sync_disabled or cloud_sync_read_only. Both fields are null when the answer could not be determined (the policy could not be read, or the caller or the account's owner could not be resolved); null is never permissive.
Timestamp format change. The cloud-sync source object's last_sync_at, next_sync_at, created and updated are now emitted as YYYY-MM-DD HH:MM:SS UTC — the canonical API datetime format. They previously omitted the UTC suffix. A client that parses these fields with a fixed pattern must accept the suffix.
A Plan With No Cloud Connections Refuses at the Door
Some plans include cloud sync as a feature but allow no cloud connections at all. On those plans the whole connect flow is refused up front — the providers listing, identities/provision/, and the OAuth completion endpoint all answer:
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
147688 | 412 | Your plan does not include cloud connections. | The plan permits zero sync sources |
This is a settled answer, and the fix is an upgrade rather than a retry. It is deliberately raised before an identity is created and before a consent screen is shown, so a user is never asked to hand a cloud credential to an account that could not use it. On the OAuth completion it is raised before the authorization code is redeemed, so a plan that lapsed while the consent screen was open leaves nothing stranded at the provider — start again from provision after upgrading.
Do not confuse it with 134248 → 503 on the same endpoints, which means the plan could not be determined right now and is worth retrying shortly. Gate on the HTTP status first: 412 is the plan answer, 503 is "ask again".
Provision Provider Identity
/current/cloudsync/workspace/{workspace_id}/identities/provision/
Starts a browser OAuth connection for the specified cloud provider and returns immediately. All four providers behave identically: the connection is completed by the person signing in to their OWN cloud account. There is no longer a background-provisioning family.
Every provider — connected by the user, in a browser. The response carries status: "provisioning" plus an authorize_url, and the identity becomes "active" only once the user completes the consent screen and your app posts the result back — see Complete a Browser OAuth Connect below. Nothing is provisioned in the background and there is no address to share a folder with. Google Drive and Box previously created a robot account inside Fastio's own Google and Box tenants; that model has been removed entirely.
Provisioning requires workspace Member on every provider. A Viewer is refused: the resulting grant is only ever usable by someone who can create a source, so issuing one to a Viewer would mint a live credential its owner could never use.
An identity in error or revoked state can be re-provisioned. A provisioning OAuth identity is returned as-is, without a fresh authorize_url, while its consent could still be live — so a user who abandoned the browser tab cannot immediately retry. That row is reclaimable: provision it again after about 30 minutes and a new authorize_url is issued, or revoke it and provision to recover sooner.
Such an identity carries properties.oauth_pending: true, and that marker is part of this contract — it is set by every provider, it is visible to non-owner callers, and it is cleared when the connect completes. It exists so a client can tell "waiting for a person to finish a consent screen" apart from "the server is still working", which otherwise look identical: both report status: "provisioning".
Read it together with authorize_url — authorize_url is issued only on the provision response and never appears when polling the identity afterwards. So provisioning + oauth_pending: true + no authorize_url is terminal by construction: no amount of polling will produce a link, and the connection must be restarted rather than waited on.
Auth: JWT required. The identity is owned by the caller, and the floor is Member on every one of the four providers — there is no per-provider carve-out, and a Viewer who could connect before is refused now. See Provisioning requires workspace Member above.
Request Body (JSON)
Send Content-Type: application/json. This endpoint parses a JSON body and rejects a form-encoded one with 127872 Invalid JSON in request body.
| Field | Type | Required | Description |
|---|---|---|---|
| account_type | string | No | OneDrive only. work (default) or personal. Selects which kind of Microsoft account the consent screen accepts, and sizes the permissions requested to it: a personal Microsoft account has no SharePoint and cannot consent to any Sites.* permission, so requesting one would fail the whole consent. Ignored by the other providers; omit it and you get work, which is the previous behaviour |
| provider | string | Yes | google_drive, box, onedrive_business, or dropbox. Which of these a workspace may actually connect varies, and the plan is only part of what decides it — a provider can be unavailable to a workspace whose plan grants it — read the workspace's providers endpoint rather than assuming all four |
Response (200 OK)
{
"result": true,
"identity": {
"id": "abc123...",
"profile_id": "1234567890123456789",
"provider": "google_drive",
"identity_email": "provisioning-pending",
"status": "provisioning",
"created": "2026-07-23 16:37:29 UTC",
"updated": "2026-07-23 16:37:29 UTC"
},
"authorize_url": "https://accounts.google.com/o/oauth2/v2/auth?...",
"instructions": "Open the authorization link to sign in and connect your own account."
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | Unsupported provider | Invalid provider name |
197845, 122987 or 108624 | 401 | This action requires workspace member permissions | Caller is below Member — on any of the four providers. One message, three call sites: 197845 when the permission level is below Member, 122987 when the caller has no user id, 108624 when the permission lookup itself failed |
147688 | 412 | Your plan does not include cloud connections. | The plan permits zero sync sources — settled; upgrade rather than retry |
1654 (Internal Error) | 500 | Maximum of 4 provider identities per user per workspace | Limit reached |
179470 | 503 | Identity provisioning in progress, please retry | Another provision for this owner is still in progress — retry shortly |
1700 (Forbidden) | 403 | Cloud sync is turned off for this organization. | The org or workspace cloud-sync policy resolves to enabled: false for the caller. params.reason = cloud_sync_disabled. See Cloud Sync Policy |
1693 (Temporarily Unavailable) | 503 | Cloud sync policy is temporarily unavailable. Please try again shortly. | The cloud-sync policy could not be read — retry shortly |
Complete a Browser OAuth Connect
/current/cloudsync/oauth/{provider}/complete/
Finishes a connection started by provision. {provider} is dropbox, onedrive, box or google_drive — all four use this endpoint.
After the user consents, the provider redirects the browser to a route on the app origin — https://go.{host}/imports/oauth/{provider}/callback — which is a front-end route, not an API one. That route reads code and state out of its own URL and posts them here, on the signed-in user's session. All four providers work exactly this way; there is deliberately no public callback endpoint for any of them, and the previous Dropbox one (GET /current/cloudsync/oauth/dropbox/callback/) has been removed.
The session is what binds the flow to a person. A state token identifies the connection, not the human holding it, so an authorize_url that gets forwarded to a colleague would otherwise attach their cloud account to your identity — whose owner could then read, and on a read_write source write, their files. Requiring the completion to arrive on the originating user's session closes that: anyone else gets 1680 (Access Denied).
Post the decline, too. When the user refuses consent the provider sends back an error instead of a code; post { "state": …, "error": … } so the identity is marked. Without it the identity sits provisioning with nothing coming to finish it.
Authentication is checked before the state is consumed, so a session that lapsed during a slow consent returns 401 with both the state and the code still redeemable — refresh the session and post the same pair once more. State tokens are single-use and short-lived (about ten minutes, matched to the provider's own code lifetime); a replayed, expired, tampered or unknown one is rejected.
Auth: JWT required. The caller must BE the user who started the connection.
Request Body (JSON)
| Field | Type | Required | Description |
|---|---|---|---|
| state | string | Yes | The state value the provider handed back |
| code | string | Conditional | The authorization code. Required unless error is sent |
| error | string | Conditional | The provider's error value when the user declined. Required unless code is sent |
Response (200 OK)
{
"result": true,
"identity": {
"id": "abc123...",
"profile_id": "1234567890123456789",
"provider": "onedrive_business",
"identity_email": "casey@contoso.com",
"status": "active",
"created": "2026-08-09 16:37:29 UTC",
"updated": "2026-08-09 16:38:04 UTC"
},
"connected": true,
"connect_error_code": null
}
connected and connect_error_code report the outcome directly, so the result does not have to be discovered by polling the identity afterwards.
| connect_error_code | Meaning |
|---|---|
null | Connected; identity is active |
declined | The user refused consent |
exchange_failed | The provider would not exchange the code |
account_read_failed | The code was exchanged, but the follow-up call asking the provider which account just connected failed. Distinct from exchange_failed because the sign-in itself worked; retrying is still the right suggestion |
credential_store_failed | The credential could not be stored |
activation_failed | The identity could not be activated |
insufficient_scope | OneDrive only. The tenant withheld a permission the importer cannot work without, so the signed-in user cannot fix it by retrying. The response also carries an admin_consent_url a tenant administrator can open |
scope_not_granted | Consent completed but omitted a permission the connection needs, and the user can fix it — for example, on Google Drive the user approved sign-in but left Drive access unticked. Reconnecting and approving that permission is the fix; no consent URL is returned |
account_mismatch | The consent was completed with a different cloud account than the one this identity was set up with. The reconnect is refused and the connection is not switched to the other account. To use another account, disconnect this identity and create a new connection |
legacy_app_only | The identity predates per-user sign-in and can no longer be used. It was created under the old model, where Fastio held a robot account in its own tenant. No path can give such a row a per-user credential, so it fails closed rather than erroring deep inside a sync. Clients should surface a reconnect prompt: disconnect and provision again to authorize your own account. Sources bound to it stop syncing until then |
Request faults — a bad state, the wrong caller, an unknown identity — are ordinary 4xx errors, not a connect_error_code.
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | Invalid JSON in request body | Body is not valid JSON |
1605 (Invalid Input) | 406 | Missing or invalid connection state | No state field |
1605 (Invalid Input) | 406 | This connection link has expired or was already used. Start the connection again. | Unknown, replayed, expired or tampered state |
1605 (Invalid Input) | 406 | Missing authorization code | Neither code nor error was sent |
1680 (Access Denied) | 401 | This connection was started by a different user. | The caller is not the user the state was issued to |
1680 (Access Denied) | 401 | This connection state does not match the connection it names. | The state and the identity it names disagree |
1609 (Not Found) | 404 | The connection being completed no longer exists. | The identity was removed while consent was open |
1664 (Datastore Error) | 500 | Failed to load the connection being completed. | The identity could not be read, before the authorization was redeemed |
1664 (Datastore Error) | 500 | The connection could not be verified after authorization; start a new connection. | The identity could not be read after the authorization was redeemed |
147688 | 412 | Your plan does not include cloud connections. | The plan permits zero sync sources. Raised before the authorization code is redeemed, so the code is unspent and nothing is stranded at the provider — settled; upgrade and start again from provision |
1610 (Internal Error) | 500 | This connection is no longer waiting to be completed. | The identity is no longer provisioning |
1700 (Forbidden) | 403 | Cloud sync is turned off for this organization. | The cloud-sync policy was switched off while consent was open. params.reason = cloud_sync_disabled. Raised before the authorization code is redeemed. See Cloud Sync Policy |
1693 (Temporarily Unavailable) | 503 | Cloud sync policy is temporarily unavailable. Please try again shortly. | The cloud-sync policy could not be read — retry shortly |
Three outcomes, and the MESSAGE tells them apart — the numeric code alone does not. The 404 is settled: the identity is genuinely gone, and reposting the same code and state cannot bring it back — start again from provision. A 1664 reading “Failed to load the connection being completed.” happens before the authorization is redeemed: the grant is still good and the code is unspent, so post the same code and state again. A 1664 reading “The connection could not be verified after authorization; start a new connection.” happens after the authorization has already been redeemed — the code is spent and reposting it will fail, so start again from provision. Reposting a spent authorization does not just fail; it marks the pending connection as failed, which is worse than doing nothing.
A completion that arrives for an identity already active is treated as success and returns the identity rather than an error — a double-posted callback is safe.
Revoke Provider Identity
/current/cloudsync/workspace/{workspace_id}/identities/{identity_id}/revoke/
Begins revocation of a provider identity. The provider-side deletion happens in the background; the endpoint returns immediately with status: "revoking". Poll the identity detail or list endpoint until status is "revoked". If a revocation is already in progress for this identity, the endpoint returns an error.
Auth: JWT required. Admin or Owner required.
Response (200 OK)
{
"result": true,
"identity": {
"id": "abc123...",
"profile_id": "1234567890123456789",
"provider": "google_drive",
"identity_email": "member@example.com",
"status": "revoking",
"created": "2026-07-23 12:00:00 UTC",
"updated": "2026-07-23 16:37:29 UTC"
}
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
176936 / 145733 | 503 | Identity revocation already in progress | Revoke already running for this identity — retry shortly |
168196 | 503 | This connection is busy. Try again in a moment. | Another operation on this identity (e.g. a concurrent OAuth completion) is still in progress — retry shortly |
117111 / 126939 | 409 | Identity is already revoked | Settled — this identity is already revoked, and retrying will never change that |
1654 (Internal Error) | 500 | Identity not found | Unknown identity ID |
Identity Statuses
| Status | Description |
|---|---|
provisioning | The user has not finished the browser consent yet, on any provider. Polling will not advance it on its own — a person has to complete a consent screen |
active | Identity is ready. On all four providers identity_email is the connected account's own address. OneDrive has one fallback: a Microsoft account that reports no address at all gets a synthetic label of the form onedrive-user:{16 hex characters} instead, so an active identity is never left showing a pending placeholder. Treat the value as a display label, not an address to route mail to |
error | Provisioning or the browser connect failed; the identity can be re-provisioned |
revoking | Revocation is in progress; poll until revoked |
revoked | Identity has been revoked; the identity can be re-provisioned |
List Identity Drives
/current/cloudsync/workspace/{workspace_id}/identities/{identity_id}/drives/
Lists the document libraries this identity can reach — the connected user's own OneDrive, plus the SharePoint libraries their account has access to. OneDrive for Business only — every other provider returns an error, because no other provider makes the caller choose a drive.
A OneDrive identity is one connected Microsoft account — work or school, or personal. A work or school account can usually reach several libraries with no meaningful default among them; a personal account has exactly one drive and no SharePoint. Which library an import uses is therefore a per-source choice, made from this catalog and fixed when the source is created.
This endpoint answers from stored rows and never contacts the provider. To rebuild the catalog, call the refresh endpoint below.
Auth: JWT required. The identity owner only — on every provider. Every catalog enumerates the connecting person's own cloud account, so a workspace admin browsing one would be reading that member's personal account; there is no admin override on any of the four. Admins keep every removal power (revoke the identity, disconnect or delete a source); what they cannot do is browse it. The same gate covers the discovery RESULT — polling a discovery job returns the folder listing, so it is owner-only too.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
site_path | string | No | Return only the libraries discovered through this SharePoint site path |
limit | int | No | Page size (default 50, max 100) |
offset | int | No | Page offset (default 0) |
Response (200 OK)
{
"result": true,
"identity_id": "abc123...",
"provider": "onedrive_business",
"drives": [
{
"drive_id": "b!X9tKqL3mEkO7nQfR2sVwYzA1bCdEfGhIjKlMnOpQrStUvWxYz0123456789AbCdEf",
"drive_type": "documentLibrary",
"name": "Documents",
"site_name": "Marketing",
"web_url": "https://contoso.sharepoint.com/sites/Marketing/Shared%20Documents",
"is_default": false,
"site_path": null
}
],
"drives_state": "ready",
"drives_refreshed_at": "2026-08-03 16:42:11 UTC",
"drives_error": null,
"requires_site_path": false,
"site_path": null,
"pagination": { "limit": 50, "offset": 0, "total": 1 }
}
site_name is worth rendering: nearly every SharePoint site has a library called “Documents”, so the library name alone is often ambiguous.
Branch on drives_state, not on the length of drives. An empty catalog is the NORMAL state on a first connection — nothing is enumerated until a refresh is asked for — so drives: [] on its own cannot say whether nobody has looked yet, an enumeration is running, access was refused, or the account genuinely reaches no libraries.
| drives_state | Meaning | What the caller does |
|---|---|---|
never_refreshed | Nobody has enumerated this identity yet | Call the refresh endpoint |
refreshing | An enumeration is queued or running | Poll until it changes |
ready | The catalog holds at least one library | Let the user pick one |
empty | The enumeration completed and found no libraries | The connected account reaches none; check the account, or name a site |
requires_site_path | The enumeration could not prove it saw everything reachable, and found nothing to offer | Refresh again with site_path |
permission_denied | Something the enumeration NAMED was refused | A site path will not fix this; the account needs access, or reconnect with fuller consent |
failed | The enumeration itself failed (provider outage, token error) | Retry; drives_error carries the detail |
requires_site_path is not a permission error. There is no Microsoft API that lists every site a signed-in user can reach — only a search that is permission-trimmed and not guaranteed exhaustive. The state means “we cannot prove this list is complete, and it is empty; name a site and we will look there”. It is raised only when nothing was refused, so it is genuinely distinct from permission_denied, which is always a refusal of something specific and is not fixed by supplying a path. A scoped refresh never produces it.
requires_site_path is a convenience alias of drives_state === "requires_site_path".
A partial consent is not an error. If the user grants access to their files but withholds the SharePoint permission, the connection succeeds and the identity becomes active — the catalog simply covers their own OneDrive and no site libraries. Treat that as a working connection with a smaller catalog, not a failed one.
drives_error is normally null on success, with ONE exception: a ready catalog that was cut off at an internal ceiling carries a truncation note. Those libraries are real and selectable — the list is merely incomplete — so show it as a warning beside a usable list rather than as a failure.
Note that ready and empty are derived from the rows returned in the same response, so the state and the list can never disagree. The other five describe how the last enumeration ENDED and are reported as recorded.
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | <Provider> does not use drive selection | Provider is not OneDrive for Business |
1609 (Not Found) | 404 | Provider identity not found for this workspace | Unknown identity, or one in another workspace |
1680 (Access Denied) | 401 | Only the owner of the connected cloud account can import its folders | Not the identity owner (a workspace admin is also refused) |
Refresh Identity Drives
/current/cloudsync/workspace/{workspace_id}/identities/{identity_id}/drives/refresh/
Re-enumerates the drive catalog from the provider. OneDrive for Business only.
Asynchronous, and it returns no job id. The response comes back immediately with drives_state: "refreshing"; poll GET .../drives/ until the state leaves refreshing. The state is recorded before the work is queued, so a client that reloads mid-refresh still sees refreshing on a cold read.
One refresh at a time per identity: a second call while one is genuinely in flight is rejected. That block expires on its own, so an interrupted refresh cannot lock the catalog permanently.
A refresh scoped with site_path replaces only the libraries belonging to that site and leaves the rest of the catalog alone, so an account whose sites have to be named one at a time can build its catalog across several calls. An unscoped refresh looks at the connected account's own OneDrive first, then the SharePoint sites it can find; it replaces only what it actually covered, so a run that found no sites does not delete the site libraries an earlier scoped refresh recorded.
An enumeration that fails part-way — a provider outage rather than a denial — leaves the previous catalog in place rather than replacing it with a partial one.
Auth: JWT required. The identity owner only — same rule as the list endpoint above, and for the same reason.
Request Body (optional)
{ "site_path": "contoso.sharepoint.com:/sites/Marketing" }
| Parameter | Type | Required | Description |
|---|---|---|---|
site_path | string | No | Enumerate one named SharePoint site instead of everything the account can find |
Response (200 OK)
{
"result": true,
"identity_id": "abc123...",
"provider": "onedrive_business",
"drives_state": "refreshing",
"drives_refreshed_at": "2026-08-03 16:40:02 UTC",
"requires_site_path": false,
"site_path": null,
"message": "Drive refresh started. Poll GET .../identities/abc123.../drives/ until drives_state leaves \"refreshing\"."
}
drives_refreshed_at is the PREVIOUS refresh's timestamp — this one has not finished — or null if none has ever completed. Use drives_state to detect a never-refreshed identity, not a null timestamp.
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | <Provider> does not use drive selection | Provider is not OneDrive for Business |
1609 (Not Found) | 404 | Provider identity not found for this workspace | Unknown identity, or one in another workspace |
1680 (Access Denied) | 401 | Only the owner of the connected cloud account can import its folders | Not the identity owner (a workspace admin is also refused) |
142434 | 503 | A drive refresh is already in progress for this identity | A refresh is in flight — retry shortly |
1610 (Internal Error) | 500 | Provider identity is not active | Identity is provisioning, revoked, or errored |
Selecting a Drive on a Source
drive_id is required when creating or discovering against a OneDrive for Business identity, and is not accepted for any other provider.
- Discover (
POST .../sources/discover/) takesdrive_idso the folders it returns come from the library the user actually chose. - Create (
POST .../sources/create/) takes the samedrive_id. It is validated against that identity's own catalog, so a caller cannot reach a library it was never granted by supplying an arbitrary id. The library's type, name and site name are recorded from the stored row, never from the request. - Update (
POST /current/cloudsync/details/{source_id}/update/) rejectsdrive_id. The library is part of what the source IS: repointing an existing source would make the recorded origin of every file it has already imported wrong. Create a second source instead.
Pass drive_id exactly as the drives endpoint returned it — it is an opaque provider string, not a Fastio id, and must not be reformatted or validated client-side.
Search indexing in that same view. The upsert_file entry of GET /current/workspace/{workspace_id}/jobs/status/ is an object (or null) carrying active, status (queued · processing · completed · failed), node_id, node_name, file_count, processed_count, failed_count, current_file, current_file_units_indexed, current_file_units_total, progress_percent, started_at, updated_at and completed_at. The two current_file_units_* fields are WITHIN-FILE progress in the units the indexer measured that file in (pages, for a document): a large document is ONE file indexed over several passes, so those two move while processed_count and progress_percent stand still. Both are always present and are null when nothing was measured and once the file is finished or failed. See the AI & Chat reference for the full field table and an example.
Who may do this. Creating a source is restricted to the identity owner for every provider, with no workspace-admin override: only the person whose cloud account is connected may graft its folders into a workspace, and they must hold Member on that workspace. For Dropbox and OneDrive for Business, discovery is owner-only as well — it browses that person's own cloud account. Admins are refused here on purpose, and keep every removal power: revoking an identity, and disconnecting or deleting a source, remain owner-or-admin.
| Error Code | HTTP Status | Reason | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | drive_required | OneDrive source without a drive_id |
1605 (Invalid Input) | 406 | drive_unknown | drive_id is not in this identity's catalog |
1605 (Invalid Input) | 406 | drive_not_supported | drive_id sent for a provider that does not use one |
1605 (Invalid Input) | 406 | drive_immutable | drive_id sent to the update endpoint |
1610 (Internal Error) | 500 | drive_lookup_failed | The catalog could not be read; retry |
Browsing Below the Provider Root
POST .../sources/discover/ accepts an optional remote_path: the remote folder to enumerate. Omit it — or send an empty string, or null — to enumerate the provider root, which is the historical behaviour and stays the default.
| Parameter | Type | Required | Description |
|---|---|---|---|
remote_path | string | No | Remote folder to enumerate, e.g. /Clients/Northwind. Omit for the provider root. Maximum 2048 characters, 4096 bytes overall, and 255 bytes for a single path component |
Discovery returns folders only, one level down. It does not walk the tree: a client expands a level at a time, passing the folder it wants to open back as remote_path on the next call. The response shape, the job id, and the polling endpoint are unchanged.
Two paths are two different questions, and they do not collide. A discovery already in flight blocks only another discovery of the same workspace, provider, drive and path — so /Clients and /Clients/Northwind can be browsed at the same time, while asking the same question twice is still deduplicated into the first.
If the same scope was tried recently and failed, and is still within its short retry window, the call answers 503 ("A recent discovery for this folder has not finished retrying. Try again shortly.") rather than 200. This is distinct from the in-flight case above — no job is currently running to hand back — and it is a normal, expected retry signal: wait briefly and call discover again.
The shape of the path is checked before the call is accepted; whether the folder exists is not. Characters, directory traversal and the length limits below are all applied synchronously, so a malformed remote_path comes back as 1605 straight away. Only the provider-side question — does this folder exist, and can it be listed — is deferred: that is reported by the discovery job, not by this endpoint, so the call returns 200 with a job id and that job finishes failed with the reason in its error_message. Check the job, not just the HTTP status.
Some folders exist in a cloud account but cannot be listed at all. When discovery can confirm one of these on a root listing, it leaves it out of the results rather than offering a dead end — OneDrive's "Personal Vault" is the known case. That check is best-effort, so an unconfirmed unlistable folder can still be offered like any other. Either way — offered and then chosen, or named directly with remote_path — the discovery job for that folder fails immediately, without retrying, and its error_message says the provider refused to list it. Such a folder cannot be imported. On Google Drive, a shortcut whose target has since been deleted is omitted from listings the same way.
remote_path is also an input on POST .../sources/create/, where it names the remote folder the source imports. On discover it names the folder to browse; the two are separate calls and neither implies the other.
What remote_path accepts, on BOTH calls. The two endpoints apply the same rule, so the picker can never offer a folder that create then refuses. A remote_path may be any name the provider can hold — Sales & Marketing, John's Files, Q3 Reports (Final), #general, 100% Done, emoji, and any script, in either Unicode normal form — except the characters providers themselves reject: *, ?, |, <, >, the null byte, and control characters. Directory traversal (.. as a whole component, in either slash direction) is refused. Length limits are 2048 characters overall, 4096 bytes overall, and 255 bytes for a single path component.
It is stored exactly as sent, including leading and trailing spaces. remote_path is an address, not a label: providers permit edge whitespace in a folder name, so trimming it would name a different folder than the one the listing offered. Post back the remote_path a discovery entry gave you, byte for byte. A value that is only whitespace counts as not given — on discover that enumerates the provider root, and on create it is refused as a missing field. remote_name, which is a display label rather than an address, is still trimmed.
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | Validation error | remote_path is longer than 2048 characters, longer than 4096 bytes overall, or has a single path component longer than 255 bytes. A missing, empty or whitespace-only remote_path is an error on sources/create/ only — on discover it is not an error at all, it enumerates the provider root |
1605 (Invalid Input) | 406 | Invalid remote path | remote_path contains a directory-traversal sequence, or one of * ? | < >, a null byte or a control character |
151157 | 503 | A recent discovery for this folder has not finished retrying. Try again shortly. | A discovery attempt for the same scope is already within its retry window |
Estimating Folders Before Connecting Them
POST /current/cloudsync/workspace/{workspace_id}/sources/estimate/ counts how many files and how many bytes up to 10 remote folders hold, so a picker can show whether each one fits the workspace plan before a source is created. It runs as a job, like discovery. It is not metered: no credits are charged for an estimate.
Auth: JWT required. The same rules as sources/discover/: the caller must be a member of the workspace and the owner of the chosen identity — there is no workspace-admin override. The identity must be active, cloud sync must be enabled for the workspace, and the endpoint is rate limited.
| Parameter | Type | Required | Description |
|---|---|---|---|
provider_identity_id | string | Yes | The identity whose cloud account holds the folders. Hyphenated or not |
remote_paths | array of string | Yes | 1 to 10 folder paths, e.g. ["/Clients", "/Archive/2025"]. A string holding a JSON array is also accepted. Each path is checked exactly like remote_path on discover and create (same characters, traversal and length rules, not trimmed). Exact duplicates are collapsed, first one kept. The whole list, JSON-encoded, may be at most 16 KB |
drive_id | string | OneDrive for Business only | Required for OneDrive for Business, as on discover; not accepted for other providers |
Response (200):
{
"result": true,
"job_id": "{job_id}",
"status": "estimating",
"message": "Estimate job created. Poll for results: GET /cloudsync/details/estimate/jobs/{job_id}/",
"drive_id": null,
"drive_type": null,
"drive_name": null,
"site_name": null
}
The drive fields are filled in for OneDrive for Business. Sending the same request again (same identity, same drive, same folders in the same order) while an estimate is still running returns that same job, in the same shape, with the message "An estimate for these folders is already running. Poll for results: GET /cloudsync/details/estimate/jobs/{job_id}/". A different order is a different request.
Polling. GET /current/cloudsync/details/estimate/jobs/{job_id}/ — the job endpoint with the fixed segment estimate where a source id would go, exactly as discovery uses discovery. Owner-only, like discovery polling. A discovery job id asked for under estimate, or an estimate job under discovery, answers not found. The response is { "result": true, "job": { ... } } with the standard job object: job_type is discovery with properties.discovery_kind set to estimate (that is how an estimate is told apart from a folder discovery), status is pending · running · completed · failed · canceled, and import_source_id is null. job.properties also carries provider, identity_id, drive_id (OneDrive for Business), estimate_paths (the folders asked about) and, once the job has completed, estimate_results and estimate_completed_at (YYYY-MM-DD HH:MM:SS UTC).
If the job as a whole cannot run — the identity was revoked, the workspace was closed, the organization's policy turned cloud sync off, or the plan could not be determined — it ends failed with the reason in error_message. Estimate jobs cannot be canceled, and they never appear in the workspace-wide jobs view.
estimate_results holds one row per folder, in the order requested:
{
"remote_path": "/Clients",
"file_count": 1250,
"total_size": 734003200,
"complete": true,
"stopped": null,
"counted_at": "2026-10-04 12:00:00 UTC",
"error": null
}
| Field | Type | Meaning |
|---|---|---|
remote_path | string | The folder, as requested |
file_count | integer | Files found anywhere under the folder. Folders themselves are not counted |
total_size | integer | Bytes, the sum of each file's size. A file whose provider reports no size (Google Docs, Sheets and Slides files) counts as 10 MiB |
complete | boolean | true when the whole folder was counted and the numbers are exact |
stopped | string | null | Why counting stopped early: limit or timeout. null when complete or on error |
counted_at | string | When the folder was counted, YYYY-MM-DD HH:MM:SS UTC |
error | string | null | Why this folder could not be counted. null otherwise |
Files and bytes are counted the same way the first sync counts a newly connected folder against the plan. The folder can change at the provider between the estimate and that sync.
complete: true— exact numbers;stoppedanderrorarenull.stopped: "limit"— the count passed the plan's per-folder file cap or size cap, and counting stopped there.file_countandtotal_sizeare the values reached, already over the cap. This folder will not fit the plan; connecting it would be refused at its first sync.stopped: "timeout"— the folder took longer than about two minutes to count. The numbers are partial and are a lower bound.errorset — the folder could not be counted;file_countandtotal_sizeare0,completeisfalseandstoppedisnull. The other folders in the same request are still counted. The message is one of: "folder not found at the cloud provider", "provider rate limited, try again shortly", "the cloud provider refused to list this folder", "the cloud provider refused access for the connected account", "this folder path cannot be read", "the folder listing could not be read completely, try again shortly", "the folder could not be counted, try again shortly".
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
178916 | 406 | Invalid JSON in request body | The body is not a JSON object |
162806 | 406 | provider_identity_id is required | Missing or empty provider_identity_id |
135552 | 406 | remote_paths must be a non-empty array of folder paths | remote_paths missing, empty, or not a list |
142495 | 406 | remote_paths accepts at most 10 folders | More than 10 entries |
101510 | 406 | Each remote_paths entry must be a non-empty folder path | An entry is not a string, or is empty or whitespace only |
191592 | 406 | The path validation message | An entry fails the remote_path rules above |
165485 | 406 | remote_paths is too long; estimate fewer folders per request | The encoded list is over 16 KB |
179221 | 404 | Provider identity not found for this workspace | No such identity in this workspace |
113609 | 500 | Provider identity is not active | The identity is revoked or not yet connected |
129651 | 500 | Import operations temporarily unavailable | The identity could not be read — retry |
114242 | 503 | A recent estimate for these folders has not finished retrying. Try again shortly. | The same estimate failed recently and is still inside its retry window |
176964 | 503 | Other estimates for this connection are still running. Try again shortly. | Two estimates on this identity are already running (for other folders); an identical request is answered with its running job instead |
178716 | 503 | Another estimate for this connection is being started. Try again shortly. | Another estimate request for the same identity was being submitted at the same moment; retry shortly |
148256 | 500 | Failed to create estimate job | The job could not be queued — retry |
139908 | 500 | Failed to resolve workspace profile | Internal failure |
drive_id errors are the same as on discover (see Selecting a Drive on a Source), and so are the owner-only refusal and the cloud-sync-disabled refusals.
Choosing Where an Import Lands
Two concurrent POST .../sources/create/ calls for the same workspace can briefly collide. If another source-creation request for this workspace is still in progress, the call answers 503 rather than creating a source or naming a field problem — wait briefly and send the same request again.
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
131035 | 503 | Import source creation is temporarily unavailable. Please try again. | Another source-creation request for this workspace is still in progress — retry shortly |
POST .../sources/create/ accepts an optional destination_node_id: the workspace storage folder the source's imported folder is created under. Omit it — or send an empty string, or null — and the import lands in the Imports system folder at the storage root, which is the historical placement and unchanged for every existing caller. Send root to place the import directly at the top level of the workspace instead, with no Imports folder. A client that always sends the key can therefore leave it null here — but not on the update endpoint below, which rejects the key whatever its value.
| Parameter | Type | Required | Description |
|---|---|---|---|
destination_node_id | string | No | Storage node id of an existing folder in this workspace, or root for the workspace's top level. Omit for the Imports system folder |
Pass the node id exactly as a storage endpoint returned it; the formatted (dashed) form is accepted.
- The node must exist in this workspace and must be a folder. It is resolved against this workspace's own storage, so an id from another workspace is simply not found here.
- Create-time only.
POST /current/cloudsync/details/{source_id}/update/rejectsdestination_node_id, on the key's presence rather than its value, and rejects it before applying any other field — a request carrying both a destination change and a legitimate one applies neither. Repointing a live source would leave every file it has already imported recorded against a location it is no longer in. The imported folder itself stays freely movable: move it in the workspace instead, and the sync follows it. - The source object carries the choice and its outcome — see Reading the destination back below.
root_node_idnames the folder the import actually created, and is populated once the first sync has run. - If the chosen folder has been trashed or deleted by the time the first sync runs, the import lands in the default
Importsfolder rather than failing, and the source recordsdestination_fallback_at. A degraded placement is visible and fixable; a failed first sync looks like a broken import. - An import's folder may not be created inside another import. A destination that is itself part of an import — an import's own folder, or anything beneath it — is rejected at create, with reason
destination_nested, before any source exists. Choose a folder outside every existing import. (Relatedly,POST /current/workspace/{workspace_id}/storage/{node_id}/move/refuses to move a cloud-sync folder inside another one.) - A destination the server could not verify is a retry, not a rejection. The nesting check walks the folder's ancestry; if that walk cannot complete, the call answers
1610 (Internal Error)with "Could not verify the chosen destination; please try again". That means undetermined, not nested — retry the same request rather than sending the user back to pick a different folder.
Reading the destination back. Every source object — from create, list, and details alike — carries two top-level fields, alongside drive_id. Both are also present inside properties.
| Field | Type | Meaning |
|---|---|---|
destination_node_id | string | null | The folder the caller chose at create; root means the workspace's top level. Null means the default Imports folder |
destination_fallback_at | string | null | UTC Y-m-d H:i:s. Set when the chosen folder could not be used at graft time and the import landed in Imports instead. Null means no fallback happened |
Read them as a pair. destination_node_id on its own is the folder the user asked for, and it reads as a successful placement — which is exactly the wrong conclusion when the import actually fell back. A non-null destination_fallback_at beside it means "you asked for that folder, you got the default, at this time". A client that renders only the first will tell the user their choice was honoured when it was not.
destination_fallback_at describes placement at graft time, not current location. The imported folder is freely movable afterwards, so treat it as a record of what happened when the import landed, not as a live assertion about where it is now. It is written once and never cleared.
A destination that does not resolve is deliberately indistinguishable from one that is not yours. A malformed id, a node that does not exist, a trashed node, and a node in another workspace all answer with the same message, the same code and the same reason — "The chosen destination folder was not found in this workspace" — so the endpoint cannot be used to probe which node ids exist elsewhere. Only "the node is a file, not a folder" answers differently, and that branch is reachable only for a node the caller can already list.
| Error Code | HTTP Status | Reason | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | destination_unknown | The id is malformed, or names a node that is missing, trashed, or in another workspace |
1605 (Invalid Input) | 406 | destination_unknown | The node is a file, not a folder ("The chosen destination must be a folder, not a file") |
1605 (Invalid Input) | 406 | destination_unknown | The value is not a string (an array or object was sent) |
1605 (Invalid Input) | 406 | destination_nested | The folder is itself part of an import ("That folder is already part of an import. Choose a folder outside it.") |
1605 (Invalid Input) | 406 | destination_immutable | destination_node_id sent to the update endpoint |
1610 (Internal Error) | 500 | (none) | The nesting check could not complete ("Could not verify the chosen destination; please try again"). Undetermined, not nested — retry |
Every 1605 rejection above is field-scoped: the response carries a detail naming destination_node_id, with kind: "invalid", the human message, a support code, and the stable reason. Branch on reason, never on the message text. The 1610 case is the one exception — it is a plain error with no field detail and no reason, because it reports that the destination could not be evaluated, not that it was bad. For that branch alone the numeric code 138825 is the discriminator, and it is contract-stable — it will not be merged into another call site. Treat it as retry the same request, never as pick a different folder.
Linking to the Folder at the Provider
Every source object — from create, list, details and update alike — carries remote_folder_web_url: a browser link to the folder the source mirrors, at the provider.
| Field | Type | Meaning |
|---|---|---|
remote_folder_web_url | string | null | Browser URL for the grafted folder at the provider. Null when no link can be built |
Null means exactly one thing: no valid stored ID-to-URL mapping can be built. Treat it as do not render the affordance — never as a broken or unhealthy source. Several unrelated situations produce it, and the field deliberately does not distinguish them, because none of them is something a user can act on:
- no folder id is stored yet. A source acquires one on a sync, so this covers a source created before the field existed until a later sync records one — it is not limited to sources that have never synced, and an existing source can gain a link without anything being done to it;
- the provider was found to publish no id for the folder at all, and the source is recorded as such;
- the provider publishes an id but has no public URL form that addresses a folder by it. Dropbox and OneDrive for Business are both in this category today and always answer null. Google Drive and Box return a link;
- the stored id does not have the shape that provider's ids have, so no URL is built from it rather than one that would not resolve;
- the connected account behind the source could not be read, so the provider is unknown and the id cannot be attributed to any URL form.
The link is built from the folder's id, never from remote_path. That is what makes it survive the folder being renamed or moved at the provider — a path-derived link would quietly open the wrong folder afterwards, which is worse than opening none. For the same reason, do not build your own provider URL out of remote_path: if this field is null, there is no link to show.
Do not parse the URL, and do not assume its shape is stable per provider. It is for opening, not for extracting ids from.
Recognising a Folder That Was Renamed or Moved
POST .../sources/discover/ returns one entry per folder, and each entry carries an already_imported flag. Compute nothing yourself: the flag needs every source on the identity, which the discovery response does not carry and the caller may not be entitled to enumerate. An absent flag does not read as unknown, it reads as false, and the picker then offers a folder that is already connected.
| Field | Type | Meaning |
|---|---|---|
remote_id | string | The provider's own id for the folder, or "" when the provider publishes none |
name | string | The folder's name — post this back as remote_name |
remote_path | string | Absolute remote path — post this back as remote_path |
type | string | folder or file |
size | integer | null | Null when the provider reports no size, which is normal for folders |
already_imported | boolean | True when this workspace already has a source for this folder |
overlaps_graft | boolean | True when this folder is not itself connected but contains, or sits inside, a folder that is. sources/create/ would refuse it (165964). Always false when already_imported is true |
already_imported follows the folder, not the path. Two keys are checked and a match on EITHER sets the flag — it is not an id check with a path check behind it. An entry is already imported when its remote_id matches the folder id of a source on the same drive, or when its remote_path matches a source's remote path. Both are proof, and neither absence disproves the other: a source that has an id can still be matched by path, and an entry whose remote_id is "" must still be matched by path. An empty value never matches an empty value on either key.
Renaming or moving a connected folder at the provider therefore keeps it marked as already imported, at its new path — which it previously did not, and a second source for the same folder could be created as a result.
The id key is scoped to the drive being listed; the path key is not. Provider item ids are unique within a drive rather than across drives, so an id is only compared against sources on the same drive — otherwise a folder in one library could be reported as already imported because an unrelated folder in another library happens to share its id, and you would be blocked from importing a folder you never imported. The path key is unscoped, which is long-standing behaviour: the same path on two libraries can mark each other as already imported even though sources/create would accept the second one.
Sources with no recorded id — everything created before the id existed, and everything on a provider that publishes none — are still matched by path exactly as before. Nothing that used to be recognised stopped being recognised.
Pass remote_id back when you create a source.
| Parameter | Type | Required | Description |
|---|---|---|---|
remote_id | string | No | The remote_id of the discovery entry the user chose |
It is optional and advisory: it can only make POST .../sources/create/ refuse a duplicate it would otherwise have admitted, never the other way round. Sending it lets the duplicate check recognise a folder that has been renamed or moved since it was connected — the case a path comparison alone cannot see. Omitting it leaves the check exactly as strict as it was, and sending one that matches nothing does not weaken the path check: the two keys are OR-ed there too, so a wrong value cannot be used to slip a duplicate past the refusal. The id is only compared against sources on the same drive, as on discovery — but the two are not otherwise identical: this endpoint scopes its path key to the drive as well, while discovery's path key is unscoped. So the same path in two libraries can read as already imported in a discovery response and still be accepted here. This endpoint is the authority on what is actually refused.
The value is not stored on the source and is not what the source is identified by. The server records the folder's id itself, from the provider, on the first sync.
A folder this workspace has already connected is refused. Two sources for one remote folder would each mirror it and each write back into it. Disconnected sources do not reserve their folder, so reconnecting a folder you previously removed keeps working. The same folder connected on a different drive is a different folder and is not refused.
The refusal spans the whole workspace, scoped to the connected cloud account. Each workspace member who connects a provider gets their own connection, so two members can be looking at the same cloud account through two of them. A folder one member has already connected is refused for the other, because it is one folder and two sources would each mirror and write back into it. Two different cloud accounts are not compared: if two members connect two separate Dropbox accounts, each may connect its own /Photos — those are different folders that happen to share a name.
A folder that contains, or sits inside, a connected folder is refused too. Connecting /Clients while /Clients/Northwind is already connected — or the other way round — would sync the same files into two places, and on a two-way source each would write back into the other's folder. The candidates are the same as for the duplicate check above: sources in this workspace on the same connected cloud account (and, for OneDrive for Business, the same library) that still hold their folder; a disconnected source does not count. Paths are compared ignoring letter case and leading or trailing slashes, one whole folder name at a time — /Foo does not overlap /Foobar. A source connected at the account root overlaps every folder. An exact match keeps the duplicate refusal (268832); only a strict ancestor or descendant gets 165964. Discovery flags these folders in advance with overlaps_graft.
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
268832 | 409 | "This folder is already connected to this workspace" | A live source anywhere in this workspace, on the same connected cloud account, already has this folder — matched on the folder's id or on remote_path. Settled — disconnect the existing source or pick another folder |
165964 | 409 | "This folder overlaps a folder already connected to this workspace." | The folder contains, or sits inside, a folder a live source on the same cloud account (and library) already has. Settled — disconnect that source or pick a folder outside it |
160255 | 503 | "Import source creation is temporarily unavailable. Please try again." | The already-connected folders could not be read, so neither check could be made. Retry |
Disconnecting a Source
POST /current/cloudsync/details/{source_id}/disconnect/ ends a source's sync relationship permanently. Owner-or-admin: the member who owns the connected cloud account, or a workspace admin (a workspace admin needs a sign-in session or an rwa credential on the workspace or its org — an unscoped API key does not qualify). The permission check runs before the “already disconnected” check below, so a caller without access learns nothing about the source's status from the answer.
| Parameter | Type | Required | Description |
|---|---|---|---|
action | string | Yes | keep leaves every imported file in the workspace as ordinary content; delete moves the files this source imported to the trash |
Neither action changes any FILE in the connected cloud account. Disconnecting ends the connection and delete trashes the workspace copies only. Nothing in the connected account's contents is added, changed or removed either way. Change notifications are registered per connected cloud ACCOUNT, not per folder — one registration serves every folder connected from that account, so disconnecting one folder does not tear it down, and the account's other connected folders keep receiving changes. The disconnected source simply stops: it reads disconnected, and nothing syncs or writes back for it again.
Response (200 OK)
A source of 1000 files or fewer is disconnected before the call returns:
{
"result": true,
"status": "disconnected",
"message": "Source has been disconnected successfully.",
"action": "delete",
"data_deleted": true
}
data_deleted is null on keep and true on delete. It is never false. A delete that did not happen is an error response — the endpoint does not answer 200 and then report in a field that it changed nothing.
A source with more than 1000 files disconnects in the background instead, answering status: "disconnecting" with a message and no action or data_deleted. Poll the source until it leaves disconnecting.
delete is REFUSED rather than guessing. The imported folder is checked in full before anything moves, and the delete is refused unless every item in it is proven to be this source's own import. How that refusal reaches you depends on which path ran. On a source of 1000 files or fewer the request itself is refused with 403 — nothing changes and the source is left connected exactly as the call found it, so the cause can be fixed and the same request sent again. On a source of more than 1000 files the call has already answered 200 with disconnecting, so the same refusal surfaces on the source instead: it parks at disconnect_pending carrying the reason, as described below. That check is a snapshot taken before any trashing begins, so it cannot speak for content added or moved into the folder while the operation is already running; on a folder being changed concurrently, disconnect once the changes have settled.
| Why | What to do |
|---|---|
| The folder holds an item somebody added or moved into it, which did not come from this connection | Move that item out of the imported folder, then retry |
| The folder holds an item belonging to a different connected folder | Deal with that connection separately, or move the item out |
| The folder holds an imported item whose connection cannot be identified | Move it out, or disconnect with keep |
| The imported folder itself is not this connection's, or no longer reads as imported | Nothing the API can fix — this connection cannot claim that folder. Read the keep caveat below before falling back to it |
| The folder is nested deeper than the check walks, or its structure loops back on itself | Flatten the folder, or disconnect with keep |
A 403 here is settled: the same request refuses identically until the folder changes, so never put it in a retry loop. The message names the item that blocked it where it can, but the wording is advisory text for a person — branch on the status.
keep is not a universally safe fallback from a refusal. It releases every imported item in the folder, including items belonging to another connected folder — those stop being imported too, and the connection that owns them stops tracking them. It is clean for content the user added themselves, which carries no import state to release. Offer it knowing that, rather than as an automatic retry.
A disconnect that could not be carried out no longer reports success. Any unproven cleanup — a refusal, or storage that did not answer — ends the request with an error and leaves the source connected and retryable, instead of recording it as disconnected with its files still in place. Callers that treated a 200 as final can keep doing so; what changed is that the failure cases are no longer 200.
On the background path (>1000 files) the usual equivalent is disconnect_pending: the source leaves disconnecting, does not reach disconnected, carries the reason in its error_message, and neither syncs nor writes back while it sits there. Send the same disconnect again once the cause is fixed — disconnect_pending accepts a retry, and disconnected does not. That is the normal settling point rather than a guarantee: a background disconnect that cannot record its own outcome can be left in disconnecting instead, so read the source's current status rather than assuming which one it reached.
A source left in disconnecting is recoverable — send the same disconnect again. The status on its own is no longer a refusal. disconnect is accepted whenever nothing is actually working on the source, which is what an abandoned background disconnect looks like, and it starts a fresh one. It answers 503 only while a disconnect is genuinely in progress, and that answer means what it says: retry shortly. So a disconnecting source that stays that way is not stuck — retry the disconnect rather than waiting.
| Error Code | HTTP Status | Cause |
|---|---|---|
173328 | 403 | delete refused: this source could not confirm every item in the folder is its own to remove. Nothing was trashed and nothing was disconnected. Settled — do not retry unchanged |
159808 | 500 | The cleanup could not be carried out, or could not be confirmed — including a folder whose contents could not be listed in full. Nothing was disconnected. Retry the same request |
153282 | 500 | The source could not be read while the disconnect held it. Nothing was changed. Retry the same request |
184326 | 503 | This source is already being disconnected — another disconnect is in flight. Retry shortly |
158130 | 406 | “Source is busy with another operation; please retry” — another operation holds the source. Retry shortly |
102628 | 406 | “A sync or disconnect job is already in progress for this source” — wait for the job to finish, then retry |
191762 | 409 | This source is already disconnected. Settled — retrying can never change that |
Confirming a Large Deletion
POST /current/cloudsync/details/{source_id}/refresh/
A sync mirrors deletions: files removed from the connected cloud folder are removed from the workspace too. A single run pauses for confirmation — instead of being applied — when it would remove an unusually large share of the connected folder: at least 10 files, and more than a quarter of the files the folder holds; or 1,000 files or more, regardless of the folder's size. A provider listing that comes back completely empty for a folder that previously held files always pauses, whatever the count. When a run pauses: nothing is deleted, the source is left in error, and its error_message names how many files the run would have removed and how many the folder holds, ending with “The person who connected this account can confirm the removal to let it proceed.” Every later sync stops in the same place, because a provider that under-reports its own listing looks exactly like a folder somebody emptied, and guessing wrong destroys content.
Removed files land in the workspace trash, not gone for good. A removal that proceeds — whether it was below the pause threshold or went through after confirmation — moves the files to the workspace's trash like any other delete, so they can be restored from there. Restoring a file that was removed from a read_write connected folder uploads it back to the cloud provider, the same as creating a new file in that folder would.
Read pending_removal on the list response, not error_message. GET .../workspace/{workspace_id}/sources/ carries a structured pending_removal field on each source — error_message is advisory text for a human and is not part of the contract. pending_removal is null when nothing is waiting for confirmation (the normal case), while a sync is queued or running, and for a stop that cannot be confirmed at all (see the fourth point below); otherwise:
"pending_removal": {
"remove_count": 6,
"folder_file_count": 16,
"files": ["Reports/q3.xlsx", "Old Drafts/"],
"files_truncated": false,
"detected_at": "2026-10-02 16:58:14 UTC",
"connector_owner": { "user_id": "2477739036763523387", "name": "Jane Doe" },
"can_confirm": true
}
| Field | Type | Meaning |
|---|---|---|
remove_count | integer | Files the stopped sync would remove — the number the confirmation is bound to |
folder_file_count | integer | null | Files the connected folder held when this was measured. Null for a stop recorded before this field existed |
files | list<string> | Up to 20 paths, relative to the connected folder, that would be removed. A folder that disappeared entirely is one entry ending in /, so files can be shorter than remove_count. May be empty for an older stop |
files_truncated | boolean | True when removed paths were left out of files (more than 20, or the list hit its size limit). A folder entry covering many files does not by itself make this true |
detected_at | string | null | When the stop was recorded, as YYYY-MM-DD HH:MM:SS UTC |
connector_owner | object | { "user_id": string | null, "name": string } — the person who connected the cloud account. user_id is null and name is "" when the owner cannot be resolved |
can_confirm | boolean | True only when the caller IS that connector owner, using a credential that can write |
This field is not present on GET .../details/{source_id}/ — read it from the list.
To let the removal through, repeat the refresh with the confirmation:
{ "acknowledge_large_delete": true }
The field is optional and the endpoint still accepts a request with no body at all. A plain refresh (no acknowledge_large_delete) stays owner-or-admin, like the rest of refresh/. Confirming the removal itself is the connector owner’s alone — there is no workspace-admin override. A caller who passes the usual refresh access check (owner or workspace admin) but is not the person who connected the account gets the first error below; a caller who fails that access check gets the endpoint’s ordinary access errors instead:
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
128584 | 403 | “Only the person who connected this cloud account can confirm removing these files” | The caller is not the connector owner |
177276 | 500 | “Import operations temporarily unavailable” | The connector-owner lookup failed transiently. Retryable |
After a confirmed refresh, pending_removal becomes null immediately. status stays error for a few seconds until the sync starts, then moves syncing → synced — or back to error with a new pending_removal if more files vanished in the meantime. The response shape of refresh/ itself does not change.
Five things about acknowledge_large_delete are worth knowing before you build on it:
- It confirms the number the caller was shown, not "delete whatever you find". The confirmation is bound to the count recorded when the sync was stopped. If the folder has lost substantially more by the time the confirmed run measures it, that run stops again and reports the NEW count — send the refresh again to confirm that one. Small movement in between is tolerated.
- It is spent by one sync. Nothing is stored on the connection, so the next scheduled sync is bounded exactly as before. Re-send it if a later run stops again.
- It only applies to a folder that is still stopped. The confirmation is matched against the folder's most recent finished sync. If that sync completed normally, there is nothing left to confirm and the request is treated as an ordinary refresh — a folder that recovered on its own is not carrying an old confirmation forward, and a confirmation given for one stopped run can never be spent on a different one later.
- It cannot clear every stopped sync. When the run could not measure the folder completely, no exact count exists and the confirmation is ignored: the message says the deletion could not be judged rather than naming a number. Those clear when the underlying read succeeds, not by confirming.
- Confirm with a person, never automatically. This authorises deleting content from the workspace. Show the count and the folder to the connector owner (
pending_removal.connector_owner) before sending it, and never wire it into a retry loop.
Reading a Source Against Its Plan Limits
Each connected folder is limited by the workspace plan in two ways: how many files it may hold and how many bytes. Every source object from the list, details and sources/create/ responses carries both caps and where the source stands against them:
| Field | Type | Meaning |
|---|---|---|
object_limit | integer | null | The plan's file cap for one connected folder |
object_limit_state | string | null | ok, approaching (at or above 90% of the cap) or exceeded (above the cap), from file_count |
size_limit | integer | null | The plan's size cap for one connected folder, in bytes |
size_limit_state | string | null | ok, approaching or exceeded, the same way, from total_size |
limit_exceeded | string | null | Which cap the most recent sync refused this source on: objects or size. null otherwise |
The two caps and the two states are null on a response that carries no plan context (the update/ response); limit_exceeded is present everywhere.
limit_exceeded is the refusal itself, not arithmetic. When a sync is refused because the folder is over a cap, the source goes to status: "error" with an error_message, and limit_exceeded names the cap. The matching object_limit_state or size_limit_state then reads exceeded even if file_count or total_size is still 0 — which is the normal case for a newly connected folder refused before anything was imported. limit_exceeded is set only while the source is still in that error: it is null again as soon as the source leaves status: "error" or its error_message changes to a different reason — when the next sync starts, succeeds, fails for another reason, or the source is suspended, paused or disconnected. Upgrading the plan does not clear it by itself: limit_exceeded and the matching exceeded state remain until the source leaves the refusal state — normally when the next sync of that source starts — even though the new plan's caps are already reported in object_limit / size_limit.
size_limit_state can read lower than what the next sync compares. total_size includes a file with no provider-declared size (a Google Docs, Sheets or Slides file) only once it has actually been imported, while the sync's own check counts each such file it has never imported as 10 MiB. Use the estimate endpoint (see Estimating Folders Before Connecting Them) to check a folder before connecting it.
Source and Job Lifecycle Refusals
The source and job lifecycle endpoints — update/, refresh/, delete/ and jobs/{job_id}/cancel/ — each have a state precondition, and a request that violates one is answered 409 Conflict, never a 5xx. The exception is the two update/ busy refusals, which answer 406 and are retryable (see below). This is the same rule the write-back endpoints already follow, applied to the other half of the surface; these used to answer 500, which told clients to retry a request whose outcome had nothing to do with a server fault.
Gate on the HTTP status. The error.code values below are the ones actually serialized, and they identify the call site rather than the reason — several share a message, and the message is what tells the cases apart.
| Endpoint | error.code | HTTP Status | Message |
|---|---|---|---|
update/ (action: pause) | 187554 | 409 | Source can only be paused when synced, in error state, or suspended by policy or workspace deletion |
update/ (action: pause) | 156588 | 409 | Source cannot be paused from its current state |
update/ (action: resume) | 112266 | 409 | Source can only be resumed when paused |
update/ (action: resume) | 122558 | 409 | Source cannot be resumed from its current state |
update/ (any field) | 144246 | 406 | Source is busy with another operation; please retry |
update/ (any field) | 199777 | 406 | A sync or disconnect job is already in progress for this source |
refresh/ | 192986 | 409 | Source must be in synced or error state to refresh |
delete/ | 172501 | 409 | Cannot delete source in active state. Disconnect or wait for completion first. |
sources/create/ | 181494 | 409 | Provider identity is not active |
sources/create/ | 268832 | 409 | This folder is already connected to this workspace |
sources/create/ | 165964 | 409 | This folder overlaps a folder already connected to this workspace. |
sources/create/ | 134664 | 412 | Import source limit reached for your plan |
sources/create/ | 134248 | 503 | Your plan could not be determined right now. Please try again shortly. |
sources/create/ | 105962 | 403 | Cloud sync is turned off for this organization. (params.reason = cloud_sync_disabled — see Cloud Sync Policy) |
sources/create/ | 178341 / 178934 | 503 | Cloud sync policy is temporarily unavailable. Please try again shortly. |
jobs/{job_id}/cancel/ | 130927 | 409 | Only pending jobs can be canceled; a running job cannot be stopped once it has started |
jobs/{job_id}/cancel/ | 125572 | 409 | Same message — the job started between reading it and cancelling it |
jobs/{job_id}/cancel/ | 104099 | 409 | A disconnect in progress cannot be canceled; wait for it to finish. |
One row here is transient, not a refusal. sources/create/ answers 134248 → 503 when the workspace's plan could not be read at all. Nothing about the request is wrong and nothing is settled: send the same request again shortly. It is the same answer the connect endpoints give for the same condition, and it is deliberately distinct from the 412 plan limit above — gate on the HTTP status first, exactly as you would there.
update/ now refuses while a sync or disconnect owns the source, and this is a change to a shipped endpoint: pausing, resuming, renaming and changing the interval used to be accepted at any moment. They were not safe — a settings change made during a source's first sync overwrote the sync's own progress, leaving the connection wedged in a state nothing could move it out of. The two refusals answer 406, matching disconnect/, which has always refused the same way for the same reason. Both are transient: re-read the source with source-details, wait for the job to finish, and send the same request again.
None of these is answered by repeating the request immediately — but they are not all permanent. Two kinds are mixed together here, and the difference decides what a client should do:
- Settled for good. A
disconnectjob is never cancellable, at any status; a running job is never cancellable; a folder already connected stays connected until someone disconnects it; and a plan limit is lifted by an upgrade. Resending the same request unchanged cannot ever succeed — change the request, or change the account. - Settled for now.
delete/,refresh/andupdate/refuse because of the state the source is in at this moment — a source that is syncing or discovering will finish, and the same call is then valid. These clear on their own.
In both cases: do not tight-loop. Re-read the source with source-details (or the job) and act on the state it is actually in; where the state is one that transitions, wait for the transition rather than polling the refusing endpoint. Some rows are not a 409 at all: the plan limit answers 412, and its fix is an upgrade rather than a different request; the unreadable plan answers 503, and its fix is to send the same request again shortly. The cloud-sync policy rows split the same way: 105962 → 403 is settled until an admin changes the policy, while 178341 / 178934 → 503 means the policy could not be read and is worth sending again shortly.
A running sync job cannot be cancelled. Cancel applies only while the job is still pending, and it is applied as a compare-and-swap on that status — so a job that starts between reading it and cancelling it answers 409 rather than having its record rewritten. Once a sync is running it runs to completion; there is no way to stop a transfer in flight, and the previous behaviour only made the audit record say otherwise.
A disconnect job is never cancellable, at any status. Cancelling one does not stop the teardown — it removes the record that something owns it, and leaves the source stuck mid-disconnect. Wait for it to finish.
A 500 still appears on these endpoints and now means what it says: a datastore read or write failed, a profile could not be resolved, or a job could not be queued. Those are worth retrying. A state refusal is not.
Write-Back Queue
Write-back is the return direction of cloud sync. A change made to an imported file inside Fastio — an edit, a new version, a delete — is pushed back out to the folder at the provider, so the connected cloud account ends up holding the same content.
FOLDER changes propagate too, and the QUEUEING is all-or-nothing. Trashing a folder inside a read_write connected folder removes every file beneath it that this connection imported from the connected cloud account as well, not only from the workspace; restoring the folder re-uploads them, undoing the removals. Moving a folder OUT of the connected folder is treated the same way as trashing it, because those files have left the connection — the workspace keeps them as ordinary content, and the provider copies are removed. Each of these queues one write-back per imported file beneath the folder, created together: if the folder's contents cannot be listed completely, nothing at all is queued and the change stays local, which is the safe direction. That queueing pass performs no removals itself; rows it already queued carry their own jobs and complete on their own; a pass that could not queue every descendant is retried before it executes anything. What follows is not a transaction at the connected account. Once queued, each removal completes on its own and is retried until it lands, so a large folder can be briefly half-removed at the provider while the rest catch up. The folder CONVERGES rather than flipping in one step — read the write-back list if you need to know when it has settled. Files under a nested connected folder, files that were never imported, and files belonging to a different connection are left alone in every case. Restoring an earlier version of a file, and copying a file into a connected folder, also reach the provider. Tell a user this before they trash or move a folder inside a read_write connection: it reaches their own cloud account, not just this workspace.
Notes DO take part in cloud sync, in both directions. A note is Markdown — a .md node holding ordinary stored content — so it mirrors to the provider as its own .md object. A note you create or edit inside a read_write synced folder is pushed, queues a write-back like any file, and appears in the connected cloud account as a .md file; trashing one propagates the delete, and an edit made to that .md at the provider is pulled back into the note. The TYPE mapping, however, is one-way, and that is the part not to assume: a .md arriving from the provider with no matching note already in the workspace is imported as an ordinary file, never converted into a note — so the Markdown in a synced code or docs folder stays files rather than becoming collaborative notes.
A provider edit that is not valid note content is refused, and the sync stays healthy. Notes cap at 100 KB and must be markdown, so if the .md at the provider grows past that, or is replaced with something binary, the note keeps its previous content and the provider copy is left untouched. This is not a sync failure — the source does not go into error over it, and the rest of the folder keeps syncing — so a note that has quietly stopped tracking its provider file is something to look for rather than something you will be alerted to. Fix it at the provider and the next sync picks it up.
It is off by default and enabled per source: a source only writes back while its access_mode is read_write. A read_only source imports and never writes — but a write-back it already queued is held, not discarded; see below.
Most write-backs are created for you. Changing an imported file queues one automatically; there is no endpoint to call for the ordinary case. The endpoints in this section exist for the cases automation cannot settle by itself — forcing a push, retrying one that failed, deciding a conflict, and cancelling one that has not run yet.
Reading the queue is member-level; every write action is not. A push, a retry, a keep_local resolve — each of these runs the transfer under the cloud credential of the member who connected the source's identity, writing into that person's own cloud account. So the write actions require that identity's owner, or a workspace admin; anyone below that is refused with 1680 (Access Denied). The identity owner needs only the write scope documented above; a workspace admin additionally needs an admin-capable credential — a sign-in session, or a token holding rwa on the workspace or its org — and is otherwise refused scope_admin_required (see Transfer Workspace Ownership above). Listing and reading a job need only ordinary view access to the workspace.
Timestamp format change. created, updated and remote_mtime_before are emitted as YYYY-MM-DD HH:MM:SS UTC — the canonical API datetime format. They previously omitted the UTC suffix. A client that parses these fields with a fixed pattern must accept the suffix.
Status lifecycle
The lifecycle is pending → uploading → completed | failed | conflict | canceled.
| status | Meaning | What the caller does |
|---|---|---|
pending | Queued; the transfer has not started | Wait, or cancel it |
uploading | The transfer is running | Wait. It cannot be retried, resolved or cancelled from here |
completed | The provider holds the change | Nothing — final |
failed | The attempt ended in an error | Read error_message, fix the cause, then retry |
conflict | The copy at the provider changed as well, so nothing was written | Resolve it keep_local or keep_remote, or cancel it |
canceled | Ended by a caller, or by the source losing write access | Nothing — final |
Two of those are not the end of the story. failed returns to pending when you retry it, and conflict returns to pending when you resolve it. completed and canceled are final: no endpoint accepts a job in either state.
canceled does not only arrive from uploading. Cancelling acts directly on a pending or conflict job, and a job is also cancelled for you when the write-back route itself goes away for good — the source is disconnected or deleted, the connected identity is revoked, the member who owns it loses access to the workspace, or the workspace is closed. A canceled job you did not cancel means the route went away, not that the transfer failed.
Flipping a source to read_only HOLDS its queued write-backs instead of cancelling them. A pending job stays pending; the route resumes automatically, with no re-queue needed, as soon as the source is switched back to read_write. The hold is bounded: a write-back still held after about 5 days ends failed instead of waiting indefinitely, with properties.terminal_reason: "policy_hold_expired" — retry-writeback accepts it from there, so the edit is never silently lost. This is a reversible policy choice, not a loss of the route: disconnecting the source, deleting it, a member removal, or a revoked connection still cancel outright, exactly as above.
A workspace on a temporary hold does NOT cancel its queued write-backs — they wait. A workspace or org that is locked, suspended or under review is on a hold that ends, so its queued jobs stay pending and the work is held rather than discarded. They are re-checked periodically and go out on their own once the hold is lifted; there is nothing to retry and nothing to re-queue. This is the difference between a hold and a closure: a closed workspace cancels, a suspended one waits. A job held this way can still reach its retry ceiling if the hold lasts long enough, and it then ends failed — which retry accepts, so the edit is never silently lost.
An edit made while a job is already uploading gets its own job. A queued pending job absorbs later edits to the same file, because it reads the file's current content when it runs. An uploading one cannot: the transfer has already read the content it is going to send, so a newer edit gets a fresh pending job queued behind it. Expect to see two jobs for one file in that window — one uploading and one pending — and expect the second to absorb any further edits rather than adding a third.
Do not treat conflict as an error state. It is a decision waiting on a person: both copies changed, and only the caller knows which one is right. Nothing has been overwritten at either end while a job sits there.
The write-back object
Every endpoint in this section returns this shape.
| Field | Type | Meaning |
|---|---|---|
id | string | The write-back job's id |
import_source_id | string | The source this job belongs to |
node_id | string | The imported file being written |
profile_id | string | The workspace that owns the source |
remote_path | string | Absolute path of the object at the provider |
operation | string | upload (a local create, edit or new version) or delete (a local delete) |
status | string | One of the six above |
file_size | integer | Size of the local file in bytes |
bytes_uploaded | integer | Bytes transferred so far; equal to file_size once completed |
remote_mtime_before | string | null | The provider's modification time for the object as it stood when the job was created — the baseline the conflict check compares against. Null until it has been captured |
error_message | string | null | Why the job failed or conflicted |
retry_count | integer | Attempts spent on the current queue entry; a retry resets it to 0 |
properties | object | See below |
created | string | YYYY-MM-DD HH:MM:SS UTC |
updated | string | YYYY-MM-DD HH:MM:SS UTC |
error_message is advisory text for a human. It is reduced to a safe summary before it leaves the API, its wording is not part of the contract, and it is not what a client should branch on — branch on status, and on the error responses documented below.
operation is part of a job's identity, not a detail of it. An upload and a delete for the same file are two independent jobs and neither blocks the other, so one file can legitimately have two live write-backs at once.
properties publishes only what a caller can act on. Internal transfer state is not included, so {} is the ordinary value for an automatically created job.
| Key | Value | When present |
|---|---|---|
triggered_by | manual_push | Only on a job created by the push endpoint — its absence is how you tell an automatic job from a forced one |
resolution | keep_local | keep_remote | Only after a conflict on this job has been resolved |
List Write-Back Jobs
/current/cloudsync/details/{source_id}/writebacks/
Lists the write-back jobs recorded for one source, newest first. There is no status filter and no sort control — read the page and filter client-side on status.
Auth: JWT required. Any workspace member with view access; the source's owner is not required.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {source_id} | string | Yes | The import source's id |
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| limit | int | No | Page size (default 50, max 200) |
| offset | int | No | Page offset (default 0) |
Both are clamped, never rejected: a limit above the maximum returns the maximum, and a limit of 0, a negative number, or a value that is not a number returns 1. A negative offset reads as 0. So a malformed page request returns a small page rather than an error, and a client that wants to validate its own paging must check what came back.
Response (200 OK)
{
"result": true,
"writebacks": [
{
"id": "al3eo-t5elp-ltput-g5a62-m3pmo-wian",
"import_source_id": "axk7q-5ljws-dv2eb-z4aux-dsrpd-ki5u",
"node_id": "2vqwd-zclih-azwk2-i6ay3-3bdrw-qa3p",
"profile_id": "1234567890123456789",
"remote_path": "/Marketing/Q3 Report.docx",
"operation": "upload",
"status": "conflict",
"file_size": 184320,
"bytes_uploaded": 0,
"remote_mtime_before": "2026-04-27 16:37:29 UTC",
"error_message": "The copy at the provider changed since this write-back was queued",
"retry_count": 0,
"properties": {},
"created": "2026-04-27 16:38:02 UTC",
"updated": "2026-04-27 16:41:15 UTC"
}
],
"pagination": { "limit": 50, "offset": 0, "total": 1 }
}
pagination.total is the number of items in writebacks, not the number of jobs the source has. It cannot be used to size a progress bar or to decide how many pages exist. Page until a page comes back shorter than limit.
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | "Source ID is required" | No source id in the path |
1609 (Not Found) | 404 | "Import source not found" | Unknown source, or one already deleted |
1680 (Access Denied) | 401 | "Insufficient permissions to access this resource" | Not a member of the source's workspace |
1680 (Access Denied) | 401 | "Cloud import features are not enabled for this workspace" | Cloud sync is switched off for the workspace |
1680 (Access Denied) | 401 | "Cloud import features are not available on your current plan" | Cloud sync not included in the plan |
10560 (Access Denied) | 403 | "Your token does not have sufficient scope for this Workspace." | A scoped credential whose scope does not cover the source's workspace |
1610 (Internal Error) | 500 | "Failed to retrieve write-back jobs" | The queue could not be read |
Write-Back Job Details
/current/cloudsync/details/{source_id}/writebacks/{writeback_id}/
Returns one write-back job. This is the endpoint to poll while a job is live, and the endpoint to re-read after any refusal — the job's current status is what tells you whether a refusal was settled or transient.
Auth: JWT required. Any workspace member with view access.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {source_id} | string | Yes | The import source's id |
| {writeback_id} | string | Yes | The write-back job's id |
Response (200 OK)
{
"result": true,
"writeback": {
"id": "al3eo-t5elp-ltput-g5a62-m3pmo-wian",
"import_source_id": "axk7q-5ljws-dv2eb-z4aux-dsrpd-ki5u",
"node_id": "2vqwd-zclih-azwk2-i6ay3-3bdrw-qa3p",
"profile_id": "1234567890123456789",
"remote_path": "/Marketing/Q3 Report.docx",
"operation": "upload",
"status": "completed",
"file_size": 184320,
"bytes_uploaded": 184320,
"remote_mtime_before": "2026-04-27 16:37:29 UTC",
"error_message": null,
"retry_count": 0,
"properties": { "triggered_by": "manual_push" },
"created": "2026-04-27 16:38:02 UTC",
"updated": "2026-04-27 16:39:44 UTC"
}
}
A job belongs to exactly one source. Asking for a valid job id under the wrong source is refused rather than answered, so the pair must match.
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | "Source ID is required" | No source id in the path |
1605 (Invalid Input) | 406 | "Write-back ID is required" | No write-back id in the path |
1609 (Not Found) | 404 | "Import source not found" | Unknown source, or one already deleted |
1609 (Not Found) | 404 | "Write-back job not found" | Unknown write-back id |
1609 (Not Found) | 404 | "Write-back job does not belong to this source" | The job exists, but under a different source |
1680 (Access Denied) | 401 | "Insufficient permissions to access this resource" | Not a member of the source's workspace |
10560 (Access Denied) | 403 | "Your token does not have sufficient scope for this Workspace." | A scoped credential whose scope does not cover the source's workspace |
Push a File Back to the Provider
/current/cloudsync/details/{source_id}/writebacks/push/{node_id}/
Queues a write-back for one imported file immediately, without waiting for a change to trigger one. Use it to re-send content the provider is known to be missing or stale on, or to force the current content out after a failure has been dealt with by other means.
The job is created pending, with operation: upload — a manual push is always an upload, never a delete — and properties.triggered_by set to manual_push. It returns as soon as the job is queued; it does not wait for the transfer.
Auth: JWT required. The owner of the source's connected identity, or a workspace admin (a workspace admin needs a sign-in session or an rwa credential on the workspace or its org; the identity owner needs only write scope). The transfer runs under the owner's cloud credential.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {source_id} | string | Yes | The import source's id |
| {node_id} | string | Yes | The imported file to push |
No request body.
{node_id} must name an imported FILE or NOTE, never a folder. This is worth stating plainly because a folder inside a synced folder is an imported node just as much as a file is, so a folder's id is a perfectly plausible thing to send. It is refused at request time — 409 Conflict, error.code 166884, "Node is not an imported file or note" — rather than accepted with a 200 and a queued job that could only fail later. There is no "push this folder" operation; push each file. A note’s id is accepted. A note is Markdown — a .md node holding ordinary stored content — so it mirrors to the provider as its own .md object, and pushing one queues a write-back exactly as pushing a file does.
The node must also be under this source and still carry its import metadata. A node that belongs to another source, or that sits outside the folder this source imported, is refused 404 — the id is not addressable through this source.
Response (200 OK)
{
"result": true,
"writeback": {
"id": "al3eo-t5elp-ltput-g5a62-m3pmo-wian",
"import_source_id": "axk7q-5ljws-dv2eb-z4aux-dsrpd-ki5u",
"node_id": "2vqwd-zclih-azwk2-i6ay3-3bdrw-qa3p",
"profile_id": "1234567890123456789",
"remote_path": "/Marketing/Q3 Report.docx",
"operation": "upload",
"status": "pending",
"file_size": 184320,
"bytes_uploaded": 0,
"remote_mtime_before": null,
"error_message": null,
"retry_count": 0,
"properties": { "triggered_by": "manual_push" },
"created": "2026-04-27 16:38:02 UTC",
"updated": "2026-04-27 16:38:02 UTC"
}
}
The response is 200, not 201.
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | "Source ID is required" | No source id in the path |
1605 (Invalid Input) | 406 | "Node ID is required" | No node id in the path |
1605 (Invalid Input) | 406 | "Invalid node ID format" | The node id is not a well-formed id |
1660 (Conflict) | 409 | "Write-back is only available for read-write import sources" | The source's access_mode is read_only |
1660 (Conflict) | 409 | "Node is not an imported file or note" | A folder, a node that is not imported, or one with no import metadata |
1609 (Not Found) | 404 | "Import source not found" | Unknown source, or one already deleted |
1609 (Not Found) | 404 | "Node not found" | Unknown node id |
1609 (Not Found) | 404 | "Node does not belong to this import source" | The node is imported, but by a different source |
1609 (Not Found) | 404 | "Node is not under this import source" | The node is outside the folder this source imported |
1660 (Conflict) | 409 | "This node already has a write-back in progress" | Transient — another live job already covers this file. See below |
1693 (Temporarily Unavailable) | 503 | "Another write-back action for this node is in progress" | Retryable — a concurrent action holds this file's turn |
1693 (Temporarily Unavailable) | 503 | "Could not check whether this node already has a write-back in progress" | Retryable — the already-covered check could not be carried out, so the request was never evaluated |
1693 (Temporarily Unavailable) | 503 | "The write-back lock for this node expired before the change was applied" | Retryable — this file's turn was held and lapsed before the job was created. Nothing was written, so re-sending cannot queue it twice |
1680 (Access Denied) | 401 | "This action requires the source owner or a workspace admin" | Neither the identity owner nor a workspace admin |
10767 (Forbidden) | 403 | "Your credential is not authorized for administrative operations on this Workspace." | The caller is a workspace admin acting on another member's source, but the credential is not admin-capable — no sign-in session, no rwa on the workspace or its org (scope_admin_required) |
10560 (Access Denied) | 403 | "Your token does not have sufficient scope for this Workspace." | A scoped credential whose scope does not cover the source's workspace |
1700 (Forbidden) | 403 | "Cloud sync is read-only here, so changes are not pushed back to the provider." / "Cloud sync is turned off for this organization." | The cloud-sync policy does not allow write-back for the caller. params.reason = cloud_sync_read_only or cloud_sync_disabled. Settled until an admin changes the policy. See Cloud Sync Policy |
1693 (Temporarily Unavailable) | 503 | "Cloud sync policy is temporarily unavailable. Please try again shortly." | Retryable — the cloud-sync policy could not be read |
1610 (Internal Error) | 500 | "Failed to create write-back job" | The job could not be queued |
The permission check runs before the access-mode check. A member who is neither owner nor admin, pushing to a read_only source, is told about their permissions rather than about the access mode — so a caller without access, or one whose credential's scope doesn't cover the workspace, learns nothing about the source's mode from the answer.
Three of the refusals above share one error.code — "Node is not an imported file or note", "Node does not belong to this import source" and "Node is not under this import source". They are distinguished by HTTP status (409 versus 404), never by the numeric code. This is the general rule for this API: numeric codes are assigned per call site and are for diagnostics, not for branching.
Retry a Failed Write-Back
/current/cloudsync/details/{source_id}/writebacks/{writeback_id}/retry/
Puts a failed job back in the queue. The job returns to pending, its error_message is cleared, and retry_count resets to 0 because the new attempt starts a fresh budget.
Retrying does not change anything about the transfer. If the cause was a permission the connected account does not have, or a provider that rejects the file, the retry will fail the same way. Fix the cause first — a write-back that failed because the connected account lacks permission to write that folder is a permanent failure, and only granting the permission makes a retry worthwhile.
Auth: JWT required. The owner of the source's connected identity, or a workspace admin (a workspace admin needs a sign-in session or an rwa credential on the workspace or its org; the identity owner needs only write scope).
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {source_id} | string | Yes | The import source's id |
| {writeback_id} | string | Yes | The write-back job's id |
No request body.
Precondition: status must be failed. Any other status is refused.
Response (200 OK)
The job, as re-queued — status: "pending", error_message: null, retry_count: 0, under the writeback key.
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | "Source ID is required" | No source id in the path |
1605 (Invalid Input) | 406 | "Write-back ID is required" | No write-back id in the path |
1609 (Not Found) | 404 | "Import source not found" | Unknown source, or one already deleted |
1609 (Not Found) | 404 | "Write-back job not found" | Unknown write-back id |
1609 (Not Found) | 404 | "Write-back job does not belong to this source" | The job exists, but under a different source |
1660 (Conflict) | 409 | "Only failed write-back jobs can be retried" | Settled — the job is not in failed, or it left failed while the request was being handled |
1660 (Conflict) | 409 | "This node already has a write-back in progress" | Transient — another live job already covers this file. See below |
1693 (Temporarily Unavailable) | 503 | "Another write-back action for this node is in progress" | Retryable — a concurrent action holds this file's turn |
1693 (Temporarily Unavailable) | 503 | "Could not check whether this node already has a write-back in progress" | Retryable — the already-covered check could not be carried out |
1693 (Temporarily Unavailable) | 503 | "The write-back lock for this node expired before the change was applied" | Retryable — this file's turn was held and lapsed before the re-queue. Nothing was written, so the job is still failed and re-sending cannot re-queue it twice |
1664 (Datastore Error) | 500 | "Failed to load import source" / "Failed to load write-back job" | Retryable — the record could not be read |
1680 (Access Denied) | 401 | "This action requires the source owner or a workspace admin" | Neither the identity owner nor a workspace admin |
10767 (Forbidden) | 403 | "Your credential is not authorized for administrative operations on this Workspace." | The caller is a workspace admin acting on another member's source, but the credential is not admin-capable — no sign-in session, no rwa on the workspace or its org (scope_admin_required) |
10560 (Access Denied) | 403 | "Your token does not have sufficient scope for this Workspace." | A scoped credential whose scope does not cover the source's workspace |
1700 (Forbidden) | 403 | "Cloud sync is read-only here, so changes are not pushed back to the provider." / "Cloud sync is turned off for this organization." | The cloud-sync policy does not allow write-back for the caller. params.reason = cloud_sync_read_only or cloud_sync_disabled. Settled until an admin changes the policy. See Cloud Sync Policy |
1693 (Temporarily Unavailable) | 503 | "Cloud sync policy is temporarily unavailable. Please try again shortly." | Retryable — the cloud-sync policy could not be read |
1610 (Internal Error) | 500 | "Failed to retry write-back job" | The job could not be re-queued |
Resolve a Write-Back Conflict
/current/cloudsync/details/{source_id}/writebacks/{writeback_id}/resolve/
Decides a conflict: the file changed in Fastio and the copy at the provider changed too, so the write was held rather than applied. Resolving re-queues the job to pending with the decision recorded, and clears error_message and retry_count.
Auth: JWT required. The owner of the source's connected identity, or a workspace admin (a workspace admin needs a sign-in session or an rwa credential on the workspace or its org; the identity owner needs only write scope).
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {source_id} | string | Yes | The import source's id |
| {writeback_id} | string | Yes | The write-back job's id |
Request Body (JSON)
| Parameter | Type | Required | Description |
|---|---|---|---|
| resolution | string | Yes | keep_local or keep_remote |
| resolution | What happens |
|---|---|
keep_local | The Fastio copy wins. The job is re-queued and pushes it out, overwriting the changed copy at the provider — the conflict check is skipped for this one attempt, which is the whole point of the choice |
keep_remote | The provider's copy wins. The remote version is pulled down over the Fastio copy, and nothing is written outward |
The value is case-sensitive, surrounding whitespace is ignored, and any other value is refused. keep_local discards the other side's change at the provider, and keep_remote discards the local one — put the choice in front of a person rather than defaulting it.
Precondition: status must be conflict.
Response (200 OK)
The job, as re-queued — status: "pending", properties.resolution set to the value you sent, under the writeback key.
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | "Source ID is required" | No source id in the path |
1605 (Invalid Input) | 406 | "Write-back ID is required" | No write-back id in the path |
1605 (Invalid Input) | 406 | "Invalid JSON in request body" | The body was not a JSON object |
1605 (Invalid Input) | 406 | resolution must be "keep_local" or "keep_remote" | Missing, misspelled, or a different value |
1609 (Not Found) | 404 | "Import source not found" | Unknown source, or one already deleted |
1609 (Not Found) | 404 | "Write-back job not found" | Unknown write-back id |
1609 (Not Found) | 404 | "Write-back job does not belong to this source" | The job exists, but under a different source |
1660 (Conflict) | 409 | "Only conflicting write-back jobs can be resolved" | Settled — the job is not in conflict, or it left conflict while the request was being handled |
1660 (Conflict) | 409 | "This node already has a write-back in progress" | Transient — a different live job already covers this file. See below |
1693 (Temporarily Unavailable) | 503 | "Another write-back action for this node is in progress" | Retryable — a concurrent action holds this file's turn |
1693 (Temporarily Unavailable) | 503 | "Could not check whether this node already has a write-back in progress" | Retryable — the already-covered check could not be carried out, so the request was never evaluated |
1693 (Temporarily Unavailable) | 503 | "The write-back lock for this node expired before the change was applied" | Retryable — this file's turn was held and lapsed before the re-queue. Nothing was written, so the job is still conflict and re-sending cannot re-queue it twice |
1664 (Datastore Error) | 500 | "Failed to load import source" / "Failed to load write-back job" | Retryable — the record could not be read |
1680 (Access Denied) | 401 | "This action requires the source owner or a workspace admin" | Neither the identity owner nor a workspace admin |
10767 (Forbidden) | 403 | "Your credential is not authorized for administrative operations on this Workspace." | The caller is a workspace admin acting on another member's source, but the credential is not admin-capable — no sign-in session, no rwa on the workspace or its org (scope_admin_required) |
10560 (Access Denied) | 403 | "Your token does not have sufficient scope for this Workspace." | A scoped credential whose scope does not cover the source's workspace |
1700 (Forbidden) | 403 | "Cloud sync is read-only here, so changes are not pushed back to the provider." / "Cloud sync is turned off for this organization." | keep_local only — the cloud-sync policy does not allow write-back for the caller. params.reason = cloud_sync_read_only or cloud_sync_disabled. keep_remote writes nothing to the provider and is never refused by policy. See Cloud Sync Policy |
1693 (Temporarily Unavailable) | 503 | "Cloud sync policy is temporarily unavailable. Please try again shortly." | keep_local only. Retryable — the cloud-sync policy could not be read |
1610 (Internal Error) | 500 | "Failed to resolve write-back conflict" | The job could not be re-queued |
The already-covered check on resolve excludes the job being resolved. A conflict job is itself live, so a check that counted it would refuse every resolve. Resolving is therefore never blocked by its own row — only by a different live write-back on the same file, for the same operation, which is what a later edit or a retry can put there while the conflict waits for a decision.
Cancel a Write-Back
/current/cloudsync/details/{source_id}/writebacks/{writeback_id}/cancel/
Ends a job that has not run yet, or one waiting on a conflict decision. The job moves to canceled and nothing is written to the provider.
Auth: JWT required. The owner of the source's connected identity, or a workspace admin (a workspace admin needs a sign-in session or an rwa credential on the workspace or its org; the identity owner needs only write scope). Cancelling runs no transfer and needs nobody's cloud credential — the same gate is applied to every write action on this queue so that one rule covers the whole surface.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {source_id} | string | Yes | The import source's id |
| {writeback_id} | string | Yes | The write-back job's id |
No request body.
Precondition: status must be pending or conflict. A job already uploading cannot be cancelled — the transfer is in flight, and there is no point at which it could be stopped cleanly. Wait for it to reach a terminal status.
Response (200 OK)
The job, with status: "canceled", under the writeback key.
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
1605 (Invalid Input) | 406 | "Source ID is required" | No source id in the path |
1605 (Invalid Input) | 406 | "Write-back ID is required" | No write-back id in the path |
1609 (Not Found) | 404 | "Import source not found" | Unknown source, or one already deleted |
1609 (Not Found) | 404 | "Write-back job not found" | Unknown write-back id |
1609 (Not Found) | 404 | "Write-back job does not belong to this source" | The job exists, but under a different source |
1660 (Conflict) | 409 | "Only pending or conflicting write-back jobs can be canceled" | Settled — the job is uploading, or already terminal |
1693 (Temporarily Unavailable) | 503 | "Another write-back action for this node is in progress" | Retryable — a concurrent action holds this file's turn |
1693 (Temporarily Unavailable) | 503 | "The write-back lock for this node expired before the change was applied" | Retryable — this file's turn was held and lapsed before the cancel was applied. Nothing was written — the job is untouched and still cancellable |
1664 (Datastore Error) | 500 | "Failed to load import source" / "Failed to load write-back job" | Retryable — the record could not be read |
1680 (Access Denied) | 401 | "This action requires the source owner or a workspace admin" | Neither the identity owner nor a workspace admin |
10767 (Forbidden) | 403 | "Your credential is not authorized for administrative operations on this Workspace." | The caller is a workspace admin acting on another member's source, but the credential is not admin-capable — no sign-in session, no rwa on the workspace or its org (scope_admin_required) |
10560 (Access Denied) | 403 | "Your token does not have sufficient scope for this Workspace." | A scoped credential whose scope does not cover the source's workspace |
1610 (Internal Error) | 500 | "Failed to cancel write-back job" | The job could not be cancelled |
Cancel has no already-covered check. It removes a live job rather than creating one, so whether the file is already covered is irrelevant to it — the two refusals about coverage that push, retry and resolve can return do not appear on this endpoint.
Reading a Write-Back Refusal
This is the part clients get wrong, so it is worth stating on its own. Refusals on the four write endpoints fall into three groups, and only two of the three are worth sending again.
| Answer | Meaning | What to do |
|---|---|---|
1693 (Temporarily Unavailable) → 503 | This file's exclusive turn could not be taken, could not be used to check coverage, or lapsed before the change was applied. Nothing was written, whichever of the three it was | Retry. Back off briefly and send the same request again, unchanged — it cannot apply the change twice |
1660 (Conflict) → 409, "This node already has a write-back in progress" | Another live job already covers this file | Re-read and wait. It clears on its own. Do not repost blindly |
1660 (Conflict) → 409, any state precondition | The job is not in a status this action accepts | Settled. Resending the same request unchanged can never succeed |
Not every 409 on this surface is settled, which is the opposite of the general rule for conflicts elsewhere in this API. The already-covered refusal is the exception, and treating it as permanent means abandoning work that would have succeeded a moment later.
A cloud-sync policy refusal is a separate answer again. Push, retry and a keep_local resolve refuse with 1700 (Forbidden) → 403 and params.reason = cloud_sync_read_only or cloud_sync_disabled when the org or workspace policy does not allow write-back — settled until an admin widens the policy, so do not retry it. A "Cloud sync policy is temporarily unavailable" 1693 → 503 is retryable like the other 503s. Cancel and a keep_remote resolve are never refused by policy. See Cloud Sync Policy.
Why the 503s happen at all. Push, retry, resolve and cancel each take a short exclusive turn on the file they act on, so two write-back actions for one file can never interleave. Three distinct things can go wrong with that turn. All three answer 1693 → 503, and on all three nothing was written:
| Message | What actually happened | Which endpoints |
|---|---|---|
| "Another write-back action for this node is in progress" | The turn could not be taken — another action is holding it right now | push, retry, resolve, cancel |
| "Could not check whether this node already has a write-back in progress" | The turn was taken, but the already-covered check could not be carried out, so the request was never evaluated on its merits | push, retry, resolve |
| "The write-back lock for this node expired before the change was applied" | The turn was taken and held, then lapsed before the change was applied. The turn is short-lived and is re-proved immediately before the write; it had expired by then, so the request stopped there | push, retry, resolve, cancel |
These are three different facts, not three phrasings of one: could not start, started but could not look, started and lost the turn before writing. Only the last one implies the request got as far as being ready to write — and it still wrote nothing, so re-sending it cannot queue, re-queue or cancel anything twice. All three are ordinary transient conditions that a plain retry fixes.
Cancel has no already-covered check — it removes a live job rather than creating one, so coverage is irrelevant to it and the middle row above cannot occur there.
Live means pending, uploading or conflict — those are the statuses that make a file "already covered". A completed, failed or canceled job does not block anything. And because operation is part of the match, a pending upload does not block a delete for the same file.
Telling the two 409s apart.
- On retry, re-read the job. If it is still
failed— the status retry requires — the refusal was the transient one, and waiting will clear it. Any other status means the refusal was settled and the job has moved on without you. - On resolve, re-read the job the same way. Still
conflictmeans the refusal was the transient one; any other status means it was settled. The check excludes the job being resolved, so a resolve is never blocked by its own row — only by a different live write-back that appeared on the same file while the conflict was waiting for a decision. - On push there is no job id to re-read, so the refusal itself is the discriminator. An access-mode refusal, or one saying the node is not an imported file or note, is settled. Only "This node already has a write-back in progress" clears as the queue drains — list the queue for that source and wait for the covering job to reach a terminal status.
- On cancel there is only one 409 to read — the state precondition — and it is settled.
In short: 1693 → 503, retry it; a 1660 saying the node is already covered, re-read and wait; a 1660 on a state precondition, settled — never retry.
Two outcomes worth planning for
A permission refusal from the provider is permanent. If the connected cloud account cannot write to the folder, every attempt fails identically. Retrying is wasted; the fix is to grant the account write access at the provider, and only then retry.
Renaming or moving an imported file inside Fastio leaves a duplicate at the provider. The write-back creates the file at its new remote path and deliberately does not delete the object at the old one — an unattended delete of a customer's cloud object is the more dangerous of the two failure modes. Expect the old copy to remain, and remove it at the provider if you do not want it.
↑ Back to top