Skip to main content

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 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.
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:
The leading segment is your tenant schema name — the same isolation boundary described in 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. 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:
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), 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: 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

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

See also

CDN assets model

The files API as a customer surface — templates, branding, and the re-mint behavior on read paths.

Recording lifecycle

Capture-to-purge states, the retention pre-delete event, and the safety-net bucket lifecycle.

Session replay

The inline-head plus durable-chunk split this plane stores.

Tenant isolation

The per-tenant boundary the object-key prefix enforces in storage.

Number lifecycle

Where the stricter regulatory bucket fits number provisioning.

Retention windows and deletion

The tenant-owned windows whose sweeps garbage-collect stored media.