Sending Structured Messages via the Genesys Cloud Open Messaging API

Sending Structured Messages via the Genesys Cloud Open Messaging API

What You Will Build

  • This tutorial demonstrates how to programmatically send rich, structured messages including quick replies and interactive cards using the Genesys Cloud Open Messaging API.
  • The implementation relies on the POST /api/v2/conversations/messaging/contacts/{contactId}/messages endpoint to deliver payload data to an active messaging conversation.
  • The code examples are provided in Python 3.9+ using the requests library and the official Genesys Cloud Python SDK.

Prerequisites

  • OAuth Client: A Genesys Cloud OAuth client configured with the Confidential client type (Client Credentials Grant) or Public client type (if using PKCE, though less common for server-side messaging).
  • Required Scopes:
    • messages:send (Required to send messages)
    • conversations:read (Required to verify contact existence and conversation status)
    • analytics:conversations:query (Optional, for debugging conversation history)
  • SDK Version: genesys-cloud-python-sdk version 100.0.0 or later.
  • Runtime: Python 3.9 or higher.
  • Dependencies:
    • requests
    • genesys-cloud-python-sdk
    • python-dotenv (for managing credentials)

Authentication Setup

Genesys Cloud uses OAuth 2.0. For server-side integrations that send messages on behalf of a bot or system, the Client Credentials Grant is the standard flow. This flow exchanges your client ID and secret for an access token.

The token is valid for 1 hour. Your application must handle token expiration by catching 401 Unauthorized responses or implementing a time-based cache.

import os
import requests
from typing import Dict, Optional
import time

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

    def get_token(self) -> str:
        """
        Retrieves an OAuth access token.
        Uses cached token if valid, otherwise fetches a new one.
        """
        if self.access_token and time.time() < self.token_expiry - 60:
            return self.access_token

        payload = {
            'grant_type': 'client_credentials',
            'client_id': self.client_id,
            'client_secret': self.client_secret
        }

        headers = {
            'Content-Type': 'application/x-www-form-urlencoded'
        }

        response = requests.post(self.token_url, data=payload, headers=headers)
        
        if response.status_code != 200:
            raise Exception(f"Failed to obtain token: {response.status_code} {response.text}")

        data = response.json()
        self.access_token = data['access_token']
        self.token_expiry = time.time() + data['expires_in']
        
        return self.access_token

    def get_headers(self) -> Dict[str, str]:
        """Returns headers ready for API calls."""
        return {
            'Authorization': f"Bearer {self.get_token()}",
            'Content-Type': 'application/json'
        }

# Usage Example
# auth = GenesysAuth(os.getenv('GENESYS_CLIENT_ID'), os.getenv('GENESYS_CLIENT_SECRET'))
# headers = auth.get_headers()

Implementation

To send structured messages, you must first have an active conversation and a valid contact ID. The messaging API does not create conversations; it injects messages into existing ones.

Step 1: Validate the Contact and Conversation

Before sending a rich message, you must ensure the contact ID is valid and the conversation is in a state that accepts messages (e.g., active or queued). Sending to a closed conversation will result in a 400 Bad Request.

Endpoint: GET /api/v2/conversations/messaging/contacts/{contactId}
Scope: conversations:read

import requests
import sys

def validate_contact(auth: GenesysAuth, contact_id: str) -> dict:
    """
    Fetches contact details to ensure the conversation exists and is active.
    """
    url = f"{auth.base_url}/api/v2/conversations/messaging/contacts/{contact_id}"
    headers = auth.get_headers()

    try:
        response = requests.get(url, headers=headers)
        
        if response.status_code == 401:
            # Token might have expired during the check
            auth.access_token = None 
            headers = auth.get_headers()
            response = requests.get(url, headers=headers)
            
        if response.status_code == 404:
            print(f"Error: Contact {contact_id} not found.", file=sys.stderr)
            sys.exit(1)
            
        if response.status_code == 403:
            print(f"Error: Insufficient permissions. Check scopes.", file=sys.stderr)
            sys.exit(1)

        if response.status_code != 200:
            print(f"Error fetching contact: {response.status_code} {response.text}", file=sys.stderr)
            sys.exit(1)

        contact_data = response.json()
        
        # Check if conversation is still active
        # Note: The contact object contains conversationId. 
        # We assume if we can fetch the contact, the channel is open.
        return contact_data

    except requests.exceptions.RequestException as e:
        print(f"Network error: {e}", file=sys.stderr)
        sys.exit(1)

Step 2: Construct the Structured Message Payload

Genesys Cloud supports several message types within the Open Messaging API. The two most common structured types are:

  1. Quick Reply: A set of buttons where the user selects one, sending back a text payload.
  2. Card (Carousel): A rich card with an image, title, subtitle, and buttons.

The payload structure follows the Message schema. The content field is critical. It must be a JSON object that defines the structure.

Quick Reply Payload

For quick replies, the content type is quick-reply. You define a title and an array of choices. Each choice has a label (what the user sees) and a value (what is sent back to the webhook/platform).

