Mountable

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 pathDoes
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-Length is required (411 without it) and a file is at most 5 GiB. At most 4 uploads per filesystem run at once (more answer 429 rate_limited). Missing parent directories are created; a file in a parent position answers 409 not_a_directory and 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 mode 0644; 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. limit is 1–1,000 (default 1,000); pass the previous answer's next as after for 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>" on PUT or DELETE: only if the file still has that ETag.
  • If-None-Match: * on PUT: 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 cursor it answers at once with {"events": [], "cursor": "…"}.
  • With cursor it 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 to wait seconds (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 / codeMeaning
400 bad_path, 400 bad_requestthe path, limit, wait, cursor or a condition header is invalid
401 unauthenticatedthe token is missing, unknown, expired or revoked: create a new session
403 read_onlya write or delete with a read-only session
404 not_foundno such file or directory, or not the token's filesystem
409 is_a_directory, not_a_directory, not_a_regular_file, directory_not_emptythe path's type does not allow the operation
409 conflictthe file kept changing during the write, or was moved while it was written; read again and retry
408 request_timeouta write's body arrived too slowly (each 8 MiB must arrive within about 2.5 minutes)
409 quota_exceededthe filesystem's byte or entry quota is full
410 cursor_expiredtake a new cursor and list again
411 length_required, 413 too_largeContent-Length missing, or more than 5 GiB
412 precondition_failedIf-Match or If-None-Match did not hold; see current_etag
429 rate_limited, 429 download_quotatoo many operations, uploads in progress or open change requests (wait Retry-After seconds), or the monthly download cap is reached
501 unsupported_filethe file is stored in a format the API does not read
503 unavailable, 503 changes_unavailabletry 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 conflict and 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.