Files

Users can store files (receipts, documents, pictures) like Firebase Storage. Every call is made as the signed-in user; a user only ever sees their own files. The app's limits are on the dashboard's Settings page: the largest file (default 10 MB), storage per user (default 100 MB) and the allowed types (default JPEG, PNG, WebP, GIF and PDF).

Uploading

POST /files with a multipart body: file, and optionally name, folder (such as receipts/2026) and visibility (private, the default, or public). The upload works exactly like the profile photo upload, with the field named file:

// Flutter
final request = http.MultipartRequest('POST', Uri.parse('$baseUrl/files'))
  ..headers.addAll({'X-App-Key': appKey, 'Accept': 'application/json', 'Authorization': 'Bearer $accessToken'})
  ..fields['folder'] = 'receipts'
  ..files.add(await http.MultipartFile.fromPath('file', path));
final file = jsonDecode((await http.Response.fromStream(await request.send())).body)['data'];
// React Native
const form = new FormData();
form.append('file', { uri: document.uri, name: document.name, type: document.mimeType } as any);
form.append('folder', 'receipts');
const response = await fetch(`${BASE_URL}/files`, {
  method: 'POST',
  headers: { 'X-App-Key': APP_KEY, Accept: 'application/json', Authorization: `Bearer ${accessToken}` },
  body: form,
});
// iOS: build the multipart body as for the profile photo, with name="file"
// and an extra part: Content-Disposition: form-data; name="folder"\r\n\r\nreceipts
// Android: OkHttp
val body = MultipartBody.Builder().setType(MultipartBody.FORM)
    .addFormDataPart("folder", "receipts")
    .addFormDataPart("file", file.name, file.asRequestBody(null))
    .build()
// Web
const form = new FormData();
form.append('file', input.files![0]);
form.append('folder', 'receipts');
await fetch(`${BASE_URL}/files`, {
  method: 'POST',
  headers: { 'X-App-Key': APP_KEY, Accept: 'application/json', Authorization: `Bearer ${accessToken}` },
  body: form,
});

The answer (201) is the file: id, name, folder, mime_type, size, checksum.sha256, visibility, public_url (public files) and status. The type is read from the content, never from the name. Errors: 413 storage/file-too-large, 413 storage/quota-exceeded, 415 storage/type-not-allowed.

Listing, renaming, deleting

  • GET /files lists the user's files, newest first (?folder= filters). Its meta also has used_bytes, quota_bytes, max_file_bytes, max_direct_upload_bytes and allowed_types, so the app can check a file before uploading.
  • GET /files/{id} gets one file; PATCH /files/{id} changes name or visibility; DELETE /files/{id} deletes it.

Downloading

Files are private unless made public. GET /files/{id}/url returns {"url", "expires_at"}: a link that works for 10 minutes without any headers, so you can hand it to an image view, a video player or the browser. Ask for a new one when it expires.

A public file also has a stable public_url that never expires (it redirects to a fresh link). Use it for pictures other users see.

Large files (direct upload)

With S3 storage, large files can go straight to storage:

  1. POST /files/uploads with {"name", "mime_type", "size", "folder", "visibility"} checks the limits and returns the pending file and an upload (method, url, headers, expires_at in 30 minutes).
  2. PUT the raw bytes to upload.url with exactly upload.headers (no app key or token).
  3. POST /files/{id}/complete checks the stored object and makes the file ready.
const { data } = await authorized('POST', '/files/uploads', { name: file.name, mime_type: file.type, size: file.size });
await fetch(data.upload.url, { method: data.upload.method, headers: data.upload.headers, body: file });
const { data: ready } = await authorized('POST', `/files/${data.file.id}/complete`);

Without S3 storage the first call answers 409 storage/direct-upload-unavailable: use POST /files. Unfinished uploads are deleted after a day.