File storage
Let viewers upload files to a canvas and get back a URL you can put in an
<img>, a link, or a fetch(). This page is the reference for
canvasdrop.files, the files primitive on the canvasdrop global that
<script src="/sdk/v1.js"> defines in every canvas. By the end you can upload,
list, serve, and delete files, and handle every error the primitive returns.
The canvas needs Enable backend on and the File storage toggle on (it is pre-enabled) in its Backend tab; see Capabilities. There is nothing to configure in the page for the built-in shared/submission scopes and no key to hold. File groups and record attachments use the resource configuration described below.
File groups and attachments
A file group is a named group of standalone uploads within one canvas, such
as documents or images. It belongs to the Files primitive. Each group has its
own permission policy: two groups can have different access rules, or use the
same preset while containing separate files. Configure the group in Backend →
Participation and permissions, then use the same name in your canvas code:
await canvasdrop.files.upload(file, { group: "documents" });
A file attached to a collection record follows that record's permissions instead. Create the parent record first, then upload with its collection name and record ID. No separate file group is needed. Uploading requires read/update access to the parent, even if a different person originally created that record:
await canvasdrop.files.upload(file, { collection: "comments", recordId: comment.id });
Changing a group or parent collection's policy changes access to existing files; it does not move them into another group. Content URLs enforce the same rights. See Permissions and defaults for presets and operation rules.
Built-in shared and submission scopes
<script src="/sdk/v1.js"></script>
<script type="module">
// <input type="file" id="picker"> and <img id="img"> are on the page
const f = await canvasdrop.files.upload(picker.files[0], { scope: "submission" }); // { id, name, size, url }
img.src = f.url; // absolute content URL
const all = await canvasdrop.files.list(); // FileMeta[]
const href = canvasdrop.files.url(f.id); // same URL as f.url, no request
await canvasdrop.files.delete(f.id); // the URL now returns 404
</script>
The built-in scopes have fixed rules. Shared (the default) files are readable by admitted
viewers; only owners/editors upload or delete them. Submission files are
visible to their authenticated uploader and owners/editors. The uploader can
delete their own submission file; owners/editors can manage all submission files.
Other viewers receive 404 NOT_FOUND for private files, including content URLs.
Existing files remain shared. List metadata includes scope and uploadedBy.
New uploads can instead use {group: "uploads"} for a configured file-group policy
or {collection: "comments", recordId: comment.id} to inherit the parent record's
permissions. These options are mutually exclusive with scope. Personal files remain
hidden from owners/editors; shared contributions allow authors and owners/editors to
manage items. list() returns only visible files under all policies, and content URLs
enforce the same rules. Bound metadata includes recordId; its scope is
record:<collection> or group:<group>. files.rename(id, name) requires read and
update permission; replacing bytes requires a new upload. See Permissions and defaults.
Methods
Signatures as declared in the SDK (CanvasdropClient.files), with the runtime
API call each one makes:
| Method | Signature | HTTP call |
|---|---|---|
upload |
upload(file: File, options?: FileUploadOptions): Promise<{ id: string; name: string; size: number; url: string }> |
POST {base}/v1/c/{slug}/files |
list |
list(): Promise<FileMeta[]> |
GET {base}/v1/c/{slug}/files |
delete |
delete(id: string): Promise<void> |
DELETE {base}/v1/c/{slug}/files/{id} |
rename |
rename(id: string, name: string): Promise<void> |
PATCH {base}/v1/c/{slug}/files/{id} |
url |
url(id: string): string |
none (builds {base}/v1/c/{slug}/files/{id}/content) |
interface FileMeta {
scope?: string; // shared, submission, group:<name>, or record:<collection>
recordId?: string | null; // parent record for attachments
uploadedBy?: string; // server-resolved author
id: string;
name: string;
size: number; // bytes
mime?: string; // always sent by the server
createdAt?: number; // Unix ms; always sent by the server
}
type FileUploadOptions =
| { scope?: "shared" | "submission" }
| { group: string }
| { collection: string; recordId: string };
upload
upload(file) posts the File as multipart/form-data under the field name
file. The stored name is file.name (or upload when the File has none)
and the stored MIME type is file.type (or application/octet-stream). The
server answers 201 { id, name, size, url } with a root-relative url; the SDK
replaces it with the absolute content URL before resolving, so f.url is
correct in both URL modes. The result carries no mime or createdAt; call
list() when you need them.
Pass { scope: "submission" } for standalone private submissions; the SDK sends a
multipart field scope. An omitted scope is shared. There is no progress callback. For a large file, show your
own pending state around the await. Ids are server-assigned UUIDs.
list
For the built-in scopes, list() resolves to shared files plus your own submission files;
owners/editors receive all shared/submission files. Group files and record attachments
are included only when their policies allow the caller to read them.
There is no paging and no filter.
delete
delete(id) removes the file row and its bytes when your role permits it.
A viewer deleting a shared file receives 403 PERMISSION_DENIED. It rejects with NotFoundError
when id is not a file of this canvas, so deleting the same id twice rejects
the second call. After a delete, the file's content URL returns 404.
url
url(id) builds the content URL synchronously from the id, without a request.
It is the same value upload returns in url. It does not check that the id
exists.
Every call (upload, list, download, delete) counts in the canvas's usage stats; uploads and deletes are also written to the audit log.
Content URLs
f.url and canvasdrop.files.url(id) both resolve to
{base}/v1/c/{slug}/files/{id}/content, absolute and correct for the URL mode
the instance runs in. The SDK detects the mode from location, so you never
build this URL by hand.
| URL mode | Content URL origin | Cross-origin? |
|---|---|---|
path |
the canvas's own origin | no |
subdomain |
the base host ({base}), not the canvas subdomain |
yes; the response carries the credentialed CORS headers your canvas origin needs |
Content is served behind the same sign-in as the canvas and through the same
cross-canvas isolation as every other runtime API call, so use a file's URL from
the canvas that owns it. An <img src> works directly. When you fetch() a
content URL yourself, send credentials so the request succeeds in subdomain
mode too:
const res = await fetch(canvasdrop.files.url(id), { credentials: "include" });
const blob = await res.blob();
Content responses use Cache-Control: private, no-store. A URL is not an access
grant: the same file visibility rule is checked on each request. A content URL
for an id that does not exist or a private file hidden from you returns 404 with
{ "code": "NOT_FOUND" }.
Content requests count toward the same per-viewer runtime rate limit as every
other SDK call: 120 requests per minute per viewer per canvas by default, set by
the operator with CANVAS_DROP_RATELIMIT_CANVAS_API_PER_MIN. A page that loads
hundreds of images at once can hit RATE_LIMITED (429); lazy-load or paginate
large galleries.
How content is served
Uploaded bytes come from other people, so the content endpoint treats them as untrusted:
Content-Typeis the MIME type stored at upload, withX-Content-Type-Options: nosniffon every response.Content-Disposition: inlineonly forimage/png,image/jpeg,image/gif,image/webp, andimage/avif. Everything else, SVG and HTML included, is served as anattachmentand downloads instead of rendering.- The filename in
Content-Dispositionis sanitized (ASCII fallback) and RFC 5987 encoded (filename*).
This keeps an uploaded active document (HTML, or a scriptable SVG) from running
against another viewer's session on the canvas origin. If your canvas needs to
display an SVG a viewer uploaded, fetch() it and render it yourself in a way
that does not execute its scripts.
Limits
| Limit | Default | Error when exceeded |
|---|---|---|
| Bytes per file | 25 MiB | FILE_TOO_LARGE (413) |
| Bytes per canvas, all files | 1 GiB | QUOTA_EXCEEDED (409) |
Both defaults are admin-tunable per instance (Admin settings, Limits group:
Max file bytes and Max canvas bytes). The request body itself is capped
at 25 MiB plus 1 MiB of multipart framing, and that transport cap is a fixed
constant: an admin can lower the per-file limit, but raising it above 25 MiB has
no effect on uploads through the SDK. A body over the transport cap is refused
with the same FILE_TOO_LARGE (413) before the file is read.
The per-canvas quota is a check before the write, not a reservation. Two uploads racing at the boundary can both succeed; this is acceptable on the trusted-org model the platform is built for.
Errors
Every method rejects with a CanvasdropError subclass; branch on err.code, or
catch the subclass you care about.
| Wire code | Status | Thrown as | When |
|---|---|---|---|
FILE_TOO_LARGE |
413 | QuotaExceededError |
the file, or the request body, is over the per-file limit |
QUOTA_EXCEEDED |
409 | QuotaExceededError |
the upload would push the canvas over its byte quota |
INVALID_BODY |
400 | CanvasdropError |
the request was not multipart, or the file field was missing or not a File |
NOT_FOUND |
404 | NotFoundError |
delete with an id that is not a file of this canvas |
CAPABILITY_DISABLED |
403 | CapabilityDisabledError |
the File storage toggle or the canvas backend is off |
STATIC_ONLY |
403 | CanvasdropError |
the canvas is on the Public link rung and the caller is not the owner or an editor |
RATE_LIMITED |
429 | CanvasdropError |
the per-viewer runtime rate limit was hit; the response carries Retry-After |
Notes:
QuotaExceededErrorkeeps the wire code inerr.codeand the status inerr.status. Note the 409: on this route the per-canvas quota is a storage conflict, not the 429 thatQUOTA_EXCEEDEDmeans for AI spend.CapabilityDisabledErrorexposes the server's repair hint aserr.hint, anderr.messageis that hint when the server sends one.- On a Public link canvas every primitive is refused for non-owners; only
the owner and the canvas's editors reach
canvasdrop.filesthere.
try {
const f = await canvasdrop.files.upload(file);
img.src = f.url;
} catch (err) {
if (err.code === "FILE_TOO_LARGE") showToast("Keep files under 25 MiB");
else if (err.code === "QUOTA_EXCEEDED") showToast("This canvas is out of file storage");
else if (err.code === "CAPABILITY_DISABLED") showToast("File storage is off for this canvas");
else throw err;
}
See error codes for the full list, and the
Runtime API for the HTTP endpoints under
/v1/c/{slug}/files that these methods call.