Skip to main content

The media asset store

Every piece of rich content a customer or your organization uploads — a campaign image, an email logo, a brand favicon, a document attached to a message — travels through one server-to-server surface: the files API. Upload a file once, receive a signed read URL for it, and reference that asset from email templates, WhatsApp media, RCS catalogs, branding settings, or any other surface that renders it. Manage assets in the dashboard under Developer > Media, or directly against the endpoints covered below.
This page is the concept for the files API. For the step-by-step operator walkthrough — browsing, re-minting links, and deleting from the dashboard — read Manage uploaded files with the Media Library. For the raw endpoint contract and multi-language SDK samples, see the Files API reference.

Section 1 — What the files API is for

The files API stores a single uploaded object per call in a private, tenant-scoped media bucket. It exists so that any image, audio clip, video, PDF, or spreadsheet your messages and templates cite is hosted by you, under your own access scope, rather than on a third-party CDN link that can expire or be repurposed. Upload paths across Orbit funnel into this store: Upload an asset with POST /api/v1/files/upload — one multipart form with a single file field — and receive an UploadResult envelope holding the asset’s id, a signed read URL, the sanitized filename, the stored content type, and its size:
cURL
A successful upload returns:
The returned url is what you store in a template block or brand setting. Read it back with GET /api/v1/files (cursor-paginated list), GET /api/v1/files/:id (single metadata), and remove it with DELETE /api/v1/files/:id.

Section 2 — Object keys and the tenant boundary

Every object lands under <tenant>/<fileId>_<filename>, so a file you upload stays inside your tenant’s namespace. For a tenant with schema name tenant_8f9e2d1c-4b3a-4f8e-9c7b-1234567890ab, an uploaded logo.png becomes:
Filenames are sanitized on ingest: directory separators and .. path-traversal sequences are stripped, and any character outside letters, digits, dash, underscore, and dot is removed. The tenant boundary is enforced server-side in apps/api/src/routes/files/files.service.ts:
  • uploadFile writes to ${tenantSchema}/${fileId}_${safeName}.
  • getFile, presignFileUrl, and deleteFile all scan with the prefix ${tenantSchema}/${fileId}_. The trailing underscore is a lookup anchor: without it, a lookup for file_abc could match a sibling object file_abcd_xxx.png and return or remove the wrong file.
  • refreshSignedMediaUrl re-mints a stored URL only when the parsed object key starts with ${ownerTenantSchema}/. A caller-controllable URL pointing at another tenant’s object is left untouched.

Section 3 — Content-type gates and reject envelopes

The upload pipeline rejects a file before its bytes are stored when the declared type, magic bytes, or size fail the gate.

Accepted types

The allow-list in files.service.ts covers:
  • Images: image/jpeg, image/png, image/gif, image/webp
  • Audio: audio/mpeg, audio/ogg, audio/wav, audio/aac, audio/amr
  • Video: video/mp4, video/3gpp
  • Documents: application/pdf, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
  • Text / data: text/plain, text/csv
  • Archives: application/zip (served only from private signed URLs, never inline)
The controller allow-list (files.controller.ts) is kept in lock-step with the service list; either rejects a MIME outside this set.

Reject envelopes

Three distinct gate failures return three distinct envelopes: Unsupported MIME — 422
Script-capable formats such as SVG are excluded deliberately — SVG can embed JavaScript and would expose the platform to stored-XSS if served from an organization-controlled path. Magic-byte mismatch — 422
This catches a renamed executable claiming to be a JPEG. Oversized — 413
The size cap is MAX_FILE_UPLOAD_BYTES = 25 * 1024 * 1024 in @devotel/shared/constants (25 MB), enforced by the multipart parser stream limit, the post-buffer guard, and the service check.

Section 4 — Signed URLs are the only egress

Objects are stored privately; the only way to read them is a signed URL minted at upload or on demand. Default lifetime is one hour. Callers that pin a URL into a long-lived row — a WhatsApp media field, an email image block, a brand logo — must either request a longer lifetime at upload (up to seven days, the V4-signing maximum) or mint a fresh URL on demand with GET /api/v1/files/:id/presign. Pass ttl_ms (in milliseconds, clamped to 60 s–7 d) on upload and presign. This is the envelope your content has to fit:

Re-mint state diagram

Several read paths re-mint URLs for you so a stored link does not die under a customer-facing page. When an email is re-opened, when a white-label sign-in or dashboard sidebar renders, and when a message preview resolves media, the platform checks whether the stored URL points at one of your objects and, if so, issues a fresh short-lived URL for it. That re-mint runs only for objects under your organization’s namespace, so a request carrying a reference to someone else’s asset fails to resolve rather than silently borrowing it. If a stored signed URL has already expired, the platform drops it and takes the consumer’s default fallback rather than serving a guaranteed 403. You can always re-mint manually:
cURL
Response:

Section 5 — Size, quota, and billing

Upload size is capped per file at 25 MB. Storage is bounded per organization; the cap shows in your billing surface as usage rather than a rejected request. A 413 PAYLOAD_TOO_LARGE means the object exceeded the 25 MB ceiling — split the asset, compress it, or host your own CDN link and reference it instead.

Section 6 — Audit events

Every upload and every delete emits an audit event (file.uploaded / file.deleted) with the user id, organization id, filename, and content type, so a run of removals or unexpected uploads is visible from Compliance > Audit the same way template or API-key changes are. The controller writes the audit row via safeAuditLog in apps/api/src/routes/files/files.controller.ts:
The audit layer backfills tenantId from organizationId so the row is scoped correctly in per-tenant audit views. Sample webhook/audit envelope emitted for an upload:

Section 7 — Worked example: upload to audit trail

This example ties the sections together end to end. 1. Upload a campaign image with a 7-day TTL
Store the returned id and url. Because ttl_ms is set to the 7-day ceiling, the URL is safe to pin into an email template that may not send immediately. 2. The TTL expires after 7 days A recipient opens the email two weeks later. The platform sees the stored URL, extracts the file id, verifies the object lives under your tenant prefix, and re-mints a fresh short-lived URL for the render. 3. Manually re-mint after expiry If your integration stores the URL outside Orbit, call presign directly:
4. View the audit trail Go to Compliance > Audit and filter by file.uploaded or file.deleted. Each row shows who performed the action, which file id was touched, and the original filename and MIME type.

Section 8 — When the URL terminus is custom-domain-terminating

Egress for an asset is the signed URL, not a platform hostname. If you front customer-facing previews or download links behind your own custom domain, a signed URL still feeds them — the custom domain terminates the dashboard/sign-in surface (see Custom domains and managed SSL), while media stays on the signed URL path. Pair the two surfaces cleanly: use custom domains for your white-label address, and the files API for the asset itself.

Cross-references