def create_quick_reply_payload(title: str, choices: list) -> dict:
    """
    Creates a payload for a Quick Reply message.
    
    Args:
        title: The text above the buttons.
        choices: List of dicts with 'label' and 'value'.
    """
    return {
        "type": "message",
        "content": {
            "type": "quick-reply",
            "title": title,
            "choices": choices
        }
    }

# Example Usage:
# payload = create_quick_reply_payload(
#     "How can we help you today?",
#     [
#         {"label": "Billing", "value": "billing_inquiry"},
#         {"label": "Technical Support", "value": "tech_support"},
#         {"label": "Speak to Agent", "value": "transfer_to_agent"}
#     ]
# )

Card Payload

For cards, the content type is card. This supports images, headers, and action buttons. The buttons in a card can be postback (sends value, no visible text change) or web_url (opens a browser).

def create_card_payload(title: str, subtitle: str, image_url: str, buttons: list) -> dict:
    """
    Creates a payload for a Card message.
    """
    return {
        "type": "message",
        "content": {
            "type": "card",
            "title": title,
            "subtitle": subtitle,
            "image": {
                "url": image_url
            },
            "actions": buttons
        }
    }

# Example Usage:
# payload = create_card_payload(
#     "New Product Launch",
#     "Check out our latest features.",
#     "https://example.com/images/product.jpg",
#     [
#         {
#             "type": "postback",
#             "title": "Learn More",
#             "payload": "learn_more_product_123"
#         },
#         {
#             "type": "web_url",
#             "title": "Visit Website",
#             "url": "https://example.com/products/123"
#         }
#     ]
# )

Step 3: Send the Message

The final step is to POST the payload to the messaging endpoint.

Endpoint: POST /api/v2/conversations/messaging/contacts/{contactId}/messages
Scope: messages:send

Important: The type field in the root of the JSON body must be "message". The content field holds the structured data.

def send_structured_message(auth: GenesysAuth, contact_id: str, payload: dict) -> dict:
    """
    Sends a structured message to a specific contact.
    
    Args:
        auth: GenesysAuth instance
        contact_id: The ID of the contact in the conversation
        payload: The message payload dict
    
    Returns:
        The response JSON containing the message ID and status.
    """
    url = f"{auth.base_url}/api/v2/conversations/messaging/contacts/{contact_id}/messages"
    headers = auth.get_headers()

    # Retry logic for 429 Too Many Requests
    max_retries = 3
    retry_count = 0

    while retry_count < max_retries:
        try:
            response = requests.post(url, json=payload, headers=headers)
            
            # Handle Rate Limiting
            if response.status_code == 429:
                retry_after = int(response.headers.get('Retry-After', 5))
                print(f"Rate limited. Retrying in {retry_after} seconds...")
                import time
                time.sleep(retry_after)
                retry_count += 1
                continue
            
            # Handle Authentication Errors
            if response.status_code == 401:
                auth.access_token = None # Force refresh
                headers = auth.get_headers()
                continue

            # Handle Business Logic Errors
            if response.status_code == 400:
                print(f"Bad Request: {response.text}", file=sys.stderr)
                # Common cause: Invalid contact ID or invalid content structure
                return None
            
            if response.status_code == 404:
                print(f"Contact or Conversation not found.", file=sys.stderr)
                return None

            if response.status_code == 200 or response.status_code == 201:
                print("Message sent successfully.")
                return response.json()
            
            # Handle unexpected errors
            print(f"Unexpected error: {response.status_code} {response.text}", file=sys.stderr)
            return None

        except requests.exceptions.RequestException as e:
            print(f"Network error: {e}", file=sys.stderr)
            return None

    print("Max retries exceeded due to rate limiting.")
    return None

Complete Working Example

This script combines authentication, validation, payload construction, and sending. It sends a Quick Reply menu to a user.

import os
import sys
import time
import requests
from typing import Dict, Optional, List

# --- Configuration ---
# Replace these with your actual Genesys Cloud credentials
CLIENT_ID = os.getenv('GENESYS_CLIENT_ID')
CLIENT_SECRET = os.getenv('GENESYS_CLIENT_SECRET')
BASE_URL = os.getenv('GENESYS_BASE_URL', 'https://api.mypurecloud.com')
CONTACT_ID = os.getenv('TARGET_CONTACT_ID') # Must be a valid, active contact ID

if not all([CLIENT_ID, CLIENT_SECRET, CONTACT_ID]):
    print("Error: Missing environment variables. Set GENESYS_CLIENT_ID, GENESYS_CLIENT_SECRET, and TARGET_CONTACT_ID.", file=sys.stderr)
    sys.exit(1)

