Files

Store, list, download, change, move, and delete files by path over HTTP or from a function. Public files can be served without a key.

Docs menu
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.