How to handle file uploads from customers in Web Messaging — accepted MIME types and size limits

How to handle file uploads from customers in Web Messaging — accepted MIME types and size limits

What You Will Build

A production-ready Python module that fetches Web Chat configuration limits, validates customer files against those limits, uploads attachments via the Genesys Cloud CX Attachments API, and handles validation failures with explicit retry logic. This tutorial uses the Genesys Cloud CX REST API with requests. The implementation covers Python 3.9+.

Prerequisites

  • OAuth 2.0 Client Credentials flow configured in Genesys Cloud
  • Required scopes: webchat:configuration:read, webchat:attachment:upload, webchat:send
  • Python 3.9 or newer
  • Dependencies: requests, urllib3, mimetypes (stdlib), typing (stdlib)
  • A valid Web Chat Configuration ID (obtainable via /api/v2/webchat/configurations)

Authentication Setup

Genesys Cloud uses OAuth 2.0 for all API access. The client credentials flow is appropriate for server-side integrations that process customer uploads. The token endpoint is https://api.mypurecloud.com/oauth/token. You must cache the token until expiration and handle 401 Unauthorized responses by refreshing or re-authenticating.

import requests
import time
from typing import Optional

class GenesysAuth:
    def __init__(self, client_id: str, client_secret: str, environment: str = "mypurecloud.com"):
        self.client_id = client_id
        self.client_secret = client_secret
        self.base_url = f"https://api.{environment}"
        self.token_url = f"{self.base_url}/oauth/token"
        self.access_token: Optional[str] = None
        self.expires_at: float = 0.0

    def get_token(self) -> str:
        if self.access_token and time.time() < self.expires_at:
            return self.access_token

        payload = {
            "grant_type": "client_credentials",
            "scope": "webchat:configuration:read webchat:attachment:upload webchat:send"
        }
        response = requests.post(
            self.token_url,
            auth=(self.client_id, self.client_secret),
            data=payload,
            timeout=10
        )
        response.raise_for_status()
        data = response.json()
        self.access_token = data["access_token"]
        self.expires_at = time.time() + data["expires_in"] - 30  # 30s buffer
        return self.access_token

The expires_in field returns seconds until token invalidation. Subtracting thirty seconds prevents edge-case 401 responses during high-throughput upload windows. The scope string must contain all required permissions separated by spaces.

Implementation

Step 1: Retrieve Web Chat Configuration Limits

Genesys Cloud enforces file attachment rules at the Web Chat configuration level. The server rejects uploads that violate these rules, but client-side validation reduces unnecessary network traffic and provides immediate feedback. The configuration endpoint returns the maximum allowed file size in bytes and an array of permitted MIME types.

HTTP Request Cycle

GET /api/v2/webchat/configurations/{webchatConfigurationId}
Host: api.mypurecloud.com
Authorization: Bearer <access_token>
Accept: application/json

Realistic Response

{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "name": "Customer Support Web Chat",
  "fileAttachments": {
    "maxSize": 10485760,
    "allowedMimeTypes": [
      "image/jpeg",
      "image/png",
      "application/pdf",
      "text/plain",
      "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
    ]
  }
}
from typing import Dict, List

class WebChatConfigFetcher:
    def __init__(self, auth: GenesysAuth):
        self.auth = auth
        self.base_url = f"https://api.{auth.environment}"

    def get_limits(self, config_id: str) -> Dict[str, any]:
        url = f"{self.base_url}/api/v2/webchat/configurations/{config_id}"
        headers = {
            "Authorization": f"Bearer {self.auth.get_token()}",
            "Accept": "application/json"
        }
        response = requests.get(url, headers=headers, timeout=15)
        response.raise_for_status()
        data = response.json()
        
        file_config = data.get("fileAttachments", {})
        return {
            "max_size_bytes": file_config.get("maxSize", 10485760),
            "allowed_mimes": file_config.get("allowedMimeTypes", [])
        }

