> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Media file upload and the presign lifecycle

> The platform's object-storage plane end to end: how a file moves from POST multipart through the size, scope, and content gates into the tenant-prefixed GCS bucket, how signed read URLs are minted, time-boxed, and re-minted, how delete and orphan garbage collection work, and which surfaces — MMS, CDN assets, knowledge bases, wallet passes, session replay — consume the same plane.

# Media file upload and the presign lifecycle

Every binary your tenant stores — an MMS attachment, a brand logo, a
knowledge-base document, a wallet-pass image, a session-replay chunk —
lives on one object-storage plane: **objects in a private Google Cloud
Storage bucket, keyed under your tenant's prefix, reachable only through
time-boxed signed URLs**. The [media asset store](/concepts/cdn-assets-model)
page covers the files API as a customer surface; this page is the plane
underneath it — the bucket layout, the presign lifecycle, and the
shared failure modes every consumer inherits.

The mental model is three sentences. Uploads enter through authenticated
multipart endpoints and are validated before their bytes are stored.
Objects land privately under a tenant-prefixed key, so the leading path
segment is the owning tenant. Reads never proxy bytes through the API —
they mint a signed URL that dies on its own clock, so a stored link is
always a snapshot with an expiry, not a durable reference.

## Ingestion path — POST /files

The canonical ingest is `POST /api/v1/files/upload`: one
`multipart/form-data` body with a single `file` field.

```bash theme={null}
curl -X POST "https://orbit.devotel.io/api/v1/files/upload" \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -F "file=@statement.pdf;type=application/pdf"
```

Three gates run in front of the bucket write, in this order:

1. **Scope and role.** API-key callers must hold `files:read` or
   `files:write` for any file route; upload and delete additionally
   require `files:write` plus an `owner`, `admin`, or `developer` role.
   Dashboard session callers bypass the scope check and are gated on
   role alone.
2. **Size.** The multipart parser aborts the stream at the 25 MB
   ceiling, before the body buffers into memory. An oversized upload
   returns `413 PAYLOAD_TOO_LARGE`.
3. **Content.** The declared MIME type must be on the allow-list —
   images (JPEG, PNG, GIF, WebP), audio (MPEG, OGG, WAV, AAC, AMR),
   video (MP4, 3GPP), documents (PDF, Office formats), plain text, CSV,
   and ZIP. A magic-byte sniff then rejects files whose first bytes
   disagree with the declared type (a renamed executable claiming to be
   a JPEG fails here). Violations return `422 Unsupported file type`.
   SVG is excluded deliberately — it can embed script and would be a
   stored-XSS vector if served from a platform-controlled path.

The upload response is an envelope with the file id, a signed read URL,
the sanitized filename, the stored content type, the size, and the
upload timestamp. The filename is sanitized on ingest: directory
separators and `..` sequences are stripped, and any character outside
letters, digits, dash, underscore, and dot is removed.

## Bucket layout — the tenant prefix

A successful upload writes exactly one object:

```
<tenant_schema>/<file_id>_<safe_filename>
```

The leading segment is your tenant schema name — the same isolation
boundary described in [Tenant isolation](/concepts/tenant-isolation),
extended from database rows to object keys. The id is a `file_`-prefixed
token minted at upload; the underscore between id and filename is a
lookup anchor, so `GET /api/v1/files/:id`, presign, and delete all
resolve a file by scanning the `${tenantSchema}/${fileId}_` prefix and
can never match a sibling object whose id merely shares a leading
substring.

Two consequences worth building against:

* **Listing is prefix enumeration.** `GET /api/v1/files` scans your
  tenant prefix and returns cursor-paginated rows, each with a fresh
  one-hour signed URL. There is no separate metadata database — object
  metadata on the bucket is the record.
* **There is no cross-tenant read.** Read, presign, delete, and the
  internal re-sign helper all resolve inside the requesting tenant's own
  prefix. A URL that names another tenant's object fails to resolve
  rather than silently borrowing it.

The plane fans out to more than this one bucket. The generic media
bucket holds customer uploads; a stricter regulatory bucket holds
number-registration KYC evidence under `<tenant_id>/regulatory/<doc_id>`
with tighter IAM and retention; session-replay overflow chunks and
recordings write their own `gs://` paths. What is shared is the model:
private object, tenant-prefixed key, signed-URL egress.

## Presigned URL lifecycle

Objects are stored privately; the **signed URL is the only egress**. The
API never proxies object bytes — every read returns a URL backed by
GCS V4 signing.

| Property         | Value                                                     |
| ---------------- | --------------------------------------------------------- |
| Default lifetime | 1 hour                                                    |
| Minimum lifetime | 60 seconds                                                |
| Maximum lifetime | 7 days (the GCS V4-signing maximum)                       |
| TTL knob         | `ttl_ms` on upload and on `GET /api/v1/files/:id/presign` |

Upload accepts `ttl_ms` when you intend to pin the returned URL into a
long-lived row. Presign mints a fresh URL for an object that already
exists:

```bash theme={null}
curl "https://orbit.devotel.io/api/v1/files/file_01h.../presign?ttl_ms=86400000" \
  -H "Authorization: Bearer $ORBIT_API_KEY"
```

The presign response carries the URL plus its own clock: `expires_at`
(the instant the link dies) and `ttl_ms` (the effective lifetime after
clamping), so your integration knows exactly when to re-mint.

