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

# Attachments

> Upload files, emails, zips, and folders to a conversation and send them with a message.

Attachments let an end user give the assistant documents: a PDF contract, a spreadsheet of line items, a photographed form, an email with its own attachments, or a whole folder. You upload each file to the conversation first, then name it in `attachment_ids` on the turn that asks about it. The platform stores the bytes and hands the files to the model on that turn. Apart from opening zips and emails into their files, it does not extract text or keep a parsed copy; the model reads the file itself.

In a deployment with no file storage configured, attachment routes answer `503 attachments_unavailable`. The policy read (`GET .../attachments/policy`) still answers, because it only reads the instance config.

## How it works

1. The instance's latest **published** config enables uploads in its `attachments` block. The draft never governs uploads. No published version is `409 instance_not_published`; uploads off is `400 attachments_not_enabled`.
2. You upload a file with one of three paths: a multipart upload, a reserve then PUT then finalize upload for large files, or a folder.
3. The platform checks the type, size, and contents, and stores a record with status `ready`.
4. You send a turn with `message` and `attachment_ids`. The stream starts with an `attachment` frame confirming each file and how it was read.

## Read the policy first

`GET .../conversations/{conversation_id}/attachments/policy?end_user=...` returns the limits the instance enforces, so your interface can refuse a file before uploading it.

<CodeGroup>
  ```bash curl theme={null}
  curl "https://api.usenexio.com/api/v1/conversation-instances/workspace-assistant/conversations/9e4c7a12-5b3d-4f81-a6e0-2d8b1c4f7a93/attachments/policy?end_user=u_dana_ortiz" \
    -H "Authorization: Bearer $NEXIO_API_KEY"
  ```

  ```python Python theme={null}
  import os
  import requests

  BASE = "https://api.usenexio.com/api/v1/conversation-instances/workspace-assistant/conversations/9e4c7a12-5b3d-4f81-a6e0-2d8b1c4f7a93"
  HEADERS = {"Authorization": "Bearer " + os.environ["NEXIO_API_KEY"]}

  policy = requests.get(f"{BASE}/attachments/policy", headers=HEADERS, params={"end_user": "u_dana_ortiz"}).json()
  print(policy["accepted_extensions"], policy["max_bytes_per_file"])
  ```

  ```typescript TypeScript theme={null}
  const BASE =
    "https://api.usenexio.com/api/v1/conversation-instances/workspace-assistant/conversations/9e4c7a12-5b3d-4f81-a6e0-2d8b1c4f7a93"
  const HEADERS = { Authorization: `Bearer ${process.env.NEXIO_API_KEY}` }

  const policy = await fetch(`${BASE}/attachments/policy?end_user=u_dana_ortiz`, { headers: HEADERS }).then((r) => r.json())
  console.log(policy.accepted_extensions, policy.max_bytes_per_file)
  ```
</CodeGroup>

Response `200`:

```json theme={null}
{
  "enabled": true,
  "max_files_per_message": 5,
  "max_bytes_per_file": 39321600,
  "max_bytes_per_message": 52428800,
  "max_attachments_per_conversation": 100,
  "max_files_per_folder": 25,
  "unwrap_archives": true,
  "accepted_extensions": [".csv", ".doc", ".docx", ".dot", ".eml", ".gif", ".htm", ".html", ".jpeg", ".jpg", ".json", ".markdown", ".md", ".odt", ".pdf", ".png", ".pot", ".pps", ".ppt", ".pptx", ".rtf", ".text", ".tsv", ".txt", ".webp", ".xls", ".xlsx", ".xlt", ".xlw", ".xml", ".zip"],
  "retention_days": null
}
```

`max_bytes_per_message` is measured on the base64-encoded size the model provider receives, which is about four thirds of the raw size. A disabled instance answers `400 attachments_not_enabled` rather than `enabled: false`.

## Accepted formats

The platform accepts a closed set of formats. A file is classified by its extension (the declared content type is used only for a name with no extension), and its bytes must agree with that type.

