Files & Media
Uploading, fetching, replacing and deleting files: the exact request and response shapes, size and type limits, thumbnails, how a record points at a file rather than containing it, who can read one — and why a record export does not include your attachments.
Overview
| Method | Path | What it does |
|---|---|---|
| POST | /api/v1/files/{domain}/upload | Upload one file. Returns a file reference. |
| POST | /api/v1/files/{domain}/upload/batch | Upload up to 10 files in one request. |
| GET | /api/v1/files/{domain}/{file_id} | Fetch the bytes. Add ?signed=true to get a time-limited URL instead. |
| GET | /api/v1/files/{domain}/{file_id}/thumb | Fetch the generated thumbnail. Images only. |
| PUT | /api/v1/files/{domain}/{file_id} | Replace the bytes behind an existing identifier. |
| DELETE | /api/v1/files/{domain}/{file_id} | Delete the file and its thumbnail. |
ℹ️ Identifiers tell you what you are holding
The File Reference
| Field | Type | Always present? | Meaning |
|---|---|---|---|
| file_id | string | Yes | The identifier. img_ or file_ plus twelve characters. |
| filename | string | Yes | The stored filename — sanitized, so not necessarily byte-identical to what you sent. See the note below. |
| mime_type | string | Yes | The content type recorded at upload. |
| size_bytes | integer | Yes | Size of the stored bytes. |
| url | string | Yes | A relative path: /api/v1/files/{domain}/{file_id}. It is not an absolute URL and it is not public. |
| thumbnail_url | string | Images only | Relative path to the thumbnail. Absent for non-images and for SVG. |
| width | integer | Images only | Pixel width. Absent for non-images and for SVG. |
| height | integer | Images only | Pixel height. Absent for non-images and for SVG. |
| uploaded_at | string | Yes | UTC timestamp, YYYY-MM-DDTHH:MM:SSZ. |
⚠️ Absent, not null
ℹ️ There is no uploader field
Uploading
One file
curl -X POST https://api.supero.dev/api/v1/files/acme-corp/upload \
-H "X-API-Key: ak_..." \
-F "file=@/path/to/invoice.pdf;type=application/pdf"{
"file_id": "file_A1b2C3d4E5f6",
"filename": "invoice.pdf",
"mime_type": "application/pdf",
"size_bytes": 184320,
"url": "/api/v1/files/acme-corp/file_A1b2C3d4E5f6",
"uploaded_at": "2026-08-26T09:14:07Z"
}{
"file_id": "img_7Gh8Ij9Kl0Mn",
"filename": "front-view.jpg",
"mime_type": "image/jpeg",
"size_bytes": 918273,
"url": "/api/v1/files/acme-corp/img_7Gh8Ij9Kl0Mn",
"thumbnail_url": "/api/v1/files/acme-corp/img_7Gh8Ij9Kl0Mn/thumb",
"width": 2400,
"height": 1600,
"uploaded_at": "2026-08-26T09:15:22Z"
}Several files
curl -X POST https://api.supero.dev/api/v1/files/acme-corp/upload/batch \
-H "X-API-Key: ak_..." \
-F "files[][email protected]" \
-F "files[][email protected]" \
-F "files[][email protected]"{
"files": [
{ "file_id": "img_7Gh8Ij9Kl0Mn", "filename": "front.jpg", "...": "..." },
{ "file_id": "img_2Op3Qr4St5Uv", "filename": "side.jpg", "...": "..." }
],
"failed": [
{ "filename": "clip.mp4", "error": "Unsupported file type: video/mp4" }
]
}⚠️ A batch with failures still returns 201
Replacing a file in place
curl -X PUT https://api.supero.dev/api/v1/files/acme-corp/file_A1b2C3d4E5f6 \
-H "X-API-Key: ak_..." \
-F "file=@/path/to/invoice-v2.pdf"Fetching, Thumbnails and Signed URLs
Fetching the bytes
curl https://api.supero.dev/api/v1/files/acme-corp/file_A1b2C3d4E5f6 \
-H "X-API-Key: ak_..." \
-o invoice.pdf- •Content-Disposition is always inline, with the stored filename. There is no attachment mode and no download parameter — if you want a browser to save rather than display, do it on your side.
- •The Content-Type on the response is derived from the stored filename rather than echoed from the type recorded at upload. For ordinary files with ordinary extensions these agree; for an unusual or missing extension they can differ, so trust mime_type on the reference if the distinction matters to you.
Thumbnails
curl https://api.supero.dev/api/v1/files/acme-corp/img_7Gh8Ij9Kl0Mn/thumb \
-H "X-API-Key: ak_..." \
-o thumb.jpgℹ️ The size is fixed
Time-limited URLs
{
"signed_url": "https://...",
"expires_at": "2026-08-26T10:14:07Z"
}⚠️ Signed URLs depend on how the deployment stores files
Size and Type Limits
| Limit | Value | What happens past it |
|---|---|---|
| Maximum file size | 25 MB per file (26,214,400 bytes) | 400 with a message naming the limit. Applies to upload and to replace. |
| Maximum files per batch | 10 | 201 with an empty files array and one failed entry explaining the limit. |
| Allowed content types | A fixed allowlist — see below | 400, "Unsupported file type: <type>". |
| Storage quota | Not checked at upload time | An upload is not rejected for quota. Plan storage allowances are metered separately — see Plans & Billing. |
| Rate limit | None on the authenticated file endpoints | Anonymous image serving is rate limited; see Rate Limits & Quotas. |
| Group | Accepted content types |
|---|---|
| Images | image/jpeg, image/png, image/gif, image/webp, image/svg+xml, image/bmp, image/tiff |
| Documents | application/pdf, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-powerpoint, application/vnd.openxmlformats-officedocument.presentationml.presentation |
| Text and data | text/plain, text/csv, text/html, application/json, application/xml |
| Archives | application/zip, application/gzip, application/x-tar |
⚠️ No audio and no video
💡 The SDKs are stricter than the API for images
Files and Records
{
"name": "Vehicle",
"fields": [
{ "name": "registration", "type": "string", "required": true },
{ "name": "inspection_report", "type": "File" },
{ "name": "photos", "type": "Image", "list": true }
]
}Attaching a file is two calls
# 1. Upload, and keep the reference from the 201 body.
REF=$(curl -s -X POST https://api.supero.dev/api/v1/files/acme-corp/upload \
-H "X-API-Key: $SUPERO_API_KEY" \
-F "[email protected]")
# 2. Store the whole reference object on the record's File field.
curl -X PUT https://api.supero.dev/api/v1/crud/acme-corp/fleet:vehicle/9f2b1c3a-8e6d-4b21-9a1f-0c7e2d4b5f10 \
-H "X-API-Key: $SUPERO_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"inspection_report\": $REF}"The attachment service
Who Can Read a File
| Operation | Permission required |
|---|---|
| Upload, batch upload, replace | Create permission |
| Fetch bytes, fetch thumbnail | Read permission |
| Delete | Create permission — not delete permission. See the note below. |
⚠️ Deleting a file is gated on create, not delete
🚨 File access is scoped to the domain only
Anonymous read paths
- •Public image serving. Images published through the image library are readable anonymously by their record identifier, subject to a per-address rate limit, a referring-site check, and the record still being active. Responses are cached for 24 hours and carry hardening headers; anything that is not a raster image is served as a download rather than displayed inline.
- •A published app's own public file path. A generated application can serve the specific files its public landing page references, without a visitor credential. The set is restricted to files that page actually references — it is not a way to read arbitrary files by identifier.
🚨 Anything your public page references becomes publicly readable
Exporting Files
⚠️ A record export does not include your attachments
import glob, json, os, urllib.request
BASE = "https://api.supero.dev"
DOMAIN = "acme-corp"
KEY = os.environ["SUPERO_API_KEY"] # ak_...
OUT = "attachments"
def find_refs(node, found):
"""A file reference is any object carrying both file_id and url.
They nest arbitrarily deep and appear inside list fields, so recurse."""
if isinstance(node, dict):
if "file_id" in node and "url" in node:
found.append(node)
else:
for value in node.values():
find_refs(value, found)
elif isinstance(node, list):
for value in node:
find_refs(value, found)
return found
refs = []
for path in sorted(glob.glob("*.json")):
if path.startswith("_"): # _schemas.json is not records
continue
find_refs(json.load(open(path)), refs)
os.makedirs(OUT, exist_ok=True)
seen, failed = set(), []
for ref in refs:
fid = ref["file_id"]
if fid in seen: # the same file can be on many records
continue
seen.add(fid)
req = urllib.request.Request(
f"{BASE}/api/v1/files/{DOMAIN}/{fid}",
headers={"X-API-Key": KEY},
)
try:
with urllib.request.urlopen(req) as r:
body = r.read()
except Exception as exc: # 404 = the record outlived the file
failed.append((fid, ref.get("filename"), str(exc)))
continue
# Keep the id in the name: two records can carry the same filename.
with open(os.path.join(OUT, f"{fid}__{ref['filename']}"), "wb") as fh:
fh.write(body)
if ref.get("size_bytes") not in (None, len(body)):
print(f" size mismatch on {fid}: {ref['size_bytes']} recorded, {len(body)} fetched")
print(f"{len(refs)} references, {len(seen)} distinct files, {len(failed)} failed")
for fid, name, err in failed:
print(f" MISSING {fid} ({name}): {err}")- •It runs with the permissions of the credential you use, exactly like the record export. A key scoped to one tenant exports one tenant's records — and therefore only reaches the file identifiers those records carried.
- •Print the failure list and read it. A reference whose file returns 404 means the record outlived the bytes, which is what a delete without a corresponding record update leaves behind. Discovering that during a migration is much worse than discovering it now.
- •Check the count. Distinct files should be at or below the reference count; if a size mismatch is reported, the file was replaced after the record was written and your record metadata is stale.
- •Thumbnails are not worth exporting. They are regenerated from the original on upload, so re-uploading the originals elsewhere reproduces them.
💡 Run the second pass on the same schedule as the first
Working with the SDKs
# Upload only — returns a reference you can store yourself
ref = org.files.upload("reports/inspection.pdf")
print(ref.file_id, ref.mime_type, ref.size_display)
# Upload and write it onto a record's field in one call
ref = org.files.attach(vehicle["uuid"], "fleet:vehicle", "inspection_report", "inspection.pdf")
# List fields append by default
ref = org.files.attach(vehicle["uuid"], "fleet:vehicle", "photos", "front.jpg")
# Several at once (ten maximum)
result = org.files.upload_batch(["front.jpg", "side.jpg", "rear.jpg"])
print(result.succeeded, result.failed_count)
# Fetch
content, filename, mime_type = org.files.download(ref.file_id)
org.files.download_to(ref.file_id, "./exports/")
# Remove the reference from the record and delete the bytes
org.files.detach(vehicle["uuid"], "fleet:vehicle", "inspection_report", ref.file_id)const ref = await org.files.upload('/tmp/logo.png');
console.log(ref.fileId, ref.mimeType, ref.sizeBytes);
await org.files.attach(vehicle.uuid, 'fleet:vehicle', 'photos', '/tmp/front.jpg');
const batch = await org.files.uploadBatch(['/tmp/a.jpg', '/tmp/b.jpg']);
console.log(batch.succeeded, batch.failedCount);
await org.files.delete(ref.fileId);ℹ️ What attach and detach actually do
What Is Not Available
- •No file listing endpoint. You cannot enumerate the files in a domain, filter them, or page through them. Files are found by reading the records that reference them, which means a file whose record was deleted without deleting the file is effectively unreachable.
- •No upload-directly-to-storage flow. Bytes always pass through the API, so the 25 MB ceiling is a real ceiling and not a client-side default you can raise.
- •No forced-download mode. The fetch response is always inline.
- •No configurable thumbnail size, and no thumbnails for SVG or for non-image types.
- •No audio or video uploads.
- •No uploader identity on the file itself.
- •No per-file access control. Permission is evaluated at the domain level for the whole endpoint family, not per file — see the isolation note above.
- •No virus or malware scanning on the upload path. The attachment service has a scanned state and a scan result field, but the operation that sets them takes the result as an input — it records a verdict you supply from your own scanner rather than producing one. Treat every uploaded file as untrusted content.
- •No client-settable expiry on a signed URL. One hour is the only window.
🚨 Deleting a file leaves the record pointing at nothing
Next Steps
- •Schema Reference — declaring File and Image fields, and list: true for galleries.
- •Managing Data — the record calls the second half of every attach depends on.
- •Dynamic UI — the upload and thumbnail editors these field types drive.
- •Transactional Services — the attachment service and its lifecycle.
- •Continuity & Exit — the record export that this page's second pass completes.
- •Rate Limits & Quotas — what is throttled, including anonymous image serving.
On this page