cirrux

Drive API

Manage a user’s Drive over JSON: list, upload, download, replace, organize, trash and delete files and folders, read who an item is shared with, create and revoke public links, fetch thumbnails, and keep an external client in sync with a delta changes feed. All endpoints are under https://api.cirrux.info/public_api/v1/drive.

Every Drive endpoint needs a user-scoped OAuth access token (see Authentication). Files belong to a person, so workspace API keys are rejected. Each endpoint states the scope it requires (drive.read, drive.create, drive.update or drive.delete). File contents are encrypted at rest with a per-file key: the simple upload/download routes handle encryption server-side, while the chunked routes let a client encrypt and stream ciphertext directly.

The file object

FieldTypeRequiredDescription
objectstringnoAlways drive_file.
uuidstringnoUnique identifier of the file.
folder_uuidstringnoParent folder. Left out of the object (not null) for a file at the Drive root.
namestringnoFile name, unique among live siblings in its folder, ignoring Unicode form and case: café typed composed or decomposed, and Café and CAFÉ, are one name. It is stored and returned exactly as you sent it. Surrounding whitespace is trimmed; at most 255 characters, no slashes or control characters, and not "." or "..".
content_typestringnoMIME type.
file_size_bytesnumbernoPlaintext size in bytes.
upload_statusstringnouploading while a chunked upload is still in progress, completed once the file is downloadable. Folder listings include files that are still uploading.
trashedbooleannoWhether the file is in the trash. Trashed items are deleted for good after 30 days.
has_previewbooleannoWhether a server-rendered thumbnail is available (see Thumbnails). Images only for now; other types are always false. It turns true a little after upload, once the preview is rendered (which bumps version), and goes back to false when the contents are replaced.
versionnumbernoMetadata version: a per-item counter incremented on every change (rename, move, trash, and so on).
ctagnumbernoContent version. 0 for a newly uploaded file, and one higher after each completed replace. Renames, moves and trash leave it alone.
created_atstringnoISO-8601 timestamp.
updated_atstringnoISO-8601 timestamp.
content_modified_atstring | nullnoYour own content modification date, as you last sent it, in UTC to the microsecond. null when none was sent. See content modification date.
created_byobject | nullnoWho uploaded the file, with the same fields as owner. null if that account no longer exists.
updated_byobject | nullnoWho last changed the file’s content or details (name, folder, trash), with the same fields as owner. Changes Cirrux makes itself, such as rendering a preview, don’t count. null when not known: the file was last changed before Cirrux kept this, or the account no longer exists.
sole_authorobject | nullnoThe one person who wrote every version of the content, with the same fields as owner. null once someone else has uploaded new content, or for a file whose content was replaced before Cirrux kept this. When sole_author.self is true you may delete the file permanently and move it out of someone else’s folder, even as an editor.
my_rolestringnoYour effective role on this item: viewer, commenter, editor, manager or owner. An item inside a shared folder inherits the folder’s role. It is none when you no longer have access, which you can see in the answer to a move that took the item out of your reach. The changes feed never sends an item you can no longer see: it sends a delete instead.
sharedbooleannoWhether a share was made directly on this item (a share root). Read with owner.self to tell “shared by you” (owner.self is true) from “shared with you” (false).
ownerobjectnoWho owns the item. Fields below.
owner.selfbooleannoWhether you are the owner.
owner.first_namestring | nullnoOwner’s first name, when set.
owner.last_namestring | nullnoOwner’s last name, when set.
owner.display_namestring | nullnoOwner’s full name, when set.

Use version as the item’s metadata version and ctag as its content version (e.g. File Provider’s metadataVersion / contentVersion). Keying the content version off version would re-download the whole file on every rename; ctag avoids that.

content_modified_at is the date your client says the content was last changed (Finder’s modification date, which macOS apps check before they save). Cirrux stores it and hands it back unchanged, and never sets it on its own. Send it as an ISO-8601 timestamp with a zone (Z or an offset); it comes back in UTC with six decimals. Set it when you create a file, when you replace its contents, or on its own with POST /v1/drive/files/{uuid}. It is not used to detect changes: rely on ctag for that, and on updated_at for when Cirrux last wrote the item.