| Kind                    | Extensions                                                            | Reaches the model as                                           | Read limit                                      |
| ----------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------- | ----------------------------------------------- |
| PDF                     | `.pdf`                                                                | file                                                           | None                                            |
| Word and text documents | `.doc`, `.dot`, `.docx`, `.odt`, `.rtf`                               | file                                                           | Text is read; images and charts inside are not. |
| Presentations           | `.ppt`, `.pps`, `.pot`, `.pptx`                                       | file                                                           | Text is read; images and charts inside are not. |
| Spreadsheets            | `.xls`, `.xlt`, `.xlw`, `.xlsx`, `.csv`, `.tsv`                       | file                                                           | The first 1,000 rows per sheet.                 |
| Plain text and data     | `.txt`, `.text`, `.md`, `.markdown`, `.json`, `.xml`, `.html`, `.htm` | file                                                           | None                                            |
| Images                  | `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`                              | image                                                          | None                                            |
| Containers              | `.eml`, `.zip`                                                        | opened into their files; the container never reaches the model | Only when `unwrap_archives` is true             |

When a read limit applies, the file's record carries a `notice` in plain words. For a file sent on its own, the `attachment` frame carries it too; for a file inside a container, read the member's record. Show it to the user: an answer drawn from part of a schedule is not an answer about the whole schedule.

Outlook `.msg` files are refused with a message that says to save the email as `.eml` or attach the file inside it directly.

## How a zip or email is opened

A `.zip` or `.eml` is accepted only when the instance sets `unwrap_archives: true`. It is opened when it is stored, by a multipart upload or at finalize, and the same rules apply on both paths:

1. It is opened one level deep, up to 100 MiB decompressed; a zip may list at most 10,000 entries.
2. Files are extracted by name. An entry with an unusable name, an empty file, a nested zip or email, or a name of a type the platform never accepts is skipped. Extraction collects at most 200 files; files past the 200th are omitted. An email's `From`, `To`, `Cc`, `Date`, and `Subject` headers and its body text are kept as one more file, `message.txt`.
3. Each extracted file is then checked by the rules for a file sent on its own. A file over `max_bytes_per_file`, of a type the instance does not accept, or whose bytes disagree with its name is skipped. The 200 extracted files count toward that ceiling whether or not they pass this check, so files that fail it can use up the allowance before a later acceptable file.
4. At most 25 accepted files are kept. Accepted files past the 25th are omitted, not refused, so the upload still succeeds.
5. Each kept file is stored as its own attachment, pointing back at the container. When anything was omitted or skipped, the container's `notice` says how many files were read, names up to 20 omitted files and counts the rest, and counts the skipped ones. Show it to the user.

The whole container is refused, and nothing from it is stored, in these cases:

| Case                                                                                                                                  | Answer                                                           |
| ------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| The container cannot be opened, lists more than 10,000 entries, expands past 100 MiB, or a file inside cannot be read or decompressed | `400 attachment_rejected`                                        |
| No file could be extracted in step 2                                                                                                  | `400 attachment_rejected`                                        |
| Files were extracted, but none passed the check in step 3                                                                             | `415 attachment_rejected`                                        |
| The kept files would take the conversation past 100 attachments                                                                       | `400 attachment_rejected`; the container is never trimmed to fit |

