Files
REST endpoints for the Files surface. Upload (direct or server-proxied), list with sort / search / starred / recent views, rename, move, archive, restore, share tokens, versioned replacement, bulk zip download, server-side code preview, and per-file metadata (starred · tags · description · EXIF readout). Comments thread on files via the universal /api/workspaces/{slug}/comments endpoint with target.type = "file".
All endpoints below sit under /api/workspaces/{slug}/files. Every route is gated by the files-surface flag plus workspace membership; archived files reject as 404 from any write endpoint.
Upload
Two paths. Pick the direct-to-blob path for most files; pick the proxy path when the upload needs server-side processing (SVG sanitization, image EXIF strip).
Direct upload
POST /api/workspaces/{slug}/files
Content-Type: application/json
Authorization: Bearer <api_key>
# Body: HandleUploadBody from @vercel/blob/client.
# clientPayload (string): JSON-encoded:
{
"surfaceSlug": "library",
"parentFolderId": null,
"name": "hero.png",
"size": 248192,
"contentType": "image/png"
}Returns a one-shot upload URL. The client streams bytes to Vercel Blob; the server fires file.uploaded through onUploadCompleted after the row lands.
Proxy upload
POST /api/workspaces/{slug}/files/proxy-upload
Content-Type: multipart/form-data
surface_slug=library
parent_folder_id= # or a folder id
file=<binary>Capped at 25 MB by the serverless body limit. Use this path for SVG uploads (DOMPurify sanitization) or raster images where you want the server to strip EXIF + generate a 256-px webp thumbnail before persisting.
List
GET /api/workspaces/{slug}/files
?surfaceSlug=library
&parentFolderId=null
&view= # null (default) | "starred" | "recent"
&q= # search query, surface-wide when non-empty
&sort= # name | name_desc | newest | oldest | largest | smallest
&order= # asc | desc (advanced override)Returns { folders: Folder[], files: File[] }. Folders are omitted from starred and recent views (those are flat surface-wide lists). Each file carries a thumbUrl (server- generated 256-px webp for raster types, null otherwise), starred, starredAt, description, tags, and exif (width, height, orientation for raster uploads).
Rename / move / restore
PATCH /api/workspaces/{slug}/files/{fileId}
{ "name": "renamed.png" } # rename
{ "parentFolderId": "fld_..." } # move into folder
{ "parentFolderId": null } # move to root
{ "restore": true } # restore a soft-deleted fileRename keeps the blob key stable so deep-links never break. Move rejects cross-surface targets. Restore + name in the same body is rejected; do them in separate PATCH calls.
Archive (soft delete)
DELETE /api/workspaces/{slug}/files/{fileId}
# Soft-delete. Sets archivedAt + archivedByPrincipal*; blob stays
# in storage and the org's quota counter stays incremented until
# the 30-day trash cleanup cron hard-deletes it. Restorable via
# the PATCH endpoint above.Metadata (starred · description · tags)
PATCH /api/workspaces/{slug}/files/{fileId}/metadata
{
"starred": true, # toggle star
"description": "Hero asset for v2", # null clears; max 2000 chars
"tags": ["Design", "Hero"] # max 16 × 32 chars
}Tags are normalised server-side: lowercased, trimmed, de-duplicated, empties dropped. Empty description clears the field. Each call accepts any subset of the three keys.
Share tokens (public anonymous read)
POST /api/workspaces/{slug}/files/{fileId}/share
# mint a token; returns { id, url, createdAt }
GET /api/workspaces/{slug}/files/{fileId}/share
# list active (non-revoked) tokens
DELETE /api/workspaces/{slug}/files/{fileId}/share?tokenId=...
# soft-revoke (sets revokedAt); idempotentEach token is 32 random bytes (URL-safe base64, ~256 bits of entropy). Public consumption happens at /share/files/{token} — a server-rendered page with the preview + a Download button.
Versions (replace bytes, keep id)
POST /api/workspaces/{slug}/files/{fileId}/versions
Content-Type: multipart/form-data
file=<binary>
# Replaces the file bytes. Same File.id, same comments, same
# share tokens, same deep-link URL. New blobKey + new sha256
# under the hood. MIME category must match the previous version
# (image → image, video → video, etc.); cross-category swaps
# reject 400.Bulk zip download
POST /api/workspaces/{slug}/files/zip
{
"fileIds": ["fil_a", "fil_b", "fil_c"],
"surfaceSlug": "library"
}
# 200 OK
# Content-Type: application/zip
# Content-Disposition: attachment; filename="library.zip"Capped at 50 files per request and 500 MB total uncompressed. Duplicate filenames are deduped server-side (photo.jpg, photo (2).jpg) so extractors don't silently overwrite.
Code preview
GET /api/workspaces/{slug}/files/{fileId}/preview
# Returns the raw text bytes for small (≤256 KB) text-like files
# so the client can render syntax-highlighted code. MIME must be
# in PREVIEW_TEXT_MIMES (text/*, application/json, yaml, xml, js,
# ts, sh, etc). Non-text MIME or oversize rejects 400.Comments on a file
POST /api/workspaces/{slug}/comments
{
"target": { "type": "file", "fileId": "fil_..." },
"body": "Replace the hero, the contrast is too low.",
"mentions": [
{ "kind": "user", "id": "usr_...", "label": "Govind" }
]
}File-targeted comments use the universal /comments endpoint. See /docs/api/comments for the full target catalog, replies, resolve / unresolve, reactions, and inbox routing rules (the file uploader is the default target author for the notification fan-out).
Webhook events
Every files mutation route emits a workspace-scoped event the webhook + SSE fan-out picks up. Subscribers wire onto the org webhook with the action name:
file.uploaded— new file row landedfile.renamed,file.movedfile.archived,file.restoredfile.version_replaced— bytes replaced via POST /versionsfile.starred,file.tagged,file.describedfile.shared,file.share_revokedfolder.created,folder.archived
Each payload carries fileId, name, and surfaceId so subscribers can deep-link or fetch metadata without a second request. file.shared adds shareTokenId + url. file.version_replaced carries the previous and new sha256.
Storage quota
Quota is org-scoped, not per-workspace. Free orgs get 5 GB, Pro 100 GB, Scale 500 GB. The upload pre-flight rejects a 413 when the bundled size would exceed the cap; in-flight uploads commit through an atomic Org.storageUsedBytes increment so concurrent uploads can't race past the cap.