{
  "object": "drive_file",
  "uuid": "a1d9e7c8-2222-4f6a-9b1e-000000000002",
  "folder_uuid": "b3f1c2a4-1111-4a2b-8c3d-000000000001",
  "name": "april-2026.pdf",
  "content_type": "application/pdf",
  "file_size_bytes": 502998,
  "upload_status": "completed",
  "trashed": false,
  "has_preview": false,
  "version": 2,
  "ctag": 0,
  "created_at": "2026-06-19T09:13:40.000Z",
  "updated_at": "2026-06-19T11:02:17.000Z",
  "content_modified_at": "2026-06-19T09:12:58.418237Z",
  "my_role": "owner",
  "shared": true,
  "owner": {
    "self": true,
    "first_name": "Jane",
    "last_name": "Doe",
    "display_name": "Jane Doe"
  }
}

The folder object

FieldTypeRequiredDescription
objectstringnoAlways drive_folder.
uuidstringnoUnique identifier of the folder.
parent_uuidstringnoParent folder. Left out of the object (not null) for a folder at the Drive root.
namestringnoFolder name, unique among live siblings, with the same rules as a file name.
colorstring | nullnoFolder color as a #RRGGBB hex string, or null for none.
trashedbooleannoWhether the folder is in the trash. Trashed items are deleted for good after 30 days.
versionnumbernoPer-item counter, incremented on every change.
created_atstringnoISO-8601 timestamp.
updated_atstringnoISO-8601 timestamp.
created_byobject | nullnoWho created the folder, with the same fields as owner. null if that account no longer exists.
updated_byobject | nullnoWho last changed the folder (name, color, parent, trash), with the same fields as owner. null when not known: the folder was last changed before Cirrux kept this, or the account no longer exists.
my_rolestringnoYour effective role on this item: viewer, commenter, editor, manager or owner. An item inside a shared folder inherits the folder’s role. It is none when you no longer have access, which you can see in the answer to a move that took the item out of your reach. The changes feed never sends an item you can no longer see: it sends a delete instead.
sharedbooleannoWhether a share was made directly on this item (a share root). Read with owner.self to tell “shared by you” (owner.self is true) from “shared with you” (false).
ownerobjectnoWho owns the item. Fields below.
owner.selfbooleannoWhether you are the owner.
owner.first_namestring | nullnoOwner’s first name, when set.
owner.last_namestring | nullnoOwner’s last name, when set.
owner.display_namestring | nullnoOwner’s full name, when set.

What your role lets you do: viewer and up can read and download. editor and up can upload into a folder, rename, move, trash, restore and replace contents. Permanently deleting needs manager or owner, or editor on something you alone wrote: a file whose sole_author is you, or a folder you created holding only such files and folders. That keeps an app’s lock and swap files deletable inside a share, while a document a colleague has edited stays protected. Anything else is 403 owner_only; move it to the trash instead. Public links need manager or owner. A commenter can do what a viewer can. A file uploaded into a folder someone shared with you is owned by that folder’s owner, and lives in that folder’s workspace.

Every item has the same owner and workspace as the folder it is in. Moving your own item into someone else’s folder gives it to that folder’s owner, with everything in it, as if it had been created there; the item keeps its uuid, and its storage counts against the new owner. You can likewise take something you alone wrote (see sole_author) out of someone else’s folder, and it becomes yours. An item that has shares or a public link of its own cannot change owner this way: 409 has_own_shares. Moving anything else out of its owner’s folders, to your own root or into a folder you own, is 403 owner_mismatch. Moving between workspaces is 403 cross_workspace. Moving or trashing the shared folder itself (the item the share was made on) needs manager or owner, otherwise 403 share_root. Creating, uploading or moving into a folder that is in the trash, or under one that is, is 409 parent_trashed. These refusals carry an item field: the item as Cirrux has it after refusing, so you can put back a change you already made locally (null if you can no longer see it).

Files

List files & folders

GET/v1/drive/files

Lists the immediate children of a folder. Pass folder_uuid as a query parameter to list a folder’s contents; omit it to list the Drive root. Trashed items are excluded. An unknown or invisible folder is 404 folder_not_found. Scope drive.read.

FieldTypeRequiredDescription
folder_uuidstringnoQuery parameter. The folder to list; omit for the root.
curl "https://api.cirrux.info/public_api/v1/drive/files?folder_uuid=b3f1c2a4-…" \
  -H "Authorization: Bearer $CIRRUX_TOKEN"
{
  "object": "list",
  "url": "/v1/drive/files",
  "folder_uuid": "b3f1c2a4-1111-4a2b-8c3d-000000000001",
  "folders": [ { "object": "drive_folder", "uuid": "…", "name": "Receipts", … } ],
  "files":   [ { "object": "drive_file",   "uuid": "…", "name": "april-2026.pdf", … } ]
}

List trash

GET/v1/drive/trash

