S
supero.docs
Documentation/Building Your App/Files & Media

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

Files in Supero live in their own storage, addressed by a file identifier, and are uploaded and fetched through a dedicated endpoint family under /api/v1/files. Records do not contain file bytes. A record field of a file type holds a small JSON object — the file reference — that names the file, its type, its size and where to fetch it.
That split is the single most important thing on this page, because it has consequences everywhere else: a record read is fast regardless of how large its attachments are, a record can be copied without copying gigabytes, and a record export contains none of the bytes. The last of those catches people out during a migration, so it has its own section below.
MethodPathWhat it does
POST/api/v1/files/{domain}/uploadUpload one file. Returns a file reference.
POST/api/v1/files/{domain}/upload/batchUpload 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}/thumbFetch 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.
That is the entire surface — six operations. Note what is not in it: there is no endpoint that lists your files. Files are discovered by reading the records that reference them, which is covered under Files and records.

ℹ️ Identifiers tell you what you are holding

A file identifier is a prefix plus twelve alphanumeric characters. Images get img_ and everything else gets file_ — for example img_7Gh8Ij9Kl0Mn or file_A1b2C3d4E5f6. An identifier that does not match that shape is rejected as a 404 rather than a 400, so a malformed identifier and a missing file look identical from the outside.

The File Reference

Every upload returns a file reference, and that same object is what you store on the record. It is the metadata — there is no second lookup to make and no separate metadata endpoint to call.
FieldTypeAlways present?Meaning
file_idstringYesThe identifier. img_ or file_ plus twelve characters.
filenamestringYesThe stored filename — sanitized, so not necessarily byte-identical to what you sent. See the note below.
mime_typestringYesThe content type recorded at upload.
size_bytesintegerYesSize of the stored bytes.
urlstringYesA relative path: /api/v1/files/{domain}/{file_id}. It is not an absolute URL and it is not public.
thumbnail_urlstringImages onlyRelative path to the thumbnail. Absent for non-images and for SVG.
widthintegerImages onlyPixel width. Absent for non-images and for SVG.
heightintegerImages onlyPixel height. Absent for non-images and for SVG.
uploaded_atstringYesUTC timestamp, YYYY-MM-DDTHH:MM:SSZ.

⚠️ Absent, not null

Fields with no value are omitted from the JSON rather than serialised as null. Upload a PDF and the response has no thumbnail_url key at all — not "thumbnail_url": null. A client written as ref.thumbnail_url === null will never match; write it as a presence check instead. The same applies to width and height.
The filename you send is sanitized before it is stored: characters outside letters, digits, dot, dash and underscore are dropped, leading underscores are stripped, the name is truncated to 100 characters, an absent extension becomes .bin, and an empty result becomes upload. Read the filename back from the response rather than assuming it round-trips — if you are showing users the name they chose, keep your own copy of it on the record.

ℹ️ There is no uploader field

A file reference records what the file is, not who put it there. There is no uploader, created_by or owner field on it. If you need attribution, record it on the owning record — a created_by field on your own schema, or the audit trail for the write that attached it.

Uploading

Uploads are ordinary multipart/form-data of raw bytes. There is no base64 encoding step and no upload-directly-to-storage handshake — the bytes go through the API, which is why the size limit is what it is.

One file

The form field is named file. Send the content type with the part when you know it; when it is missing or generic, it is inferred from the filename.
bash
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"
A successful upload returns 201 with the file reference:
json
{
  "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"
}
An image gets three more fields, because a thumbnail is generated and the dimensions are read at upload time:
json
{
  "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"
}
A missing file part returns 400. A file that is too large or of an unsupported type also returns 400, with the reason in the body.

Several files

