Developer Docs Logo
Customer Chat

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:

  1. Download the binary from the platform's content API.
  2. Request a presigned upload URL from the CRM.
  3. PUT the binary directly to the presigned URL.
  4. Send an ATTACHMENT type 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"
}
FieldDescription
attachment_idPass this as attachment_id when sending the message in Step 4.
upload_urlPresigned S3 PUT URL. Single-use, valid until upload_expires_at (~5 min).
upload_expires_atExact expiry timestamp for upload_url. Do not attempt the PUT after this time.
attachment_pathInternal storage key. Informational: not needed for upload or send.

uploader must match the send_as principal used in Step 4. Uploading as a contact and sending as an agent (or vice versa) returns 403.

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:

  1. Get a signed download URL from the CRM using content as the attachment_id.
  2. Download the binary from the signed URL.
  3. 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
    • .pdf
    • .docx
    • .csv
    • .xlsx

On this page