Lists everything currently in the trash: every trashed (but not yet permanently deleted) folder and file you can see, flat across the whole workspace. Trash is a special folder, so the response mirrors the /v1/drive/files envelope. Restore an item with POST /v1/drive/files/{uuid} or /v1/drive/folders/{uuid} and trashed: false. Scope drive.read.

curl https://api.cirrux.info/public_api/v1/drive/trash \
  -H "Authorization: Bearer $CIRRUX_TOKEN"
{
  "object": "list",
  "url": "/v1/drive/trash",
  "folders": [ { "object": "drive_folder", "uuid": "…", "name": "Old receipts", "trashed": true, … } ],
  "files":   [ { "object": "drive_file",   "uuid": "…", "name": "draft.pdf",     "trashed": true, … } ]
}

Retrieve a file

GET/v1/drive/files/{uuid}

Returns a single drive_file. Scope drive.read.

Upload a file

POST/v1/drive/files

Uploads a file whose bytes you send inline as base64. Cirrux encrypts and stores it. This route buffers the whole file in memory, so it is capped at 100 MB. Use the chunked flow below for larger files. Returns 201 with the created drive_file. Scope drive.create.

FieldTypeRequiredDescription
namestringyesFile name.
content_typestringyesMIME type.
datastringyesStandard base64-encoded file contents, with padding. Empty for a 0-byte file.
folder_uuidstring | nullnoDestination folder; null or omitted for the root.
content_modified_atstringnoYour own content modification date.
curl -X POST https://api.cirrux.info/public_api/v1/drive/files \
  -H "Authorization: Bearer $CIRRUX_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "hello.txt",
    "content_type": "text/plain",
    "data": "aGVsbG8gd29ybGQ=",
    "folder_uuid": null
  }'

Download a file

GET/v1/drive/files/{uuid}/download

Returns the decrypted contents alongside the file name, type and size. data is URL-safe base64 without padding. Like the simple upload, this buffers in memory and is capped at 100 MB (413 file_too_large), so use the chunked download for larger files. Scope drive.read.

{
  "uuid": "a1d9e7c8-…",
  "name": "hello.txt",
  "content_type": "text/plain",
  "size": 11,
  "data": "aGVsbG8gd29ybGQ"
}

Large files (chunked, client-side encryption)

For files above the in-memory cap, up to 2 GiB, upload and download ciphertext directly to storage in chunks. Cirrux hands you a per-file data_key (base64, over TLS) and presigned URLs; your client does the AES-256-GCM chunk encryption itself, so plaintext never touches Cirrux. All steps use scope drive.create (upload) or drive.read (download). Presigned URLs are valid for one hour; ask for them again if they run out. An upload that is not completed within 24 hours is discarded, and so is a replace: init returns the deadline as expires_at, and after it part_urls and complete answer 409 upload_expired (abort still works). When another person’s upload blocks the file, 409 upload_in_progress carries that upload’s expires_at, the latest moment it can still be holding the file.

Wire format. The object is a 16-byte header followed by the chunks. The header is the 8 magic bytes CXDRV2 plus two zero bytes, the format version (1 byte, 1), the algorithm id (1 byte, 1 for AES-256-GCM), chunk_size_bytes as a little-endian u32, and 2 zero bytes. It goes in front of the first chunk. Each chunk is a fresh 12-byte IV, the AES-256-GCM ciphertext of up to chunk_size_bytes of plaintext, and the 16-byte tag. Every chunk is authenticated with 56 bytes of additional data (AAD): the file’s file_uuid as 16 raw bytes, then the chunk index, the chunk count and that chunk’s plaintext size as little-endian u32s, then zero padding. Always the file_uuid, never the version_uuid, including for a replace: the file keeps its UUID across versions. Complete decrypts the first chunk to check this, and rejects an upload it cannot decrypt.

Upload

POST/v1/drive/uploads

Initiate. Body: name, content_type, plaintext_size_bytes (all required) and optional folder_uuid and content_modified_at. Returns file_uuid, version_uuid, data_key, chunk_size_bytes (5 MiB), chunk_count, plaintext_size_bytes, ciphertext_size_bytes, an s3_upload_id and expires_at. A 0-byte file is finished on the spot: the answer carries upload_status: "completed" and there are no parts to upload.

POST/v1/drive/uploads/{uuid}/part_urls

Get presigned PUT URLs. Body: part_numbers (array of integers from 1 to 10000). Returns { "part_urls": [{ "part_number", "url" }] }. PUT each encrypted chunk to its URL and keep the ETag response header.

POST/v1/drive/uploads/{uuid}/complete

