# Upload and save files

**Upload endpoint:** `POST /api/upload-tus` (tus 1.0.0) **Attach endpoint:** `POST /api/tickets/multi`

Attaching a file to a record is a two-step operation: upload the bytes to the tus endpoint to get a `file_uid`, then include that `file_uid` in a record `create` or `edit` call under a file-type field.

## Step 1: upload the bytes[​](#step-1-upload-the-bytes "Direct link to Step 1: upload the bytes")

The upload endpoint follows the [tus 1.0.0](https://tus.io/) protocol. Clients create an upload with `POST` and write the content with `PATCH` to the returned `Location`.

### Create the upload[​](#create-the-upload "Direct link to Create the upload")

```
curl --request POST \
  --url "https://acme.comind.work/api/upload-tus" \
  --header "Authorization: CMW_AUTH_CODE YOUR-TOKEN" \
  --header "Tus-Resumable: 1.0.0" \
  --header "Upload-Length: 18234" \
  --header "Upload-Metadata: filename aW52b2ljZS5wZGY="
```

`Upload-Length` is the total byte size. `Upload-Metadata` is a space-separated list of `key base64value` pairs - at minimum include `filename` with the original name base64-encoded.

The response has no body. The `Location` header holds the upload URL, which ends with the `file_uid`:

```
Location: https://acme.comind.work/api/upload-tus/e9b0a42e-1f3c-4d8b-9a7e-2c5b41d8b6af
```

### Write the content[​](#write-the-content "Direct link to Write the content")

`PATCH` the bytes to the returned `Location`:

```
curl --request PATCH \
  --url "https://acme.comind.work/api/upload-tus/e9b0a42e-1f3c-4d8b-9a7e-2c5b41d8b6af" \
  --header "Authorization: CMW_AUTH_CODE YOUR-TOKEN" \
  --header "Content-Type: application/offset+octet-stream" \
  --header "Tus-Resumable: 1.0.0" \
  --header "Upload-Offset: 0" \
  --data-binary "@invoice.pdf"
```

A `204 No Content` response means the upload completed. The `file_uid` (the last path segment of the upload URL) is now a valid reference and can be attached to any record the authenticated user can edit.

info

tus supports chunked resumable uploads. For most integrations a single `PATCH` with the full content is enough. The chunked flow is only needed for very large files or unreliable networks - send additional `PATCH` requests with the correct `Upload-Offset` on each.

## Step 2: attach to a record[​](#step-2-attach-to-a-record "Direct link to Step 2: attach to a record")

File-type fields accept an array of attachment objects. The shape is:

```
{
  "file_uid": "e9b0a42e-1f3c-4d8b-9a7e-2c5b41d8b6af",
  "id": "e9b0a42e-1f3c-4d8b-9a7e-2c5b41d8b6af",
  "link_data": { "size": 18234 },
  "pending": true,
  "title": "invoice.pdf"
}
```

| Field            | Purpose                                                                         |
| ---------------- | ------------------------------------------------------------------------------- |
| `file_uid`       | GUID returned by the tus upload (required)                                      |
| `id`             | Same value as `file_uid` for pending uploads (required)                         |
| `link_data.size` | Byte size of the uploaded content (recommended)                                 |
| `pending`        | `true` tells the server to commit the upload on save (required for new uploads) |
| `title`          | Display name shown in the UI - usually the original filename                    |

### Create a record with an attachment[​](#create-a-record-with-an-attachment "Direct link to Create a record with an attachment")

```
curl --request POST \
  --url "https://acme.comind.work/api/tickets/multi" \
  --header "Authorization: CMW_AUTH_CODE YOUR-TOKEN" \
  --header "Content-Type: application/json" \
  --data '[{
    "transition": "add",
    "app_alias": "DEAL",
    "workspace_alias": "CRM",
    "title": "Acme Corp - Q2 invoice",
    "attachments": [{
      "file_uid": "e9b0a42e-1f3c-4d8b-9a7e-2c5b41d8b6af",
      "id": "e9b0a42e-1f3c-4d8b-9a7e-2c5b41d8b6af",
      "link_data": { "size": 18234 },
      "pending": true,
      "title": "invoice.pdf"
    }]
  }]'
```

### Attach to an existing record[​](#attach-to-an-existing-record "Direct link to Attach to an existing record")

Use `transition: "edit"` and include the record `id`:

```
curl --request POST \
  --url "https://acme.comind.work/api/tickets/multi" \
  --header "Authorization: CMW_AUTH_CODE YOUR-TOKEN" \
  --header "Content-Type: application/json" \
  --data '[{
    "id": "record-guid",
    "transition": "edit",
    "attachments": [{
      "file_uid": "e9b0a42e-1f3c-4d8b-9a7e-2c5b41d8b6af",
      "id": "e9b0a42e-1f3c-4d8b-9a7e-2c5b41d8b6af",
      "link_data": { "size": 18234 },
      "pending": true,
      "title": "invoice.pdf"
    }]
  }]'
```

warning

The attachments array is a full replacement, not an append. To preserve existing attachments when adding new ones, read the current record first and include the existing attachment objects (without `pending: true`) alongside the new one. Removing specific attachments from the array deletes them from the record.

## File and filelist fields[​](#file-and-filelist-fields "Direct link to File and filelist fields")

Apps define two kinds of file fields:

| Control type | Accepts                | Typical usage                                 |
| ------------ | ---------------------- | --------------------------------------------- |
| `file`       | Exactly one attachment | Profile picture, cover image, signed contract |
| `filelist`   | Many attachments       | General attachments, document bundles         |

Use `GET /api/schema/{workspace}!{app}` to discover which fields on an app are file-type. The schema response lists each field's control type; the field db name is what you use in the record payload (`attachments`, `c_picture`, `c_cover_image`, etc.).

## TypeScript SDK example[​](#typescript-sdk-example "Direct link to TypeScript SDK example")

The `@comind/api` package wraps both steps. Upload the file, then save a record that references the returned `file_uid`:

```
import { Comind } from "@comind/api";
import fs from "fs";

const comind = new Comind();

async function attachInvoice(dealId: string, filePath: string) {
  const fileUid = await comind.records.saveFileWithTus(filePath);
  const fileName = filePath.split("/").pop()!;
  const fileSize = fs.statSync(filePath).size;

  await comind.records.save([{
    id: dealId,
    transition: "edit",
    attachments: [{
      file_uid: fileUid,
      id: fileUid,
      link_data: { size: fileSize },
      pending: true,
      title: fileName,
    }],
  }]);
}
```

`saveFileWithTus` performs both the `POST` create and the `PATCH` write, and returns the `file_uid` directly.

## Notes[​](#notes "Direct link to Notes")

* File bytes are stored only after the record save succeeds. An upload that is never attached to a record is discarded.
* Uploads respect the authenticated user's permissions. The attach step fails if the user cannot edit the target record or its file field.
* Content-type detection is automatic. The server uses the `filename` in `Upload-Metadata` to determine the MIME type.

tip

The [MCP tools](/ai/mcp-tools.md) layer wraps this protocol with size and extension limits suited to AI tool calls (text formats only, up to 256 KB, max 5 files per call). Direct API calls have no such limits - use the API for binary files, large uploads, or bulk imports.
