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
| Field | Type | Notes |
|---|---|---|
filename | text, required | |
mimeType | text, required, filterable | |
size | number, required | Bytes. Cross-checked against the real object. |
s3Key | text, required | Storage location. Send it on create; don't construct it yourself. |
category | enum, filterable, computed | image, document, video, audio, archive, other — derived from mimeType and faceted, so ?facets=category gives a breakdown for free. |
downloadUrl | text, computed, never stored | Present 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.