Reference

File upload and download

Every project has a built-in files entity backed by its own private storage. Uploads go directly from the client to storage via a presigned URL — file bytes never pass through the API — so there's no request-size ceiling beyond the per-file cap, and no base64 encoding anywhere.

The three steps

1. Mint a presigned upload URL

POST /files/presigned-url
[
  { "filename": "invoice.pdf", "mimeType": "application/pdf", "size": 248311 }
]

The body is a bare JSON array, not an object wrapping one. One to fifty entries per call.

→ 200
{
  "files": [
    {
      "filename": "invoice.pdf",
      "s3Key": "TEN3/9f2c…/invoice.pdf",
      "uploadUrl": "https://<bucket>.s3.amazonaws.com/",
      "uploadFields": { "key": "…", "Content-Type": "application/pdf",
                        "bucket": "…", "policy": "…", "x-amz-signature": "…" }
    }
  ]
}

Validation is all-or-nothing. If any entry is oversized or carries a blocked mimeType, the whole call fails and nothing is minted — so you never have to work out which of N URLs are usable. Keep the s3Key; step 3 needs it.

2. Upload

POST multipart/form-data to uploadUrl, with every key/value from uploadFields as form fields first, then the file itself last, under the field name file.

const form = new FormData();
Object.entries(uploadFields).forEach(([k, v]) => form.append(k, v));
form.append("file", fileBlob);
await fetch(uploadUrl, { method: "POST", body: form });   // → 204

Send no Authorization header — the presigned policy is the credential — and don't set Content-Type on the request yourself; let the form encoding set the boundary. Success is 204 No Content with an empty body. The URL is valid for 15 minutes; mint a fresh one rather than caching it.

3. Register the file

POST /files
{ "filename": "invoice.pdf", "mimeType": "application/pdf",
  "size": 248311, "s3Key": "TEN3/9f2c…/invoice.pdf" }

→ 201 { "id": "FIL12", "filename": "invoice.pdf", "category": "document", … }

There's no separate confirm step. This call verifies the object actually landed in storage, and that its real size and content type match what you declared, before creating the record. A files record therefore always corresponds to a real uploaded object — there is no "pending" or "orphaned" state to handle.

If step 2 was skipped or failed, this returns a 400. category is derived from mimeType server-side; never send it.

Downloading

GET /files/FIL12
→ 200 { "id": "FIL12", "filename": "invoice.pdf", …,
        "downloadUrl": "https://…&X-Amz-Expires=300" }

downloadUrl is minted fresh on every read and is valid for 5 minutes, with Content-Disposition: attachment. Never store or cache it — re-read the record when the user clicks download. Storage is private; there is no public URL.

A collection GET /files does not include downloadUrl on its items. That is deliberate: presigning every item of an unbounded list would cost real latency for URLs mostly never used. Render a list from the metadata, then fetch the individual record when the user actually asks for the file. There is no bulk-get-by-id yet, so that is one request per file.

Attaching files to your own entities

Use an ordinary reference field targeting files. There is no special field type.

create_field(entity_id="ENT7", name="attachments", type="reference", multiple=true,
             targets=[{"entity": "files", "projection": ["filename", "mimeType"]}])
POST /invoices
{ "number": "INV-1001", "attachments": [{ "id": "FIL12" }] }

GET /invoices/INV9
{ "number": "INV-1001",
  "attachments": [{ "id": "FIL12", "filename": "invoice.pdf",
                    "mimeType": "application/pdf" }] }

The embedded form carries metadata only — never a downloadUrl. To download an attachment, read GET /files/{id} with the embedded id.

Reverse lookup works like any reference field: GET /invoices?attachments=FIL12 finds every invoice carrying that file, with no search: true needed.

The files entity

FieldTypeNotes
filenametext, required
mimeTypetext, required, filterable
sizenumber, requiredBytes. Cross-checked against the real object.
s3Keytext, requiredStorage location. Send it on create; don't construct it yourself.
categoryenum, filterable, computedimage, document, video, audio, archive, other — derived from mimeType and faceted, so ?facets=category gives a breakdown for free.
downloadUrltext, computed, never storedPresent only on a single-record GET.

files is extensible the same way users is — add your own fields, such as a caption, an uploadedBy, or a reference to a folder entity.

Limits

  • 100 MB per file, a single global cap. No multipart upload above it.
  • 50 files per presigned-url call.
  • Executable and script mime types are blocked at mint time.
  • Soft delete does not remove the stored object. DELETE /files/{id} flips the record's status like any entity; the bytes stay in storage.

Not available today: virus and malware scanning, content verification beyond the mime-type check, image thumbnails or transforms, CDN delivery, per-file access rules beyond tenant isolation, and storage quota metering. See Limits for the platform-wide list.