The batch endpoint takes repeated parts named files[] — up to ten. A part name of files is also accepted if no files[] parts are present, but files[] is the shape to write against.
bash
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]"
json
{
  "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

The status code reports that the request was processed, not that every file succeeded. A batch where two of three files failed returns 201 with a populated failed array, and so does a batch that exceeded the ten-file limit — that one comes back with an empty files array and a single failed entry explaining the limit. Two consequences: never treat 201 as "all uploaded", always read failed; and the limit-exceeded entry carries only an error, with no filename key, so a client that reads entry["filename"] unconditionally will throw on exactly the case it was written to report.

Replacing a file in place

PUT to the file's own path with a new file part swaps the bytes while keeping the identifier and the URL. Any record already pointing at that identifier now resolves to the new content with no record write at all — which is the point, and also the risk. The size and type rules are re-applied, and the thumbnail is regenerated.
bash
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"
It returns 200 with the updated reference. Note that size_bytes and mime_type on the record you stored earlier are now stale — if you display them, write the new reference back onto the record too.

Fetching, Thumbnails and Signed URLs

Fetching the bytes

GET the file's path and the bytes are streamed back to you. This is an authenticated request like any other — the response is the file itself, not a redirect and not a link.
bash
curl https://api.supero.dev/api/v1/files/acme-corp/file_A1b2C3d4E5f6 \
  -H "X-API-Key: ak_..." \
  -o invoice.pdf
Two details worth knowing before you build a viewer on top of it:
  • •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

Thumbnails are generated at upload time, not on demand, for raster images only. They are JPEG, and they fit inside 200 by 200 pixels with the aspect ratio preserved — so a wide image comes back 200 pixels wide and shorter than 200 tall, not stretched or cropped. The response is cached for 24 hours.
bash
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

There is no size, width or height parameter on the thumbnail route — 200 by 200 is the only variant that exists. If your interface needs a different size, generate it yourself and upload it as its own file, or scale the thumbnail in CSS. Requesting a thumbnail for a non-image, for an SVG, or for a file that has none returns 404; the presence of thumbnail_url on the reference is the reliable test.

Time-limited URLs

Adding ?signed=true changes the response from bytes to JSON: a URL that carries its own authorisation for a limited window, plus the expiry.
json
{
  "signed_url": "https://...",
  "expires_at": "2026-08-26T10:14:07Z"
}
The window is one hour, and it is not adjustable — there is no expiry parameter to send. Use this to hand a browser, a PDF renderer or a third-party service a URL it can fetch without your credential; do not use it as a sharing mechanism, because anyone holding the URL can fetch the file until it expires.

⚠️ Signed URLs depend on how the deployment stores files

On Supero Cloud, ?signed=true returns a working time-limited HTTPS URL. On a self-hosted deployment backed by local disk rather than object storage, it may return a URL a browser cannot use, or 404 with a message about failing to generate one. If you are building on-premise, verify this path early rather than designing around it — the direct authenticated fetch works on every deployment.

Size and Type Limits

LimitValueWhat happens past it
Maximum file size25 MB per file (26,214,400 bytes)400 with a message naming the limit. Applies to upload and to replace.
Maximum files per batch10201 with an empty files array and one failed entry explaining the limit.
Allowed content typesA fixed allowlist — see below400, "Unsupported file type: <type>".
Storage quotaNot checked at upload timeAn upload is not rejected for quota. Plan storage allowances are metered separately — see Plans & Billing.
Rate limitNone on the authenticated file endpointsAnonymous image serving is rate limited; see Rate Limits & Quotas.
The type check is on the content type, not on the file extension. There is no extension allowlist to configure and renaming a file changes nothing. The accepted types are:
GroupAccepted content types
Imagesimage/jpeg, image/png, image/gif, image/webp, image/svg+xml, image/bmp, image/tiff
Documentsapplication/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 datatext/plain, text/csv, text/html, application/json, application/xml
Archivesapplication/zip, application/gzip, application/x-tar

⚠️ No audio and no video

There is no audio/* or video/* type on the allowlist, so an mp3 or an mp4 is rejected with 400 regardless of size. This surprises people because some client-side helpers will happily guess a media content type from the extension and send it — the guess is made on your machine, and the rejection happens at the server. If your application handles media, host it elsewhere and store the URL on the record as a plain string field.

💡 The SDKs are stricter than the API for images

Both SDKs apply their own client-side size check before sending, and for image content types that check is 10 MB rather than 25 MB. A 15 MB photograph is therefore refused by the SDK with a local error and accepted by the API over a direct multipart call. If you hit an unexpected size error that never reached the network, this is why.

Files and Records

Two system field types connect a record to a file: File for documents and Image for pictures. Declare a field with one of them, and the generated interface renders an upload control and a thumbnail preview for it. Adding list: true to the field makes it a gallery — the field then holds an array of references rather than one. See Schema Reference for field declaration and Dynamic UI for the editors these types drive.
json
{
  "name": "Vehicle",
  "fields": [
    { "name": "registration", "type": "string", "required": true },
    { "name": "inspection_report", "type": "File" },
    { "name": "photos", "type": "Image", "list": true }
  ]
}
The value stored on the record is the whole file reference object, nested inside the record document — not a bare identifier string and not a URL. That is why a record read gives you the filename, size and thumbnail path without a second call.

Attaching a file is two calls

There is no endpoint that uploads and attaches in one request over REST. Upload first, then write the returned reference onto the record with an ordinary update — see CRUD Endpoints.
bash
# 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}"
For a list field, read the current array, append the new reference, and write the whole array back. The SDKs wrap both of these — see Working with the SDKs below, where attach does the two calls for you and rolls the uploaded file back if the record write fails.

The attachment service

If you need more than a field — a lifecycle around the upload, with states such as pending upload, uploaded, scanned, archived and failed — extend the attachment base schema instead of declaring a bare File field. It wraps the same file type and adds the operations and states, in the same way as every other transactional service. Support & Ticketing shows it composed with tickets and comments.

Who Can Read a File

Every file endpoint requires authentication. All three normal credentials work — a user JWT, a service API key, and, for a signed-in browser, the session established at login, which is what allows an interface to render an image URL directly without attaching a header. See Authentication.
OperationPermission required
Upload, batch upload, replaceCreate permission
Fetch bytes, fetch thumbnailRead permission
DeleteCreate permission — not delete permission. See the note below.

⚠️ Deleting a file is gated on create, not delete

A role that can create records can also delete files, whether or not it has delete permission. If part of your access design is a role that may add documents but must never remove them, that separation does not hold on this surface — the guard you need is your own, applied before you call the endpoint. Verify it against a real credential rather than assuming the role catalogue covers it; see Access Control (RBAC).

🚨 File access is scoped to the domain only

The isolation boundary on this endpoint family is the domain in the path. There is no project or tenant scoping on a file: any caller with read permission in the same domain who holds the identifier can fetch the bytes, regardless of which project or tenant the owning record belongs to, and regardless of the access policy on that record. Identifiers are random enough not to be guessable, but do not treat one as a secret and do not rely on file storage to separate sensitive documents between tenants. If one tenant's documents must be unreachable by another, that separation has to be a separate domain — not a field on a record. See Building Multi-Tenant Apps.

Anonymous read paths

Two narrow surfaces serve files without a credential, and both exist so that a public page can show an image. Neither exposes your file storage generally, but both are worth knowing about before you decide where a document lives.
  • •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

Putting a file behind a public landing page or the image library makes it fetchable by anyone who has the URL, with no login and no audit of who read it. Deactivating the record stops new reads, but a cached copy can outlive it and a copy already downloaded cannot be recalled. Never reference a signed contract, an invoice, an identity document or anything else tenant-private from a public page — upload a separate, deliberately public asset instead.

Exporting Files

⚠️ A record export does not include your attachments

Exporting your records gives you JSON containing file references — the identifier, the filename, the size, the relative path. It does not contain a single byte of any uploaded file. If your application holds signed documents, invoices, scans or user uploads, an export that looks complete because every record came back is missing the half that usually matters most. A complete extract is two passes: records first, then a walk over the file identifiers in those records fetching each one.
The first pass is the record export script on Continuity & Exit, which writes one JSON file per object type. The script below is the second pass. It walks those files, finds every file reference in them — references are nested inside records and inside arrays, so a shallow scan of top-level fields will miss most of them — and downloads each distinct file once.
python
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}")
Points worth knowing before you rely on it:
  • •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

Since a delete is permanent and there is no restore, your own export is the only recovery path that exists — and an export of records without their attachments is a recovery path that restores the paperwork and loses the documents. If you schedule the record export, schedule this alongside it.

Working with the SDKs

Both SDKs expose the same endpoints through org.files, plus convenience methods that combine an upload with the record write. The Python SDK uses snake_case names, the JavaScript SDK camelCase; the behaviour is the same. See Python SDK and JavaScript SDK.
python
# 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)
javascript
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

attach uploads and then writes the reference onto the record, and if the record write fails it deletes the file it just uploaded so you are not left with an orphan. The batch variant does not roll back, so a failed record write after a batch upload leaves the uploaded files in storage. detach goes the other way and deletes the bytes before removing the reference — so an interrupted detach leaves a record pointing at a file that no longer exists.
A loaded record also gets file methods directly on it, so record.attach(field, path), record.detach(field, file_id) and record.list_files(field) work without going through org.files. list_files is a local read of the field you already fetched, not a server call — it does not discover files you have not loaded.

What Is Not Available

Stated so you design around it rather than discovering it halfway through.
  • •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

Deleting a file removes the bytes and the thumbnail. It does not touch any record that references them, so every record still carries a reference that now resolves to 404, and nothing in the product will flag it. There is no undo and no restore — the bytes are gone, and your own export is the only copy that exists. Remove the reference from the record in the same operation, and prefer the SDK detach, which does both. If you are cleaning up in bulk, take an export of the files first.

Next Steps