The fileAttachments object is optional in older deployments. The code falls back to a ten-megabyte default and an empty MIME list if the configuration omits these fields. An empty MIME list means the server enforces a strict default whitelist, so client-side validation should reject unknown types.

Step 2: Validate File Size and MIME Type Client-Side

Relying solely on server-side validation increases latency and consumes API quota. You must verify the file before transmission. Python’s os.path.getsize provides byte-level accuracy. MIME detection requires careful handling because file extensions are easily spoofed. The mimetypes module reads magic numbers when available, but production systems should pair it with server-side validation.

import os
import mimetypes

def validate_file(file_path: str, max_size: int, allowed_mimes: List[str]) -> str:
    if not os.path.isfile(file_path):
        raise FileNotFoundError(f"Path does not exist or is not a file: {file_path}")
    
    file_size = os.path.getsize(file_path)
    if file_size > max_size:
        raise ValueError(
            f"File size {file_size} bytes exceeds limit of {max_size} bytes. "
            f"Reduce file size or compress before uploading."
        )
    
    detected_mime, _ = mimetypes.guess_type(file_path)
    if detected_mime is None:
        raise ValueError("Unable to detect MIME type. Provide a valid file extension.")
    
    if detected_mime not in allowed_mimes:
        raise ValueError(
            f"MIME type '{detected_mime}' is not permitted. "
            f"Allowed types: {', '.join(allowed_mimes)}"
        )
    
    return detected_mime

This function fails fast. It raises explicit exceptions that your application can catch and translate into user-facing messages. The server will still validate the upload, but this prevents sending invalid payloads to the API gateway.

Step 3: Upload the File with Retry Logic

The attachment upload endpoint accepts multipart/form-data. Genesys Cloud returns a 429 Too Many Requests response when the account exceeds the upload rate limit. The API design enforces per-tenant throttling to protect the message bus from flood conditions. You must implement exponential backoff with jitter to comply with rate limits without blocking other services.

HTTP Request Cycle

POST /api/v2/attachments/upload
Host: api.mypurecloud.com
Authorization: Bearer <access_token>
Content-Type: multipart/form-data; boundary=----FormBoundary7MA4YWxkTrZu0gW

Realistic Response

{
  "id": "9f8e7d6c-5b4a-3210-fedc-ba9876543210",
  "name": "invoice_q3.pdf",
  "type": "application/pdf",
  "size": 245760
}
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
import time

class AttachmentUploader:
    def __init__(self, auth: GenesysAuth):
        self.auth = auth
        self.base_url = f"https://api.{auth.environment}"
        self.session = requests.Session()
        self._setup_retry_policy()

    def _setup_retry_policy(self):
        retry_strategy = Retry(
            total=3,
            backoff_factor=1.5,
            status_forcelist=[429, 500, 502, 503, 504],
            allowed_methods=["POST"]
        )
        adapter = HTTPAdapter(max_retries=retry_strategy)
        self.session.mount("https://", adapter)

    def upload(self, file_path: str, mime_type: str) -> Dict[str, any]:
        url = f"{self.base_url}/api/v2/attachments/upload"
        headers = {
            "Authorization": f"Bearer {self.auth.get_token()}",
            "Accept": "application/json"
        }
        
        with open(file_path, "rb") as f:
            files = {"file": (os.path.basename(file_path), f, mime_type)}
            response = self.session.post(
                url,
                headers=headers,
                files=files,
                timeout=30
            )
            
        if response.status_code == 400:
            raise ValueError(f"Server rejected file: {response.json().get('message', 'Invalid payload')}")
        if response.status_code == 413:
            raise ValueError("File size exceeds server-side limit. Check Web Chat configuration.")
        if response.status_code == 401:
            self.auth.access_token = None  # Force token refresh on next call
            raise ConnectionError("Authentication token expired. Refresh required.")
            
        response.raise_for_status()
        return response.json()