Finalize. Body: parts (array of { part_number, etag }). Flips the file to upload_status: "completed" and returns the drive_file. If the bytes that arrived do not add up to ciphertext_size_bytes, the upload is discarded with 422 upload_size_mismatch. If the first chunk does not decrypt with the file’s key and AAD (see the wire format above), it is discarded with 422 undecryptable_content.

POST/v1/drive/uploads/{uuid}/abort

Cancel an in-progress upload and discard the staged parts. Only the user who started an upload can continue or abort it; for anyone else it is 404.

Download

GET/v1/drive/files/{uuid}/download_init

Returns file_uuid, data_key, algo, chunk_size_bytes, chunk_count, plaintext_size_bytes, ciphertext_size_bytes and a presigned download_url. Fetch ciphertext byte ranges from the URL and decrypt each chunk with the key. A file that has not finished uploading is 422 upload_not_completed.

Replace a file's contents

Upload new bytes for an existing file, keeping its UUID. It works like the chunked upload above, but targets a file: Cirrux stages a new version while the current one stays live and downloadable, then swaps atomically on complete. The previous version is retained about 24 hours afterward, so a download already in progress never breaks. The file’s ctag goes up by one. It needs the editor role, and only growth over the current size counts against the workspace’s storage. All steps use scope drive.update.

POST/v1/drive/files/{uuid}/replace

Initiate. Body: plaintext_size_bytes (required, at most 2 GiB) and optional content_type, which keeps the file’s current type when left out, and optional content_modified_at, which becomes the file’s date when the new content goes live on complete (without one, the date is cleared, since it described the old content). Returns the same fields as the chunked upload above, including version_uuid. Only one upload may be in flight per file (a second returns 409 upload_in_progress), and only a completed file can be replaced (409 file_not_replaceable). Only the person who started a replace can get its part URLs, complete it or abort it. Anyone else with edit rights gets 409 upload_in_progress, and a viewer gets 403 not_authorized.

A plaintext_size_bytes of 0 empties the file on the spot, like a 0-byte upload: the answer carries upload_status: "completed", the file’s ctag has already moved on, and there are no parts to upload or complete to call.

To avoid overwriting a change made elsewhere, send optional base_ctag: the ctag of the version your edit is based on. If the file has moved on since, init returns 409 version_conflict with the current ctag in the body, and nothing is staged. Read the file again and retry. Without base_ctag, a replace always wins.

POST/v1/drive/files/{uuid}/replace/part_urls

Get presigned PUT URLs, as for the chunked upload. With nothing in flight it returns 409 no_upload_in_progress.

POST/v1/drive/files/{uuid}/replace/complete

Finalize. Body: parts (array of { part_number, etag }). Promotes the new version and returns the updated drive_file. Content that does not decrypt with the file’s file_uuid in the AAD is discarded with 422 undecryptable_content, and the current version stays live.

POST/v1/drive/files/{uuid}/replace/abort

Cancel an in-progress replacement and discard the staged version.

Update a file

POST/v1/drive/files/{uuid}

Send name to rename and/or folder_uuid to move (include the key with a null value to move to the root), trashed to trash or restore, or content_modified_at to set your own date without touching the content. Omitting a key leaves it unchanged. Returns the updated drive_file. Scope drive.update (setting trashed: true also requires drive.delete). Restoring a file whose folder is still in the trash is 409 parent_trashed: restore the folder first. If a live file took its name (in any Unicode form or case) while it was in the trash, it comes back as name (1).ext (the lowest free number), and the response carries the new name. This route never changes the contents: a body with data or content_type is refused with 422 unsupported_field (naming the field) before anything is applied. Use the replace routes below for new contents.

FieldTypeRequiredDescription
namestringnoNew name.
folder_uuidstring | nullnoNew parent folder; null moves to the root. Present key triggers a move.
trashedbooleannoSet false to restore from trash (scope drive.update); set true to move to trash (scope drive.delete).
content_modified_atstring | nullnoYour own content modification date; null clears it. Needs the editor role.

Trash a file

POST/v1/drive/files/{uuid}/trash

Moves the file to the trash, where it can be restored for 30 days. Returns the file with trashed: true. Trashing a trashed file is fine. Scope drive.delete.

Delete a file

DELETE/v1/drive/files/{uuid}

Permanently deletes the file. Returns 204 with an empty body. Deleting it again is 404. Scope drive.delete.

Folders

Create a folder

POST/v1/drive/folders

Creates a folder. Returns 201 with the drive_folder. Scope drive.create.

