> ## 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.

# The media attach pipeline: upload, scan, and per-channel delivery

> How an uploaded file travels from the Files API through the virus scan and the per-channel size and type allow-lists to provider push and delivery receipt — the quarantine verdicts, the API surface that drives the upload path, and the audit rows each step leaves behind.

# The media attach pipeline: upload, scan, and per-channel delivery

Every image, audio clip, or document you send through Orbit travels one
pipeline: upload, virus scan, per-channel size and type checks, provider
push, delivery receipt. This page connects those stages to the exact API
calls and dashboard surfaces that drive them, so when a send fails on a
size cap or a scan verdict you know which stage refused it and what to do
next.

The inbound direction runs the same gauntlet in reverse: media a contact
sends you (MMS attachments, email attachments) is byte-scanned before it
reaches an agent inbox or the message store. The inbound model is covered
in [Inbound media threat scan and quarantine model](/concepts/inbound-media-threat-scan-model)
and [Inbound attachment threat scan](/concepts/inbound-attachment-threat-scan);
this page covers the outbound path you compose and the surfaces both
directions share.

## 1. What the attach pipeline connects

The outbound path is five stages. Each one can refuse the file, and each
refusal carries its own code:

1. **Upload.** `POST /api/v1/files/upload` accepts a multipart body and
   stores the bytes in your tenant's media bucket. The intake checks run
   before buffering: a MIME type outside the allow-list fails with a 422,
   and anything over the 25 MB ceiling fails with a 413.
2. **Virus scan.** The uploaded file enters the content scanner and sits
   in `pending` until a verdict lands. Any download, attachment fetch, or
   agent read on a `pending` file returns `402 FILE_SCAN_QUARANTINED` with
   a `scan_id` and a `scan_eta_seconds` estimate instead of the bytes.
3. **Attach to a send.** `POST /api/v1/messages` carries the file as
   `media_url` (or `media_urls` for multiple attachments) referencing the
   signed URL the upload returned. The message envelope never embeds your
   bytes; it references the stored object.
4. **Channel size check.** Before the provider is called, the send is
   checked against the per-channel allow-list and caps in the next
   section. A 4 MB video is a valid upload and an invalid MMS; the
   rejection names the channel cap it tripped, not the upload cap.
5. **Provider push and delivery receipt.** Cleared media is pushed to the
   channel and the delivery receipt flows back through the normal
   message status pipeline — see
   [Message send request lifecycle](/concepts/message-send-request-lifecycle).

A quarantined or oversized file never reaches stages 4 and 5: the hold is
a fault-line decision at the earliest stage that can make it, so suspect
content is never passed to your recipient.

## 2. Per-channel file-class allow-list

Intake and channel checks are separate gates with separate ceilings.
The upload allow-list covers the file classes the channels actually
send: JPEG, PNG, GIF, and WebP images; MP3, OGG, WAV, and M4A audio; MP4
and 3GPP video; PDF, Word, and Excel documents; plain text and CSV.
SVG and executable types are deliberately absent — an SVG can embed
script content, so it is refused at intake rather than stored and served
back.

Per channel, the caps shrink from the 25 MB intake ceiling to what the
provider and carrier gateways accept:

| Channel | Accepted classes | Cap | Notes |
| - | - | - | - |
| SMS/MMS | JPEG, PNG, GIF, WebP, MP4, 3GPP, MP3, OGG, AAC | 5 MB hard ceiling per attachment; 1 MB carrier-recommended | Attachments over 1 MB are accepted but flagged for optimization, because individual carriers down-render or drop large MMS. Non-canonical containers (HEIC, QuickTime, WebM) are rejected with the carrier-native format to convert to. See [MMS](/channels/mms). |
| WhatsApp | Documents, images, video, audio, stickers | Per-type caps set by Meta at the Graph API: documents up to 100 MB, with separate (lower) caps per media type | There is no single blanket ceiling; keep each file within its type's limit or WhatsApp rejects the send. See [WhatsApp](/channels/whatsapp). |
| RCS | Media on rich cards and carousels, suggested replies | Per-media limits set by the RCS aggregator's template approval | Rich card media is validated when the template is approved; send what the approved template declares. See [RCS Business Messaging](/channels/rcs). |
| Email | Any allow-listed class as an attachment | 10 MB per attachment, 25 MB total across all attachments, 20 attachments per email | Attach by signed URL (preferred) or inline base64 `content`. See [Email attachments](/guides/email-attachments). |
| Instagram | Text plus image, video, audio, and file attachments | Meta's per-type size caps | Same Meta-side enforcement as WhatsApp. See [Instagram](/channels/instagram). |