The HTTPAdapter with urllib3.util.Retry handles transient failures automatically. The backoff_factor controls the delay between retries using the formula sleep = backoff_factor * (2 ** (retry - 1)). This prevents cascading failures when the API gateway throttles upload requests.

Step 4: Process the Attachment Response

The upload response contains the attachmentId. You reference this ID when sending a message to the conversation. The API does not attach the file automatically. You must include the attachment reference in the message payload.

HTTP Request Cycle for Message Submission

POST /api/v2/conversations/messages/{conversationId}/messages
Host: api.mypurecloud.com
Authorization: Bearer <access_token>
Content-Type: application/json

Realistic Request Body

{
  "to": {
    "id": "agent-user-id",
    "type": "user"
  },
  "from": {
    "id": "customer-contact-id",
    "type": "contact"
  },
  "text": "Please review the attached document.",
  "attachments": [
    {
      "id": "9f8e7d6c-5b4a-3210-fedc-ba9876543210",
      "name": "invoice_q3.pdf",
      "type": "application/pdf"
    }
  ]
}

The attachments array expects the exact ID returned from /api/v2/attachments/upload. The server validates that the attachment belongs to the conversation context. If you upload a file outside an active conversation, you must associate it during message submission or use the conversation-scoped upload flow.

Complete Working Example

import os
import requests
import time
from typing import Optional, Dict, List
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
import mimetypes

class GenesysAuth:
    def __init__(self, client_id: str, client_secret: str, environment: str = "mypurecloud.com"):
        self.client_id = client_id
        self.client_secret = client_secret
        self.environment = environment
        self.base_url = f"https://api.{environment}"
        self.token_url = f"{self.base_url}/oauth/token"
        self.access_token: Optional[str] = None
        self.expires_at: float = 0.0

    def get_token(self) -> str:
        if self.access_token and time.time() < self.expires_at:
            return self.access_token
        payload = {
            "grant_type": "client_credentials",
            "scope": "webchat:configuration:read webchat:attachment:upload webchat:send"
        }
        response = requests.post(self.token_url, auth=(self.client_id, self.client_secret), data=payload, timeout=10)
        response.raise_for_status()
        data = response.json()
        self.access_token = data["access_token"]
        self.expires_at = time.time() + data["expires_in"] - 30
        return self.access_token

class WebChatConfigFetcher:
    def __init__(self, auth: GenesysAuth):
        self.auth = auth
        self.base_url = f"https://api.{auth.environment}"

    def get_limits(self, config_id: str) -> Dict[str, any]:
        url = f"{self.base_url}/api/v2/webchat/configurations/{config_id}"
        headers = {"Authorization": f"Bearer {self.auth.get_token()}", "Accept": "application/json"}
        response = requests.get(url, headers=headers, timeout=15)
        response.raise_for_status()
        data = response.json()
        file_config = data.get("fileAttachments", {})
        return {
            "max_size_bytes": file_config.get("maxSize", 10485760),
            "allowed_mimes": file_config.get("allowedMimeTypes", [])
        }

def validate_file(file_path: str, max_size: int, allowed_mimes: List[str]) -> str:
    if not os.path.isfile(file_path):
        raise FileNotFoundError(f"Path does not exist or is not a file: {file_path}")
    file_size = os.path.getsize(file_path)
    if file_size > max_size:
        raise ValueError(f"File size {file_size} bytes exceeds limit of {max_size} bytes.")
    detected_mime, _ = mimetypes.guess_type(file_path)
    if detected_mime is None:
        raise ValueError("Unable to detect MIME type. Provide a valid file extension.")
    if detected_mime not in allowed_mimes:
        raise ValueError(f"MIME type '{detected_mime}' is not permitted. Allowed: {', '.join(allowed_mimes)}")
    return detected_mime

