curl -s -X POST $SUBSTRIO_URL/v1/_files -H "Authorization: Bearer $SUBSTRIO_KEY" \
-H 'content-type: application/json' \
-d '{"path":"docs/report.pdf","data":"SGVsbG8=","encoding":"base64"}'
# {"path":"docs/report.pdf","size":5,"content_type":"application/pdf","cache_control":"","public":false,"hash":"...","created_at":1750000000,"updated_at":1750000000}
A path is 1 to 65535 bytes of valid UTF-8, with no leading or trailing /,
and no empty, . or .. segment; there is no folder to create first.
Uploading
| Operation | Route | Scope |
|---|---|---|
file.upload |
POST /v1/_files |
curator |
Send the file two ways: as JSON (path, data, and the fields below), or as
raw bytes with the path in an X-Filename header and the type in
Content-Type.
| Field | Type | Default | Description |
|---|---|---|---|
path |
string | required | Where to store the file |
data |
string | required | The file's bytes, as text or base64 |
encoding |
string | text |
text or base64 |
content_type |
string | guessed from path |
The stored MIME type |
cache_control |
string | none | The stored Cache-Control value |
public |
boolean | false |
Serves the file with no key at /_files/{path} on the app's own hosts (see Serving a public file) |
curl -s -X POST $SUBSTRIO_URL/v1/_files -H "Authorization: Bearer $SUBSTRIO_KEY" \
-H "X-Filename: assets/logo.png" -H "Content-Type: image/png" \
--data-binary @./logo.png
Both calls return the file's metadata: {"path", "size", "content_type", "cache_control", "public", "hash", "created_at", "updated_at"} (hash is
its content hash, created_at/updated_at are Unix seconds). A second
upload to the same path replaces it.
Listing
| Operation | Route | Scope |
|---|---|---|
file.list |
GET /v1/_files |
read-only |
curl -s "$SUBSTRIO_URL/v1/_files?prefix=assets/&limit=50" -H "Authorization: Bearer $SUBSTRIO_KEY"
# {"files":[{"path":"assets/logo.png","size":2048, ...}],"next_cursor":null,"count":1}
prefix narrows the listing to paths starting with it; cursor (from a
prior page's next_cursor) continues a listing; limit caps the page size.
Downloading and deleting
| Operation | Route | Scope |
|---|---|---|
file.download |
GET /v1/_files/{path} |
read-only |
file.delete |
DELETE /v1/_files/{path} |
operator |
curl -s $SUBSTRIO_URL/v1/_files/docs/report.pdf -H "Authorization: Bearer $SUBSTRIO_KEY" -o report.pdf
curl -s -X DELETE $SUBSTRIO_URL/v1/_files/docs/report.pdf -H "Authorization: Bearer $SUBSTRIO_KEY"
# {"deleted":true,"path":"docs/report.pdf"}
file.download returns the file's bytes with its stored content type.
Both take the path as the route's tail; an unknown path returns 404
FILE_NOT_FOUND.
Changing a file's settings
| Operation | Route | Scope |
|---|---|---|
file.update |
PATCH /v1/_files/{path} |
curator |
Change a stored file's settings without sending its bytes again. Its content,
size, hash, and created_at remain unchanged, and updated_at is set to the
current time. Specify at least one field; omitted fields keep their stored values.
| Field | Type | Description |
|---|---|---|
public |
boolean | true serves the file with no key at /_files/{path} on the app's own hosts; false takes that away at once |
content_type |
string | The stored MIME type; "" or application/octet-stream takes the type the path's name gives, as an upload does |
cache_control |
string | The stored Cache-Control value; "" clears it |
curl -s -X PATCH $SUBSTRIO_URL/v1/_files/docs/report.pdf -H "Authorization: Bearer $SUBSTRIO_KEY" \
-H 'content-type: application/json' -d '{"public":true,"cache_control":"max-age=3600"}'
# {"path":"docs/report.pdf","size":5,"content_type":"application/pdf","cache_control":"max-age=3600","public":true,"hash":"...","created_at":1750000000,"updated_at":1750000300}
It returns the file's metadata, as file.upload does. The next download
returns the new content type. Over MCP it is the substrio_file_update
tool, with path beside the fields.
| Refusal | Status | When |
|---|---|---|
FILE_NOT_FOUND |
404 | no file at the path |
INVALID_ARGUMENT |
400 | no field named, a field of the wrong type, or a content_type or cache_control over 65535 bytes or holding a control character (each is answered as a header); field names it |
A change made while an upload replaces the same file never puts the old content back: the settings are merged onto whatever the file holds when they are written.
Moving or renaming
| Operation | Route | Scope |
|---|---|---|
file.move |
POST /v1/_files/move |
curator |
Rename a file's path without copying its content. Stored bytes are addressed by hash, so a move writes only the path record regardless of file size. The file keeps its settings, hash, and timestamps.
| Field | Type | Default | Description |
|---|---|---|---|
from |
string | required | The file's current path |
to |
string | required | Its new path |
replace |
boolean | false |
Replace a file already at to |
curl -s -X POST $SUBSTRIO_URL/v1/_files/move -H "Authorization: Bearer $SUBSTRIO_KEY" \
-H 'content-type: application/json' -d '{"from":"drafts/report.pdf","to":"docs/report.pdf"}'
# {"path":"docs/report.pdf","size":5,"content_type":"application/pdf","cache_control":"","public":false,"hash":"...","created_at":1750000000,"updated_at":1750000000}
It returns the file's metadata at the new path; the old path then returns 404.
With replace: true, the file previously at to is deleted and its storage
is freed when no other path uses the same content. When from equals to,
the call returns the file unchanged.
| Refusal | Status | When |
|---|---|---|
FILE_NOT_FOUND |
404 | no file at from |
FILE_EXISTS |
409 | a file is already at to and replace is not true; both files stay as they were |
INVALID_ARGUMENT |
400 | from or to missing or not a valid path, or replace not a boolean; field names it |
A move stores the destination before removing the source. If a move returns an error, inspect both paths before retrying.
Serving a public file
| Operation | Route | Scope |
|---|---|---|
file.public.get |
GET /_files/{path} on the app's own hosts |
public, no key |
A file marked public (public: true on file.upload, or later with
file.update) is served to anyone, with no key, at /_files/{path} on any of
the app's hosts (its platform host and every attached domain). Each path
segment is URL-encoded as in any URL.
curl -s -X PATCH $SUBSTRIO_URL/v1/_files/media/logo.svg -H "Authorization: Bearer $SUBSTRIO_KEY" \
-H 'content-type: application/json' -d '{"public":true}'
curl -si https://myapp.substrio.com/_files/media/logo.svg
# HTTP/1.1 200 OK
# Content-Type: image/svg+xml
# ETag: "9f2c..."
# Cache-Control: no-cache
The response contains the stored content type, cache_control if set (or
no-cache otherwise), and an ETag containing the quoted content hash shown
by the listing. A request whose If-None-Match matches that ETag returns 304
with no body. Changing settings with file.update does not change the ETag;
new bytes do.
| Answer | Status | When |
|---|---|---|
| the file's bytes | 200 | the path names a public file |
| no body | 304 | If-None-Match names the file's ETag |
FILE_NOT_FOUND |
404 | no file at the path, the file is private, or the path is not a valid one; the body is the same in each case, so a private file's existence never shows |
TENANT_NOT_FOUND |
404 | no app holds the host |
A private file stays reachable only through file.download with a key.
Calling files from a function
A function accesses the same store directly through substrio.files, without
HTTP, JSON, or base64 between the call and the stored bytes:
export default async function (req) {
const meta = substrio.files.put("reports/" + req.params.id + ".json", JSON.stringify(req.body));
const stat = substrio.files.stat(meta.path);
const bytes = substrio.files.get(meta.path);
return substrio.files.response(meta.path) ?? new Response("not found", { status: 404 });
}
put(path, data, options?) stores data (an ArrayBuffer, typed array,
string or Blob), replacing the path if it exists, and returns its
metadata. stat(path) and get(path) return null for a path that does
not exist, rather than throwing. remove(path) deletes a file and returns
whether it existed. response(path, init?) returns the file as a Response
streamed from storage, honoring an init.range byte range; slice(path, offset, length, init?) returns one byte range of it as a whole response, with
no Content-Range header.
Size limits and errors
A raw upload (X-Filename plus a body of bytes) is limited only by the
app's storage quota. A JSON upload ({"path", "data"}) counts against the
request body limit, 16 MiB by default (--max-body-size). Base64 data is
checked against the same limit after decoding. A body past that limit returns
413 REQUEST_TOO_LARGE, with retryable: false and
next_action: "reduce_request_or_increase_limit".
A buffered upload whose Content-Length exceeds the limit is refused before
its body is buffered. The response includes Connection: close. Sending the
same upload again receives the same refusal. Reduce its size, or have the
server administrator increase the limit, before sending a new request on a
new connection. Admitted streamed raw uploads retain their storage-quota
limit.
An invalid path (too long, leading or trailing /, an empty, . or ..
segment, invalid UTF-8, or a control byte) returns 400 INVALID_ARGUMENT
(or KEY_TOO_LARGE past 65535 bytes), naming the field that carried it
(path or X-Filename). A storage quota refusal on upload answers with
the quota's own error.
A content_type or cache_control is answered as a response header, so one
holding a control character (a CR or LF among them) or longer than 65535
bytes answers 400 INVALID_ARGUMENT and stores nothing, naming the field
(content_type, cache_control, or Content-Type for a raw upload).
file.update checks its fields the same way.
Files versus a site release
Files here are stored and replaced one path at a time, with no history. A static site is released as a whole, versioned, content-addressed deploy instead: see operations/static-sites.md.