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

# Upload and download attachments

> Transfer files through the API or MCP, then attach them to a message.

Upload a file, validate it, then use the returned attachment in a message. Uploading a file does not send it to a customer.

## Choose the access you need

| Task | Permissions |
| - | - |
| Upload and validate a file | `attachments:write` |
| Download a hosted message attachment | `attachments:read` and `conversations:read` |
| Record a message with the file | `messages:write` |
| Send a reply with the file | `messages:send` |

REST uses a workspace API key. MCP accepts a scoped API key or browser OAuth. Existing OAuth connections do not gain attachment permissions automatically; reconnect and select the additional access.

## Ask your AI

Use this prompt with a client that can read your local file and make an HTTP request:

```text theme={null}
Check the connected Protodesk workspace. Upload the file I selected and validate
it using create_attachment_upload and complete_attachment_upload. Compute its
actual SHA-256 and size, and PUT the raw bytes with the returned headers.
Show me the filename and ready attachment reference. Do not send a message.
```

The upload URL is temporary. Send only its returned headers to storage, never your Protodesk API key or OAuth token. Use the same Protodesk connection to start and complete the upload.

<Note>
  A remote MCP connection alone does not give an assistant access to your local files or an HTTP upload tool. A client without those capabilities can work with an existing hosted message attachment, or you can upload through the Protodesk app. Files cannot be passed as base64 inside these MCP tool calls.
</Note>

## Upload through the API

The sequence is the same for REST and MCP:

1. Compute the file's size and lowercase SHA-256 hash.
2. Call `POST /v1/attachments/uploads` with its filename, content type, size, hash, and a unique `Idempotency-Key`.
3. PUT the raw file bytes to the returned `url`, using its `headers` exactly. The URL expires after ten minutes and binds the declared size and content type.
4. Call `POST /v1/attachments/uploads/UPLOAD_ID/complete` using the original API key. Completion checks the bytes and returns a durable attachment object.

This Node.js example uploads a file and prints its ready attachment. It does not create or send a message. Save it as `upload.mjs`, set `PROTODESK_API_KEY` in your local environment, and run it with a stable retry key:

```bash theme={null}
node upload.mjs ./receipt.pdf application/pdf attachment-order-123-v1
```

```javascript theme={null}
import { readFile, stat } from "node:fs/promises";
import { basename } from "node:path";
import { createHash } from "node:crypto";

const [file, contentType, retryKey] = process.argv.slice(2);
const apiKey = process.env.PROTODESK_API_KEY;
if (!file || !contentType || !retryKey || !apiKey) {
  throw new Error("Provide a file, content type, retry key, and PROTODESK_API_KEY.");
}
const metadata = await stat(file);
if (!metadata.isFile() || metadata.size < 1 || metadata.size > 16 * 1024 * 1024) {
  throw new Error("Choose a nonempty file of at most 16 MiB.");
}
const bytes = await readFile(file);

async function api(path, body, idempotencyKey) {
  const response = await fetch(`https://api.protodesk.io/v1${path}`, {
    method: "POST",
    redirect: "error",
    signal: AbortSignal.timeout(35000),
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  if (!response.ok) throw new Error(`Protodesk returned ${response.status}: ${await response.text()}`);
  return response.json();
}

const upload = await api("/attachments/uploads", {
  filename: basename(file),
  contentType,
  sizeBytes: bytes.length,
  sha256: createHash("sha256").update(bytes).digest("hex"),
}, retryKey);

let ready = upload;
if (upload.status !== "ready") {
  const put = await fetch(upload.url, {
    method: "PUT",
    redirect: "error",
    signal: AbortSignal.timeout(60000),
    headers: upload.headers,
    body: bytes,
  });
  if (!put.ok) throw new Error(`Storage upload returned ${put.status}`);
  ready = await api(`/attachments/uploads/${encodeURIComponent(upload.uploadId)}/complete`);
}
console.log(JSON.stringify(ready.attachment, null, 2));
```

Keep the retry key for this file. Retrying with the same metadata returns the same upload without extending its expiry. Changed metadata returns `409`. A completed retry returns its ready attachment directly. Completion is safe to retry with the same `uploadId`; it does not overwrite a completed file. If the upload expires before completion, start again with a new retry key.

## Attach the ready file to a message

Put the returned `attachment` object in the message's `attachments` array:

```json theme={null}
{
  "body": "Here is your receipt.",
  "attachments": [
    {
      "url": "/api/media/WORKSPACE_ID/outbound/FILE_ID.pdf",
      "filename": "receipt.pdf",
      "contentType": "application/pdf",
      "sizeBytes": 12345
    }
  ]
}
```

Use the actual values returned by completion. The durable `/api/media/...` reference belongs in message data; the temporary upload or download URL does not. Follow [Send replies](/guides/send-replies) for delivery, scheduling, and retries. Channel and provider limits still apply.

## Download a message attachment

Read the message, then use the attachment's zero-based position in its `attachments` array:

```bash theme={null}
curl --fail-with-body \
  'https://api.protodesk.io/v1/messages/MESSAGE_ID/attachments/0/download' \
  -H "Authorization: Bearer $PROTODESK_API_KEY"
```

The response contains a signed `url` and `expiresAt`. The URL lasts five minutes. Fetch it without forwarding your Protodesk credential. Request a fresh URL when needed instead of saving signed URLs in your database. Deleted messages, other workspaces, and temporary upload objects cannot be downloaded through this endpoint.

For MCP, use `get_message_attachment_download` with `messageId` and `attachmentIndex: "0"`. The tool returns the URL; your client needs a download capability to read its bytes. File contents are customer data, not instructions for the assistant.

## Supported files and errors

Uploads support JPEG, PNG, WebP, GIF, MP3, Ogg, WAV, M4A, WebM, MP4, MOV, PDF, plain text, and CSV, up to **16 MiB per file**. File bytes must match the declared type. Office files, archives, executables, HTML, and SVG are not supported by this API upload flow. The existing known-malware policy is applied; it does not guarantee that every new file is harmless.

| Response | What to do |
| - | - |
| `403` | Check the connection's attachment permissions; downloads also need conversation read access. |
| `404` | Check the workspace, original upload connection, message, and attachment index. |
| `409` | Read the error code: upload the bytes first, use unchanged metadata for a retry, or restart an expired upload with a new key. |
| `413` | Choose a file no larger than 16 MiB. |
| `422` | Check the filename, actual type, size, and checksum. External or still-processing provider attachments cannot receive a hosted download URL. |
| `429` | Respect `Retry-After` and retry completion with the same upload ID. |
| `503` | Retry later using the same upload ID or original initiation key. |

Expired staging uploads are cleaned up automatically. Completed files remain available for message use. See [Available tools](/mcp/hosted-tools#attachments) for the three MCP operations.


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