File and Media Messages
This document covers attachment handling in both directions, when a platform user sends a file to the CRM (incoming), and when an agent sends a file from the CRM back to the platform user (outgoing).
Incoming: Platform User Sends a File (Platform → CRM)
When a platform user sends an image, video, audio, or file, your webhook handler must download it from the platform and upload it to the CRM before posting the message. The flow requires four steps:
- Download the binary from the platform's content API.
- Request a presigned upload URL from the CRM.
- PUT the binary directly to the presigned URL.
- Send an
ATTACHMENTtype message referencing the uploaded file.
Step 1: Download from the platform
Download the file binary from the platform's content API using the message ID from the incoming webhook event.
const { fileBytes, contentType, fileName } =
await platformClient.downloadContent(messageId, platformAccessToken);Step 2: Request a presigned upload URL
Send file_name, content_type, size_bytes, and uploader to get a single-use S3 upload URL. All four fields are required.
POST /v1/conversations/attachments
Authorization: Bearer {workspace_access_token}
Content-Type: application/json
{
"file_name": "image.jpg",
"content_type": "image/jpeg",
"size_bytes": 184320,
"uploader": {
"contact_id": "550e8400-e29b-41d4-a716-446655440000"
}
}Use agent_id instead of contact_id in uploader when the file is sent by an agent.
Response 201:
{
"attachment_id": "5f1f69a0-2e0e-4d4a-8917-02b8b63aebf1",
"attachment_path": "oauth/8b86b34d-.../5f1f69a0-..._image.jpg",
"upload_url": "https://s3.amazonaws.com/bucket/oauth/...?X-Amz-...",
"upload_expires_at": "2026-05-07T12:34:56Z"
}| Field | Description |
|---|---|
attachment_id | Pass this as attachment_id when sending the message in Step 4. |
upload_url | Presigned S3 PUT URL. Single-use, valid until upload_expires_at (~5 min). |
upload_expires_at | Exact expiry timestamp for upload_url. Do not attempt the PUT after this time. |
attachment_path | Internal storage key. Informational: not needed for upload or send. |
uploadermust match thesend_asprincipal used in Step 4. Uploading as a contact and sending as an agent (or vice versa) returns403.
Step 3: Upload the binary to the presigned URL
PUT the binary directly to upload_url. No CRM Authorization header: the presigned URL is self-authenticating. Content-Type and Content-Length must exactly match the values declared in Step 2 or S3 will reject the upload.
PUT {upload_url}
Content-Type: image/jpeg
Content-Length: 184320
<binary file bytes>Step 4: Send the attachment message
Post an ATTACHMENT message to the conversation, referencing the attachment_id from Step 2. The send_as principal must match the uploader used in Step 2.
POST /v1/conversations/{conversation_id}/messages
Authorization: Bearer {workspace_access_token}
Content-Type: application/json
{
"message_type": "ATTACHMENT",
"attachment_id": "5f1f69a0-2e0e-4d4a-8917-02b8b63aebf1",
"send_as": {
"contact_id": "550e8400-e29b-41d4-a716-446655440000"
}
}content is not required for ATTACHMENT messages: attachment captions are not supported.
Response 202:
{
"request_id": "ca9b5a07-1d77-46f3-9fa6-6c827db6ac3d"
}Complete Example
async function handleIncomingAttachment(
crmBaseUrl: string,
crmToken: string,
conversationId: string,
contactId: string,
messageId: string,
platformAccessToken: string,
): Promise<void> {
// Step 1: Download binary from platform
const { fileBytes, contentType, fileName } =
await platformClient.downloadContent(messageId, platformAccessToken);
// Step 2: Request presigned upload URL from CRM
const presignRes = await fetch(`${crmBaseUrl}/v1/conversations/attachments`, {
method: "POST",
headers: {
"Authorization": `Bearer ${crmToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
file_name: fileName,
content_type: contentType,
size_bytes: fileBytes.byteLength,
uploader: { contact_id: contactId },
}),
});
if (!presignRes.ok) {
throw new Error(`presign request failed: ${presignRes.status}`);
}
const { attachment_id, upload_url, upload_expires_at } = await presignRes.json() as {
attachment_id: string;
attachment_path: string;
upload_url: string;
upload_expires_at: string;
};
if (Date.now() >= new Date(upload_expires_at).getTime()) {
throw new Error("presigned URL expired before upload could begin");
}
// Step 3: PUT binary directly to presigned URL, no CRM token
// Content-Type and Content-Length must match the values declared in Step 2
const uploadRes = await fetch(upload_url, {
method: "PUT",
body: fileBytes,
headers: {
"Content-Type": contentType,
"Content-Length": String(fileBytes.byteLength),
},
});
if (!uploadRes.ok) {
throw new Error(`S3 upload failed: ${uploadRes.status}`);
}
// Step 4: Send ATTACHMENT message referencing the uploaded file
const msgRes = await fetch(`${crmBaseUrl}/v1/conversations/${conversationId}/messages`, {
method: "POST",
headers: {
"Authorization": `Bearer ${crmToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
message_type: "ATTACHMENT",
attachment_id,
send_as: { contact_id: contactId },
}),
});
if (!msgRes.ok) {
throw new Error(`send message failed: ${msgRes.status}`);
}
const { request_id } = await msgRes.json() as { request_id: string };
console.log("attachment message enqueued, request_id:", request_id);
}Outgoing: Agent Sends a File (CRM → Platform)
When an agent sends a file from the CRM, the webhook fires a message.created event with type: "ATTACHMENT". The content field carries the attachment_id, not the file itself. Your handler must fetch a signed download URL from the CRM, download the binary, and forward it to the platform. The flow has three steps:
- Get a signed download URL from the CRM using
contentas theattachment_id. - Download the binary from the signed URL.
- Forward the file to the platform.
The incoming event looks like this:
{
"event": "message.created",
"data": {
"id": "ff0e8400-e29b-41d4-a716-446655440010",
"channel_id": "cc0e8400-e29b-41d4-a716-446655440007",
"conversation_id": "ee0e8400-e29b-41d4-a716-446655440009",
"workspace_id": "660e8400-e29b-41d4-a716-446655440001",
"content": "aa1e9500-f30c-52e5-b827-557766551122",
"type": "ATTACHMENT",
"sender": {
"id": "770e8400-e29b-41d4-a716-446655440002",
"type": "AGENT"
},
"contact_id": "bb0e8400-e29b-41d4-a716-446655440006"
}
}Step 1: Get a signed download URL
Call the CRM with data.content as the attachment_id to get a temporary signed URL for the file.
GET /v1/conversations/attachments/{attachment_id}?type=ATTACHMENT
Authorization: Bearer {workspace_access_token}Response 200:
{
"attachment_url": "https://storage.example.com/signed-url-for-file?..."
}The signed URL is valid for 24 hours. The server caches it and only regenerates it when the cached URL is older than 24 hours.
Step 2: Download the binary
Fetch the file bytes from the signed URL. No authorization header is needed: the URL is self-authenticating. Read the Content-Type response header to determine how to forward it in Step 3.
const res = await fetch(attachment_url);
if (!res.ok) throw new Error(`download failed: ${res.status}`);
const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
const fileBytes = new Uint8Array(await res.arrayBuffer());Step 3 - Forward to the platform
Send the binary to the platform using the appropriate method for the content type.
if (contentType.startsWith("image/")) {
await platformClient.pushImage(platformToken, platformUserID, fileBytes);
} else if (contentType.startsWith("video/")) {
await platformClient.pushVideo(platformToken, platformUserID, fileBytes);
} else if (contentType.startsWith("audio/")) {
await platformClient.pushAudio(platformToken, platformUserID, fileBytes);
} else {
await platformClient.pushFile(platformToken, platformUserID, fileBytes, contentType);
}Complete Example
async function handleOutgoingAttachment(
crmBaseUrl: string,
crmToken: string,
platformToken: string,
platformUserID: string,
attachmentId: string, // pass data.content from the message.created event
): Promise<void> {
// Step 1: Get signed download URL from CRM
const urlRes = await fetch(
`${crmBaseUrl}/v1/conversations/attachments/${attachmentId}?type=ATTACHMENT`,
{
headers: { Authorization: `Bearer ${crmToken}` },
}
);
if (!urlRes.ok) throw new Error(`failed to get attachment URL: ${urlRes.status}`);
const { attachment_url } = await urlRes.json() as { attachment_url: string };
// Step 2: Download the binary from the signed URL
const fileRes = await fetch(attachment_url);
if (!fileRes.ok) throw new Error(`failed to download attachment: ${fileRes.status}`);
const contentType = fileRes.headers.get("Content-Type") ?? "application/octet-stream";
const fileBytes = new Uint8Array(await fileRes.arrayBuffer());
// Step 3: Forward to platform based on content type
if (contentType.startsWith("image/")) {
await platformClient.pushImage(platformToken, platformUserID, fileBytes);
} else if (contentType.startsWith("video/")) {
await platformClient.pushVideo(platformToken, platformUserID, fileBytes);
} else if (contentType.startsWith("audio/")) {
await platformClient.pushAudio(platformToken, platformUserID, fileBytes);
} else {
await platformClient.pushFile(platformToken, platformUserID, fileBytes, contentType);
}
}Attachment Limits and Allowed Extensions
CRM attachment limit: 50 MB per file.
Allowed Extensions:
- Image
- .png
- .jpg
- .jpeg
- .webp
- .gif
- .svg
- Video
- .avi
- .mp4
- .mov
- Sound
- .mp3
- .wav
- .aiff
- .au
- File
- .docx
- .csv
- .xlsx