There is no revocation primitive — a signed URL lives until its expiry.
That is why the default is one hour and the floor is sixty seconds: pick
the shortest TTL that survives your consumer's render or playback
window, and re-mint on demand rather than pinning week-long links.

Several platform read paths re-mint for you. When an email is
re-opened, when white-label branding renders, or when a message preview
resolves archived media, the platform checks whether the stored URL
points at one of your objects and issues a fresh short-lived URL — only
for objects under your own tenant prefix, and it drops a URL it can
positively prove is already expired instead of serving a guaranteed
dead link.

## Delete and garbage collection

`DELETE /api/v1/files/:id` removes the object and returns 204; an upload
or delete against an id outside your tenant namespace returns 404, not
someone else's bytes. Every upload and delete emits an audit event
visible under **Compliance > Audit**.

Explicit delete is only half the story. Files you upload but never
attach — an abandoned MMS draft, an orphaned branding image — show up
as storage usage under your organization's billing rather than as a
rejected request, so clean-up is your control to exercise. For
channel-owned artifacts, retention sweeps do the deletion for you:
recordings purge their media when their window closes, after firing a
pre-delete event with a final download window (see [Recording
lifecycle](/concepts/recording-lifecycle)), and backing buckets carry a
hard lifecycle cap as a safety net so no object can outlive the
platform-wide ceiling regardless of the tenant posture. Retention
windows stay tenant-owned; the sweep is the garbage collector that
enforces them.

## Rate-limit surface

Limits are per method and fire at the API layer, in front of every
route:

| Operation                            | Limit                |
| ------------------------------------ | -------------------- |
| Upload (`POST /api/v1/files/upload`) | 10 requests / minute |
| List, get, presign                   | 60 requests / minute |
| Delete                               | 20 requests / minute |

These are API-plane limits, not storage limits: bulk backfills and
attachment-heavy imports should spread uploads across minutes or accept
a 429 and retry. Signed-URL downloads do not pass through the API and
are not rate-limited here — once you hold a live URL, reading through
it is a GCS operation.

## Who consumes this plane

| Surface                  | What it stores here                                     | Page                                                     |
| ------------------------ | ------------------------------------------------------- | -------------------------------------------------------- |
| MMS attachments          | Media files a message cites                             | [MMS composition](/concepts/mms-composition-model)       |
| CDN assets / branding    | Logos, favicons, campaign images                        | [CDN assets model](/concepts/cdn-assets-model)           |
| Knowledge-base ingestion | Uploaded source documents                               | [Knowledge pipeline](/concepts/knowledge-pipeline)       |
| Wallet passes            | Images and pass artifacts                               | [Wallet pass lifecycle](/concepts/wallet-pass-lifecycle) |
| Session replay           | Overflow event chunks under `session-replay/{schema}/…` | [Session replay](/concepts/session-replay)               |
| Recordings               | Captured audio/video media                              | [Recording lifecycle](/concepts/recording-lifecycle)     |
| Number registration      | KYC documents in the stricter regulatory bucket         | [Number lifecycle](/concepts/number-lifecycle)           |

Every consumer inherits the same properties: private storage, a
tenant-prefixed key, signed-URL-only egress, and expiry-by-default.
Surfaces that pin a URL into a durable row either request a longer TTL
at upload or re-mint on read.

## Failure modes

| Symptom                        | Cause                                                                       | Remedy                                                                                               |
| ------------------------------ | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `413 PAYLOAD_TOO_LARGE`        | File exceeds the 25 MB ceiling                                              | Split the asset, compress it, or host your own CDN link and reference it instead                     |
| `422 Unsupported file type`    | MIME outside the allow-list, or magic bytes disagree with the declared type | Convert to an accepted format; never rename a disallowed file to bypass the gate                     |
| `403` on a read                | API key missing `files:read` (upload/delete also need `files:write` + role) | Re-issue the key with the right scope; upload/delete additionally require owner, admin, or developer |
| Signed URL 403s in the browser | The signature expired — TTLs are one hour by default, seven days at most    | Mint a fresh URL via presign; never treat a stored signed URL as durable                             |
| `429`                          | Per-method rate limit exceeded                                              | Back off and spread the calls; limits reset per minute                                               |
| `404` on get/presign/delete    | The id does not exist in your tenant namespace                              | Re-list to confirm the id; a 404 here never exposes another tenant's object                          |
| Upload stops mid-transfer      | Client closed the connection                                                | Re-send the file — partial uploads are never committed                                               |

## See also

<CardGroup cols={2}>
  <Card title="CDN assets model" href="/concepts/cdn-assets-model">
    The files API as a customer surface — templates, branding, and the
    re-mint behavior on read paths.
  </Card>

  <Card title="Recording lifecycle" href="/concepts/recording-lifecycle">
    Capture-to-purge states, the retention pre-delete event, and the
    safety-net bucket lifecycle.
  </Card>

  <Card title="Session replay" href="/concepts/session-replay">
    The inline-head plus durable-chunk split this plane stores.
  </Card>

  <Card title="Tenant isolation" href="/concepts/tenant-isolation">
    The per-tenant boundary the object-key prefix enforces in storage.
  </Card>

  <Card title="Number lifecycle" href="/concepts/number-lifecycle">
    Where the stricter regulatory bucket fits number provisioning.
  </Card>

  <Card title="Retention windows and deletion" href="/concepts/retention-windows-and-deletion">
    The tenant-owned windows whose sweeps garbage-collect stored media.
  </Card>
</CardGroup>
