Files API
Read, write, delete and list files in one filesystem over HTTPS, write conditionally and follow changes, without mounting.
A backend that only needs some files does not have to run FUSE. The files API works on one filesystem at a time and goes through the same checks, quotas and billing as a mount: files it writes are read by mounts and the other way round.
Authentication
Every request carries a session token:
Authorization: Bearer mtblst_…. Create one with an API key (or a CLI
login) by asking for a token session:
curl -s https://mountable.io/api/v1/mount-sessions \
-H "Authorization: Bearer $MOUNTABLE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"filesystem_id": "'"$FS_ID"'", "mode": "rw", "max_duration_seconds": 3600,
"idempotency_key": "sync-42", "credential": "token"}'The answer's token is shown once. The session is active at once and works
until it expires or is revoked; a revoked session is refused within 15
seconds, and a transfer in progress ends within that time too. A token is
never renewed: when a request answers
401 unauthenticated, create a new session. A token works only for its own
filesystem; any other filesystem in the URL answers 404 not_found. A
read-only session ("mode": "ro") reads and lists but cannot write.
For a backend, create an API key limited to the one filesystem it syncs (see Permissions). Keep keys and tokens on the server: the files API is not for browsers.
Paths
{path} is relative to the filesystem root, /-separated and
percent-encoded. Every name must be valid UTF-8, at most 255 bytes, and not
empty, . or ..; an encoded / (%2F) and NUL are refused with
400 bad_path. Paths are checked, never rewritten.
Files and listings describe an entry with FileInfo:
{"path": "/tickets/AB-42.md", "type": "file", "size": 1234,
"etag": "\"3f2a…\"", "modified_at": "2026-10-17T12:00:00Z"}type is file, directory or symlink; etag is present for files only.
Operations
All paths start with https://mountable.io/api/v1/filesystems/{fs_id}.
| Method and path | Does |
|---|---|
GET /files/{path} | the file's bytes, with ETag, Content-Length and Last-Modified |
HEAD /files/{path} | the same headers without the body |
PUT /files/{path} | write the whole file: 201 created or 200 replaced, with its FileInfo |
DELETE /files/{path} | delete a file, a symbolic link (not its target) or an empty directory: 204 |
GET /list/{path}?after=&limit= | one directory in name order: {"entries": [FileInfo], "next": name or null} |
GET /changes?cursor=&wait= | the filesystem's changes since a cursor (below) |
- Read. The body is what a mount reads: holes read as zeros. A read counts its whole size against the organisation's monthly download cap before the first byte, and gives back what was not sent. A body that breaks off is cut short by closing the connection, never sent as if complete.
- Write.
Content-Lengthis required (411without it) and a file is at most 5 GiB. At most 4 uploads per filesystem run at once (more answer429 rate_limited). Missing parent directories are created; a file in a parent position answers409 not_a_directoryand is left as it is. The new file replaces the old one in one step: readers, mounts included, see the old file or the new one. A new file gets mode0644; a replaced file keeps its permission bits. Symbolic links and hard-linked files are not written (409 not_a_regular_file). - Delete. Not recursive: a directory with entries answers
409 directory_not_empty. Deleting one name of a hard-linked file keeps the file's data for its other names. - List.
limitis 1–1,000 (default 1,000); pass the previous answer'snextasafterfor the next page.GET /list/lists the root.
Writes, deletes and creates count against the filesystem's limit of 1,000 per minute, and stored bytes against its quota, as for mounts (Contract and limits). Listings and change events return no file bodies and are not counted as downloads.
Conditional writes
Every file has an ETag that changes whenever its content changes (also
when the same bytes are written again), and not for chmod or a new
modification time.
If-Match: "<etag>"onPUTorDELETE: only if the file still has that ETag.If-None-Match: *onPUT: only if nothing exists at the path.
A failed condition answers 412 with the current ETag:
{"detail": {"code": "precondition_failed", "current_etag": "\"…\"" or null}}.
The check and the write are one step for every other writer, other API
requests and mounts alike, so a conditional write never overwrites a change
it did not see. A conditional delete of a hard-linked file answers
409 not_a_regular_file.
Following changes
GET /changes is a long poll over the filesystem's change log:
- Without
cursorit answers at once with{"events": [], "cursor": "…"}. - With
cursorit answers with the changes made after it, in order, at most 1,000, and the cursor to use next. When there are none it waits up towaitseconds (0–25, default 25) and then answers with no events and the same cursor.
An event says that a path changed and what is there now:
{"type": "created", "path": "/tickets/AB-42.md", "file": FileInfo or null}type is created, updated or deleted; file is null when nothing
exists at the path any more. Treat events as invalidations: reconcile each
path to its file, do not replay events as a history. A rename shows as
deleted for the old path and created for the new one, for a directory
and everything below it. One write from a mount can show as several events,
and chmod or touch show as updated with the same ETag, so compare
ETags. A change made through one name of a hard-linked file is reported for
that name only.
To stay in sync without gaps, take a cursor first, then list or read
what you need, then follow the changes from that cursor. Changes made
while you were listing arrive as events. The change log keeps about an
hour: a cursor whose history is gone answers 410 cursor_expired, and you
start again the same way (new cursor, list, follow). A client that keeps
polling does not see 410 in ordinary use.
At most 4 change requests per filesystem can be open at once; more answer
429 rate_limited.
Errors
Errors carry a stable code: {"detail": {"code": "…", "message": "…"}}.
| Status / code | Meaning |
|---|---|
400 bad_path, 400 bad_request | the path, limit, wait, cursor or a condition header is invalid |
401 unauthenticated | the token is missing, unknown, expired or revoked: create a new session |
403 read_only | a write or delete with a read-only session |
404 not_found | no such file or directory, or not the token's filesystem |
409 is_a_directory, not_a_directory, not_a_regular_file, directory_not_empty | the path's type does not allow the operation |
409 conflict | the file kept changing during the write, or was moved while it was written; read again and retry |
408 request_timeout | a write's body arrived too slowly (each 8 MiB must arrive within about 2.5 minutes) |
409 quota_exceeded | the filesystem's byte or entry quota is full |
410 cursor_expired | take a new cursor and list again |
411 length_required, 413 too_large | Content-Length missing, or more than 5 GiB |
412 precondition_failed | If-Match or If-None-Match did not hold; see current_etag |
429 rate_limited, 429 download_quota | too many operations, uploads in progress or open change requests (wait Retry-After seconds), or the monthly download cap is reached |
501 unsupported_file | the file is stored in a format the API does not read |
503 unavailable, 503 changes_unavailable | try again later; after a failed write, read the file before retrying |
Limits
- Moving or removing a directory while another client writes into it is
not supported, for the files API and mounts alike. A write whose file
was moved away under it fails with
409 conflictand nothing is lost; in rarer cases, described in Contract and limits, a write can land in the wrong place. - The files API does not take a mount's write coordination: a mount that has a file open and writes after an API write replaces it, as any later write would.
- Not offered: rename, recursive delete, byte ranges, uploads of unknown
length,
chmod, creating symbolic links, extended attributes, copy, and webhooks.