Files
File endpoints of the REST API
All endpoints below require the API-Key
header unless noted otherwise.
File objects in responses have this shape:
{
"id": "5f2d4a1e-...",
"name": "photo.png",
"key": "projects/demo/photo.png",
"mimeType": "image/png",
"size": 102400,
"folderId": "9c8b7a6d-...",
"visibility": "public",
"url": "api/hosting/projects/demo/photo.png",
"createdAt": "2026-07-15 12:00:00",
"updatedAt": "2026-07-15 12:00:00"
}key is the object's path relative to the storage root — it identifies the
file and determines its public URL. url is relative to your instance's base
URL; resolve it against that to get the public hosting link (the SDK does this
for you).
visibility is public or private. url is always the canonical hosting
URL regardless of visibility — for a private file it only works with a
signed URL or valid credentials.
List files
GET /api/filesReturns every file on the instance. Supports the list query parameters.
curl "https://files.example.com/api/files?limit=20&orderBy=size&orderDirection=DESC" \
-H "API-Key: your-api-key"{ "data": { "files": [ ... ] }, "error": null }Get a file
GET /api/files?id=<file-id>
GET /api/files?key=<object-key>Fetch a single file by its id or by its key:
curl "https://files.example.com/api/files?key=projects/demo/photo.png" \
-H "API-Key: your-api-key"{ "data": { "file": { ... } }, "error": null }Responds 404 when no file matches.
Search files by name
GET /api/files?name=<file-name>Matches the exact file name across every folder — useful because the same
name can now exist in more than one folder. Also supports the list query
parameters. An empty result is a success with an empty files array.
Upload files
POST /api/filesSend a multipart/form-data body:
| Field | Type | Description |
|---|---|---|
files | file | One or more files to upload (repeat the field for multiple files) |
folder | string | Optional target folder path, e.g. projects/demo. Nested folders are created automatically. Defaults to the root folder. |
visibility | string | Optional, public or private. Defaults to the instance's default visibility, set under Settings in the dashboard (public unless changed). Applies to every file in the request. |
curl -X POST https://files.example.com/api/files \
-H "API-Key: your-api-key" \
-F "folder=projects/demo" \
-F "files=@./photo.png" \
-F "files=@./notes.txt"{ "data": { "files": [ ... ] }, "error": null }Files are stored under the name you upload them with, so you control the
public URL. Names are unique per folder: the same name in two different
folders is fine, but uploading over an existing name responds 409.
The request body is streamed straight to disk, so upload size is bounded by your volume rather than by memory. Uploads land in a staging area and are moved into place only once the whole request has been received — if anything fails partway through, nothing appears in the target folder.
| Status | Cause |
|---|---|
409 | A file with that name already exists in the target folder |
413 | A file exceeded the maximum upload size set under Settings, or too many files in one request |
507 | Not enough free space on the volume |
Report canceled uploads
POST /api/files/canceledAn upload aborted partway through its request is recorded as canceled by the
upload route itself. Files a client drops from its own queue before sending
never reach the server, so report their names to keep the activity history
complete. Needs the files:write scope.
{ "names": ["holiday.mov", "notes.txt"] }{ "data": null, "error": null }Rename a file or change its visibility
PUT /api/filesJSON body — name, visibility, or both. At least one is required, or the
request responds 400:
{ "id": "<file-id>", "name": "new-name.png", "visibility": "private" }The name must be a single path segment — separators and .. respond 400.
Renaming onto a name already used in the same folder responds 409. An
unrecognised visibility responds 400.
Changing visibility alone never touches the bytes on disk. Renaming changes the key, which invalidates any signed URL previously minted for the old key.
Move files
POST /api/files/moveJSON body, with the destination given as either folderId or folder:
{ "id": "<file-id>", "folder": "projects/archive" }| Field | Type | Description |
|---|---|---|
id | string | The file to move. Send this or ids. |
ids | string[] | Several files to move into the same folder. See below. |
folderId | string | The destination folder's id. Responds 404 if it doesn't exist. |
folder | string | The destination folder's key, created (with any missing parents) like an upload's folder field. "" is the root. |
A moved file keeps its id, name and visibility; its key and url change to
the new location, which breaks any public link or signed URL pointing at the old
one. The response carries the file as it is now:
{ "data": { "file": { "key": "projects/archive/photo.png", ... } }, "error": null }| Status | Meaning |
|---|---|
400 | Missing id or destination, or an invalid folder key |
404 | No such file, or no folder with that folderId |
409 | A file or folder with the same name already exists in the destination |
Moving a file into the folder it is already in succeeds without changing anything.
Move several files
Send ids instead of id to move a batch into one folder:
{ "ids": ["<file-id>", "<file-id>"], "folderId": "<folder-id>" }Each file is moved on its own, so a name clash or a bad id does not stop the rest. The response lists the files that were moved and the ids that were not:
{
"data": {
"files": [{ "id": "<file-id>", "key": "projects/archive/a.png", ... }],
"failed": [{ "id": "<file-id>", "message": "\"b.png\" already exists in the destination folder" }]
},
"error": null
}Responds 404 only when none of the ids exist, and 400 for an empty list or
more than MAX_MOVE_BATCH (default 500) ids.
Sign a URL
POST /api/files/signMints a time-limited URL that reads a file without an API key — the way to hand
a private object to a browser, an <img> tag, or a third party.
JSON body:
{ "key": "projects/demo/report.pdf", "expiresIn": 3600 }| Field | Type | Description |
|---|---|---|
id | string | The file id. Either this or key is required. |
key | string | The object key. Either this or id is required. |
expiresIn | number | Seconds the link stays valid. Defaults to the instance's default signed link lifetime and is clamped to its maximum, both set under Settings. |
{
"data": {
"url": "api/hosting/projects/demo/report.pdf?exp=1786291200&sig=...",
"expiresAt": "2026-08-09T12:00:00.000Z"
},
"error": null
}Like url on a file object, the returned url is relative to your instance's
base URL. Signing a public file is allowed; the link simply also works unsigned.
Signatures are stateless, so an individual link cannot be revoked. Rotating
AUTH_SECRET invalidates every outstanding signed URL at once — that is the
only way to withdraw them early.
Delete a file
DELETE /api/filesJSON body:
{ "id": "<file-id>" }Removes the file from disk and the database. Responds 404 if no such file
exists; a file already missing from disk still has its record cleaned up and
reports success.
Delete several files
Send ids instead of id to delete a batch in one request:
{ "ids": ["<file-id>", "<file-id>"] }Each id is handled on its own, so one bad id does not stop the rest. The response lists both outcomes:
{
"data": {
"deleted": ["<file-id>"],
"failed": [{ "id": "<file-id>", "message": "File not found" }]
},
"error": null
}Responds 404 only when none of the ids exist, and 400 for an empty list or
more than MAX_DELETE_BATCH (default 500) ids.
Download a file
GET /api/files/download/<file-id>Streams the raw file with Content-Disposition: attachment, so it works
directly as a download link in authenticated contexts.
curl -OJ https://files.example.com/api/files/download/<file-id> \
-H "API-Key: your-api-key"Supports byte ranges and conditional requests, so an interrupted download can be resumed rather than restarted:
curl -C - -OJ https://files.example.com/api/files/download/<file-id> \
-H "API-Key: your-api-key"Download several files as a zip
POST /api/files/downloadJSON body:
{ "ids": ["<file-id>", "<other-id>"] }Streams one zip containing every requested file, private ones included. Each
entry keeps its path below the deepest folder the files share: files picked from
one folder sit at the top of the archive, while a selection spanning
projects/web and projects/api keeps web/ and api/ apart. The archive is
named after that shared folder (projects.zip), or files.zip when the files
only share the root.
curl -OJ https://files.example.com/api/files/download \
-H "API-Key: your-api-key" \
-H "Content-Type: application/json" \
-d '{"ids": ["<file-id>", "<other-id>"]}'Every id must resolve: an unknown id, or a file whose bytes are missing from
disk, responds 404 and no archive is sent. Duplicate ids are collapsed, and
more than MAX_DOWNLOAD_BATCH (default 1000) ids responds 400. The ids may
also be sent as repeated ids form fields
(application/x-www-form-urlencoded), so a plain HTML form can start the
download.
Entries are stored uncompressed, which keeps the server's CPU idle and lets the
response carry an exact Content-Length. Archives over 4 GB use ZIP64. The zip
is generated per request, so unlike a single-file download it cannot be resumed
with a byte range.
Serve a file
GET /api/hosting/<object-key>Streams the file inline with the correct Content-Type, which makes it
suitable for embedding in web pages:
<img src="https://files.example.com/api/hosting/projects/demo/photo.png" />This is the URL carried in the url field of file objects and shown as the
"hosting URL" in the dashboard.
Public files need no authentication. A private file is served only to a caller that presents one of:
- a valid signature (
exp+sig) fromPOST /api/files/sign, - an
API-Keyheader, - a dashboard session cookie.
Anything else responds 404 — the same response as a key that does not exist,
so the endpoint cannot be used to discover which private keys are real. Private
responses also carry Cache-Control: private, no-store so a proxy or CDN in
front of Vaze cannot retain them.
Add ?download=1 to serve with Content-Disposition: attachment instead of
inline. It grants no extra access and is not part of the signature, so it can be
appended to a signed URL.
Hosted responses are sent with Content-Security-Policy: sandbox and
X-Content-Type-Options: nosniff. Assets render normally; uploaded HTML and
SVG render without executing scripts, so hosted content can never act against
your instance with your session.
Each day's requests, bytes sent and misses are counted for the dashboard's hosting traffic, leaving out requests made from a signed-in dashboard or app session. See Stats.
Public hosting URLs are exactly that — anyone who knows a file's key can fetch
it. Upload with visibility: private, or make new files private by default
under Settings in the dashboard, for anything you don't want reachable by URL.
Range requests and caching
Both /api/hosting/<key> and /api/files/download/<id> are range-aware and
revalidatable. Every response carries Accept-Ranges: bytes.
Byte ranges
A Range header returns 206 Partial Content with a Content-Range. This is
what lets a browser seek in an audio or video file — Safari in particular
refuses to play a video at all from a server that can't serve ranges.
curl -r 0-1023 https://files.example.com/api/hosting/clips/demo.mp4| Request | Result |
|---|---|
Range: bytes=0-499 | 206, first 500 bytes |
Range: bytes=500- | 206, byte 500 to the end |
Range: bytes=-500 | 206, final 500 bytes |
| Start past end of file | 416 with Content-Range: bytes */<size> |
An end past the last byte is clamped rather than rejected. Multi-range requests
(bytes=0-9,20-29) are answered with the complete file — a server may always
decline to honour a range.
Conditional requests
Public responses carry an ETag and Last-Modified derived from the file's
size and modification time. Send either back to skip the transfer:
curl -H 'If-None-Match: "a-6899f2c1"' \
https://files.example.com/api/hosting/projects/demo/photo.png
# → 304 Not Modified, no bodyIf-None-Match takes precedence over If-Modified-Since when both are sent.
If-Range is honoured with strong comparison: if the validator is stale, the
whole file is returned rather than a range that would corrupt the cached copy.
Cache-Control on public responses is
public, max-age=<public cache lifetime>, must-revalidate, defaulting to
max-age=0 — always revalidate, which is cheap because a match costs a bodiless
304. Raise the public cache lifetime under Settings when keys on your
instance are effectively immutable.
Private files are sent Cache-Control: private, no-store and carry no
validators — offering a revalidation path would contradict no-store. Byte
ranges still work, so seeking in a private video behaves normally.
Vaze