class AttachmentUploader:
    def __init__(self, auth: GenesysAuth):
        self.auth = auth
        self.base_url = f"https://api.{auth.environment}"
        self.session = requests.Session()
        retry_strategy = Retry(total=3, backoff_factor=1.5, status_forcelist=[429, 500, 502, 503, 504], allowed_methods=["POST"])
        adapter = HTTPAdapter(max_retries=retry_strategy)
        self.session.mount("https://", adapter)

    def upload(self, file_path: str, mime_type: str) -> Dict[str, any]:
        url = f"{self.base_url}/api/v2/attachments/upload"
        headers = {"Authorization": f"Bearer {self.auth.get_token()}", "Accept": "application/json"}
        with open(file_path, "rb") as f:
            files = {"file": (os.path.basename(file_path), f, mime_type)}
            response = self.session.post(url, headers=headers, files=files, timeout=30)
        if response.status_code == 400:
            raise ValueError(f"Server rejected file: {response.json().get('message', 'Invalid payload')}")
        if response.status_code == 413:
            raise ValueError("File size exceeds server-side limit.")
        if response.status_code == 401:
            self.auth.access_token = None
            raise ConnectionError("Authentication token expired.")
        response.raise_for_status()
        return response.json()

def main():
    auth = GenesysAuth(client_id="YOUR_CLIENT_ID", client_secret="YOUR_CLIENT_SECRET")
    config_fetcher = WebChatConfigFetcher(auth)
    uploader = AttachmentUploader(auth)
    
    config_id = "YOUR_WEBCHAT_CONFIG_ID"
    file_path = "/path/to/customer/document.pdf"
    
    limits = config_fetcher.get_limits(config_id)
    mime_type = validate_file(file_path, limits["max_size_bytes"], limits["allowed_mimes"])
    attachment = uploader.upload(file_path, mime_type)
    print(f"Upload successful. Attachment ID: {attachment['id']}")

if __name__ == "__main__":
    main()

Replace YOUR_CLIENT_ID, YOUR_CLIENT_SECRET, YOUR_WEBCHAT_CONFIG_ID, and file_path with your environment values. The script validates, uploads, and prints the attachment identifier for subsequent message submission.

Common Errors & Debugging

Error: 400 Bad Request

  • Cause: The MIME type is not in the allowed list, or the multipart payload is malformed.
  • Fix: Verify the Content-Type header matches the file extension. Ensure the file field name in the multipart form matches the API specification exactly. Check the Web Chat configuration for custom MIME restrictions.
  • Code Fix: Use mimetypes.guess_type to confirm detection matches the configuration. Log the raw response body to inspect the message field returned by the API.

Error: 413 Payload Too Large

  • Cause: The file exceeds the maxSize defined in the Web Chat configuration or the platform-wide limit.
  • Fix: Compress the file before upload. Update the Web Chat configuration to increase maxSize if business requirements allow. Never bypass server-side limits client-side.
  • Code Fix: The validate_file function catches this before transmission. If the error occurs during upload, the server enforced a stricter limit than the configuration returned. Re-fetch the configuration and compare values.

Error: 429 Too Many Requests

  • Cause: The account has exceeded the upload rate limit. Genesys Cloud throttles attachment uploads to protect the message bus.
  • Fix: The retry policy in AttachmentUploader handles this automatically. Monitor the Retry-After header if you need precise backoff timing. Space out concurrent uploads using a queue with concurrency limits.
  • Code Fix: The urllib3.util.Retry configuration includes 429 in status_forcelist. Increase total retries or backoff_factor if your workload requires higher resilience.

Error: 500 / 503 Internal Server Error

  • Cause: Temporary platform outage or storage backend unavailability.
  • Fix: Wait and retry. The retry policy covers these status codes. If the error persists beyond ten minutes, check the Genesys Cloud status dashboard.
  • Code Fix: The HTTPAdapter mounts retry logic for 500 and 503. Ensure your application does not block indefinitely by setting a maximum timeout on the session.

Official References