Read the error message before retrying: it states the exact ceiling in
bytes that tripped, and it tells you whether the refusal came from intake
(the 25 MB upload cap) or from the channel (the per-channel cap). The two
are fixed on different sides — shrink or re-encode for a channel cap,
split or re-host for the intake cap. The per-surface table and fixes are
in [Troubleshooting: attachment size caps](/troubleshooting/attachment-size-caps)
and [MMS media and rich content](/guides/mms-media-rich-content).

## 3. The quarantine decision tree

The scanner grades every file it inspects and routes the verdict:

* **`clean`** — no threat signal. Downloads, attachment fetches, and
  sends proceed against the stored object from here on.
* **`pending`** — no verdict yet. The file is held fail-closed: any read
  returns `402 FILE_SCAN_QUARANTINED` until the scanner answers, so bytes
  are never served to a recipient or an AI agent before the verdict.
* **`quarantined`** — the scanner flagged the content. The file is
  dropped before it can reach a delivery, an attachment fetch, or an
  agent context, and replaying the identical bytes never heals it.

Inbound media adds a banded verdict on top of the same states: the byte
scan scores each attachment from 0 to 100 and bands it **clean**,
**suspicious**, or **malicious**, mapping to **allow**, **review**, and
**quarantine**. An external antivirus verdict, where one is wired, is the
dominant signal: a `malicious` external verdict quarantines outright; a
`suspicious` one lands in the review band rather than going straight to
delivery. Byte-level signatures — executable headers, macro payloads in
Office documents and PDFs, script shebangs — raise the score even without
an external feed. Suspicious and malicious results route to quarantine
or human review; nothing that fails to reach a clean verdict is served.

Work a verdict from the dashboard the same way you work it over the API:
Settings → Media shows each flagged asset with its quarantine state, and
lets you re-enable a false positive or remove the file. The console loop
is in [Manage media quarantines and upload policy](/guides/settings-media-console);
the 402 envelope mechanics, the poll-to-clean retry loop, and the
escalation path with the `scan_id` are in
[Troubleshooting: FILE\_SCAN\_QUARANTINED](/troubleshooting/file-scan-quarantined).
Where the hold is about what the content says rather than what it can do
— sensitive data patterns, policy matches — that is the data-loss
prevention surface, covered in [DLP scanner](/compliance/dlp-scanner).

## 4. The Files and media API surface

The upload path is driven by five endpoints, guarded by the `files:read`
and `files:write` API-key scopes:

| Endpoint | Returns |
| - | - |
| `POST /api/v1/files/upload` | `201` with the file row: `id`, signed read `url`, `filename`, `content_type`, `size`, `uploaded_at`. 413 over the size cap, 422 outside the MIME allow-list. |
| `GET /api/v1/files` | Cursor-paginated list of the tenant's files, each row with a fresh 1-hour signed read URL. |
| `GET /api/v1/files/{id}` | One file's metadata plus scan state — this is the row you poll to a verdict after a 402. 404 outside your tenant namespace. |
| `GET /api/v1/files/{id}/presign` | A fresh presigned read URL with a caller-controlled lifetime (`ttl_ms`, 1 minute to 7 days). |
| `DELETE /api/v1/files/{id}` | `204`. Removes the object from your media bucket. |

Attach to a send by referencing the returned URL: `POST /api/v1/messages`
takes `media_url` (or `media_urls` for up to ten files) alongside the
channel and body fields. Email additionally accepts inline base64
`content` per attachment. There is no separate "quarantine list"
endpoint with its own shape — the verdict lives on the file row, so the
same `GET /api/v1/files` list and `GET /api/v1/files/{id}` get you the
quarantine state, and Settings → Media in the dashboard renders the same
rows. The full request/response contracts are in the
[Files API reference](/api-reference/endpoints/files).

### Worked example: end-to-end media send over the Files API

Upload a product image, poll it to clean, and send it on MMS:

```bash theme={null}
# 1. Upload — returns the file row with a signed read URL
curl -s -X POST "https://api.orbit.devotel.io/api/v1/files/upload" \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -F "file=@product.jpeg;type=image/jpeg"
# -> { "data": { "id": "file_0192", "url": "https://…", "content_type": "image/jpeg", "size": 412000 } }

# 2. Poll the row to a scan verdict before sending
curl -s "https://api.orbit.devotel.io/api/v1/files/file_0192" \
  -H "Authorization: Bearer $ORBIT_API_KEY"
# -> look for scan state "clean"; while "pending", wait scan_eta_seconds and retry

# 3. Attach by URL on the send
curl -s -X POST "https://api.orbit.devotel.io/api/v1/messages" \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "channel": "mms", "to": "+15551234567", "body": "Product shot attached.", "media_url": "https://…" }'
```

If step 2 returns the 402 quarantine envelope instead, branch on the
state as in the troubleshooting loop: `pending` polls again after
`scan_eta_seconds`; `quarantined` stops the loop and replaces the
artifact.