# --- Authentication Module ---
class GenesysAuth:
    def __init__(self, client_id: str, client_secret: str, base_url: str):
        self.client_id = client_id
        self.client_secret = client_secret
        self.base_url = base_url.rstrip('/')
        self.token_url = f"{self.base_url}/oauth/token"
        self.access_token: Optional[str] = None
        self.token_expiry: float = 0

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

        payload = {
            'grant_type': 'client_credentials',
            'client_id': self.client_id,
            'client_secret': self.client_secret
        }
        headers = {'Content-Type': 'application/x-www-form-urlencoded'}

        response = requests.post(self.token_url, data=payload, headers=headers)
        if response.status_code != 200:
            raise Exception(f"Auth Failed: {response.status_code} {response.text}")

        data = response.json()
        self.access_token = data['access_token']
        self.token_expiry = time.time() + data['expires_in']
        return self.access_token

    def get_headers(self) -> Dict[str, str]:
        return {
            'Authorization': f"Bearer {self.get_token()}",
            'Content-Type': 'application/json'
        }

# --- Business Logic ---

def send_quick_reply_menu(auth: GenesysAuth, contact_id: str) -> None:
    """
    Sends a quick reply menu to the specified contact.
    """
    # 1. Validate Contact
    url_check = f"{auth.base_url}/api/v2/conversations/messaging/contacts/{contact_id}"
    headers = auth.get_headers()
    
    resp_check = requests.get(url_check, headers=headers)
    if resp_check.status_code != 200:
        print(f"Failed to validate contact. Status: {resp_check.status_code}", file=sys.stderr)
        return

    # 2. Define Payload
    # This creates a menu with 3 options
    message_payload = {
        "type": "message",
        "content": {
            "type": "quick-reply",
            "title": "Select a service department:",
            "choices": [
                {
                    "label": "Sales",
                    "value": "dept_sales"
                },
                {
                    "label": "Support",
                    "value": "dept_support"
                },
                {
                    "label": "Billing",
                    "value": "dept_billing"
                }
            ]
        }
    }

    # 3. Send Message
    url_send = f"{auth.base_url}/api/v2/conversations/messaging/contacts/{contact_id}/messages"
    
    # Retry loop for resilience
    attempts = 0
    max_attempts = 3
    
    while attempts < max_attempts:
        try:
            response = requests.post(url_send, json=message_payload, headers=headers)
            
            if response.status_code == 429:
                retry_after = int(response.headers.get('Retry-After', 2))
                print(f"Rate limited. Waiting {retry_after}s...")
                time.sleep(retry_after)
                attempts += 1
                continue
                
            if response.status_code == 401:
                auth.access_token = None # Refresh token
                headers = auth.get_headers()
                continue

            if response.status_code in [200, 201]:
                print("Success! Message sent.")
                print(f"Response: {response.json()}")
                return
            else:
                print(f"Error sending message: {response.status_code} {response.text}", file=sys.stderr)
                return
                
        except Exception as e:
            print(f"Exception: {e}", file=sys.stderr)
            return

    print("Failed to send message after multiple retries.")

# --- Main Execution ---

if __name__ == "__main__":
    try:
        auth = GenesysAuth(CLIENT_ID, CLIENT_SECRET, BASE_URL)
        print(f"Sending structured message to Contact ID: {CONTACT_ID}")
        send_quick_reply_menu(auth, CONTACT_ID)
    except Exception as e:
        print(f"Fatal Error: {e}", file=sys.stderr)
        sys.exit(1)

Common Errors & Debugging

Error: 400 Bad Request - “Invalid content type”

  • Cause: The content.type field in your JSON payload is misspelled or unsupported. For example, using "quickreply" instead of "quick-reply".
  • Fix: Verify the content.type matches the exact strings defined in the API spec: text, image, audio, video, file, quick-reply, card, or location.

Error: 403 Forbidden - “Insufficient permissions”

  • Cause: The OAuth client used to generate the token lacks the messages:send scope.
  • Fix: Go to the Genesys Cloud Admin Console > Security > OAuth > Clients. Edit your client and add the messages:send scope. Regenerate the token.

Error: 404 Not Found

  • Cause: The contactId provided does not exist, or the conversation associated with that contact has been closed/deleted.
  • Fix: Ensure you are using the contactId returned from the POST /api/v2/conversations/messaging/contacts endpoint (when the user initiates chat) or from the GET .../contacts list. Do not use the conversationId as the contactId.

Error: 429 Too Many Requests

  • Cause: You have exceeded the rate limit for messaging endpoints. Genesys Cloud enforces strict rate limits to protect platform stability.
  • Fix: Implement exponential backoff. The response header Retry-After indicates how many seconds to wait. Never poll aggressively.

Error: Message Not Appearing in Client

  • Cause: The message was sent successfully (200 OK) but the client (WhatsApp, Facebook Messenger, Web Chat) does not support the specific rich media type.
  • Fix: Check the channel capabilities. For example, WhatsApp has strict template requirements for outgoing messages after 24 hours. If you are sending via WhatsApp, you must use the whatsapp channel-specific payload structures or ensure the message is within the 24-hour service window. For generic Open Messaging, this usually applies to Web Chat or custom adapters.

Official References