FieldTypeRequiredDescription
namestringyesFolder name.
parent_uuidstring | nullnoParent folder; null or omitted for the root.
colorstringnoOptional folder color, a #RRGGBB hex string.

Retrieve a folder

GET/v1/drive/folders/{uuid}

Returns a single drive_folder. Scope drive.read.

Update a folder

POST/v1/drive/folders/{uuid}

Send name to rename, parent_uuid to move (null moves to the root), color (a #RRGGBB hex string, or blank to clear) to recolor, and/or trashed to trash or restore the folder and its subtree. Returns the updated drive_folder. Scope drive.update (setting trashed: true also requires drive.delete).

FieldTypeRequiredDescription
namestringnoNew name.
parent_uuidstring | nullnoNew parent folder; null moves to the root. Present key triggers a move.
colorstring | nullnoNew color, a #RRGGBB hex string. Blank clears it.
trashedbooleannoSet false to restore, true to trash. Cascades over the whole subtree. Restoring brings back only what was trashed with the folder. The folder, or anything in it whose name was taken in the meantime, comes back as name (1).

Trash a folder

POST/v1/drive/folders/{uuid}/trash

Moves the folder to the trash. Trashing cascades: every descendant folder and file is trashed too. Scope drive.delete.

Delete a folder

DELETE/v1/drive/folders/{uuid}

Permanently deletes the folder and its entire subtree, whether or not it is empty. Returns 204. Scope drive.delete.

Sharing

Every file and folder already carries your own effective role inline as my_role (see the object definitions above), so you rarely need an extra call to know your access. To see the full picture, who else an item is shared with and its public link, read its sharing settings. A grant on a folder applies to its whole subtree. Grants to people are read-only over the API (share with people in the Drive app); public links can be created and revoked.

Get sharing settings

GET/v1/drive/files/{uuid}/sharing
GET/v1/drive/folders/{uuid}/sharing

Returns a drive_sharing object: the direct grants on the item and its public link, if any. You only need to be able to see the item (viewer or higher). Scope drive.read.

FieldTypeRequiredDescription
objectstringnoAlways drive_sharing.
resource_typestringnofile or folder.
resource_uuidstringnoThe item these settings belong to.
accessesdrive_access[]noThe direct grants on the item (the grant object is below).
public_linkdrive_public_link | nullnoThe public link, or null when there is none.

The grant object

FieldTypeRequiredDescription
objectstringnoAlways drive_access.
uuidstringnoUnique identifier of the grant.
principal_typestringnoWho it grants to: user or workspace.
principal_user_uuidstringnoThe user, for a user grant. Left out otherwise.
principal_workspace_uuidstringnoThe workspace, for a workspace grant. Left out otherwise.
principal_emailstring | nullnoThe principal’s email, when known.
rolestringnoviewer, commenter, editor or manager.
statusstringnopending, accepted or revoked.
created_atstringnoISO-8601 timestamp.
updated_atstringnoISO-8601 timestamp.

The public link object

FieldTypeRequiredDescription
objectstringnoAlways drive_public_link.
uuidstringnoUnique identifier of the link.
resource_typestringnofile or folder.
resource_uuidstringnoThe shared item.
tokenstringnoThe opaque token in the public URL.
urlstringnoThe full public share URL.
statusstringnopending until the copy completes, then ready (or failed).
created_atstringnoISO-8601 timestamp.
updated_atstringnoISO-8601 timestamp.
{
  "object": "drive_sharing",
  "resource_type": "folder",
  "resource_uuid": "b3f1c2a4-…",
  "accesses": [
    {
      "object": "drive_access",
      "uuid": "d4a0b8fb-…",
      "principal_type": "user",
      "principal_user_uuid": "9c2e7a10-…",
      "principal_email": "sam@example.com",
      "role": "editor",
      "status": "accepted",
      "created_at": "2026-06-19T09:13:40.000Z",
      "updated_at": "2026-06-19T09:13:40.000Z"
    }
  ],
  "public_link": null
}

Create or revoke a public link

POST/v1/drive/files/{uuid}/public_link
POST/v1/drive/folders/{uuid}/public_link
DELETE/v1/drive/files/{uuid}/public_link
DELETE/v1/drive/folders/{uuid}/public_link

POST creates a link anyone can download the item from without signing in, and returns 201 with the drive_public_link. It starts as pending and becomes ready once the public copy is made. When a linked file’s contents are replaced, or a file finishes uploading into a linked folder, its copy is refreshed shortly after; a file still uploading isn’t listed. An item has at most one link: a second POST is 409 public_link_exists. DELETE revokes it and returns 204, or 404 public_link_not_found when there is none. Both need the manager or owner role. Scope drive.update.

curl -X POST https://api.cirrux.info/public_api/v1/drive/files/a1d9e7c8-…/public_link \
  -H "Authorization: Bearer $CIRRUX_TOKEN"

Thumbnails

Server-rendered image previews, generated once per file and shared across everyone with access. Gate on the file’s has_preview flag: when it is true a preview is ready, otherwise show a generic icon and skip the request (no round-trip). Previews cover images of up to 40 MB only for now (jpeg, png, gif, webp, tiff); other files are always has_preview: false. Scope drive.read.

Fetch a thumbnail

GET/v1/drive/files/{uuid}/thumbnail

Returns the preview as raw image bytes (Content-Type: image/jpeg or image/png). A trashed file still serves its thumbnail; only a permanently deleted file returns 404.

FieldTypeRequiredDescription
variantstringnoQuery parameter. The size and fit. One variant is available today, 1024_fit (aspect-preserving, longest edge 1024px), which is also the default. An unknown value returns 400 invalid_variant.
ctagnumbernoQuery parameter, optional. The file’s current ctag. Naming the content version makes the response safe to cache for good.

A thumbnail changes when the file’s contents are replaced, and so does its ETag. With ctag set to the file’s current ctag, the URL names fixed bytes and the response carries Cache-Control: private, max-age=31536000, immutable. Without it (or with an older ctag) the response is private, no-cache: keep it, and revalidate by sending If-None-Match with its ETag to get a cheap 304 Not Modified while the picture is unchanged.

curl "https://api.cirrux.info/public_api/v1/drive/files/a1d9e7c8-…/thumbnail?variant=1024_fit" \
  -H "Authorization: Bearer $CIRRUX_TOKEN" --output thumb.jpg

Besides 200, the endpoint can return:

  • 202: eligible but not rendered yet (Retry-After: 1). Rare, since clients gate on has_preview. Show the icon and retry shortly.
  • 204: the file has no preview (unsupported type, over the size cap, or the render failed). Treat it like has_preview: false and stop asking for this version.
  • 304: not modified (your If-None-Match matched). Reuse the cached image.

Fetch thumbnails in a batch

POST/v1/drive/thumbnails/batch

Fetch up to 25 thumbnails in one request (the File Provider batch hint). The call itself returns 200 whenever it was accepted; each item carries its own status, and ready items inline their bytes as base64. A request over 25 uuids returns 400 too_many_uuids.

FieldTypeRequiredDescription
uuidsstring[]yesThe file uuids to fetch, max 25.
variantstringnoThe variant to render. Defaults to 1024_fit.
{
  "object": "list",
  "thumbnails": [
    { "uuid": "a1d9e7c8-…", "status": "ready", "data": "<base64>", "content_type": "image/jpeg", "version": 3 },
    { "uuid": "b2e8f6d9-…", "status": "generating" },
    { "uuid": "c3f9a7ea-…", "status": "no_preview" },
    { "uuid": "d4a0b8fb-…", "status": "not_found" }
  ]
}

Each item’s status mirrors the single-fetch outcome:

  • ready: includes data (base64), content_type and the file version.
  • generating: not rendered yet; re-request it (single fetch or a later batch).
  • no_preview: the file has no preview.
  • not_found: unknown, invisible, or deleted uuid.

Changes (delta sync)

Instead of re-listing the whole tree, poll /v1/drive/changes to get just what changed since your last sync. It returns creates, updates and deletes plus a new cursor to resume from, the same model Google Drive, Dropbox and Microsoft Graph use.

Fetch changes

GET/v1/drive/changes
FieldTypeRequiredDescription
sincestringnoQuery parameter. The sync_token from your last response. Omit it (or send it empty) for the first sync, which returns a paginated snapshot of every live, fully uploaded item. Trashed items are not in the snapshot.
limitnumbernoQuery parameter. Page size, default 250, max 1000. Other values are clamped, not refused: 0 or less means 250, more than 1000 means 1000.

Keep calling with the returned sync_token while has_more is true; the first sync’s pages lead straight into the ongoing delta. Persist the token after each page: it’s a durable, crash-safe resume point. Scope drive.read.

as_of and freshness. as_of is the server’s clock when your request started. It does not promise that every write before it is included: a change reaches the feed once every database write that began before it has finished. That is usually immediate, but a long write elsewhere can hold changes back for longer (we have seen up to a minute). Nothing is skipped; a held-back change arrives on a later call. To read your own write back straight away, GET the item by uuid, which is always current.

curl "https://api.cirrux.info/public_api/v1/drive/changes?since=$SYNC_TOKEN" \
  -H "Authorization: Bearer $CIRRUX_TOKEN"
{
  "object": "list",
  "url": "/v1/drive/changes",
  "as_of": "2026-06-19T12:41:00.000Z",
  "sync_token": "eyJ2IjoxLCJtIjoiZCIsIngiOjEyMDk0MDMzLCJpIjoxMDU5Mn0",
  "has_more": false,
  "changes": [
    {
      "type": "create",
      "resource_type": "file",
      "uuid": "a1d9e7c8-…",
      "resource": { "object": "drive_file", "name": "april-2026.pdf", "trashed": false, … }
    },
    {
      "type": "update",
      "resource_type": "file",
      "uuid": "c7b5a3d1-…",
      "resource": { "object": "drive_file", "name": "draft.pdf", "trashed": true, … }
    },
    {
      "type": "delete",
      "resource_type": "file",
      "uuid": "e9f0a1b2-…",
      "parent_uuid": "b3f1c2a4-…"
    }
  ]
}

Each change is one of:

FieldTypeRequiredDescription
typestringnocreate, update or delete. Apply create and update the same way (upsert by uuid).
resource_typestringnofile or folder.
uuidstringnoThe item that changed.
resourceobjectnoThe full file/folder, on create and update. Omitted on delete.
parent_uuidstring | nullnoOn delete only: where the removed item lived, when still known.

Notes:

  • Trash is an update. A trashed item arrives as an update with trashed: true; restoring sends trashed: false. An item created and trashed between two calls arrives as a create with trashed: true. A delete is permanent and carries no resource.
  • Apply in order. Changes are in commit order, and a folder’s create comes before those of its children. The deletes of a cascade come in no particular order. Delivery is at-least-once, so applying must be idempotent; skip a change whose version you already have, and ignore a delete for a uuid you never saw (an aborted upload sends one).
  • Folder cascade. Trashing or deleting a folder emits an explicit change for every descendant, so you never have to walk the tree yourself.
  • Files still uploading are withheld until upload_status is completed.
  • One workspace per feed. The feed only covers the token’s workspace. If you sync several workspaces, use a separate token and cursor for each, since each is an independent domain. Items live in their owner’s workspace, so a folder someone shares with you from their organization’s workspace appears in that workspace’s feed (if you’re a member), not in your Personal one.
  • Access changes. When you gain access to a folder (it is shared with you, or someone moves it into a folder you can see), the feed sends a create for it and for everything in it, parents first. When you lose access (the share is revoked, or the folder is moved somewhere you can’t see), it sends a delete for it and for everything in it, with no resource. Files work the same way. If you can still see part of what changed, through another share, that part comes as a create, so apply the changes in order.
  • Page size. limit counts the changes the server reads, and an access change is one of them. A page with an access change can therefore hold more than limit changes. One too large to send in a page (more than 5,000 items) answers 410 sync_token_invalid instead, and you reseed.
StatuserrorWhen
410sync_token_invalidThe token can’t be honored, or an access change is too large to send. Discard the token and reseed with no since.

Push notifications

Register a device to receive Apple Push Notifications whenever Drive content changes. Designed for native File Provider sync apps that re-enumerate a domain (via the changes feed) when they receive a push. Cirrux pushes to every active subscription whose user can see the changed item. You supply the hex APNs device_token and an apns_client_uuid for a server-managed APNs client; Cirrux mints the provider JWT server-side, so the app ships no .p8 key. Scope drive.read.

Register a subscription

POST/v1/drive/apns_subscriptions
FieldTypeRequiredDescription
apns_client_uuidstringyesServer-managed APNs client configured by Cirrux.
device_tokenstringyesHex APNs device token from pushRegistry(_:didUpdate:for:).
container_identifierstringyesFile Provider container identifier.
domain_identifierstringyesFile Provider domain identifier; must match the authenticated workspace UUID.

An unknown apns_client_uuid is 404 apns_client_not_found, a token that is not even-length hex is 422 invalid_device_token, and a domain_identifier other than the workspace UUID is 422 invalid_domain_identifier. Each device keeps its own subscription: registering again with the same device_token (and the same apns_client_uuid, container_identifier and domain_identifier) updates it in place and returns the same uuid, so a user’s second Mac gets a subscription of its own. When a device’s token changes, register the new one; the old subscription stops at the first push APNs refuses. Returns 201; store the returned uuid to unregister later. device_token is never returned.

{
  "object": "drive_apns_subscription",
  "uuid": "b3f1c2a4-0000-0000-0000-000000000000",
  "apns_client_uuid": "b7f3b1dc-0000-0000-0000-000000000000",
  "container_identifier": "NSFileProviderWorkingSetContainerItemIdentifier",
  "domain_identifier": "b3f1c2a4-3333-4a2b-8c3d-000000000002",
  "created_at": "2026-06-16T12:00:00.000Z",
  "updated_at": "2026-06-16T12:00:00.000Z",
  "unregistered_at": null
}

Unregister a subscription

DELETE/v1/drive/apns_subscriptions/{uuid}

Marks the subscription unregistered; no further pushes are sent. Idempotent; an unknown uuid is 404 subscription_not_found. Returns 200 with the subscription, now carrying an unregistered_at timestamp. Cirrux also auto-unregisters a device when APNs reports it gone (410 / BadDeviceToken).

Errors

Drive error codes

In addition to the shared auth errors, Drive endpoints can return:

StatuserrorWhen
404not_foundFile, folder or upload is absent or not visible to you.
404folder_not_foundThe folder to list, upload into or move into is absent or not visible.
403not_authorizedYou can see the item but your role does not allow the change.
403owner_mismatchA move would take an item out of its owner's folders, or a create names an owner other than the folder's. Carries item.
403cross_workspaceA move would take an item into another workspace. Carries item.
403share_rootMoving, trashing or deleting the shared folder itself needs manager or owner. Carries item.
403owner_onlyA permanent delete needs owner, manager, or that you alone wrote the item. Carries item.
409has_own_sharesA move would give an item to another owner, but it or something in it has shares or a public link of its own. Carries item.
422name_takenA live item with that name already exists in the folder. Names are compared ignoring Unicode form (NFC or NFD) and case.
422invalid_nameThe name is empty, too long, "." or "..", or has a slash or control character.
422missing_required_fieldsA required body field is missing.
422invalid_file_sizeThe declared file size is not valid.
422invalid_content_modified_atcontent_modified_at is not an ISO-8601 timestamp with a zone.
413file_too_largeThe simple upload or download route’s 100 MB cap was exceeded (includes max_size).
413file_too_largeA chunked upload or replace is over 2 GiB (includes max_size).
422unsupported_fieldThe body sent a field this route refuses, such as data on the metadata update route. The body names the field.
422storage_limit_exceededThe workspace storage pool is full (includes pool_bytes). Read the pool and its use ahead of time with GET /v1/workspace/storage.
422invalid_moveA move would put a folder inside its own subtree.
422invalidA folder color is not a #RRGGBB hex string.
409parent_trashedA create, upload, move or restore would put an item in or under a folder that is in the trash. Carries item for a move or restore.
409upload_in_progressThe file already has an upload or replace in flight, or someone else started the one you are trying to continue. Carries that upload's expires_at when known.
409upload_expiredA part_urls or complete step on an upload past its 24-hour expires_at. The staging is discarded.
422undecryptable_contentComplete could not decrypt the first chunk with the file key and AAD. The upload is discarded; a replace leaves the live file as it was.
502storage_unavailableObject storage failed during an upload step. Nothing was discarded; retry the same step.
500internal_server_errorSomething unexpected went wrong on our side. Always JSON, never an HTML page.
500content_unreadableThe stored content of a file failed to decrypt on download.
409version_conflictA replace sent base_ctag and the file has a newer version. The body carries the current ctag.
422invalid_base_ctagbase_ctag is not a non-negative integer.
422invalid_uuidA uuid in the path or body is not a valid UUID.
429rate_limitedOver the API rate limit. Wait for the Retry-After header (seconds) before trying again.
409no_upload_in_progressA part_urls, complete or abort step found nothing in flight.
409file_not_replaceableOnly a completed file can have its contents replaced.
422invalid_part_numberspart_numbers is empty or has a number outside 1 to 10000.
422invalid_partsparts is missing a part_number or etag, or does not match what was uploaded.
422upload_size_mismatchThe uploaded bytes do not match ciphertext_size_bytes. The upload is discarded.
422upload_not_completedA chunked download was asked for a file that is still uploading.
409public_link_existsThe item already has a public link.
404public_link_not_foundThere is no public link to revoke.
400invalid_bodyThe simple upload data is missing or not valid base64.
400invalid_variantThe requested thumbnail variant is unknown.
400too_many_uuidsA thumbnail batch requested more than 25 uuids.
410sync_token_invalidThe changes cursor can’t be honored; reseed.