## 5. Presigned URL vs direct-body upload

Uploads are always direct-body: one multipart `POST /api/v1/files/upload`
with the file in a `file` field. The API stores the object and hands back
a signed read URL; bytes are never proxied through the API on the read
side either — `GET /api/v1/files/{id}/presign` mints a fresh signed URL
instead.

The choice you actually make is on the **attach** side, and on the signed
URL lifetime:

* **Reference by URL (`media_url`).** The default for every channel. The
  message carries a pointer; the provider fetches the object at send
  time. Use the upload's returned `url` for sends that happen soon after
  the upload.
* **Inline base64 `content` (email only).** Hand the bytes in the request
  itself. Reserved for small assets you do not want to store — it counts
  against the same 10 MB per-attachment and 25 MB per-email caps, and it
  skips the reuse and audit trail a stored file gives you.
* **Pin a longer TTL when the URL outlives an hour.** Signed read URLs
  default to a 1-hour lifetime. If you store the URL in a long-lived row
  — an email-builder image block, media archived for redelivery — pass
  `ttl_ms` on upload or on `presign` (up to 7 days, the signing maximum)
  so the link is still live when the row re-renders. An expired link is
  not a scanner verdict: mint a fresh one with `presign` rather than
  re-uploading.

## 6. Tenant-visible audit surface

Each upload and each delete writes an audit row scoped to your
organization, visible in the dashboard's audit trail and exportable like
any other audit event: the action (`file.uploaded`, `file.deleted`), the
file id, and the filename, content type, and size at intake. Scan runs
are carried on the file row rather than as a separate audit stream — the
`scan_id` in a 402 envelope names the individual scan run, so you and
support read the same verdict record when you escalate.

Who sees what stays tenant-scoped: your organization's members see your
files, your audit rows, and your quarantine states; another tenant's
rows live in a separate namespace and return 404 on direct access. Escalating
a verdict shares exactly three things — the file id, the `scan_id`, and
why the content type is expected in your flow — nothing else leaves
your tenant.

## 7. Troubleshooting

| You see | Stage | Move |
| - | - | - |
| `FILE_TOO_LARGE` / `PAYLOAD_TOO_LARGE` (413) | Upload intake | The file exceeds the 25 MB upload ceiling. Compress, split, or host it yourself and reference the link. Retrying the same bytes returns the same code. |
| 422 "Unsupported file type" / `INVALID_FILE_TYPE` / `MAGIC_BYTE_MISMATCH` | Upload intake or send-time type check | The MIME type is outside the allow-list, or the declared type does not match the bytes. Re-encode to a canonical type (HEIC → JPEG, QuickTime → MP4 for MMS); renaming the extension changes nothing. |
| Email `VALIDATION_ERROR` (422) citing per-attachment or total size | Email composition | Over the 10 MB per-file or 25 MB per-email cap. Shrink or drop attachments; the limits table is in [Email attachments](/guides/email-attachments). |
| `402 FILE_SCAN_QUARANTINED`, state `pending` | Scan | A wait case, not a failure. Poll `GET /api/v1/files/{id}` after `scan_eta_seconds`; the download succeeds once the row reads `clean`. |
| `402 FILE_SCAN_QUARANTINED`, state `quarantined` | Scan | A permanent verdict. Replace the artifact and re-upload a cleaned file; replaying the same bytes re-enters the same verdict. Escalate a wrong verdict with the file id and `scan_id` before re-uploading. |
| A scan that never reaches a verdict within its ETA | Scan timeout | The file stays held fail-closed while `pending`. Bound your polling to a few ETA multiples; past that, escalate with the `scan_id` rather than re-uploading identical bytes, which restarts the same scan. |

The wider intake-rejection cluster (`INVALID_FILE_TYPE`,
`MAGIC_BYTE_MISMATCH`, `FILE_TOO_LARGE`, `INVALID_AUDIO`) and its retry
semantics are in [Troubleshooting: fan-out and upload errors](/troubleshooting/fan-out-and-upload-errors);
502-class cases where the platform rather than your payload is at fault
are in [Troubleshooting: MEDIA\_UPLOAD\_FAILED](/troubleshooting/media-upload-failed).

## See also

* [Manage media quarantines and upload policy in Settings → Media](/guides/settings-media-console) — the dashboard quarantine console.
* [Media library guide](/guides/media-library) — browse, re-serve, and delete stored files.
* [DLP scanner](/compliance/dlp-scanner) — policy and sensitive-data scanning over the same content.
* [Troubleshooting: FILE\_SCAN\_QUARANTINED](/troubleshooting/file-scan-quarantined) — the 402 envelope and the poll-to-clean loop.
* [Troubleshooting: attachment size caps](/troubleshooting/attachment-size-caps) — the per-surface size table.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.