A folder differs: it is refused above 25 files (see [Upload a folder](#upload-a-folder)).

## Upload a file (multipart)

`POST .../attachments` with `multipart/form-data` fields `end_user` and `file`. The body may be up to `max_bytes_per_file` plus 1 MiB. Use this path when your server holds the bytes.

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST "https://api.usenexio.com/api/v1/conversation-instances/workspace-assistant/conversations/9e4c7a12-5b3d-4f81-a6e0-2d8b1c4f7a93/attachments" \
    -H "Authorization: Bearer $NEXIO_API_KEY" \
    -F "end_user=u_dana_ortiz" \
    -F "file=@contract-2026.pdf;type=application/pdf"
  ```

  ```python Python theme={null}
  with open("contract-2026.pdf", "rb") as fh:
      resp = requests.post(
          f"{BASE}/attachments",
          headers=HEADERS,
          data={"end_user": "u_dana_ortiz"},
          files={"file": ("contract-2026.pdf", fh, "application/pdf")},
      )
  resp.raise_for_status()
  attachment = resp.json()["attachment"]
  ```

  ```typescript TypeScript theme={null}
  import { readFile } from "node:fs/promises"

  const form = new FormData()
  form.append("end_user", "u_dana_ortiz")
  form.append(
    "file",
    new Blob([await readFile("contract-2026.pdf")], { type: "application/pdf" }),
    "contract-2026.pdf",
  )
  const uploaded = await fetch(`${BASE}/attachments`, { method: "POST", headers: HEADERS, body: form })
  const { attachment } = await uploaded.json()
  ```
</CodeGroup>

Response `201`:

```json theme={null}
{
  "attachment": {
    "id": "7a3c1e9f-4b2d-4e6a-8c05-9f1d3b7e2a64",
    "filename": "contract-2026.pdf",
    "media_type": "application/pdf",
    "size_bytes": 482113,
    "status": "ready",
    "delivery": "file",
    "created_at": "2026-09-23T14:05:02Z"
  }
}
```

Uploading the same file with the same name to the same conversation again returns the existing `ready` record, as long as the current policy still accepts it, and does not count against the conversation limit.

## Upload a large file (reserve, PUT, finalize)

Use this path to send bytes straight from the user's browser to storage, for example when your own server caps request bodies. Your server calls reserve and finalize with its key; the browser only receives a one-file upload URL.

1. **Reserve.** `POST .../attachments/reserve` with `{end_user, filename, content_type, size_bytes}`. The platform checks the name and declared size, creates a record with status `storing`, and returns `upload_url`, a presigned PUT URL for this one file that expires in 15 minutes.
2. **PUT the bytes** to `upload_url` with no `Authorization` header. The URL can only write this one file.
3. **Finalize.** `POST .../attachments/{attachment_id}/finalize` with `{end_user}`. The platform reads what arrived, measures the real size, checks the type against the bytes, and marks the record `ready`, or refuses it.

<CodeGroup>
  ```bash curl theme={null}
  BASE="https://api.usenexio.com/api/v1/conversation-instances/workspace-assistant/conversations/9e4c7a12-5b3d-4f81-a6e0-2d8b1c4f7a93"

  # 1. Reserve
  curl -X POST "$BASE/attachments/reserve" \
    -H "Authorization: Bearer $NEXIO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "end_user": "u_dana_ortiz",
      "filename": "statement-of-values.xlsx",
      "content_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
      "size_bytes": 18874368
    }'

  # 2. PUT the bytes to the upload_url from the reserve response
  UPLOAD_URL="<upload_url from step 1>"
  curl -X PUT --upload-file statement-of-values.xlsx "$UPLOAD_URL"

  # 3. Finalize
  curl -X POST "$BASE/attachments/1c8e5b27-6d4f-4a93-b0e2-7f5a3c9d1e48/finalize" \
    -H "Authorization: Bearer $NEXIO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"end_user": "u_dana_ortiz"}'
  ```

  ```python Python theme={null}
  import os

  path = "statement-of-values.xlsx"
  reservation = requests.post(
      f"{BASE}/attachments/reserve",
      headers=HEADERS,
      json={
          "end_user": "u_dana_ortiz",
          "filename": path,
          "content_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
          "size_bytes": os.path.getsize(path),
      },
  ).json()

  with open(path, "rb") as fh:
      put = requests.put(reservation["upload_url"], data=fh)
  put.raise_for_status()

  attachment_id = reservation["attachment"]["id"]
  final = requests.post(
      f"{BASE}/attachments/{attachment_id}/finalize",
      headers=HEADERS,
      json={"end_user": "u_dana_ortiz"},
  )
  final.raise_for_status()
  attachment = final.json()["attachment"]
  ```

  ```typescript TypeScript theme={null}
  import { readFile, stat } from "node:fs/promises"

  const path = "statement-of-values.xlsx"
  const reservation = await fetch(`${BASE}/attachments/reserve`, {
    method: "POST",
    headers: { ...HEADERS, "Content-Type": "application/json" },
    body: JSON.stringify({
      end_user: "u_dana_ortiz",
      filename: path,
      content_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
      size_bytes: (await stat(path)).size,
    }),
  }).then((r) => r.json())

  const put = await fetch(reservation.upload_url, { method: "PUT", body: await readFile(path) })
  if (!put.ok) throw new Error(`upload: ${put.status}`)

  const final = await fetch(`${BASE}/attachments/${reservation.attachment.id}/finalize`, {
    method: "POST",
    headers: { ...HEADERS, "Content-Type": "application/json" },
    body: JSON.stringify({ end_user: "u_dana_ortiz" }),
  })
  const { attachment } = await final.json()
  ```
</CodeGroup>

Reserve response `201`:

```json theme={null}
{
  "attachment": {
    "id": "1c8e5b27-6d4f-4a93-b0e2-7f5a3c9d1e48",
    "filename": "statement-of-values.xlsx",
    "media_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    "size_bytes": 18874368,
    "status": "storing",
    "delivery": "file",
    "created_at": "2026-09-23T14:06:40Z"
  },
  "upload_url": "https://storage.example.com/conversation-attachments/1c8e5b27-6d4f-4a93-b0e2-7f5a3c9d1e48?X-Amz-Signature=REDACTED",
  "upload_url_expires_at": "2026-09-23T14:21:40Z"
}
```

Finalize returns `200` with `{attachment}` at status `ready`, with its `notice` when the format has a read limit. Finalize is safe to repeat: a second call on a `ready` record returns it again. When a file that is not in a folder finalizes with the same bytes and name as a `ready` file the conversation already holds, finalize returns the existing record (with its own `id`) and retires the reservation, so always use the `id` from the finalize response. The daily cleanup pass removes a reservation nobody finalized within an hour.

## Upload a folder

A folder is a container your application opens: you send its list of files, and the platform returns one upload URL per file. The folder counts as one attachment toward `max_files_per_message`.

1. `POST .../attachments/folders` with `{end_user, name, files: [{path, size_bytes}]}`. At most 25 files per folder.
2. The response has `container` (the folder record), `members` (one reservation per file, each with `upload_url` and `source_path` echoing the `path` you sent), and `skipped` (files the instance will not read, each with `path` and `reason`). The rest of the folder still opens. A folder with nothing readable is refused.
3. PUT each member's bytes to its `upload_url`, then finalize each member by its `attachment.id`.
4. When every member is finalized, the folder is `ready`. Name the container id in `attachment_ids`. A turn reads a folder's files only when the instance sets `unwrap_archives: true`; otherwise the upload succeeds but the turn answers `400 attachment_not_accepted`.

A folder's identity is its roster: each member's path and declared size. The platform does not compare file contents. If a folder with the same roster is already open and `ready` in the conversation, the response is `200` with `reused: true`, no members, and nothing to upload. Otherwise a new folder opens with `201`. A file whose content changed but whose path and size did not is not detected, so delete the old folder before you upload the changed one. The folder and its files together count toward the conversation's 100-attachment limit.

<CodeGroup>
  ```bash curl theme={null}
  BASE="https://api.usenexio.com/api/v1/conversation-instances/workspace-assistant/conversations/9e4c7a12-5b3d-4f81-a6e0-2d8b1c4f7a93"

  curl -X POST "$BASE/attachments/folders" \
    -H "Authorization: Bearer $NEXIO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "end_user": "u_dana_ortiz",
      "name": "supplier-contracts",
      "files": [
        {"path": "supplier-contracts/contract-2026.pdf", "size_bytes": 482113},
        {"path": "supplier-contracts/contract-2025.pdf", "size_bytes": 391208},
        {"path": "supplier-contracts/notes.msg", "size_bytes": 52011}
      ]
    }'
  ```

  ```python Python theme={null}
  folder = requests.post(
      f"{BASE}/attachments/folders",
      headers=HEADERS,
      json={
          "end_user": "u_dana_ortiz",
          "name": "supplier-contracts",
          "files": [
              {"path": "supplier-contracts/contract-2026.pdf", "size_bytes": 482113},
              {"path": "supplier-contracts/contract-2025.pdf", "size_bytes": 391208},
              {"path": "supplier-contracts/notes.msg", "size_bytes": 52011},
          ],
      },
  ).json()

  for member in folder["members"]:
      with open(member["source_path"], "rb") as fh:
          requests.put(member["upload_url"], data=fh).raise_for_status()
      requests.post(
          f"{BASE}/attachments/{member['attachment']['id']}/finalize",
          headers=HEADERS,
          json={"end_user": "u_dana_ortiz"},
      ).raise_for_status()

  container_id = folder["container"]["id"]
  ```

  ```typescript TypeScript theme={null}
  const folder = await fetch(`${BASE}/attachments/folders`, {
    method: "POST",
    headers: { ...HEADERS, "Content-Type": "application/json" },
    body: JSON.stringify({
      end_user: "u_dana_ortiz",
      name: "supplier-contracts",
      files: [
        { path: "supplier-contracts/contract-2026.pdf", size_bytes: 482113 },
        { path: "supplier-contracts/contract-2025.pdf", size_bytes: 391208 },
        { path: "supplier-contracts/notes.msg", size_bytes: 52011 },
      ],
    }),
  }).then((r) => r.json())

  for (const member of folder.members) {
    const bytes = await readFile(member.source_path)
    await fetch(member.upload_url, { method: "PUT", body: bytes })
    await fetch(`${BASE}/attachments/${member.attachment.id}/finalize`, {
      method: "POST",
      headers: { ...HEADERS, "Content-Type": "application/json" },
      body: JSON.stringify({ end_user: "u_dana_ortiz" }),
    })
  }
  const containerId: string = folder.container.id
  ```
</CodeGroup>

In this example `notes.msg` is listed in `skipped` with its reason, and the two PDFs open.

## Send files with a message

Name ready attachments in `attachment_ids` alongside `message`. A container (zip, email, or folder) resolves to the files inside it. In this example the user compares two supplier contracts.

```json theme={null}
{
  "end_user": "u_dana_ortiz",
  "message": "Compare the payment terms in these two contracts.",
  "attachment_ids": ["7a3c1e9f-4b2d-4e6a-8c05-9f1d3b7e2a64", "5e2d9b14-8c3a-4f67-a1b0-6d4e2c8f9a13"]
}
```

The stream then begins with an `attachment` frame before the `conversation` frame:

```text theme={null}
event: attachment
data: {"type":"attachment","message_id":"c7d1e5a3-2f84-4b6c-9e0a-1b3d5f7a9c28","attachments":[{"id":"7a3c1e9f-4b2d-4e6a-8c05-9f1d3b7e2a64","name":"contract-2026.pdf","media_type":"application/pdf"},{"id":"5e2d9b14-8c3a-4f67-a1b0-6d4e2c8f9a13","name":"contract-2025.pdf","media_type":"application/pdf"}]}
```

The stored user message records each attachment as a `document_reference` block, never the bytes. A file sent on its own gets one block with its id, name, media type, and notice. Files that came out of a container are recorded in a `members` array inside a block that carries the container's id and name.

Later turns carry earlier attachments forward on their own; you do not name them again. Each turn gives the model the conversation's attachments, newest first, as long as each file still passes the instance's current attachment policy and fits the per-turn byte budget (50 MiB once base64-encoded). A file the current policy or the budget leaves out, or one deleted since, stays in the transcript and is named to the model as out of view. Files ride on the messages that carried them, so a file on a message older than the history window (`max_history_messages`) is not sent.

A turn is refused, before anything streams, when the files cannot all be used. It never runs with fewer documents than you named.

| Condition                                                                                                                                      | Status and code               |
| ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
| The published config does not enable attachments                                                                                               | `400 attachments_not_enabled` |
| More attachments than `max_files_per_message`, or more than 25 files once containers are opened                                                | `400 too_many_attachments`    |
| A file's type or size is not accepted under the current published policy, or a file came out of a container and `unwrap_archives` is now false | `400 attachment_not_accepted` |
| An id is not a UUID or is listed twice, or `attachment_ids` is sent without `message`                                                          | `400 invalid_turn_request`    |
| An id is not in this conversation, belongs to another end user, or is not `ready`                                                              | `404 attachment_not_found`    |
| The files exceed 50 MiB once base64-encoded                                                                                                    | `413 attachments_too_large`   |

If a named attachment is deleted after these checks and before the first model call, the stream ends with an `error` frame whose code is `attachment_changed`.

## Limits

| Limit                                                             | Value                                                                                                                                            |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| One file                                                          | `max_bytes_per_file`, at most 39,321,600 bytes (37.5 MiB)                                                                                        |
| Attachments on one message                                        | `max_files_per_message`, default 5, at most 10                                                                                                   |
| Files on one message after containers are opened                  | 25                                                                                                                                               |
| Bytes on one message                                              | 50 MiB, base64-encoded                                                                                                                           |
| Live attachments in one conversation, container contents included | 100                                                                                                                                              |
| Files in one folder                                               | 25; a larger folder is refused                                                                                                                   |
| Files extracted from one opened zip or email                      | 200, counted before the per-file checks; the rest are omitted, and the container's `notice` names up to 20 omitted files and counts the rest     |
| Files kept from one opened zip or email                           | 25 accepted files; the rest are omitted and reported in the container's `notice` ([How a zip or email is opened](#how-a-zip-or-email-is-opened)) |
| Zip or email contents                                             | 100 MiB decompressed, one level deep; a zip may list at most 10,000 entries                                                                      |
| Upload URL lifetime                                               | 15 minutes                                                                                                                                       |
| Content URL lifetime                                              | 5 minutes                                                                                                                                        |

## Attachment record and statuses

| Field                  | Meaning                                                                                                                                                                                                                                           |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                   | Attachment id.                                                                                                                                                                                                                                    |
| `filename`             | Stored name, cleaned of unsafe path segments.                                                                                                                                                                                                     |
| `media_type`           | The type the platform resolved from name and bytes.                                                                                                                                                                                               |
| `size_bytes`           | Measured size. On a `storing` record, the size you declared.                                                                                                                                                                                      |
| `status`               | See below.                                                                                                                                                                                                                                        |
| `delivery`             | `file`, `image`, or `unwrap` (a container).                                                                                                                                                                                                       |
| `notice`               | A plain-words note: how the format is read when there is a limit, or, on a container, what was read and what was left out.                                                                                                                        |
| `container_kind`       | `folder` on a folder; `zip` or `eml` on a zip or email stored through finalize. Absent on a zip or email stored by a multipart upload, on plain files, and on files inside a container.                                                           |
| `parent_attachment_id` | The container a file came from.                                                                                                                                                                                                                   |
| `expires_at`           | When access to the file ends. From then it is left out of lists, answers `404` when read or downloaded, and cannot be named on a turn. A later retention sweep deletes the record and removes the bytes. Absent when it follows the conversation. |

| Status       | Meaning                                                                             |
| ------------ | ----------------------------------------------------------------------------------- |
| `storing`    | Reserved; bytes not yet finalized. Cannot ride a turn.                              |
| `unwrapping` | A container whose contents are still being opened or, for a folder, still arriving. |
| `ready`      | Usable on a turn.                                                                   |

When finalize finds that the bytes never arrived, it answers `400 attachment_rejected` and leaves the record `storing`: PUT the bytes (while the upload URL is valid) and finalize again, or leave it and the cleanup pass removes it once it is more than an hour old. Any other refusal at finalize removes the record at once, and a later read of it answers `404 attachment_not_found`. The finalize error response carries the reason.

## Read, list, download, delete

* `GET .../attachments?end_user=...&offset=...` lists up to 100 records per page, newest first, including files inside containers. Read `has_more` and pass `next_offset` back as `offset`. A bad offset is `400 invalid_offset`.
* `GET .../attachments/{attachment_id}?end_user=...` returns `{attachment}`.
* `GET .../attachments/{attachment_id}/content?end_user=...` answers `302` to a URL that expires in 5 minutes and downloads the file as `application/octet-stream`. Only a `ready` record has content; any other answers `404 attachment_not_found`.
* `DELETE .../attachments/{attachment_id}?end_user=...` answers `204`. The record disappears at once. The daily cleanup pass removes the stored bytes once an hour has passed. Deleting a container deletes its files. A file inside a zip or email cannot be deleted alone (`409 attachment_member_delete`); delete the container. A file inside a folder can be deleted alone.

Every route is scoped to org, conversation, and `end_user`; a miss on any of them is `404`.

## Retention

`attachments.retention_days` sets how long files live; `null` follows the conversation's retention. When a conversation is deleted by retention, its attachments are swept after it. See [Export and retention](/conversations/export-and-retention).

## Upload errors

| Status and code                                                            | Cause                                                                                                                                                                                                                                                                                                                                                                |
| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400 invalid_request`                                                      | Not multipart, missing `file` or `end_user`, missing `filename` or folder `name`, or an unknown JSON field.                                                                                                                                                                                                                                                          |
| `400 attachments_not_enabled`                                              | The published config does not enable attachments.                                                                                                                                                                                                                                                                                                                    |
| `400 attachment_rejected`                                                  | The file itself is unusable: empty, a container that could not be opened or read, a container from which no file could be extracted, or bytes never arrived (finalize only; the record stays `storing` so you can upload and finalize again). Also a folder with no files or more than 25 files, or an upload that would take the conversation past 100 attachments. |
| `404 instance_not_found`, `conversation_not_found`, `attachment_not_found` | Out of scope or unknown.                                                                                                                                                                                                                                                                                                                                             |
| `409 instance_not_published`                                               | No published version, so no policy is in force.                                                                                                                                                                                                                                                                                                                      |
| `409 attachment_not_reservable`                                            | Finalize on a container that is still `unwrapping`, such as a folder's container id. Finalize each member instead. A deleted or refused attachment is `404 attachment_not_found`; a `ready` one returns 200.                                                                                                                                                         |
| `413 attachment_too_large`                                                 | Over `max_bytes_per_file`, or a folder whose readable files together exceed 50 MiB once base64-encoded.                                                                                                                                                                                                                                                              |
| `415 attachment_rejected`                                                  | The type is outside the accepted set, narrowed away by the instance, or the bytes disagree with the name; or every file extracted from a container, or every file in a folder, fails those checks.                                                                                                                                                                   |
| `500 instance_config_invalid`, `internal_error`                            | Platform fault.                                                                                                                                                                                                                                                                                                                                                      |
| `503 attachments_unavailable`                                              | No file storage in this deployment.                                                                                                                                                                                                                                                                                                                                  |

<CardGroup cols={2}>
  <Card title="Turns and streaming" href="/conversations/turns-and-streaming">
    The `attachment` frame and turn errors.
  </Card>

  <Card title="Upload an attachment (API reference)" href="/api-reference/conversations/attachments/upload-attachment">
    The generated endpoint contract.
  </Card>
</CardGroup>
